跳至正文
来两杯美式
返回

给生产 Agent 做体检(四):LLM 输出不完美怎么办——归一化、一次修复与失败隔离

By 来两杯美式
发布于

上一篇讲的是”预防”:用严格 JSON Schema、显式枚举约束和合法示例,把 LLM 的输出协议钉死,让绝大多数请求一次就产出可解析、可校验的结构化结果。但预防不是保险。哪怕协议写得再严,跑上几千个批次之后,模型总会有偶发的格式偏差——把枚举值写成别名、把数组字段吐成单个字符串、漏掉一两行结果。预防解决的是”出错频率”,本篇要解决的是”出错之后怎么办”。

我们的核心结论是:单条异常不应该被放大成整批失败,也不应该被悄悄吞掉。整条链路采用”严格校验 + 安全归一化 + 一次修复 + 失败隔离”的组合——归一化只修形式、不动语义;校验守住协议边界;修复只给一次机会;修复仍失败就隔离坏行、放行好结果。与此同时,任务的终态语义要把”流水线走到哪一步”和”结果好不好”拆开,避免把流程走完误报成成功。

这套兜底不是为某一个模型准备的。它真正的收益在后面换模型时兑现:我们把默认诊断模型切换到 DeepSeek 时,输出链路几乎没有返工。换模型不等于改配置,协议韧性才是换模型的前提。

一、问题:一条坏输出,放倒一整批

先看没有韧性层时的两个具体问题。

第一个问题是单条异常被放大成整批失败。诊断任务是按批次执行的:一次请求带 8 个对话实例,模型返回 8 行诊断结果,应用侧校验后逐行落库。某个批次里只要有一行出现格式偏差——比如 outcome 字段返回了 PASS 而不是协议规定的 SUCCESS(两者语义相同但拼写不同)——整批结果就过不了校验。如果处理方式是”校验失败即抛错”,那么 8 个实例里 7 个本来完全可用的诊断结果也被连坐丢弃。更糟的是,如果调用方把这个异常当作致命错误,整个任务可能直接中断,几百个实例的进度全部卡住。

偶发偏差的特点是低频率、高代价。单独看某一个批次,失败概率不高;但乘上批次数之后,“任务级失败”几乎必然发生。这与上一篇的结论形成对照:预防降低的是单次概率,兜底控制的是单次失败的影响面。

第二个问题是任务完成状态误报。早期实现里,任务有一个 DONE 阶段,流水线走完就置为 DONE,页面上把它渲染成”已完成”。但如果其中 30 个实例因为校验失败被跳过,用户看到的是”已完成”,实际拿到的是一份缺了 30 行的诊断报告。对下游的使用者来说,这比直接报错更危险——错误的”成功”让人不再怀疑数据完整性。

二、备选方案与取舍

面对”输出偶发不合法”,可选的方案大致有四条路:

方案思路问题
放宽校验把 schema 改松,枚举变字符串,字段全部可选无法验证的数据流入诊断结果,污染下游聚类和统计
无限重试校验失败就重新请求,直到通过成本失控;同一输入反复失败时任务挂死
换更强的模型用结构化输出能力更好的模型兜底成本上升,且没有模型能保证 100% 合规
分层兜底归一化 + 严格校验 + 一次修复 + 失败隔离实现复杂度略高,但每层职责清晰

放宽校验是最先被否掉的。诊断结果里的 outcomeseverityfailureMode 都是后续聚类的分组键,一个游离的 PASS 会让”无问题”这一组凭空多出一个类别,统计口径直接碎掉。无限重试则是在用钱换确定性,而且当问题出在输入本身时,重试只会稳定复现失败。

最后选了分层兜底。它的核心原则是:模型负责语义判断,应用负责协议边界。模型说的内容(这个对话有没有问题、问题严重吗)我们不去猜、不去改;模型说的形式(枚举拼写、容器形态、行数对齐)由应用用确定性的代码纠正或拒绝。四个环节各管一段,出问题时能精确定位到层。

三、三层防线:归一化、校验、修复

1. 有限归一化:只修形式,不动语义

模型输出的偏差里有相当一部分是”形式偏差”:语义完全正确,只是写法不合协议。典型有三类:

这些都可以在校验之前用确定性的映射表修掉:

const OUTCOME_ALIASES: Record<string, Outcome> = {
  PASS: "SUCCESS",
  FAIL: "FAILURE",
};

const SEVERITY_ALIASES: Record<string, Severity> = {
  NONE: "OK",
};

function normalizeField(
  value: unknown,
  aliases?: Record<string, string>
): unknown {
  if (typeof value === "string" && aliases?.[value]) return aliases[value];
  return value;
}

