
1. 聊聊结构化输出以及为什么你得重视它先按住键盘我问你一个问题当你调用一个主流大模型让它从一段产品评论里抽取“商品名称、评价星级、问题描述”三个字段时模型返回的是什么大概率是一段带了解释的自然语言比如“我觉得这款耳机音质很好但降噪一般我给四颗星”——听起来没问题可到了你的业务代码里这段内容没法直接入库没法做统计分析更没法触发后续自动化处理。你需要的是类似下面这样的东西{ product_name: 某耳机, rating: 4, issue: 降噪效果一般 }“LLM的结构化输出”这个命题正是在解决这类问题。它指的是通过一套可控的方法让大模型输出的内容严格遵循预定义的格式通常是JSON、XML或表格而不是一句随性的自然语言。说白了就是把一个“爱自由发挥的文科生”训成一个“按规范填表的质检员”。这个能力几乎是一切LLM工程应用的基石。你在做一个信息抽取、知识库问答、Agent工具调用、报表生成、业务流程自动化不管表面多花哨最终落点都是“让模型输出能被程序直接吃掉的数据”。我见过太多项目演示阶段跑得很好一上线就崩根因常常不是模型能力不够而是输出格式不稳定下游解析逻辑脆得跟纸一样。这篇文章会把这层东西拆开结构化输出的几种实现思路、怎么选型、完整的实操流程、以及那些你在文档里很难查到的坑和恢复策略。适合正在做大模型应用开发的人也适合刚入门、想搞明白“为什么我加了JSON三个字母它还是给我回一篇小作文”的开发者。2. 结构化输出的主流实现路子各有各的脾气2.1 提示词约束最省事也最靠运气最早期的做法就是你在System Prompt里拼命强调“请你只输出JSON不要包含任何其他内容”。有些模型听话有些模型不听话还有些模型在你请求里加了“必须”之后会把JSON包在一个Markdown代码块里或者在里面夹带注释。请从以下用户评论中提取信息只输出JSON格式不要输出任何其他文字。 评论耳机音质不错但降噪一般我给四颗星。这类做法不是不能用它适合两种场合第一模型够强理解力到位第二任务简单到模型出错的可能性很小。但它最大的问题在于不可控你没办法从根本上阻断模型输出非JSON内容。只要模型在生成过程中的某一步“跑偏”整段结果就报废了。由于成本最低我现在仍然会在部分一次性、非关键路径的任务里用它但凡是进入生产环境、需要反复执行的任务我不会把稳定性寄托在提示词上。2.2 Function Calling把输出框在函数签名里Function Calling有的文档叫工具调用是模型厂商提供的一种能力你在请求里声明一批函数的名称、参数描述模型在生成时会先判断该调用哪个函数然后返回一个结构化的参数对象。举个例子你要做一个天气查询工具{ name: get_weather, description: 查询指定城市的天气情况, parameters: { type: object, properties: { city: {type: string, description: 城市名称如北京、上海}, date: {type: string, description: 日期格式为YYYY-MM-DD} }, required: [city, date] } }模型输出会变成类似这样{city: 北京, date: 2025-06-01}它比纯提示词强在模型厂商在训练阶段就针对“按函数签名输出参数”做了对齐所以格式偏离的概率低得多。但要注意Function Calling本质上是给“工具调用”设计的如果你只是想稳定输出一个业务数据结构硬套函数定义会显得很别扭——你造了一个并不存在的工具只为让模型学会填表多少有点绕路。2.3 JSON Schema / 响应格式约束生产环境里最常用的方案后来主流服务商陆续支持了“响应格式限制”大多是基于JSON Schema的。你在请求里给出一份详细的Schema服务商会在解码阶段确保输出符合这个结构。比如{ type: object, properties: { summary: { type: string, description: 一句话总结评论内容 }, rating: { type: integer, minimum: 1, maximum: 5 }, issue: { type: string, enum: [音质, 降噪, 续航, 其他] } }, required: [summary, rating, issue] }如果我用一个支持这种约束的SDK调用模型最终拿到的结果大概率就是一个严格合法的JSON对象。不满足Schema条件的token会在解码过程中被拒绝这比事后解析要可靠得多。目前各家实现细节不同有的偏宽松只保证“是合法JSON”有的支持严格校验模式几乎能做到百分之百符合。选型时优先选带“strict”或“schema”模式的接口哪怕稍微慢一点换来的是下游代码省心很多。2.4 受限解码 / 语法级约束没有接口限制时的硬核路子如果没有厂商提供的结构化输出接口或者你在跑本地开源模型那就需要另一种思路在生成时直接干预采样过程把每一步可能输出的token限制在“符合语法”的集合里。这类方案的代表是Outlines、Guidance等开源库。它们会在生成前解析你的Schema或自定义语法甚至是一段模板构建一个有限状态机。模型每生成一个token库会先过滤掉那些会让结果偏离格式的候选token然后才做概率采样。import outlines schema { type: object, properties: { summary: {type: string}, rating: {type: integer, minimum: 1, maximum: 5} }, required: [summary, rating] } generator outlines.generate.json(model, schema) result generator(请分析评论音质不错降噪一般四颗星)它的原理不复杂语言模型生成到“rating”的值位置时本来有10个、20个候选token但受限解码会把范围缩小到0-9这几个数字。这样一来输出在诞生那一刻就是合法的根本不会出现“跑偏”的可能。代价是生成速度会受影响因为每次都要查询状态机、过滤token。但在本地部署场景里这个代价是值得的因为它换来的确定性极强。如果你用了大模型但一直没有解决格式崩坏的问题试试这条路效果会让你意外。3. 实操过程用结构化输出做一个稳定的信息抽取接口3.1 场景定义与方案选型我以“用户评论信息抽取”为例完整走一遍结构化输出的实现。目标是从一批用户评论中抽取出评论摘要一句话评分等级1-5的整数主要吐槽点限定了音质、降噪、续航、舒适度、其他五个选项是否推荐购买布尔值需求明确后我直接选JSON Schema方案。为什么不用Function Calling因为我不需要触发任何外部动作只是想拿到一份结构化的业务数据。为什么不用受限解码因为当前项目用的是线上服务没有直接改采样过程的入口所以走服务商提供的Schema更实际。如果你在本地环境或者用开源模型完全可以切到受限解码如果你们公司的内部平台只开放了Function Calling那就把它当成一种“虚拟函数”来用只要结果稳手段是可以迁移的。3.2 设计一份合理的JSON Schema很多人在这里容易翻车Schema写得过于简单模型即使“合规”也容易输出垃圾内容。比如你把issue字段设计成一个字符串不加枚举模型可能输出“这款耳机音质虽然不错但降噪有点拉胯”——这串内容作为枚举值完全没法映射到业务分类上。我实践下来的Schema设计原则有三条第一能用枚举限定的字段绝不放开成自由文本。自由文本在解码层面只能做类型校验做不了内容校验枚举则直接把输出空间锁死。第二字段描述写清楚。很多人觉得Schema里的description是写给人看的无所谓。但服务商的实现通常会把description作为Prompt的一部分喂给模型一句话描述写得含糊模型对字段的理解就差一截。第三required字段不要漏。如果有一个字段在大多数任务里都该出现那就把它放进required里防止模型“觉得没必要填”就直接省掉。基于这三点我把Schema定成{ type: object, properties: { summary: { type: string, description: 用不超过20个字概括这条评论的核心内容 }, rating: { type: integer, description: 用户对产品的整体评分1表示很差5表示很好, minimum: 1, maximum: 5 }, issue: { type: string, description: 用户主要提及的问题类别只能从枚举值中选择, enum: [音质, 降噪, 续航, 舒适度, 其他] }, recommend: { type: boolean, description: 根据评论情感判断用户是否推荐购买 } }, required: [summary, rating, issue, recommend] }3.3 编写Prompt与调用代码这里有一个很多教程不会讲的细节即使有了SchemaSystem Prompt也不能写得太敷衍。Schema约束的是格式Prompt约束的是语义理解两者配合才能同时解决“格式错”和“理解偏”两个问题。你是一个用户评论分析助手。请阅读用户评论提取关键信息并按照给定的JSON Schema返回结果。 注意 1. 必须严格遵循Schema不要额外添加字段。 2. summary要简洁不要复述整个评论。 3. issue只能从枚举值里选不要自创分类。 4. 如果评论与产品无关将recommend设为false并在summary中说明。 用户评论耳机戴着挺舒服续航也够用但降噪效果一般坐地铁还是能听到噪音。价格性价比还行总体满意。调用代码import json from some_llm_sdk import Client, ResponseFormat client Client(api_keyyour-key-here) response client.chat.completions.create( modelyour-model-name, messages[ {role: system, content: 你是一个用户评论分析助手。请阅读用户评论提取关键信息并按照给定的JSON Schema返回结果。}, {role: user, content: 用户评论耳机戴着挺舒服续航也够用但降噪效果一般坐地铁还是能听到噪音。价格性价比还行总体满意。} ], response_formatResponseFormat.json_schema(schemaschema) ) result json.loads(response.content) print(result)输出{ summary: 佩戴舒适续航好但降噪一般性价比尚可, rating: 4, issue: 降噪, recommend: true }整个过程里最容易被忽略的其实是response_format参数。以前我图省事只在Prompt里写“请返回JSON”然后自己在代码里做容错。后来换成显式声明Schema后格式错误率从大约30%降到了个位数这是个质的区别。3.4 后处理解析、校验、重试的铁三角哪怕结构化输出已经很可靠也不能裸奔。任何模型服务都有极低概率抽风网络抖动返回空字符串、服务端更新导致行为微变、你的Schema写得不严谨导致边界情况。所以我在调用后面永远挂着一个三层后处理def safe_parse(response_text, schema): text response_text.strip() # 第一层尝试直接解析 try: data json.loads(text) return validate(data, schema) except json.JSONDecodeError: # 第二层尝试截取代码块或大括号范围 text extract_json_block(text) data json.loads(text) return validate(data, schema) except Exception: # 第三层带着错误信息重试一次 return retry_with_error_feedback(text)第一层是理想状态第二层是常见意外兜底第三层则把错误信息拼回消息历史让模型自己“看到问题后修正”比重新空跑一次的召回率高得多。这里我多提一句重试不是无限重试设置最多2到3次就够了。生产环境中每一轮重试都是钱和时间超过阈值宁可走“人工兜底”或“默认值填充”也别让接口卡死。4. 绕不开的坑常见格式错误、恢复策略与防错设计4.1 我踩过的五种典型错误模式就算使用了结构化输出方案你还是可能遇到劣化输入。常见错误我也整理成了一张表错误类型典型表现常见触发原因恢复思路混入解释文案JSON前面多了一段“好的这是结果”Prompt约束没生效模型“话痨”模式正则定位第一个JSON起始符强行截断JSON截断输出到一半达到token上限上下文太长或Schema太大增加输出token上限或拆分任务字段名不一致要求输出rating它给你stars描述不清、跨语言混用在Schema description里明确字段含义类型错误要求integer它输出4模型从文本里提取了带引号的值标注类型说明后处理统一转类型空值滥用该输出空数组它输出nullSchema没有专门定义数组字段明确字段类型为array并在Prompt里强调“无结果时返回[]”每一种我都实际遇到过。尤其是字段名不一致这个最容易忽略很多模型内部偏好用更自然的英文单词你Schema里写的字段名如果太生僻比如prod_score它生成的JSON里就可能改名成product_score。所以Schema的字段命名要么贴近常识要么在description里反复点名。4.2 第一道防线用正则和容错解析兜底当模型没走结构化接口、或者返回格式被包装过了我一般用一个“抓取修复”的组合套路。抓取的核心思路不全文做JSON解析而是先定位最外层的大括号或中括号范围把可能的前缀、后缀杂物全部丢掉。最简单的方法是找第一个{或[然后从末尾往前找匹配的闭合符。像下面这段怪东西当然可以以下是你需要的JSON json { rating: 4, issue: 降噪 }希望对你有所帮助第一步去掉Markdown代码块标记第二步去掉外围自然语言第三步才做json.loads。顺序不能反一旦先把整段文本丢进json.loads抛出的异常反而会打断你本来可以做容错的流程。 还有一种情况是JSON里混入了尾逗号比如rating: 4,}。有些修复库比如json_repair这类工具能帮你修正如果修复效果不稳定更稳的做法是截取合法前缀重新解析。 ### 4.3 第二道防线带错误信息的重试机制 重试不是简单重跑一遍。瞎重试等于花同样的成本再赌一次运气。轻量做法是把上一次的错误信息拼到下一次请求里 python messages [ {role: system, content: system_prompt}, {role: user, content: origin_input} ] if last_error: messages.append({ role: user, content: f你上次返回的内容存在以下问题{last_error}。请重新生成确保完全符合给定的JSON格式。 })这样做的效果比重新空跑高很多因为模型拿到了“哪里错了”的反馈会主动修正。实测下来这种带错误反馈的重试一次成功率达到90%以上。在Agent场景里这个方法经常配合日志回放让模型“看到环境反馈再行动”本质是同一招。4.4 第三道防线业务侧的兜底与降级不管前面的机制多完美总会有极端情况。生产系统必须做好“模型输出不可用时业务不至于挂”的设计。我的做法是分三层降级第一层默认值填充。比如rating解析失败时默认给0并在日志里标记为异常数据。第二层人工审核队列。凡是结构化解析失败的原始文本全部进一个“待人工处理”的列表宁可让人看一眼也不要让脏数据入库。第三层缓存和兜底答案。同一个输入重复多次失败直接返回一个事先写好的静态结果避免每次都烧成本。这三个习惯听着不复杂但绝大多数线上事故都出在“没做兜底”这一个小细节上。结构化输出只能降低概率业务设计才能控制风险。5. 关于Schema设计与调参我的几条实践心得5.1 温度别调太高但也不建议调到0很多人一听“结构化输出不稳定”第一反应是把temperature调到0。方向没错但不绝对。过低的温度会让模型输出变得机械在字段语义理解上反而可能变得死板尤其是涉及主观判断的字段比如“推荐是否购买”。我一般把temperature设在0.1到0.3之间。这个区间既能把随机性压住又保留了模型对语义的判断余量。还有一点如果服务支持seed参数配合固定seed可以进一步提升可复现性排障的时候更舒服。5.2 Schema字段数量不是越多越好我见过一个同事为了让模型输出尽量信息丰富一口气定义了四十多个字段。结果就是模型经常把不相关的内容填进某些字段或者为凑满字段开始“编”。结构化输出的复杂度与稳定性是反比关系字段越多约束越多模型犯错的概率也越高。实用做法是把必需字段控制在十个以内。太复杂的提取需求拆成多个小任务调用每个任务只负责一小块。有人会觉得这样调用次数多、成本高但你把重试成本加进去算一笔账反而更划算。5.3 把“格式约束”和“语义质量”分开治理这句话值得刻在电脑边上结构化输出解决的是“形状对不对”不等于“内容对不对”。你拿到的JSON完全合规但summary可能写得很差rating可能理解错了。所以校验逻辑要分两层格式层用Schema语义层用业务规则甚至传统模型评分。比如recommend字段是布尔值但模型基于一条负面评论给出true这时候格式毫无问题语义已经错了。这类问题不能靠加Schema求解只能通过few-shot示例、更多上下文控制、以及针对性的后置校验来解决。我在生产里常用一个轻量做法在Schema之外再加一个断言列表针对业务规则做硬性校验不满足就归入“需要重试”的类别。5.4 留意输出token上限与Schema长度的关系大模型输出token有上限Schema本身也会占用一定的上下文和输出预算。当Schema很大、说明很详细时留给实际内容的token反而变少。尤其在做长文本抽取时摘要字段还没填完就截断了。解决方式要么压缩description长度要么最大化输出预算要么把任务拆得更细。我的经验是Schema里的description尽量精简到一两句话太长的描述不仅浪费token还可能影响模型注意力的分配。它看一段又臭又长的字段说明时理解效果反而更差。做了这些年LLM应用我的体感是结构化输出是少数“前期稍微多花点心思后期能省掉一大堆麻烦”的环节。它不性感不像多轮对话和Agent那么抓眼球但绝大多数经得起生产考验的LLM项目都在这个不起眼的环节上下了笨功夫。下次你的项目再遇到模型输出不稳定先别急着换更大参数的模型回到你这边的格式约束和Schema上看看很多答案就藏在那里。