← 返回资讯

让大模型稳定输出 JSON:从提示技巧到强制模式

2026-07-01

让模型稳定输出合法 JSON 是大模型工程化的第一道坎。不加任何约束时,模型会在 JSON 外包一层 markdown 代码块、漏掉引号、截断长数组——任何一种都会让 json.loads() 崩掉。从低到高有五层保障手段。

你大概率是这么撞上这个坑的:demo 阶段随手写个 prompt「帮我提取里面的姓名和职位,输出 JSON」,跑十次有八次是对的,你觉得够用了就上线。结果生产环境跑了几千次调用之后,后台开始报 json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0),一查发现模型输出的是这样一段东西:

好的,以下是提取结果:
```json
{"name": "张三", "position": "工程师"}

希望对你有帮助!


`json.loads()` 拿到这一坨直接炸,因为开头的中文句子根本不是合法 JSON 的起始字符。这不是模型"抽风",而是它被训练成了一个对话助手,天然有加解释、加客套话的倾向——你不明确堵死这个口子,它就会按对话习惯来。下面五种方法,本质上是在用不同强度的约束把这个倾向摁下去,强度从低到高,出问题的概率跟着往下掉。

## 方法一:提示写法优化(零成本)

基础写法,成功率约 75-85%:

```python
SYSTEM = """
你只输出合法的 JSON,不加任何解释、不加 markdown 代码块。
输出格式:
{"name": string, "score": number, "tags": string[]}
"""

进阶写法,在 system 里加”反例声明”:

SYSTEM = """
只输出 JSON 对象,规则:
- 不要输出 ```json 代码块
- 不要在 JSON 前后加任何文字
- 所有字符串用双引号,不用单引号
- 数字不加引号

错误示例(不要这样输出):
```json
{"name": "张三"}

正确示例: {“name”: “张三”, “score”: 95, “tags”: [“优秀”, “积极”]} """


这两版写法的差距不在字数多少,而在"给没给反例"。纯正向描述(只说该怎么做)对模型的约束力其实偏弱,因为模型见过太多"输出前先说一句客套话"的对话数据,正向指令很容易被这类惯性覆盖掉。反例声明相当于把模型最容易犯的错误明确摆出来打叉,命中率能再往上提一截。但提示写法有个硬伤:它是"软约束",模型完全可能不听——尤其是长对话里,前面几轮的语气会污染后面的输出风格,你会看到明明 system prompt 写得很清楚,模型还是在第五轮开始输出"根据以上分析:"这种开场白。这也是为什么成功率上限卡在 85% 左右,纯提示工程没法再往上顶了,真要保证到 95%+ 就得换成平台的原生能力。

## 方法二:JSON Mode(平台原生支持)

OpenAI 兼容 API 大多支持 `response_format`,强制输出合法 JSON,成功率约 95%+:

