上一篇讲的是”预防”:用严格 JSON Schema、显式枚举约束和合法示例,把 LLM 的输出协议钉死,让绝大多数请求一次就产出可解析、可校验的结构化结果。但预防不是保险。哪怕协议写得再严,跑上几千个批次之后,模型总会有偶发的格式偏差——把枚举值写成别名、把数组字段吐成单个字符串、漏掉一两行结果。预防解决的是”出错频率”,本篇要解决的是”出错之后怎么办”。
我们的核心结论是:单条异常不应该被放大成整批失败,也不应该被悄悄吞掉。整条链路采用”严格校验 + 安全归一化 + 一次修复 + 失败隔离”的组合——归一化只修形式、不动语义;校验守住协议边界;修复只给一次机会;修复仍失败就隔离坏行、放行好结果。与此同时,任务的终态语义要把”流水线走到哪一步”和”结果好不好”拆开,避免把流程走完误报成成功。
这套兜底不是为某一个模型准备的。它真正的收益在后面换模型时兑现:我们把默认诊断模型切换到 DeepSeek 时,输出链路几乎没有返工。换模型不等于改配置,协议韧性才是换模型的前提。
一、问题:一条坏输出,放倒一整批
先看没有韧性层时的两个具体问题。
第一个问题是单条异常被放大成整批失败。诊断任务是按批次执行的:一次请求带 8 个对话实例,模型返回 8 行诊断结果,应用侧校验后逐行落库。某个批次里只要有一行出现格式偏差——比如 outcome 字段返回了 PASS 而不是协议规定的 SUCCESS(两者语义相同但拼写不同)——整批结果就过不了校验。如果处理方式是”校验失败即抛错”,那么 8 个实例里 7 个本来完全可用的诊断结果也被连坐丢弃。更糟的是,如果调用方把这个异常当作致命错误,整个任务可能直接中断,几百个实例的进度全部卡住。
偶发偏差的特点是低频率、高代价。单独看某一个批次,失败概率不高;但乘上批次数之后,“任务级失败”几乎必然发生。这与上一篇的结论形成对照:预防降低的是单次概率,兜底控制的是单次失败的影响面。
第二个问题是任务完成状态误报。早期实现里,任务有一个 DONE 阶段,流水线走完就置为 DONE,页面上把它渲染成”已完成”。但如果其中 30 个实例因为校验失败被跳过,用户看到的是”已完成”,实际拿到的是一份缺了 30 行的诊断报告。对下游的使用者来说,这比直接报错更危险——错误的”成功”让人不再怀疑数据完整性。
二、备选方案与取舍
面对”输出偶发不合法”,可选的方案大致有四条路:
| 方案 | 思路 | 问题 |
|---|---|---|
| 放宽校验 | 把 schema 改松,枚举变字符串,字段全部可选 | 无法验证的数据流入诊断结果,污染下游聚类和统计 |
| 无限重试 | 校验失败就重新请求,直到通过 | 成本失控;同一输入反复失败时任务挂死 |
| 换更强的模型 | 用结构化输出能力更好的模型兜底 | 成本上升,且没有模型能保证 100% 合规 |
| 分层兜底 | 归一化 + 严格校验 + 一次修复 + 失败隔离 | 实现复杂度略高,但每层职责清晰 |
放宽校验是最先被否掉的。诊断结果里的 outcome、severity、failureMode 都是后续聚类的分组键,一个游离的 PASS 会让”无问题”这一组凭空多出一个类别,统计口径直接碎掉。无限重试则是在用钱换确定性,而且当问题出在输入本身时,重试只会稳定复现失败。
最后选了分层兜底。它的核心原则是:模型负责语义判断,应用负责协议边界。模型说的内容(这个对话有没有问题、问题严重吗)我们不去猜、不去改;模型说的形式(枚举拼写、容器形态、行数对齐)由应用用确定性的代码纠正或拒绝。四个环节各管一段,出问题时能精确定位到层。
三、三层防线:归一化、校验、修复
1. 有限归一化:只修形式,不动语义
模型输出的偏差里有相当一部分是”形式偏差”:语义完全正确,只是写法不合协议。典型有三类:
- 枚举别名:协议规定
outcome只能是SUCCESS/FAILURE,模型返回了PASS。这是同一个语义的另一种说法。 - 近义替换:严重度字段应返回
OK,模型写成了NONE。 - 容器形态:协议要求字符串数组,模型返回了单个字符串;或者返回了一个”内容是合法 JSON 数组的字符串”。
这些都可以在校验之前用确定性的映射表修掉:
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[];
}
关键在于归一化必须有边界。它只做两件事:映射固定别名、规整容器形态。它绝不做的包括:
- 不猜测业务场景:
outcome缺失时不去根据evidence推断一个填上; - 不猜测失败模式:
failureMode拼错且不在别名表里时,不去”找最像的”枚举值; - 不修补证据:
evidence为空时不去从对话里截一段凑数。
原因很简单:别名映射是可审计的——你可以列一张表,说明 PASS 和 SUCCESS 语义等价,评审时逐条确认。而任何”猜测”都是应用在替模型做语义判断,一旦猜错,错误会以”合法数据”的身份流入下游聚类,比格式错误更难发现。归一化只处理形式,不改语义,这是整条链路里最重要的一条红线。
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 分离
失败隔离解决执行层的问题,状态语义解决展示层的问题。重构之后,任务用两个正交的字段描述自己:
stage:流水线位置。任务走到了哪一步——筛选、诊断、生成优化事项、汇总报告、DONE。它回答”走到哪了”。status:任务结果。COMPLETED、PARTIAL、INSUFFICIENT_DATA、FAILED,加上处理中的PENDING/RUNNING。它回答”结果怎么样”。
四类终态的语义:
| status | 含义 | 页面呈现 |
|---|---|---|
COMPLETED | 全部候选实例完成诊断 | 已完成 |
PARTIAL | 部分实例失败 | 覆盖率 + 失败数 + 可重试入口 |
INSUFFICIENT_DATA | 诊断数据不足 | 已完成/失败计数 + 可重试入口 |
FAILED | 任务级失败 | 错误信息 + 重试入口 |
其中 INSUFFICIENT_DATA 的触发条件是数据不足以支撑任何结论——比如质检后有效对话为零、或候选全部诊断失败。要注意它和”零候选”是两回事:零候选但数据干净的任务属于正常完成、产出空报告(第二篇专门讨论过这个边界),不会落到 INSUFFICIENT_DATA。
关键的规则是:DONE 只是终止阶段,不承担”成功”的用户语义。页面主文案一律按 status 呈现:只要存在失败实例,哪怕 stage 已经是 DONE,用户看到的就是”部分完成(覆盖率 97%)“,而不是”已完成”。配套的细节是进度统计的容错——失败计数缺失或非法时按 0 处理而不是渲染出 NaN,待处理数按”候选数 − 已完成 − 失败”计算且下限为 0。另外当候选实例全部进入终态但任务还在 RUNNING 时,进度卡切换为”正在生成最终报告…”的动画态,避免覆盖率到 100% 后用户看着静止的进度条以为卡死了。
这套设计的本质是:把”流程走完”和”结果合格”从同一个字段里拆出来。混在一起时,状态机必然在某个场景下说谎。
六、换模型实战:韧性层是换模型的前提
这套韧性层的直接受益场景,是默认诊断模型切换到 DeepSeek。我们是先加固了输出链路、再切的模型,顺序反过来大概率要返工。
原因在于:换模型时输出分布一定会漂移。新模型的枚举拼写习惯、数组字段形态、对示例的遵循度,都与旧模型不同。具体到这次切换,DeepSeek 在我们的诊断任务上整体表现是合格的,但偶发偏差的”类型”和旧模型不一样——别名集合要补充、归一化表要跟着调整。如果没有归一化层,这些偏差全部砸在校验上,批批失败;如果没有失败隔离,第一次失败任务就停了,你连”新模型整体表现如何”都评估不出来。
所以”换模型 ≠ 改配置”。真正可迁移的检查清单是:
- 继续走 OpenAI Chat Completions 兼容协议,
OPENAI_MODEL与OPENAI_BASE_URL是仅有的两个改动点; - 切换前先确认归一化别名表、修复请求、失败隔离都在位并通过测试;
- 切换后观察首批任务的校验失败类型,向别名表里补充确定性映射,而不是放宽 schema;
- 用小批量任务先验证再放量,失败隔离保证了试跑本身是安全的。
模型升级、供应商切换、甚至同一模型的后端更新,都会带来同样的输出漂移。韧性层把”漂移”降级成”调一张别名表”的操作,这才是它长期的价值。
七、成本与吞吐:参数化的权衡
韧性层解决”对不对”,吞吐参数解决”快不快、贵不贵”。这些参数全部走环境变量,部署时可调:
| 参数 | 含义 | 默认值(示意) | 调大 | 调小 |
|---|---|---|---|---|
DIAGNOSIS_BATCH_SIZE | 单次请求携带的实例数 | 8 | 请求次数少、成本低,但单批失败影响面大、上下文更长 | 单批失败损失小,但请求次数与固定开销上升 |
DIAGNOSIS_CONCURRENCY | 诊断批次并发数 | 3 | 吞吐高,但可能触发限流、放大瞬时负载 | 稳定但任务变慢 |
ACTION_CONCURRENCY | 后续动作(优化事项生成等)并发数 | 2 | 报告阶段更快 | 更稳 |
DIAGNOSIS_TIMEOUT_MS | 单请求超时 | 120000 | 长输出不易被误杀 | 快速失败、快速隔离,但重试/修复机会变少 |
几个权衡值得展开。批大小的选择本质是”失败影响面”与”请求开销”的交换:批越大,单次请求的固定成本摊得越薄,但一次校验失败就要隔离更多实例。8 是我们权衡后的点——单批触发的行级修复请求数量仍然可控,最坏情况下隔离的损失也以 8 个实例为上限。诊断并发与超时的组合则要对着供应商限流策略调:并发高 + 超时长意味着失败时同时挂着的请求更多,瞬时浪费更大。修复请求共享原请求的超时预算之外单独计时,避免”归一化失败→修复超时”的串联等待翻倍。
这些默认值不是普适最优解,而是”生产 Agent 诊断这种离线批量任务”下的合理起点。在线场景的取舍会完全不同——延迟敏感时,你可能宁可批小一点、并发高一点。
八、踩坑清单与局限
落地过程中踩过的坑,按出现频率排:
- 归一化表悄悄变成”猜测表”。最初有人提议把
failureMode的模糊拼写用模糊匹配修掉,被否掉了。判断标准就一条:这条映射能否写成一行可审计的等价说明?PASS等于SUCCESS可以,“最像wrong-refund-policy的枚举”不行。归一化表一旦混入猜测,它就从安全网变成了污染源。 - 修复通道差点绕过一致性检查。修复请求的输出只过了字段级 Zod 校验,没核对
rowId是否与被修复的输入行一致,导致对不上号的数据漏进了库。修复必须走与首次完全相同的校验路径,一条都不能少。 - 失败计数缺失渲染出
NaN。处理中阶段的任务详情接口没返回失败实例数,前端直接拿它算待处理数量,显示成NaN。教训是展示层的所有统计输入都要过一遍”非有限值按 0”的规范化纯函数,且这个规范化要集中、可测试,不要散在各组件里。 - 覆盖率 100% 时的”假死”观感。全部实例进入终态后任务还在做报告汇总,进度条停在 100% 不动,用户以为卡死。用”报告生成中”动画态覆盖这个区间,判断逻辑抽成基于
candidateInstances/diagnosedInstances/failedInstances的纯函数。 - 日志差点记下完整模型原文。排障时很想看原始输出,但对话内容和模型全文进日志有合规风险。最终只保留校验错误摘要与修复失败原因,不记 API Key、完整对话和模型原文。
局限也要说清楚。这套方案适用的边界是:离线或准离线的批量结构化抽取任务,且任务对单条失败的容忍度较高(可以接受部分完成 + 重试)。它不适合的场景包括:对完整性零容忍的任务(每一条都必须成功,此时失败隔离只是把问题推迟到人工面前);高吞吐实时链路(一次修复的额外延迟可能不可接受);以及输出 schema 频繁变化的探索期(归一化别名表和校验的维护成本会吃掉收益)。另外,归一化和修复只能处理”形式偏差”,对”格式合法但语义错误”的输出无能为力——那是评估准确性问题,要用上一篇的协议设计和后续的抽样人审去管。
九、总结
这一篇和上一篇是一对:第三篇讲预防,用严格协议把出错频率压下来;本篇讲兜底,用分层设计把单次失败的影响面控制住。四层各自的一句话总结:
- 有限归一化:只映射固定别名和容器形态,只修形式不改语义;
- 严格校验:Zod 严格 schema 加批次一致性,守住协议边界的最后一道闸;
- 一次修复:带原始输出、协议和精简错误再给模型一次机会,成本收益拐点之后不再纠缠;
- 失败隔离:坏行标记
FAILED,好结果先落库,任务以部分完成的诚实姿态收尾。
加上 stage 与 status 分离的终态语义,这套设计反复回到同一个原则:模型负责语义判断,应用负责协议边界。语义上相信模型(包括相信它的自纠能力,所以给一次修复),形式上不相信模型(所以归一化有边界、校验不放宽、状态不撒谎)。也正是有了这层韧性,换模型才能变成”调两个环境变量加一张别名表”的轻操作。
下一篇是这个系列的第五篇,讲任务编排:为什么我们没上消息队列,而是直接用数据库做了任务的分发、续跑与可靠性——韧性层保证单批不炸,编排层保证整个任务在进程重启、并发竞争下仍然走得完。