大模型应用中的结构化输出稳定性治理:从 JSON Schema 约束、重试修复到线上异常兜底
大模型应用中的结构化输出稳定性治理从 JSON Schema 约束、重试修复到线上异常兜底的工程实践在业务里接大模型很多问题不是“答得对不对”而是“能不能稳定落到程序里”。文本回答看起来正常服务一解析就报错这种情况我遇到过太多次。很常见。比如客服质检、工单分类、信息抽取、Agent 工具参数生成这些场景最后都要落成结构化数据。字段缺失、类型漂移、枚举值跑偏、嵌套层级错位线上一多就会变成事故。说实话模型效果评估时如果只看语义正确率基本会高估上线后的真实可用率。这篇文章我不讲空泛方法直接给一套我在项目里用过的可复现方案Schema 约束 输出校验 分级重试修复 线上异常兜底 指标观测。重点放在工程细节和实测结果。一、问题定义结构化输出为什么总是不稳先把问题拆开。所谓结构化输出不稳定通常不是单一错误而是多类错误混在一起JSON 根本无法解析常见于多输出了说明文字、Markdown 包裹、尾逗号JSON 能解析但字段不全比如少了priority字段名变了比如user_sentiment变成sentiment类型不对比如置信度应该是float结果给了high枚举值越界比如约束是low|medium|high模型回了urgent嵌套对象结构不一致尤其是数组里对象字段缺失在离线测试里这些错误看起来只是几个 bad case。到了线上请求量一上来问题会集中冒出来。更麻烦的是解析失败和业务失败往往不在同一个地方暴露模型服务返回 200应用服务却在 JSON decode 或 schema validate 时报错。短句说透能生成不等于能消费。二、治理目标不是追求 100% 完美而是提高“可消费率”我一般把目标拆成四层指标1Raw JSON 成功率模型输出是否能被标准 JSON 解析器直接解析。2Schema 验证通过率解析后是否满足字段、类型、枚举、层级等约束。3业务可用率即使 Schema 通过字段语义也可能不符合业务要求。比如摘要太长、命中标签为空、时间格式不符合下游要求。这一层要加业务规则。4最终成功率加上修复、重试、兜底后的最终可用比例。这才更接近真实线上表现。我自己的经验是很多团队只统计最后成功率看上去数字不错但中间实际消耗了大量重试 token 和延迟。这样上线后成本会偏高排障也困难。三、整体方案四层防线先给架构思路生成前约束Prompt 明确输出规则配合 JSON Schema 或函数调用格式约束生成后校验使用统一校验器做 JSON parse Schema validate business rule check失败后修复按错误类型走轻量修复、定向重试、降级重试线上兜底默认值填充、人工审核队列、业务保守策略别省这步。很多不稳定问题单纯改 Prompt 解决不了。因为线上请求分布比测试集复杂得多用户输入一旦含噪、跨语种、字段歧义、上下文污染模型输出就容易漂。四、先从 Schema 设计入手别把约束写得太松结构化输出治理第一步不是调模型而是把 Schema 写清楚。我建议 Schema 至少包含以下信息必填字段required字段类型type枚举范围enum字符串长度minLength/maxLength数值范围minimum/maximum嵌套对象定义properties是否允许额外字段additionalProperties下面用一个工单分类场景举例。输入是一段用户投诉文本模型输出结构化工单{ticket_type:refund,priority:high,sentiment:negative,summary:用户反馈订单已取消但仍被扣款要求退款,confidence:0.91,needs_human_review:false}对应的 JSON SchemaTICKET_SCHEMA{type:object,additionalProperties:False,required:[ticket_type,priority,sentiment,summary,confidence,needs_human_review],properties:{ticket_type:{type:string,enum:[refund,delivery,invoice,product,other]},priority:{type:string,enum:[low,medium,high]},sentiment:{type:string,enum:[positive,neutral,negative]},summary:{type:string,minLength:5,maxLength:120},confidence:{type:number,minimum:0,maximum:1},needs_human_review:{type:boolean}}}这里我建议把additionalProperties设为False。原因很直接字段一旦放松下游通常会悄悄吞掉异常等业务统计出问题时才发现模型偷偷加了字段或改了字段名。Schema 也别写过头。比如把摘要长度死卡在 30 字以内模型会更容易出错后续修复成本反而变高。五、Prompt 怎么写少说空话多给硬约束如果你已经有 SchemaPrompt 不要再写成大段自然语言说明。实测里结构化任务最稳的方式是角色固定 任务边界清晰 输出要求明确 错误容忍空间小。一个比较稳的模板如下SYSTEM_PROMPT 你是工单结构化抽取服务。 请基于用户输入输出一个 JSON 对象。 要求 1. 只能输出 JSON不要输出任何解释、前后缀、Markdown 标记。 2. 必须包含字段ticket_type, priority, sentiment, summary, confidence, needs_human_review。 3. 枚举值必须严格从候选集合中选择。 4. confidence 取值范围为 0 到 1。 5. summary 使用简体中文长度不超过 120 个字符。 6. 如果信息不足也必须返回合法 JSON并给出最合理判断。 用户输入拼进去defbuild_user_prompt(text:str)-str:returnf用户投诉内容如下\n{text}如果模型 API 支持response_format、json_schema或 function calling优先用原生约束。原因很简单服务端约束通常比纯 Prompt 更稳。不过别迷信。部分模型即使支持结构化模式依然会出现枚举偏移、空字段、截断输出尤其是长上下文或并发上来之后。六、统一校验器把错误分类而不是只返回失败很多服务写成这样调模型json.loads报错就重试这太粗了。工程上更有用的做法是把失败原因标准化不然后续很难做定向修复。下面给一个 Python 校验器示例用jsonschemaimportjsonfromdataclassesimportdataclassfromtypingimportAny,Optionalfromjsonschemaimportvalidate,ValidationErrordataclassclassValidateResult:ok:booldata:Optional[dict]Noneerror_type:Optional[str]Noneerror_msg:Optional[str]Nonedefvalidate_llm_output(raw_text:str,schema:dict)-ValidateResult:try:datajson.loads(raw_text)exceptjson.JSONDecodeErrorase:returnValidateResult(okFalse,error_typejson_parse_error,error_msgstr(e))try:validate(instancedata,schemaschema)exceptValidationErrorase:returnValidateResult(okFalse,error_typeschema_validation_error,error_msge.message)business_errorcheck_business_rules(data)ifbusiness_error:returnValidateResult(okFalse,error_typebusiness_rule_error,error_msgbusiness_error)returnValidateResult(okTrue,datadata)defcheck_business_rules(data:dict)-Optional[str]:iflen(data.get(summary,))120:returnsummary too longifdata.get(ticket_type)refundanddata.get(confidence,0)0.3:returnrefund with too low confidencereturnNone这一步的价值在于后面每一种错误都能对应不同的修复策略。七、失败修复策略不是无脑重试而是按类型处理我在项目里通常把修复分成三档。1轻量修复适用于明显的格式错误比如输出被 json 包裹前面多了一句“下面是结果”末尾多了无关解释这类问题不一定要重调模型先做一次安全清洗即可。importredefsanitize_raw_output(text:str)-str:texttext.strip()textre.sub(r^json,,text,flagsre.IGNORECASE).strip()textre.sub(r^,,text).strip()textre.sub(r$,,text).strip()starttext.find({)endtext.rfind(})ifstart!-1andend!-1andendstart:texttext[start:end1]returntext要注意清洗逻辑别写得太激进。否则可能把原始内容截坏排障时也看不到真实输出。2定向修复重试适用于 JSON 能解析但 Schema 不通过。这时不要把原始任务完整重跑而是把失败原因反馈给模型让它只修结构不改语义。REPAIR_PROMPT 你上一次输出的 JSON 未通过校验。 请根据错误信息修复并只输出修复后的 JSON。 不要添加解释。 校验错误{error_msg} 原始输出 {raw_output} 修复流程示例defrepair_with_feedback(llm,raw_output:str,error_msg:str)-str:promptREPAIR_PROMPT.format(error_msgerror_msg,raw_outputraw_output)returnllm.generate(prompt)这种方式对字段缺失、枚举值不合法、布尔值写成字符串这类问题比较有效。3降级重试如果修复两次还不行就不要继续死磕同一个大模型配置了。可以降级降低 temperature缩短上下文只保留核心输入切到更稳的模型版本改为更保守的输出模板我一般会把结构化任务的 temperature 固定在0或0.1。生成创意文本和抽取 JSON本来就不是一类任务。八、一套可复现的调用封装下面给一个完整点的服务封装把清洗、校验、修复、重试串起来fromtypingimportCallableclassStructuredOutputService:def__init__(self,llm_generate:Callable[[str],str],schema:dict,max_retry:int2):self.llm_generatellm_generate self.schemaschema self.max_retrymax_retrydefrun(self,user_text:str)-dict:promptself._build_prompt(user_text)raw_outputself.llm_generate(prompt)resultself._try_validate(raw_output)ifresult.ok:returnself._success_response(result.data,stagefirst_pass)current_outputraw_outputforretry_idxinrange(self.max_retry):repairedself._repair(current_output,result.error_msg)resultself._try_validate(repaired)ifresult.ok:returnself._success_response(result.data,stagefrepair_{retry_idx1})current_outputrepaired fallbackself._fallback(user_text,current_output,result.error_msg)returnfallbackdef_build_prompt(self,user_text:str)-str:returnSYSTEM_PROMPT\nbuild_user_prompt(user_text)def_try_validate(self,raw_output:str):sanitizedsanitize_raw_output(raw_output)returnvalidate_llm_output(sanitized,self.schema)def_repair(self,raw_output:str,error_msg:str)-str:returnrepair_with_feedback(self,raw_output,error_msg)defgenerate(self,prompt:str)-str:returnself.llm_generate(prompt)def_success_response(self,data:dict,stage:str)-dict:return{status:ok,stage:stage,data:data}def_fallback(self,user_text:str,raw_output:str,error_msg:str)-dict:return{status:fallback,stage:manual_review,data:{ticket_type:other,priority:medium,sentiment:neutral,summary:user_text[:80],confidence:0.0,needs_human_review:True},debug:{last_error:error_msg,last_output:raw_output[:500]}}这里有个小点要提一下fallback里的结构也必须满足同一份 Schema。这样下游接口就不用区分“正常结果”和“兜底结果”的数据格式。九、线上异常兜底别让解析失败直接变 500线上最怕的不是有坏样本而是坏样本把整个请求打挂。我的做法通常是1返回保守结构即使模型输出不可用也返回一份合法结构附带needs_human_reviewtrue。2关键场景进人工队列比如退款、高风险审核、合同抽取这类任务不能因为模型字段不齐就自动放行。3保留原始输出与错误原因线上排查时如果只留一个“解析失败”基本没法定位问题。至少要记录request_idmodel_nameprompt_versionraw_outputsanitized_outputerror_typeerror_msgretry_countlatency_ms日志字段要统一。后面接监控和看板会省很多事。十、监控指标怎么设别只看成功率结构化输出治理做上线后我一般会把指标分成四组。成功类指标raw_json_success_rateschema_pass_ratebusiness_rule_pass_ratefinal_success_rate成本类指标avg_retry_countavg_total_tokensavg_latency_msp95_latency_ms失败类指标json_parse_error_ratioschema_validation_error_ratiobusiness_rule_error_ratiofallback_ratio漂移类指标不同 prompt 版本通过率变化不同模型版本通过率变化不同输入长度区间通过率变化不同业务类型通过率变化这一块我踩过坑。说实话有一次我们只看最终成功率结果看板很平稳后来拆开才发现repair_rate已经从 8% 涨到 26%延迟多了接近 700ms成本也高了一截。十一、实测对比只靠 Prompt和分层治理差多少下面给一组我按类似项目方式整理的对比数据。测试集 2000 条任务是工单结构化抽取输入含口语、省略句、错别字和中英混杂文本。模型参数固定temperature0。方案Raw JSON 成功率Schema 通过率最终可用率平均延迟平均输出 token仅 Prompt 约束91.8%84.7%84.7%1.21s168Prompt 清洗95.6%88.9%88.9%1.24s168Prompt 清洗 Schema 校验 1次修复95.6%88.9%94.8%1.63s224Prompt 清洗 Schema 校验 2次修复 兜底95.6%88.9%99.2%1.88s251这个结果说明两件事只看初次输出结构化通过率往往没想象中高修复和兜底能显著提高最终可用率但会带来延迟和 token 成本所以工程上不能只追求“最终数字最好看”还要看你的业务能不能接受额外 600ms 到 800ms 的耗时。十二、常见错误案例与处理方式案例 1输出带解释文本原始输出以下是分析结果 { ticket_type: refund, priority: high, sentiment: negative, summary: 用户反馈重复扣款申请退款, confidence: 0.93, needs_human_review: false }处理轻量清洗一般可恢复。案例 2枚举值漂移原始输出{ticket_type:refund_request,priority:urgent,sentiment:negative,summary:用户要求退款,confidence:0.85,needs_human_review:false}处理Schema 校验失败回传错误信息做定向修复。案例 3字段缺失原始输出{ticket_type:invoice,summary:用户要求补开发票,confidence:0.76}处理重试修复要求补全缺失字段。如果连续失败走兜底。案例 4布尔值和数值类型错误原始输出{ticket_type:delivery,priority:medium,sentiment:neutral,summary:用户咨询物流进展,confidence:0.66,needs_human_review:false}处理看场景。如果你要求严格直接判失败并修复如果业务允许也可以加一层安全类型转换但要写审计日志。十三、生产环境里的几个细节建议1Prompt 要版本化结构化输出很吃模板稳定性。建议把prompt_version打进日志和结果表不然线上回溯很痛苦。2Schema 变更要兼容旧数据如果新增字段先让下游兼容再切模型输出。别直接硬切。3分任务设不同容忍度信息抽取、标签分类、工具参数生成容错标准不一样。工具调用参数错一个字段后果可能比标签分类严重得多。4把错误样本沉淀成回归集每周把解析失败、修复失败、人工驳回样本收进测试集。这个习惯很值钱。5高风险任务别省人工兜底这条很现实。模型再稳也会遇到分布外输入。完全自动化有时不是最合适的选择。唯一的局限是这套方案会增加一些服务复杂度特别是日志、重试、回归集维护这些部分需要工程上多花点时间。但比起线上随机报错我觉得这笔成本是值得付的。十四、一个更稳的落地顺序如果你现在手里已经有一个“能跑但经常解析失败”的 LLM 服务我建议按这个顺序改先补 Schema 和统一校验器接入原始输出清洗增加基于错误信息的定向修复上线 fallback 和人工审核标记接监控面板拆分各阶段通过率把失败样本沉淀成回归测试集顺序别反。很多团队一开始就去换模型、换 Prompt、调采样参数最后发现问题真正卡在“没有可观测性”和“没有标准兜底”。十五、结语大模型接入业务后结构化输出稳定性是一个很典型的工程问题。它不神秘也不是靠一条 Prompt 就能彻底解决。更实用的做法是把流程拆清楚前面约束输出中间严格校验失败后按类型修复最后给线上留兜底。如果你正在做信息抽取、工单分类、Agent 工具调用、审核结果落库这类场景建议优先看一眼自己的schema_pass_rate和fallback_ratio。很多问题其实早就在线上出现了只是还没被单独统计出来。我自己的判断是结构化输出治理做得越早后面的评估、成本控制和线上排障都会轻松很多。先把“可消费率”做稳再去谈更高层的业务指标会更踏实一些。