让大模型稳定输出 JSON:从提示词到解析容错的完整做法

写业务代码接大模型,最烦的不是回答质量,是返回的不是能直接 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,不要编造。

四个关键点:

  1. 明确说「不要 ```json 围栏」 —— 不说它八成会加
  2. 给出完整字段和类型 —— 只描述语义不给结构,格式必飘
  3. 规定缺失值怎么填 —— 不说就用 null,它会自己编一个
  4. 把 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

把原始响应完整粘进格式化工具看一眼,比在代码里打十行日志快:

👉 JSON 在线格式化|JSON 转 CSV / YAML

比对两次输出差在哪,用 diff 最快:

👉 文本对比

十二、小结

  • 能上 schema 就上 schema,提示词约束是最后手段
  • JSON 模式只保证语法,不保证字段
  • 解析器要能剥围栏、找起始符、补括号
  • 解析完必须做字段校验,数值要有上下界
  • 重试要把具体错误回灌,别原样重发

一句话:别求模型写得规范,让接口不许它写不规范。


相关工具:JSON 格式化|JSON 转 CSV|文本对比|AI 工具入口

还有 66 个免费在线工具

纯前端实现,不用注册,数据不上传服务器。

浏览全部工具