AI Agent 测试死循环烧掉一半配额?日志逆向工程与熔断机制复盘

发布时间:2026/9/9 18:21:41
AI Agent 测试死循环烧掉一半配额?日志逆向工程与熔断机制复盘 上个月我把 Claude Code 接进日常工作流帮我在一个 Python 服务端项目里改代码、补测试、修 CI 问题。前一周用得很顺写 CRUD 接口、整理类型注解、修单元测试都比我预期快结果到周末打开用量面板一看人直接愣住了一周时间烧掉了整个月度配额的一半。按正常节奏算这一周应该只占 10% 到 15% 才对。我一开始怀疑是并行开的会话太多或者是哪个后台进程没杀干净。后来把本地日志翻出来做了几天“逆向工程”才找到真正的元凶Agent 在一个测试驱动的修复流程里反复执行同一个 pytest 命令陷入了一场极其低效的自杀式循环。表面看它很努力一直在改代码、跑测试、看结果实际上每一次循环都在做无用功还让上下文窗口越撑越大单次请求成本水涨船高。这篇文章我会完整复盘这次配额事故怎么通过日志和工具调用记录还原 Agent 的真实行为问题到底出在哪以及后来我加了哪些“刹车”机制把同样工作量的配额消耗从 50% 压回了 15% 左右。如果你也在用 Claude Code 或类似的 AI Agent 做开发这些排查思路和防护手段可以直接抄作业。1. 事故现场一周烧掉一半配额我的第一反应是算错了1.1 我以为的正常使用节奏先交代项目背景。这是一个中等体量的 FastAPI 服务带 pytest 测试单体仓库大概 60 多个测试文件。我平时让 Claude Code 干的事情主要分三类实现新 API endpoint、修复失败的单测、给模块补类型注解和 docstring。在事故那一周我并没有开什么大规模重构任务也没有让它处理全仓库级的自动化改造。每天大概 30 到 50 次以内的对话交互任务粒度都很小按道理配额消耗是可控的。我给自己定的心理预期是一周消耗月度整体配额的 10% 到 15%这样一个月撑下来还有不少余量。结果到了周四晚上我随手打开用量面板瞥了一眼数字停在差不多 45% 的位置。当时我还安慰自己可能这周任务确实比平时多周末应该会缓下来。结果周五还没过完消耗已经突破了 50%。这个数字让我没法淡定了因为我粗略一算如果不做干预月底之前额度肯定提前见底。1.2 异常出现的具体症状第一反应是“会不会记错时间窗口了”。我仔细核对了用量面板里的时间维度确认这一波消耗就是从周一开始的跟我的实际使用时间线完全对得上没有历史包袱。第二个怀疑对象是并行子任务太多。Claude Code 支持分多个会话同时干活我偶尔会开两三个会话分别处理不同模块。我把每个会话的活跃时长和工具调用频率翻出来看了一遍并没有发现某个会话特别夸张排除。第三个排查方向是我最不愿意承认的会不会某个会话从白天一路挂到了深夜后台还在跑任务。因为我确实点过几次CtrlC但不确定是不是所有子进程都被干净地杀掉。这个怀疑让我花了不少时间在进程管理上最后也没有实质性发现。三个怀疑全部落空后我意识到不能靠猜了得看底层数据。我决定对本地日志做一次彻底的逆向分析——不是传统意义上的反编译而是通过 Agent 留下的每一次工具调用轨迹重构它这一周到底做了什么、按什么顺序做、每件事花了多少成本。这个思路后来救了我因为日志挖出的真相和我最初的猜测完全不一样。2. 逆向工程从日志轨迹还原 Agent 的行为路径2.1 第一步翻日志确认信息源Claude Code 会把每次会话的完整记录写成本地 JSONL 文件。不同版本的存放路径略有差异就我用的这个版本来说会话数据放在~/.claude/projects/项目名/目录下每个会话对应一个.jsonl文件。老版本可能在~/.claude/logs/下面如果你找不到把两个路径都扫一遍就行。每行 JSONL 记录一个事件包括用户输入、Agent 的回复、工具调用、工具执行结果、API 元信息等。虽然没有直接的“本次请求成本”字段但根据模型名称、输入输出 token 数量部分事件会带 usage 数据和事件类型可以很精确地还原出每一次 API 调用的规模。我写了个 Python 脚本把事件全部读进来先看概览import json from pathlib import Path from collections import Counter def load_events(root): events [] for f in Path(root).rglob(*.jsonl): with f.open(encodingutf-8) as fp: for line in fp: line line.strip() if not line: continue try: events.append(json.loads(line)) except json.JSONDecodeError: continue return events if __name__ __main__: events load_events(Path.home() / .claude / projects) print(total events:, len(events)) types Counter(e.get(type, unknown) for e in events) for t, c in types.most_common(15): print(t, c)跑完之后第一眼就很刺眼Bash 工具调用非常密集而且集中在某两天的晚上时段。光看数量就比 Read 和 Edit 多了好几倍这在我们这种常规开发任务里是不正常的——改代码的频率不应该比跑命令低那么多。2.2 第二步还原请求序列拼出执行节奏事件统计只能说明“命令多”还不能说明“命令在同一个坑里反复跑”。我接着把 Bash 工具相关的事件拆出来按时间排序看每一天的工具调用是怎么串起来的。bash_cmds [] for e in events: if e.get(type) assistant: content e.get(message, {}).get(content, []) for block in content: if isinstance(block, dict) and block.get(type) tool_use: name block.get(name, ) if name Bash: inp block.get(input, {}) cmd inp.get(command, ) bash_cmds.append((e.get(timestamp, ), cmd)) # 按时间排序打印前50条 for ts, cmd in sorted(bash_cmds)[:50]: print(ts, cmd[:120])看到输出之后我整个人是愣住的。在周二晚上 20:14 到 21:02 这 48 分钟里Bash 命令出现了 22 次其中有 18 次是同一行命令python -m pytest tests/test_order_flow.py::test_create_order_with_coupon -x -s18 次重复跑同一个测试用例中间穿插的 Read 和 Edit 操作全部集中在app/services/order.py和app/api/order_api.py这两个文件上。也就是说Agent 在那 48 分钟里只做了一件事改一段代码 → 跑同一个测试 → 失败 → 再改 → 再跑循环了接近二十轮。这是我第一次真正意识到Agent 的行为轨迹可以像视频录像一样被完整还原。只要日志里保留了工具调用和时间戳完全可以把一段会话拆成逐帧画面来看。而“逐帧”看下来你就能看穿它每个决策到底是在朝正确方向走还是在原地空转。2.3 第三步统计模式确认成本重心为了把“成本重心”坐实我又统计了两组数据。第一组是工具调用类型的消耗占比。我按事件里能拿到的 token 估计值粗略算了一下——有 usage 字段的直接用没有的就按输入输出文本长度估算。结果 Bash 工具这一项占所有工具调用消耗的 60% 左右其中 pytest 相关命令又占了 Bash 消耗的 70% 以上。第二组是重复命令的出现次数。同样的测试命令在同一天出现超过 5 次的比例非常高覆盖了周二和周四两个晚上。这说明不是偶发事件而是 Agent 多次陷入同一种循环。到这里谜底已经很清晰我的配额不是被“正常使用”烧掉的而是被一个隐藏在测试驱动流程里的死循环吃掉的。Agent 确实在做它被委派的工作——修复测试、保证通过——但它完全没有意识到每次重试的投入和产出已经严重失衡。它每次都以为“这次改动应该能让测试过了”结果每次都当面撞墙。3. 真凶落网Agent 在测试循环里的自杀式反复3.1 一个可以复现的死循环案例我把周二那段会话单独还原出来理清了整个循环的触发链条。当天我给 Agent 的任务是“修复test_order_flow.py里失败的测试确保 order 创建流程相关用例全部通过。”Agent 的第一反应是读测试文件、读相关业务代码然后跑一遍测试收集失败信息。命令是python -m pytest tests/test_order_flow.py::test_create_order_with_coupon -x -s第一次运行直接抛OperationalError: no such column: coupon.code。这是一个典型的数据库迁移问题——测试库还是旧 schema新加的列没有迁移进去。正常经历过这种坑的开发者看到这个报错的第一反应是检查迁移脚本和测试夹具而不是去改业务代码。但 Agent 没有这个“条件反射”。它的推理链条把“测试失败”直接映射成了“业务代码有问题”。于是它开始读order.py里创建订单的逻辑试图找出为什么coupon.code不存在。第一次 Edit 之后重新跑测试报错没变第二次它换方向去读 SQLAlchemy model 定义怀疑是模型里没有声明这个字段于是改 model但数据库迁移层面的问题改 model 声明也解决不了第三次它甚至翻了 fixture 文件想往测试夹具里补数据但测试数据库本身没有跑过新迁移补 fixture 同样无效。整个过程里它始终没有做两个我最期待的动作第一查一下迁移状态第二把报错信息当成“环境问题”来处理。它更不会想到这个测试在本地本来就跑不过因为它依赖前置的 schema 迁移步骤而迁移只在 CI 里自动执行。3.2 致命盲区到底在哪里这个案例背后暴露了三个叠加的盲区这才是“致命盲区”的真正含义。盲区一Agent 无法区分测试失败的类型。测试失败至少有三类业务代码 bug 导致的失败、测试环境/基建问题导致的失败比如数据库没迁移、端口被占、mock 没配好、测试本身写得不稳定或断言不合理的 flaky。有经验的开发者靠直觉能在前几秒内做初步分类但 Agent 的默认策略基本是“失败 我要改代码”分类能力非常弱。盲区二Agent 没有熔断机制。一个正常开发者在连续三次看到同样的报错后一定会停下来重新审视前提条件——是不是环境问题是不是这个命令在本地本来就跑不过但 Agent 的默认策略是只要任务没完成就继续尝试直到上下文窗口接近上限或配额耗尽。它不会“痛苦”所以也不会“停下来想想”。盲区三Agent 对“验证成本”没有感知。每次跑测试工具输出会整个塞进上下文。pytest 失败带堆栈可能要几百到几千 tokenAgent 读完这些堆栈再思考再改代码又是一轮不小的消耗。更关键的是这个消耗有强烈的正反馈效应上下文越长后面每一轮的输入 token 就越多单次请求成本就越高越到后面越贵。3.3 成本的正反馈为什么看似小循环会吃掉大配额我专门按 token 量级做了个粗略估算表动作单次估算 token 消耗说明运行一次 pytest含堆栈输出800 ~ 2500取决于失败堆栈长度和 -s 输出量读取一个待修改文件300 ~ 800取决于文件长度按实际读取内容计算一次代码编辑与后续回复1500 ~ 3000包含 diff、推理过程、总结上下文累积带来的额外成本逐轮递增前面所有对话历史都会进入后续请求按 18 次循环来算光 pytest 命令和代码修改的直接消耗就在 5 万 token 以上。真正可怕的不是单次消耗而是上下文累积到第 18 轮时模型要“读完”前 17 轮的所有历史输入 token 已经爆炸式增长。换算到实际配额这一晚上就相当于吃掉了整个月度配额的几个百分点。这还没算模型思考过程产生的推理 token。Claude Code 在后台的 reasoning 也会计入消耗在它停下来之前每一刻都在烧钱。4. 对症下药给 Agent 装上刹车片4.1 任务设计层面的约束搞清楚事故原因之后我先从最便宜的地方入手改任务设计。之前我下命令的习惯太“老板”了上来就给开放式目标比如“把测试全部跑通”“把这个模块的错误处理补全”。这种任务对 Agent 来说自由度太高它很容易在验证环节原地打转。现在我改成更收紧的指令模板。核心原则是明确范围、明确验证方式、明确终止条件。请先运行 python -m pytest tests/test_order_flow.py --collect-only 确认能收集到用例。 然后只运行该文件不要运行整个测试套件。 如果报错中包含 OperationalError / ConnectionRefused / EnvironmentError 等环境类关键字请停止修改业务代码在回复里列出环境问题排查清单。 同一个测试命令如果连续执行 3 次仍然失败请停止报告当前状态和后续建议。 只允许修改 app/services/ 和 app/api/ 下的文件不要动 tests/ 目录。听起来啰嗦但实测下来效果立竿见影。Agent 不再把每一次失败都当成“继续改代码”的信号而是会在第 3 次失败后主动停下来把问题交还给我。代价是它自动修复那些简单 bug 的成功率确实降了一点——因为有些问题确实需要多试几次才能定位——但整体配额消耗下降了一个数量级这笔账非常划算。4.2 Claude Code 配置层的防线任务指令只是个软约束更可靠的是把规则写进工程配置让 Agent 每次都自动加载。Claude Code 支持通过项目根目录的CLAUDE.md给 Agent 附加“人设”和规则。我在这里写了专门的测试行为规范## 测试执行规范 - 运行测试前先执行 collect-only 确认测试可收集如果 collect 阶段报错说明是测试基建问题禁止修改业务代码。 - 连续运行同一测试命令最多 3 次第 3 次失败后必须停止报告失败模式和可能的环境原因。 - 失败信息中出现 OperationalError、ConnectionError、TimeoutError、PermissionError 等关键词时优先怀疑环境与配置而不是业务逻辑。 - 所有测试运行命令建议自动追加 --timeout30防止用例挂起导致长时间无输出。 - 本地开发调试只跑被修改模块的关联测试禁止默认全量执行 pytest。这份文件在会话开始时会被自动加载相当于给 Agent 装了一本“操作守则”。实测下来这些规则能有效干预默认行为模式。尤其是连续失败 3 次的熔断规则直接把之前那个 18 次循环的案例压到了 3 次以内。如果你用的是较新的 Claude Code 版本还可以尝试配置 pre-tool-use hooks在工具调用前检查即将执行的命令是否触发了“重复命令熔断”条件命中就直接拒绝。具体语法每个版本略有差异这里不展开但思路是可行的在工具调用这一层做硬拦截比靠模型自觉可靠得多。这是硬刹车片规则是软刹车片两个配合起来才安全。4.3 测试基建的可诊断性改造除了管住 Agent我还把测试工程本身做得更“抗瞎搞”。第一件事统一测试夹具的自包含性。以前不少 fixture 依赖真实数据库连接经常出现“本地先跑迁移才能跑测试”的隐性前提。我把这些依赖尽量重塑为自包含的用tmp_path和monkeypatch隔离文件系统与外部服务数据库依赖换成内存 SQLite 或测试容器。改完之后本地测试对运行环境的假设大幅减少报错信息也更贴近真实代码问题。第二件事给所有用例加超时。我们用pytest-timeout给整个测试套件设置了默认 30 秒超时。这个改动对 Agent 特别友好即使某个用例真的挂起也不会出现完全无输出的“死等”状态失败信息里至少会带Timeout字样而Timeout是规则里明确要求它优先怀疑环境问题的关键词。第三件事给用例分层次打标签。我把依赖网络、数据库等外部资源的用例统一标记为pytest.mark.integration并配置成默认不执行。Agent 日常开发只跑单元测试集成测试留给 CI# pyproject.toml 的关键配置 [tool.pytest.ini_options] markers [ integration: depends on external resources, ] addopts -m not integration --timeout30打完标签之后Agent 在本地跑测试几乎不会碰到需要真实外部服务的用例环境类失败率大幅下降。之前那种“疯狂重试同一条命令”的场景触发条件少了一大半。5. 验证与复盘配额从 50% 降回 15%5.1 同任务下的对照组为了确认这些改动不是心理安慰我专门做了个不严谨但足够说明问题的对照。事故后第二周我用同样类型的任务修接口、补测试、处理代码审查意见干了几乎等量的活对比结果如下维度事故周修复后一周周配额消耗约 50%约 15%其中测试相关消耗占比约 30%约 5%单个会话最长工具调用次数4012同命令重复执行超 3 次的事件5 次0 次最夸张的改善来自重复循环那一项。不是说 Agent 突然变聪明了而是我给了它“什么时候该停下来”的边界条件。以前它靠本能试错现在它靠规则工作行为轨迹完全不同。5.2 从这次事故中总结的三条规律第一Agent 的效能瓶颈不在写代码而在验证环节的收敛速度。它改代码的能力很强但“确认这次改动是否有效”的过程如果不可控成本就会失控。第二“测试失败”对 Agent 来说是一个非常模糊的信号它默认的归因方式就是“代码有问题”这是最需要人工干预的地方。任何能帮它区分“代码问题”和“环境问题”的手段都在同时帮你省时间和省配额。第三逆向追踪日志应该成为使用 Agent 的日常习惯。不用每次都做精细分析但至少每周扫一眼工具调用统计重点关注重复命令和 Bash 调用占比。成本异常一定会在日志里留下清晰的模式关键是你要去看。5.3 长期维护建议最后分享几个我目前仍在沿用的习惯。每周五花十分钟跑一遍日志统计脚本看看有没有新的重复命令热点。给 CLAUDE.md 里加的任何新规则都先跑一个真实任务验证效果。凡是涉及外部依赖的用例一律打 integration 标签绝不混入默认测试集。另外我也养成了一个“手动熔断”的习惯如果远程观察到一个会话在同一命令上反复执行我会直接结束会话换一个新会话接着干。新会话上下文干净解决同样问题的成本往往比在旧会话里硬扛低很多。这个技巧听着朴素但省钱效果极好。那天在用量面板看到 50% 数字的时候我第一反应是工具坏了或者我操作失误。现在回头看其实问题一直摆在日志里差的就是一个“慢下来看录像”的动作。以后谁再跟我抱怨 Agent 吃配额我都会先问一句你翻过它的工具调用记录了吗