function normalizeArrayField(value: unknown): string[] {
  if (typeof value === "string") {
    const trimmed = value.trim();
    if (trimmed.startsWith("[")) {
      try {
        return JSON.parse(trimmed) as string[];
      } catch {
        /* fallthrough */
      }
    }
    return [trimmed];
  }
  return value as string[];
}

关键在于归一化必须有边界。它只做两件事:映射固定别名、规整容器形态。它绝不做的包括:

原因很简单:别名映射是可审计的——你可以列一张表,说明 PASSSUCCESS 语义等价,评审时逐条确认。而任何”猜测”都是应用在替模型做语义判断,一旦猜错,错误会以”合法数据”的身份流入下游聚类,比格式错误更难发现。归一化只处理形式,不改语义,这是整条链路里最重要的一条红线。

2. 严格校验:Zod 严格 schema + 批次一致性

归一化之后,结果进入严格校验。这里沿用上一篇的 instanceDiagnosisSchema:Zod 严格模式,多余字段报错、枚举不放宽、必填字段不妥协。校验通过的才会落库,保证库里的每一条诊断结果都是”可验证的”。

严格校验之外还有一道批次级检查:返回的 rowId 集合必须与输入批次完全一致。这是容易被忽略的一环。模型偶尔会”合并”两行相似的对话、或者漏掉一行它认为重复的实例,逐行 schema 校验发现不了这种问题——每行单独看都是合法的,但行数少了。批次一致性检查确保 8 个进、8 个出,一行不多、一行不少,多出来的拒绝、缺的视为失败。

3. 一次修复:给模型一次带着错误上下文的自纠机会

归一化修不掉的偏差(比如字段类型真的错了、漏了整行),进入修复流程。做法是向同一个模型再发一次单行修复请求,携带两样东西:该行的原始输出原文,以及精简后的校验错误。字段结构不靠 prompt 描述——修复请求与主链路发送完全相同的 json_schema response_format,协议天然同步(见第三篇):

async function repairRow(
  rawItem: unknown,
  zodError: ZodError
): Promise<string> {
  return callModel({
    system: [
      "你之前返回的这一行诊断 JSON 未通过校验。",
      "校验错误:",
      summarize(zodError), // 精简后的错误路径与期望类型
      "只返回修正后的这一行 JSON,不要任何解释。",
    ].join("\n"),
    user: JSON.stringify(rawItem), // 该行的原始输出原文
  });
}

修复请求的输出要过完全相同的校验:同样的 Zod schema、同样的一致性检查(修复输出的 rowId 必须与被修复的输入行一致)。不存在”修复通道降低标准”——否则修复就成了变相放宽校验的后门。

为什么只修一次?这是一个成本与收益的拐点判断。第一次修复的成功率是可观的:错误上下文明确、原始输出就在眼前,模型纠正拼写和结构类错误的意愿和能力都没问题。但第二次修复面对的是”模型看了错误说明仍然没改对”的输入,这类残差里混着更深的语义问题,继续重试的边际收益急剧下降,而成本是线性叠加的。与其无限循环,不如把残差交给失败隔离——标记、记录、放行,人工事后排查。一次修复,足够了。

四、失败隔离:坏行止步,好结果先落库

修复之后仍不合法的行,处理方式是:该行标记 FAILED,记录最终错误(原始校验失败与修复失败分别保留可读信息,供任务详情页排障用),然后继续执行下一个批次。失败止步于行,不连坐同批的其他实例——这正是上一篇”行级归并”在执行层的延续。

这里有一个容易被忽视的持久化细节:有效诊断要先于坏行修复落库。也就是每个批次校验通过后立即写库,而不是等整个任务结束统一提交。如果顺序反了——先跑完所有修复再统一落库——那么进程在中途被杀掉时,已验证的结果会一起丢失,重启后全部重跑。先落库的含义是:任何时刻进程中断,库里保留的都是”已通过校验的完整成果”,损失被限制在中断时刻正在处理的那个批次里。

失败隔离换来的任务形态是”部分完成”而非”全有或全无”:100 个实例里 3 个实例修复后仍失败,任务最终交付 97 条有效诊断,外加 3 条明确标记失败的记录。对离线诊断这种场景,97% 的可用数据加上清晰的缺口说明,远比”全部重来”或”假装成功”有价值。

五、任务终态语义:stage 与 status 分离

失败隔离解决执行层的问题,状态语义解决展示层的问题。重构之后,任务用两个正交的字段描述自己:

四类终态的语义:

status含义页面呈现
COMPLETED全部候选实例完成诊断已完成
PARTIAL部分实例失败覆盖率 + 失败数 + 可重试入口
INSUFFICIENT_DATA诊断数据不足已完成/失败计数 + 可重试入口
FAILED任务级失败错误信息 + 重试入口

