
最近一直在用 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 和模型服务之间走的通常是 SSEServer-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 }滑动窗口解决了单点采样抖动的问题但窗口期内的速度在边界切换时还是会跳变。为了再平滑一点我叠加了一个 EMAlet 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累计输出本轮输出总 Token1,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 nameopencode-token-speed row labelModel/label value${snapshot.model}/value /row row labelSpeed/label value color${color}${snapshot.speed.toFixed(1)} tok/s/value /row row labelPeak/label value${snapshot.peak.toFixed(1)} tok/s/value /row row labelOutput/label value${snapshot.totalTokens} tok/value /row row labelContext/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 tokenlogin 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()是相对启动时刻的高精度计时在流式高频场景里稳得多。这个小坑建议所有做实时速度显示的人都提前踩平。