工具报错之后既触发重试又触发回滚,最后任务状态对不上,常见原因不是某一侧写错了,而是两条链路在同一时刻都动了手:重试把已经在回滚中被删掉的东西又写回去,或者回滚先跑完、重试后跑完,日志里只剩最后一条结果。建议的做法是先把重试和回滚拆成两轮单独验证,各自看清楚副作用,再合并开启。放到 Muse-Glimmer-30B 这类带工具调用的模型链路上,模型侧通常只负责发起调用和解析返回,重试判定、退避、回滚执行一般落在编排层或工具网关,所以这两条链路要分别在编排层观察,而不是从模型的输出文本里猜。
适用场景:工具调用失败后既配了重试又配了回滚,任务状态却和预期不一致。处理方向:先只开重试、关掉回滚跑一轮,观察重复执行有没有产生额外写入;再只开回滚、关掉重试跑一轮,检查临时文件、已写入记录和外部请求产生的状态是否清干净;两轮都确认后再合并开启,并给每次操作打上任务 ID 与步骤号。验证方式:用同一份任务脚本重复跑,对比每轮的状态快照。风险边界:重试次数与退避要结合上游限流策略确认,回滚覆盖范围要结合外部系统是否能撤销来确认,不能假设回滚一定等价于“回到没执行过”。
把失败场景拆成可重试和不可重试两类
对所有错误一律重试,是最容易让状态变乱的做法。判断依据可以先用返回码和错误类型做粗分,不确定的归到不可重试一侧,交给回滚处理,比反过来更保守。
| 失败表现 | 归类 | 处理动作 |
|---|---|---|
| 限流(如 429)、连接超时、连接被重置、部分 5xx | 可重试 | 退避后重试,超过次数上限再进回滚 |
| 参数校验失败(如 400)、鉴权或权限不足(如 401/403) | 不可重试 | 不重试,直接标记失败并触发回滚 |
| 资源不存在(如 404)、返回体 schema 与预期不符 | 不可重试 | 同上,重试通常仍是同样结果 |
| 幂等键冲突、唯一约束冲突 | 需要结合环境确认 | 先确认是不是上一次调用已经成功,再决定重试还是直接当成功处理 |
这张表的意义在于:不可重试的错误不进重试链路,就不会出现“回滚已经删掉,重试又写回来”的交错。表里的返回码只是常见例子,实际要按你接入的工具接口文档和网关行为对齐。
只开重试、关掉回滚跑第一轮
这一轮把回滚开关置为关闭,只保留重试,目的是单独看重复执行会不会产生副作用。需要观察的位置至少要覆盖三处:目标系统的写入次数(同一条记录是不是被插了多条)、对上游或对工具的请求次数(是不是超过了预期次数)、同一任务的状态变化(任务表里状态是反复跳还是单向推进)。
RETRYABLE = {429, 500, 502, 503, 504} def call_with_retry(fn, task_id, step_no, max_attempts=3, base=0.5): for attempt in range(1, max_attempts + 1): resp = fn() log(task_id, step_no, attempt, resp.code) if resp.code in RETRYABLE and attempt < max_attempts: sleep(base * (2 ** (attempt - 1)) + random_jitter()) continue return resp return resp这是一个通用骨架,退避用指数加随机抖动,重试次数从 2 到 3 次起步通常够用;具体次数和 base 需要结合上游限流策略确认。验证方式:跑完后查一遍目标系统里同一业务键的记录条数,条数大于 1 说明这次调用不具备幂等性,重试前必须补幂等键,否则合并开启回滚时问题会更难定位。
只开回滚、关掉重试跑第二轮
这一轮反过来,关掉重试、只保留回滚,看的是回滚是否真的清干净。回滚覆盖范围建议逐项对照,不要只看主记录:
- 临时文件:本地或共享存储上这一步骤产生的临时文件、分片、中间导出物。
- 已写入记录:主表写入、关联表写入、状态字段被改动过的行。
- 外部请求产生的状态:第三方侧已创建的订单、已发出的消息、已提交的工单、已分配的资源占用。
- 任务上下文:内存或缓存里这一步骤留下的中间变量、锁、标记位。
检查方式是把回滚前后的状态各取一次快照,再按上面四项逐个比对。外部请求产生的状态往往是最容易漏的一项:如果对方接口不支持撤销,回滚只能做本地补偿记录,这一点要在设计阶段就写清楚,不能默认“回滚等于没执行过”。
两套都开时给每次操作打上任务 ID 与步骤号
前面两轮确认完,再把重试和回滚一起打开。这时日志必须能按任务串起来,否则又会回到分不清谁先动手的状态。字段建议至少包含:task_id、step_no、attempt、op(是调用还是回滚)、target(操作对象)、status、ts。其中 op 和 attempt 是关键,缺了这两个字段,重试和回滚的日志会混成一条线。
task_id 的生成位置建议放在任务入口,也就是请求进入编排层的那一刻,之后随上下文一路透传给每个步骤和每次工具调用;不要在下游或每个步骤里重新生成,否则同一次任务会散成多个 ID。检索方式可以直接按字段过滤,日志量不大时用命令行也能看:
grep "task_id=abc123" app.log | sort -k2把结果按 step_no 和 ts 排序后,应该能看到每个步骤下 attempt 递增、op 从 call 切到 rollback 的顺序。如果顺序看上去是乱的,说明有异步分支没带上 task_id,需要先补透传再继续验证。
用同一份任务脚本重复跑并对比状态
单次跑通不代表可复现。建议用同一份任务脚本重复运行 3 次,每次都在关键节点记录状态快照:写入记录条数、外部资源标识列表、任务最终状态。三次结果做差异对照,比只看一次日志更能暴露偶发问题。
| 轮次 | 重试次数 | 已写入记录 | 外部资源 | 最终状态 |
|---|---|---|---|---|
| 第 1 次 | 记录实际值 | 记录条数或主键列表 | 记录标识或“无” | 记录状态值 |
| 第 2 次 | 记录实际值 | 记录条数或主键列表 | 记录标识或“无” | 记录状态值 |
| 第 3 次 | 记录实际值 | 记录条数或主键列表 | 记录标识或“无” | 记录状态值 |
三次的“外部资源”和“最终状态”两列如果都能对齐,说明重试与回滚组合后行为稳定;如果有某一轮多出一条外部资源,优先怀疑那一轮的失败类型被误判成了可重试。差异本身不是结论,要回到带 task_id 的日志里,看那一轮到底是哪一步先动的手。