← 返回导航站首页

AI Agent结构化输出实战指南:JSON Schema、约束解码与校验修复

Agent 的产出要被程序消费,就不能是"一段看起来像 JSON 的文字"。

2026 年做 Agent 的人都会遇到同一个场景:模型回答得很漂亮,下游程序一 json.loads 直接崩。结构化输出(Structured Output)看着是个小问题,实际上它是 Agent 从"聊天"变成"干活"的那道门槛——迈不过去,后面所有自动化都是空中楼阁。

本文按「谁负责约束 → schema 怎么设计 → 七类失败怎么修 → 校验修复循环 → 假成功 → 版本钉扎」的顺序讲清楚,所有条目都来自真实生产环境的翻车记录。

一、先定"谁负责约束":四条路线,别指望提示词

很多人的第一条实现是往提示词里加一句"请只输出 JSON"。这条路线在短输出、简单结构上能用,一旦输出变长、字段变多,格式崩坏率会肉眼可见地上升。

路线 做法 语法可靠性 成本 适用场景
提示词约束 "只输出 JSON,不要解释" 低(长输出必崩) 0 原型验证
原生结构化输出 response_format: json_schema / structured outputs 高(模型侧保证) API 支持度不一 云端 API 首选
约束解码 llama.cpp GBNF grammar、vLLM guided decoding、Outlines 高(采样层屏蔽非法 token) 需要本地推理栈 本地模型、离线场景
校验层 schema validate + 修复重试循环 唯一能保证语义的 一点工程成本 所有场景,必须要有

两条铁律:

  1. 语法正确 ≠ 语义正确。 JSON Mode / 约束解码只保证"能 parse",不保证"字段内容对"。它把 {"status":"succes"} 这种拼写错误挡住了,但挡不住 {"status":"ok","items":[]} 这种"格式完美、语义撒谎"。
  2. 校验层不能省。 无论用哪条路线,程序侧都必须有一个 schema 校验器作为最后一道门。模型会换版本、服务商会换实现、你的提示词会被改——校验器是唯一不随之漂移的东西。

二、Schema 设计十条规则

Schema 是你和模型之间的接口契约。设计得好,模型一次就对;设计得差,你会把 30% 的 token 花在重试上。

1. 浅优于深:嵌套不超过三层

三层以上的嵌套会让字段归属频繁出错。宁可把结构拍平,用列表 + 类型字段表达组合关系:

// 差:深层嵌套,模型经常把 city 挂到 person 下面
{"order": {"customer": {"address": {"city": "..."}}}}
// 好:拍平成列表,每项自带类型
{"fields": [{"path": "customer.city", "value": "..."}]}

2. 枚举替代自由字符串

status 这种字段用自由字符串,模型一定会给你造出新值。写死枚举,无效值在模型侧就被挡住,省掉一轮重试:

{"status": {"type": "string", "enum": ["ok", "partial", "failed", "need_input"]}}

3. 可选字段要么给默认值,要么删掉

"可选"在模型眼里等于"看心情"。一个字段如果不是每次都出现,就把它变成枚举里的一个状态,或者给它一个明确的默认值。

4. 数字用整数,别用浮点

金额用最小货币单位的整数(分),比例用整数百分比(85 而不是 0.85)。浮点在序列化和跨语言传递里会引入精度噪声,而 Agent 的产出通常要入账或入库。

5. 时间统一 ISO 8601 带时区

2026-09-18T10:30:00+08:00。不要接受"今天""昨天""3天后"这类相对表达——它们只在生成的那一刻成立,落库之后就变成永久错误。

6. 每个字段都要有 description

JSON Schema 的 description 就是给模型看的接口文档。写清楚单位、格式、边界、以及"什么时候不该填"。经验:负例描述比正例描述更省重试。

7. 别让模型生成 id、时间戳、哈希

模型生成的 ID 必然幻觉,时间戳必然是整个训练分布的均值附近。这些字段由程序补齐,schema 里根本不要出现,或者声明为 "const" 由服务端填充。

8. 变长列表要给上限,元素结构必须固定

items 里如果允许任意结构,你会收到混着字符串和对象的数组。规定元素类型、给 maxItems,并让程序侧校验长度。

9. 加 schema_version

结构化输出是要演化的。加一个 schema_version 常量字段,未来改结构时下游能明确分流,而不是靠猜字段是否存在。

10. 别把"自报置信度"当成验收依据

让模型返回 confidence: 0.92 看起来很美,但它只是措辞果断度的副产品,不是测量。它可以用来做路由(低置信度走人工),绝不能用来验收——校准需要外部参照物(重放任务、留出结果、独立验证器),不是模型自己说的话。

三、七类常见失败与对应修法

把失败分类,是省钱的第一步。同一种原因失败两次就该停下来想,而不是再重试一次。