其中 INSUFFICIENT_DATA 的触发条件是数据不足以支撑任何结论——比如质检后有效对话为零、或候选全部诊断失败。要注意它和”零候选”是两回事:零候选但数据干净的任务属于正常完成、产出空报告(第二篇专门讨论过这个边界),不会落到 INSUFFICIENT_DATA

关键的规则是:DONE 只是终止阶段,不承担”成功”的用户语义。页面主文案一律按 status 呈现:只要存在失败实例,哪怕 stage 已经是 DONE,用户看到的就是”部分完成(覆盖率 97%)“,而不是”已完成”。配套的细节是进度统计的容错——失败计数缺失或非法时按 0 处理而不是渲染出 NaN,待处理数按”候选数 − 已完成 − 失败”计算且下限为 0。另外当候选实例全部进入终态但任务还在 RUNNING 时,进度卡切换为”正在生成最终报告…”的动画态,避免覆盖率到 100% 后用户看着静止的进度条以为卡死了。

这套设计的本质是:把”流程走完”和”结果合格”从同一个字段里拆出来。混在一起时,状态机必然在某个场景下说谎。

六、换模型实战:韧性层是换模型的前提

这套韧性层的直接受益场景,是默认诊断模型切换到 DeepSeek。我们是先加固了输出链路、再切的模型,顺序反过来大概率要返工。

原因在于:换模型时输出分布一定会漂移。新模型的枚举拼写习惯、数组字段形态、对示例的遵循度,都与旧模型不同。具体到这次切换,DeepSeek 在我们的诊断任务上整体表现是合格的,但偶发偏差的”类型”和旧模型不一样——别名集合要补充、归一化表要跟着调整。如果没有归一化层,这些偏差全部砸在校验上,批批失败;如果没有失败隔离,第一次失败任务就停了,你连”新模型整体表现如何”都评估不出来。

所以”换模型 ≠ 改配置”。真正可迁移的检查清单是:

  1. 继续走 OpenAI Chat Completions 兼容协议,OPENAI_MODELOPENAI_BASE_URL 是仅有的两个改动点;
  2. 切换前先确认归一化别名表、修复请求、失败隔离都在位并通过测试;
  3. 切换后观察首批任务的校验失败类型,向别名表里补充确定性映射,而不是放宽 schema;
  4. 用小批量任务先验证再放量,失败隔离保证了试跑本身是安全的。

模型升级、供应商切换、甚至同一模型的后端更新,都会带来同样的输出漂移。韧性层把”漂移”降级成”调一张别名表”的操作,这才是它长期的价值。

七、成本与吞吐:参数化的权衡

韧性层解决”对不对”,吞吐参数解决”快不快、贵不贵”。这些参数全部走环境变量,部署时可调:

参数含义默认值(示意)调大调小
DIAGNOSIS_BATCH_SIZE单次请求携带的实例数8请求次数少、成本低,但单批失败影响面大、上下文更长单批失败损失小,但请求次数与固定开销上升
DIAGNOSIS_CONCURRENCY诊断批次并发数3吞吐高,但可能触发限流、放大瞬时负载稳定但任务变慢
ACTION_CONCURRENCY后续动作(优化事项生成等)并发数2报告阶段更快更稳
DIAGNOSIS_TIMEOUT_MS单请求超时120000长输出不易被误杀快速失败、快速隔离,但重试/修复机会变少

几个权衡值得展开。批大小的选择本质是”失败影响面”与”请求开销”的交换:批越大,单次请求的固定成本摊得越薄,但一次校验失败就要隔离更多实例。8 是我们权衡后的点——单批触发的行级修复请求数量仍然可控,最坏情况下隔离的损失也以 8 个实例为上限。诊断并发与超时的组合则要对着供应商限流策略调:并发高 + 超时长意味着失败时同时挂着的请求更多,瞬时浪费更大。修复请求共享原请求的超时预算之外单独计时,避免”归一化失败→修复超时”的串联等待翻倍。

这些默认值不是普适最优解,而是”生产 Agent 诊断这种离线批量任务”下的合理起点。在线场景的取舍会完全不同——延迟敏感时,你可能宁可批小一点、并发高一点。

八、踩坑清单与局限

