ENZH

Token 账单取证

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 数据,一个检查配置和成本细节。几分钟后,按消息来源分类的结果出来了:

来源轮次占比
常规聊天49791.8%
心跳408.2%
定时任务00%

定时任务:零。框架里所有 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 增长趋势:

轮次时间趋势
02月25日 14:02基准
1052月26日 00:50~1.7x 基准
2102月26日 18:51~2.1x 基准
3152月27日 07:11~2.5x 基准
4202月27日 10:43~2.8x 基准
5362月27日 23:44~3x 基准

单调递增。没有下降,没有平台期。上下文只涨不缩。

然后 Claude Code 又启动了一个分析 agent,拆解第 390 轮时上下文里到底装了什么

类别估算 Token 数占比
10 张行内图片 (base64)~348,82982.2%
系统提示词 (人格配置文件 + 技能 + 工具)~41,8769.9%
工具调用参数~27,3106.4%
思考块 (72 个)~18,8124.4%
工具返回结果~8,1391.9%
文本 (用户 + 助手)~8,4102.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 轮。一次都没有。

原因是两个配置值的组合:

  1. Gemini Pro 的 1M 上下文窗口——模型最多接受 100 万 token
  2. 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 伴侣的完整过程。

Mio 从零开始Part 0 of 9
← PrevNext →

© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0