工具链越长,回退策略越应该先设计
补档说明:本文属于「AI 工程落地周记」系列,计划发布时间为 2025-08-13 16:10。当前先保留为草稿,后续补充真实案例、代码片段和复盘细节后再发布。
工具调用一多,很多团队的第一反应都是把主流程先串起来:
- 先检索
- 再生成
- 再解析
- 再写入系统
- 再同步下游
流程能跑起来之后,大家才开始补“如果某一步失败怎么办”。
但我后来越来越确定,工具链一旦变长,回退策略就不该是上线后的补丁,而应该是设计阶段的主问题。
因为工具链越长,失败就越不是“有没有失败”,而是“会在哪里以什么方式失败”。你不先定义回退规则,系统迟早会在某个半成功状态里把你拖进返工。
一个 6 步链路里,真正危险的是第 4 步以后
假设一条内容处理链是这样的:
- 检索相关资料
- 调模型生成结构化结果
- 校验 schema
- 写入 CMS
- 更新搜索索引
- 发送通知
前 3 步大多还是可重算的;
从第 4 步开始,你面对的是副作用:
- 已经写进数据库了没有
- 索引更新了一半怎么办
- 通知已经发了还能不能撤
也就是说,工具链的后半段,问题不再是“结果对不对”,而是“系统状态还一致吗”。
为什么很多团队的默认回退策略会出事
因为最常见的默认策略其实只有一句话:失败就整条链重试。
这在纯计算任务里也许还能勉强接受,但只要链路里有外部写入,它就会带来一堆副作用:
- 重复创建内容
- 重复发消息
- 重复扣费
- 重复触发下游任务
更糟的是,你可能直到用户投诉才知道系统重放过。
我现在先按“节点性质”设计回退
现在只要工具链一长,我会先把每个节点按下面三类标出来:
1. 可重算节点
例如:
- 检索
- 文本生成
- 结构化解析
这类节点失败了,可以局部重试,重点是限次数和保留 trace。
2. 幂等副作用节点
例如:
- 用固定
contentId写入草稿 - 用固定
jobId更新索引
这类节点可以重试,但前提是必须带幂等键。
3. 非幂等副作用节点
例如:
- 对外发送邮件或 IM 通知
- 提交第三方审批
- 调用不可撤销的外部动作
这类节点默认不能盲重试,而是要先查状态、再决定是否继续。
回退不是一个动作,而是一张策略表
后来我更喜欢给链路直接配一张节点级策略表:
{
"generate_structured_output": {
"retry": "local",
"maxAttempts": 2,
"fallback": "smaller_context"
},
"write_cms": {
"retry": "idempotent",
"idempotencyKey": "contentId"
},
"send_notification": {
"retry": "manual_or_check_first",
"compensation": "mark_pending_notification"
}
}
这张表的意义是,系统不再把“失败”当成统一事件,而是把它还原成不同节点的不同恢复方式。
一个常被忽略的现实:有些节点应该降级,不应该硬撑
工具链一长之后,最稳的系统通常不是“每一步都必须成功”,而是“知道哪些步骤失败后可以先降级完成主链路”。
例如:
- 搜索索引失败,可以先把内容发布成功,再异步补索引
- 通知失败,可以先记待发送队列,而不是回滚主内容
- 结构化解析失败,可以回退到更保守的生成模板
也就是说,回退策略有时候不是“撤回”,而是“降级完成”。
我现在会强制加的 4 个字段
只要一条链路涉及多个工具,我现在几乎都会要求每个节点至少有:
stepIdattemptidempotencyKeycompensationAction
没有这几个字段,系统一旦出问题,你很难明确回答:
- 失败发生在哪一步
- 这一轮是不是重复执行
- 可不可以安全重跑
- 如果不能重跑,要怎么补偿
回退设计越晚做,代价越高
因为一旦链路已经串起来,后面再补回退,通常意味着你要回头改:
- 状态表
- 日志字段
- 外部接口幂等
- 节点边界
这些往往不是一句“再加个 try/catch”能解决的。
所以我现在宁愿在设计阶段慢一点,把回退先问清楚,也不愿意上线后在事故里边跑边补。
总结
工具链越长,回退策略越应该先设计。因为链路一长,失败就不再是偶发分支,而是系统必经状态。
真正稳的系统,不是没有失败,而是每一种失败都有预先定义的处理方式:哪里能重试,哪里要查状态,哪里该降级,哪里必须补偿。回退策略越早成为架构的一部分,后面的工具链才能越长越稳,而不是越长越脆。
