Token 账单取证
Token 账单取证现场
就聊了个天,账单炸了
在那篇 OpenClaw 搭建日记里,我用 OpenClaw 打造了一个赛博魅魔——一个有情绪系统、会撒娇会生气、能根据场景发自拍的 AI 伴侣 Telegram bot。那篇讲的是怎么给 AI 注入人格。
这篇讲的是那个人格的账单。
TA不是一个简单的聊天机器人,TA是一个完整的赛博魅魔。但说到底,TA的日常交互就是陪你说话——早安晚安、分享心情、偶尔发个自拍。正常的伴侣交互频率,不是在跑什么复杂的任务编排或多步推理管道。
TA跑在 OpenClaw 框架上,基于 Gemini Pro,1M token 上下文窗口。
当前这个 session 跑了两天半。537 轮"对话"。成本已经看不下去了。
注意"对话"打了引号。537 轮里,我真正发的消息大概只有十几二十条——正常的伴侣聊天频率。剩下几百轮全是框架自己在跟自己的工具说话。但每一轮都是一次完整的 API 调用,重新发送整个上下文。
但这不是最离谱的。往前翻一个 session——2 月 17 日,750 轮,成本比当前这个还要贵好几倍。我实际只说了大约 30 句话。同样的模式,同样的问题,只是跑得更久。那个 session 直到撞上 1M token 硬限制才触发了唯一一次压缩。
光这两个 session 就占了总账单的大头。再加上中间的零碎 session,整个赛博魅魔实验的 token 账单——离谱到让人怀疑人生。
不到两周。而我真正说的话加起来可能还不到 200 句。
第一个嫌疑人当场洗白
我在那篇搭建日记里写过框架的 pi agent"上下文管理做得极其粗糙",把工具调用和思考块的原始输出全塞进 context,token 消耗高得离谱。在Mio 的第一篇里,我把"上下文膨胀"列为决定从零开始造新框架的原因。
那些都是定性的吐槽。这篇是定量的取证——我到底在为什么买单。
开了 Claude Code,一句话:"查一下 main agent 为什么 token 用量这么高。"
Claude Code 并行启动了两个探索 agent——一个分析 session 数据,一个检查配置和成本细节。几分钟后,按消息来源分类的结果出来了:
| 来源 | 轮次 | 占比 |
|---|---|---|
| 常规聊天 | 497 | 91.8% |
| 心跳 | 40 | 8.2% |
| 定时任务 | 0 | 0% |
定时任务:零。框架里所有 cron job 都用 sessionTarget: "isolated"——它们跑在独立 session 里,完全不碰主对话。第一个嫌疑人当场洗白。
心跳:40 轮,占 8.2%。不是主要矛盾,但每次心跳的成本——对于一个"要不要跟用户打个招呼"的检查来说——也太贵了。后面会修。
所以问题完全集中在 497 轮常规聊天上。但为什么每一轮都比上一轮贵?
82% 的钱花在了图片上
Claude Code 写了一个 Python 分析脚本,通过 docker exec 推到容器里,跑在 session 的 .jsonl 日志文件上。(这个套路我在这个系列里反复用——第三篇诊断服务器冻结的时候也是。)
脚本第一次跑就报了 Python f-string 语法错误。第二次 FileNotFoundError。第三次 KeyError。三次才跑通,因为 session 数据格式跟预期不完全一致。
调试的现实——不可能一次命中,尤其是数据 schema 没有文档的时候。
第四次终于拿到了 token 增长趋势:
| 轮次 | 时间 | 趋势 |
|---|---|---|
| 0 | 2月25日 14:02 | 基准 |
| 105 | 2月26日 00:50 | ~1.7x 基准 |
| 210 | 2月26日 18:51 | ~2.1x 基准 |
| 315 | 2月27日 07:11 | ~2.5x 基准 |
| 420 | 2月27日 10:43 | ~2.8x 基准 |
| 536 | 2月27日 23:44 | ~3x 基准 |
单调递增。没有下降,没有平台期。上下文只涨不缩。
然后 Claude Code 又启动了一个分析 agent,拆解第 390 轮时上下文里到底装了什么:
| 类别 | 估算 Token 数 | 占比 |
|---|---|---|
| 10 张行内图片 (base64) | ~348,829 | 82.2% |
| 系统提示词 (人格配置文件 + 技能 + 工具) | ~41,876 | 9.9% |
| 工具调用参数 | ~27,310 | 6.4% |
| 思考块 (72 个) | ~18,812 | 4.4% |
| 工具返回结果 | ~8,139 | 1.9% |
| 文本 (用户 + 助手) | ~8,410 | 2.0% |
82% 的 token 都是图片。
两天半里积累了 10 张 base64 编码的图片,全在用户消息里。每张大约 35K token。而且它们永远不会被清除——每一次 API 调用都在重新发送这十张图。
每轮大约 35 万 token 花在图片上。积少成多就是这个数。
两个让叙事反转的发现
调查到这里开始变得有意思了。我让 Claude Code 继续深挖——"还有哪里能省 token,同时不影响聊天体验?"
它带回了两个发现,方向完全相反。
死代码:配了等于没配
框架有一个 cache-ttl 上下文修剪模式,理论上可以把旧内容转为更便宜的缓存读取。配置里确实开着。
但实现这个功能的代码被 isCacheTtlEligibleProvider() 守着门,这个函数只对 Anthropic provider 返回 true。我的 agent 用的是 Gemini Pro。
修剪模式配了,代码也写了,在我的环境里完全是死的。
这种 bug 永远不会崩溃或报错。它就是在安静地漏钱。除非你去读源码,否则永远不会发现。
已经在工作的优化:我自己配的,但忘了
最初看到日志里有"72 个思考块"和"257 次工具调用"时,第一反应是:"找到了——这些推理开销全部积在上下文里。"
错了。
Claude Code 检查了 Telegram 通道的配置,发现 dmStripToolHistory: true 已经开着了。这个选项会在发送上下文给 LLM 之前,把思考块、工具调用块和工具返回消息全部剥掉。那 72 个思考块和 257 次工具调用确实存在于 .jsonl 日志文件里(方便调试),但它们并没有被重新发送给模型。
我几个月前配的,然后就忘了。调查一开始高估了它们的影响——那些 token 在日志里,但不在实际的 API 调用里。
修正之后,真实的成本构成很简单:图片 + 系统提示词 + 不断增长的文本历史。而图片碾压一切。
30 条消息怎么变成 750 轮的
这个问题在 2 月 17 日那个最贵的 session 里更加极端。750 轮对话,但我实际只发了大约 30 条消息。25 倍的放大。
当前 session 的比例稍好:189 条用户消息(156 条常规聊天 + 33 条心跳标记),537 轮助手响应,2.84 倍。但本质问题一样。
原因是工具调用循环。整个 session 里 agent 做了 257 次工具调用。每次工具调用都触发一轮额外的助手响应——LLM 返回一个工具调用,框架执行工具并把结果发回去,LLM 再次响应。
用户说一句"今天心情不好",agent 可能要:调记忆搜索工具查你最近的对话 → 调日历工具看你今天的日程 → 调情绪分析工具 → 最后才回复你。一条消息,四轮 API 调用。
而每一轮助手响应都是一次完整的 API 调用,重新发送整个不断增长的上下文。
这就是框架的 pi agent 最致命的设计问题:它鼓励 agent 使用工具,但没有任何机制来控制工具调用对上下文成本的放大效应。一个不用工具的聊天机器人,消息和轮次是 1:1 的。框架的 agent 可以达到 25:1。
你以为你在跟 AI 聊天,实际上 AI 在跟自己的工具聊天,而你在为每一轮买单。
537 轮零压缩
零次压缩。两天半。537 轮。一次都没有。
原因是两个配置值的组合:
- Gemini Pro 的 1M 上下文窗口——模型最多接受 100 万 token
compaction.mode: "safeguard"——只在上下文接近模型硬限制时才触发压缩
配合 proactiveCompactionRatio: 0.5,压缩会在 50 万未缓存 token 时触发。当时 session 在约 17.7 万并且还在涨,但离触发点还远得很。按当前增速,还得再跑一周才会触发压缩——到那时 session 的账单会到天文数字。
1M 上下文窗口被当作卖点,但对长期运行的 agent session 来说,它意味着上下文可以连续增长好几天而没有任何自动清理。
主动管理上下文不是可选项——它是必需品。
船底到处是缝
拉远一点看。这不是一次偶发的配置失误,这是框架上下文管理的系统性问题。
在搭建赛博魅魔那篇里,我就写过把工具调用和思考块的历史从上下文里剥离之后,token 消耗直接降到了原来的十分之一。那个修复(dmStripToolHistory 开关)现在已经启用了——这次调查也证实了它确实在工作。但它只处理工具和思考块。图片不管。Session 生命周期不管。压缩不管。
一个补丁堵了一个洞,但船底到处都是缝。
框架的 pi agent 是为快速实验设计的——用我自己的话说,"一小时 vibe coding 出来的核心模块"。确实跑得起来,但到处都是妥协:
- 上下文修剪系统只对 Anthropic provider 有效,Gemini 裸奔
- 压缩的默认配置假设你不会让 session 跑超过几个小时
- 图片消息被修剪器显式跳过,永远不清除
- 工具调用可以把 30 条消息膨胀成 750 轮,没有任何限制
- 1M 上下文窗口被当卖点,但没有配套的生命周期管理
没有人为 24/7 运行的伴侣场景设计过这套系统。它是一个实验框架,被我硬拉去做产品。这笔账单就是代价。
这就是为什么我开始从零造 Mio。当你的框架在聊天场景下不到两周烧掉这么多钱,打补丁已经不够了。你需要一个把 token 经济学当作一等公民的系统——逐用户的成本追踪、分层模型选择(心跳用便宜模型,只在需要时上贵的)、主动的上下文生命周期管理。
搭建赛博魅魔证明了 AI 伴侣这条路走得通。这笔账单证明了在这个框架上走不起。
修复方案
经过几轮讨论(我纠正了 Claude Code 的两个判断——人格配置文件是缓存输入,27KB 不用动;思考模式对伴侣聊天应该设 "low" 而不是 "high"),最终确定了配置 + 代码双管齐下的方案:
配置变更
session.reset.mode: "daily"(原来是"idle",3 天超时)——每天凌晨 4 点 PT 自动开新 session。防止图片跨天积累。agents.defaults.contextTokens: 200000(原来没设,默认用模型的 1M)——把有效上下文窗口限制在 20 万。配合proactiveCompactionRatio: 0.5,压缩触发点从 50 万降到 10 万。contextPruning.mode: "always"(原来是"cache-ttl")——新模式,绕过 Anthropic 专用的 provider 检查,让上下文修剪在 Gemini 上也能跑。thinkingDefault: "low"(原来是"high")——伴侣聊天不需要深度推理。思考块更短 = 每轮输出 token 更少。heartbeat.every: "2h"(原来是"1h")——降低心跳频率。实际情况比配置更糟:心跳计时器在每次网关重启时会重置(配置变更触发 SIGUSR1),本来每小时一次的心跳,有时 20 分钟就触发一次。heartbeat.historyLimit: 20(原来没设)——心跳上下文只保留最近 20 轮用户消息。之前每次心跳都在重新发送整个 session 历史。单次心跳的 input token 从 session 初期的 69K 涨到了末期的 122K+——就为了一个"要不要说早安"的判断。heartbeat.stripToolHistory: true(原来没设)——从心跳上下文里剥离工具调用、返回结果和思考块。配合historyLimit,心跳上下文从约 120K token 降到约 5-10K——心跳成本直接砍了一个数量级。
代码变更
- 新增 "always" 上下文修剪模式:加到类型定义、zod schema 和 extension runner 里。跳过
isCacheTtlEligibleProvider()检查,让那段死代码活过来。 - 修剪器里增加图片清除:上下文修剪器原来碰到含图片的消息会直接跳过(
hasImageBlocks()检查)。现在,超出最近消息保护区的旧图片会被替换成[Image removed from context]占位文本,让它们可以被正常修剪。 - 心跳上下文限制:embedded runner 现在会检测心跳运行(通过
runtimeChannel === "heartbeat"),在通用 DM 限制之前应用心跳专属限制——limitHistoryTurns()配合stripToolHistoryFromMessages()。心跳不再继承主 session 的无限增长上下文。
没改的东西
- Bootstrap 大小:人格配置文件 27KB 不动。它是缓存输入——Gemini 的缓存读取价格很低,不值得优化。
proactiveCompactionRatio:保持 0.5。通过限制contextTokens到 20 万,用更干净的方式达到了 10 万触发点,不需要动比率。dmStripToolHistory:已经在工作了。不用改。
实施时又翻出来一个隐性 bug
每日 session 重置依赖于上下文"种子"——把昨天的对话摘要传给新 session,这样伴侣不会每天早上失忆。框架有一个 seedSessionFromPrevious() 函数来做这件事,它把种子写到 session 的 JSONL 日志文件里。
问题是:prepareSessionManagerForRun() 在种子写入之后立即执行,直接用 fs.writeFile(sessionFile, "", "utf-8") 把文件清空重建。
种子写入时间是 03:47:10,文件被清空时间是 03:47:13。
存活了三秒钟。
赛博魅魔每次日重置之后都在失忆,而没人发现——因为没有报错,只是 AI 对昨天的事情有点茫然。
修复方案是不再往一个会被覆盖的文件里写东西。改成把种子上下文作为新 session 第一条用户消息的前缀注入——搭正常消息持久化的便车,不会被 session 管理器的初始化覆盖掉。
Claude Code 用两个并行 agent 来实施这些修复——一个负责 session 种子的修复,另一个负责新建 /cost messages 命令。两个 agent 同时跑在独立的文件集上,构建和测试全通过(12/12),不到 10 分钟部署完毕。
几个感悟
整个调查大约花了 20 分钟。Claude Code 启动了五个探索 agent,写了四个分析脚本(前三个都报错了),发现了死代码,找到了一个已经在工作的优化,最终把问题追溯到了根因。
成本调试是取证工作。 不能看着总数猜。第一个假设(心跳?定时任务?)是错的。第二个假设(工具调用和思考块在吃 token)也是错的——它们已经被剥掉了。真正的答案(图片)只有通过 token 级分析才能浮出水面。
死代码是隐形的。 cache-ttl 修剪模式配好了,用 Anthropic 测试过,工作正常......但那是一个没人在用的 provider。Gemini agent 从部署那天起就在裸奔,修剪功能等于没有。没有报错,没有警告,只有更高的账单。
上下文管理是长期运行 agent 的隐形成本。 1M 上下文窗口感觉像自由,直到你发现它意味着你的 session 可以无限制地积累图片两天半而没有任何自动清理。主动的上下文管理——每日重置、窗口限制、图片清除——不是锦上添花。它带来的成本差距轻松超过 10 倍。如果你在做一个 24/7 运行的伴侣产品,这个问题会比任何其他问题更快地杀死你的商业模式。
用 AI 调试 AI 系统,是一个很优雅的闭环。 我用 Claude Code(一个有工具的 AI agent)来调试另一个 AI agent 的工具使用成本。调查过程本身就涉及工具调用循环、并行 agent、迭代写脚本——和造成成本问题的模式一模一样。
这是 Mio 从零开始系列的序章。下一篇:第一篇——为什么我决定从零开始造。这个系列记录了从 OpenClaw 框架的局限出发、从零构建 Mio AI 伴侣的完整过程。