dsh-token-bi
DeepSeek Harness 的 token 消耗看板 —— 把散落在各个会话里的用量汇总成一张表:每天烧了多少、哪个模型最贵、哪些工作目录在消耗、父子会话各占几成、每一轮具体花在哪。
DSH 自带单会话的 token 统计,但没有跨会话的视图。这个插件补上这一层,并对齐 Host 自己的计量口径,不会出现"插件显示的数字和会话里对不上"。
中文 | English

目录
它解决什么问题
用 DSH 一段时间后,几个问题会冒出来:
- 这个月一共花了多少 token?散在 20 多个会话里,没有一个地方能看总量。
- 哪天突然变多了?是哪次任务把用量拉高的?
- 缓存命中率怎么样?—— 这一项容易被忽略但影响最大:
cacheRead通常是总量的绝大部分,因为每轮都要把整个上下文重新读一遍。 - 让子代理并行干活时,父会话和子会话各占多少?
这个插件把会话日志里 provider 上报的用量折叠成上述维度。
安装
前置条件
- DSH Desktop 或
dshCLI(Web 界面) - Node.js ≥ 22.15(会话日志是 zstd 压缩的,需要
zlib的 zstd 支持)
从本地目录安装(当前可用方式)
git clone https://github.com/361533652/dsh-token-bi.git
dsh plugin --profile desktop add "link:/绝对路径/dsh-token-bi"
Windows 路径示例:
dsh plugin --profile desktop add "link:E:\gitbub-project\dsh-token-bi"
link: 表示软链安装,改动仓库里的代码无需重装。安装完成后完全退出并重启 DSH Desktop(不是刷新页面)——客户端 bundle 的路径表在进程启动时构建一次。
验证
重启后左侧栏面板列表里会出现「Token 账单」,位置紧挨着「插件」。
如果没出现,按顺序检查:
- 设置 → 插件 → 已安装插件 里是否有
Token 账单且开关为开 dsh --profile desktop --dump-config输出里是否有id: token-bi这一行- Host 日志(
%APPDATA%\DSH Desktop\logs\host\)里搜token-bi,看有无激活失败
插件在加载时会把 [token-bi] factory、[token-bi] apply、[token-bi] slot:xxx 写到浏览器控制台(用 console.error,因此会进 Host 日志)。这些标记能区分"代码没下载"、"下载了没激活"、"激活了没注册成功"。
界面说明
入口是左侧栏的全局面板(和「插件」「自动化任务」同级),不绑定某个会话——汇总数据天生属于全局层。
顶部数字
| 指标 | 含义 |
|---|---|
| 累计 token | 当前时间窗口内的四类 token 总和 |
| 今日消耗 | 本地时区下今天的总和 |
| 会话 | 窗口内的会话数,副标题是其中正在运行的个数 |
| 轮次 | 去重后的 turn 数 |
| 缓存命中占比 | cacheRead / 总量。这个数通常很高(80%+),说明上下文复用得多 |
图表
- 每日消耗 —— 堆叠柱状图,四段分别是输入、缓存读、缓存写、输出。悬停看当天明细。
- 最近 7 天节奏 —— 按小时聚合的折线,用来看"什么时候在烧 token"。
- 模型分布 —— 按
provider/model拆分,附带占比。 - 工作区分布 —— 按会话的
cwd分组,超过 8 个时折叠并显示被折叠部分的合计(避免让人误以为数据缺失)。 - 会话明细 —— 主表。点任意一行展开该会话的逐轮时间线(每轮一条三段式条形)。
- 调用树 —— 父会话下挂子会话,用深浅两种颜色区分"这个会话自己花的"和"它派生的子会话花的"。
时间窗口
右上角三档:7 天 / 30 天 / 全部。
切换会向 Host 重新取数并按日重算总量,不是只过滤已有数据——所以数字、图表、会话列表会一起变。
导出
右上角「导出 CSV」,导出当前窗口内的会话列表,带 UTF-8 BOM,Excel 直接打开不乱码。
数据从哪来、怎么算
数据源
两个来源,按会话 id 合并,活跃会话优先:
| 来源 | 读什么 | 作用 |
|---|---|---|
ctx.sessions |
内存中的会话事件 | 当前打开的会话,永远最新 |
ctx.sessionQuery |
冷启动的持久日志 | 历史会话,让没打开的会话也能统计 |
不需要读原始 .jsonl.zstd 文件——都走 DSH 的公开服务。
计量口径
这是本插件最需要讲清楚的部分。规则直接对齐 Host 的 tokenUsage 投影(@deepseek-ai/dsh-token-meter):
- 只看 provider 上报的用量,不估算。来源是
assistant/message事件的usage字段,或stream里最后一个type: 'usage'的 chunk。 - 同一
(turn, step)后到的样本替换先到的。一次重试会产生多个样本,这条规则保证重试的用量只计一次,不会重复累加。 - 四个桶分开记:
inputTokens/outputTokens/cacheReadTokens/cacheWriteTokens,总量是四者之和。
第 2 条是关键。如果不做替换,失败重试的会话数字会虚高。校验方式:拿一个会话跑一遍折叠,四个桶的数字应当和会话界面里显示的完全一致。
时间归属
按事件时间戳落到本地时区的日期。浏览器把 getTimezoneOffset() 传给 Host,Host 用它给每条记录分日——所以跨时区使用、或跨零点运行的长任务,日期归属都是对的。
缓存
- 单会话结果按
(会话 id, revision)缓存,revision 是活跃会话的 seq 或持久会话的元数据;没变就复用。 - 整个报告缓存 10 秒,
refresh=1强制重建。 - 缓存键包含时间窗口,切换档位不会串数据。
HTTP 接口
插件在 Host 上注册一个前缀路由,浏览器用同源请求读数据。
GET /api/dsh-token-bi/report?offset=<分钟>&days=<窗口>&refresh=1
GET /api/dsh-token-bi/session?id=<会话 id>&offset=<分钟>
| 参数 | 说明 |
|---|---|
offset |
浏览器时区偏移(分钟),默认取 Host 时区 |
days |
窗口天数;0 或 all 表示全部 |
refresh |
1 时跳过 10 秒缓存 |
report 返回:
{
"generatedAt": 1792500000000,
"offsetMinutes": 480,
"rangeDays": 30,
"cutoffDay": "2026-09-22", // 窗口起点;全部时为省略
"totals": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0,
"sessions": 0, "live": 0, "turns": 0, "steps": 0 },
"sessions": [ /* 每会话:id/title/cwd/parentSession/totals/models/days/... */ ],
"models": [ /* provider/model 汇总 */ ],
"days": [ /* 按日,含缺失日补零 */ ],
"hours": [ /* 最近 7 天按小时 */ ],
"cwds": [ /* 按工作目录 */ ],
"errors": [ /* 读失败的会话,不阻断整体 */ ]
}
session 返回单个会话的逐轮 rows(turn/step/time/input/output/cacheRead/cacheWrite/total),供展开行按需拉取——所以首屏不会因为历史很长而变慢。
这个路由沿用 DSH 插件惯用的
webServer前缀注册。注意:路由在进程级只注册一次,重载时替换 handler 而非重复注册——重复注册会让webServer抛错并使整个插件激活失败(这是开发过程中踩过的真实坑,见test/reload.mjs)。
配置
没有配置项。 所有参数(时间窗口、筛选)都在界面上切换,不需要改 profile 配置。
插件在 profile 里只有一行:
- insert:
- id: token-bi
name: dsh-token-bi
设计取舍
几个刻意的决定,写下来免得后来的人以为可以随手改:
汇总放在全局面板,不放在会话里。 跨会话的数据放进单会话的界面是位置错误,用户会以为那是"这个会话的统计"。
只读日志,不写任何东西。 插件不追加会话事件、不落自己的数据库。DSH 的会话日志是唯一事实来源,任何派生数据都应该是可重建的缓存。
对齐 Host 口径而非自创公式。 用户会拿插件数字和会话界面比对;两边不一致比数字不准更糟。
不做成本估算。 不同 provider、不同模型、不同缓存策略的单价差异极大,硬编码一张价格表会给出误导性的确定性。想知道钱花了多少,看 provider 的账单。
没有设置页。 没有任何可配置项,加一个设置页会多出一个什么都不做的入口。
已知限制
- 只统计上报了用量的步。 如果某个 provider 不返回 usage,那一步对统计不可见。这不是漏算,是无从算起。
- 压缩掉的历史仍然计入。 统计是"日志口径"而非"当前可见上下文口径"——被压缩的消息仍然算在会话总量里,这符合"这轮对话花了多少"的直觉,但和"当前上下文多大"是两件事。
- 子会话独立计费、独立统计。 父会话的数字不包含子会话;调用树里两者分层显示,叠加才是整棵子树的花费。
- 极长的会话会截断逐轮列表。 展开行最多显示最近 60 轮,避免长会话拖慢渲染;总量不受影响。
- 数据只覆盖本机。 读的是本机 DSH 的会话存储;多机部署需要分别查看。
- 需要 Web 界面。 依赖
webServer服务,纯 TUI profile 下不会激活。
开发
目录结构
index.js Host 半:注册 HTTP 路由
lib/collector.mjs 聚合:合并活跃/持久会话,产出报告
lib/usage.mjs 口径:把一份会话日志折叠成用量记录(纯函数,无 IO)
lib/client.js 浏览器半:面板注册 + 页面与图表
cordis.patch.yml bundle patch:插入 Host 行
locale/{zh,en}.json 插件元信息(标题、描述、图标)
test/ 离线测试,见下
lib/usage.mjs 是纯函数模块,不碰文件系统和 Cordis,可以直接拿任意事件数组调用——口径逻辑都在这里,改动应当先补测试。
测试
npm test # locale 一致性、重载竞态、槽位注册、页面渲染
npm run test:store # 用本机会话存储跑一遍聚合(只读)
npm run test:route # 用桩 context 驱动 Host 路由
| 测试 | 覆盖什么 | 需要什么 |
|---|---|---|
locale-check.cjs |
中英文字典键集一致、t() 用到的键都有定义 |
无 |
reload.mjs |
热重载时不重复注册路由(回归:曾经导致插件消失) | 无 |
slots.mjs |
对真实 SlotCore 跑注册,且不再注册设置页 |
DSH_APP_DIR |
render.mjs |
用最小 hooks 运行时把页面真渲染一遍,含展开行交互 | 本机会话存储 |
harness.mjs |
聚合结果的人眼核对输出 | 本机会话存储 |
route.mjs |
路由的 200/400/404 与 JSON 契约 | 本机会话存储 |
测试默认读 $DSH_HOME/sessions(默认 ~/.dsh/sessions),可用 DSH_SESSIONS 或命令行参数指向别处;没有历史数据时会建空报告而不是报错。
slots.mjs 需要 DSH 自带的 @deepseek-ai/dsh-client-ui-slots:
DSH_APP_DIR="E:\dsh\DSH Desktop\resources\app" npm test
找不到时该测试会打印 SKIP 并以 0 退出,不会假装通过。
改动时的注意事项
lib/client.js不能requireDSH 的客户端包(如ui-primitives)。样式和控件要么自己写,要么照抄 Host 的实现后改成自己的前缀类名,只引用--dsw-alias-*主题变量。- 样式必须走主题 token,否则深色模式下会瞎。
dsh.client.inject填的是包名(如@deepseek-ai/dsh-client-ui-renderer),不是运行时服务名(slots)——写错会导致模块一直停在 loading 并把整个页面拖成 "Failed to load plugins"。- 客户端入口放在
lib/client.js,与生态里其他插件一致。
No comments yet. Be the first to write one.