1. 为什么我要给 OpenCode 写一个 Token 监控插件
用 OpenCode 写代码这件事,一旦上手就很难回去了。它把终端、编辑器、模型调用串成一条顺滑的链路,敲几行指令就能让模型帮你改文件、跑测试、补注释。但用得越久,我心里越没底——我根本不知道每次对话到底烧了多少 Token,缓存命中率是高是低,响应速度是快是慢。这种感觉就像开车不看油表,跑到半路熄火才知道没油了。
市面上大部分 AI 编程工具的用量统计都藏在网页后台,要切浏览器、登录、翻菜单,一套流程下来思路全断了。我想要的很简单:在 OpenCode 界面里直接看到实时数据——当前会话累计消耗多少 Token、缓存命中率多少、每秒输出多少 Token。这三个指标基本能反映一次对话的"性价比":Token 用量告诉你花了多少钱,命中率告诉你省了多少钱,速度告诉你等得值不值。
这个插件就是干这个的。它挂在 OpenCode 的会话生命周期上,监听每一次模型请求和响应,把 usage 字段里的数据抓出来,实时渲染到状态栏或者侧边面板。目前已适配 V2 版本的接口协议,V1 的老用户升级后也能平滑迁移。适合谁用?三类人:一是天天泡在 OpenCode 里写代码的重度用户,二是需要控制 API 成本的小团队,三是想搞清楚"缓存到底有没有生效"的技术控。下面我把整个插件的设计思路、核心实现、踩过的坑全部摊开讲。
2. 插件整体设计与核心思路拆解
2.1 需求拆解:三个指标到底在解决什么问题
先把这个插件要展示的三个核心指标说清楚,不然后面的实现逻辑没法展开。
Token 速度,准确说是输出速率(tokens per second,TPS),指的是模型每秒生成多少个 Token。这个指标直接决定你的等待体验。实测下来,主流模型在正常负载下 TPS 在 30 到 80 之间,低于 20 就会明显感觉"卡顿",高于 100 基本是秒出。注意这里要区分首 Token 延迟(TTFT)和持续输出速率,前者反映的是排队和预填充时间,后者才是真正的生成速度。我的插件两个都统计,但状态栏默认显示 TPS,因为更直观。
命中率,指的是缓存命中率(cache hit rate)。现在主流模型服务都支持 prompt caching,你重复发送的系统提示词、上下文前缀如果命中缓存,价格能便宜到十分之一甚至更低。命中率的计算公式是:
命中率 = 缓存命中的 Token 数 / 总输入 Token 数 × 100%这个数字低于 50% 就说明你的提示词结构有问题,缓存没吃上,钱白花了。我见过有人命中率长期在 10% 以下,一问才知道他把每次都变的时间戳塞在了系统提示词最前面,缓存直接失效。
Token 用量,包括输入 Token、输出 Token、缓存读取 Token、缓存写入 Token 四个部分。这四个数字加起来才是真实成本。很多人只看输入输出,忽略了缓存写入其实也是要收费的(虽然比正常输入便宜),算总账的时候对不上。
2.2 技术选型:为什么用插件而不是改源码
OpenCode 本身是开源的,理论上我可以直接改源码加统计逻辑。但我没这么干,原因有三个。
第一,升级成本。OpenCode 迭代很快,V1 到 V2 接口协议就变了不少。改源码意味着每次升级都要重新 merge,冲突处理起来很烦。插件走的是官方扩展点,接口稳定,升级基本无感。
第二,职责分离。统计逻辑和核心功能耦合在一起,出问题不好排查。插件崩了顶多不显示数据,不影响正常写代码。这个隔离性在实际使用中太重要了,我踩过一次坑:早期版本统计逻辑里有个未捕获的异常,直接把整个会话搞挂了,后来全部改成 try-catch 兜底。
第三,可配置性。不同人对指标的关注点不一样,有人只关心钱,有人只关心速度。插件可以做成配置项,让用户自己选显示哪些、刷新频率多少、阈值告警怎么设。改源码做不到这么灵活。
具体技术栈上,插件用 TypeScript 写,跑在 OpenCode 的插件运行时里。数据采集走的是事件钩子(hook)机制,监听message.completed这类事件,从事件 payload 里拿 usage 数据。渲染层用 OpenCode 提供的 UI API,支持状态栏和面板两种模式。
2.3 V2 适配的关键变化
V2 版本最大的变化是 usage 数据的结构。V1 时代 usage 字段比较扁平,大概长这样:
{ "prompt_tokens": 1200, "completion_tokens": 350, "total_tokens": 1550 }V2 把缓存相关的字段拆得更细了,变成了嵌套结构:
{ "usage": { "input_tokens": 1200, "output_tokens": 350, "cache_read_input_tokens": 800, "cache_creation_input_tokens": 200 } }这个变化看着小,但影响很大。V1 时代你根本不知道缓存有没有生效,V2 才能算出真实命中率。我的插件在适配时做了版本探测:启动时读一次 OpenCode 的版本号,V1 走老解析逻辑,V2 走新逻辑,中间用适配器模式隔开。这样老用户升级 OpenCode 不会导致插件报错。
提示:如果你是从 V1 升级上来的,第一次看到命中率数据可能会吓一跳——很多人以为自己缓存用得挺好,实际一测发现命中率只有 20% 多。别慌,这是正常的,后面我会讲怎么优化。
3. 核心细节解析与实操要点
3.1 数据采集:钩子怎么挂、数据怎么拿
插件的数据来源只有一个:OpenCode 在每次模型响应完成后触发的事件。这个事件的 payload 里带着完整的 usage 信息。核心代码大概是这样:
export function activate(context: ExtensionContext) { const disposable = context.events.on('message.completed', (event) => { try { const usage = extractUsage(event); if (!usage) return; statsCollector.record(usage); ui.update(statsCollector.snapshot()); } catch (err) { logger.warn('usage extract failed', err); } }); context.subscriptions.push(disposable); }这里有几个细节值得说。
第一,事件可能不携带 usage。比如用户中途取消、网络中断、模型返回错误,这些情况下 payload 里可能没有 usage 字段。所以extractUsage必须做空值判断,拿不到就静默跳过,不能抛异常。
第二,事件触发频率可能很高。如果你开了流式输出,某些实现会在每个 chunk 都触发事件。这时候要做节流(throttle),我设的是 200ms 一次,既保证实时性又不会把 UI 刷爆。
第三,多会话并发。OpenCode 支持同时开多个会话,每个会话的统计数据要分开算。我用 sessionId 做 key,维护一个 Map,UI 上只显示当前活跃会话的数据,但历史数据保留,方便你回看。
3.2 速度计算:别被平均值骗了
Token 速度的计算看着简单,实际有坑。最朴素的做法是:
TPS = 输出 Token 数 / 总耗时但这个算法有两个问题。一是首 Token 延迟被算进去了,如果模型排队排了 3 秒,实际生成只用了 1 秒,算出来 TPS 会低得离谱。二是流式输出的时间戳不好拿,你只能拿到开始和结束时间。
我的做法是分段计算。从事件 payload 里尽量拿到首 Token 的时间戳(V2 协议里有first_token_at字段),然后:
TTFT = first_token_at - request_start_at TPS = output_tokens / (response_end_at - first_token_at)这样算出来的 TPS 才是真实的生成速度。如果拿不到首 Token 时间戳,就退化成整体计算,但在 UI 上标注"估算值"。
还有一个细节:滑动窗口。单次请求的 TPS 波动很大,有时候模型抽风生成特别快,有时候特别慢。我维护了一个最近 10 次请求的滑动窗口,显示的是窗口内的加权平均。这样数字更稳定,不会一直跳。
3.3 命中率计算:分子分母都要抠清楚
命中率的计算是重灾区,很多人算错。正确的公式是:
命中率 = cache_read_input_tokens / (input_tokens + cache_read_input_tokens + cache_creation_input_tokens)注意分母是所有输入侧的 Token,包括正常输入、缓存读取、缓存写入三部分。为什么缓存写入也要算进分母?因为它也是你这次请求实际处理的输入量,只是走了不同的计费通道。
我见过有人把分母写成input_tokens + cache_read_input_tokens,漏掉了 cache_creation,结果命中率虚高。还有人把输出 Token 也算进分母,那就更离谱了。
另外,命中率要按会话累计,不要按单次请求看。单次请求的命中率波动极大,第一次请求命中率必然是 0(因为还没建立缓存),第二次可能就跳到 80%。看累计值才有意义。我的插件默认显示会话累计命中率,同时保留最近一次请求的命中率作为参考。
3.4 UI 渲染:状态栏还是面板
OpenCode 提供了两种 UI 挂载点:状态栏(status bar)和侧边面板(panel)。我的插件两种都支持,用户自己选。
状态栏适合极简显示,一行字搞定:
TPS 45.2 | 命中 78% | 12.3k tok面板适合详细展示,可以放表格、图表、历史记录。我做了个简单的柱状图,显示最近 20 次请求的 TPS 变化,一眼就能看出模型什么时候在抽风。
渲染性能上有个坑:不要每次事件都全量重绘。我一开始图省事,每次数据更新就重建整个 DOM,结果高频事件下 CPU 直接飙到 30%。后来改成差量更新,只改变化的文本节点,CPU 降到 2% 以下。
注意:状态栏的宽度有限,数字要格式化。Token 数超过 1000 用 k 表示,超过 100 万用 M 表示。TPS 保留一位小数,命中率取整。别把一堆小数位堆上去,看着累。
4. 实操过程与核心环节实现
4.1 环境准备与插件安装
先把环境理清楚。你需要:
- OpenCode 本体,V2 版本(V1 也能用,但部分功能受限)
- Node.js 18 以上
- 一个能正常调用的模型服务
安装插件有三种方式,我推荐第二种。
方式一:从插件市场装。OpenCode 有内置的插件市场,搜 "token-stats" 就能找到。点安装,重启生效。最省事,但版本更新可能滞后。
方式二:从源码装。克隆仓库,npm install && npm run build,然后把产物目录软链到 OpenCode 的插件目录。适合想改代码的人。
git clone <repo-url> opencode-token-stats cd opencode-token-stats npm install npm run build ln -s $(pwd)/dist ~/.opencode/plugins/token-stats方式三:手动配置。在 OpenCode 的配置文件里加一行:
{ "plugins": [ { "name": "token-stats", "path": "/path/to/plugin" } ] }装完之后重启 OpenCode,状态栏应该会出现数据。如果没出现,先看日志,日志里会打印插件加载情况。
4.2 配置项详解与参数选择
插件有一份配置文件,放在~/.opencode/token-stats.json。默认配置长这样:
{ "display": "statusbar", "refreshInterval": 200, "windowSize": 10, "showTTFT": false, "alertThreshold": { "tpsLow": 20, "hitRateLow": 50 }, "format": { "tokenUnit": "k", "tpsPrecision": 1 } }逐个说下参数怎么选。
display:statusbar或panel。屏幕小的选 statusbar,屏幕大的选 panel。我平时用 statusbar,需要看历史的时候临时切 panel。
refreshInterval:刷新间隔,单位毫秒。默认 200。设太小 UI 会抖,设太大实时性差。200 是实测下来最舒服的值。
windowSize:滑动窗口大小。默认 10。窗口越大数字越稳但越滞后,窗口越小越灵敏但越跳。10 次请求大概覆盖 1 到 2 分钟的使用,比较合适。
showTTFT:是否显示首 Token 延迟。默认关。TTFT 对普通用户意义不大,但对调优的人很重要。如果你在排查"为什么感觉卡",打开它。
alertThreshold:告警阈值。TPS 低于 20 或者命中率低于 50% 时,状态栏数字变红。这个阈值可以按你的模型调整,有些小模型 TPS 本来就低,阈值要往下调。
4.3 一次完整的实测记录
我拿一个真实项目跑了一遍,记录下数据。项目是一个中等规模的 TypeScript 后端,大概 50 个文件。我用 OpenCode 让它帮我重构一个模块。
第一次请求:输入 3200 Token,输出 800 Token,缓存读取 0,缓存写入 3200。命中率 0%(正常,第一次没缓存)。TPS 42.3,TTFT 1.2 秒。
第二次请求:输入 3400 Token,输出 600 Token,缓存读取 3000,缓存写入 400。命中率 88%。TPS 51.7,TTFT 0.4 秒。注意 TTFT 大幅下降,这就是缓存的威力。
第十次请求:累计输入 35000 Token,累计输出 7000 Token,累计缓存读取 28000。会话累计命中率 80%。平均 TPS 48.5。
成本对比:如果不用缓存,这十次请求的输入成本是 35000 Token 全价。用了缓存之后,28000 Token 走缓存价(假设是 1/10 价格),实际成本相当于 35000 - 28000 + 2800 = 9800 Token 全价。省了 72%。这个数字是实打实的,插件把它算出来之后我才真正意识到缓存有多重要。
4.4 命中率优化的实操技巧
看到命中率数据之后,我做了几件事把命中率从 80% 提到了 95% 以上,分享下。
第一,把稳定内容放前面。系统提示词、项目背景、代码规范这些不变的内容,全部放在 prompt 最前面。变化的内容(用户当前问题、临时上下文)放后面。缓存是按前缀匹配的,前缀越稳定,命中率越高。
第二,去掉时间戳和随机 ID。我之前的系统提示词里有个"当前时间:xxx",每次都不一样,直接把缓存打穿。改成让模型自己判断时间,或者把时间放到用户消息里。
第三,控制上下文长度。缓存有最小长度要求(不同模型不一样,一般 1024 Token 起),太短的 prompt 不缓存。但也不是越长越好,超过模型上限会被截断,反而破坏缓存。我一般控制在 2000 到 8000 Token 之间。
第四,注意缓存过期。缓存有 TTL,一般是 5 分钟。如果你两次请求间隔超过 5 分钟,缓存就失效了。所以连续工作时命中率高,断断续续工作时命中率低,这是正常的。
5. 常见问题与排查技巧实录
5.1 数据不显示或显示为 0
这是最常见的问题。排查顺序如下。
第一步,看插件有没有加载。打开 OpenCode 的日志,搜 "token-stats"。如果没有任何输出,说明插件没加载成功。检查插件路径、配置文件格式、Node 版本。
第二步,看事件有没有触发。在插件代码里临时加一行console.log(event),看message.completed事件有没有来。如果没来,可能是 OpenCode 版本不匹配,V2 的事件名可能和 V1 不一样。
第三步,看 usage 字段有没有。事件来了但数据是 0,说明 payload 里没有 usage。这种情况通常是模型服务没返回 usage 信息,或者返回的字段名和预期不符。打印完整 payload 看看实际结构。
第四步,看解析逻辑。字段名对不上是最隐蔽的问题。V2 协议里是cache_read_input_tokens,有些服务可能写成cache_read_tokens。我的插件做了字段名兼容,但如果你用的是魔改版服务,可能还要再加。
5.2 命中率异常高或异常低
异常高(接近 100%):先怀疑是不是算错了。检查分母有没有漏掉 cache_creation。如果分母算对了还是 100%,那可能是模型服务把没命中的也报成命中了,这种情况少见但存在。
异常低(长期低于 30%):大概率是 prompt 结构问题。按我上面说的四条优化。还有一个可能:你的请求间隔太长,缓存一直过期。试试连续快速发几次请求,看命中率会不会上去。
忽高忽低:正常现象。第一次请求命中率 0,第二次可能 90%,第三次可能 60%(因为上下文变了)。看累计值,别看单次。
5.3 TPS 显示异常
TPS 显示为 0 或负数:时间戳计算出问题了。检查first_token_at和response_end_at的差值,如果是负数说明时钟不同步或者字段拿反了。
TPS 高得离谱(几百上千):可能是把缓存读取的 Token 也算进输出里了。输出 Token 只算output_tokens,别把输入侧的算进来。
TPS 一直很低:先排除网络问题。如果网络正常,可能是模型服务负载高。换个时间段试试,或者换个模型对比。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 数据完全不显示 | 插件未加载 | 查日志搜插件名 | 检查路径和配置 |
| 数据全为 0 | usage 字段缺失 | 打印完整 payload | 确认模型服务版本 |
| 命中率算错 | 分母漏项 | 核对公式 | 补上 cache_creation |
| TPS 异常 | 时间戳错误 | 打印时间字段 | 修正计算逻辑 |
| UI 卡顿 | 全量重绘 | 看 CPU 占用 | 改差量更新 |
| 多会话数据串了 | sessionId 未隔离 | 打印 sessionId | 按会话分 Map |
5.5 几个我踩过的坑
坑一:异常没兜住导致会话崩溃。早期版本我在事件回调里直接抛异常,结果一次解析失败把整个会话搞挂了。后来所有回调都包了 try-catch,出错只记日志,不影响主流程。这个教训很深刻,插件的第一原则是不能影响宿主。
坑二:内存泄漏。我用 Map 存历史数据,但从来没清理过。跑了一整天之后内存涨到几百兆。后来加了 LRU 淘汰,只保留最近 100 个会话的数据。
坑三:格式化函数性能问题。Token 数格式化我一开始用正则,高频调用下成了瓶颈。后来改成简单的数学运算加查表,性能提升明显。
坑四:V2 升级后字段名变了没发现。V2 刚出的时候我直接升级,结果数据全 0。查了半天才发现字段名从prompt_tokens变成了input_tokens。后来加了版本探测和字段名兼容,才算稳了。
提示:如果你要自己改这个插件,记住一条铁律——任何可能抛异常的地方都要兜住。插件崩了事小,把用户的会话搞崩了事大。
6. 后续可以怎么扩展
这个插件目前只做了最基础的统计和展示,能扩展的方向不少。我自己在琢磨的有几个。
成本估算。现在只显示 Token 数,不显示钱。如果能配置每个模型的单价,就能实时算出这次会话花了多少钱。这个功能对团队用户特别有用,可以设个预算告警。
历史趋势图。现在只有最近 20 次的柱状图,如果能存历史数据,画个按天/按周的趋势图,就能看出使用习惯的变化。
自动优化建议。根据命中率和 TPS 数据,自动给出优化建议。比如"你的命中率偏低,建议把系统提示词里的动态内容移到用户消息"。
多模型对比。同一个任务用不同模型跑,对比 TPS、命中率、成本,帮你选最合适的模型。
这些扩展都不难,核心数据采集层已经搭好了,剩下的就是加 UI 和逻辑。我个人的体会是,监控类工具的价值不在于数据本身,而在于数据带来的行为改变。装了插件之前我从来不看 Token 用量,装了之后我会主动优化 prompt 结构,一个月下来 API 成本降了将近一半。这个投入产出比,比任何优化技巧都高。
最后分享一个小技巧:如果你觉得状态栏太占地方,可以设成只在鼠标悬停时展开。平时就显示一个极简的图标,需要看数据的时候再展开。这样既不干扰写代码,又能随时掌握情况。