```python
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "提取以下文本中的人名和职位,输出JSON"},
        {"role": "user", "content": user_text}
    ],
    response_format={"type": "json_object"},  # 关键参数
)
data = json.loads(response.choices[0].message.content)

注意:启用 JSON mode 时,system prompt 里必须明确提到”JSON”,否则部分平台会报错。这不是玄学,是接口层面的硬性校验——比如你只写了”提取人名和职位”没提”JSON”两个字,请求可能直接被拒,报错文案类似 'messages' must contain the word 'json' in some form。第一次踩到这个坑的人往往会懵:明明 response_format 都传对了,为什么还报错?原因就是平台在服务端做了个简单的字符串匹配检查,防止你传了 JSON mode 参数却完全没告诉模型要输出 JSON,导致模型生成一个空对象或者卡死重试。

JSON mode 的原理是在解码层面加约束:模型生成每一个 token 时,采样器会过滤掉那些会导致输出偏离合法 JSON 语法的候选 token。它保证的是”语法合法”,不保证”字段齐全、语义正确”——这是很多人踩的第二个坑:拿到的 JSON 能 json.loads() 成功,但字段名对不上、必填字段缺失。JSON mode 不认识你的业务 schema,它只认识 JSON 这门语法本身。如果你需要模型严格按照你定义的字段结构输出,就得往下看方法三和方法四,那两种才是真正把 schema 传给模型的方式。

另外要提醒一句:不同平台对 JSON mode 的支持程度不一样,有的只保证顶层是合法 JSON 对象,数组、嵌套对象的支持度因模型版本而异,上线前务必拿你的真实 schema 跑几百条测试样本,别只测十条就当过了。

方法三:Function Calling(最稳定)

把目标 schema 包装成一个”虚拟函数”,模型输出参数而非自由文本,成功率 99%+:

tools = [{
    "type": "function",
    "function": {
        "name": "extract_info",
        "description": "提取文本中的结构化信息",
        "parameters": {
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "人名"},
                "score": {"type": "number", "description": "评分 0-100"},
                "tags": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "标签列表"
                }
            },
            "required": ["name", "score"]
        }
    }
}]

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": user_text}],
    tools=tools,
    tool_choice={"type": "function", "function": {"name": "extract_info"}}
)

# 取出参数
args = response.choices[0].message.tool_calls[0].function.arguments
data = json.loads(args)

为什么 function calling 比 JSON mode 更稳?关键在于 parameters 字段用的是 JSON Schema 语法,模型在训练阶段就大量学过”根据函数签名生成合法参数”这个任务——这是它的强项,比”自由生成一段符合某种格式描述的文本”这个任务约束力强得多。你给的 schema 越具体(每个字段都写清楚 description,把取值范围、单位、格式都在描述里说明白),模型填错的概率越低。反过来,如果 description 写得含糊,比如 score 只写”评分”不写”0-100 的整数”,模型照样可能填个字符串 "优秀" 进去——schema 的类型约束管的是”这是不是数字”,管不了”这个数字符不符合你的业务预期”。

实战中还有一个容易漏的点:tool_choice 如果不强制指定具体函数(写成 "auto" 而不是上面代码里的 {"type": "function", "function": {"name": "extract_info"}}),模型有权选择不调用函数、直接输出自然语言文本,这时候 response.choices[0].message.tool_calls 会是 None,你直接取 [0] 会抛 IndexErrorTypeError: 'NoneType' object is not subscriptable。所以只要你的场景是”一定要拿到这个函数的参数”,就把 tool_choice 锁死成强制调用,不要留 "auto" 的活口。

还有一个进阶场景是”多函数候选”:如果你 tools 里放了好几个函数,让模型自己判断该调用哪个(比如客服场景里”查订单”和”查物流”两个工具二选一),这时候 tool_choice 才适合用 "auto",但你的业务代码要处理”模型没调用任何工具”的分支,不能假设 tool_calls 一定非空。

方法四:Structured Output with Pydantic(类型安全)

配合 Pydantic + instructor 库,直接返回强类型对象:

import instructor
from pydantic import BaseModel
from openai import OpenAI

class PersonInfo(BaseModel):
    name: str
    score: float
    tags: list[str] = []

client = instructor.from_openai(OpenAI())

person = client.chat.completions.create(
    model="gpt-4o",
    response_model=PersonInfo,   # 直接指定 Pydantic 模型
    messages=[{"role": "user", "content": user_text}]
)
# person 已经是 PersonInfo 实例,无需手动解析
print(person.name, person.score)

instructor 内部会自动重试并修复格式错误,是生产环境推荐方案。

它的重试逻辑值得展开说说:instructor 会把 Pydantic 的 ValidationError 捕获下来,连同错误信息一起重新塞回给模型(相当于告诉它”你刚才这里填错了,字段 X 应该是 float 不是 str,重新填”),再发起一次请求。这个”把校验错误喂回给模型”的动作,比你自己写 try/except 然后简单重试要聪明得多——纯重试是碰运气,喂错误信息是让模型知道错在哪、下一次有方向地改。默认重试次数通常是 1-3 次,你可以用 max_retries 参数调整,但也别调太高,每多一次重试就是一次完整的 API 调用延迟和成本,调到 5 次以上如果还失败,大概率是你的 schema 设计有问题(比如某个字段的约束条件模型理解不了),而不是靠重试能救回来的。

代价也要说清楚:instructor 是个第三方库,会多引入一层依赖和版本兼容的维护成本,团队里如果对 Pydantic 不熟,调试报错栈会比原生 SDK 深一层。简单场景(比如就提取两三个字段)用方法三的 function calling 足够,不需要为了”类型安全”这个好处专门引入一个新库;但如果你的输出结构本身就是多层嵌套(订单里套商品列表,商品里又套规格字典),Pydantic 的模型定义和自动校验能省下大量手写 isinstance 判断的代码,这时候引入它才划算。

方法五:输出修复兜底

以上方法都用上后,仍有极低概率出现残缺 JSON(网络截断、模型 bug)。加一层修复兜底:

import json, re

def safe_json_parse(text: str) -> dict:
    # 去掉 markdown 代码块
    text = re.sub(r"```(?:json)?\s*", "", text).strip()
    # 去掉末尾多余文字(截断后的常见问题)
    text = re.sub(r"}\s*[^}]*$", "}", text)
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        # 使用 json_repair 库修复
        from json_repair import repair_json
        return json.loads(repair_json(text))

