你以为你在“接入大模型”其实你在“接入一个不稳定的文本生成器”。一旦你的业务链路里出现了JSON.parse(模型输出)—— 你就已经把事故种子埋进了生产。我写这篇不是复述文档文档告诉你“能用”不会告诉你“会炸”。过去几个月我在做一个典型的 AI 工程系统上游是业务请求中间是 LLM 推理和若干工具调用下游是严格依赖结构化数据的工作流风控、工单、检索、路由、计费、监控。我们一开始也很朴素让模型“返回 JSON”。效果在测试环境很好上线之后开始出现一种非常阴险的故障99.9% 的请求都 OK0.1% 的请求随机失败带引号的名字、超长文本、用户复制了一段代码、模型被安全提示打断……失败之后触发重试重试又失败 → 触发更大范围重试队列堆积、线程打满、下游数据落库出现脏字段这不是模型“变笨了”而是你在用“文本协议”承载“数据协议”。下面我用工程师视角把这事拆开JSON mode / tool calling / structured output 到底差在哪生产里会踩的 6 个坑以及怎么把坑变成指标给你两套可直接复用的实现TypeScript(Zod) / Python(Pydantic)文章比较长5000字但我保证不是堆概念是能直接搬进仓库的那种。1. 三种“结构化输出”到底差在哪别混为一谈很多团队把下面三件事统称“结构化输出”然后就开始争论“到底选哪个”。其实这三种完全不是一类东西1.1 Prompt 约定 JSON最便宜也最不可靠典型写法system你是一个提取器user请返回 JSON字段有 a/b/c优点便宜实现简单兼容任何模型缺点没有协议保证它“尽量”输出 JSON不是“必须”输出 JSON容易出现前后缀\njson\n{…}\n\n解释一段\n遇到边界字符引号、换行、unicode更容易破坏格式我对这种方式的定位很明确只能用于“失败代价极低”的场景。1.2 Tool Calling / Function Calling像 RPC但不是强约束很多平台支持把“输出 JSON”包装成一次函数调用你声明一个 tool schema模型返回一个 tool call携带参数你的程序拿参数当作结构化数据这比纯 prompt 好因为模型更愿意“遵守工具接口”你可以在“工具层”做校验和默认值但工程上要清醒这仍然可能出现字段缺失、值不在范围、enum 乱填多轮对话时模型可能在工具调用前后夹杂自然语言模型可能调用了“你不希望它调用”的工具需要 allowlist它的最佳用途是需要动作编排调用 API、查库、下单而不是纯结构化抽取。1.3 真正的 Structured OutputSchema-Constrained Decoding这类能力的核心不是“提示词更强”而是约束解码你给 JSON Schema或等价的结构定义解码器在每个 token 步骤把“不可能组成合法 JSON 的 token”直接屏蔽结果是输出在语法上满足 schema结构正确、类型正确这类方案把“结构正确性”从应用层regex/parse/retry下沉到推理层是质变。但别误会它不保证语义正确字段里填的内容仍可能胡说它对 schema 设计非常敏感太深、太宽都会让质量下降结论如果下游强依赖结构structured output 是生产默认选项。2. 生产级 Structured Output 的 6 个坑我踩过的那种坑 1流式输出被中断截断 JSON 不是“坏运气”是必然事件很多同学喜欢 streaming因为用户体验好、能做渐进渲染。但对于 structured outputstreaming 有一个天然矛盾structured output 想要一个“完整闭合的 JSON”streaming 可能随时因超时、取消、网络抖动、上游限流而中断于是你会拿到{items:[{id:1,name:a},这时候你如果做JSON.parse()必炸。工程建议对“必须结构化”的链路默认不要 streaming或者只在 UI 层 streaming而业务解析层拿完整结果如果必须 streaming必须把协议变成流式输出自然语言给用户看最后一段输出严格 JSON给机器用并且要允许“最后 JSON 丢失”时回退到非流式重试。坑 2max_tokens / 超时导致 schema 破坏要在协议层兜底很多 structured output 的实现能保证“生成过程中合法”但一旦你达到 max_tokens触发超时被上游取消输出仍然会变成“半截”。这不是模型问题是传输层和资源控制层的问题。建议把结构化输出的生成预算单独配置不要和聊天混用schema 要有“长度上限”策略比如列表长度、字符串长度对关键字段宁可短一点也不要无限长坑 3optional / null 滥用你以为在容错其实在吞错很多人为了“让它别报错”把字段都做成 optionalname?: stringscore?: number这样 schema_valid_rate 可能很高但你拿到的结构化对象里面全是null/缺失字段。然后下游继续跑路由器拿不到 category → 默认走一个兜底模型 → 成本暴涨风控拿不到 risk_level → 默认放行 → 真事故建议关键字段不要 optional真要 optional也要在业务层做semantic reject结构有效但语义无效坑 4enum/范围约束类型对了值错了即使 structured output 保证类型正确也可能出现confidence: 5明明约束 0~1sentiment: good明明 enum 是 positive/negative/neutral所以 schema 应该写“范围约束”和“枚举”并且要把这些约束当成生产指标结构有效但违反范围 → 记为 semantic reject触发 semantic reject → 进入修复流程下一节代码会给你坑 5嵌套太深schema 不是越细越好我见过最常见的“聪明反被聪明误”业务方把一个复杂的业务对象完整塞进 schema嵌套 5~7 层每层都有 optional/list/union结果模型生成质量明显下降结构虽然能闭合但内容大量空洞整体 token 成本飙升经验值结构化输出适合 2~3 层嵌套超过 3 层建议拆成两段先抽骨架再补细节坑 6多模型/多供应商同一 schema稳定性差一个数量级工程上你一定会做主模型 fallback不同任务路由不同模型但 structured output 的稳定性并不均匀某些模型对 enum/范围支持好某些模型对长列表支持差某些模型一旦遇到复杂描述字段就开始啰嗦建议schema 也是“兼容性矩阵”的一部分上线前做最小 eval同一批样本跑 3 个模型记录schema_valid_raterepair_ratesemantic_reject_rate3. 端到端工程实现TypeScript Zod 的“两段式”管道目标很简单让下游永远拿到一个typed object或明确失败而不是string。我推荐一个在生产里非常好用的模式硬约束阶段尽可能一次得到 schema 合法对象软修复阶段如果失败进入“修复模式”让模型只做“修 JSON”不做“重新理解任务”下面这套代码可以直接拷走。3.1 定义 schema把“描述字符串”当成 prompt 的一部分// structured_output.tsimport{z}fromzod;exportconstTicketTriageSchemaz.object({category:z.enum([billing,bug,feature,security]).describe(工单分类计费/缺陷/需求/安全),priority:z.enum([p0,p1,p2,p3]).describe(紧急程度p0线上故障, p1严重影响, p2一般问题, p3咨询),confidence:z.number().min(0).max(1).describe(模型对分类的置信度0~1),summary:z.string().min(10).max(200).describe(一句话摘要10~200字),actions:z.array(z.string().min(2).max(80)).min(1).max(6).describe(建议动作列表最多6条),});exporttypeTicketTriagez.infertypeofTicketTriageSchema;注意.describe()不是写给人看的是写给模型看的。经验描述要短、明确、带边界enum 的含义要写出来否则模型会乱填3.2 调用 校验 指标把失败变成可观测事件下面写一个通用 client// llm_client.tsimport{z}fromzod;exporttypeLLMProvider{structured:T(args:{model:string;system:string;user:string;schema:z.ZodSchemaT;timeoutMs:number;})Promiseunknown;// 返回 raw};exporttypeMetrics{inc:(name:string,labels?:Recordstring,string)void;observe:(name:string,value:number,labels?:Recordstring,string)void;};exportasyncfunctionrunStructuredT(args:{provider:LLMProvider;model:string;system:string;user:string;schema:z.ZodSchemaT;timeoutMs:number;metrics:Metrics;}):Promise{ok:true;value:T}|{ok:false;error:string;raw?:unknown}{constt0Date.now();try{constrawawaitargs.provider.structured({model:args.model,system:args.system,user:args.user,schema:args.schema,timeoutMs:args.timeoutMs,});constparsedargs.schema.safeParse(raw);if(!parsed.success){args.metrics.inc(llm_schema_invalid_total,{model:args.model});return{ok:false,error:parsed.error.message,raw};}args.metrics.inc(llm_schema_valid_total,{model:args.model});args.metrics.observe(llm_latency_ms,Date.now()-t0,{model:args.model});return{ok:true,value:parsed.data};}catch(e:any){args.metrics.inc(llm_call_error_total,{model:args.model});return{ok:false,error:String(e?.message??e)};}}3.3 修复流程不要“重试原任务”要“修复 JSON”很多人解析失败就“重试同样 prompt”。这会导致同样错误重复出现token 成本翻倍失败时延更长更稳定的做法让模型只做JSON 修复输入是“失败的 raw 内容 schema 要求”// repair.tsimport{z}fromzod;exportasyncfunctionrepairToSchemaT(args:{provider:{text:(a:{model:string;system:string;user:string;timeoutMs:number})Promisestring;};model:string;schema:z.ZodSchemaT;badText:string;timeoutMs:number;}):PromiseT{constsystem你是一个严格的JSON修复器。你只输出JSON本体不要输出多余字符。;constuser[把下面内容修复为满足给定schema的JSON。,要求,1) 只输出JSON不要markdown代码块不要解释,2) 若字段缺失请根据上下文合理补全若无法补全用最安全的默认值,3) 保持语义不变,\n[坏输出],args.badText,].join(\n);constfixedawaitargs.provider.text({model:args.model,system,user,timeoutMs:args.timeoutMs,});constobjJSON.parse(fixed);constparsedargs.schema.parse(obj);returnparsed;}3.4 幂等与重试避免“解析失败→重试雪崩”建议一次 structured 一次 repair仍失败就明确失败并降级异步处理。4. Python Pydantic把业务规则写进 schema而不是写进 promptfromtypingimportList,LiteralfrompydanticimportBaseModel,Field,field_validatorclassQueryIntent(BaseModel):intent:Literal[lookup,compare,troubleshoot,buy]Field(description用户意图查资料/对比/排障/购买)keywords:List[str]Field(min_length1,max_length8,description检索关键词列表)must_include:List[str]Field(default_factorylist,max_length5,description必须包含的词)language:Literal[zh,en]Field(description查询语言)field_validator(keywords)classmethoddefno_empty(cls,v:List[str]):v[x.strip()forxinvifx.strip()]ifnotv:raiseValueError(keywords empty)returnv5. 观测与治理把“输出质量”当成一等公民指标三个核心指标schema_valid_raterepair_ratesemantic_reject_rate6. 结尾决策表 可复用模板把“结构化输出”当成协议而不是提示词技巧。
LLM Structured Output 生产工程:别再写正则解析JSON 了(工程师踩坑版)
你以为你在“接入大模型”其实你在“接入一个不稳定的文本生成器”。一旦你的业务链路里出现了JSON.parse(模型输出)—— 你就已经把事故种子埋进了生产。我写这篇不是复述文档文档告诉你“能用”不会告诉你“会炸”。过去几个月我在做一个典型的 AI 工程系统上游是业务请求中间是 LLM 推理和若干工具调用下游是严格依赖结构化数据的工作流风控、工单、检索、路由、计费、监控。我们一开始也很朴素让模型“返回 JSON”。效果在测试环境很好上线之后开始出现一种非常阴险的故障99.9% 的请求都 OK0.1% 的请求随机失败带引号的名字、超长文本、用户复制了一段代码、模型被安全提示打断……失败之后触发重试重试又失败 → 触发更大范围重试队列堆积、线程打满、下游数据落库出现脏字段这不是模型“变笨了”而是你在用“文本协议”承载“数据协议”。下面我用工程师视角把这事拆开JSON mode / tool calling / structured output 到底差在哪生产里会踩的 6 个坑以及怎么把坑变成指标给你两套可直接复用的实现TypeScript(Zod) / Python(Pydantic)文章比较长5000字但我保证不是堆概念是能直接搬进仓库的那种。1. 三种“结构化输出”到底差在哪别混为一谈很多团队把下面三件事统称“结构化输出”然后就开始争论“到底选哪个”。其实这三种完全不是一类东西1.1 Prompt 约定 JSON最便宜也最不可靠典型写法system你是一个提取器user请返回 JSON字段有 a/b/c优点便宜实现简单兼容任何模型缺点没有协议保证它“尽量”输出 JSON不是“必须”输出 JSON容易出现前后缀\njson\n{…}\n\n解释一段\n遇到边界字符引号、换行、unicode更容易破坏格式我对这种方式的定位很明确只能用于“失败代价极低”的场景。1.2 Tool Calling / Function Calling像 RPC但不是强约束很多平台支持把“输出 JSON”包装成一次函数调用你声明一个 tool schema模型返回一个 tool call携带参数你的程序拿参数当作结构化数据这比纯 prompt 好因为模型更愿意“遵守工具接口”你可以在“工具层”做校验和默认值但工程上要清醒这仍然可能出现字段缺失、值不在范围、enum 乱填多轮对话时模型可能在工具调用前后夹杂自然语言模型可能调用了“你不希望它调用”的工具需要 allowlist它的最佳用途是需要动作编排调用 API、查库、下单而不是纯结构化抽取。1.3 真正的 Structured OutputSchema-Constrained Decoding这类能力的核心不是“提示词更强”而是约束解码你给 JSON Schema或等价的结构定义解码器在每个 token 步骤把“不可能组成合法 JSON 的 token”直接屏蔽结果是输出在语法上满足 schema结构正确、类型正确这类方案把“结构正确性”从应用层regex/parse/retry下沉到推理层是质变。但别误会它不保证语义正确字段里填的内容仍可能胡说它对 schema 设计非常敏感太深、太宽都会让质量下降结论如果下游强依赖结构structured output 是生产默认选项。2. 生产级 Structured Output 的 6 个坑我踩过的那种坑 1流式输出被中断截断 JSON 不是“坏运气”是必然事件很多同学喜欢 streaming因为用户体验好、能做渐进渲染。但对于 structured outputstreaming 有一个天然矛盾structured output 想要一个“完整闭合的 JSON”streaming 可能随时因超时、取消、网络抖动、上游限流而中断于是你会拿到{items:[{id:1,name:a},这时候你如果做JSON.parse()必炸。工程建议对“必须结构化”的链路默认不要 streaming或者只在 UI 层 streaming而业务解析层拿完整结果如果必须 streaming必须把协议变成流式输出自然语言给用户看最后一段输出严格 JSON给机器用并且要允许“最后 JSON 丢失”时回退到非流式重试。坑 2max_tokens / 超时导致 schema 破坏要在协议层兜底很多 structured output 的实现能保证“生成过程中合法”但一旦你达到 max_tokens触发超时被上游取消输出仍然会变成“半截”。这不是模型问题是传输层和资源控制层的问题。建议把结构化输出的生成预算单独配置不要和聊天混用schema 要有“长度上限”策略比如列表长度、字符串长度对关键字段宁可短一点也不要无限长坑 3optional / null 滥用你以为在容错其实在吞错很多人为了“让它别报错”把字段都做成 optionalname?: stringscore?: number这样 schema_valid_rate 可能很高但你拿到的结构化对象里面全是null/缺失字段。然后下游继续跑路由器拿不到 category → 默认走一个兜底模型 → 成本暴涨风控拿不到 risk_level → 默认放行 → 真事故建议关键字段不要 optional真要 optional也要在业务层做semantic reject结构有效但语义无效坑 4enum/范围约束类型对了值错了即使 structured output 保证类型正确也可能出现confidence: 5明明约束 0~1sentiment: good明明 enum 是 positive/negative/neutral所以 schema 应该写“范围约束”和“枚举”并且要把这些约束当成生产指标结构有效但违反范围 → 记为 semantic reject触发 semantic reject → 进入修复流程下一节代码会给你坑 5嵌套太深schema 不是越细越好我见过最常见的“聪明反被聪明误”业务方把一个复杂的业务对象完整塞进 schema嵌套 5~7 层每层都有 optional/list/union结果模型生成质量明显下降结构虽然能闭合但内容大量空洞整体 token 成本飙升经验值结构化输出适合 2~3 层嵌套超过 3 层建议拆成两段先抽骨架再补细节坑 6多模型/多供应商同一 schema稳定性差一个数量级工程上你一定会做主模型 fallback不同任务路由不同模型但 structured output 的稳定性并不均匀某些模型对 enum/范围支持好某些模型对长列表支持差某些模型一旦遇到复杂描述字段就开始啰嗦建议schema 也是“兼容性矩阵”的一部分上线前做最小 eval同一批样本跑 3 个模型记录schema_valid_raterepair_ratesemantic_reject_rate3. 端到端工程实现TypeScript Zod 的“两段式”管道目标很简单让下游永远拿到一个typed object或明确失败而不是string。我推荐一个在生产里非常好用的模式硬约束阶段尽可能一次得到 schema 合法对象软修复阶段如果失败进入“修复模式”让模型只做“修 JSON”不做“重新理解任务”下面这套代码可以直接拷走。3.1 定义 schema把“描述字符串”当成 prompt 的一部分// structured_output.tsimport{z}fromzod;exportconstTicketTriageSchemaz.object({category:z.enum([billing,bug,feature,security]).describe(工单分类计费/缺陷/需求/安全),priority:z.enum([p0,p1,p2,p3]).describe(紧急程度p0线上故障, p1严重影响, p2一般问题, p3咨询),confidence:z.number().min(0).max(1).describe(模型对分类的置信度0~1),summary:z.string().min(10).max(200).describe(一句话摘要10~200字),actions:z.array(z.string().min(2).max(80)).min(1).max(6).describe(建议动作列表最多6条),});exporttypeTicketTriagez.infertypeofTicketTriageSchema;注意.describe()不是写给人看的是写给模型看的。经验描述要短、明确、带边界enum 的含义要写出来否则模型会乱填3.2 调用 校验 指标把失败变成可观测事件下面写一个通用 client// llm_client.tsimport{z}fromzod;exporttypeLLMProvider{structured:T(args:{model:string;system:string;user:string;schema:z.ZodSchemaT;timeoutMs:number;})Promiseunknown;// 返回 raw};exporttypeMetrics{inc:(name:string,labels?:Recordstring,string)void;observe:(name:string,value:number,labels?:Recordstring,string)void;};exportasyncfunctionrunStructuredT(args:{provider:LLMProvider;model:string;system:string;user:string;schema:z.ZodSchemaT;timeoutMs:number;metrics:Metrics;}):Promise{ok:true;value:T}|{ok:false;error:string;raw?:unknown}{constt0Date.now();try{constrawawaitargs.provider.structured({model:args.model,system:args.system,user:args.user,schema:args.schema,timeoutMs:args.timeoutMs,});constparsedargs.schema.safeParse(raw);if(!parsed.success){args.metrics.inc(llm_schema_invalid_total,{model:args.model});return{ok:false,error:parsed.error.message,raw};}args.metrics.inc(llm_schema_valid_total,{model:args.model});args.metrics.observe(llm_latency_ms,Date.now()-t0,{model:args.model});return{ok:true,value:parsed.data};}catch(e:any){args.metrics.inc(llm_call_error_total,{model:args.model});return{ok:false,error:String(e?.message??e)};}}3.3 修复流程不要“重试原任务”要“修复 JSON”很多人解析失败就“重试同样 prompt”。这会导致同样错误重复出现token 成本翻倍失败时延更长更稳定的做法让模型只做JSON 修复输入是“失败的 raw 内容 schema 要求”// repair.tsimport{z}fromzod;exportasyncfunctionrepairToSchemaT(args:{provider:{text:(a:{model:string;system:string;user:string;timeoutMs:number})Promisestring;};model:string;schema:z.ZodSchemaT;badText:string;timeoutMs:number;}):PromiseT{constsystem你是一个严格的JSON修复器。你只输出JSON本体不要输出多余字符。;constuser[把下面内容修复为满足给定schema的JSON。,要求,1) 只输出JSON不要markdown代码块不要解释,2) 若字段缺失请根据上下文合理补全若无法补全用最安全的默认值,3) 保持语义不变,\n[坏输出],args.badText,].join(\n);constfixedawaitargs.provider.text({model:args.model,system,user,timeoutMs:args.timeoutMs,});constobjJSON.parse(fixed);constparsedargs.schema.parse(obj);returnparsed;}3.4 幂等与重试避免“解析失败→重试雪崩”建议一次 structured 一次 repair仍失败就明确失败并降级异步处理。4. Python Pydantic把业务规则写进 schema而不是写进 promptfromtypingimportList,LiteralfrompydanticimportBaseModel,Field,field_validatorclassQueryIntent(BaseModel):intent:Literal[lookup,compare,troubleshoot,buy]Field(description用户意图查资料/对比/排障/购买)keywords:List[str]Field(min_length1,max_length8,description检索关键词列表)must_include:List[str]Field(default_factorylist,max_length5,description必须包含的词)language:Literal[zh,en]Field(description查询语言)field_validator(keywords)classmethoddefno_empty(cls,v:List[str]):v[x.strip()forxinvifx.strip()]ifnotv:raiseValueError(keywords empty)returnv5. 观测与治理把“输出质量”当成一等公民指标三个核心指标schema_valid_raterepair_ratesemantic_reject_rate6. 结尾决策表 可复用模板把“结构化输出”当成协议而不是提示词技巧。