dsh-token-dashboard
English | 中文
搞清楚 token 到底花在哪了。这个插件从挂载那一刻起记录 DeepSeek Harness 里的每一次模型调用,
按天、模型、用途、项目聚合用量,并把看板放在一次点击之外 —— 就在侧栏底部、你的账号行正上方。
侧栏底部 看板面板
┌──────────────────────┐ ┌──────────────────────────────────────┐
│ 📊 Token 用量 12.3K │ ───▶ │ 3 张主指标 + 6 项次指标 │
├──────────────────────┤ │ 每日堆叠柱状图 │
│ G Greye │ │ 用量日历(近一年每天一格) │
└──────────────────────┘ │ 按模型;按项目 | 按用途并排 │
│ 最近调用明细 │
└──────────────────────────────────────┘
布局按它拿到的那块面板来排,而不是按窗口:KPI 两行、两列表格行、长项目路径
的截断都以面板宽度为断点(容器查询),所以窗口很宽但主面板很窄时同样会重排。
图表自己测量宽度并据此绘制 viewBox,标签不会再被面板宽度拉伸或压扁。
> 第三方、非侵入式插件。 它从你的 DSH profile 挂载,绝不改动 app.asar,
> 也不会动任何随应用发布的组件。
- 安装 —— 一条命令,或在插件页操作
- 它记什么 —— 以及背后的口径选择
- 怎么读这些数字
- 数据文件
- 配置
- 已知限制 —— 提 issue 前请先看
- 开发
安装
本插件是第三方 bundle:从你的 DSH profile 加载,而不是从 app.asar。
安装过程不动应用本身的任何文件,所以卸载是干净的,应用升级也不会覆盖它。
方式 A —— 一条命令(CLI)
dsh plugin --profile desktop add github:guanhaisen/dsh-token-dashboard
先把 DeepSeek Harness 完全退出。 CLI 拒绝改动正在运行的应用所持有的 profile;
方式 B 和 C 则可以在运行中操作。
方式 B —— 插件页(不用终端)
- 打开侧栏的 插件 入口。
- 选择安装,粘贴同样的 spec:
github:guanhaisen/dsh-token-dashboard。 - 看 pnpm 的输出,然后确认。
方式 C —— 让 agent 装
直接让模型把 github:guanhaisen/dsh-token-dashboard 装到 desktop profile,
它会用 plugin_manager 工具。每次工具调用需要 danger-full-access 或一次批准。
锁定版本(打 tag 后推荐)
上面的 spec 跟随默认分支。想固定在某个发布版:
dsh plugin --profile desktop add 'github:guanhaisen/dsh-token-dashboard#v0.1.0'
安装器替你做的事
以下都不需要你手写:
- 把依赖加进
/profiles/desktop/package.json; - 自动把 bundle 追加到
dsh.profile.bundles—— 一个声明了dsh.bundle的新装包
会被对账进这个列表,而它才是真正让它挂载的东西; - 调用自带的 pnpm(不需要你另外装);
- 因为 Desktop 组合启用了 HMR,改动实时生效,不用重启。
之后刷新页面。客户端 bundle 的 revision 由 client.js 的 mtime 和大小派生,刷新即拿到新版本。
如果入口没出现,说明 bundle 没被选中:把 "dsh-token-dashboard" 加进
/profiles/desktop/package.json 的 dsh.profile.bundles,然后重启。
确认它在跑
让 agent 执行 plugin_manager { action: "list_plugins" }。应当看到
dsh-token-dashboard 条目 enabled: true、fiberPhase: "active"。
它记什么
用量来自 llm/stream —— 每一次 provider 调用都要经过的唯一漏斗
(LlmRuntime.streamWithRegistration)。主对话、子代理、压缩、会话标题生成
全都走过它,所以统计是完整的,不是抽样。
每次调用贡献这些字段:
| 字段 | 含义 |
|---|---|
inputTokens / outputTokens |
provider 上报的原值 |
cacheReadTokens / cacheWriteTokens |
提示缓存命中与写入,单列 |
reasoningTokens |
推理 token(provider 上报时才有) |
provider / model |
实际服务这次调用的路由 |
purpose |
chat、compaction 或 session-title |
sessionId / cwd |
会话与项目目录 |
durationMs / ttftMs |
总耗时与首字延迟 |
ok / error |
这次调用成功与否 |
聚合维度:按天(默认以 UTC+8 为界)、按模型、按用途、按项目,
外加一份最新在前的最近调用明细。
口径选择,摆明说
- 只向前记录,不补历史。 插件挂载之前的调用不会被导入。看板上写「自安装以来」
就是这个意思。想要历史,那是一个独立的一次性导入器,不是本插件。 - 不读取
~/.dsh/channel-pack/token-ledger.json。 那是dsh-our-free-model
的文件:只覆盖它包装过的 adapter,且按 90 天滚动裁剪。两个账本互不相干。 - 每个字段都按 provider 上报原样记录。 缓存读取与缓存写入都留在各自的计数器里,
不折进inputTokens;不做归一化,也不做任何猜测。看板上那两个总量是对上报数字做的
算术,不是对它们的解释。图表的堆叠用的就是这四个计数器,所以柱子和它上面的卡片
不可能对不上。 - 不做价格换算。 插件拿不到可靠的按路由单价,所以看板只讲 token,不讲钱。
用猜来的单价算出成本,比不给数字更糟。
怎么读这些数字
页面自上而下分四段:读数、每日图表、年度日历、排行表格。整页是一个列容器而不是一套
视口断点,所以窗口很宽但主面板很窄时同样会重排 —— 它按拿到的面板宽度布局。
读数区
三块着色的主读数,下面压一条六格的紧凑读数带。第一块是主卡:总量本身,下面带一条
构成条和四段各自的占比。那条构成条就是上方图表的拆分 —— 同样的四段、同一套色阶 ——
所以主数字本身就说明了它由什么组成,不需要再画第三张图。
看板给两个总量,因为一个数字回答不了两个问题:
- 总 token =
inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens。
provider 实际处理过的每一个 token。缓存读取属于这里:它们是真实占用上下文窗口、
真实计费的 token,只是单价更低。在热缓存的工作负载下它们占绝对多数 —— 实测一次
会话里缓存占 99.6%,漏掉它们会把当天用量少算 257 倍。 - 非缓存 token =
inputTokens + outputTokens。没吃到提示缓存的那部分工作:
未命中缓存的输入加上输出。
> 这不是计费数字,而且刻意不按计费命名。 缓存读取同样计费 —— 通常约为未缓存
> 输入单价的十分之一,但依然计费。把 input + output 叫作「计费 token」(本插件
> 早期版本就是这么写的)等于告诉读者缓存是免费的,这是错的。本插件不做价格换算,
> 所以它报告的是构成,从来不是成本。
注意 provider 的 inputTokens 指的是未命中缓存的输入:DSH 内部对同一个量用的
名字就是 uncachedInputTokens。这也是为什么在热缓存会话里,inputTokens 单独看会
比缓存那个数小得多。
主卡上还有一枚环比标签,只在「近 7 天」「近 30 天」出现。它由日历序列算出 ——
那是唯一一份不论区间、都保存了每个有记录日的数据 —— 所以做比较不需要第二次往返。
涨不涂成坏事、跌不涂成好事:看的是消耗,不是指标,所以颜色保持中性,只让箭头表示
方向。上一个周期完全没记录时不给百分比(除以它会得到 Infinity%),而是写「新增」。
每日图表
柱状图分四段堆叠 —— 缓存读取(最底)、缓存写入、输入、输出 —— 所以柱子的高度和
卡片里的总量是同一个数。缓存刻意画在最底下:它占 99% 时,画在上面会把输入和输出
挤出绘图区。图例是一排小胶囊,各自带上本区间的合计,所以四部分不用悬停就能读到。
纵轴按整数刻度缩放,而不是直接切分原始峰值:步长向上取到 1 / 2 / 2.5 / 5 / 10
这类整数,于是 143M 的峰值画出的是 0 / 50M / 100M / 150M / 200M,而不是
35.8M / 71.6M / 107.5M / 143.3M。最高的柱子因此停在顶部网格线下方一点,而它顶着的
那条网格线就是它所对照的数 —— 柱子是按取整后的轴顶缩放、不是按峰值,所以柱子和标签
不可能对不上。
Host 认定的「今天」那一列始终带一层淡淡的底色,即使什么都没悬停:还在进行中的一天,
它的柱子是本区间里最矮的,没有这个标记,最后一列看起来就像塌了。
把鼠标停在柱子上,会显示当天的构成。 提示里列出四段的精确值(不是卡片上那种
K/M 缩写)以及该列合计,不用切到表格就能读数。悬停目标是整列而不是柱子本身 ——
在以缓存为主的日子里,用量很少的那天会细成发丝;被悬停的列会加底色,所以零高度的
那天也能看出选中了哪一天。按周合桶的列会写明(· 7 天合计),不会把一周的数当成
一天的。提示与图例取自同一处声明,所以行和它的色块不可能对某个段的颜色各说各话。
用量日历
柱状图下面是日历热力图:一天一格,一列一周,周一在上、周日在下,颜色深浅代表
当天总量。它回答的是另一个问题 —— 「我什么时候忙」,而不是「我选的区间里用了多少」,
所以它刻意不受区间选择影响,始终显示截至今天的近一年。卡片副标题写明了这一点,
图例旁边还写着近一年里实际有记录的天数。如果让它跟着区间走,「今日」下就只剩一个
方块,「全部」下又变成一整年,这也是它被固定住的原因。
- 色阶取的是这个窗口自己的四分位,不是固定的 token 阈值。固定阈值会把轻量用户
的每一天都涂成最浅、把重度用户的每一天都涂成最深,那样就完全看不出哪几天更重。
零 token 的天不参与分位、保留自己的色阶(空格子),否则空闲的一天会把下界拉到 0,
把所有真正有记录的天挤进最深的颜色。 - 平的一周读作「都正常」,而不是「都拉满」。 当窗口内每个有记录的天总量都一样时,
分位会坍缩;此时它们落在中间色阶,而不是全部涂满。 - 格子的值就是柱状图画的那个总量(含缓存读取),所以两张图不可能对「那天用了
多少 token」各说各话。 - 窗口里每一天都画出来,有记录没记录都一样;空白的意义就在于此,跳过没记录的天
会把一整年压扁,也会让空白处失真。 - 悬停某一格显示完整日期、星期、精确合计与请求数。被悬停的格子保留自己的颜色,
只额外描一圈边 —— 正在被解释的那个深浅不会被高亮盖掉。 - 窗口结束于 Host 认定的「今天」,用的是与账本一致的、可配置的日界偏移,而不是 UTC:
按 UTC 收尾会在 UTC+8 每天的头 8 小时里丢掉今天的记录。
网格按面板宽度自适应:有空间时格子固定 11–13px,先压缩间距,所以窄主面板里看到的
仍然是一整年的方块,而不是糊成一片发丝。
网格旁边是一栏四项读数,由同一份序列算出,所以它和被它注解的格子不可能对不上:
近一年合计、有记录日均、最高单日、以及当前的连续记录天数。放它们的理由很直接 ——
一年里只有五天有记录时,光看一片几乎全空的方格等于什么都没说。窄面板上这一栏落到
网格下方,窗口里没有记录时整块不出现。
其余数字:
- 缓存命中率 =
cacheReadTokens / (inputTokens + cacheReadTokens)。分母为 0 时
显示—而不是0%:「什么都没发」和「什么都没命中」是两件事。 - 平均首字延迟 只对真正产出过首 token 的调用取均值。一个在吐字前就失败的调用
贡献 0 个样本,而不是贡献一个 0 ms。 - 没有上报用量的调用仍计为一次请求(失败则计入错误),但不贡献 token ——
这样一条失败的路由不会悄悄把你的均值往 0 拉。 - 「最近调用」只列出你选中的日期区间。 区间外的记录一律不显示,所以这张表永远
不会和上面卡片、图表对不上;区间内没有记录时它显示为空,而不是把上次碰巧发生的
调用留在那里。 - 区间长到每天一根柱会细成发丝时,图表自动按周合桶,桶的标签是它的第一天。图表只画
有记录的日期,所以横轴是「有记录的日期序列」,不是严格线性的日历。
排行表格
三张表,都按总 token 降序,总量那一列底下有一条按比例的条,所以排序不用心算也看得出:
- 按模型以模型名打头,把提供方放在下面一行。原先每行都单占一列重复提供方,
为一个几乎不变的值花掉一整列。 - 按项目以文件夹名打头,把上层目录放在下面一行。这一列里每条路径的前三十个字符
都一样,所以单行截断恰好把唯一不同的部分藏掉了;完整路径仍留在单元格的title里。 - 按用途翻译三个用途(
chat、compaction、session-title),而不是直接打原始 key。 - 最近调用按时间倒序列出所选区间的调用,状态是一个小胶囊。它的「总量」列与卡片
口径一致、含缓存读取 —— 只加输入和输出,会在「总量」这一列印出一个比它所属那天小
两个数量级的数。
只有调用日志在自己的框里滚动,较短的排行表跟随页面滚动:一个 330px 的滚动框塞进
220px 高的卡片里,滚动条存在但永远够不到。
数据文件
/token-dashboard/ledger.json
` 通常是~/.dsh。可用环境变量DSH_TOKEN_DASHBOARD_STATE_DIR` 覆盖目录。
- 原子写入(
ledger.json.tmp→rename),2 秒防抖,插件卸载时同步落盘 ——
所以退出前最后几次调用不会丢在防抖窗口里。 - 写盘失败会重试,不会被遗忘。 如果写入抛错(磁盘只读、卷满),待写增量仍保持
脏标记,卸载时的落盘会再试一次;这些记录留在内存里,不会被丢弃。 - 多进程安全。 每次落盘都先重读当前文件,只并入本进程自上次写入以来的增量。
因此两个 profile(比如desktop和headless)写同一份账本是相加而不是互相覆盖。 - 损坏可恢复。 截断、非法 JSON、schema 不符或形状不对的文件,一律按空账本处理
并记一条 warn。插件照常工作,看板显示空态。 - 日历在传输上每天只占两个字段。 它在 Host 侧由整本账本生成(不依赖选中区间),
所以响应里多出一条calendar序列:{ day, totalTokens, requests },每天约 60 字节,
730 天保留期全满时约 45 KB。卡片只读这两个数,若改成把一整年的完整派生结果发过去,
体积会大二十倍左右,而且没有一处会用到。 - 想清空数据,直接删掉这个文件。 刻意不提供清空按钮。
配置
改 cordis.patch.yml 里这一行(或在你 profile 的 cordis.patch.yml 里覆盖):
| 字段 | 默认 | 含义 |
|---|---|---|
retentionDays |
730 |
日聚合保留天数,超期整条丢弃 |
recentLimit |
300 |
明细记录上限(最新在前) |
utcOffsetMinutes |
480 |
固定日界偏移;480 = UTC+8 |
trackProjects |
true |
是否记录 cwd 维度 |
flushDebounceMs |
2000 |
落盘防抖窗口 |
用不了的值会被拒绝,而不是被钳制。 每个数值字段必须是有限数值且落在合理区间内
(retentionDays 1–36500、recentLimit 0–10000、utcOffsetMinutes ±720/840、
flushDebounceMs 0–60000),trackProjects 必须是真正的布尔值;其余情况一律回退到
默认值,并打一条点名该字段的 warn。这件事比听上去更重要:retentionDays 写成 null
或手滑写成 "730" 时,旧版本会把它折算成 0,于是保留期截止点变成明天,下一次
落盘就会删掉所有已记录的日期。配置笔误不能有毁掉你历史的能力,所以用不了的值会被
忽略,而不是被重新解释。
日界偏移还会决定页面上写的话:看板脚注显示的是实际生效的偏移
(UTC+8、UTC±0、UTC+5:30 …),而不是假定默认值。
已知限制
- 只向前记录。 见上面的口径选择。
- 日界是固定偏移,不是你的系统时区。 默认 UTC+8 且可配置,但它不会跟着笔记本
跨时区走。这是刻意的:会移动的日界会悄悄拆开或合并已经记下的日子。 - 没有成本、预算、告警。 只有 token。
- 用量就是 provider 上报的那个数。 不上报用量的 provider 只会产生零 token 的请求;
看板把它们记为请求,不会编造数字。 - 被重试取代的那次尝试各算一次请求,且不计为错误。
llm/stream每发起一次
provider 尝试就进入一次,而 DSH 会把失败的尝试在同一个 step 内重试成一次全新尝试。
因此一次结束时未上报用量的尝试,会贡献 1 个requests、0 个 token,并在「最近调用」
里显示为一条零 token 且标为ok的行。对照会话日志实测,它的指纹非常明确:
ttftMs === durationMs(唯一的 chunk 在流结束时才到达,于是首字样本等于总耗时)、
usageReported: false、跨度内同一会话存在一个llm/retry事件,且为传输层失败。
结果是:requests统计的是尝试次数,会跟随但略高于计费调用数;errors也不
反映重试。token 总量不受影响 —— 被取代的尝试不上报用量,所以不贡献任何 token。
实测的一天里,插件总量与日志推导值相差在 0.06% 以内,295 次调用中 294 次逐字段一致。 - 开发和测试都是在 Desktop profile 上做的。 只要组合里提供了
llm和一个
connection载体,理论上都能跑,但只有 Desktop 组合被实际验证过。
开发
零依赖、零构建 —— index.js 和 client.js 就是源码直接发布。这是刻意的约束:
需要工具链才能审计的插件,大多数用户根本不会去审计。
index.js Host 半身:llm/stream 录制器、账本存储、RPC 路由
client.js Client 半身:侧栏底部入口与看板面板
cordis.patch.yml 把 Host 半身挂进 Loader 树
test/ 零依赖测试
dev/ 本地预览工具(不随包发布,见 dev/preview/README.md)
npm test # 或:node test/run-all.cjs
7 个文件,317 个断言:
| 文件 | 覆盖 |
|---|---|
ledger-check.cjs |
UTC+8 日界(午夜前后各一毫秒)、bucket 累加与钳位、派生量、增量合并、保留期、容错解析、payload 装配、「最近调用」与选中区间一致、日历序列在任何区间下都列出每一天,且总量与卡片一致、用不了的配置值 |
store-check.cjs |
防抖落盘、原子写、重启后仍能读回整天的记录、重复 read 不累加、两个 store 交错落盘不丢计数、保留期裁剪、明细去重与上限、写盘失败与 logger 抛错的兜底、写盘失败后在卸载时重试而非丢弃增量 |
recorder-check.cjs |
块原样透传(同一对象引用)、惰性、一次调用一条记录、错误原样 rethrow、消费者提前中止、sink 抛错不污染模型通道 |
route-check.cjs |
405 / 415 / 400、信封校验、rpcId 回显、未知方法、store 抛错不外泄、四种区间、空账本 |
client-check.cjs |
在桩 facade 下加载 bundle、slot 注册与顺序、无连接降级、数据源状态机、切换区间时后发请求取代进行中的请求、中止不产生错误态、格式化、纵轴步长取整且每个刻度精度一致、图表分桶、柱子与卡片在缓存写入上口径一致、按面板宽度的容器查询且无视口断点残留、提示的半宽是提示的真实上界、网格线不吞掉指针、日历窗口是整整一年、结束于 Host 的今天而非 UTC 的今天、补齐到整周、分位色阶忽略空白天、平坦窗口落在中间色阶、五档深浅是一套梯度,网格与图例共用、构成条也共用同一套梯度、没有失效的样式规则、payload 归一化、zh/en 键集一致且日历会索引到的每个月/星期标签都在 |
integration-check.cjs |
真实 apply() 端到端:一次模型调用 → 落账 → 从路由读回;cwd 与 purpose 维度;失败调用计为 error;卸载时落盘、二次挂载续算;一次真实调用在四种区间下都进入日历序列;已有账本在错误保留期配置下存活;被中止的请求 |
render-check.cjs |
两个组件每一棵分支真实渲染:宽/收起两态、加载/错误/无连接/空态、一张主卡 + 两张主读数 + 六格读数带,且每张卡都有副标题行、主卡的构成条带齐四段并给出各自占比、图表 viewBox 等于渲染宽度且没有 preserveAspectRatio 拉伸、纵轴刻度为整数、图表几何与零高度柱、柱子按取整后的轴顶缩放、有缓存写入时柱子达到卡片总量、日历卡位于柱状图与表格之间,按天而不是按有记录的天数画格,首列从周一起且七行星期都在,保留窗口说明,且在「今日」区间下仍是整整一年、日历悬停走真实组件状态:未悬停不出提示、显示当天的日期/星期/精确合计/请求数、描边只有一处且会移动而不是累加、描边叠在原有深浅之上、提示位于定位盒内、没有 calendar 序列的 payload 仍能渲染、按项目与按用途两张卡同处一行、项目路径以文件夹名打头且完整路径保留在 title、零值段在图例中变淡但不消失、图表悬停走真实组件状态:每列一个热区、未悬停不出提示、显示当天的分段值与合计、提示随指针在列间移动并在移出时消失、底色恰标记一列、按周合桶的列标注为多天、零 token 的那天也能解释、提示在两端向内夹取且对准列中心、提示位于定位过的图表盒内、未知命中率画成破折号而不是 0.0%、完全没有 totals 的 payload、缺 meta/totals 不崩、可访问名称、脚注时区 |
所有测试接缝都以 __internal 从 Host 半身导出并直接驱动。
测试框架本身会 await 每条测试:异步测试体返回的 promise 会被跟踪,report 在退出前
等它 settle。这一点是承重的 —— 早先版本在异步测试 promise 一创建时就记了「ok」,于是
第一个 await 之后的断言在 process.exit 触发时还排在队列里、从未真正执行。套件的
异步半边此前是在空跑。
License
MIT