这层兜底代码里那行 re.sub(r"}\s*[^}]*$", "}", text) 专门治一种典型病:流式接口在网络抖动或超时中断时,返回的内容可能停在半截,比如 {"name": "张三", "score": 95, "tag——这种情况连 json_repair 都未必救得回来,因为最后一个字段值本身就没写完。所以这段正则处理的是”JSON 主体已经完整,但后面又跟了一截多余文字”这种情况(常见于模型在合法 JSON 后面又多蹦出一句”以上就是提取结果”),而不是”JSON 主体本身被截断”这种情况。这两种失败模式在日志里长得很像,都是 JSONDecodeError,但根因和修法完全不同:前者靠正则清理,后者只能是重试请求或者干脆判定这次调用失败进重试队列,别指望字符串修复能凭空补出被截断的内容。

json_repair 这个库能处理的典型问题包括:单引号换双引号、末尾多余逗号、缺少右括号、字符串里的换行没转义等,但它不是万能的,如果模型输出的内容本身逻辑就是错的(比如该是数组的地方给了个对象),json_repair 也修不出你要的结构。所以这一层的定位很明确:兜底极端情况的语法瑕疵,不能替代前四种方法,更不能作为主力方案——见过团队图省事只用方法一 + 这层修复兜底,成功率长期卡在 90% 出头,代价是线上偶发解析失败很难复现排查,因为你根本不知道模型当时具体输出了什么。生产环境的正确姿势是:方法二或方法三打底把成功率提到 95%+,这层修复只处理剩下那零星几个漏网之鱼。

流式输出场景怎么办

上面的方法都是假设你拿到的是一次性返回的完整字符串,但如果你用的是流式接口(stream=True),JSON 是一个 token 一个 token 吐出来的,中途做解析没有意义——半截的 {"name": "张 本来就不是合法 JSON,你不能指望每收到一个 token 就 json.loads() 一次。

实操上有两条路:一是简单粗暴,流式只用来做”打字机效果”给用户看进度条,但后台业务逻辑等流式结束、把所有片段拼接成完整字符串之后再统一走上面的解析流程,这是目前用得最多的做法,改造成本最低。二是用增量 JSON 解析器(比如 Python 生态里的 json-stream),边收 token 边解析已经完整的字段,适合那种”字段很多、想让前端提前展示已经解析出来的部分”的场景,比如一个长报告里先解析出标题和摘要就先展示,不用等全部生成完。第二种做法工程复杂度高不少,一般中小型应用不建议一上来就用,先用第一种能不能满足需求。

需要额外提醒的是:function calling 和 JSON mode 在流式模式下,delta 里返回的是参数字符串的片段,同样需要等 finish_reason 变成 stop(或 tool_calls)之后再拼接完整,中途单独某个 delta.content 片段做 json.loads() 大概率会失败,这也是很多人调流式 + JSON mode 组合时第一次遇到的坑。

各方法对比

方法成功率额外成本适用场景
提示优化~80%快速原型
JSON Mode~95%通用生产
Function Calling~99%轻微需要 schema 约束
Pydantic + instructor~99.5%库依赖复杂嵌套结构
输出修复兜底兜底极低与上述组合使用

常见问题

模型输出了合法 JSON 但字段值错误(如数字范围超出),怎么处理? JSON 合法性和业务正确性是两个层面。合法性用上述方法保证,业务校验需要在 Pydantic model 里加 validatorfield_validator,不满足时触发重试。

模型输出的 JSON 嵌套太深,一层层解析很麻烦? 尽量把 schema 设计”扁平化”,避免超过 3 层嵌套。复杂结构拆成多次调用,每次输出简单 JSON 后合并,比单次输出复杂 schema 更稳定。

用了 JSON mode 或 function calling,还需要做重试吗? 需要。这两种方法解决的是”格式合法性”问题,解决不了网络层的问题——超时、限流(429)、服务端瞬时错误(5xx)该重试还是得重试。建议给外层套一个带退避的重试装饰器,别每次失败都立刻重试,那样容易在限流窗口期把请求打得更密集:

import time
import random

def call_with_retry(func, max_retries=3, base_delay=1.0):
    for attempt in range(max_retries):
        try:
            return func()
        except json.JSONDecodeError:
            if attempt == max_retries - 1:
                raise
            # 指数退避 + 随机抖动,避免多个失败请求同时重试造成新的拥堵
            delay = base_delay * (2 ** attempt) + random.uniform(0, 0.5)
            time.sleep(delay)

真正上生产之后你会发现,纯粹的”JSON 格式不合法”只是众多失败原因里的一小部分,更常见的是这几类,排查思路各不相同:

报错现象常见根因排查方向
JSONDecodeError 且内容明显不完整输出被截断,max_tokens 设得太小检查 max_tokens 是否够覆盖你的 schema,尤其数组类字段元素多的时候
请求返回 401API Key 失效、拼错、或者用错了环境(测试 key 打到生产环境)核对 key 来源和调用的 base_url 是否匹配
请求返回 429触发限流,QPS 或 token 配额超限加退避重试,或检查并发数是否超过账号套餐上限
请求超时输入过长导致模型推理时间变长,或者网络链路不稳适当调大 timeout,同时评估是否要拆分长输入
输出中文变问号或乱码客户端没有按 UTF-8 解码响应体确认 HTTP 客户端和 json.loads 全链路都是 UTF-8
输入超过上下文窗口长文档 + 长 system prompt 叠加超限先做摘要或分段,再喂给模型,别一次塞进去

这张表的价值在于:拿到一个报错先归类,别一上来就怀疑”是不是提示词又写错了”。JSON 解析失败和网络层失败是两类完全不同的问题,混在一起排查只会浪费时间——先看是不是 5xx/429/超时这种网络层问题,排除之后再回头看是不是 JSON 格式本身出的问题,效率会高很多。

加了这么多层保障,成本会不会翻倍? 不会明显增加。JSON mode 和 function calling 本身不额外收费,是解码策略层面的约束,跟你按 token 计费的额度没关系。真正增加成本的是”重试次数”——如果你的 schema 设计得不合理导致频繁触发重试,那才是隐性成本,多跑一次就是多算一次完整的 token 消耗。所以与其在修复层花心思,不如把精力放在 schema 设计和 system prompt 的清晰度上,从源头减少出错概率,比事后修复更省钱也更稳。


延伸阅读:大模型应用开发模式 · 应用模式 Hub · 结构化输出与 schema 校验 · 防提示注入