落地过程中踩过的坑,按出现频率排:

  1. 归一化表悄悄变成”猜测表”。最初有人提议把 failureMode 的模糊拼写用模糊匹配修掉,被否掉了。判断标准就一条:这条映射能否写成一行可审计的等价说明?PASS 等于 SUCCESS 可以,“最像 wrong-refund-policy 的枚举”不行。归一化表一旦混入猜测,它就从安全网变成了污染源。
  2. 修复通道差点绕过一致性检查。修复请求的输出只过了字段级 Zod 校验,没核对 rowId 是否与被修复的输入行一致,导致对不上号的数据漏进了库。修复必须走与首次完全相同的校验路径,一条都不能少。
  3. 失败计数缺失渲染出 NaN。处理中阶段的任务详情接口没返回失败实例数,前端直接拿它算待处理数量,显示成 NaN。教训是展示层的所有统计输入都要过一遍”非有限值按 0”的规范化纯函数,且这个规范化要集中、可测试,不要散在各组件里。
  4. 覆盖率 100% 时的”假死”观感。全部实例进入终态后任务还在做报告汇总,进度条停在 100% 不动,用户以为卡死。用”报告生成中”动画态覆盖这个区间,判断逻辑抽成基于 candidateInstances / diagnosedInstances / failedInstances 的纯函数。
  5. 日志差点记下完整模型原文。排障时很想看原始输出,但对话内容和模型全文进日志有合规风险。最终只保留校验错误摘要与修复失败原因,不记 API Key、完整对话和模型原文。

局限也要说清楚。这套方案适用的边界是:离线或准离线的批量结构化抽取任务,且任务对单条失败的容忍度较高(可以接受部分完成 + 重试)。它不适合的场景包括:对完整性零容忍的任务(每一条都必须成功,此时失败隔离只是把问题推迟到人工面前);高吞吐实时链路(一次修复的额外延迟可能不可接受);以及输出 schema 频繁变化的探索期(归一化别名表和校验的维护成本会吃掉收益)。另外,归一化和修复只能处理”形式偏差”,对”格式合法但语义错误”的输出无能为力——那是评估准确性问题,要用上一篇的协议设计和后续的抽样人审去管。

九、总结

这一篇和上一篇是一对:第三篇讲预防,用严格协议把出错频率压下来;本篇讲兜底,用分层设计把单次失败的影响面控制住。四层各自的一句话总结:

加上 stagestatus 分离的终态语义,这套设计反复回到同一个原则:模型负责语义判断,应用负责协议边界。语义上相信模型(包括相信它的自纠能力,所以给一次修复),形式上不相信模型(所以归一化有边界、校验不放宽、状态不撒谎)。也正是有了这层韧性,换模型才能变成”调两个环境变量加一张别名表”的轻操作。

下一篇是这个系列的第五篇,讲任务编排:为什么我们没上消息队列,而是直接用数据库做了任务的分发、续跑与可靠性——韧性层保证单批不炸,编排层保证整个任务在进程重启、并发竞争下仍然走得完。


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
查看系列全部文章
  1. 01.把存量系统交给 Agent,先想清楚 API、Skill、MCP 各是什么
  2. 02.存量系统 Agent 化,物理上到底怎么「接」
  3. 03.单点跑通之后,怎么把 Agent 能力沉淀成全公司可复用
  4. 04.DeepSeek Harness:把 Agent 宿主变成可组合的基础设施,企业能拿它做什么
  5. 05.一个系统,两种访客:给浏览器和 Agent 设计同一套身份体系
  6. 06.定时是 Agent 平台的另一半:Trigger/Action Registry 与调度原语分层
  7. 07.Agent 刚才干了什么:审计、调用日志与敏感数据豁免
  8. 08.提示词安全攻防全解析:越狱、注入与信息泄露
  9. 09.给生产 Agent 做体检(一):只用对话文本的黑盒诊断工作流
  10. 10.给生产 Agent 做体检(二):诊断之前,先定义人口——数据质检与风险筛选
  11. 11.给生产 Agent 做体检(三):让 LLM 稳定输出结构化诊断——严格 JSON Schema 实践
  12. 12.给生产 Agent 做体检(四):LLM 输出不完美怎么办——归一化、一次修复与失败隔离
  13. 13.给生产 Agent 做体检(五):不要消息队列——基于数据库的任务编排与可靠性
  14. 14.给生产 Agent 做体检(六):从诊断明细到 PM 能用的报告——评估结果的产品化
  15. 15.AI Agent:从工具到同事,中间隔着一层「自主性」
  16. 16.Agent 评测工程化(一):为什么不能只看平均分
  17. 17.Agent 评测工程化(二):Rubric 不是 Prompt,而是可执行的质量协议
  18. 18.Agent 评测工程化(三):从低分样本到问题簇
  19. 19.Agent 评测工程化(四):红线、一票否决与 1-5 分
  20. 20.Agent 评测工程化(五):让 LLM-as-judge 稳定输出结构化结果
  21. 21.Agent 评测工程化(六):评测前,先把 Agent 输出拆开
  22. 22.Agent 评测工程化(七):从评分报表到 PM 决策工作台
  23. 23.Agent 评测工程化(八):不用消息队列,也能跑长任务
  24. 24.Agent 评测工程化(九):把一次性评测变成持续优化体系

上一篇
给生产 Agent 做体检(五):不要消息队列——基于数据库的任务编排与可靠性
下一篇
给生产 Agent 做体检(三):让 LLM 稳定输出结构化诊断——严格 JSON Schema 实践