输出在抖不是模型坏了LangChain 消息边界与结构化输出的稳定化改造同一张工单三次调用三种格式问题并不在模型。[读者] 把 LLM 接进业务系统的后端与平台工程师 [痛点] 输出时好时坏解析靠正则兜底重试越重越贵 [现在读] 这条链路已上线抖动就是故障不是体验问题 [读完] 能用消息边界加 Schema 契约把输出做成可交付接口[旧方案] Prompt 里写只输出 JSON StrOutputParser | v [新需求] 输出要直接进下游系统字段可解析、可枚举、可空判定 | v [冲突] 自然语言输出没有契约解析层只能靠猜 | v [后果] 偶发解析失败 重试放大成本 超时拖垮整批我是老李在一个做企业服务的团队里带平台组。我们有一套叫“工单裁决台”的系统接客服工单的争议初判客户投诉、退款争议、履约纠纷先由模型给出一个初步判定再决定是自动回复、转人工还是升级到客诉组。最初的版本很简单一条 prompt一次模型调用把返回的文本丢进关键词匹配。那时候模型的输出只给人看人瞟一眼就知道该点哪个按钮格式丑一点无所谓。后来业务要求把那一下点击也省掉——判定结果直接写回工单系统的字段里。同一份 prompt 原封不动搬过去第二天我们就开始收到解析失败的告警。你有没有想过当输出从“给人看的文字”变成“给系统吃的字段”你还在用 json.loads 加 try/except 兜底吗同一份 prompt为什么在测试环境稳如老狗到了生产会漂成三种格式如果重试会放大成本、超时又会拖垮整批可用性的最后一道坎到底该由谁来兜01、故事工单裁决台上线的第三个月值班群里开始出现一种很规律的告警每天 21 点到 23 点工单积压量翻倍。白天它跑得很好晚上就崩。我们给自己定的任务是把争议工单的初判从人工 3 分钟压到模型几秒钟并且判定结果要能直接驱动下游动作——自动回复、转人工、升级客诉组。人不再看模型说了什么只看系统据此做了什么。转折点是“换模型”。因为成本我们把后端换成了 OpenAI 兼容网关后面的一批模型随时可切。切的那天我们发现同一张工单A 模型返回一段带前言的自然语言B 模型返回被 Markdown 围栏包住的 JSONC 模型返回规范 JSON 却把布尔值写成中文的“否”。三种脾气共用一套解析代码。于是链路里出现了一层层补丁先 strip再找第一个左花括号再找最后一个右花括号再用正则把“是/否”映射成布尔值最后再拿字典的 get 挨个取字段。补丁写完那周解析确实不报错了——直到某个晚上一个模型把所有字段名改成了驼峰式。我们开始怀疑模型“不听话”。这个怀疑方向后来被证明只对了一半。格式先崩语义后崩补丁再多也只遮眼02、问题旧方案的本质是一句话把自然语言输出当成结构化输出来解析。StrOutputParser 做的事情是把模型返回的消息对象里的文本内容拼成字符串它不校验、不约束、不感知你的字段定义。约束只存在于 prompt 里那句“请只输出 JSON”而 prompt 是建议不是契约。业务影响很直接。解析失败的那次调用工单会掉进一个空的 catch 分支既不自动回复也不转人工它卡在那儿直到积压告警。更糟的是“解析成功但语义错”的情况——模型返回了合法 JSON但 need_escalate 是字符串“否”下游按真值判断一个字符串在 if 里恒为真所有该转人工的工单全被自动回复了。技术表现可以归成四类一是格式违约返回里有前言、有 Markdown 围栏、有多余说明二是字段漂移字段名、大小写、枚举取值在模型之间不一致三是长度失控为了“让模型判断更准”我们把整段工单历史塞进上下文token 随对话轮次线性增长四是时间无界调用没有显式超时SDK 层的默认重试叠加业务层的 while 重试一个慢请求可以拖住一整批串行任务。所以我们把“修好”定义成五条可验证的完成标准而不是“看起来稳了”对同一批固定评测样本解析成功率由脚本跑出来不靠体感每个字段的类型与取值域在代码里静态声明不依赖事后正则修补单次调用有明确的时间上限最坏耗时可计算重试有次数预算且预算耗尽后必然进入确定的降级路径不向业务层抛异常单请求 token 有上限超限走同一条降级路径。注意这五条里没有一条是“让模型更聪明”。这不是偶然。不是模型错是契约缺席标准先立住才谈得上稳03、原理消息角色不是装饰是边界。LangChain 的消息体系把一次调用拆成 System、Human、AI、Tool 四类System 承载稳定不变的行为约定Human 承载每次变化的数据AI 承载历史回复Tool 承载外部工具返回的事实。把“只输出 JSON”写在 Human 里它会随着每张工单的正文一起被稀释写在 System 里它每次都在同一个位置、以同一种权重出现。而 ToolMessage 的意义在于外部事实必须标明来源不能让模型自己编一段塞进 AI 消息里冒充事实。上下文是被计费的边界。每多一条消息就多一份 token也多一份让模型“参考”的东西。工单系统里最常见的错误是把整条工单历史全量拼进上下文理由是“信息越全越准”。实际上历史越长模型越容易把上一轮的输出格式带到这一轮——这也是为什么同一份 prompt 在测试环境稳定、在生产漂移测试用例是三行生产输入是三百行。结构化输出的关键在输出侧不在提示侧。这是本篇第一个反直觉判断把约束从 prompt 搬到 schema比在 prompt 里写十遍“务必输出 JSON”有效得多。原因在于位置不同——prompt 是对生成过程的建议schema 是对解码结果的约束在支持 json_schema 一类能力的后端上或在客户端执行的解析加校验在不支持的后端上。用 Pydantic 定义模型再用 with_structured_output 绑定本质是把“格式对不对”从运行时赌博变成了调用前就写死的合同。具体能力矩阵与参数组合需按当前官方文档核验。第二个反直觉判断温度调到 0 不等于输出确定。它降低的是采样随机性不改变格式违约的概率结构也不消除后端批处理、浮点累加顺序带来的差异。如果你的失败样本是“返回里多了一段前言”那降温度只会让模型稳定地继续多那段前言。第三个反直觉判断重试不是稳定性手段是有预算的补偿。稳定性来自“失败有确定的归宿”而不是“失败多试几次”。没有预算的重试只是把一次失败推到更贵、更晚的地方。边界先划清约束放输出温度非确定重试需有价04、架构[输入] 工单标题 正文 历史摘要 | v [模块] System 契约 Human 数据 ChatPromptTemplate 管道 | v [数据/状态] Pydantic Schema字段类型、枚举、描述 | v [处理] 结构化输出绑定 超时 预算内重试 降级 | v [输出] Judgement 对象 / 降级对象同一类型这条链路里真正起作用的不是那个管道符号而是三个边界。第一个是输入边界进入 Human 的只有标题、正文和一段被裁剪过的历史摘要不是全量历史第二个是输出边界模型返回的东西必须先变成一个 Pydantic 实例才算调用成功第三个是时间与成本边界timeout、max_retries 和 max_tokens 三个旋钮决定了这次调用最坏能花掉多少秒、多少钱。收益是可解析性从“运行时靠猜”变成了“调用前就写死”。下游拿到的一定是 Judgement 对象字段类型固定枚举值可枚举不需要再写正则。降级对象和正常对象是同一个类型下游不需要写两套分支。代价也是真的。第一Schema 变成了一份要维护的契约改字段要同步下游改枚举要考虑旧数据的兼容。第二strict 一类的严格模式并不是所有模型、所有兼容网关都支持换后端时要重新验证否则会静默退回到“提示侧约束”。第三结构化输出本身有额外开销——schema 要作为约束传给模型token 会多一些。适用条件是明确的输出要进系统、字段有清晰取值域、业务能接受一次降级。如果输出只是给人看的一段解释文案这套东西就是过度设计。入口要收窄出口要定型时间要封顶三界先立稳05、实战一次环境与依赖Python 3.11 以上安装 langchain、langchain-openai、pydantic。本节未在当前环境实测以下为预期结果。pipinstall-Ulangchain langchain-openai pydantic核心实现是一个最小但完整的 V1用 ChatPromptTemplate 组装 System 与 Human用 ChatOpenAI 指向 OpenAI 兼容网关用 StrOutputParser 拿字符串再手工 json.loads。这个版本会跑通也会把问题留在明面上。# v1_pipeline.pyimportjsonfromlangchain_core.promptsimportChatPromptTemplatefromlangchain_core.output_parsersimportStrOutputParserfromlangchain_openaiimportChatOpenAI SYSTEM你是工单争议判定助手。请只输出 JSON字段为 need_escalate、reason、action。HUMAN工单标题{title} 工单正文{content}promptChatPromptTemplate.from_messages([(system,SYSTEM),(human,HUMAN),])modelChatOpenAI(modelyour-model-name,base_urlhttps://your-gateway.example.com/v1,api_keysk-your-key,temperature0.7,)chainprompt|model|StrOutputParser()defjudge(title:str,content:str)-str:returnchain.invoke({title:title,content:content})if__name____main__:rawjudge(退款被拒,客户称商品已签收超过七天要求全额退款。)print(raw)print(json.loads(raw))启动与验证直接 python v1_pipeline.py。示例输出预期如下——第一次打印出的是模型原始文本第二次解析在多数情况下会抛出 JSONDecodeError报错停在第一行第一个字符因为字符串开头是一句“好的下面是判定结果”。# 示例输出未在当前环境实测以下为预期结果# 第一次打印一句中文前言 被 Markdown 围栏包住的 JSON 片段# 第二次打印json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)V1 跑通的意义在于它证明了模型确实能做这件事判断内容基本可用同时它把三个问题原样留在了代码里——解析没有契约、调用没有超时、失败没有归宿。这三条就是后面两章要逐个拆掉的东西。先让它能跑再让它露怯问题留在明处才好逐个拆06、排查现象一解析失败异常是 JSONDecodeError集中在夜间批量任务。怀疑是模型不听话、随机性太大。检查动作是把原始返回体落盘而不是只看异常栈。证据很清楚失败的样本里返回字符串的开头是一句中文前言JSON 被 Markdown 围栏包着成功的样本里模型偶尔也会带一句总结。根因不是随机性是 StrOutputParser 只做字符串拼接链路上根本没有任何 schema 约束模型每一次都在按自己的训练分布自由发挥。修复方向是结构化输出而不是继续加固正则。错误尝试我们先把 temperature 从 0.7 降到 0上线观察两天认为“这样模型就不会乱来了”。为什么错失败样本是格式违约不是采样随机性。温度 0 只是让模型稳定地继续违约——它让失败变得更可复现而不是更少。真正解决问题的是后来把约束加到输出侧。现象二解析成功但业务出错need_escalate 被写成中文“否”下游按真值判断全部走成了“需要升级”转人工队列瞬间打满。怀疑是模型中文理解有问题。检查动作是把 Prompt 和返回体对照看。证据是Prompt 里从头到尾没告诉模型这个字段是布尔值更没给过取值域。根因是字段缺少取值域契约模型只能猜一个它觉得自然的值。修复办法是在 Pydantic 里用 bool 类型加 Field 描述枚举字段用 Literal 把取值域钉死。现象三整批任务偶发卡住单张工单正常批量就超时。怀疑是模型服务慢。检查动作是看耗时分布和异常类型而不是看平均值。证据是绝大多数请求很快极少数请求一直没有返回而业务层的重试逻辑是在外层套了一圈 whileSDK 内部还有自己的默认重试两层叠加之后一个慢请求能占住整批的串行进度。根因是调用没有显式超时重试也没有预算。修复方向是把 timeout 显式设上把 max_retries 关掉重试收敛到业务层并带上次数预算预算耗尽就降级。三条链路指向同一个结论这三类故障里没有一类是靠“换个更强的模型”解决的。先看原始体再对字段义最后量长尾三类不同因07、优化第一处修改针对格式违约。把 Pydantic 模型定义成输出契约字段类型、取值域、描述都写进去然后用 with_structured_output 绑定到模型上。原因是这层约束发生在解析侧模型返回后立刻被校验成对象不合法就直接失败不会流到下游。新行为是调用成功即得到 Judgement 实例不再有“字符串里找花括号”这一步。# v2_structured.pyfromtypingimportLiteralfrompydanticimportBaseModel,Fieldfromlangchain_openaiimportChatOpenAIclassJudgement(BaseModel):need_escalate:boolField(description是否需要人工升级)severity:Literal[low,normal,high]Field(description争议严重度)reason:strField(description不超过 60 字的判定依据必须引用工单中的事实)action:strField(description下一步动作必须是一个可执行指令)modelChatOpenAI(modelyour-model-name,base_urlhttps://your-gateway.example.com/v1,api_keysk-your-key,temperature0,timeout20,max_retries0,max_tokens400,)structuredmodel.with_structured_output(Judgement,methodjson_schema,strictTrue)这里有两个参数选择值得说明。一是 max_retries 设成 0不是不要重试而是不许 SDK 偷偷重试重试必须发生在业务层因为只有业务层知道预算和降级路径。二是 timeout 设成显式值让最坏耗时可计算这是完成标准里写死的一条。strict 与 method 的具体取值组合需按当前官方文档与所用兼容网关逐项核验不是所有后端都支持。第二处修改针对没有归宿的失败。把重试、降级、超时收敛到一个函数里并且让降级返回的对象和正常返回的对象是同一个类型。# v2_fallback.pyfromlangchain_core.promptsimportChatPromptTemplatefromv2_structuredimportJudgement,structured SYSTEM你是工单争议判定助手。只依据给出的工单内容判定不要引入外部信息。HUMAN工单标题{title} 工单正文{content}promptChatPromptTemplate.from_messages([(system,SYSTEM),(human,HUMAN),])chainprompt|structureddefjudge_safe(title:str,content:str,budget:int2)-Judgement:last_errNonefor_inrange(budget1):try:returnchain.invoke({title:title,content:content})exceptExceptionase:last_errereturnJudgement(need_escalateTrue,severitynormal,reason自动判定不可用转人工type(last_err).__name__,actionpush_to_human_queue,)新行为是无论是解析失败、超时还是限流业务层拿到的永远是一个 Judgement最坏情况是 need_escalate 为真、action 指向人工队列。验证方式是构造三类输入——正常工单、超长工单、故意填错 base_url 的工单——观察前者返回结构化结果、后两者落到同一条降级路径且不抛异常。这里给出的是验证方法不宣称任何成功率数字。第三处修改针对上下文与成本。把历史压缩成一段摘要在 Human 里给出而不是把全量对话都塞进去max_tokens 设上限超限同样走降级。原因是上下文每扩一段token 成本、格式漂移概率和延迟是同时上升的。约束放前端归宿留在后预算写进码三改各有由08、演进[同一输入争议工单] | --[V1] 提示侧约束 字符串解析 / 失败即抛异常 / 无超时无预算 | --[V2] Schema 约束 显式超时 预算内重试 同类型降级 | [Trade-off] 得到可解析、可枚举、可预期的失败归宿 失去字段扩展的自由度以及一部分后端的可移植性 边界Schema 变更需同步下游strict 能力需按后端核验正确性上V1 的正确性取决于运气——同一份输入可能在解析这一步就丢了V2 把正确性拆成两层格式正确由 schema 保证语义正确交给评测集职责不再混在一起。稳定性上V1 的失败是可扩散的一次解析失败能拖住一整批V2 的失败是可收敛的最坏情况是一张工单进人工队列不会向上冒泡。这是两代方案最本质的差别不是 V2 失败更少而是 V2 的失败有边界。复杂度上V2 确实更高多了一个 Schema 要维护、多了一段降级逻辑、多了一份和后端能力的适配工作。这份复杂度换来的是可观测性——失败可以被分类计数而不是笼统地记一个“报错”。成本上V2 多了 schema 带来的 token 开销但省掉了失败重试和补丁维护。是否划算取决于失败率这个要自己测。适用范围上V1 适合原型验证和输出只给人看的场景V2 适合输出要进系统的场景。这不是先进与落后是场景不同。遗留问题也很明确多模型切换时strict 能力不一致结构化降级会让人工队列承担兜底压力需要容量预案Schema 演进缺少版本化机制。旧版赌运气新版留退路稳不是不错是错得有界09、洞见9.1 输出的确定性来自契约不来自参数我们最初的所有排查动作都落在“怎么让模型更听话”上改措辞、加感叹号、降温度、换模型。这些都收效甚微因为它们在调整的是建议的强度不是约束的强度。反直觉判断把“只输出 JSON”从 prompt 搬到 schema效果远大于把 temperature 从 0.7 调到 0。前者改变的是失败会不会流到下游后者只改变失败发生的频率。9.2 消息角色划分的是责任不是格式System、Human、AI、Tool 四类消息最初我以为只是给模型看的标签。后来才明白它划分的是责任边界System 是你对行为负责的部分Human 是用户负责的部分AI 是历史Tool 是外部事实负责的部分。把业务约束写进 Human等于让它和用户输入一起被稀释把模型自己编的内容当 AI 消息塞进历史等于让幻觉获得“事实”的身份。上下文边界管理不好成本和正确性会同时失守。9.3 重试是补偿不是稳定器反直觉判断重试次数增加系统可用性未必上升有时反而下降。原因是没有预算的重试会把失败放大成雪崩——第一次超时占住一个连接第二次重试再占一个第三次把队列填满。真正的稳定性来自“失败有确定归宿”也就是降级路径存在且被验证过。降级不是认输它是可用性的下限本身。9.4 换模型不是换参数是换了一份隐性契约我们一直以为接的是 OpenAI 兼容服务所以换模型等于换一个 model 名字。实际上兼容的是接口形状不是输出行为。不同模型对“只输出 JSON”的理解、对枚举值的默认表达、对长度的偏好全都不一样。所谓兼容层兼容的是协议不是脾气。这也解释了为什么同一份 prompt 在测试环境稳定、在生产漂移——测试环境只跑过一个模型。契约胜参数责任分角色重试需有价兼容非同脾10、系统落地原来我们有的是一条 prompt 加一个 StrOutputParser加上一堆事后补丁strip、找花括号、正则映射枚举、外层 while 重试。它能跑但它的正确性依赖输入长得像测试用例。本篇新增了三样东西。第一是输出契约一份 Pydantic 模型字段类型、枚举取值、字段描述都写死配合结构化输出绑定让“格式对不对”在调用返回那一刻就有结论。第二是失败归宿一个带预算的重试循环预算耗尽必然产出同类型的降级对象业务层不需要写第二套分支。第三是资源边界显式 timeout、显式 max_tokens、显式关闭 SDK 内部重试把最坏耗时和最坏成本都变成可计算的值。现在能做的是把工单初判真正做成一个接口而不是一段文本。下游拿到的永远是 Judgement格式问题不再需要下游兜底人工队列只处理“自动判定不可用”这一种确定的输入而不是随机涌入的各种残片。还缺什么缺评测。现在我们知道格式稳定了但不知道判断准不准——这两件事必须分开度量格式靠 schema语义靠固定评测集。也缺观测失败目前只记了类型名没有按模型、按网关、按工单类型拆分无法定位是哪个后端的脾气变了。还缺 Schema 的版本管理字段一旦变更历史数据怎么兼容还没有答案。下一步的演进方向是清楚的把 Schema 变成有版本号的资产把降级率和失败分类接进监控把多后端的能力差异做成一张需要逐项核验的兼容表。这三件事做完这套链路才算真的能长期维护。有契约可依有退路可走有边界可算才算落了地11、小结Q1 → 输出进系统就别再靠正则兜底用 Pydantic 定义字段类型与取值域用结构化输出绑定让格式在返回那一刻定案。 Q2 → 漂移不是因为不稳定而是因为约束只写在 prompt 里且测试环境只跑过一个模型约束搬到输出侧换后端才可控。 Q3 → 由降级路径来兜显式 timeout 预算内重试 同类型的失败对象让最坏情况可计算、可承接。 状态 → 一条可解析、可降级、可计费的 LLM 调用链路V1 跑通V2 立住边界。一问格式稳二问漂移因三问谁兜底答在契约中12、作业12.1 理解题为什么把“只输出 JSON”从 prompt 搬到 schema效果比降温度更明显参考答案两者作用的位置不同。prompt 是对生成过程的建议模型可以在遵循建议的同时加上前言、围栏或改写字段名schema 作用在结果校验或解码约束上不合法就是失败失败会走确定的处理路径。降温度只降低采样随机性不改变格式违约的概率结构甚至会让违约变得稳定复现。前者改变的是“失败会不会流到下游”后者改变的是“失败发生的频率”。12.2 实战题给 Judgement 增加一个 evidence 字段要求必须引用工单中的原文片段并保证旧代码不崩。参考答案在 Pydantic 模型里加一个带默认值的字段例如 evidence: str Field(default‘’, description‘引用工单中的原文片段不超过 80 字’)。给默认值是为了让降级路径和旧调用方仍能构造出对象结构化输出绑定不需要改写schema 会自动带上新字段。同时要给评测集补一条校验evidence 为空时是否可接受需要业务明确。字段一旦成为契约就要考虑旧数据兼容。12.3 排障题上线后解析失败率归零但转人工队列反而变长了怎么查参考答案这说明失败从“抛异常”变成了“走降级”故障没有消失只是被隐藏了。排查顺序是先按失败类型给降级路径打点分类区分是 schema 校验失败、超时还是限流再看是否集中在某个模型或某个网关上这一步能验证“换后端换脾气”的假设然后核对 timeout 与 max_retries 的实际取值是否生效特别是 SDK 内部重试是否被真的关掉最后检查 max_tokens 是否过小导致输出被截断截断在高频场景下会稳定触发校验失败。关键是要让降级可计数、可分类否则它就是个黑洞。12.4 架构判断题团队提出把重试次数从 2 次调到 5 次来提升成功率你同意吗参考答案不建议直接调。重试次数不是独立旋钮它和 timeout、并发度、下游容量是一个整体。在超时场景下重试会成倍占用连接和时间预算把单点慢请求放大成批处理雪崩在限流场景下重试只会加剧限流。正确做法是先按失败类型拆分对可重试的瞬时错误给有限预算对不可重试的格式错误直接降级并且让降级路径本身被监控验证过。判断标准不是“成功率涨没涨”而是“最坏耗时可不可计算、失败有没有确定归宿”。题问位置差题问契约变题问黑洞处题问预算先13、思考这篇的核心冲突只有一句话当输出从给人看变成给系统吃稳定的定义就变了。给人看的输出稳定意味着“读起来合理”给系统吃的输出稳定意味着“出错时有确定的归宿”。这两种稳定需要的工程手段完全不同。我们花了很多时间在调提示词和调温度上那是在优化第一种稳定。真正把问题解决的是三件看起来很朴素的事把约束放到输出侧把失败收敛到一条路径把时间和成本写成显式的数字。它们都不聪明但它们可验证。换模型这件事也值得再想一层。所谓 OpenAI 兼容兼容的是接口形状不是行为脾气。任何一次后端切换本质上都是一次隐性契约的变更必须重新验证而不是改个名字就上线。最后留一句给自己的判断标准判断一条 LLM 链路是否真的稳不看它成功时多漂亮看它失败时去哪里。稳在失败处不在成功时契约先立住脾气任它变