大模型写代码、写文案、做总结都是一把好手,但一让它"按格式返回",很多人的第一反应就是头大。你明明在提示词里写了"请返回 JSON",结果它给你来一段"好的,以下是您需要的 JSON 数据:",后面还贴心地加了三个反引号和一句"希望对您有帮助"。程序那边json.loads()一跑,直接抛异常,整条链路崩掉。这不是模型不听话,而是我们没搞明白一件事:大模型的本质是"下一个 token 预测器",它天生倾向于输出自然语言,而不是机器可解析的严格结构。
这篇内容就是围绕"让大模型稳定吐出 JSON"这件事展开的。我会把目前工程上真正能落地的五种结构化输出姿势拆开讲清楚——从最原始的提示词约束,到 JSON Mode、Function Calling、Pydantic 校验,再到本地小模型配合语法约束解码。每一种都会说清楚它解决什么问题、在什么场景下用、坑在哪里。最后再补一层工程兜底:当模型就是不听话的时候,代码层面怎么补救。适合正在做大模型应用开发、Agent 编排、数据抽取、接口对接的工程师,也适合刚入门想搞清楚"结构化输出到底怎么回事"的朋友。
1. 为什么大模型默认不爱吐 JSON
1.1 从"下一个 token 预测"说起
要理解为什么模型不听话,得先理解它是怎么工作的。大模型在推理时,做的事情非常朴素:给定前面所有的 token,预测下一个最可能出现的 token,然后把这个 token 拼到序列后面,再预测下一个,如此循环。它没有"格式"这个概念,它只有"概率分布"。
当你在提示词里写"返回 JSON"时,模型确实会提高输出{的概率,但它同时也会提高输出"好的,以下是 JSON:"这类客套话的概率——因为在它的训练语料里,人类回答"请给我 JSON"时,往往前面就是会带一句寒暄。这就是为什么你经常收到带 markdown 代码块围栏、带解释文字、甚至带注释的"伪 JSON"。
更麻烦的是,JSON 对语法极其严格:键必须双引号、不能有尾逗号、不能有注释、字符串里的引号要转义。而模型是逐 token 生成的,它在生成第 50 个 token 的时候,并不能"回头看"整个结构是否合法。一旦前面某个地方漏了个引号,后面就会一路错下去,最后你拿到一个语法错误的字符串。
1.2 三种典型的"翻车现场"
我把实际项目里遇到过的翻车情况归了三类,你可以对照看看自己中过哪一枪。
第一类是包裹污染。模型返回的内容长这样:
好的,这是您要的数据: ```json {"name": "张三", "age": 28}希望对您有帮助。
你要的是中间那一段,但前后全是废话。用正则去抠虽然能抠出来,但一旦模型换了个说法,正则就失效了。 第二类是**语法漂移**。模型返回了看似 JSON 的东西,但里面用了单引号、尾逗号、或者把 `null` 写成了 `None`(Python 习惯)、`undefined`(JS 习惯)。这种最坑,因为肉眼看没问题,程序一解析就炸。 第三类是**结构幻觉**。你要求返回 `{"name": ..., "age": ...}`,结果模型自作主张加了个 `"hobbies"` 字段,或者把 `age` 写成了字符串 `"28"`。字段类型对不上,下游的 Pydantic 或 Java Bean 直接反序列化失败。 > 提示:这三种翻车里,第二类和第三类是最难用提示词根治的,因为它们涉及 token 级别的语法约束和 schema 一致性,必须靠工程手段兜底。 ### 1.3 结构化输出的本质:把"概率生成"约束成"合法生成" 理解了上面这些,你就会明白:所谓"结构化输出",本质上是在做一件事——**把模型自由的概率生成过程,约束到合法结构的子空间里**。约束得越早、越硬,输出就越稳。 按约束的"硬度"从软到硬排,大致是这么一条光谱:纯提示词约束(最软)→ JSON Mode → Function Calling / Tool Use → Schema 校验 + 重试 → 语法约束解码(最硬)。下面五种姿势,基本就是沿着这条光谱展开的。你不需要每种都用,但需要知道每种的能力边界在哪,才能在对的场景选对的工具。 ## 2. 姿势一:提示词约束,最软但最通用 ### 2.1 提示词到底能约束到什么程度 提示词约束是所有方法里门槛最低的,任何模型、任何 API 都能用,不需要特殊支持。它的核心思路是:通过精心设计的指令和示例,把模型"引导"到输出 JSON 的路径上。 一个能用的提示词模板大概长这样:你是一个数据抽取引擎。请从用户提供的文本中抽取信息,并严格按以下 JSON Schema 输出,不要输出任何解释、前后缀或 markdown 代码块。
Schema: { "name": "string, 人名", "age": "integer, 年龄", "city": "string, 城市" }
规则:
- 只输出 JSON 对象本身,第一个字符必须是 {,最后一个字符必须是 }
- 所有字符串用双引号
- 缺失字段填 null,不要省略字段
- 不要输出注释、不要输出尾逗号
用户文本:{input}
注意几个关键点:**明确首尾字符**、**明确引号类型**、**明确缺失值处理**、**明确禁止项**。这四条是提示词约束里性价比最高的。 ### 2.2 少样本示例比长篇规则更管用 实测下来,与其写一大段规则,不如给两三个"输入-输出"示例。模型对示例的模仿能力远强于对抽象规则的理解。比如:示例1: 输入:李四今年35岁,住在杭州 输出:{"name": "李四", "age": 35, "city": "杭州"}
示例2: 输入:王五,北京,未提供年龄 输出:{"name": "王五", "age": null, "city": "北京"}
两个示例就把"缺失填 null""字段顺序""类型"全交代清楚了,比写十条规则都直观。这也是提示词工程里常说的"show, don't tell"。 ### 2.3 提示词约束的天花板在哪 提示词约束最大的问题是**不稳定**。同一个提示词,换个模型、换个温度参数、甚至同一模型多跑几次,结果都可能不一样。温度越高越发散,越容易跑偏。而且它对复杂嵌套结构几乎无能为力——你让它输出三层嵌套的 JSON,它大概率会在第二层就开始漏字段。 所以我的经验是:**提示词约束适合做"第一道引导",但绝不能作为唯一保障**。它应该和其他姿势叠加使用,而不是单打独斗。如果你的场景对稳定性要求高,光靠提示词就是在赌运气。 ## 3. 姿势二:JSON Mode,让模型"闭嘴只输出 JSON" ### 3.1 JSON Mode 解决了什么 JSON Mode 是很多大模型 API 提供的一个开关。打开之后,模型被强制只输出合法的 JSON 字符串,不会再有"好的,以下是……"这种寒暄,也不会再有 markdown 代码块围栏。它相当于在解码阶段加了一层约束:**只允许生成能构成合法 JSON 的 token 序列**。 用起来很简单,以常见的对话接口为例,请求体里加一个参数: ```python response = client.chat.completions.create( model="your-model", messages=[ {"role": "system", "content": "你是一个数据抽取引擎,只输出 JSON。"}, {"role": "user", "content": "抽取:张三,28岁,上海"} ], response_format={"type": "json_object"} )拿到response.choices[0].message.content之后,直接json.loads()就能用,不用再抠代码块。
3.2 JSON Mode 的边界:合法 ≠ 符合你的 schema
这里有个特别容易踩的坑:JSON Mode 只保证"是合法 JSON",不保证"是你想要的 JSON"。也就是说,它可能返回{"result": "张三,28岁,上海"}——语法完全合法,但结构完全不是你要的。
所以用 JSON Mode 时,提示词里必须把 schema 写清楚,JSON Mode 负责"语法合法",提示词负责"结构正确",两者是配合关系,不是替代关系。很多人以为开了 JSON Mode 就万事大吉,结果拿到一个合法但没用的 JSON,白白浪费一轮调用。
3.3 什么时候该用 JSON Mode
JSON Mode 最适合的场景是:结构相对简单、字段固定、对稳定性有要求但不想引入复杂工具链。比如做文本分类、情感分析、简单的信息抽取。它的优点是接入成本极低,改一个参数就行;缺点是它不管 schema,复杂结构还是得靠后面的姿势。
另外要注意,不同厂商对 JSON Mode 的支持程度不一样,有的叫json_object,有的叫json_mode,有的干脆没有。上线前一定要在目标模型上实测,别照着文档写完发现参数不生效。
4. 姿势三:Function Calling,把 schema 交给模型"填表"
4.1 Function Calling 的工作机制
Function Calling(也叫 Tool Use)是目前工程上最靠谱的结构化输出方案之一。它的思路和前面完全不同:你不是让模型"写 JSON",而是给它一张"表格"(工具定义),让它"填表"。
你定义一个函数,把参数用 JSON Schema 描述清楚:
tools = [{ "type": "function", "function": { "name": "extract_person", "description": "从文本中抽取人物信息", "parameters": { "type": "object", "properties": { "name": {"type": "string", "description": "人名"}, "age": {"type": "integer", "description": "年龄"}, "city": {"type": "string", "description": "城市"} }, "required": ["name", "age", "city"] } } }]模型收到之后,如果判断需要调用这个函数,就会返回一个结构化的tool_calls,里面的arguments就是符合你 schema 的 JSON 字符串。你解析这个字符串就行,不用再担心它加寒暄或者漏字段。
4.2 为什么它比 JSON Mode 更稳
关键在于:Function Calling 的 schema 是"强约束"。模型在生成参数时,是被引导着按 schema 的字段和类型来的,而不是自由发挥。字段名、类型、必填项都在 schema 里定义好了,模型填错的概率大幅降低。
而且它天然解决了"结构幻觉"问题——模型不会给你加 schema 里没有的字段,因为它的输出空间被限制在 schema 定义的属性里。这一点是纯提示词和 JSON Mode 都做不到的。
4.3 实战中的几个坑
第一个坑是模型可能不调用函数。如果你给的文本里没有可抽取的信息,模型可能直接回一句自然语言"文本中没有人物信息",而不是调用函数。这时候你得在提示词里明确"无论如何都要调用函数,没有信息就填 null"。
第二个坑是arguments 是字符串不是对象。很多接口返回的arguments是一个 JSON 字符串,需要你自己json.loads()一次。新手经常直接当字典用,结果报TypeError。
第三个坑是并行调用。有些模型会一次返回多个tool_calls,如果你的业务逻辑只处理第一个,就会漏数据。要么在提示词里限制只调用一次,要么在代码里遍历处理。
注意:Function Calling 的 schema 描述(description 字段)非常关键,它相当于给模型的"填写说明"。描述写得越清楚,模型填得越准。别偷懒只写字段名。
5. 姿势四:Pydantic 校验,把"事后检查"做成"自动重试"
5.1 Pydantic 在链路里的位置
前面三种姿势都是在"生成端"做文章,Pydantic 则是在"接收端"做文章。它的角色是校验器 + 类型转换器:模型返回的 JSON 先过一遍 Pydantic 模型,字段类型对不对、必填项有没有、取值范围合不合法,全都检查一遍。
定义一个 Pydantic 模型:
from pydantic import BaseModel, Field, ValidationError from typing import Optional class Person(BaseModel): name: str = Field(description="人名") age: int = Field(ge=0, le=150, description="年龄") city: Optional[str] = None拿到模型输出后:
try: person = Person.model_validate_json(raw_output) except ValidationError as e: print("校验失败:", e)Pydantic 的好处是它不只是"检查",还会做类型强制转换。比如模型返回"age": "28"(字符串),Pydantic 会自动转成整数 28。这能救回不少"类型漂移"的翻车。
5.2 校验失败之后怎么办:重试闭环
光校验不够,关键是校验失败之后要有补救动作。最实用的做法是"带错误信息重试":把 Pydantic 报的错原样塞回给模型,让它自己修。
def extract_with_retry(text, max_retries=3): prompt = build_prompt(text) for i in range(max_retries): raw = call_llm(prompt) try: return Person.model_validate_json(raw) except ValidationError as e: prompt = f"{build_prompt(text)}\n\n上次输出有误:{e}\n请修正后重新输出。" raise RuntimeError("重试多次仍失败")这个闭环的威力在于:模型看到具体的错误信息(比如"age 字段期望整数,收到字符串"),修正的准确率非常高。实测下来,第一次失败后重试的成功率能到 80% 以上,两次重试基本能覆盖绝大多数情况。
5.3 用 Pydantic 反向生成 schema
Pydantic 还有个隐藏用法:用模型类自动生成 JSON Schema,喂给 Function Calling 或提示词。这样你只需要维护一份 Pydantic 定义,schema 和校验逻辑就统一了,不会出现"schema 写了一套、校验写了一套、两边还对不上"的尴尬。
schema = Person.model_json_schema() # 直接把这个 schema 塞进 tools 定义或提示词这是我在项目里最推荐的组合拳:Pydantic 定义单一数据源 → 生成 schema 给模型 → 模型输出 → Pydantic 校验 → 失败重试。整条链路闭环,维护成本还低。
6. 姿势五:语法约束解码,本地部署的硬核方案
6.1 什么是语法约束解码
如果你在本地部署模型(比如用 llama.cpp 这类推理框架),还有一个更硬核的选项:语法约束解码(Grammar-Constrained Decoding)。它的原理是在解码的每一步,根据一个预定义的语法(比如 GBNF 语法或 JSON Schema),把不符合语法的 token 概率直接置零。也就是说,模型在物理上无法生成非法 JSON。
这比前面所有方法都硬,因为它不是"引导"或"校验",而是"禁止"。模型想输出一个单引号?对不起,这个 token 的概率是 0,采样不到。
6.2 怎么用起来
以 llama.cpp 为例,它支持传入 GBNF 语法文件。你可以手写一个 JSON 语法,也可以用工具从 JSON Schema 自动转换。跑起来大概是这样:
./main -m model.gguf -p "抽取人物信息:张三,28岁" --grammar-file json.gbnf输出的内容会被严格约束成合法 JSON。对于本地部署、对稳定性要求极高的场景,这是终极方案。
6.3 代价与适用边界
语法约束解码不是没有代价。第一,它会拖慢推理速度,因为每一步都要做语法状态检查。第二,它对嵌套复杂结构的语法文件编写有一定门槛,写错了会导致模型"卡死"或者输出奇怪的东西。第三,它主要适用于本地部署,云端 API 一般不给这个能力。
所以我的建议是:云端 API 场景优先用 Function Calling + Pydantic;本地部署且对稳定性有极致要求时,再上语法约束解码。不要为了用而用。
7. 工程兜底:当模型就是不听话时怎么办
7.1 分层防御的整体思路
前面五种姿势不是互斥的,真正稳的系统是分层叠加的。我一般会这么搭:
| 层级 | 手段 | 作用 |
|---|---|---|
| 第一层 | 提示词 + 少样本示例 | 引导模型走向正确结构 |
| 第二层 | JSON Mode / Function Calling | 约束生成端,保证语法合法 |
| 第三层 | Pydantic 校验 | 检查结构、类型、取值范围 |
| 第四层 | 带错误重试 | 失败后自动修复 |
| 第五层 | 兜底默认值 / 降级 | 多次失败后返回安全结果 |
这五层下来,结构化输出的成功率能做到 99% 以上。单靠任何一层都不行,组合起来才稳。
7.2 清洗层的几个实用技巧
即使有前面几层,偶尔还是会收到带污染的字符串。这时候一个健壮的清洗函数能救命:
import json import re def robust_json_parse(raw: str): # 1. 去掉 markdown 代码块围栏 raw = re.sub(r"^```(?:json)?\s*", "", raw.strip()) raw = re.sub(r"\s*```$", "", raw) # 2. 截取第一个 { 到最后一个 } start = raw.find("{") end = raw.rfind("}") if start != -1 and end != -1: raw = raw[start:end+1] # 3. 尝试解析 return json.loads(raw)这个函数能处理掉 90% 的"包裹污染"。注意第 2 步用rfind而不是find,因为嵌套 JSON 里会有多个},取最后一个才能包住整个对象。
7.3 流式输出下的结构化难题
如果你的场景是流式输出(streaming),结构化会更麻烦,因为 token 是一个个来的,你没法等全部生成完再解析。这时候有两个思路:一是先流式展示、后结构化落库,把展示和解析分开;二是用增量解析,边收边尝试解析,遇到完整对象就吐出来。后者实现复杂,一般用在 Agent 场景里需要实时响应的场合。
7.4 几个容易被忽略的细节
第一,温度参数。做结构化抽取时,温度建议调到 0 或接近 0,减少随机性。温度越高,模型越"有创意",越容易跑偏。
第二,max_tokens 要留够。如果 max_tokens 设太小,JSON 还没输出完就被截断了,你拿到的是半个对象。宁可设大一点。
第三,字段顺序。有些模型对字段顺序敏感,schema 里把必填字段放前面,能提高一次成功率。
第四,中文和特殊字符。如果字段值里有中文、换行、引号,一定要确保模型正确转义。Pydantic 校验能帮你发现这类问题。
8. 五种姿势怎么选:一张决策表
说了这么多,最后落到"我到底该用哪个"这个问题上。我整理了一张决策表,按场景对号入座:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 快速原型、结构简单 | 提示词 + JSON Mode | 接入成本最低,够用 |
| 生产环境、结构固定 | Function Calling + Pydantic | 稳定性最高,维护成本可控 |
| 复杂嵌套结构 | Function Calling + Pydantic + 重试 | 单层扛不住,必须叠加 |
| 本地部署、极致稳定 | 语法约束解码 + Pydantic | 物理层面杜绝非法输出 |
| 流式 Agent 场景 | 增量解析 + 校验 | 兼顾实时性和正确性 |
选型的核心原则就一条:约束越硬越好,但要在成本和收益之间找平衡。不是所有场景都值得上语法约束解码,也不是所有场景都能靠提示词糊弄过去。搞清楚你的稳定性要求、模型能力、部署方式,答案自然就出来了。
我在实际项目里踩过最深的坑,是早期太迷信提示词,觉得"写清楚就行了",结果上线后各种边界 case 把服务打挂。后来老老实实把 Function Calling 和 Pydantic 加上,又补了重试闭环,才真正稳下来。结构化输出这件事,没有银弹,只有分层防御。把每一层都做扎实,比指望某一层做到完美要靠谱得多。