跳至正文
来两杯美式
返回

给生产 Agent 做体检(三):让 LLM 稳定输出结构化诊断——严格 JSON Schema 实践

By 来两杯美式
发布于

让 LLM 稳定输出结构化数据,靠的不是在 prompt 里写一百行”请务必输出合法 JSON、字段名必须是 rowId、outcome 只能取这几个值”,而是把协议从 prompt 里拿出来,交给请求参数和校验层去保证。response_format: json_object 只约束”这是一个 JSON”,不约束”这个 JSON 里有什么”;真正可靠的方案是严格 JSON Schema(OpenAI Structured Outputs 兼容子集)加上服务端独立校验,prompt 只负责讲清楚”诊断的语义”这一件事。

这次重构的背景是:诊断链路批量读取生产 Agent 的客服对话,让模型对每条对话输出一个结构化诊断(结论、失败模式、证据、严重级别等)。升级前的协议事实上由 prompt 承担,结果是字段名漂移、枚举不全、单条异常放大成整批失败。升级后,字段集合、枚举值、数值边界全部收敛到一个静态 JSON Schema,与 Zod 校验共享同一组领域常量,模型输出的协议性错误在归并阶段按行隔离,不再污染同行。

这篇文章是这个系列里最”工程”的一篇,会完整拆解:问题复盘、方案取舍、严格 Schema 的具体设计、双 Schema 如何共享单一来源、prompt 职责怎么重新划分、行级归并与两阶段持久化,以及 Zod 一个很容易踩的坑——普通 z.object() 遇到额外字段是剥离而不是拒绝。

严格 JSON Schema 总览:坏行问题、静态 Schema 机器化协议、双 Schema 单一来源、两道防线与行级归并的三层递进结构

一、问题复盘:json_object 只管外壳,不管内容

先看升级前的真实状态。所有诊断请求固定发送:

{ "response_format": { "type": "json_object" } }

这个模式能保证模型输出的是一段可解析的 JSON,仅此而已。它不约束:

这些约束当时全部写在 prompt 里,靠模型”自觉遵守”。实际跑起来出现了几类典型的坏行:

1. 字段名不一致:一部分行输出 row_id,另一部分输出 rowId;
2. 枚举漂移:outcome 出现 SUCCESS、PASS、NEGATIVE、NONE 等混用;
3. 数组形态漂移:failureModes 有时是 ["LOGISTICS_DELAY"],
   有时干脆是一个逗号分隔的字符串;
4. 证据为空:observedFacts 或 evidenceRowIds 返回空数组。

为了救这些坏行,早期还做过一层”语义别名归一化”:把 PASS 映射成 SUCCESS,把字符串拆成数组,把 row_id 改写成 rowId。这层代码短暂地降低了失败率,但带来一个更隐蔽的问题:归一化规则本身成为协议的一部分,而且只存在于应用代码里。模型不知道哪些别名会被救、哪些不会,下游统计也不知道哪些枚举值是被程序改写过的。协议被拆到了三个地方——prompt、模型的习惯、应用的归一化代码——任何一处改动都可能让另外两处失效。

第二个放大器在批量校验这一层。一次请求诊断二十条对话,响应是一个 items 数组。当时的解析逻辑是:对整个数组做一次 Zod 校验,任何一条不合法,整批标记失败。也就是说,十九条完全合格的诊断,会因为一条 outcome: "PASS" 整批丢弃、整批重试。重试后的新响应里可能又有新的坏行,失败被不断放大,覆盖率长期上不去。

还有一个修复链路自己的问题:当某条坏行触发”修复请求”时,修复 prompt 里列举的枚举值不全,模型照着修复 prompt 输出,又产出了主 schema 不认的值。协议在主链路和修复链路之间也漂移了。

把问题列成一张表:

问题表层原因深层原因
row_id / rowId 混用模型输出不稳定字段协议放在 prompt 里,无强约束
枚举漂移(PASS、NEGATIVE 等)模型用语习惯枚举没有机器可校验的定义
修复 prompt 枚举不全文档没同步协议有多个副本
整批失败数组整体校验没有”按行隔离异常”的归并层
归一化掩盖问题兜底代码越写越多协议边界模糊,坏数据被静默改写

