写业务代码接大模型,最烦的不是回答质量,是返回的不是能直接 parse 的 JSON。表现大概这几种:
- 外面套了一层
```json围栏 - 开头来一句「好的,以下是提取结果:」
- 末尾补一句「如需调整请告诉我」
- 字段名拼错、少一个必填 key
- 字符串里有未转义的换行
每一条都能让 JSON.parse 直接抛异常。解决办法分四档,能上强约束就别靠提示词求它。
一、四档约束,按可靠性排序
| 档位 | 做法 | 可靠性 | 代价 |
|---|---|---|---|
| 1 | 只在提示词里说「返回 JSON」 | 低,长回答必翻车 | 无 |
| 2 | JSON 模式(response_format) |
中,保证语法合法,不保证字段 | 需模型支持 |
| 3 | 结构化输出 / JSON Schema | 高,字段和类型都锁死 | 需模型支持,schema 有写法限制 |
| 4 | 函数调用(tool use) | 高,等价于第 3 档 | 要定义工具,逻辑略绕 |
结论:只要模型支持第 3 档,就不要停在 1、2 档。提示词约束本质是「请求模型配合」,schema 约束是「接口层强制」,后者不会因为对话变长而失效。
二、第 1 档:提示词怎么写才算合格
不得不用第 1 档时(比如老模型、本地小模型),提示词要满足四条:
你是数据提取器。只输出 JSON,不要输出任何解释、前后缀、Markdown 代码围栏。
严格使用下列字段,不要增删,不要改名:
{
"name": "string,人名",
"age": "number,整数,未知填 0",
"tags": ["string"]
}
如果某个字段无法从原文确定,用 null,不要编造。
四个关键点:
- 明确说「不要 ```json 围栏」 —— 不说它八成会加
- 给出完整字段和类型 —— 只描述语义不给结构,格式必飘
- 规定缺失值怎么填 —— 不说就用
null,它会自己编一个 - 把 schema 放在提示词末尾 —— 靠后的内容对生成的影响更强
还有一个反直觉的技巧:把「不要解释」换成「第一个字符必须是 {」。前者是负向指令,模型容易忽略;后者是可验证的正向约束,命中率高得多。
三、第 2 档:JSON 模式
# OpenAI 兼容接口
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
)
data = json.loads(resp.choices[0].message.content)
有个坑:JSON 模式通常要求提示词里出现 "JSON" 这个词,否则会直接报错。这不是 bug,是厂商的护栏设计。提示词里写「以 JSON 格式输出」就够了。
JSON 模式保证的是「语法合法」,不保证字段对。它可以合法地给你 {"result": "..."} 而不是你要的 {"name": ..., "age": ...}。所以还是要自己校验。
四、第 3 档:JSON Schema / 结构化输出
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"tags": {"type": "array", "items": {"type": "string"}},
},
"required": ["name", "age", "tags"],
"additionalProperties": False,
}
resp = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[{"role": "user", "content": prompt}],
response_format={
"type": "json_schema",
"json_schema": {"name": "person", "strict": True, "schema": schema},
},
)
这套写法有几个硬性要求,漏一个就报错:
| 要求 | 说明 |
|---|---|
required 必须列出全部字段 |
想可选就用 "type": ["string", "null"] |
additionalProperties: false |
必须显式关掉,否则模型可能加字段 |
不支持 oneOf / allOf 等复杂组合 |
用扁平结构代替 |
| 嵌套层数别太深 | 一般 5 层内稳妥 |
没有这段支持的话,各家自己实现:国内厂商多叫「结构化输出」「JSON Schema 约束」,部分只支持在 function calling 里用 schema。名字不一样,原理一样——把 schema 交给推理引擎做解码约束,而不是让模型自己遵守。
五、第 4 档:函数调用
适合「结果要直接触发动作」的场景:
tools = [{
"type": "function",
"function": {
"name": "save_order",
"description": "保存解析出的订单信息",
"parameters": {
"type": "object",
"properties": {
"order_no": {"type": "string"},
"amount": {"type": "number"},
},
"required": ["order_no", "amount"],
},
},
}]
resp = client.chat.completions.create(model="...", messages=msgs, tools=tools)
args = resp.choices[0].message.tool_calls[0].function.arguments
data = json.loads(args) # arguments 是字符串,还要 parse 一次
注意最后那行:arguments 是 JSON 字符串,不是对象。忘了 parse 一次是最常见的接错点。
六、解析失败的六种形态与修法
| 现象 | 根因 | 修法 |
|---|---|---|
| `Unexpected token `` | 被 ```json 围栏包住 |
剥围栏 |
Unexpected token 好 |
前面有中文解说 | 定位第一个 { 或 [ |
Unexpected end of JSON input |
输出被 max_tokens 截断 |
调大上限或要求精简 |
Unexpected token , |
中文全角标点混进 JSON | 全角转半角 |
Unexpected token } |
尾随逗号 | 正则去掉 ,} ,] |
| 解析成功但字段缺失 | 没做 schema 约束 | 上第 3 档 + 校验 |
截断这条最隐蔽:输出是合法前缀但完整的 JSON 需要闭合括号,报错位置在末尾,看起来像格式错,实际是长度不够。判断方法很简单——看 finish_reason,等于 length 就是被截断了。
七、一个能吃掉脏格式的解析器
线上兜底用这个,比裸 JSON.parse 稳得多:
function parseLoose(text) {
if (!text) throw new Error('空响应')
// 1) 剥掉 Markdown 代码围栏
let s = text.trim()
s = s.replace(/^```(?:json|JSON)?\s*/m, '').replace(/```\s*$/m, '').trim()
// 2) 从第一个 { 或 [ 开始截,丢掉前面的解说
const start = s.search(/[{[]/)
if (start === -1) throw new Error('响应里没有 JSON 起始符')
s = s.slice(start)
// 3) 去掉尾随逗号
s = s.replace(/,\s*([}\]])/g, '$1')
// 4) 全角标点转半角(只转 JSON 结构相关的,字符串内容别动)
s = s.replace(/[“”]/g, '"').replace(/[‘’]/g, "'")
// 5) 括号配平:截断时补上缺的闭合符
const stack = []
let inStr = false, esc = false
for (const ch of s) {
if (esc) { esc = false; continue }
if (ch === '\\') { esc = true; continue }
if (ch === '"') { inStr = !inStr; continue }
if (inStr) continue
if (ch === '{' || ch === '[') stack.push(ch)
else if (ch === '}' || ch === ']') stack.pop()
}
for (let i = stack.length - 1; i >= 0; i--) s += stack[i] === '{' ? '}' : ']'
return JSON.parse(s)
}
第 5 步的括号配平只能救「被截断」的情况,救不了「内容本身写错了」。所以它必须配合校验,不能当成万能药。
八、解析成功不等于数据可用
一定要加一道字段校验,最省事的是 zod:
import { z } from 'zod'
const Person = z.object({
name: z.string().min(1),
age: z.number().int().min(0).max(150),
tags: z.array(z.string()).max(20),
})
const parsed = Person.safeParse(parseLoose(raw))
if (!parsed.success) {
// parsed.error.issues 里有每个字段的具体问题
}
min / max 这些约束不是多余的。模型很容易给出 age: 999 或者 30 个 tags,没有边界校验就会直接进数据库。
九、失败了怎么重试:把错误回灌
最常见也最有效的策略:不要简单地重发同一个请求,要把错误信息塞回去。
async function askWithRetry(prompt, schema, maxTry = 3) {
let lastErr = ''
for (let i = 0; i < maxTry; i++) {
const msg = i === 0
? prompt
: `${prompt}\n\n上次输出无法使用,错误:${lastErr}\n请只输出符合 schema 的 JSON。`
const raw = await callModel(msg, schema)
try {
const data = parseLoose(raw)
const r = schema.safeParse(data) // zod
if (r.success) return r.data
lastErr = r.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join('; ')
} catch (e) {
lastErr = e.message
}
}
throw new Error(`重试 ${maxTry} 次仍失败:${lastErr}`)
}
三个细节:
- 错误信息要具体到字段,
"age: Expected number, received string"比"格式错误"有用十倍 - 重试次数控制在 2~3 次,再多也不会好转,只是烧钱
- 把失败样例存下来,攒够十几条就能看出是提示词问题还是模型能力问题
十、三个反直觉的坑
温度调到 0 并不能保证输出稳定。 它只是让采样更确定,不等于格式一定对;而且不少厂商在温度为 0 时仍有浮点误差。要稳定就上 schema,别指望温度。
让模型「先思考再输出 JSON」会破坏 JSON 模式。 推理内容如果被写进正文,前缀就不是 { 了。要思维链就用厂商支持的「推理内容单独字段」,不要混在正文里。
流式输出下不能边收边 parse。 必须收全再解析。想边流式边用,只能做增量展示,不能做结构化使用。
十一、排错顺序
| 步骤 | 检查什么 |
|---|---|
| 1 | finish_reason 是不是 length(截断) |
| 2 | 原始响应前 50 字符有没有围栏或解说 |
| 3 | 有没有 ```、全角标点、尾随逗号 |
| 4 | 字段名大小写、下划线/驼峰是否和约定一致 |
| 5 | 数值字段是不是被写成了字符串 "18" |
| 6 | 数组里是不是混进了 null |
把原始响应完整粘进格式化工具看一眼,比在代码里打十行日志快:
比对两次输出差在哪,用 diff 最快:
👉 文本对比
十二、小结
- 能上 schema 就上 schema,提示词约束是最后手段
- JSON 模式只保证语法,不保证字段
- 解析器要能剥围栏、找起始符、补括号
- 解析完必须做字段校验,数值要有上下界
- 重试要把具体错误回灌,别原样重发
一句话:别求模型写得规范,让接口不许它写不规范。
相关工具:JSON 格式化|JSON 转 CSV|文本对比|AI 工具入口