现象 根因 修法
输出被 ```json 包裹 提示词没禁 markdown,模型习惯性包装 明确"不要代码块",加一层剥壳正则兜底
Unterminated string / 解析到一半断掉 max_tokens 太小,或思维链吃光了预算 提高 max_tokens;本地推理关掉思考模式(实测思考模式会让 content 直接返回空)
content 为空但 HTTP 200 思考内容占满了输出预算,或触发了限流提示文本 检查 finish_reason 和 reasoning 字段,别只看状态码
类型漂移("1" vs 1、"true" vs true) 模型对类型的坚持弱于对语义的坚持 严格模式 + 一层显式类型强制表(写清楚哪些字段允许 coercion)
枚举出现幻觉值 模型按语义补了一个"更合理"的值 schema 枚举 + 服务端白名单校验,落库前直接拒绝
字段缺失 / 多出陌生字段 提示词只说了"要有什么",没说"只能有什么" additionalProperties: false + required 全列
中文标点、全角引号导致解析失败 模型在中文语境下混用全角符号 parse 前做归一化(全角→半角),再进解析器

一个反直觉的经验:限流和额度问题经常伪装成格式问题。接口在排队时会返回一句自然语言提示(例如"稍后再试"),下游按 JSON 解析就报格式错误——查日志时会一路查错方向。所以任何解析失败,先打印原始响应前 200 字符,确认拿到的是不是模型输出。

四、校验修复循环:能修的不问模型

正确的处理顺序是先机械修复,再问模型,最后才判失败。让模型修一个尾随逗号,是拿最贵的算力干最便宜的活。

import json, re, jsonschema

def normalize(raw: str) -> str:
    s = raw.strip()
    s = re.sub(r"^```(?:json)?|```$", "", s, flags=re.M).strip()   # 剥代码块
    s = s.replace("“", '"').replace("”", '"').replace("‘", "'")     # 全角引号
    s = re.sub(r",(\s*[}\]])", r"\1", s)                            # 去尾随逗号
    return s

def parse_and_validate(raw, schema):
    """返回 (data, errors)。同因失败两次即停,由调用方计数。"""
    try:
        data = json.loads(normalize(raw))
    except json.JSONDecodeError as e:
        return None, [f"parse_error at line {e.lineno} col {e.colno}: {e.msg}"]
    # 单值自动包装成列表:模型对"单元素数组"的坚持度很低
    for k, v in schema.get("properties", {}).items():
        if v.get("type") == "array" and not isinstance(data.get(k), list):
            if data.get(k) is not None:
                data[k] = [data[k]]
    errs = [f"{'/'.join(map(str, e.path)) or '<root>'}: {e.message}"
            for e in jsonschema.Draft202012Validator(schema).iter_errors(data)]
    return data, errs

错误回注的三条纪律:

  1. 只回注错误路径和期望类型,不要把整份 schema 再贴一遍。错误信息 items/2/price: expected integer, got string "9.9" 就够了。
  2. 每次重试必须改变输入。要么附上错误信息,要么降温度。原样重发等于买彩票,而且每一次都是一笔 token 税。
  3. 重试上限 2 次,之后走降级路径:宽松解析入库并标记待复核,或者直接进人工队列。明确失败比无限重试便宜得多。

五、修复层的边界:宁可失败,不要猜

机械修复可以做:

一旦需要"猜语义"就必须停手。 例如模型返回了 {"total": 120, "items": [...]} 但 items 求和是 118——修复层不该"顺手"把 total 改成 118,那是把数据错误洗成了数据正确。正确做法是把它当作校验失败:要么让模型重算,要么标记为异常。

六、假成功:结构化输出最隐蔽的坑

HTTP 200 + 合法 JSON + 内容错误,是生产环境里最贵的失败形态,因为它不会触发任何告警。

三个防护动作:

  1. 空集合不能表达失败。 items: [] 分不清"确实没有"和"没查到"。要求 schema 必须返回 status 和 count,用状态字段表达失败,不要用空数组表达。
  2. 回读产物,不读自述。 模型说"已保存"不算数,程序去把产物读回来验证一遍。自述是读路径,产物是写路径;验收只能看写路径。
  3. 三层验证分开做: 语法(能 parse)→ 结构(过 schema)→ 语义(业务不变量,比如 sum(items) == total、count == len(items)、日期在合理区间)。只做前两层的系统,会在第三层静默出错。

大结果不要塞回上下文,指针化:返回 {"result_id": "..."},让程序拿着 ID 去取。既省 token,也避免了"模型复述时改写了数据"这一类隐性错误。

七、版本钉扎与回归测试

结构化输出的行为依赖三件套:模型版本 + 提示词 + schema。任何一件动,行为都可能静默变化。

八、上线检查清单

小结

结构化输出不是"让模型输出 JSON"这么一句提示词,而是一条完整链路:

约束(谁保证语法)→ 契约(schema 设计)→ 修复(机械变换)→ 校验(schema + 不变量)→ 验收(外部产物)→ 回归(版本钉扎)。

省掉任何一环,模型就会用"格式正确但内容错误"的方式把问题推迟到下游,而下游往往就是你的用户。


📚 延伸阅读:AI Agent工具调用实战指南、AI Agent自动化可靠性实战指南、AI Agent评测与可观测性实战指南


📚 更多创作资源: https://afdian.com/a/felix007