结论很清楚:可靠性问题的根因不是模型不听话,而是协议没有被机器化。

二、备选方案与取舍

重构之前,真正可选的路线有三条。

方案一:完全放宽校验

既然模型输出不稳定,那就把 Zod 校验放宽到几乎什么都接受:枚举改成 z.string(),数组允许为空,字段全部 optional。下游聚类和统计时再做兜底处理。

这条路很快就被否了。诊断结果的核心消费者是失败模式聚类和 PM 报告,聚类的输入键就是 failureModeseverity 这几个枚举。枚举一旦放开,聚类键会碎成一地:REFUND_POLICY_ERROR退款政策错误refund_policy 会被当成三个不同的问题。放宽校验等于把脏数据原封不动传给下游,污染的是最贵的分析层。

方案二:写更长的 prompt

把字段表、完整的枚举列表、两三个 JSON 示例全部塞进 prompt,反复强调格式。

这条路我们其实已经走过,它的问题不是”不够长”,而是结构性的:prompt 是自然语言,约束力是概率性的。prompt 越长,模型对开头和结尾的格式指令遵循得越好,中间部分反而容易被长上下文冲淡;而且每次换模型、调温度、改对话样本,格式漂移就要重新观察一遍。更重要的是,prompt 里的协议没有机器可校验的形态——你没法对一段自然语言跑契约测试。

方案三:严格 Schema + 服务端防线(最终选择)

请求侧切换到 OpenAI Structured Outputs 兼容的严格 JSON Schema,让网关在解码阶段就限制输出必须符合 Schema;服务端保留一套独立的 Zod 严格校验作为第二道防线,并承担 JSON Schema 表达不了的语义约束。

三条路线的对比:

维度放宽校验更长 prompt严格 Schema + 防线
格式可靠性差(放弃治疗)不稳定,概率性协议保证
枚举一致性靠模型自觉Schema 强制
协议副本数多处prompt 一处但不可测单一来源,可测试
下游数据质量被污染时好时坏稳定
实现成本中(契约+归并改造)

选第三条路的代价是要做一次不小的改造:契约模块重写、批量校验逻辑重构成行级归并、持久化改成分阶段。但这些都是一次性的工程成本,换来的是协议问题从此退出运维视野。

三、严格协议设计:一份静态 JSON Schema

新协议的核心是一份静态的、与 OpenAI Structured Outputs 兼容子集兼容的 JSON Schema。先看请求侧怎么发:

{
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "instance_diagnosis",
      "strict": true,
      "schema": { "...": "见下文" }
    }
  }
}

strict: true 是关键开关:它要求 Schema 满足一整套严格子集的规则(所有对象 additionalProperties: false、所有字段进 required、枚举用 enum 而非 oneOf 等),换取的是网关在解码时逐 token 地限制输出——模型在物理上生成不出不符合 Schema 的内容。这和 prompt 里”请输出 JSON”有本质区别:前者是协议,后者是请求。

根对象刻意设计成最简的 envelope:

{
  "type": "object",
  "properties": {
    "items": { "type": "array", "items": { "$ref": "#/$defs/diagnosisItem" } }
  },
  "required": ["items"],
  "additionalProperties": false,
  "$defs": {
    "diagnosisItem": {
      "type": "object",
      "properties": {
        "rowId": { "type": "integer", "minimum": 1 },
        "scenarioKey": { "type": "string", "pattern": "^[A-Z0-9_]+$" },
        "outcome": { "enum": ["SUCCESS", "FAILURE"] },
        "failureModes": { "type": "array", "items": { "enum": ["..."] } },
        "observedFacts": {
          "type": "array",
          "minItems": 1,
          "items": { "type": "object", "properties": { "...": "..." } }
        },
        "severity": { "enum": ["P0", "P1", "P2", "OK"] },
        "confidence": { "enum": ["HIGH", "MEDIUM", "LOW"] },
        "evidenceRowIds": {
          "type": "array",
          "minItems": 1,
          "items": { "type": "integer", "minimum": 1 }
        }
      },
      "required": [
        "rowId",
        "scenarioKey",
        "outcome",
        "failureModes",
        "observedFacts",
        "severity",
        "confidence",
        "evidenceRowIds"
      ],
      "additionalProperties": false
    }
  }
}

