上周末我做了一次规模不小的代码迁移,用Claude Code做主力,一口气启动了449个并行子Agent。结果半夜手机被报错信息轰炸,爬起来对着日志一排排看,449个子Agent里至少有438个是被限额拦腰砍断的。更要命的是,这438个任务的产出不是缺胳膊少腿,而是多数连第一轮输出都没能留下,等于全部从头重做了一遍。那天晚上我一边骂骂咧咧补任务,一边把整条链路从任务设计到CLI参数到外层调度脚本全过了一遍。今天把这次事故里踩出的经验写下来,给所有正在用Claude Code跑批量任务的同行提个醒。
1. 惨案复盘:449个子Agent怎么会被一锅端
1.1 我当时的任务拆法
那批任务是给仓库里几十个历史模块做统一重构,包含接口迁移、废弃API替换、单元测试补全和文档更新。每个模块的工作量不小,但彼此之间几乎没有依赖。为了赶进度,我用主控脚本把每个模块拆成一个独立任务,交给一个子Agent去完成,理论上模块并行处理,全跑完再统一汇总。
这个思路乍一看没什么问题:模块间独立,子Agent之间不需要通信,即使某个失败也不会影响其他模块。我甚至特意把每个子Agent的工作目录隔离开,避免多个Agent写同一个文件造成冲突。任务脚本大约生成了449个独立的prompt文件,每个prompt里写了模块路径、重构目标、输出文件要求,以及一句"完成后把结果报告给主控"。
问题出在执行方式上。我是通过并行启动多个claude非交互进程来模拟子Agent的,这意味着每一个进程都在同一个账号下消耗配额,也都共享同一个API速率限制。进程多、任务重、上下文长,灾难几乎是必然的。
1.2 所谓"被限额杀掉",现场都长什么样
凌晨两点半,我看到的报错并不是单一的。翻日志的时候发现"被杀"的原因大致有三种:
- 上下文长度超限:子Agent在分析模块时读了大量源文件,加上中间思考过程,很快就触碰到上下文窗口上限。Claude Code虽然有自动压缩(compact)机制,但压缩之后有些关键细节丢了,任务变得不可信,CLI进程直接终止。
- API速率限制:我同时开了接近40个并行进程,打到API的每分钟请求数和token数很快超过限额,接下来所有请求都开始报速率限制错误。部分子Agent在某个请求被拒之后没有重试逻辑,直接失败退出。
- 账号额度用尽或使用量异常:单日用量触顶,后启动的子Agent全部无法获取模型响应,进程在启动阶段就被杀掉。
这三种情况在现场日志里往往交替出现。最难受的是,很多子Agent已经跑到一半了,比如分析完了、改了一半文件,被限额杀死后没有任何恢复手段,进程退出码非零,输出没有落盘,甚至连它到底改过哪些文件都不确定。我只能把这类任务标记为失败,重新丢回队列。
1.3 438这个数字是怎么统计出来的
任务全部结束后,我写了一个小脚本遍历所有子任务的日志目录,按退出状态和产出文件数做了个统计:
| 状态 | 数量 | 占比 | 说明 |
|---|---|---|---|
| 正常完成且有完整产出 | 11 | 2.4% | 少数运气好的短任务 |
| 被限额终止,但产出文件完整可用 | 0 | 0% | 一个都没有 |
| 被限额终止,产出文件缺失或损坏 | 438 | 97.6% | 需要从头重跑的 |
| 其他错误 | 0 | 0% | 相符 |
也就是说,449个任务里只有11个交了完整结果,剩下438个全部要重做。这个数字让我意识到问题不是运气,而是我的任务设计和执行方式从根上就错了。子Agent在执行过程中产生的任何中间状态都没有被保留,一旦被杀,唯一的选择就是从零开始。
2. 为什么子Agent一被限额就会从头重做
2.1 Claude Code子Agent的执行模型决定了恢复成本
我用了一个词评价这次事故中的子Agent:无状态函数。你给它一个输入prompt,它在一个隔离的上下文里运行,最终只向主控返回一个结果,中间没有任何共享内存,也不保留执行痕迹。正常情况下这挺好的,隔离、清晰、不容易污染全局。
但代价是,一旦进程在返回结果之前被杀死,主控拿到的只有失败,而不是"执行到某一步的进度"。Claude Code不是常驻服务,它的工作内存就是上下文。上下文没了,工作进度就全没了。这跟你打开记事本写文章没保存就断电是一样的,哪怕你脑子里记得前半段,程序本身不会帮你续写。
我这次踩的坑就在于,我让每个子Agent承担了"分析-修改-测试-写文档"一整条完整流水线。任何一步被限额卡住,之前所有步骤的成果都不存在。没有中间产物,没有分步落盘,没有断点续跑。重做是唯一的路径。
2.2 上下文膨胀才是最大的杀手
统计显示,438个失败任务中有接近八成是上下文长度触顶导致的中断。为什么子Agent的上下文涨得这么快?因为我把任务全部塞进了一个prompt里,告诉它"请先阅读src目录下所有相关文件,然后重构,再补测试,最后更新文档"。于是它老老实实把几十个源文件读进去,还要在思考过程中引用大量代码片段,上下文迅速膨胀到十几万token。
Claude Code有自动上下文压缩机制,会在接近上限时对早期对话做摘要压缩。但压缩有个致命问题:关键细节被压缩掉之后,子Agent对代码的理解会出现偏差。更糟糕的是,压缩过程本身也消耗时间,一旦压缩后仍然超限,进程照样会死。就算不直接死,压缩后的输出质量也明显下降,很多任务实际上已经无法产生正确的重构结果,我后来验证时发现这类"假完成"比直接失败更坑人。
经验是:不要给子Agent塞下整个模块的所有信息,更不要在单个任务里要求它完成多个阶段。你要做的是把上下文控制在它真正需要的范围内。
2.3 并行放大限额问题
那次惨案的另一个放大器是并行度。我开了40个进程同时跑,本来是想提速,结果每个进程都在疯狂请求API。速率限制就像单车道收费站:40辆车同时冲进同一个收费口,后面的全被拦下。被拦下的进程如果内部没有等待和重试机制,就直接崩溃退出。
并行还有另一个问题:共享同一个账号的额度。其中一个任务多消耗了额度,就会拖累其他任务。我是在事后统计token消耗时发现,某些子Agent因为上下文失控,单个任务就烧掉了巨量token。这些"大户"把额度吃光之后,其余任务全部陪葬。
所以并行本身是好事,但必须给整个batch设置总预算上限,同时限制并发的进程数。盲目追求并行只会让限额问题从单点爆发变成群体性覆灭。
3. 把任务的"可恢复性"设计进去
3.1 拆任务:最小可交付单元加上中间产物落盘
第二次跑同样的迁移任务时,我不再让一个子Agent干完整条流水线,而是把流水线拆成四类独立任务:分析、修改、验证、写文档。
分析任务只读代码,输出一份结构化的JSON,包含需要修改的文件列表、改动类型和风险点。这个JSON作为唯一产物落盘。修改任务只接收JSON清单,逐文件执行改动,每改完一个文件就把该文件的diff存下来。验证任务只负责编译和运行测试,把结果写进报告。写文档任务只读取最终的diff和测试报告,生成文档。
每一类任务都只做一件事,上下文可控,输出明确。更关键的是,任何一个任务被杀,我们损失的只是一个步骤,而不是整条流水线。比如修改任务杀到一半,没关系,已经落盘的diff还在,重新跑一遍修改任务时只处理那些没有diff的文件就行。
我把这个过程叫做"最小可交付单元"设计。每个子Agent的产物必须是一个可独立使用的文件,而不是一句"我完成了"的总结。文件存在就代表这个步骤完成了,文件缺失就代表没完成,主控脚本只需要检查文件是否存在,就能准确判断哪些任务需要重跑。
3.2 给每个子Agent装上"保险丝"
为了让单个子Agent不失控,我调整了启动参数。核心思想是给任务设置显式边界。
- 给每个子Agent设置最大turn数,避免它在某一步循环里无限打转。我在实际配置里把turn上限设为50到80,正常情况下足够完成一个单步骤任务,一旦超过这个数,进程自动终止,由外层脚本接管重跑。
- 在prompt里明确要求子Agent每执行完一个小步骤就输出一次进度。这个进度不是给用户看的,而是给日志系统用的。进度信息会被外层脚本解析,写入任务状态文件。这样即使被杀,我也能从最后一条进度知道它死在哪。
- 不让子Agent使用无限长思考模式。虽然更有创意的思考在某些任务里效果好,但代价是token消耗暴涨,更容易触发额度上限。对批量任务来说,稳定比聪明更重要。
这些"保险丝"不会让任务跑得更快,但能让任务死得明明白白。即使死了,外层也能快速判断是否需要重跑、从哪里重跑。
3.3 外层任务队列加自动重试:被杀也能续跑
单靠Claude Code自己的行为是防不住所有限额的。为了应对1.3描述的群体性重做问题,我在外层加了一个简单的任务队列脚本。大概逻辑是这样的:
- 任务清单以文件形式存在一个
pending/目录里,每个任务一个JSON文件。 - 调度器从
pending/里取任务,启动一个claude非交互进程去执行。 - 进程结束时不管成功失败,调度器都检查产物文件。
- 产物文件存在且校验通过,任务文件移到
done/;产物文件缺失,任务文件留在pending/,计数加一,过一段时间重新调度。 - 重试超过三次仍然失败,任务移到
failed/并发送告警。
伪代码大概是这个感觉,用Python写的话可以更简洁:
import json, subprocess, pathlib, time pending = pathlib.Path("pending") done = pathlib.Path("done") failed = pathlib.Path("failed") for task_file in list(pending.glob("*.json")): task = json.loads(task_file.read_text()) for attempt in range(3): result = subprocess.run( ["claude", "--print", "--output-format", "json", task["prompt"]], capture_output=True, text=True, timeout=600 ) output_marker = pathlib.Path(task["expected_output"]) if output_marker.exists() and output_marker.stat().st_size > 0: task_file.replace(done / task_file.name) break time.sleep(2 ** attempt) # 指数退避 else: task_file.replace(failed / task_file.name) print("任务彻底失败:", task_file.name)得益于每个子任务都是独立文件,重跑一个任务的开销只占整个batch很小一部分。第二次跑同一批迁移,最终只有9个任务在第一次执行失败后通过重试解决了,真正需要人工介入的只有3个。虽然仍不是100%完美,但比第一轮的438个从头重做好太多了。
4. 关于限额调优和参数实测的几个心得
4.1 哪些设置真的能减少被限额杀掉
我在前后两轮任务里对比了不同配置的效果,有几点值得分享。
- 输出格式用JSON而不是纯文本。非交互模式下使用
--output-format json或stream-json,既能结构化解析,也能减少模型为了包装回答而生成的废话token。 - 给prompt加"只输出结果,不输出理由"的约束。很多人不以为意,但Claude Code默认会解释它做了什么。在批量任务里,这种解释性文字消耗了可观token,还容易触发上下文超限。明确约束之后,单个子Agent的token消耗平均降低了20%到30%。
- 按任务复杂度选择不同模型。简单的分析和落盘任务,用规格更轻的模型足够;只有修改代码这种高难度任务,才动用最强模型。预算压力至少减半,额度上限撑得更久。
- 适时使用上下文压缩配置。Claude Code在某些版本里可以通过环境变量或配置开关控制自动压缩策略。我的建议是让压缩阈值稍微保守一点,提前压缩,避免在临界点因为压缩失败被杀。
这些调整不一定适合所有场景,但如果你也在批量跑子Agent,可以对照自己的日志看看哪种限额占比最高,然后针对性调整。
4.2 哪些"优化"是坑
第一轮优化过程中我也走过弯路。比如想当然地开启了极长的思考链模式,以为能让子Agent更仔细地处理重构逻辑,结果单个任务的上下文和token用量直接翻倍,还没跑到一半就被额度过高压死。后来又试着把并行度降到5,虽然稳定了,但总耗时变成原来的三倍。平衡点是通过实验找出来的,最终并发数设置在8到12之间,配合队列重试,整体进度和稳定性都还能接受。
另一个坑是试图在子Agent内部做"多轮自查"。我让每个子Agent修改完代码后自己再检查一遍,看起来合理,实际上等于把两个任务塞进了一个上下文里,检查和修改共享同一份可能已经膨胀的上下文,反而更容易触发超限。正确的做法是让验证任务独立运行,使用新的上下文,这样即使检查失败,修改产物还在,顶多重跑一次验证。
4.3 日志审计的实用命令
出了这么大事,没有日志就抓瞎。我的所有子Agent进程都会把标准输出和标准错误重定向到各自的任务日志文件。事后审计我用的是几个很基础的Linux命令:
# 统计所有日志中的退出码分布 grep -h "exit_code" logs/*.log | sort | uniq -c # 找出所有上下文超限的日志 grep -l "context_length_exceeded" logs/*.log # 统计每个任务消耗的token量 grep -h '"total_cost_usd"' logs/*.jsonl | awk -F'"' '{print $4}' | sort -n | tail配合stream-json输出,这些日志里有每次请求的token消耗、错误码、模型名称等字段。审计频率可以做成定时任务,门槛也很低。我现在的标准是每晚跑一次统计,只要失败率超过5%,第二天早上就要检查是任务设计有问题还是参数需要调整。
5. 我现在的标准做法:三层防护
5.1 任务设计层:生产者与消费者分离
第一层防护在任务还没启动前就生效。我把任务清单当作生产者,子Agent当作消费者。任务清单只描述要做什么和产物落在哪,不包含任何业务逻辑。每个清单里的任务之间没有依赖,也没有顺序要求。这样即使某个子Agent被杀,只要它对应的清单文件还在,重跑就只是重新消费一次。
同时坚持"一个子Agent只输出一个核心产物"的原则。要么是一份分析JSON,要么是一个diff补丁,要么是提交记录,要么是测试报告。不要让他同时输出好几种东西,否则被杀后你很难判断哪些完成了哪些没有。
5.2 执行编排层:外部接管可靠性
第二层防护就是前面提到的任务队列脚本。但脚本不能只是傻傻地重试,还得附带状态记录和git检查点。因为每次子Agent修改文件可能会污染工作目录,我在子Agent执行前自动创建一个git分支,任务结束后根据产物质量决定是否合并。如果任务失败,直接切回干净分支,彻底避免失败任务留下的半改动影响后续任务。
对于特别重要的任务,我会在子Agent运行期间定时做git commit。由于每一步改动都提交了,哪怕子Agent突然被杀,工作区里也保留了已提交的完整状态。重跑的时候先检查git log,如果已经有一个符合要求的提交,那就直接判定任务完成,不用真跑。
5.3 事后分析层:每天看数据,每周调参数
第三层防护不是自动化,而是习惯。我每天看一遍失败日志的统计表,关注四个指标:失败率、失败原因分布、平均token消耗、重试成功比例。每周根据这些数据调整任务拆分粒度、并发数和模型选择。
刚开始我觉得这是多余的,但经历了449比438那次事故后,我认识到批量任务的核心不在于避免失败,而在于让失败的成本足够低。数据分析是让成本变低的唯一依据。只要你能准确说出哪种限额在什么条件下发生,你就能针对性地改任务设计或配置。数据不会骗人。
6. 这次事故教会我的几件事
如果只挑一条最重要的经验,我会说:不要让子Agent去完成一个"不可重入"的任务。所谓不可重入,就是这个任务一旦启动,任何一次失败都只能从头再来。正确的做法是把任务设计成可重入的,任何一步失败,重跑时都能跳过已经完成的部分。
我能看到的最好状态是:任务队列里跑着上百个子Agent,即使中途杀掉一半,调度脚本也能从断点恢复,最终所有任务全部完成。这个目标可以通过任务拆分、产物落盘、外层重试和git检查点实现,并不复杂。
另外一个很实际的心得:别在深夜跑不熟悉的批量任务。第一轮那449个子Agent我选择在凌晨启动,想着睡一觉起来看结果,结果半夜爬起来救火。后来所有新任务我都会先用小批量试跑,确认日志结构、错误码和产物都符合预期,再放大量级。这个习惯帮我省下了无数个凌晨。
说到底,Claude Code的子Agent是工具,不是保姆。它能干很多的活,但可靠性需要掌握在使用者手里。设计好任务边界,写好中间产物,搭好重试机制,批量任务才能真正变成省心的事。