
最近在终端里折腾OpenCode折腾得挺上头。这个开源的AI编程助手确实能打模型随便接插件体系也开放唯一的遗憾是官方界面始终没把Token生成速度——也就是我们常说的TPS——直接摆到台面上。速度这玩意儿不看数字很难有直观感知有的模型你体感“还行”一测才发现大段生成的时候能让人等到怀疑网络。所以我干脆写了一个OpenCode插件专门在对话生成时实时计算并展示Token生成速度TPS。这篇文章把插件从原理到实现完整拆一遍涉及OpenCode插件事件机制、TPS的计算口径、完整代码、踩坑记录和扩展玩法。不管你是OpenCode的重度用户还是想学如何在AI工具里做“过程指标监控”都可以直接抄作业。先说清楚这东西解决什么问题。我日常要在好几个模型之间切换工作每个模型都有自己的脾气有的思考快、出字慢有的首字反馈快、越到后面越肉还有的中转服务商会在配置里写“高并发高速率”实际用起来TPS跌到个位数。有了这个插件所有速度数字实时摆在眼前选模型、配路由、调服务商参数时都不用再靠玄学猜了。1. 项目背景与方案选型1.1 为什么一个TPS监控插件这么有必要先对齐一下TPS这个概念。TPS是Tokens Per Second的缩写指模型每秒生成的Token数量。在OpenCode这类终端AI编程工具里TPS直接决定了你写完一句提示词之后在终端干等多久才能看到后续代码。一顿生成几百上千个Token如果TPS只有个位数肉眼可见地“一字一字蹦”编程节奏会被打得很碎如果TPS能稳定在几十甚至上百体验就是瀑布式的几乎不用等。我在实际使用中的经验是OpenCode的TUI界面虽然会显示一些基本信息但全程不会给你一个清晰的“速度”指标。官方插件机制又非常开放公开了大量事件钩子天然适合在上面做增强。于是我开始琢磨能不能做一个轻量插件在模型生成过程中持续监听最新事件把Token增量和耗时换算成TPS实时显示出来。抛开个人体验这个插件还有几个非常实用的场景。第一是横向对比不同模型的真实速度同一个任务分别让几个模型跑一遍哪个快哪个慢不再靠体感打分。第二是验证第三方服务商宣传的“高速”是否属实很多中转服务号称高吞吐实际测下来TPS却惨不忍睹一个不依赖官方后台的独立测量工具就很有价值。第三是辅助调优当TPS长时间偏低时你能马上判断是网络瓶颈、模型负载还是提示词让模型进入了超大输出路径。1.2 两条路轮询日志 vs 事件订阅确定目标之后第一个要解决的就是“怎么拿到生成过程中的Token变化情况”。我当时想了两条路。方案A是轮询OpenCode的日志文件。OpenCode在debug模式下会输出大量事件日志到文件理论上可以定时读取、解析里面的Token数据。这个方案实现起来最直接不需要依赖插件API但有两个硬伤一是日志刷新的频率和内容结构随时可能变解析逻辑非常脆弱二是轮询本身有延迟读文件、正则提取、再展示整个链路做不到真正“实时”最多是“准实时”。方案B是走官方插件系统订阅OpenCode暴露的流式事件。OpenCode插件运行时可以直接接收session.message.start、session.message.updated、session.message.completed这类生命周期事件事件里带着消息体、会话ID还有模型用量usage信息。插件收到事件后自己维护一份会话状态算Token增量、算耗时、刷新显示全部发生在事件回调里数据链路最短。我最后选了方案B。原因很直接事件订阅是官方能力事件结构和数据字段相对稳定回调是同步触发的实时性远好于轮询而且插件方案可以完整保留每个session的上下文天然支持多会话并发统计。轮询日志只适合做最后兜底不适合做核心链路。1.3 插件显示方案stderr输出与文件日志插件把TPS算出来之后还有一个问题怎么“显示”给用户OpenCode的TUI是整个终端全屏渲染的插件直接往stdout里打console.log极大概率会被TUI自身的内容覆盖你根本看不到。我实测下来比较稳的组合是双通道输出。第一通道是stderr也就是process.stderr.write。OpenCode会把插件进程的stderr内容带进自己的日志面板在TUI下方的小区域能看到滚动输出方便快速瞄一眼当前的TPS。第二通道是文件日志。插件把每次刷新和最终汇总同时追加写到一个日志文件比如/tmp/tps-monitor.log在外面开一个tail -f随时查看详细数据做数据留档也很方便。这里有个经验之谈输出频率一定要做节流。OpenCode的流式更新事件触发频率非常高如果每次回调都写一次stderrTPS还没看清终端先被刷爆了。我的做法是100毫秒内的多次更新合并成一次输出只推最新的计算值。后面代码部分会详细说。2. Token与TPS理解这两个核心指标2.1 Token到底是什么怎么数才不算错聊TPS之前先得把Token这件事说清楚不然后面算数全是糊涂账。Token是模型处理和生成文本的最小单位你可以把它理解成一种“文字积木”。英文世界一个大致的经验值是1个Token约等于4个字符中文由于字符信息密度更高一个汉字大约对应0.6到1.5个Token具体要看模型用的分词器。把Token数数明白这件事比想象中重要。一方面许多API按Token计费模型接口还经常有每分钟Token数TPM的限流你写插件统计TPS的同时如果能顺手累计Token等于把用量监控也做了。另一方面TPS的分母就是Token数Token数数不准TPS必然是错的。怎么数才准最准确的做法是调用模型对应的分词器比如OpenAI系模型常用tiktoken库加载cl100k_base这类编码表后再计算。但这个方案在OpenCode插件里并不划算插件每次收到事件都要跑一遍分词CPU开销和延迟都上去了实时显示场景没有必要。更聪明的做法是直接读取事件消息里携带的usage字段OpenCode在消息更新事件里一般会带模型的用量信息里面就有input_tokens、output_tokens或同义字段这是模型自己上报的最接近真实值。只有当usage字段取不到时我才退化到用文本长度估算英文按字符数除以4中文按字符数乘以0.75左右再取个整。还有一个细节要注意事件里的usage标签经常是“累计值”而不是“增量”。也就是说message.usage.output_tokens给的是这个消息生成到目前已产出的总Token数并不是上一次更新到现在的新增Token数。我的插件里直接拿累计值除以总耗时算平均TPS思路是顺的要是你想算“最近1秒的瞬时TPS”就得自己记录上一次的Token数用差值除以间隔时间。两种口径各有用途下面展开说。2.2 TPS的三种算法口径别被“虚高TPS”糊弄TPS这个词看着简单真正落地时有很多口径不同口径算出来的数差别能到30%甚至更大。这也是网上经常有人说“TPS虚高”的根本原因。第一种口径纯生成速度。从模型产出第一个Token之后开始计时到消息生成完毕结束用这段时间的Token总数除以耗时。这个口径剔除了首字延迟和网络传输时间最能反映模型“吐字”本身有多快。平台展示的“生成速度”宣传数字绝大多数是这个口径。第二种口径端到端速度。从你按下回车、请求发出那一刻开始计时到最后一个Token落地结束。这个口径包含了请求排队、网络往返、模型预填充prefill等多个环节反映的是用户实际等待时间。这个数字通常比纯生成速度低不少。第三种口径瞬时TPS。把整个生成过程切成以秒或几百毫秒为单位的小窗口看每个窗口内平均每秒生成多少Token。因为流式输出本身存在波动开头和结尾的速度通常不一样瞬时TPS能看出某一段是不是明显卡顿。我在这套插件里的口径选择是主指标用“从消息开始生成到当前时刻的累计平均TPS”相当于端到端和纯生成速度的中间值同时记录第一个Token到达的时间TTFTTime To First Token并把“开始生成”和“拿到第一个Token”两个时间点都存下来。这样既能给用户一个平滑、不跳变的速度值又能保留足够信息去判断“慢在连接还是慢在生成”。这里还要多说一句防忽悠。很多平台展示的TPS是拿固定测速prompt跑出来的短文本、低并发数字自然好看你真实使用时是长代码、上下文塞满TPS能差出一大截。所以比起绝对TPS你自己插件在同一负载下跑出来的相对值更有参考价值。2.3 一个计算实例把口径差异讲明白数字说再多都不如直接算一遍。假设一次生成任务总共产出了2000个Token请求发出到第一个Token返回用了2秒也就是TTFT2秒第一个Token到最后一个Token之间用了16秒那么纯生成速度就是2000/16125 TPS如果从请求发出那一刻算到底总时间是18秒端到端速度是2000/18≈111 TPS。两种口径差了14个TPS在对比模型时很容易造成误判。如果服务商拿“纯生成速度”当卖点你自己按“端到端速度”去测测出来的数字当然显得“虚高”。这里没有对错关键是口径必须对齐。我的插件里为了兼顾清晰会同时记录三个时间点startAt事件开始、firstTokenAt首次看到Token、endAt消息完成日志里三行全打出来。平时显示给用户的就是滑动窗口平滑后的累计TPS但原始数据都在你不满意随时可以换算法。3. 实操开发手写这个TPS插件3.1 环境准备开始写代码前先确保基础环境没问题。OpenCode本身的安装方式有三种任选一种就行npm全局安装npm install -g opencode-aiHomebrew安装brew install opencode-ai或者用官方安装脚本。安装完成后命令行输入opencode --version能正常输出版本号即可。插件开发建议用Node 18以上的版本OpenCode的插件运行环境基于Node太老的版本容易出现语法兼容问题。可以用node -v确认一下。接下来在你自己项目的根目录或者任意工作目录建插件项目。我的习惯是所有OpenCode相关配置都放在~/.config/opencode下面插件代码也统一放一起不容易丢。OpenCode默认读取~/.config/opencode/opencode.json作为全局配置插件目录选在~/.config/opencode/plugin就好后续调试也方便。3.2 项目结构与构建配置插件本质上是一个npm包需要能被Node加载。我采用的是TypeScript编写、tsup打包成ESM的方案。项目结构如下opencode-tps-monitor/ ├── package.json ├── tsconfig.json ├── src/ │ └── index.ts └── dist/ // 构建产物最后被OpenCode加载的入口package.json里几个关键字段要写对type设为modulemain指向dist/index.jsscripts里加build和dev。构建工具选tsup因为OpenCode插件生态里tsup用得很多配置少、产物干净直接可以出ESM格式。dev脚本用tsx watch做热重载改完代码立刻能看到效果比来回build省事很多。在配置OpenCode加载插件时无论是写在全局opencode.json里还是按项目放到项目级配置文件本质上都是告诉OpenCode“去这个路径加载插件包”。我推荐先在全局配置里加插件稳定后再考虑要不要按项目隔离。3.3 核心代码逐块拆解插件核心就一个文件src/index.ts但里面的逻辑要拆开看不然不好维护。第一块是会话状态管理。因为OpenCode支持同时开多个会话如果用全局变量存startAt和token数两个会话一并发消息就全乱了。我用一个Mapkey是session.idvalue是当前会话的起始时间、Token累计数、历史TPS数组、上一次刷新时间等状态。每个事件回调进来先定位到自己的session状态互不干扰。第二块是Token获取函数。我写了一个getOutputTokens函数逻辑是优先读message.usage里的output tokens字段不同版本命名可能不同所以做了一层兼容如果取不到usage再fallback到遍历message.parts把所有assistant文本part的字符数累加再按我们前面说的经验系数估算Token。整个函数用try/catch包起来避免某个part结构异常导致插件崩溃。第三块是节流与平滑。事件刷新至少间隔100毫秒防止高频写屏。计算出的瞬时TPS推入一个长度为20的滑动窗口数组取平均值作为展示值这样数字不会像心电图一样乱跳。第四块是输出。写stderr和写日志文件分开两个函数stderr使用\r回车符覆盖当前行这样同一行内反复刷新不会产生大量滚屏文件日志则每次都追加时间戳、会话ID、Token数和TPS格式是JSON Lines后面要接统计或图表直接可以解析。第五块是事件生命周期。我在session.message.start里初始化状态并记录startAt在session.message.updated里更新Token并刷新TPS在session.message.completed里做最终结算并清理Map。异常场景也要照顾如果触发了session.message.error这类事件同样要清理状态不然Map里会有大量僵尸session。我把核心实现贴在下面这是去掉注释后的版本方便直接复制改。import type { OpenCode } from opencode-ai/plugin import { appendFileSync } from node:fs interface SessionState { startAt: number firstTokenAt: number | null lastUpdateAt: number outputTokens: number history: number[] } const sessions new Mapstring, SessionState() const LOG_FILE process.env.OPCODE_TPS_LOG ?? /tmp/tps-monitor.log function logToFile(line: string) { try { appendFileSync(LOG_FILE, ${new Date().toISOString()} ${line}\n) } catch { // 忽略写入失败 } } function getOutputTokens(message: any): number { try { if (typeof message?.usage?.outputTokens number) { return message.usage.outputTokens } if (typeof message?.usage?.completion_tokens number) { return message.usage.completion_tokens } let estimated 0 if (Array.isArray(message?.parts)) { for (const part of message.parts) { if (typeof part?.text string) { estimated Math.ceil(part.text.length / 3) } } } return estimated } catch { return 0 } } function smooth(value: number, history: number[]) { history.push(value) if (history.length 20) history.shift() return history.reduce((a, b) a b, 0) / history.length } function render(state: SessionState, sessionId: string) { const now Date.now() const elapsed (now - state.startAt) / 1000 if (elapsed 0) return const rawTps state.outputTokens / elapsed const avgTps smooth(rawTps, state.history) const ttft state.firstTokenAt ? ((state.firstTokenAt - state.startAt) / 1000).toFixed(2) : N/A const line [tps] session${sessionId} tokens${state.outputTokens} elapsed${elapsed.toFixed(1)}s ttft${ttft}s tps${avgTps.toFixed(1)} process.stderr.write(\u001b[2K\r${line}) logToFile(line) } export const plugin: OpenCode { event: { async session.message.start(payload: any) { sessions.set(payload.session.id, { startAt: Date.now(), firstTokenAt: null, lastUpdateAt: 0, outputTokens: 0, history: [], }) }, async session.message.updated(payload: any) { const state sessions.get(payload.session.id) if (!state) return if (state.firstTokenAt null) { state.firstTokenAt Date.now() } state.outputTokens getOutputTokens(payload.message) const now Date.now() if (now - state.lastUpdateAt 100) return state.lastUpdateAt now render(state, payload.session.id) }, async session.message.completed(payload: any) { const state sessions.get(payload.session.id) if (!state) return state.outputTokens getOutputTokens(payload.message) const elapsed (Date.now() - state.startAt) / 1000 const finalTps elapsed 0 ? state.outputTokens / elapsed : 0 const line [tps:done] session${payload.session.id} tokens${state.outputTokens} elapsed${elapsed.toFixed(1)}s avg_tps${finalTps.toFixed(1)} process.stderr.write(\n${line}\n) logToFile(line) sessions.delete(payload.session.id) }, async session.message.error(payload: any) { sessions.delete(payload.session.id) }, }, }补充一句OpenCode不同版本对插件导出的要求不完全一致有的版本要求export default一个插件对象有的版本从package.json的main字段加载后读取export const plugin。写完之前先确认一下你本地版本的类型定义把最后的导出语句对齐。这段代码里有几个值得注意的决策一是用stderr而不是stdout写数据原因前面说过避免污染TUI主内容二是获取Token优先信usage字段因为模型自己统计的通常最准文本估算只是兜底三是事件回调里全程使用any类型不是偷懒而是OpenCode不同小版本的payload结构确实有变动插件里做好防御比强类型更实际四是completed事件里除了再次输出一条汇总还负责把Map里的状态删掉这一条是防内存泄漏的关键。3.4 接入OpenCode并验证代码写完后在项目目录执行npm install和npm run build确认dist/index.js生成。然后在opencode.json里加入插件配置{ $schema: https://opencode.ai/config.json, plugins: { tps-monitor: { path: ~/.config/opencode/plugin/opencode-tps-monitor } } }需要说明的是不同版本OpenCode对插件路径的解析规则略有不同如果你的路径写得很玄学直接写绝对路径最稳妥。配置完成后启动opencode随便输入一个需要较长输出的提示词比如“用中文写一篇3000字的科普文章”。在输出过程中你会看到终端底部区域持续跳动TPS数值结束后有一条汇总日志。打开另一个终端窗口执行tail -f /tmp/tps-monitor.log能看到每条记录的JSON行数据。我第一次验证的时候发现一个有意思的现象首字返回很快但中期TPS会掉下来一截然后恢复。后来定位到是模型在生成到长代码块时单次流式返回的数据块变小导致的瞬时波动。这正是为什么要做20个样本的滑动平均你单看某一秒会被这种波动误导。我还把日志文件路径做成了环境变量OPCODE_TPS_LOG这样在多插件环境里可以指定自己的日志位置避免和别的插件共用一个文件方便查问题。4. 常见问题与排查实录4.1 插件加载不生效最常见的问题就是OpenCode压根没加载这个插件表现是无论怎么生成界面和日志文件都没有任何输出。排查顺序如下先检查opencode.json里plugins配置的路径是否正确尤其是路径末尾有没有准确指向插件目录。插件目录下必须有package.json和dist/index.js两个关键文件main字段不能写错。我把main漏写的时候OpenCode加载插件会直接报错但界面不一定显示需要开--debug看日志。再用opencode --debug启动一次看启动日志里有没有加载tps-monitor的痕迹。如果看到插件相关的报错把错误信息带进搜索引擎基本都能定位。最后别忘了重启OpenCode插件配置只在启动时加载改完配置不重启是不会有变化的。4.2 TPS一直为0如果插件加载了但TPS始终显示0问题多半出在Token获取上。先打印一下payload.message的完整结构确认事件里有没有usage字段。不同OpenCode版本、不同模型提供商甚至同一个模型的非流式响应和流式响应usage字段的位置都可能不一样。我的经验是拿一次真实事件打日志再对照当前版本的类型定义去调整getOutputTokens里的字段名。如果模型提供方没有返回usage那么兜底的文本估算逻辑要确保message.parts里的assistant文本part能被遍历到。这里最容易翻车的地方是OpenCode有时会把文本放在part.type为text的part里但如果你防御性的遍历逻辑漏掉了type判断可能压根没进到text分支。4.3 输出刷屏与多会话干扰刚开始调试时因为没有节流事件回调每一帧都往stderr写终端直接被刷成走马灯。解决办法就是我代码里的100毫秒节流这个时间窗口我调过很多次50毫秒还是会偶尔跳帧200毫秒又嫌反应慢100毫秒是目前兼容“实时感”和可读性的折中。多会话并发时只要每个会话的状态都挂在session.id对应的Map项里就不会互相干扰。但要注意completed事件必须删Map项我见过有同学写完插件跑了一晚上第二天发现内存涨了快200MB就是漏了这步。另外如果OpenCode在消息中途被用户主动中断或发生错误触发的事件可能是cancel或error这些回调里也要做清理。4.4 不同版本OpenCode的字段兼容OpenCode迭代很快插件API在近几个版本里变过不少次。今天能用的payload结构明天升级后可能就换了。我的建议是插件代码里对关键字段尽量做类型保护和fallback别假设字段一定存在在package.json里把openCode的peerDependencies范围写得严谨一点避免装了不兼容版本升级OpenCode后先用短消息测试一遍插件不要一上来就跑长任务。还有一个实用小技巧把你在升级过程中见过的字段变化记录到插件的README里下次遇到排查可以直接翻。下面这张表是我自己排查时常用的速查表顺手贴出来。现象可能原因快速定位方法插件完全无输出配置路径错误、未重启、main字段错误检查opencode.json用--debug启动TPS一直为0usage字段取不到、fallback遍历漏类型打印message完整结构对照类型定义终端刷屏事件回调缺少节流加100ms以上的刷新间隔内存持续上涨completed/error未清理Map确认所有终止事件都执行sessions.delete5. 实测数据与后续扩展5.1 我本地跑出来的TPS样本插件稳定之后我在本地做了一轮简单实测。为了控制变量我用了同一个提示词清空上下文每个模型连续测三轮取中位数。结果大概有几个档位轻量模型明显快权重较大的模型会慢一些部分中转服务商的表现波动极大同一提示词第一轮和第二轮的TPS差距能到一倍。这里不点名具体数字因为网络环境和负载对速度影响太大单点数据容易带偏别人但结论很明确TPS是一个动态指标必须连续观察几分钟才有参考价值单跑一次就是抽卡。这个“波动极大”的发现其实也是插件本身的价值。服务商后台显示的TPS往往是总量除以总时长的平均掩盖了中间掉速的问题。而我的插件记录整条生成过程一眼就能看出是不是隔几秒就掉到个位数。如果你做的是长文档生成、批量代码重构这类长任务这种细节决定了最终体验。5.2 低成本扩展用量统计与速度报警这个插件写好后我觉得最值的地方不只是显示速度而是把“过程数据”沉淀了下来。现在文件日志里每一条都是JSON行后面可以写个十几行的脚本每天汇总一下今天AI总共生成了多少Token、平均TPS多少、最慢的一段出现在几点。如果你用的是按量计费的模型再乘以单价一天在AI上花了多少钱也能直接算出来。更进一步可以在插件里加一个阈值报警当连续10秒TPS低于设定值比如10 TPS时调用系统通知命令发一条提醒免得你盯着终端干等。我看到网上有人用OpenCode做无人值守的批量编码任务时就在插件里加了这种速度看护效果很好。5.3 最后一点个人心得插件很小加起来才不到两百行但它把我用OpenCode的体验提升了一个档次。过去我只知道自己“感觉有点慢”现在我能精确地说出是首字慢、连接慢还是生成速度本身不行。做同类插件时我最大的体会是不要把统计逻辑和服务端的业务逻辑耦合在一起。TPS监控做成独立的插件包OpenCode升级不会影响你的主流程。这个思路放到其他AI工具上也是一样凡是基于事件流的工具都可以用类似的插件模式去采集过程指标。动手试一次你会发现理解一个AI工具的方式变得完全不一样。