(真实契约里还有 scenarioName、intentKey、changeTargets 等字段,这里做了精简。)

几个设计决策值得展开:

注意这些约束全部是”结构性”的:字段集合、类型、枚举、边界。它们恰好是 JSON Schema 严格子集能表达的东西,这不是巧合,而是选型时就以”网关能表达什么”为边界来设计协议的。

四、双 Schema 单一来源:让协议不再漂移

严格 JSON Schema 上线后,一个新风险出现了:应用内部还有一套 Zod 校验。两套 Schema 描述同一个协议,只要有一处改了枚举、另一处忘了同步,就会出现”模型输出完全合法、服务端却拒绝”或反过来”服务端放行了下游不认识的数据”。

以前的失败模式会换一个地方重现。解决方案是把两套 Schema 的”词汇表”收敛为同一份领域常量:

// diagnosis-contract.ts —— 唯一的领域词汇表
export const taskOutcomes = ["SUCCESS", "FAILURE"] as const;
export const failureModes = [
  "LOGISTICS_DELAY",
  "REFUND_POLICY_ERROR",
  "TOOL_CALL_ERROR",
  "HALLUCINATED_ORDER",
  "REPETITION_LOOP",
  "ESCALATION_MISSED",
] as const;
export const diagnosisSeverities = ["P0", "P1", "P2", "OK"] as const;
export const diagnosisConfidences = ["HIGH", "MEDIUM", "LOW"] as const;

// Zod 侧:直接引用常量
export const instanceDiagnosisSchema = z
  .object({
    rowId: z.number().int().positive(),
    scenarioKey: z.string().regex(/^[A-Z0-9_]+$/),
    outcome: z.enum(taskOutcomes),
    failureModes: z.array(z.enum(failureModes)),
    observedFacts: z.array(observableFactSchema).min(1),
    severity: z.enum(diagnosisSeverities),
    confidence: z.enum(diagnosisConfidences),
    evidenceRowIds: z.array(z.number().int().positive()).min(1),
  })
  .strict();

// JSON Schema 侧:同样从常量派生
export const instanceDiagnosisResponseJsonSchema = {
  type: "object",
  properties: {
    items: { type: "array", items: diagnosisItemJsonSchema },
  },
  required: ["items"],
  additionalProperties: false,
} as const;

const diagnosisItemJsonSchema = {
  type: "object",
  properties: {
    rowId: { type: "integer", minimum: 1 },
    scenarioKey: { type: "string", pattern: "^[A-Z0-9_]+$" },
    outcome: { enum: [...taskOutcomes] },
    failureModes: { type: "array", items: { enum: [...failureModes] } },
    observedFacts: {
      type: "array",
      minItems: 1,
      items: observableFactJsonSchema,
    },
    severity: { enum: [...diagnosisSeverities] },
    confidence: { enum: [...diagnosisConfidences] },
    evidenceRowIds: {
      type: "array",
      minItems: 1,
      items: { type: "integer", minimum: 1 },
    },
  },
  required: [...diagnosisFields],
  additionalProperties: false,
} as const;

从此以后,新增一个失败模式只需要改常量数组一处,Zod 和 JSON Schema 同时生效。配套写了一组契约测试(contract parity tests),断言两套 Schema 的字段集合、required 集合、枚举值逐一相等:

expect(itemJsonSchema.required).toEqual(Object.keys(itemJsonSchema.properties));
expect(itemJsonSchema.properties.outcome.enum).toEqual([...taskOutcomes]);
expect(
  instanceDiagnosisSchema.safeParse({ ...validItem, extra: true }).success
).toBe(false);

这类测试的价值在于把”协议一致性”变成一个可以在 CI 里失败的东西。以前协议漂移要等线上坏行出现才被发现,现在等价于一个编译错误。

五、Prompt 职责重划:只讲语义,不讲协议

Schema 接管协议之后,prompt 迎来一次大瘦身。所有这些内容从 prompt 里删除:

Prompt 里剩下的只有三类语义信息:

1. 诊断语义:什么算 FAILURE(例如:承诺了不存在的物流单号、
   把不可退的商品说成可退、循环重复同一句安抚话术),
   observedFacts 应该描述"对话里可见的事实"。

