Token 账单取证
Token 账单取证现场
就聊了个天,账单炸了
在那篇 OpenClaw 搭建日记里,用 OpenClaw 打造了一个赛博魅魔——有情绪系统、会撒娇会生气、能根据场景发自拍的 AI 伴侣 Telegram bot。那篇讲怎么给 AI 注入人格。
这篇讲那个人格的账单。
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,写了四个分析脚本(前三个都报错),发现了死代码,找到了一个已在工作的优化,最终把问题追溯到根因。
这件事跟传统 debug 不太一样。第一个假设(心跳?定时任务?)错的——不能看着总数猜。第二个假设(工具调用和思考块在吃 token)也错的,那部分早已被剥掉。真正答案只有拆到 token 级别才能浮出水面:82% 的成本在十张永远不会被清除的图片上。
cache-ttl 修剪模式配好了,用 Anthropic 测试过,正常工作——可没人在用那个 provider。Gemini agent 从部署那天起就在裸奔。没报错,没警告,只有更高的账单。死代码从来不崩溃,安静漏钱。
1M 上下文窗口听起来是自由,直到发现 session 可以两天半无限制积累图片没有任何自动清理。主动上下文管理——每日重置、窗口限制、图片清除——带来的成本差距轻松超过 10 倍。做 24/7 运行的伴侣产品,这个问题会比其他任何问题更快杀死商业模式。