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 + 修复重试循环 | 唯一能保证语义的 | 一点工程成本 | 所有场景,必须要有 |
两条铁律:
{"status":"succes"} 这种拼写错误挡住了,但挡不住 {"status":"ok","items":[]} 这种"格式完美、语义撒谎"。Schema 是你和模型之间的接口契约。设计得好,模型一次就对;设计得差,你会把 30% 的 token 花在重试上。
三层以上的嵌套会让字段归属频繁出错。宁可把结构拍平,用列表 + 类型字段表达组合关系:
// 差:深层嵌套,模型经常把 city 挂到 person 下面
{"order": {"customer": {"address": {"city": "..."}}}}
// 好:拍平成列表,每项自带类型
{"fields": [{"path": "customer.city", "value": "..."}]}
status 这种字段用自由字符串,模型一定会给你造出新值。写死枚举,无效值在模型侧就被挡住,省掉一轮重试:
{"status": {"type": "string", "enum": ["ok", "partial", "failed", "need_input"]}}
"可选"在模型眼里等于"看心情"。一个字段如果不是每次都出现,就把它变成枚举里的一个状态,或者给它一个明确的默认值。
金额用最小货币单位的整数(分),比例用整数百分比(85 而不是 0.85)。浮点在序列化和跨语言传递里会引入精度噪声,而 Agent 的产出通常要入账或入库。
2026-09-18T10:30:00+08:00。不要接受"今天""昨天""3天后"这类相对表达——它们只在生成的那一刻成立,落库之后就变成永久错误。
JSON Schema 的 description 就是给模型看的接口文档。写清楚单位、格式、边界、以及"什么时候不该填"。经验:负例描述比正例描述更省重试。
模型生成的 ID 必然幻觉,时间戳必然是整个训练分布的均值附近。这些字段由程序补齐,schema 里根本不要出现,或者声明为 "const" 由服务端填充。
items 里如果允许任意结构,你会收到混着字符串和对象的数组。规定元素类型、给 maxItems,并让程序侧校验长度。
结构化输出是要演化的。加一个 schema_version 常量字段,未来改结构时下游能明确分流,而不是靠猜字段是否存在。
让模型返回 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
错误回注的三条纪律:
items/2/price: expected integer, got string "9.9" 就够了。机械修复可以做:
summary / summary_text / 摘要 → 统一字段)一旦需要"猜语义"就必须停手。 例如模型返回了 {"total": 120, "items": [...]} 但 items 求和是 118——修复层不该"顺手"把 total 改成 118,那是把数据错误洗成了数据正确。正确做法是把它当作校验失败:要么让模型重算,要么标记为异常。
HTTP 200 + 合法 JSON + 内容错误,是生产环境里最贵的失败形态,因为它不会触发任何告警。
三个防护动作:
items: [] 分不清"确实没有"和"没查到"。要求 schema 必须返回 status 和 count,用状态字段表达失败,不要用空数组表达。sum(items) == total、count == len(items)、日期在合理区间)。只做前两层的系统,会在第三层静默出错。大结果不要塞回上下文,指针化:返回 {"result_id": "..."},让程序拿着 ID 去取。既省 token,也避免了"模型复述时改写了数据"这一类隐性错误。
结构化输出的行为依赖三件套:模型版本 + 提示词 + schema。任何一件动,行为都可能静默变化。
json.loads 的测试全过,下游却全挂。maxItemsadditionalProperties: false,required 完整列出结构化输出不是"让模型输出 JSON"这么一句提示词,而是一条完整链路:
约束(谁保证语法)→ 契约(schema 设计)→ 修复(机械变换)→ 校验(schema + 不变量)→ 验收(外部产物)→ 回归(版本钉扎)。
省掉任何一环,模型就会用"格式正确但内容错误"的方式把问题推迟到下游,而下游往往就是你的用户。
📚 延伸阅读:AI Agent工具调用实战指南、AI Agent自动化可靠性实战指南、AI Agent评测与可观测性实战指南
📚 更多创作资源: https://afdian.com/a/felix007