2. 证据边界:observedFacts 只能记录对话中可见的现象,
   不得填写不可见的内部根因(如"模型参数衰减""检索索引损坏")。
   诊断是黑盒的,看得见什么就记什么。

3. rowId 一一对应:每个输入行必须给出对应 rowId 的诊断,
   不得合并、不得跳过。

这个划分背后的原则是:凡是能被机器校验的,都不要用自然语言说。 字段名对不对、枚举合不合法、数组空不空,Schema 和 Zod 都能判断,prompt 里写了也是浪费 token 并且引入版本不同步的风险;而”什么算失败""证据的边界在哪”是真正的语义判断,只有 prompt 能表达。

同一时间被大幅收窄的还有前文提到的语义别名归一化:模糊匹配、“找最像的枚举”、字段改名这类带猜测性质的修复被彻底删除,只保留一张可逐条审计的固定别名白名单和容器形态规整——白名单的边界画在哪里、为什么这样画,第四篇会专门展开。白名单之外的偏差不再被代码兜住,直接显式失败,进入单行修复流程,由模型在下一次请求里输出合法值。这看起来是”变得更严格”,实际上是让失败显式化:无边界地静默改写数据,你永远不知道有多少数据被改过、改得对不对;显式失败的问题只是多一次修复调用。前者腐蚀数据可信度,后者只花一点算力。

修复请求(repair request)也遵循同样的原则:修复 prompt 只说明”请针对这一行重新给出诊断,保持语义判断标准不变,注意校验错误提示的问题”,字段结构由随请求发送的同一份 json_schema 保证。主链路和修复链路发送完全相同的 response_format,协议天然同步。

六、行级归并:每个期望 rowId 恰好一个结局

Schema 保证了单条 item 的结构合法,但批量响应还有一层协议问题:响应里的行和输入行的对应关系。旧逻辑”整批 Zod 校验,一坏全坏”的解法显然不对,正确的问题是:给定输入的期望 rowId 集合,如何把一次响应归并成”每个期望 rowId 恰好一个结局”。

归并器的输入是原始响应(未校验的 JSON)和期望 rowId 集合,规则按行判定:

响应中该 rowId 的情况归并结果
恰好一条,且通过 Zod 校验success,立即落库
恰好一条,但未通过 Zod 校验failure(可修复),保留原始 item 和精简校验错误作为修复上下文
出现多条failure(可修复),不采用其中任何一条
完全没出现failure(可修复),错误类型 missing
出现了期望集合之外的 rowId、缺失 rowId、非正整数 rowId只记为响应级协议异常,附加到相关 missing 行的修复上下文,不生成不存在的失败行

两个细节值得强调。

第一,遍历输入而不是遍历响应。归并的循环主体是”期望 rowId 集合”,响应只是被查表的对象。这样即使模型返回了 rowId: 999 这种输入里根本不存在的行,也不会凭空在数据库里造出一条 999 的失败记录——它只会作为异常信息附加到 missing 行的上下文里。失败永远指向真实存在的输入行。

第二,重复行一条都不采信。同一 rowId 返回两条内容不同的诊断时,没有任何可靠办法裁决哪条是真的,两条全弃、该行进入修复。这比”取第一条”慢一次调用,但避免了用错误的静默裁决污染统计。

归并之后是两阶段持久化,解决”单条异常放大”的另一半:

阶段一:初次批量响应归并完成
  → 立即保存所有 success 行(状态 COMPLETED)
  → 数据库真实状态重算任务计数

阶段二:对每个 failure 行并发受控地发起单行修复
  → 修复成功 → 保存为 COMPLETED
  → 修复失败 → 保存为最终失败,只影响该行

落库保持幂等:已经 COMPLETED 的行不会被后续失败覆盖。这样一来,即使修复请求超时、甚至 worker 进程在修复中途被杀掉,第一阶段已经验证通过的同行诊断仍然完好地留在数据库里。旧的”整批处理完才落库”模式下,一次进程中断等于丢掉整批已经支付了 token 成本的有效结果——这是用数据库的真实状态做断点,而不是指望进程活得够久。

另外有一类错误无法按行拆分,属于批级错误:非法 JSON、根值不是对象、items 缺失或不是数组、请求超时或 HTTP 失败、模型拒绝、内容为空或被截断。批级错误不产生任何初次成功项,批次内每个期望 rowId 各自进入一次单行修复。单行修复只执行一次逻辑尝试,不递归修复自己的失败——否则一个系统性问题会引发指数级的重试风暴。

