ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

大模型结构化输出实战:五种让模型稳定吐JSON的工程方案

大模型结构化输出实战:五种让模型稳定吐JSON的工程方案 大模型写代码、写文案、做总结都是一把好手但一让它按格式返回很多人的第一反应就是头大。你明明在提示词里写了请返回 JSON结果它给你来一段好的以下是您需要的 JSON 数据后面还贴心地加了三个反引号和一句希望对您有帮助。程序那边json.loads()一跑直接抛异常整条链路崩掉。这不是模型不听话而是我们没搞明白一件事大模型的本质是下一个 token 预测器它天生倾向于输出自然语言而不是机器可解析的严格结构。这篇内容就是围绕让大模型稳定吐出 JSON这件事展开的。我会把目前工程上真正能落地的五种结构化输出姿势拆开讲清楚——从最原始的提示词约束到 JSON Mode、Function Calling、Pydantic 校验再到本地小模型配合语法约束解码。每一种都会说清楚它解决什么问题、在什么场景下用、坑在哪里。最后再补一层工程兜底当模型就是不听话的时候代码层面怎么补救。适合正在做大模型应用开发、Agent 编排、数据抽取、接口对接的工程师也适合刚入门想搞清楚结构化输出到底怎么回事的朋友。1. 为什么大模型默认不爱吐 JSON1.1 从下一个 token 预测说起要理解为什么模型不听话得先理解它是怎么工作的。大模型在推理时做的事情非常朴素给定前面所有的 token预测下一个最可能出现的 token然后把这个 token 拼到序列后面再预测下一个如此循环。它没有格式这个概念它只有概率分布。当你在提示词里写返回 JSON时模型确实会提高输出{的概率但它同时也会提高输出好的以下是 JSON这类客套话的概率——因为在它的训练语料里人类回答请给我 JSON时往往前面就是会带一句寒暄。这就是为什么你经常收到带 markdown 代码块围栏、带解释文字、甚至带注释的伪 JSON。更麻烦的是JSON 对语法极其严格键必须双引号、不能有尾逗号、不能有注释、字符串里的引号要转义。而模型是逐 token 生成的它在生成第 50 个 token 的时候并不能回头看整个结构是否合法。一旦前面某个地方漏了个引号后面就会一路错下去最后你拿到一个语法错误的字符串。1.2 三种典型的翻车现场我把实际项目里遇到过的翻车情况归了三类你可以对照看看自己中过哪一枪。第一类是包裹污染。模型返回的内容长这样好的这是您要的数据 json {name: 张三, age: 28}希望对您有帮助。你要的是中间那一段但前后全是废话。用正则去抠虽然能抠出来但一旦模型换了个说法正则就失效了。 第二类是**语法漂移**。模型返回了看似 JSON 的东西但里面用了单引号、尾逗号、或者把 null 写成了 NonePython 习惯、undefinedJS 习惯。这种最坑因为肉眼看没问题程序一解析就炸。 第三类是**结构幻觉**。你要求返回 {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, dont 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( modelyour-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 ModeJSON 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(ge0, le150, 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_retries3): 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 反向生成 schemaPydantic 还有个隐藏用法用模型类自动生成 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:end1] # 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 加上又补了重试闭环才真正稳下来。结构化输出这件事没有银弹只有分层防御。把每一层都做扎实比指望某一层做到完美要靠谱得多。
返回列表