如何实现 Claude Code 和 Codex 等 Agent CLI 的自动重试
什么 Agent CLI 的自动重试比普通重试更难
这个问题提得很实在,我们直接上结论:Agent CLI 的自动重试,难点不在“隔几秒再试一次”,而在“还能不能在原上下文里继续”。
你可以把它理解成一次长对话。普通 API 重试,更像电话占线再拨一遍;而 Agent CLI 重试,更像对方刚讲到一半信号断了,你得先判断要不要回拨,回拨以后要不要从头说,对方还记不记得刚刚聊到哪。谁说这两者是一回事呢?它们压根不是一个工程问题。
具体看,有四个难点最典型。
1. 它是流式的
一旦输出已经发给用户,你就不能像处理普通请求那样,把失败偷偷吞掉然后悄悄重来。因为前面那部分内容已经被看到了,再次重放时如果策略不对,前端很容易看到重复文本、错乱状态,工具调用生命周期也会一起乱套。这波不是玄学,是工程。
2. 它通常绑定会话上下文
Codex 这类 provider 会绑定 thread,Claude Code 一类实现也会有 continuation target 或等价的续跑上下文。真正能自动重试的前提,不只是“这个错误长得像暂时故障”,还包括“这次执行还有没有继续下去的载体”。
3. 它不是所有错误都值得重试
网络抖动、SSE idle timeout、上游临时故障,这些通常可以试一试。可如果你遇到的是认证失败、上下文已经丢了,或者 provider 根本没有 resume 能力,那继续重试多数不是恢复,而是在制造噪音。
4. 它需要边界
无限自动重试几乎总是错的。技术趋势可以热闹一阵子,工程规律往往会稳定很多年,其中一条就是:失败恢复一定要有边界。系统必须知道自己最多试几次、每次隔多久、什么时候该停手承认这回真不行了。
也正因为这几个特点,HagiCode 最后没有把自动重试写成某个 provider 里的几行 try/catch,而是把它提炼成一层共享能力。说到底,工程问题还是要回到工程方法里解决。
HagiCode 的做法:把重试从 Provider 里拿出来
HagiCode 当前这套真实实现,可以压缩成一句话:
共享层统一管理重试流程,具体 Provider 只负责回答两个问题:这个终态值不值得重试?当前上下文还能不能继续?
这件事不复杂,可是很关键。因为一旦把职责切开,Claude Code、Codex,甚至其他 Agent CLI 都能复用同一个骨架。模型会说,工具会变,工作流会升级,但工程上的基本盘一直都在那里。
第一层:用统一协调器管理重试循环
项目中的核心实现片段大概是下面这样:
internal static class ProviderErrorAutoRetryCoordinator |
|
{ |
|
public static async IAsyncEnumerable<CliMessage> ExecuteAsync( |
|
string prompt, |
|
ProviderErrorAutoRetrySettings? settings, |
|
Func<string, IAsyncEnumerable<CliMessage>> executeAttemptAsync, |
|
Func<bool> canRetryInSameContext, |
|
Func<TimeSpan, CancellationToken, Task> delayAsync, |
|
Func<CliMessage, bool> isRetryableTerminalMessage, |
|
[EnumeratorCancellation] CancellationToken cancellationToken) |
|
{ |
|
var normalizedSettings = ProviderErrorAutoRetrySettings.Normalize(settings); |
|
var retrySchedule = normalizedSettings.Enabled |
|
? normalizedSettings.GetRetrySchedule() |
|
: []; |
|
for (var attempt = 0; ; attempt++) |
|
{ |
|
var attemptPrompt = attempt == 0 |
|
? prompt |
|
: ProviderErrorAutoRetrySettings.ContinuationPrompt; |
|
CliMessage? terminalFailure = null; |
|
await foreach (var message in executeAttemptAsync(attemptPrompt) |
|
.WithCancellation(cancellationToken)) |
|
{ |
|
if (isRetryableTerminalMessage(message)) |
|
{ |
|
terminalFailure = message; |
|
break; |
|
} |
|
yield return message; |
|
} |
|
if (terminalFailure is null) |
|
{ |
|
yield break; |
|
} |
|
if (attempt >= retrySchedule.Count || !canRetryInSameContext()) |
|
{ |
|
yield return terminalFailure; |
|
yield break; |
|
} |
|
await delayAsync(retrySchedule[attempt], cancellationToken); |
|
} |
|
} |
|
} |
这段代码干的事情,其实非常朴素,但很有力。
- 中间失败先不直接透传,协调器先判断能不能恢复
- 只有重试预算耗尽,最终失败才真正回到上层
- 第二轮开始不再发送原始 prompt,而是统一发送 continuation prompt
这也就是为什么我前面一直强调,自动重试不是简单的“再请求一次”。它不是在补一个异常分支,而是在管理一条执行生命周其。听起来有点像产品经理,但工程上确实如此。
第二层:把重试策略快照化
另一个很容易被忽略的问题是:谁来决定这次请求是否开启自动重试?
HagiCode 的答案是,不要依赖某个“此刻的全局配置”,而是把策略做成 snapshot,跟着这次请求一起走。这样一来,会话排队、消息持久化、执行转发、provider 适配,都不会把策略弄丢。一次成功不叫体系,持续成功才叫体系。
核心结构可以简化成这样:
public sealed record ProviderErrorAutoRetrySnapshot |
|
{ |
|
public const string DefaultStrategy = "default"; |
|
public bool Enabled { get; init; } |
|
public string Strategy { get; init; } = DefaultStrategy; |
|
public static ProviderErrorAutoRetrySnapshot Normalize(bool? enabled, string? strategy) |
|
{ |
|
return new ProviderErrorAutoRetrySnapshot |
|
{ |
|
Enabled = enabled ?? true, |
|
Strategy = string.IsNullOrWhiteSpace(strategy) |
|
? DefaultStrategy |
|
: strategy.Trim() |
|
}; |
|
} |
|
} |
然后在执行侧再映射成 provider 真正消费的设置对象。这个做法的价值很直接:
- 业务层决定“该不该重试”
- 运行时决定“怎么重试”
两边各管一摊,互相不打架。很多问题不是不能做,只是没把代价算明白。把策略快照化,本质上就是在提前把代价算清楚。
第三层:Provider 只做终态判定和上下文判定
到了具体的 Claude Code 或 Codex provider,这里的职责反而很薄。你可以把它理解成增强,不要把它误会成代替。
以 Codex 为例,它最终接入共享协调器时,本质上只需要提供三样东西:
await foreach (var message in ProviderErrorAutoRetryCoordinator.ExecuteAsync( |
|
prompt, |
|
options.ProviderErrorAutoRetry, |
|
retryPrompt => ExecuteCodexAttemptAsync(...), |
|
() => !string.IsNullOrWhiteSpace(resolvedThreadId), |
|
DelayAsync, |
|
IsRetryableTerminalFailure, |
|
cancellationToken)) |
|
{ |
|
yield return message; |
|
} |
你会发现,真正属于 Provider 自己的判断只有两个:
IsRetryableTerminalFailurecanRetryInSameContext
Codex 看的是 thread 还能不能续上,Claude Code 看的是 continuation target 还在不在。退避策略、重试次数、后续 prompt,这些通通不该让 Provider 自己重新发明一遍。
这一层拆出来以后,HagiCode 接更多 CLI 的成本就低很多了。你不用复制一整套重试状态机,只要把“这个 provider 的边界条件”接进来就行。写得快,不等于写得稳;接得住,不等于接得好;能跑起来,也不等于能长期维护。
一个很容易做错的点:别把所有报错都当可重试
这次分析里,我觉得最值得单拎出来讲的,不是“怎么实现重试”,而是“怎么避免错误重试”。
最开始的问题切入口,是 Codex 少识别了一条 reconnect 报文。按直觉,很多人会选一个最小修法:往白名单里再加一条字符串前缀。这个思路不能说错,只是它更像 Demo 时期的解法,不太像长期维护的解法。
从当前 HagiCode 的落地来看,系统已经往更稳的方向走了一步。它不再只盯着某个字面字符串,而是把可恢复的终态统一交给共享协调器处理。这样做的好处很明显:
- 不容易因为某条文案的小改动就彻底失效
- 测试覆盖可以围绕“终态 envelope”展开,而不是单条硬编码文本
- 同一个 provider 的重试逻辑会更一致
当然,这里要立一个边界:更通用,不等于更宽松。只要当前上下文不能继续,哪怕报错看起来很像暂时故障,也不应该盲目 replay。
这点很关键。真正让人安心的,不是它偶尔灵一次,而是它大多数时候都靠谱。如果一个流程只能靠高手维持,那它离普及还差得远。
实践里最值得保留的三条经验
文章写到这里,差不多可以往实践层收一收了。如果你准备在自己的项目里实现类似能力,我最建议先守住下面三条。
1. 重试预算必须有边界
HagiCode 当前默认的退避节奏是:
- 10 秒
- 20 秒
- 60 秒
这个节奏不一定适合所有系统,但“有边界”这件事必须保留。要不然,自动重试很快就会从恢复机制变成事故放大器。别急着把名字起得太大,先看看这东西能不能在团队里活过两个迭代。
2. continuation prompt 要统一
项目里使用的是固定 continuation prompt,让后续 attempt 明确走“继续当前上下文”的路径,而不是重新发起一轮完整请求。这个能力不花哨,可是你真做项目时离不开。很多能力看起来像魔法,拆开以后不过是一套被打磨过的工程流程。
3. 共享库和适配层都要有镜像测试
这点我很想多说一句。很多团队会在共享运行时里写一层测试,然后觉得差不多了。其实不够。
HagiCode 这边之所以让我比较放心,是因为两层都补了测试:
- 共享 Provider 测“是否真的发生了自动续跑”
- 适配层测“最终错误和流式消息有没有被整理坏”
我这次也额外补跑了两组相关测试,结果都是 31 个用例全部通过。这个结果本身说明不了设计一定完美,可它至少能说明一件事:当前这套自动重试不是纸面方案,而是已经被代码和测试共同约束住的能力。Talk is cheap. Show me the code. 放到这里,恰好合适。
总结
如果把整篇文章压缩成一句话,那就是:
Claude Code、Codex 等 Agent CLI 的自动重试,最好不要做成某个 Provider 内部的局部技巧,而应该
更多推荐


所有评论(0)