七、服务端防线:Zod strict 的坑与语义兜底

严格 Schema 并不意味着服务端可以裸奔。防线必须有,而且要独立于模型网关——网关是外部依赖,它的兼容性、它的实现细节,都不应该成为数据质量的最后一道闸。

服务端防线的主体是 Zod,而这里有一个值得单独讲透的坑:

普通的 z.object() 遇到额外字段,行为是”剥离”而不是”拒绝”。

const loose = z.object({ outcome: z.enum(["SUCCESS", "FAILURE"]) });

// 旧协议下,这样一条响应会怎样?
loose.parse({ outcome: "SUCCESS", row_id: 3, extra: "whatever" });
// 成功!返回 { outcome: "SUCCESS" },row_id 和 extra 被静默剥离

也就是说,在默认配置下,Zod 扮演的是”过滤器”角色:把认识的字段挑出来,不认识的扔掉。对于”我只想安全地读取两个字段”的场景,这是合理默认;但对于”我要校验一个协议”的场景,它是危险的——row_id 混进响应里时校验照样通过,你甚至不会在错误日志里看到任何痕迹。剥离不是拒绝,静默通过比报错更糟。

正确做法是 .strict()

const strict = z
  .object({
    outcome: z.enum(["SUCCESS", "FAILURE"]),
  })
  .strict();

strict.parse({ outcome: "SUCCESS", row_id: 3 });
// 抛错:Unrecognized key 'row_id'

.strict()(等价于 z.strictObject())把”未知字段”从可容忍变成错误。诊断契约里 item schema 和 envelope schema 都用了 strict object,服务端从此不再静默接受任何协议之外的字段。这个坑的隐蔽之处在于:升级严格 Schema 之前,Zod 校验”看起来一直在工作”——它确实在工作,只是它当时的工作方式是过滤而不是守门。

Zod 承担的第二类职责,是 JSON Schema 严格子集表达不了的语义约束。例如”observedFacts 不得记录不可见内部根因”:

const observableFactSchema = z
  .object({
    fact: z.string().trim().min(1),
  })
  .strict()
  .refine(value => !internalRootCausePattern.test(value.fact), {
    message:
      "observedFacts must describe visible dialogue facts, not internal root causes",
  });

这类语义边界写不进 JSON Schema,所以留在 Zod,作为结构约束之外的语义防线。两层防线的分工:

防线载体覆盖的约束
第一道严格 JSON Schema(网关)字段集合、类型、枚举、整数下限、key 正则、minItems
第二道Zod strict(服务端)同样的结构约束(防网关失效)+ 语义 refine(证据边界等)

两道防线缺一不可:只有第一道,网关兼容性问题或实现差异会直接放脏数据进来;只有第二道,模型输出的大量低级格式错误全部变成修复调用,成本和延迟都会显著上升。第一道把协议错误的数量压到接近零,第二道保证漏网之鱼一条也进不了库。

八、踩坑清单

这次改造过程中确认过的坑,按杀伤力排序:

九、局限与适用边界

严格 Schema 不是免费的,它有明确的表达力边界和生态约束。

严格子集的表达力是有限的。 OpenAI Structured Outputs 的严格模式支持的是 JSON Schema 的一个子集:不支持的条件逻辑(如”outcome 为 FAILURE 时 failureModes 必须非空”这种字段间约束)写不进去,minItems 支持但跨字段的联合约束不支持,复杂的 oneOf/anyOf 组合在部分网关上有兼容性差异。所以协议设计的原则是”结构进 Schema、语义进 Zod”,不要试图把所有校验都塞进 Schema 而扭曲它的形态。

网关兼容性需要实测。 “OpenAI 兼容”网关对 json_schema + strict: true 的支持程度参差不齐:有的完整支持,有的会静默降级为普通 JSON 模式,有的对特定关键字的解释不一。我们的做法是:单元测试验证请求序列化和本地契约,另外保留一个只在配置了真实网关凭证时才手动运行的 smoke test,专门验证关键约束的实际生效情况。上线前跑一次,比上线后从坏行里发现降级便宜得多。

