“请输出 JSON”为什么经常失败
在提示词里写”只输出 JSON,不要其他内容”,成功率取决于模型、任务复杂度和运气。失败模式很固定:前面加一句”好的,这是结果:”、后面加一段解释、用单引号、末尾多一个逗号、字段值里出现未转义的换行或引号、字段名和要求的对不上。
原因在于自回归解码本身是无约束的。模型每一步从完整词表上采样,任何 token 都有非零概率。语法约束只存在于提示词里,是软性的语义引导,不是硬性限制。模型”知道”要输出 JSON,但它同时也在追求语义连贯——当它觉得该说一句解释的时候,没有任何机制阻止它输出那个 token。
输出越长、字段越多、嵌套越深,累积的失败概率越高。如果单步出错概率是 ε,n 个 token 的整体成功率大致是 (1-ε)^n。一个 200 token 的 JSON,即使 ε 只有 0.5%,成功率也只剩三成多。
受限解码:在采样那一刻屏蔽非法分支
受限解码(constrained decoding)把约束从”提示词里说说”搬到”采样时执行”。做法是维护一个状态机,跟踪当前已生成的 token 序列在目标文法中处于什么状态,然后计算在该状态下哪些 token 是合法的,把非法 token 的 logit 置为 -inf,再 softmax 采样。
logits = model(next_token_logits)
legal_mask = grammar_state.allowed_tokens() # 位掩码
logits[~legal_mask] = -inf
next_token = sample(softmax(logits / T))
emit(next_token)
grammar_state = grammar_state.advance(next_token)
JSON 文法可以编译成下推自动机,但下推自动机无法直接枚举”下一个合法 token”。工程上的标准做法是把它近似成上下文无关文法可处理的形式,或者用增量式的字符级状态机——逐字符跟踪”当前在字符串内/外、在第几层嵌套、期待键还是期待值”,然后判断哪些 token 的字符串内容能接上去。
这一步实现不复杂但细节多:token 是子词单位,一个 token 可能横跨语法状态边界(比如 ":{" 这种合并 token),必须按 token 的字符串内容整体校验,而不是按字符逐个校验。另外词表里有很多含特殊字符的 token,需要预先建好”token 内容 → 是否含引号/反斜杠/换行”的索引,否则每步扫描全词表会很慢。
性能代价主要在这一步的掩码计算上。常见的优化是把词表按首字符分组,只检查可能合法的那些组。
JSON Schema 与文法的关系
JSON Schema 描述的是值域约束,文法描述的是语法结构。两者覆盖的范围不同:
| 约束类型 | 属于 | 受限解码能强制 |
|---|---|---|
| 括号配对、逗号位置 | 语法 | 能 |
| 键名必须是 age | 语法(枚举键) | 能 |
| age 必须是整数 | 语法(正则) | 能 |
| age 在 0-150 之间 | 语义值域 | 部分能(数字可用正则近似) |
| 数组长度 ≥ 2 | 语义值域 | 难 |
| 字段间依赖(有 a 则必有 b) | 跨字段语义 | 不能 |
实践中能可靠强制的是语法层和”可用正则表达”的值域层。整数范围这类可以用正则近似(0-150 能写成正则,但正则会变长);数组长度、跨字段依赖这类基本做不了,只能靠后校验。
所以正确的心智模型是:受限解码保证”一定能 parse”,不保证”内容一定对”。前者是语法问题,后者是语义问题,得靠校验-重试循环解决。
校验-重试循环怎么设计
重试不是简单地”再跑一遍”。裸重试会重复犯同样的错,因为提示词和解码温度都没变。有效的循环要满足几个条件:
for attempt in range(MAX_RETRY):
raw = generate(prompt, schema)
err = validate(raw, schema)
if err is None:
return parse(raw)
prompt = append_error_feedback(prompt, raw, err) # 带错误信息
return fallback() # 兜底降级
错误信息必须回传。把校验失败的具体原因拼进上下文——”字段 status 的值 ‘已完成’ 不在枚举 [‘pending’,’done’] 中”——比笼统说”格式错误”有效得多。模型能看到自己错在哪,第二次通常就能改对。
限制次数。三次几乎覆盖所有可修复的情况,超过三次还在错,说明是结构性问题(schema 太复杂、任务超出能力),再重试只是烧钱。三次都失败应该走降级而不是继续。
兜底要具体。降级方案取决于字段重要性:关键字段缺失就返回结构化错误对象让上游处理;非关键字段可以给默认值并标记 partial: true;整份都不可用时返回空结果并记录日志,别抛异常让调用方崩溃。
温度要调低。结构化输出任务不存在”创意”需求,temperature 设 0 或接近 0 能显著减少随机性错误。如果一定要采样多样性,也应该只在不影响结构的字段上放开。
字段设计上的稳健性技巧
schema 的复杂度直接决定失败率。几个经验规则:
扁平优于嵌套。三层嵌套的对象失败率明显高于一层。如果嵌套只是为了组织,考虑扁平化加前缀:user_name、user_age 比 user: {name, age} 稳。
用枚举代替自由文本。状态、类型、分类这类字段,凡是能穷举的都写成枚举。枚举既是受限解码最容易强制的部分,也避免了模型自由发挥导致的下游解析失败。
数字写成数字类型而非字符串。模型有把数字包进引号的倾向,schema 里显式声明类型能减少这类错误,但要配合校验。
避免可选字段的歧义。允许 null 和允许字段缺失是两回事,下游处理逻辑不一样。schema 里写清楚,并在提示词里说明缺失时该输出 null 还是不输出该键。
字段名用 snake_case 且语义明确。date 容易和”日期时间”混淆,created_at_iso8601 不会。模型对模糊字段名会自行假设。
数组元素数量要有上界预期。让模型输出”最重要的三项”,而不是”所有相关的项”。无上界的数组既容易超长截断,又容易让模型为了凑数而编造。
几个踩过的坑
把 JSON 塞在 markdown 代码块里是常见败因。模型习惯输出 “`json 包裹,parser 拿到带围栏的字符串直接报错。要么在提示词里明确禁止围栏,要么在解析前做一次围栏剥离(这个防御很便宜,值得加)。
Unicode 转义和字面量混用。中文内容有时被输出成 u4e2du6587,有时是字面中文,两种都合法但对下游日志检索影响不同。如果需要统一,在 schema 之外单独说明。
超长输出的截断问题。max_tokens 设太小,JSON 在半截被切断,报错信息是”意外的输入结束”,很容易被误判成模型犯错而不是配置问题。给结构化输出预留的 max_tokens 要比预期长度宽裕一半。
流式输出下受限解码要特殊处理。逐 token 吐给前端时,中间的半成品状态是不合法 JSON,前端不能即时 parse。要么在服务端缓冲完整再发,要么前端用容忍不完整的流式 parser。
Comments (3)
实测有效,感谢。
已经收藏,下周评审直接用这套话术。
刚入行的时候要是有这篇就好了,能少走很多弯路。
Please Sign in to join the discussion.