LLM JSON 接入生产:结构约束与异常处理
文章归纳 LLM 输出接入自动化流水线时的四类故障:字段漂移、混入说明文字、输出截断和拒绝响应。围绕 Node.js、FastAPI 与 n8n,讨论仅靠提示词要求合法 JSON 的局限及严格结构约束。
AI 深度解读
LLM 能返回一段合法 JSON,不代表它能稳定接进业务流水线。字段名漂移、输出被截断、模型拒答,再加上 webhook 重试,足以让本地跑通的提取脚本,在生产环境里漏字段、报解析错误,甚至重复创建客户记录。
作者提出的方案是:用严格 Schema 约束输出,用完成状态拦住异常,再用幂等机制保护下游写入。这套分层思路值得借鉴,但文中的「确定性」和「100% 可信」说得过满,示例也还留着需要补齐的工程缺口。
为什么只要求「返回 JSON」不够
作者用本地测试 10 次、每周自动运行 10,000 次作对比,说明低频试跑容易掩盖边界问题。这不是一组公开压测结果,原文没有给出失败率、延迟或成本测量。
它列出的四种故障很具体:
- 字段从
urgencyScore变成urgency_score,JSON 能解析,下游 CRM 却读不到值。 - 输出混入 Markdown 代码围栏,或者多出一句解释。
- 长输入耗尽输出预算,返回
finish_reason: "length",字符串写到一半就结束。 - 安全拒答走了另一条响应路径,业务代码仍把结果当 JSON 处理。
前两类属于格式和契约问题,后两类属于调用状态问题。靠正则清理围栏,最多处理部分表面症状;jsonrepair 或再次请求模型修复,也无法保证字段始终符合业务约定。作者称这些补救会使延迟和 token 费用翻倍,但没有对照数据,不能当成通用结论。
原文区分了提示词约束、json_object 和严格 json_schema。关键差异是:合法 JSON 只保证语法成立,严格 Schema 进一步约束字段、类型和枚举。
作者用受约束解码(Constrained Token Decoding)解释其机制:采样时,把违反约束的 token 概率屏蔽为 -∞。大白话就是,生成过程本身受规则限制,而非等生成结束再修格式。不过,这一机制描述不应直接套到所有兼容接口的具体实现上。
严格 Schema 怎样变成可维护的代码契约
作者提醒了三条规则:每层对象都设置 "additionalProperties": false;properties 中的属性全部进入 required;可空字段用 ["string", "null"] 表达。另一个建议是减少递归嵌套,让路由字段使用明确的 enum。
前两条针对严格 Schema 的结构要求,浅嵌套和枚举则更多是设计建议。它们不宜被混成同一种服务端限制。
对 Node.js / TypeScript 项目,最有价值的做法是让 Zod 同时提供运行时约束和 TypeScript 类型,减少手写两套定义造成的偏差。下面是原文的发票提取示例:
import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
import { z } from "zod";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const InvoiceExtractionSchema = z.object({
vendor_name: z.string(),
invoice_number: z.string(),
currency: z.enum(["USD", "EUR", "GBP", "CAD", "AUD"]),
line_items: z.array(
z.object({
description: z.string(),
quantity: z.number(),
unit_price: z.number(),
line_total: z.number(),
})
),
subtotal: z.number(),
tax_amount: z.number(),
grand_total: z.number(),
});
export type InvoiceExtraction = z.infer<typeof InvoiceExtractionSchema>;
export async function extractVerifiedInvoice(ocrText: string) {
const completion = await openai.beta.chat.completions.parse({
model: "gpt-4o-mini",
temperature: 0,
max_completion_tokens: 1200,
messages: [
{
role: "system",
content: "Extract structured invoice fields accurately from raw OCR text.",
},
{ role: "user", content: ocrText.slice(0, 12000) },
],
response_format: zodResponseFormat(InvoiceExtractionSchema, "invoice_extraction_v1"),
});
const choice = completion.choices[0];
if (choice.finish_reason === "length") {
throw new Error("Token budget exceeded (finish_reason=length); increase max_completion_tokens.");
}
if (choice.message.refusal || !choice.message.parsed) {
throw new Error(`Extraction refused or empty: ${choice.message.refusal ?? "unknown"}`);
}
const data = choice.message.parsed;
// 确定性的模型输出算术校验
const computedSum = data.line_items.reduce((acc, item) => acc + item.line_total, 0);
const mathDelta = Math.abs(computedSum + data.tax_amount - data.grand_total);
return {
...data,
needs_human_audit: mathDelta > 0.05,
computed_delta: Number(mathDelta.toFixed(2)),
};
}
重点看 response_format 和 mathDelta > 0.05:前者控制输出结构,后者检查部分金额关系,两者解决不同问题。
Python 3.11 示例采用 AsyncOpenAI、Pydantic BaseModel 和 client.beta.chat.completions.parse(),通过 ConfigDict(extra="forbid") 禁止额外字段。模型同样是 gpt-4o-mini,设置 temperature=0.0、max_completion_tokens=450,输入截取前 6000 个字符;紧急程度限制在 1 到 100,摘要描述要求少于 160 字符。
这里要区分真正的约束与描述性提示。摘要长度只写在 description 里,没有展示对应的长度校验;temperature: 0 也不能单独构成结果完全确定的保证。
解析成功之前,工作流还缺哪些关卡
作者要求先检查 finish_reason 和 message.refusal,再读取 .parsed 或执行 JSON.parse()。FastAPI 示例对截断返回 502,对拒答返回 422,对缺少解析结果返回 500。
这个顺序合理,但原文没有给出 OpenAI SDK 版本,也没说明 parse() 遇到截断时是否会先抛异常。接入时必须确认实际 SDK 的行为,把调用阶段的异常也纳入处理,不能只照搬返回后的判断。
在 n8n 和 Make.com 中,作者建议检查节点是否真正发送了 API 级约束。原文没有提供节点版本和对照测试,因此不能据此断言所有 Structured Output Parser 都依赖修复重试。
其直接调用方案使用 HTTP Request 节点请求 Chat Completions 的 /v1/chat/completions,设置 response_format.type = "json_schema"、strict: true,Schema 名为 n8n_crm_router_v1。模型参数是 gpt-4o-mini、temperature: 0、max_completion_tokens: 500,输入表达式为 {{ JSON.stringify($json.body).slice(0, 6000) }}。输出包含 intent、priority、crm_note,其中意图限定为 demo_request、support_issue、partnership、noise。
随后用 {{ $json.choices[0].finish_reason === "stop" }} 分流,再解析 message.content。这条路径也应补上拒答和内容存在性检查。
最后是去重:对事件标识做 SHA-256,再查询 Redis、Data Store 或 Supabase。Python 示例使用请求头幂等键,或 source_event_id:sender_email,截取哈希前 24 位;生产检查清单则建议使用 event_id + schema_version,两处设计并不完全一致。
哈希负责生成标识,真正阻止重复处理的是原子的状态控制。
示例里的进程内 set 无法跨 worker 共享,并发请求也可能同时通过检查。作者提示多 worker 改用 Redis SET NX EX,方向正确,但抢占后失败如何重试、写入成功后如何记录完成,原文没有展开。
我的判断
我赞同把结构约束、响应状态、业务校验和副作用控制分开。相比不断加重试提示,这更容易定位问题:究竟是没有生成完、拒绝生成,还是数据结构正确但业务内容有误。
不过,严格 Schema 不能证明提取结果真实。示例检查了行金额之和加税额是否接近总额,却没有检查 quantity × unit_price、subtotal,也不能识别「多个金额一起抄错但仍然平衡」。因此,作者所谓三行校验让财务工作流获得「100% 可信」,没有依据。
字符截取同样有取舍。slice(0, N) 能控制部分输入规模,却不是精确的 token 预算;发票尾部的总额也可能被裁掉。原文提到死信队列、分块和摘要,但没有实现。
这篇适合作为生产接入的检查框架。它没有提供足够证据,证明这套示例已经具备标题所暗示的完整生产保障。
能拿来做什么
- 在 Node.js 提取接口中,让 Zod 成为字段定义的统一来源,并在业务写入前处理截断、拒答、空结果以及 SDK 调用异常。
- 为发票提取增加逐行金额、小计和总计校验,将超过
0.05的差额标记为待审核,同时保留原始 OCR 文本供核对。 - 检查 n8n 节点实际发送的请求,确认存在
json_schema和strict: true,并让异常分支保留输入上下文。 - 为 CRM webhook 定义包含事件和 Schema 版本的幂等键,使用共享存储控制并发,并明确失败后的重试规则。