开发者通过解析Claude Code的JSONL日志发现,每步文件操作都是独立API调用,揭示了AI编程工具的真实token消耗模式和调试盲区。
几周前,我花了一个下午用 Claude Code 做功能。花了很长时间、消耗了大量 token、还跑了几个 subagent。当我问它在做什么时,它给了一个总结,但我没有任何可以核对的依据。结果糟糕到我把分支扔掉了,可我仍然说不出哪里出了问题、究竟是哪个 agent 搞砸的。
这件事比浪费一个下午更让我困扰。于是我去寻找日志,结果发现 Claude Code 把每一步都记下来了。
每个会话都是一个 JSONL 文件,位于 ~/.claude/projects/<project>/<session-id>.jsonl。文件夹名是你的工作目录,斜杠替换成了短横线。每运行一步就追加一行,一个 JSON 对象占一行。
ls -t ~/.claude/projects/*/*.jsonl | head -5
每条 assistant 消息行都带有一个 usage 块,包含 input_tokens、output_tokens、cache_creation_input_tokens 和 cache_read_input_tokens。这就是你整个会话的完整费用明细,逐项列出,就躺在你的磁盘上。
我写了一个解析器,然后拿我所有的日志来分析。以下是让我惊讶的发现。
我之前的认知模型是:我发一个提示词,Claude 回复。实际发生的是一个循环:它读取文件、grep、编辑、再读取,每一步都是一次独立的 API 调用,每次都会重新发送你的整个上下文。
对 60 个会话中的 1,436 次 turn 进行了统计,以一次用户提示到下一次用户提示之间的独立 requestId 值为计数依据:
四分之一的 turn 包含三次或更少的调用。约六分之一会超过二十几次。所以"为什么这个简单的问题消耗这么大"通常答案很无聊:它不是一个问题,而是三十六个来回。
值得知道的是:文件每收到一个内容块就写一行,而不是每个 API 调用一行。单个响应会变成一条 thinking 行、一条 text 行,然后每条 tool_use 各占一行——它们共享同一个 requestId,也都携带相同的 usage 块。如果你把每行的 usage 加起来,你的成本会被重复计算三到四倍。先按 requestId 分组。
这是改变了我的认知的一条。
每个 subagent 都有自己独立的上下文窗口,使用它被分配到的模型——未必是你正在对话的那个模型。而且父级 transcript 不包含它们的工作内容。它只记录了一个 subagent 被启动了,以及返回的文字。就这些。
子 agent 写的是各自独立的文件。所以当你查看"这个会话"时,你读的是你从未打开过的文档的摘要,而你花 token 买的内容大部分在那些文件里。
如果你派生出五个 subagent,屏幕上显示的那个文件反而是信息量最少的。
当一次 API 调用在 turn 中途失败时——登录过期、速率限制、服务器过载——turn 就停了。终端看起来和 Claude 在思考时完全一样。没有错误横幅、没有声音、spinner 没有任何变化。唯一发现的方式是过一会儿再回来看。
这些失败都在日志里,而且很容易找到:
grep -l '"isApiErrorMessage":true' ~/.claude/projects/*/*.jsonl
它们是 type 为 "assistant" 的行,带有一个 error 字段,内容大概是"API Error: Connection closed mid-response. The response above may be incomplete."
让我栽跟头的部分来了。我最初的反应是检查错误是不是文件的最后一行——如果它之后什么都没有,那会话就死在那里。这个测试找不到任何东西,而且它是错的。Claude Code 在会话结束后会继续追加:last-prompt、system、file-history-snapshot、mode changes。文件在对话结束后仍然在增长。
真正的问题是:错误之后是否有其他 assistant 回复跟上来。在我自己的日志里,我能找到的七次 API 错误中,有三次后续没有任何回复。那些会话就停在了那里,而当时我毫不知情。
二分之一是我自己语料库的比例,不是定律——在你的日志上跑一下,有趣的数字是你自己的那个。
无状态的 API 意味着每次调用都会重新发送整个对话。Prompt 缓存缓解了这个问题:重复的部分以 cache_read_input_tokens 计费,比新鲜输入便宜得多。
直到缓存变冷。然后整个提示会以 cache_creation_input_tokens 的全价重新创建,你需要为重建一个你已经有了的东西付费。
我把自己的日志加了一遍——60 个会话、21,810 次独立 API 调用——曲线比我预期的更出乎意料:
98.9% 跨线传输的内容都是被读回来的上下文。我输入的新文字四舍五入后接近零。输出——Claude 在那些会话中实际生成的代码和文字——只占 0.2%。
这不是一张账单。缓存读取的费用只是新鲜 token 的一小部分,所以钱并不按那些比例分割。这是一种形态:agent 会话大部分是相同的上下文来回移动,新工作叠加在上面只占微不足道的比例。
如果你自己去计数,有一个警告:每行都携带其整个 API 调用的 usage,而且重复了。单次响应变成一条 thinking 行、一条 text 行和每条 tool call 各一行,它们都报告相同的数字。把每行加总会把你的成本重复计算三到四倍。先按 requestId 分组。
有一个值得知道的盲点,因为它限制了任何日志读取工具能告诉你的东西:批准对话框。当 Claude 请求运行某个东西的权限时,在你回答之前什么都不会写入。一个等待你的会话在磁盘上看起来和已经结束的一模一样。
所以"日志包含一切"并不完全正确。它们包含了一切发生的事——而一个等待人类的会话还没有发生。
坦白部分——这最终变成了什么
我都是用脚本读日志,后来懒得跑脚本了,就写了一个东西可以在 turn 发生时实时绘制:窗口填充、每次调用的延迟和 token、每个 subagent 折叠在启动它的 spawn 下方,有自己的窗口和模型。它叫 seedeep,是 MIT 许可的,它读取日志但不触碰日志:https://github.com/duqaXxX/seedeep

但这篇文章的重点是日志,不是那个工具。以上所有东西都在你已有的文件里,一百行 Python 就能搞定大部分。数字让我足够惊讶,所以我觉得无论你是否运行了我写的任何东西,这些都值得知道。