上个月我把 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 支持分多个会话同时干活,我偶尔会开两三个会话分别处理不同模块。我把每个会话的活跃时长和工具调用频率翻出来看了一遍,并没有发现某个会话特别夸张,排除。
第三个排查方向是我最不愿意承认的:会不会某个会话从白天一路挂到了深夜,后台还在跑任务。因为我确实点过几次Ctrl+C,但不确定是不是所有子进程都被干净地杀掉。这个怀疑让我花了不少时间在进程管理上,最后也没有实质性发现。
三个怀疑全部落空后,我意识到不能靠猜了,得看底层数据。我决定对本地日志做一次彻底的逆向分析——不是传统意义上的反编译,而是通过 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(encoding="utf-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 失败带堆栈可能要几百到几千 token,Agent 读完这些堆栈再思考再改代码,又是一轮不小的消耗。更关键的是,这个消耗有强烈的正反馈效应:上下文越长,后面每一轮的输入 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 等关键词时,优先怀疑环境与配置,而不是业务逻辑。 - 所有测试运行命令建议自动追加 `--timeout=30`,防止用例挂起导致长时间无输出。 - 本地开发调试只跑被修改模块的关联测试,禁止默认全量执行 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' --timeout=30"打完标签之后,Agent 在本地跑测试几乎不会碰到需要真实外部服务的用例,环境类失败率大幅下降。之前那种“疯狂重试同一条命令”的场景,触发条件少了一大半。
5. 验证与复盘:配额从 50% 降回 15%
5.1 同任务下的对照组
为了确认这些改动不是心理安慰,我专门做了个不严谨但足够说明问题的对照。事故后第二周,我用同样类型的任务(修接口、补测试、处理代码审查意见)干了几乎等量的活,对比结果如下:
| 维度 | 事故周 | 修复后一周 |
|---|---|---|
| 周配额消耗 | 约 50% | 约 15% |
| 其中测试相关消耗占比 | 约 30% | 约 5% |
| 单个会话最长工具调用次数 | 40+ | 12 |
| 同命令重复执行超 3 次的事件 | 5 次 | 0 次 |
最夸张的改善来自重复循环那一项。不是说 Agent 突然变聪明了,而是我给了它“什么时候该停下来”的边界条件。以前它靠本能试错,现在它靠规则工作,行为轨迹完全不同。
5.2 从这次事故中总结的三条规律
第一,Agent 的效能瓶颈不在写代码,而在验证环节的收敛速度。它改代码的能力很强,但“确认这次改动是否有效”的过程如果不可控,成本就会失控。
第二,“测试失败”对 Agent 来说是一个非常模糊的信号,它默认的归因方式就是“代码有问题”,这是最需要人工干预的地方。任何能帮它区分“代码问题”和“环境问题”的手段,都在同时帮你省时间和省配额。
第三,逆向追踪日志应该成为使用 Agent 的日常习惯。不用每次都做精细分析,但至少每周扫一眼工具调用统计,重点关注重复命令和 Bash 调用占比。成本异常一定会在日志里留下清晰的模式,关键是你要去看。
5.3 长期维护建议
最后分享几个我目前仍在沿用的习惯。
每周五花十分钟跑一遍日志统计脚本,看看有没有新的重复命令热点。给 CLAUDE.md 里加的任何新规则,都先跑一个真实任务验证效果。凡是涉及外部依赖的用例,一律打 integration 标签,绝不混入默认测试集。
另外,我也养成了一个“手动熔断”的习惯:如果远程观察到一个会话在同一命令上反复执行,我会直接结束会话,换一个新会话接着干。新会话上下文干净,解决同样问题的成本往往比在旧会话里硬扛低很多。这个技巧听着朴素,但省钱效果极好。
那天在用量面板看到 50% 数字的时候,我第一反应是工具坏了或者我操作失误。现在回头看,其实问题一直摆在日志里,差的就是一个“慢下来看录像”的动作。以后谁再跟我抱怨 Agent 吃配额,我都会先问一句:你翻过它的工具调用记录了吗?