它解决协议,不解决语义。 Schema 能保证 severity 一定是四个枚举值之一,不能保证这个 P1 判得对不对、证据是不是真的来自对话原文。语义质量靠的是第一篇讲过的诊断工作流设计(证据引用、逐行对应),以及后续报告篇会讲的人工抽样复核。不要期待一个 Schema 能替你判断模型说得对不对。

适用边界。 这套方案适合”批量、有明确契约、下游是程序化消费”的 LLM 调用——诊断、分类、抽取、评估这类任务。对于面向人的自由文本生成,严格 Schema 没有必要也没有意义。另外,单次批量的大小要克制:一次请求诊断的行数越多,单次失败重试的代价越大;批次太小又会浪费请求开销,这个平衡点与具体模型和网关相关,值得用真实负载测一测。

十、总结

这一篇的核心命题可以压缩成一句话:模型负责语义判断,应用负责协议边界。

整个改造就是不断把职责放回正确的位置:字段集合、枚举、数值边界从 prompt 挪到 JSON Schema;词汇表从多处副本收敛为一份共享常量;格式指令从 prompt 删除,换成语义定义和证据边界;整批校验换成行级归并;静默归一化换成显式失败加一次修复;服务端用 strict Zod 兜住网关覆盖不到的语义约束。做完这些之后,prompt 变短了,代码变多了,但”输出不合法”从一类需要人盯着的线上问题,变成了一个有固定处理路径(单行修复)的普通工程事件。

协议层稳住之后,剩下的自然问题是:模型输出仍然会有不完美的时刻——拒绝生成、长度截断、单行修复也失败。这些情况下如何归一化、如何隔离失败、如何不让局部问题拖垮全局,是下一篇的内容。


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
查看系列全部文章
  1. 01.把存量系统交给 Agent,先想清楚 API、Skill、MCP 各是什么
  2. 02.存量系统 Agent 化,物理上到底怎么「接」
  3. 03.单点跑通之后,怎么把 Agent 能力沉淀成全公司可复用
  4. 04.DeepSeek Harness:把 Agent 宿主变成可组合的基础设施,企业能拿它做什么
  5. 05.一个系统,两种访客:给浏览器和 Agent 设计同一套身份体系
  6. 06.定时是 Agent 平台的另一半:Trigger/Action Registry 与调度原语分层
  7. 07.Agent 刚才干了什么:审计、调用日志与敏感数据豁免
  8. 08.提示词安全攻防全解析:越狱、注入与信息泄露
  9. 09.给生产 Agent 做体检(一):只用对话文本的黑盒诊断工作流
  10. 10.给生产 Agent 做体检(二):诊断之前,先定义人口——数据质检与风险筛选
  11. 11.给生产 Agent 做体检(三):让 LLM 稳定输出结构化诊断——严格 JSON Schema 实践
  12. 12.给生产 Agent 做体检(四):LLM 输出不完美怎么办——归一化、一次修复与失败隔离
  13. 13.给生产 Agent 做体检(五):不要消息队列——基于数据库的任务编排与可靠性
  14. 14.给生产 Agent 做体检(六):从诊断明细到 PM 能用的报告——评估结果的产品化
  15. 15.AI Agent:从工具到同事,中间隔着一层「自主性」
  16. 16.Agent 评测工程化(一):为什么不能只看平均分
  17. 17.Agent 评测工程化(二):Rubric 不是 Prompt,而是可执行的质量协议
  18. 18.Agent 评测工程化(三):从低分样本到问题簇
  19. 19.Agent 评测工程化(四):红线、一票否决与 1-5 分
  20. 20.Agent 评测工程化(五):让 LLM-as-judge 稳定输出结构化结果
  21. 21.Agent 评测工程化(六):评测前,先把 Agent 输出拆开
  22. 22.Agent 评测工程化(七):从评分报表到 PM 决策工作台
  23. 23.Agent 评测工程化(八):不用消息队列,也能跑长任务
  24. 24.Agent 评测工程化(九):把一次性评测变成持续优化体系

上一篇
给生产 Agent 做体检(四):LLM 输出不完美怎么办——归一化、一次修复与失败隔离
下一篇
给生产 Agent 做体检(二):诊断之前,先定义人口——数据质检与风险筛选