结构化输出问答器实战:Schema先行与校验重试打造可靠交付
发布时间:2026/10/12 2:47:44 锦皓数字建站

1. 从“能聊天”到“能交付”结构化输出问答器到底在解决什么问题做Agent方向有一段时间的朋友大概都有同感让模型聊起来不难难的是让它“按格式交货”。你问它一句它洋洋洒洒回你三段话看着挺聪明可你要把这结果塞进下游系统——写数据库、调接口、渲染前端表格——立刻就抓瞎了。这就是结构化输出问答器要解决的核心痛点把大模型从“会说话的文科生”改造成“能填表的理科生”。我这次做的这个实践项目目标很明确用户用自然语言提问系统返回的不是一段散文而是一个严格符合预定义Schema的结构化对象比如JSON。这个对象可以直接被程序消费不需要再做正则清洗、字符串切割这些脏活。它适合谁参考三类人一是正在做Agent工具调用、需要模型稳定吐参数的开发者二是做数据抽取、表单自动填充、报表生成这类业务的工程师三是想搞明白“约束解码”“Schema校验”这些概念到底怎么落地的人。为什么标题里强调“问答器”而不是“抽取器”因为问答场景比纯抽取更复杂。抽取是给定文本找字段输入输出都是确定的而问答是开放式的用户可能问“帮我对比一下这三个方案的优缺点”模型得先理解意图再决定输出什么结构。这就逼着我们在提示词设计、Schema定义、校验重试三个环节上都得下功夫。下面我按实际搭建的顺序把整套思路和踩过的坑完整拆一遍。2. 整体架构设计为什么我选择“Schema先行 校验兜底”这条路2.1 三种主流方案的取舍逻辑在动手之前我先梳理了业界常见的三种实现路径这里直接上对比表方便你按自己的场景选方案核心做法优点缺点适用场景纯提示词约束在Prompt里写“请输出JSON字段为...”实现最快零依赖模型经常加解释、漏字段、格式漂移原型验证、对稳定性要求低函数调用/工具模式用模型原生的function calling能力格式由平台保证较稳受平台限制复杂嵌套Schema支持参差参数提取、工具调用Schema先行校验重试先定义Schema生成后本地校验失败则带错误重试可控性最强跨模型通用需要写校验和重试逻辑生产环境、多模型切换我最终选了第三条路理由很实在我不想被某一家平台的function calling绑死。项目里可能要换模型今天用A明天用B如果格式保证依赖平台特性迁移成本就高了。而“Schema先行”这套逻辑是模型无关的——不管背后是谁我都用同一套Schema去约束、同一套校验器去检查、同一套重试策略去补救。多写的那点校验代码换来的是架构上的自由这笔账很划算。2.2 数据流的四个关键节点整个问答器的数据流我拆成四段每一段都有明确的职责边界意图理解与Schema选择用户问题进来先判断它属于哪类问答对比类、列表类、单值类然后加载对应的Schema。这一步很关键因为不同问题类型对应的结构完全不同。提示词组装把Schema转成模型能读懂的描述连同用户问题、少量示例一起塞进Prompt。模型生成与解析拿到原始输出先做一次宽松解析去掉可能的markdown代码块包裹再尝试JSON解析。校验与重试用Schema校验器逐字段检查不通过就把错误信息回填给模型让它修正后重来最多重试N次。提示这四个节点里最容易被忽视的是第1步。很多人上来就把所有Schema一股脑塞给模型结果模型选错结构。我的经验是Schema选择这一步最好用规则或轻量分类先做掉别全丢给模型。2.3 为什么重试机制是刚需而不是可选项我实测下来即使提示词写得再好模型首次输出的合规率大概在70%到85%之间浮动取决于模型能力和Schema复杂度。也就是说每五次问答就有一次左右会翻车。如果没有重试机制这个系统根本没法上生产。重试的价值不只是“补救”它还能把错误信息作为反馈信号让模型在第二轮里针对性修正——这比单纯重新生成一次的成功率高得多。我后面会详细讲重试提示词怎么写。3. Schema设计结构化输出的地基怎么打才不塌3.1 字段类型与嵌套层级的控制原则Schema设计是整个项目里最考验功力的地方。我的核心原则是能扁平就扁平能少一层就少一层。原因很简单模型对嵌套结构的遵循能力会随着层级加深而明显下降。我做过一个对比测试同样是五个字段扁平结构的首次合规率比三层嵌套高出将近20个百分点。具体到字段类型有几个经验值字符串最安全但要注意长度约束太长的字段模型容易截断或编造。枚举强烈推荐。把可能的取值列出来模型的选择准确率会大幅提升比让它自由发挥强太多。数字要明确是整数还是浮点是否允许负数范围是多少。不写清楚模型可能给你返回字符串形式的数字。布尔简单场景好用但别用它表达三态以上的逻辑。数组数组元素类型要明确且最好限制长度上限否则模型可能返回空数组或超长数组。3.2 一个真实可用的Schema示例下面是我在“方案对比问答”场景里用的Schema用JSON Schema风格描述你可以直接拿去改{ type: object, properties: { question_type: { type: string, enum: [comparison, listing, single_fact] }, summary: { type: string, maxLength: 200 }, items: { type: array, minItems: 1, maxItems: 5, items: { type: object, properties: { name: { type: string }, pros: { type: array, items: { type: string } }, cons: { type: array, items: { type: string } }, score: { type: number, minimum: 0, maximum: 10 } }, required: [name, pros, cons, score] } } }, required: [question_type, summary, items] }这个Schema里我特意加了minItems和maxItems因为不加的话模型有时候会返回一个空数组或者一口气列十几个条目下游渲染直接崩。score字段限定0到10避免模型给出“8.5分满分100”这种自相矛盾的结果。3.3 必填与选填的边界怎么划必填字段required的划定有个反直觉的经验必填字段越少整体合规率越高。因为每多一个必填项模型就多一个可能遗漏的点。我的做法是只把下游系统真正依赖的字段设为必填其余全部选填。比如上面那个Schema如果summary只是给人看的下游不消费那它完全可以选填模型不填也不影响流程。注意选填字段在提示词里也要说明“如果信息不足可以省略”否则模型会为了凑字段而编造内容。编造比遗漏更危险因为它会污染数据。3.4 Schema版本管理的小技巧项目迭代过程中Schema一定会变。我的做法是给每个Schema加一个version字段并在代码里维护一个Schema注册表。这样当模型输出里带了旧版本号或者校验失败时我能快速定位是不是Schema变更导致的。这个习惯在多人协作时尤其重要避免“我改了Schema没通知你”这类扯皮。4. 提示词工程让模型乖乖按格式交货的实战写法4.1 提示词的三段式结构我用的提示词模板固定为三段角色与任务说明 Schema描述 输出规则。这个顺序不是随便排的先让模型进入角色再给它结构约束最后强调格式纪律符合模型逐层理解的习惯。角色部分我写得很克制就一句话“你是一个结构化信息提取助手只输出符合要求的JSON。”不写“你是一个专业的、经验丰富的、乐于助人的...”这种废话因为角色描述越长模型越容易在输出里加入“好的我来帮你分析”这类寒暄。Schema描述部分我不直接贴JSON Schema原文而是转成更口语化的字段说明。实测下来模型对自然语言字段说明的遵循度比纯JSON Schema更高。比如我会写“items是一个数组每个元素包含name字符串、pros字符串数组、cons字符串数组、score0到10的数字”。4.2 输出规则的六条铁律输出规则这块我总结了六条每次都会写进提示词只输出JSON不要有任何解释性文字。不要用markdown代码块包裹。所有字符串用双引号。数字不要加引号。如果某个选填字段没有信息直接省略该字段不要填null。输出前自己检查一遍字段是否齐全。第6条看起来有点玄学但实测有效。让模型“自检”这个动作能显著降低遗漏率。我猜是因为它触发了模型内部的二次确认机制。4.3 少样本示例怎么放才有效示例few-shot是提升合规率的利器但放法有讲究。我的经验是放两个示例一个简单一个复杂。简单示例让模型看到基本格式复杂示例展示嵌套和数组怎么处理。示例不要超过三个否则会挤占上下文还可能让模型过度模仿示例内容而非结构。另外示例里的字段值要用明显无关的占位内容比如“示例名称A”“示例优点1”避免模型把示例内容当成答案的一部分抄进正式输出。4.4 重试提示词的写法当首次校验失败重试提示词要包含三样东西原始问题 上次的错误输出 具体的校验错误信息。错误信息要精确到字段比如“items[0].score 的值是字符串8要求是数字类型”。这样模型才知道改哪里。我试过只写“格式不对请重试”成功率提升非常有限写清楚具体错误后第二轮修正成功率能到90%以上。5. 校验与重试把不稳定的输出变成可靠交付5.1 解析阶段的容错处理模型输出拿到手第一步不是直接JSON.parse而是先做清洗。常见的脏数据有三种被markdown代码块包裹、前后有多余文字、用了单引号。我的清洗流程是import json import re def clean_and_parse(raw_output): # 去掉markdown代码块标记 text re.sub(rjson\s*|\s*, , raw_output) # 尝试直接解析 try: return json.loads(text), None except json.JSONDecodeError as e: # 尝试提取第一个完整的JSON对象 match re.search(r\{.*\}, text, re.DOTALL) if match: try: return json.loads(match.group()), None except json.JSONDecodeError as e2: return None, str(e2) return None, str(e)这段代码的关键是两级解析先整体试失败再尝试提取花括号内容。实测能救回不少“模型多说了两句话”的情况。5.2 校验器的分层设计校验我分三层做从轻到重第一层语法校验。就是上面的JSON解析确保是合法JSON。第二层结构校验。检查必填字段是否存在、类型是否正确、数组长度是否在范围内。这层我用JSON Schema校验库来做省得手写一堆if-else。第三层业务校验。这层是自定义的比如检查score字段是否在合理区间、枚举值是否在允许列表内。业务规则因项目而异必须单独写。三层分开的好处是错误信息清晰。语法错误就报语法错误别混着结构错误一起说否则模型也懵。5.3 重试策略的参数选择重试次数我设成3次这是权衡后的结果。设1次太少很多本可以救回来的case直接失败设5次以上边际收益急剧下降而且延迟和成本上去了。3次基本能覆盖95%以上的可修复错误。重试时我会稍微调高温度参数比如从0.1调到0.3给模型一点“换个思路”的空间。如果一直用极低温度模型可能反复犯同样的错。提示重试要有终止条件。如果连续两次错误信息完全一样说明模型卡住了这时候直接放弃并返回错误别浪费token。5.4 失败兜底与降级方案再好的重试也有失败的时候。我的兜底方案是返回一个最小可用结构把能提取到的字段填上缺失字段标记为“未获取”。这样下游系统至少不会因为空响应而崩溃。同时记录失败日志方便后续分析是Schema设计问题还是模型能力问题。6. 常见问题与排查技巧实录6.1 模型总爱加解释性文字怎么办这是最高频的问题。模型输出前面带一句“好的根据您的问题我整理如下”后面跟JSON。解决办法有三一是提示词里明确禁止二是用清洗逻辑把JSON之前的内容切掉三是用更强的模型。我一般三管齐下。如果还不行可以在提示词末尾加一句“你的输出将被程序直接解析任何非JSON字符都会导致解析失败”用“后果”来强化约束。6.2 字段类型漂移的排查思路类型漂移指的是模型把数字写成字符串、把单值写成数组。排查时先看Schema描述是否清晰再看示例是否展示了正确类型。我遇到过一次Schema里score写的是number但示例里我手滑写成了字符串结果模型全跟着示例走。所以示例和Schema必须严格一致这是血泪教训。6.3 嵌套数组返回空的问题模型对嵌套数组的处理能力较弱经常返回空数组。我的对策是在提示词里明确“如果信息充足items至少包含2个元素”并在Schema里设minItems。另外把嵌套层级减少也能缓解比如把三层嵌套压成两层。6.4 常见问题速查表问题现象可能原因解决方向输出带解释文字提示词约束不够加禁止条款清洗逻辑字段遗漏必填项太多或描述不清减少必填强化字段说明类型错误Schema与示例不一致核对两者统一类型数组为空嵌套过深或未设下限减少层级设minItems重试仍失败错误信息不具体精确到字段和错误类型输出被截断字段过长或maxTokens不足限制字段长度调大token上限6.5 几个不写在文档里的实操心得第一个心得先跑通再优化。别一上来就设计完美Schema先用最简结构跑通全流程再逐步加字段和约束。我见过有人花两天设计Schema结果发现模型根本不支持那么复杂的嵌套白费功夫。第二个心得日志要记原始输出。校验失败时一定要把模型的原始输出完整记下来别只记错误信息。很多时候问题出在你看不到的地方比如模型在JSON后面加了个句号。第三个心得不同模型要重新调参。同一套提示词和Schema换个模型可能合规率差很多。别指望一次调好到处用换模型后至少跑一轮回归测试。7. 性能与成本结构化输出的隐性开销7.1 重试带来的成本放大结构化输出的成本不只是单次调用。假设首次合规率80%重试3次那么平均每个问题的调用次数是1 0.2 0.04 0.008 ≈ 1.25次。看起来不多但如果你的业务量是每天十万次这就是两万五千次额外调用。所以提升首次合规率就是省钱。把Schema简化、提示词优化做到位比事后重试划算得多。7.2 延迟的构成与优化一次问答的延迟由三部分组成模型生成时间、校验时间、重试时间。模型生成是大头校验基本可以忽略。优化延迟的关键是减少重试。我的做法是把校验放在流式输出的末尾一旦发现格式错误立即中断不用等完整输出。这样能省下不少时间。7.3 缓存策略的引入对于高频重复的问题我加了一层结果缓存。相同问题相同Schema版本直接返回缓存结果不走模型。这个策略在FAQ类场景里效果显著能砍掉大量重复调用。缓存key要包含Schema版本号否则Schema更新后旧缓存会污染结果。8. 可扩展方向从问答器到通用结构化引擎这套东西跑通之后我发现它的适用面比预想的广。把Schema换成“工单分类结构”它就是个自动分诊器换成“简历解析结构”它就是个简历抽取器换成“会议纪要结构”它就能把一段对话整理成待办列表。核心逻辑没变变的只是Schema和提示词。我目前在做的一个扩展是多Schema路由用户问题进来先用一个轻量分类器判断意图再动态加载对应Schema。这样一套系统能同时服务多种问答类型不用为每个场景单独部署。另一个方向是Schema自动生成给一批标注好的输入输出样本让模型反推出Schema减少手工设计的工作量。这个还在试验阶段等跑稳了再单独写一篇。最后分享一个我在调试时常用的小技巧把Schema校验失败的case单独存一个文件每周review一次看看有没有共性。我靠这个习惯发现了三个Schema设计缺陷都是靠单次调试发现不了的。结构化输出这件事本质上是在模型的不确定性和程序的确定性之间架桥桥墩打得越扎实上面跑的业务就越稳。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。