最近一直在用 OpenCode 跑各种代码任务,模型生成速度快的时候代码唰唰往外冒,慢的时候恨不得半天蹦一个字,盯着终端干着急。后来翻了 OpenCode 的源码文档才发现,流式输出里每个增量块其实都带着对应的 token 增量,官方界面却始终只给一个最终总用量,根本看不到"当前每秒生成多少 token"。为了解决这个感知空白,我写了个 OpenCode 插件,实时显示 Token 生成速度,同时把显示层接进 DSH 插件样式体系,顺着dsh plugin命令就能直接装。这篇文章把这套插件的设计思路、核心实现和踩过的坑完整捋一遍,适合正在用 OpenCode 写自动化任务、又想把 token 用量和生成节奏掌握在手的人参考。
1. 为什么 OpenCode 需要实时 Token 速度
1.1 Token 速度背后是成本和体验的双重指标
先简单说下 Token 是什么。大模型处理文本的基本单位不是"字",而是按 Token 切分,一个 Token 可能是半个词、一个词,也可能是几个字符组合。模型供应商计费、上下文窗口限制、速率限制(Rate Limit)全部围绕 Token 展开。所以"Token 生成速度"这个指标不是给数据党自嗨用的,它直接挂钩两件事:第一,交互体验,第二,成本节奏。
从体验上讲,OpenCode 跑长任务时,如果上下文塞得比较满,生成速度会肉眼可见地下降。很多时候你盯着屏幕看半天没输出,第一反应是"是不是卡死了",实际上模型还在正常生成,只是推理变慢了。这时候如果没有实时速度指标,你根本分不清是网络挂了、服务限流了,还是单纯这段生成本来就慢。有了实时 tokens/s 数值,至少能判断"现在还在干活,只是啃骨头阶段"。
从成本角度讲,OpenCode 对接的多家模型服务基本都按 Token 计费,而且很多套餐还有每分钟/每小时 Token 上限。你在一个长任务里摸黑跑,跑到一半报速率限制,整个会话直接废掉。实时看到当前消耗速率,就能判断这一轮请求离上限还有多远,该不该早点切断重来。对依赖 OpenCode 做批量代码生成和重构的人来说,这个指标就是仪表盘上的时速表,比最终统计有用得多。
1.2 官方界面留下了一个体验空白
我用 OpenCode 有段时间了,它的默认界面做得不算差:能看当前模型、上下文占用、总输出 Token 数,命令面板里也有会话统计。但问题在于,这些都偏"事后统计",没有"过程仪表"。你正在等一个长流式输出的时候,界面上除了光标和文本滚动,没有任何量化信息告诉你生成得快还是慢。
很多人其实被这个问题困扰过。网上搜 OpenCode 相关讨论,能看到不少关于 token 失效、token 续签、token exchange failed 之类的话题,说明大家花了很多精力在"能不能登录、能不能续上"上面,但真正用起来之后,反而缺少"当前生成速度是多少、还有多少余量"这种运行时指标。这也正是我觉得有必要做这个插件的原因:把流式数据里本来就有、但官方没展示的实时信息拿出来,做一个轻量级的仪表面板。
另一个出发点是想清楚之后可以做点什么:接入 DSH 这套终端插件生态以后,插件就不再是"一次性脚本",而是能在dsh plugin体系里安装、更新、共享的东西。这一点后面会详细讲,先说整体设计。
2. 整体设计:先想清楚三层结构再动手
2.1 数据采集、指标计算、样式渲染三层分离
做终端插件最容易翻车的地方,就是所有逻辑堆在一个文件里,EventEmitter 一回调就开始刷屏、算数、画界面,结果一个函数几十个职责,改一个颜色都可能碰坏取数逻辑。我这次一开始就按三层来拆:
- 数据采集层:负责监听 OpenCode 的事件流,拿到每次生成的增量文本和 usage 数据。
- 指标计算层:把增量数据喂给滑动窗口和平均算法,算出实时速度、峰值速度、累计消耗。
- 渲染层:只负责把计算结果画出来,可以在终端里原地刷新,也可以输出成 DSH 样式面板。
这样拆的好处非常直接。OpenCode 的流式事件是高频异步的,而 UI 刷新不能跟着事件频率走,否则终端会刷成鬼畜。数据层和渲染层中间夹一个计算层,相当于加了个缓冲和节流阀。后续如果想换显示风格,比如从终端面板切到 Webview,或者从 DSH 样式切回纯文本模式,只需要替换渲染层,采集和计算逻辑完全不用动。
另外一个工程上的考虑是插件要能在 OpenCode 的插件机制里跑起来,所以在结构上要符合它的事件订阅规范。OpenCode 这类终端 AI 套件现在普遍提供事件钩子,包括流式响应开始、增量块到达、响应结束、请求出错等。插件只需要关注增量块和结束事件,不需要侵入核心代码,这也是它能以"插件"而不是"魔改"方式存在的前提。
2.2 为什么显示层选择 DSH 样式而不是自己造轮子
DSH 是我这段时间在终端工具圈里比较关注的一套插件与显示体系。它自带插件市场(dshmarket)、插件树加载机制和 web 认证流程,安装命令也很直接,比如dsh plugin --profile web add dshmarket。选择把显示层接到 DSH 上,不是因为它花哨,而是因为省事且规范。
省事在于,DSH 已经定义了面板、Webview、配色、布局的通用构件,我不需要自己设计一套终端 UI 规范。终端 UI 看起来简单,真正折腾起来很坑:文字对齐要处理中英文宽度、颜色要兼容不同终端配色主题、刷新时的闪烁要控制。DSH 把这些基础能力标准化了,我只需要关心"面板里放什么数据"。
规范在于,做成 DSH 插件之后,分发和安装路径都清晰了。你可以直接把插件项目放到 dshmarket 或者自己的仓库里,别人用一条dsh plugin命令装完就能用,而不是让人去 clone 仓库、手动复制脚本。团队协作时,DSH 的配置文件也能进入版本管理,换台机器拉下来就恢复环境。
当然,DSH 也不是没有门槛。它要求插件注册到插件树里,配置里写错一个字段就可能导致加载失败。热词里那条error: dsh: plugin tree failed to load: failed to apply loader entry include就是典型。这个问题我在后面的排查章节会专门讲,它本质上是指令加载器读 include 配置时出了问题,要么路径不对,要么语法错误。
3. 核心实现:实时 Token 生成速度怎么算才靠谱
3.1 从流式输出里接住 Token 增量
OpenCode 和模型服务之间走的通常是 SSE(Server-Sent Events)流式协议。每次事件里带着一段增量文本,也就是delta.content。虽然不同服务商的字段名略有差异,但大体结构是一致的:一个流式响应由多个 chunk 组成,每个 chunk 里有一段新的文本片段,把这些片段连起来就是完整输出。
我们要做的第一步,就是把这段增量接住。以 TypeScript 为例(目前 OpenCode 插件生态里比较主流的开发语言),核心思路是这样:
// 简化示例,说明核心取数逻辑 class TokenMeter { private totalTokens = 0; private totalTextLength = 0; onDelta(content: string) { // 增量文本到达 this.totalTextLength += content.length; // 精确 token 数要等 usage 字段或自行调 tokenizer, // 实际实现里可以先按字符估算,后面再校准 const estimatedTokens = this.estimateTokens(content); this.totalTokens += estimatedTokens; // 把本次增量的时间戳和 token 数交给计算层 this.emitter.emit('sample', { tokens: estimatedTokens, timestamp: performance.now(), }); } private estimateTokens(text: string): number { // 常见做法:中文 1 Token ≈ 1~1.5 字,英文 1 Token ≈ 4 字符 // 更精确可以用 tokenizer,但流式场景里字符估算开销更低 if (!text) return 0; return Math.ceil(text.length / 2.5); } }这里要注意一个细节:不同模型服务返回 usage 的时机不一样。有的服务在流式响应结束时会返回一个总的usage字段,里面有prompt_tokens、completion_tokens等精确值。有的服务则不会返回。实战中我通常用"字符数估算"作为实时速度的材料,在流结束时用usage里面的精确值做一次校正,把累计消耗刷新成准确数字。这样既保证了实时性的低延迟,又保证了最终统计的准确性。
3.2 滑动窗口加指数移动平均:速度值才不会上蹿下跳
拿到增量 Token 数之后,最原始的做法是"每秒 Token 数 = 这一秒收到的 Token 数"。这样算出来的曲线会非常抖:模型生成一个长单词可能要几百毫秒,下一秒可能一个 Token 都没出,瞬时速度直接归零;再下一秒连续出好几个词,速度又飙上去。这种上下乱跳的数据对用户没有参考价值,反而制造焦虑。
我用的方案是两个算法叠加:滑动窗口 + 指数移动平均(EMA)。
滑动窗口的逻辑是:维护一个固定长度的采样数组,比如 5 秒内的采样点。每个采样点记录(timestamp, accumulatedTokens)。计算速度时,用窗口内最新样本和最早样本之间的 Token 差,除以时间差:
type Sample = { timestamp: number; tokens: number }; function calculateSpeed(samples: Sample[], windowMs = 5000): number { if (samples.length < 2) return 0; const now = Date.now(); // 丢弃窗口外的旧样本 const valid = samples.filter((s) => now - s.timestamp <= windowMs); if (valid.length < 2) return 0; const oldest = valid[0]; const newest = valid[valid.length - 1]; const deltaTokens = newest.tokens - oldest.tokens; const deltaMs = newest.timestamp - oldest.timestamp; if (deltaMs <= 0) return 0; return (deltaTokens / deltaMs) * 1000; // tokens/s }滑动窗口解决了"单点采样抖动"的问题,但窗口期内的速度在边界切换时还是会跳变。为了再平滑一点,我叠加了一个 EMA:
let smoothSpeed = 0; function updateSmoothSpeed(instantSpeed: number, alpha = 0.3) { // alpha 越大越贴近瞬时值,越小越平滑 if (smoothSpeed === 0) { smoothSpeed = instantSpeed; } else { smoothSpeed = alpha * instantSpeed + (1 - alpha) * smoothSpeed; } return smoothSpeed; }alpha取 0.3 左右是我实测下来比较舒服的值,既有足够的响应速度,又不会因为一个瞬时脉冲就大幅跳动。如果是在跑代码生成这种输出节奏波动较大的场景,建议窗口开 5 秒、alpha 开 0.2~0.3;如果是在做普通问答这种相对稳定的输出,窗口 3 秒、alpha 0.4 会更灵敏。总之机器性能好的可以稍微调低窗口换取细腻度,性能差的建议加大窗口减少计算频率。
3.3 刷新策略:别让终端刷成鬼畜
采集到了数据、算出了速度,接下来就是显示。最忌讳的做法是每次采到增量就一直console.log,那样终端会疯狂滚动,历史记录全被刷没,而且控制台日志本身也会影响性能。我的做法是固定一个刷新周期,比如 500ms,用 ANSI 转义序列在原位置重绘。
function renderStatus(speed: number, totalTokens: number) { // \r 回到行首,\x1b[K 清除光标到行尾的内容 process.stdout.write('\r\x1b[K'); process.stdout.write( `speed: ${speed.toFixed(1)} tok/s | total: ${totalTokens} tok` ); }只要是终端里做实时状态展示,这套"回行首 + 清行尾 + 重写"的组合就是基本功。要注意的是,如果同一行里既有插件刷新的速度信息,又有 OpenCode 自己的日志输出,两者会打架。所以我实际做的时候把速度面板固定渲染在终端最下方独立区域,或者直接通过 DSH 的 Webview 组件渲染到独立面板里,避免和主输出流搅在一起。
还有一个小细节:渲染频率和采样频率要解耦。流式事件可能一秒触发几十次,但我强制节流,最多 2 秒刷一次 UI。这样刻度稳定、人眼看起来舒服,也减少了不必要的终端绘制开销。
4. DSH 样式显示与插件接入全程
4.1 把插件注册进 DSH 插件树
DSH 并不是一个单纯的"皮肤",它有实际的项目结构约束。插件注册进 DSH,关键是要把自己的组件挂到插件树上,让 DSH 在启动时能发现并加载它。我做完速度统计模块之后,接入 DSH 的部分大概分了三步。
第一步,建一个符合 DSH 约定的目录结构。一个最小可用的 DSH 插件通常长这样:
my-token-meter/ ├── dsh-plugin.json ├── src/ │ ├── index.ts │ └── panels/ │ └── speed-panel.ts └── dist/dsh-plugin.json是这个插件的身份证明,DSH 启动时首先读它。我项目里的配置大致是这样:
{ "name": "opencode-token-meter", "version": "0.1.0", "entry": "dist/index.js", "loaders": { "include": [ "dist/panels/speed-panel.js" ] }, "dependencies": { "dsh-web": "^1.0.0" } }第二步,把核心逻辑编译成 DSH 能加载的入口文件。DSH 的插件加载器会按照entry找到入口,初始化插件实例,然后把事件总线、渲染上下文注入进来。入口里做的事情就是把速度计算器和 DSH 的渲染组件绑定。
第三步,测试加载。这一步我踩过一个大坑,就是热词里提到的那条:
error: dsh: plugin tree failed to load: failed to apply loader entry include这条报错看着吓人,实际排查后发现是loaders.include里的路径写错了。我一开始写的相对路径是"dist/panels/speed-panel.js",但 DSH 的加载器在某些版本里要求跟插件根目录相对,有些版本要求跟配置文件相对,结果路径解析不到文件,插件树直接加载失败。解决办法是先把 include 配置里的路径改成绝对路径确认组件能加载,再切回相对路径验证解析规则。如果你也遇到这条报错,优先检查 include 路径的目录层级和 JSON 语法,别急着重装。
4.2 渲染出 DSH 风格的速度面板
DSH 的显示核心是一套面板组件机制。它支持把数据渲染成 Webview 里的 HTML,也支持终端内的文本块。我这边选择的是把它渲染成一个"面板",这样既能有表格对齐、颜色标注,又不会干扰主输出区域。
面板的数据结构大致如下:
| 指标 | 说明 | 示例 |
|---|---|---|
| 当前模型 | OpenCode 当前会话使用的模型名 | gpt-4.1 |
| 当前速度 | 实时 Token 生成速度 | 42.5 tok/s |
| 峰值速度 | 本轮会话最高速度 | 68.3 tok/s |
| 累计输出 | 本轮输出总 Token | 1,824 tok |
| 上下文 Token | 当前会话上下文占用 | 32,150 tok |
在 DSH 面板里,我会把当前速度用颜色区分档位:速度在 20 tok/s 以上标成绿色表示流畅,5~20 tok/s 标成黄色表示正常偏慢,5 tok/s 以下标成红色表示可能有问题。这个档位可以根据模型和任务类型调,因为有的推理模型本身生成就慢,标红会引发误判。
实际渲染的核心代码大致是这个思路:
function renderSpeedPanel(snapshot: SpeedSnapshot): string { const color = pickColor(snapshot.speed); return ` <panel name="opencode-token-speed"> <row> <label>Model</label> <value>${snapshot.model}</value> </row> <row> <label>Speed</label> <value color="${color}">${snapshot.speed.toFixed(1)} tok/s</value> </row> <row> <label>Peak</label> <value>${snapshot.peak.toFixed(1)} tok/s</value> </row> <row> <label>Output</label> <value>${snapshot.totalTokens} tok</value> </row> <row> <label>Context</label> <value>${snapshot.contextTokens} tok</value> </row> </panel> `; }DSH 的页面和渲染接口在不同版本里略有差异,但你只要抓住了核心思路——把数据计算和渲染分开,数据给得干净,渲染层只是搬运工——无论接口怎么变都容易适配。
4.3 DSH Web 认证的一个注意点
DSH 的 web 模式需要在浏览器里认证一次。如果你用的是dsh plugin --profile web ...方式安装插件,第一次启动时终端通常会打印一个 URL,要求浏览器打开完成授权。如果没注意这条提示,后续请求就很有可能碰到类似dsh web authentication required; reopen the url printed by dsh web的报错。
这个报错的含义很简单:DSH web 模式下的会话凭证没找到或者已经失效,需要重新完成认证流程。解决办法是把终端打印出来的 URL 重新打开一次,或者用dsh auth login之类的命令刷新凭证。我在本地测试时经常遇到,并不是插件本身的问题,而是 web 认证的会话有效期比较短,隔一段时间不用就得重新授权。如果你的插件要给别人用,这一点最好在 README 里写清楚,否则用户看到dsh web authentication required会一头雾水。
5. 常见问题与排查实录
5.1 Token 认证与刷新报错速查表
做这个插件的过程中,我在 OpenCode、DSH 两边来回折腾,也把网上高频出现的 Token 相关报错收集整理了一下。很多报错看起来五花八门,核心其实落在"凭证失效"和"登录态过期"两条线上。
| 报错信息 | 可能原因 | 处理建议 |
|---|---|---|
token exchange failed: token endpoint returned status 403 forbidden: country | 账号所属区域受限 | 检查账号设置和所在区域,确认服务商是否开放当地访问 |
sign-in could not be completed token exchange failed: error sending request | 认证端点网络请求失败 | 检查网络连通性,确认认证服务是否短暂故障,稍后重试 |
your access token could not be refreshed. please log out and sign in again | 访问令牌已过期且刷新失败 | 按提示登出后重新登录,重新走一遍 OAuth 流程 |
failed to refresh token: 400 bad request invalid refresh_token: empty string | 刷新令牌为空或本地会话数据损坏 | 删除本地会话缓存文件,重新登录生成新的 refresh token |
login server error: token exchange failed ... | 登录服务端响应异常或凭证有误 | 先用无凭证模式确认网络,再检查 API Key 或客户端凭证配置 |
这类问题的排查思路通常是先分清是哪个环节:网络层、认证服务层、还是本地缓存层。如果报错里带error sending request,大概率是网络层;如果带token endpoint returned,大概率是认证服务返回了错误状态码,需要看是 400 还是 403;如果带refresh_token相关字样,那就是本地会话缓存出了问题,删除缓存重新登录是最高效的解法。
5.2 DSH 插件加载与显示问题排查
DSH 插件跑不起来,跟 Token 报错是两个独立战场,这里单独理一下。
最常见的是插件树加载失败:
error: dsh: plugin tree failed to load: failed to apply loader entry include排查步骤我建议按顺序来:
- 先看
dsh-plugin.json里的 JSON 语法有没有问题,逗号多了少了、引号是不是英文半角,这些低级错误会直接导致解析失败。 - 再看
loaders.include里的路径能不能对得上实际文件。尤其是用了构建工具的项目,src/和dist/路径混写最容易出错。 - 确认入口文件
entry指向的 JS 是实际构建产物,而不是 TypeScript 源文件。DSH 的 loader 不负责编译 TS。 - 加日志排查:在入口文件最顶部写一行
console.log('[my-plugin] loaded'),如果启动时能看到这行日志,说明入口加载没问题,问题在后面的插件树挂载环节。
另一个常见问题是"面板不刷新"。这种情况一般不是渲染层的问题,而是数据源没触发。OpenCode 的事件流如果因为会话状态异常没有正常推送 delta 事件,速度计算器就永远拿不到新样本,面板自然不动。遇到这种情况,先看 OpenCode 侧有没有报 Token 错误,把上一节的速查表拿出来对一遍,通常问题就清楚了。
还有一次我遇到面板上速度数字一直显示 0,排查了半天发现是流式接口返回的 delta 是累计全文而不是增量片段。也就是说服务商每次推送的不是"新增的部分",而是"截至当前的完整文本"。这种情况不能用delta.content.length直接累加,要先缓存上一个快照,用newText.length - oldText.length计算增量。不同服务商的 SSE 实现有差异,写兼容层时要特别留意。
一点实操心得
整个插件从动手到跑通,前后花了两天时间,大部分时间不是耗在写代码,而是耗在事件流的调试和 DSH 加载规范的摸查上。我最大的体会是:做这类工具插件,先把数据管道打通,再谈界面美观。数据采集、计算、渲染三层如果从第一天就分开写,后面接 DSH 样式、改刷新频率、适配不同服务商都会非常顺;反之,所有逻辑糊在一起,后续每加一个功能都是灾难。
如果你也打算做类似的 OpenCode 插件,我建议先别急着画 UI,第一步把"能不能从流式事件里稳定拿到增量文本"验证清楚,第二步再写一个简单的终端文本行把速度打出来,最后再考虑接到 DSH 面板上。每层都能独立验证,排查问题时就不会眉毛胡子一把抓。速度单位的选用也值得提一句:不同人的习惯不一样,有的喜欢 tokens/s,有的喜欢字符数/s,我建议插件里做成可配置项,默认 tokens/s,这样既专业又通用。
最后分享一个小技巧:做流式 UI 时,把"采样时间戳"用performance.now()而不是Date.now(),因为Date.now()受系统时钟调整影响,可能在时间跳变时算出负速度或超大速度。performance.now()是相对启动时刻的高精度计时,在流式高频场景里稳得多。这个小坑,建议所有做实时速度显示的人都提前踩平。