资讯动态

大模型结构化输出五种方案与工程兜底实践

发布时间:2026/9/28 14:10:46 来源:尧图企业网站定制
1. 为什么大模型输出 JSON 这件事值得单独拎出来讲但凡用大模型做过一点正经工程的人大概率都经历过这个场景你写了一段自认为天衣无缝的提示词要求模型“只返回 JSON不要任何多余解释”结果模型回你一句“好的以下是您需要的 JSON”然后才慢悠悠地贴出代码块。更离谱的是有时候它会在 JSON 里塞注释、用单引号、末尾多一个逗号甚至把字段名从user_name悄悄改成userName。你拿着这个字符串去json.loads()直接抛异常整条链路崩掉。这就是结构化输出要解决的核心问题。所谓结构化输出就是让大模型返回的内容不是一段自由文本而是符合预先定义 schema 的数据结构通常是 JSON也可能是 XML、YAML 或者表格。它的价值在于只有输出结构可预测下游程序才能稳定消费。你做信息抽取、做 Agent 工具调用、做表单自动填充、做 RAG 的引用溯源全都依赖这一步。这篇文章面向的是已经在大模型应用开发一线、或者正准备把大模型接进自己业务系统的工程师。我会把目前主流的五种让模型“吐出”结构化 JSON 的姿势逐一拆开讲每种都配上适用场景、实操代码和踩坑记录最后再讲工程上怎么做兜底——毕竟再好的方案也有翻车的时候兜底策略决定了你的系统是能扛住生产流量还是一到高峰期就满地报错。先给一个全局认知让模型输出 JSON本质上是在“约束生成空间”。约束得越硬模型自由度越低稳定性越高但灵活性和语义质量可能下降。五种姿势其实就是五种不同强度的约束手段从最软的提示词约束到最硬的语法级约束各有各的位置。2. 五种结构化输出姿势全拆解2.1 姿势一纯提示词约束最软但最通用这是所有人上手的第一种方式也是门槛最低的。核心思路就是在 prompt 里明确告诉模型输出格式通常配合 few-shot 示例。一个能用的模板大概长这样你是一个信息抽取助手。请从用户提供的文本中抽取以下字段并以 JSON 格式返回 - name: 人名字符串 - age: 年龄整数 - city: 城市字符串 要求 1. 只输出 JSON不要任何解释、不要 markdown 代码块 2. 字段缺失时用 null 3. 不要添加额外字段 示例输入张三今年 28 岁住在杭州。 示例输出{name: 张三, age: 28, city: 杭州} 现在处理李四 35 岁来自成都。这种方式的优点是零依赖、任何模型都能用、改起来快。缺点是稳定性完全看模型心情。实测下来7B 级别的小模型在字段一多、嵌套一深的时候格式错误率能到 20% 以上。即便是 GPT-4 级别的模型在长上下文、复杂 schema 下也会偶发漏字段或者多加解释性文字。我个人的经验是纯提示词约束适合原型验证和字段极少的场景。一旦要上生产必须叠加后面的硬约束手段。另外有个小技巧把“不要 markdown 代码块”这句话放在 prompt 最后一行效果比放在中间好因为模型对末尾指令的遵循度更高。2.2 姿势二JSON Mode模型层面的格式开关OpenAI 最早推出了response_format{type: json_object}这个参数后来国内多家模型服务也跟进支持。开启之后模型被强制输出合法 JSON不会再给你加“好的以下是”这种前缀。调用方式很直接from openai import OpenAI client OpenAI() resp client.chat.completions.create( modelgpt-4o-mini, response_format{type: json_object}, messages[ {role: system, content: 你是一个信息抽取助手只输出 JSON。}, {role: user, content: 抽取李四 35 岁来自成都。字段为 name, age, city} ] ) print(resp.choices[0].message.content)这里有个必须注意的点JSON Mode 只保证“输出是合法 JSON”不保证“字段符合你的 schema”。也就是说它可能给你返回{name: 李四, age: 35, city: 成都, extra: ...}age 变成了字符串还多了个字段。所以 JSON Mode 解决的是语法合法性不解决语义正确性。还有一个坑很多模型的 JSON Mode 要求你在 prompt 里必须出现“JSON”这个词否则会报错或者不生效。这个细节文档里经常一笔带过但实际调试时能卡你半天。2.3 姿势三Function Calling把 schema 交给模型当“函数签名”Function Calling有的平台叫 Tool Calling是目前工程上最主流的结构化输出方案。它的思路很巧妙你不是让模型“输出 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] } } }] resp client.chat.completions.create( modelgpt-4o-mini, toolstools, tool_choice{type: function, function: {name: extract_person}}, messages[{role: user, content: 抽取李四 35 岁来自成都。}] ) import json args resp.choices[0].message.tool_calls[0].function.arguments data json.loads(args)Function Calling 相比 JSON Mode 的优势在于schema 是结构化传给模型的模型对字段类型、必填项的理解更准确字段名漂移的概率大幅降低。tool_choice强制指定函数后模型基本不会跑偏去聊天。但它的坑也不少。第一不同平台对arguments的返回格式处理不一致有的返回字符串需要你自己json.loads有的直接返回对象。第二嵌套 schema 一深模型填错层级的概率上升。第三部分开源模型对 Function Calling 的支持是“模拟”的底层还是提示词拼接稳定性打折扣。2.4 姿势四Pydantic 校验重试把类型系统拉进来前面三种都是在“生成端”做约束Pydantic 这一派是在“接收端”做约束。核心思路是用 Pydantic 定义好数据模型模型输出后立刻校验校验失败就把错误信息塞回去让模型重试。from pydantic import BaseModel, Field, ValidationError import json class Person(BaseModel): name: str Field(description人名) age: int Field(ge0, le150, description年龄) city: str Field(description城市) def extract_with_retry(text: str, max_retry: int 3) - Person: prompt f抽取人物信息返回 JSON字段name, age, city。文本{text} for i in range(max_retry): raw call_llm(prompt) try: return Person.model_validate_json(raw) except ValidationError as e: prompt f上次输出有误{e}\n请修正后重新返回 JSON。原文{text} raise RuntimeError(重试耗尽)Pydantic 的价值在于它把“格式正确”升级成了“语义正确”。age 必须是 0 到 150 的整数city 不能为空这些约束模型不一定每次都满足但校验层能兜住。配合重试机制整体成功率能拉到 99% 以上。这里的关键经验是重试时一定要把具体的校验错误告诉模型而不是简单说“你错了重来”。告诉它“age 字段期望整数但收到字符串 35”模型修正的准确率远高于模糊反馈。另外重试次数别设太多2 到 3 次足够再多就是浪费 token说明这个 schema 对当前模型太难了该换方案。2.5 姿势五语法级约束解码最硬核的终极方案前面四种本质都是“引导”这一种是“强制”。语法约束解码Constrained Decoding的思路是在模型生成每一个 token 的时候根据目标语法比如 JSON Schema 编译成的正则或下推自动机动态屏蔽掉不合法的 token让模型根本没机会输出错误格式。代表工具是outlines、guidance、lm-format-enforcer这类库。以 outlines 为例import outlines from pydantic import BaseModel class Person(BaseModel): name: str age: int city: str model outlines.models.transformers(Qwen/Qwen2.5-7B-Instruct) generator outlines.generate.json(model, Person) result generator(抽取李四 35 岁来自成都。) print(result) # 直接是 Person 实例这种方式的理论保证是最强的只要 schema 编译正确输出 100% 符合结构。代价是它需要你能拿到模型的 logits也就是得本地部署或者用支持该能力的推理框架。纯 API 调用通常用不了。另外约束解码会轻微影响生成质量因为模型被强行掰着走某些情况下语义流畅度会下降。五种姿势的对比我整理成一张表方便你按场景选姿势约束强度依赖适用场景主要风险提示词约束弱无原型验证、字段极少格式漂移、加解释JSON Mode中API 支持快速接入、语法合法即可字段类型/名称不符Function Calling中强API 支持Agent、工具调用、多字段嵌套深易错层Pydantic 校验重试强代码层生产系统、类型严格重试耗 token约束解码最强本地推理高稳定要求、离线部署需 logits、影响流畅度3. 工程兜底再稳的方案也要有 Plan B3.1 分层防御的整体架构生产系统里我从来不指望单一手段能 100% 稳定。合理的做法是分层防御第一层用 Function Calling 或 JSON Mode 拿到初步结果第二层用 Pydantic 做严格校验第三层做修复和降级。具体来说一次完整的结构化输出请求应该经过这几个关卡生成、解析、校验、修复、降级。生成阶段用强约束手段解析阶段处理各种边界情况比如模型返回了 markdown 代码块包裹的 JSON校验阶段用 Pydantic 卡类型和业务规则修复阶段针对可修复错误做自动纠正降级阶段在实在救不回来时返回安全默认值并记录日志。3.2 JSON 解析的容错处理模型返回的 JSON 经常带“包装”。最常见的是被json代码块包住其次是前后有解释性文字。写一个健壮的提取函数能省掉大量麻烦import json import re def robust_json_parse(text: str): text text.strip() # 去掉 markdown 代码块 fence re.search(r(?:json)?\s*(.*?), text, re.DOTALL) if fence: text fence.group(1).strip() # 截取第一个 { 到最后一个 } start text.find({) end text.rfind(}) if start ! -1 and end ! -1 and end start: text text[start:end1] # 处理常见脏数据尾逗号、单引号 text re.sub(r,\s*([}\]]), r\1, text) try: return json.loads(text) except json.JSONDecodeError: # 最后尝试单引号替换 return json.loads(text.replace(, ))这个函数不优雅但实战中能救回相当一部分“差一点就合法”的输出。尾逗号和单引号是模型最爱犯的两个错优先处理这两个收益最高。3.3 重试策略与降级设计重试不是无脑循环。我的做法是分级重试第一次失败把校验错误反馈给模型重试第二次失败换一个更简单的 prompt 模板重试第三次还失败直接降级。降级策略要提前设计好。比如信息抽取场景降级可以返回一个所有字段为 null 的对象同时打上degraded: true标记让下游知道这条数据不可信。千万不要在降级时抛异常把整条链路搞崩也不要返回一个看起来正常但字段是瞎编的对象那比报错还危险。重试次数和超时也要设上限。我见过有系统因为模型一直返回错误格式重试了十几次单次请求耗时飙到几十秒直接把上游拖垮。一般重试 2 次、单次超时 10 到 30 秒是合理区间。3.4 监控与可观测性结构化输出的失败率必须被监控。至少要记录这几个指标首次成功率、重试后成功率、降级率、平均重试次数、各类校验错误的分布。有了这些数据你才能判断是 prompt 该优化了还是模型该换了还是 schema 设计得太复杂了。我踩过的一个坑是早期没做错误分类只知道“失败了”排查时完全抓瞎。后来把 ValidationError 按字段和错误类型打点才发现 80% 的失败集中在某一个嵌套字段上把那个字段拆平之后成功率立刻上去了。4. 常见问题与排查技巧实录4.1 高频问题速查表现象可能原因排查方向解决手段输出带 markdown 代码块提示词未禁止检查 prompt加“不要代码块” 解析层剥离字段名大小写不一致模型自由发挥对比 schema用 Function Calling 或加字段说明数字变字符串类型约束缺失校验日志Pydantic 强转 重试反馈嵌套对象层级错乱schema 过深简化 schema拆平结构或分步抽取中文乱码或转义异常编码处理检查 ensure_ascii解析时统一 utf-8偶发空返回模型截断检查 max_tokens提高上限 重试重试也不通过schema 太难分析错误分布换模型或降级4.2 几个反直觉的实操心得第一个心得schema 越简单成功率越高而且不是线性关系。字段从 5 个加到 10 个失败率可能翻三倍。所以能拆就拆一次抽 5 个字段比一次抽 15 个字段稳得多。如果业务需要 15 个字段分三次调用总成本可能比一次调用加重试还低。第二个心得字段描述description的质量直接影响准确率。age: {type: integer}和age: {type: integer, description: 人物年龄单位为岁未知填 -1}的效果差很多。模型是靠描述来理解字段语义的描述写得越具体漂移越少。第三个心得枚举值一定要用 enum 约束。比如性别字段如果你只写 string模型可能给你返回“男”“男性”“male”“M”四种写法。用enum: [男, 女, 未知]之后基本不会跑偏。第四个心得温度参数对结构化输出影响很大。做抽取任务时把 temperature 调到 0 或 0.1稳定性明显提升。创意写作才需要高温度结构化输出不需要“创造力”。4.3 关于模型选择的现实建议不是所有模型都值得为结构化输出投入同样的工程成本。我的经验是如果字段少、schema 简单7B 级别的模型配合 Function Calling 加 Pydantic 校验就够用如果 schema 复杂、嵌套深建议直接上更大参数量的模型或者用支持约束解码的本地部署方案。在结构化输出这件事上模型能力的差距比 prompt 技巧的差距大得多。另外同一个模型在不同平台上的结构化输出表现可能不一样因为推理框架对 Function Calling 的实现有差异。选型时一定要用你自己的真实 schema 做压测别只看 benchmark。5. 一套可直接抄作业的组合方案把上面的东西串起来我给一个我实际在用的组合Function Calling 负责生成robust_json_parse 负责解析Pydantic 负责校验分级重试负责修复降级返回负责兜底监控打点负责可观测。def structured_extract(text: str, schema_cls, max_retry: int 2): for attempt in range(max_retry 1): raw call_llm_with_tools(text, schema_cls) try: data robust_json_parse(raw) return schema_cls.model_validate(data) except (json.JSONDecodeError, ValidationError) as e: if attempt max_retry: log_metric(structured_output_degraded, schema_cls.__name__) return schema_cls.degraded() text f{text}\n\n上次输出错误{e}请修正。 return schema_cls.degraded()这套组合在我经手的几个抽取类项目里首次成功率能到 92% 左右加上一次重试到 98%降级率控制在 2% 以内。剩下的 2% 基本都是输入文本本身有问题比如空文本或者超长文本被截断那属于数据质量问题不是结构化输出的锅。最后分享一个我最近才想明白的点结构化输出的稳定性一半靠技术手段一半靠 schema 设计。把 schema 设计得贴合模型的理解习惯比堆砌约束手段更有效。模型不是数据库别指望它像 SQL 一样精确顺着它的脾气设计结构事情会顺很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