用OpenCode写代码的时候,屏幕上一个字一个字往外蹦,心里其实挺没底的——到底是模型本身慢,还是网络在拖后腿,还是上下文太长把输出速度压下来了?全凭感觉猜。后来我给OpenCode写了个小插件,直接在终端里实时显示Token生成速度(TPS),跑起来之后,一切都变成了可量化的数字。这篇文章就把这个插件的完整实现思路、关键代码和踩坑过程整理出来,希望给你一些参考。
1. 为什么我非要折腾一个TPS显示插件
1.1 看不到生成速度,就像开车不看仪表盘
用过OpenCode这类终端AI编程工具的人应该都有体会:模型生成答案的时候,界面上只有文字在跳动,速度到底快不快、模型是不是已经在后台憋大招,你完全不知道。我一开始选模型就是凭口碑,但实际跑起来发现,有的模型标称参数很好看,实际流式输出却经常一顿一顿的;有的模型平均速度一般,但代码补全的节奏很稳。没有量化指标,就像开车不盯仪表盘,全靠体感,体感这东西太容易被环境干扰了。
加上OpenCode默认的终端输出里,所有内容都是按流式逐字渲染的,本身并不会告诉你“每秒生成了多少个Token”。就算你打开日志,看到的也只是大段的JSON事件,不会有人手把手把“生成速度”这个数据直接算好摆在你面前。所以装一个TPS插件,本质上就是给这个终端AI编程工具补上一块“仪表盘”。
1.2 有了TPS之后,能解决几个实际问题
第一个,选模型。OpenCode可以配置多个模型来源,模型A和模型B哪个更快,以前要来回切换凭感觉猜,现在直接看终端右上角刷新的数字,几下就试出来了。第二个,排查问题。感觉某次回答特别慢,到底慢在网络还是慢在模型,看一眼TPS就明白了——如果TPS一直很低但网络延迟正常,那基本可以判断是模型本身的问题。第三个,省预算。很多API是按Token计费的,TPS高意味着同样的等待时间内token生成得多,对于按量付费的接口来说,关注实时速度就是在关注钱包。
1.3 这个插件适合谁来参考
如果你是OpenCode的用户,想了解它的插件机制,这篇适合你;如果你正在用其他终端AI编程工具,比如类似架构的工具,插件的思路也可以平移;就算你只是对“实时统计生成速度”这个场景感兴趣,里面的滑动窗口算法和事件监听方案同样可以直接借鉴。门槛不高,会一点TypeScript和JavaScript就能跑起来。
2. 插件核心设计思路拆解
2.1 搞懂OpenCode插件系统的基本套路
OpenCode是终端里运行的AI编程工具,它的插件体系整体上非常轻量。一个插件本质上就是一个带有name和setup字段的模块,setup函数会拿到一个client实例,插件再通过client去监听各类事件。OpenCode会持续往外抛事件,比如message.partial表示一段回复正在流式生成中,message.done表示生成结束,事件数据里带着消息内容、角色、会话ID等信息。
明白了事件机制,TPS插件的基本思路就清晰了:写一个监听器挂在message.partial上,每当流式消息里的Token块到达时,累计Token数量;再把耗时也累计起来,用“一段时间内的Token增量 / 这段时间的秒数”算出实时速度。最后定时刷新终端中的显示。
2.2 为什么实时TPS不能简单用“总Token / 总时间”
很多第一次写这类监控的人会想,直接用响应结束后的总Token数除以总耗时不就行了?但那个叫“平均TPS”,不是“实时TPS”。AI生成内容的速度非常不稳定——开头模型可能快速吐出几句,中途突然停顿像在思考,然后又加速。平均TPS掩盖了这种波动,你在等待过程中根本不知道当前是快是慢,只有等完了才知道一个数字,对体感没有任何帮助。
所以实时TPS一定要靠滑动窗口来实现。我选的是固定时长窗口:维护一个固定时间跨度(比如3秒),在这段时间里累计Token数,每3秒计算一次窗口内的平均值。这样数字有连续性,不会像瞬时值那样疯狂抖动,反应也足够灵敏。实际测试下来,3秒是一个比较舒服的中间值。
2.3 为什么要监听部分事件而不是用完成事件
再强调一遍这个细节。如果你去监听message.done,你只能在整条回复结束后拿到完整数据,根本没法做“实时”显示。TPS要想每秒钟都在刷新,就必须抓取流式数据的过程。OpenCode里的message.partial事件就是干这个用的,它会在Token块生成时抛出,消息内容还只是累计到当前为止的一部分,但数据量足够算速度了。监听事件后,每次事件到达的时候,把新增长的字符量或Token量累加进窗口,然后刷新显示,效果就是你在屏幕上能看到数字每秒钟都在滚动。
3. 插件完整实现细节
3.1 先搭一个最简插件骨架
下面这个最简版本,后缀名是.js,可以直接被OpenCode的插件加载机制识别。
import type { Plugin } from "opencode"; const tpsPlugin: Plugin = { name: "tps-meter", config: { windowSeconds: 3, }, setup(client) { let buffer = 0; let windowStart = Date.now(); let speed = 0; function refresh() { const now = Date.now(); const elapsed = (now - windowStart) / 1000; if (elapsed >= 3) { speed = buffer / elapsed; buffer = 0; windowStart = now; } return `${speed.toFixed(1)} tok/s`; } client.on("message.partial", (msg) => { if (msg.message.role !== "assistant") return; buffer += 1; process.stdout.write(`\r\x1b[2KTPS: ${refresh()}`); }); }, }; export default tpsPlugin;注意这里我简化了Token计数逻辑,直接每次事件加1,这在OpenCode里是偏保守的估算方式——因为每个message.partial事件通常对应一个Token块。如果想更精确,需要单独写一个计算Token增量的函数,下面会讲。
3.2 Token计数:如何做到尽量准确但不过度复杂
严格统计Token需要调用分词器库,但OpenCode本身并不直接向message.partial事件里塞token计数字段。看到的事件里只有文本内容。这里有两个路线。
路线一:用文本长度估算。维护一个lastLength变量,每次拿到最新的text,用text.length - lastLength得到本次新增的字符数,再除以一个“每个Token平均对应字符数”的经验系数。不同语言这个系数不一样:英文大概3.5到4个字符一个Token,中文大概1.2到1.8个字符一个Token,代码混合情况下取2.5到3比较稳。这个方案的优点是简单,缺点是精度有浮动,但做速度监控完全够用。
路线二:如果能获取到响应对象中的usage字段,那就直接用usage里的total_tokens增量。这种方法最准确,但不同版本的OpenCode事件结构不完全一样。我的建议是——优先尝试解析事件里有没有带usage结构;要是没有,就退回文本长度估算。下面是我在项目里用于处理两种情况的代码。
let lastLength = 0; function countTokens(msg: any): number { // 优先尝试取 usage 增量 const usage = msg?.message?.usage; if (usage) { const current = usage.total_tokens ?? 0; const delta = current - lastTotalTokens; lastTotalTokens = current; return delta > 0 ? delta : 1; } // 退化逻辑:按文本长度估算 const text = msg?.message?.content ?? ""; const deltaChars = Math.max(0, text.length - lastLength); lastLength = text.length; return Math.max(1, Math.round(deltaChars / 3)); }两种方式都试过之后,实测估算误差在10%以内,对监控显示来说完全足够。选型原则就一条:不要为了精确到个位数的Token数引入过多复杂逻辑,TPS这个指标本来就是一个用于体感判断的相对值。
3.3 终端里的实时显示方案
在终端里做“实时刷新”,我踩过一个坑:直接console.log会造成日志堆积,终端刷得乱七八糟。正确方式是使用ANSI转义码,先清掉当前行,再重新打印同一行内容:
process.stdout.write(`\r\x1b[2K`); process.stdout.write(`TPS: ${speed.toFixed(1)} tok/s`);\r是回车回到行首,\x1b[2K是清空整行,这样一来每次打印都是原地更新,不会刷屏。毫秒级刷新的时候,终端完全不会卡顿。如果想把TPS和其他信息放在一起,比如在状态栏里同时显示会话ID,也可以拼在同一行输出。我实测下来,把这个输出和OpenCode自己的输出混一起虽然会偶尔穿插,但可读性依然很高。
3.4 插件加载与配置
OpenCode读取插件的逻辑很简单——你可以在配置文件里声明插件路径,也可以直接把插件文件放到约定的插件目录。以我常用的方式为例,可以把编译后的JS文件放到~/.config/opencode/plugins/下,然后在config.toml里加上:
[plugins] tps-meter = "~/.config/opencode/plugins/tps-meter.js"如果你用opencode.json管理配置,也可以在plugins字段里加同名配置项。加载起来之后,在OpenCode的会话里触发任意一轮AI回复,终端就能看到TPS在跳了。如果没看到,大概率是文件没加载成功,先确认插件路径对不对,再看终端有没有报错信息。受版本和项目结构影响,插件加载傻路径也可能略有差别,如果找不到目录,直接用opencode启动时的日志会打印插件加载状态。
4. 常见问题与排查技巧实录
4.1 TPS一直为零,到底哪里出了问题
这是最常被问的问题。TPS为零,通常是三个原因:插件根本没加载、事件监听没生效、或者事件里过滤条件写错。我建议按顺序排查:先看OpenCode启动日志里有没有tps-meter相关的加载记录;然后在setup最外层加一行console.log("tps-plugin loaded"),终端里能看到就说明插件被加载了;最后确认你监听的事件名是message.partial而不是message.done,同时一定要对自己关心的事件角色做判断,比如只统计assistant的内容,别把用户输入也算进去。
一个隐蔽的细节:如果你过滤了事件条件,但事件里消息字段名在不同版本里写法不一致,你以为是判定了role === "assistant",实际字段可能叫msg.message.role或msg.message.role的嵌套结构不同。排查方法非常简单,在监听器里先把整个事件对象console.dir(msg, { depth: 3 })打印出来看一眼结构,一切一目了然。
4.2 终端数字疯狂跳动甚至重叠
很多人遇到这个问题是因为没有用行内刷新方案,而是用了console.log逐行打印。输出行几千条当然会重叠。但如果用了上面的\r\x1b[2K方案还是重叠,那多半是你把刷新逻辑和OpenCode自身渲染混在同一个输出流里,导致清行命令会把OpenCode已经输出的内容也清掉。比如OpenCode正在绘制代码块边框的时候,你的插件突然清空了当前行,视觉上就会非常乱。
我的处理办法是:把TPS显示固定放在每行输出的最前方,并且缩短刷新频率,不做每个Token都刷新,而是每300毫秒刷新一次。用setInterval做定时刷新比在事件回调里刷新更稳,可以减少输出频率,也不容易和OpenCode的渲染打架。
setInterval(() => { if (bufferTokens > 0) { updateDisplay(); } }, 300);实测下来,如果每隔300毫秒刷新,终端视觉上足够平滑,也不会出现抢行、闪屏这些毛病。
4.3 瞬时TPS虚高,怎么让它更接近真实体感
我一开始用纯瞬时值计算,就是每次事件到来都算一次(当前累计Token数 - 上次累计Token数) / 时间差,结果数字上下翻飞得很夸张,偶尔冲到几百Tok/s,过一秒又变成个位数。后来才意识到,这就是典型“瞬时值虚高”——模型有时候会快速吐出一段缓存好的内容(尤其是命中上下文缓存的时候),然后进入长时间思考停顿,瞬时速度完全没参考价值。
解决办法就是回到第一节说的滑动窗口。窗口内token数和时间差都做累计,算的是“最近几秒的平均值”。这样即使某一瞬间来了个大爆发,只要后面的停顿久一点,数字会迅速被拉回正常区间。我还在代码里加了一个抗抖动逻辑:如果窗口内的Token数小于2,就不更新显示,避免停顿期间数字乱跳。
4.4 不同会话之间的数字互相干扰
OpenCode支持同时打开多个会话(session)。如果插件里用一个全局变量累计Token数,那多个会话同时跑起来时,窗口统计就会被混在一起,显示的数字是各路Token的合集。这个数字不能说完全没用,但它不再代表某个单一会话的速度。
要想精确到每个会话独立统计,思路是把计数变量从全局平铺变成按sessionId存map。事件数据里一般都能拿到sessionID,用Map<sessionId, WindowState>来维护每个会话各自的窗口。我日常使用其实很少同时跑多个并发会话,所以一直用全局统计,但如果你们团队并行使用同一台终端,就会遇到这个问题——做会话隔离也不复杂,就是多一个map而已。
4.5 显示行被代码输出挤没了
让插件把数字固定在屏幕底部会让代码输出变乱,所以大多数实现都是让数字跟内容混在输出流里。一旦遇到大段代码刷屏,TPS数字就会被顶上去,甚至被挤得找不着。我的土办法是:把TPS显示和当前输入行的前缀拼在一起,放在用户输入框的提示符前面,而不是放在输出内容流里。这样即使代码刷屏了,用户输入框附近始终能看到速度。这个方案在OpenCode终端里实测是可行的,只要你拿到当前输入行的DOM位置或始终在提示符前打印,就能稳住位置。
5. 更进一步:还能给这个插件加些什么
5.1 顺手统计Token消耗金额
既然Token数已经拿到了,顺手做成本估算很简单。不同模型的计价差别很大,按官方定价表配置一个modelPricing映射,然后在插件里读取当前会话使用的模型名,乘上单价即可。我加了一个配置项,允许在config.toml里定义自定义模型的价格,因为各家API时不时搞活动,价格变了直接改配置比改代码省事得多。
显示上我把累计成本拼在TPS后面,实时速度加钱包余量一起看,比单纯盯着TPS更直观。比如某次任务很慢时,你可以当场意识到——“这轮对话烧掉了多少额度”,然后决定要不要换成便宜的模型。
5.2 感知Prompt缓存命中率对速度的影响
我的插件后来还加了一个简单的能力:检测事件里是否存在缓存命中的标记。现在好多模型API支持上下文缓存,命中缓存的部分比正常生成快得多。如果你看到TPS突然飙高,但实际网络通畅,很可能是缓存命中了一部分内容,而不是模型突然变强了。加这个检测不复杂,读取事件里的usage.prompt_cache_hit_tokens字段,如果有值且大于0,就在TPS后面打一个小的[cache +N]标记。这个信息在排查“为什么TPS突然波动那么大”的时候很有用。
5.3 可选的音频回调提示
写代码时眼睛专注在编辑器里,不一定每次都转头看终端。后来我在插件里加了一个可选的音频提示,每当窗口内平均TPS低于设定的阈值时,播放一段短提示音,提醒“这个模型卡住了”。音频调用很简单,用系统的播放命令传一个短的wav文件路径,或者直接调用console.log("\x07")触发终端响铃。但说实话,这功能新鲜两天就不想开了,提示音反而打扰写代码,最后我还是默认关闭它。保留选项只是作为团队展示时的趣味彩蛋。
6. 踩坑后的一些建议
写这个插件前前后后花了一个晚上,大部分时间不是耗在逻辑上,而是耗在一些非常基础却又绕不开的坑上。比如OpenCode插件加载失败的时候报错信息不直观,要自己加console.log定位;比如不同版本的事件结构有细微差别,写过滤条件前必须先打印事件对象;再比如终端输出控制,任何实时监控类插件,在没有用ANSI转义码做行内刷新之前,体验都跟刷屏日志一样糟糕。
按我现在的习惯,TPS插件已经是OpenCode环境里常驻的配置之一了。选模型时,我拿三个候选模型各跑一段基准任务,直接看终端里每秒刷新的数字做对比,比自己翻文档、看社区口碑靠谱得多。日常写代码的时候反而不怎么盯着数字看,但它就在那里——速度一旦感觉不对劲,瞟一眼就能判断是网络问题、上下文太长还是模型卡住了,省掉了不少反复试错的功夫。
如果你也是重度OpenCode用户,强烈建议自己也写一个。这个插件的代码量真不大,核心逻辑百来行,但写完之后你对“终端AI编程”过程的掌控感明显不一样。最后提醒一句:不同版本的OpenCode插件API可能有细微差别,碰到失效就去node_modules里看看最新的类型定义,两分钟就能适配好,别被一开始的报错劝退了。