dsh-token-monitor · 模型 Token 用量监测
DSH(DeepSeek Harness)Web 插件。在左侧边栏底部、设置按钮正上方放一个小窗口,显示当前模型的当日 token 总量和每小时直方图(输入命中缓存 / 输入未命中缓存 / 输出),右侧一个【日志】按钮打开完整的用量报告弹窗。
数据全部来自本机会话日志($DSH_HOME/sessions/**/session.v3.jsonl.zstd),不额外埋点、不改动 DSH 源码、不依赖任何第三方包。
一、装上之后长什么样
浮窗(侧边栏会话列表下方、页脚上方)
挂载点是 shell.overlay(ui-layout 声明的全框浮层,可叠加、默认点击穿透)。
┌─────────────────────────────┐
│ deepseek-flash 日志 — │ ← 头部可拖动;右上角【日志】【最小化】
│ 2亿 今日总量 · 1.3k 次请求 │
│ ¥14.35 今日花费 ¥35.42 余额 │
│ ▓▓░░▓▓▓░░░░▓▓▓░░░░░░░░░░░░ │ ← 峰谷时段带(深=高峰 / 浅=空闲)
│ ▁▃█▅▂ ▁▁▁▁ ▁▁▁▁ ▁▁▁▁▁▁▁▁▁▁ │ ← 24 小时堆叠直方图
│ 00:00 12:00 23:00 │
│ ● 输入(命中缓存) ● 未命中 ● 输出 │
└─────────────────────────────┘
[ ⚙ Cordis Plugin ... ]
[ ⚙ Settings ]
- 右上角【—】最小化 → 收成一个小胶囊(总量 + 花费),点胶囊还原;状态记在 localStorage
- 拖动头部可移动位置,双击头部回到默认锚点;位置也记在 localStorage
- 位置不是写死的:实时量侧边栏与页脚容器的矩形(
ResizeObserver + resize),
所以侧栏宽度变化、收起成窄栏、页脚条目增减都会自动跟随
为什么不用 sidebar.footer.action:那个槽位只能表达"页脚内的一行",
而需求是「工作区 → 会话列表 → 本插件 → 页脚」的次序,落在列表与页脚之间——
只有浮层能做到,且不会被其它插件(如 Cordis Plugin 区)挤占。
小窗口(旧形态,已改为浮窗)
┌────────────────────────────────────────┐
│ deepseek-official/deepseek-flash 日志 │ ← 模型名 + 【日志】按钮
│ 1.1亿 今日总量 · 858 次请求 │ ← 当日总量
│ ▁▃█▅▂▁▁▁ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ │ ← 24 小时堆叠直方图
│ 00:00 12:00 23:00 │
│ ● 输入(命中缓存) 1.1亿 │ ← 图例(跟随悬浮小时)
│ ● 输入(未命中缓存) 908k │
│ ● 输出 605k │
│ 悬停柱子看该小时明细 │
└────────────────────────────────────────┘
[ ⚙ 设置 ] ← 本插件在它上方
- 鼠标悬停任意柱子 → 图例与提示行切换成那一小时的明细。
- 每 30 秒自动刷新一次,窗口重新聚焦时立刻补一次。
日志弹窗(点【日志】)
- 区间:今日 / 近一周 / 近一月 / 自定义(起止日期选择器,按上海日界,最多 400 天)
- 视图:按对话 / 按模型(可翻页)
- 总量卡片:总用量、输入(命中缓存)、输入(未命中缓存)、输出
- 时间分布:区间 ≤2 天按小时、更长按天,堆叠柱图 + 悬浮读数
- 模型分布:每个模型的用量占比条(同样按三桶堆叠)
- 明细表:按对话(标题 / 工作区 / 最后活动时间 / 涉及模型)或按模型
- 健康度:缓存命中率、活跃小时、活跃小时均值、日均、峰值小时、最费对话、覆盖会话数
- 页脚:写明统计口径与采样时刻;若有个别日志读取异常,在这里如实列出(不静默吞掉)
二、统计口径(重要,决定你看到的数字是什么意思)
每一条用量都来自会话日志里 assistant/message 事件的 data.usage,即 provider 自己回报的用量:
| 界面上的桶 | 日志字段 | 说明 |
|---|
| 输入(命中缓存) | cacheReadTokens | 命中 prefix cache 的输入 |
| 输入(未命中缓存) | inputTokens + cacheWriteTokens | DeepSeek 侧 inputTokens 不含缓存读取;cacheWriteTokens 恒为 0,存在时并入此桶 |
| 输出 | outputTokens | 已包含 reasoning tokens,不重复计算 |
- 模型归属:按事件顺序取「最近一次
request/context」作为该次请求生效的路由(provider/model)。DSH 只在路由/上下文变化时才追加 request/context,所以用游标取最近值是正确的归因方式;连 request/context 都没有的极端情况会记为 unknown 而不是丢弃。
- 时区:固定 Asia/Shanghai(UTC+8),日界 = 本地 00:00。近一周 = 含今日共 7 天,近一月 = 含今日共 30 天。
- 会话标题:优先取 DSH 投影缓存(
storages/session_projcache)里的标题(即侧边栏显示的那个),取不到则退回日志里的 session/title,再退回首条用户消息(截断 80 字)。
- 不含费用估算:按你的要求,只报 token,不做任何价格折算。
三、安装 / 卸载
# 从 GitHub 直接装(推荐)
cd /opt/deepseek-harness && DSH_HOME=~/.dsh pnpm dsh plugin --profile web add github:hipigod/dsh-token-monitor
# 或者先克隆到本地再以本地路径装(改代码即时生效,不用重装)
git clone https://github.com/hipigod/dsh-token-monitor.git /root/apps/dsh-plugin-token-monitor
cd /opt/deepseek-harness && DSH_HOME=~/.dsh pnpm dsh plugin --profile web add /root/apps/dsh-plugin-token-monitor
# 生效(宿主路由与客户端 bundle 都需要重启进程)
systemd-run --on-active=2 --unit="dsh-restart-$(date +%s)" systemctl restart dsh-web.service
# 然后用浏览器刷新 http://127.0.0.1:3080
⚠️ 不要在当前会话里直接 systemctl restart dsh-web.service:agent 自己是这个服务的子进程,直接重启会把当前对话打断(已实测踩过两次)。用上面的 systemd-run --on-active 定时重启,命令会先返回,再由 systemd 去重启。
# 卸载
cd /opt/deepseek-harness && DSH_HOME=/root/.dsh pnpm dsh plugin --profile web remove @local/dsh-token-monitor
回滚
# 好版本快照(每次 preflight 通过后建议复制一份)
ls -t /root/archive/dsh-token-monitor-backups/
cp -a /root/archive/dsh-token-monitor-backups/client.js.good-<时间> /root/apps/dsh-plugin-token-monitor/client.js
# 或整体回滚 profile
cp -a /root/.dsh/archive/profile-web-backup-20260915-163321/. /root/.dsh/profiles/web/
systemd-run --on-active=2 --unit="dsh-rollback-$(date +%s)" systemctl restart dsh-web.service
改完源码后:宿主侧改动(index.js)要重启;客户端改动(client.js)只需在浏览器刷新页面(reg 里的 bundle 是实时从磁盘读的,实测 curl 已能取到新内容)。
四、自检(不用看界面就能判断好坏)
插件自带一个诊断端点:
curl -s http://127.0.0.1:3080/plugin-api/token-monitor/doctor | jq
它会如实报告三件事:
host.sessionsRoot —— 宿主实际读的日志目录;
client.available / client.rows —— 浏览器模块表里登记了哪些插件(这是「界面上会不会出现」的唯一权威依据,宿主路由 200 不代表客户端会被加载);
client.ours —— 本插件那一行,含 bundle 绝对路径;null 就说明 dsh.client 声明或 ./client 导出有问题。
另外两个只读路由:
GET /plugin-api/token-monitor/overview 今日总览(小窗口数据)
GET /plugin-api/token-monitor/log?period=…&view=… 日志报告
period = today | week | month | custom | all
start / end = YYYY-MM-DD(period=custom 时生效)
view = total | conversation
口径一致性是硬约束:totals、models 求和、buckets 求和、conversations 求和在任一次响应里必须相等(三者都来自同一批已过滤记录)。对不上就是 bug。
五、已修缺陷(都是真浏览器/真数据抓到的,不是"看起来对")
| # | 症状 | 根因 | 修法 |
|---|
| 1 | 整个 Web 应用不挂载:页面只剩 Failed to load plugins,侧栏/会话全没了;正在对话的 agent 也被连带打断(2026-09-15 16:33–17:06 的真实事故) | client.js 里 __ModuleLoader__.load({ id }) 写的是 dsh-token-monitor,而模块表行 id 是包名 @local/dsh-token-monitor。client-modules 的 arrive() 以行 id 为键等待 bundle 自注册,不匹配即抛 bundle … loaded without registering "<id>" via __ModuleLoader__.load | 两处统一为包名;加回归断言(tests/client.test.mjs)+ 真浏览器 boot 审计(tests/e2e-browser.mjs)+ 放行闸门 preflight.sh |
| 2 | 弹窗内容被裁成 380px 宽、右侧全部看不见 | ui-primitives 的 Modal 卡片写死 width: min(380px, 100%) + overflow: hidden。只改内层 contentClassName 无效——外层卡片才是裁剪者 | 改用 headless: true 自绘标题栏,并通过 Modal 的 className(唯一透传到卡片的入口)覆盖宽度 |
| 3 | 侧栏模型名压到【日志】按钮上(文字重叠) | flex 子项默认不收缩到可用宽度,只写 text-overflow: ellipsis 不会生效 | 给标题显式 max-width: 96px + min-width: 0;模型名只显示末段,完整 provider/model 放进 title |
| 4 | 内层盒子比卡片宽 40px(贴边/图例被裁) | 内层宽度算式与卡片内边距不匹配 | 内层改 box-sizing: border-box + width: 100%,图例 flex-wrap + 右对齐 |
| 5 | 用户截图里弹窗下边内容被裁(记录deepseek… 那行看不见) | 高度按 84vh 算,而笔记本 + 浏览器工具栏后可用高度只有 ~550px,内容超出卡片被 overflow: hidden 吃掉 | 改成 flex 链:卡片 max-height: calc(100vh - 48px),内层 flex: 1 1 auto + min-height: 0 + overflow-y: auto,滚动交给内层 |
| 6b | 弹窗顶部「花费」卡显示 ¥0.0 / 未启用计价,而同一弹窗的模型分布显示 ¥13.09(自相矛盾) | 宿主 logPayload 的返回对象漏了顶层 cost/currency 两个字段(模型分布读的是 models[].cost,所以显示正常) | 客户端加兜底:顶层缺 cost 时用 totals.cost 重建、币种从余额推断;宿主侧也补上字段(下次重启生效) |
| 7 | 浮窗完全拖不动(位置永远不变) | React 的 onPointerDown 在无头 Chromium 下不触发;另外我以为 jsdom 支持 pointer 事件 | 改用 mouse 事件( + window 上的 move/up,指针滑出头部也不丢事件),并把「拖动后坐标真的变了」变成端到端硬断言 |
这两个(5、6)是我最初 e2e 只跑 1400×900 一个尺寸漏掉的——测试视口不等于用户视口。
现在 tests/e2e-browser.mjs 里加了 1280×560 矮视口回归:断言「卡片不越视口 + 内层可滚 + 滚到底最后一段可达」。
配色
统一用 DeepSeek 自己的蓝色系(--dsw-static-deepseek-*),三桶靠明度区分,而不是靠色相:
| 桶 | token | 值 |
|---|
| 输入(命中缓存) | --dsw-static-deepseek-400 | rgb(103, 158, 254) |
| 输入(未命中缓存) | --dsw-static-deepseek-500 | rgb(65, 118, 230) |
| 输出 | --dsw-static-deepseek-450 | rgb(86, 134, 254) |
明度顺序也表达语义:命中缓存是"省下来的"(偏浅),输出是"真花出去的"(最亮)。
六、花费与余额(2026-09-15 新增)
小窗口在「今日总量」下方显示 今日花费 + 账户余额,日志弹窗里另有「花费」「账户余额」卡与明细表的「花费」列。
单价(官方价目表,元 / 百万 tokens)
来源 DeepSeek 官方定价页(2026-09-10 12:00 起生效)。价格分峰谷,这是计费正确性的核心:
| 模型 | 输入·命中缓存 | 输入·未命中 | 输出 |
|---|
deepseek-flash(V4.1-Flash) | 0.02 / 0.04 | 1 / 2 | 4 / 8 |
deepseek-v4-pro | 0.15 / 0.30 | 4.5 / 9.0 | 13.5 / 27.0 |
「空闲 / 高峰」——空闲时段价格正好是高峰的一半。高峰时段 = 北京时间周一至周五 9:00–12:00、14:00–18:00,其余(含周末全天)为空闲。
因此:花费不能拿区间总量乘单一单价,必须逐条按该条记录的时间取价再累加。aggregate / overview 都是这么算的,并且额外出一个峰谷分解(今天有多少钱花在高峰时段),直接回答"要不要把活挪到空闲时段跑"。
配置
单价可能再变,所以写在插件配置里,改 ~/.dsh/profiles/web/cordis.patch.yml 即可,不必改代码:
- id: token-monitor
config:
pricing:
currency: CNY
unit: 1000000
peakMultiplier: 2 # 高峰 = 空闲 × 2
defaultModel: { cacheRead: 0.02, uncached: 1, output: 4 } # 未登记模型的兜底价
models:
deepseek-flash: { cacheRead: 0.02, uncached: 1, output: 4 }
deepseek-v4-pro: { cacheRead: 0.15, uncached: 4.5, output: 13.5 }
apiKeyEnv: DEEPSEEK_API_KEY # 余额查询用;也可直接 apiKey: sk-...
未登记单价的模型不会静默算成 0(那会被读成"不花钱"),而是用 defaultModel 估算并在弹窗里点名提示。
余额
调 DeepSeek 官方 GET /user/balance(只读、免费)。凭据解析顺序:插件配置 apiKey → 环境变量 apiKeyEnv → $DSH_HOME/.credentials.yaml 的 refs 段。结果缓存 120 秒,接口超时 6 秒即降级。
取不到时显示「—」并在悬浮里写明原因(no-key / timeout / network / http-4xx),绝不用 0 冒充——0 会被读成"没钱了",这是误导。
峰谷时段带(直方图上方)
直方图上方一条 24 格色带,同一 DeepSeek 蓝的明度差表达价格差异:高峰 deepseek-500(深,贵)、空闲 deepseek-200(浅,半价)。不用红/绿是因为峰谷只是价格差,不是对错。
七、放行闸门(改完必跑)
bash /root/apps/dsh-plugin-token-monitor/preflight.sh # 退出码 0 才允许说"完成"
它依次跑:客户端契约(含 id == 包名)→ 宿主逻辑 → 元素树 → profile 挂载核对 → 真浏览器 boot 审计(全新 context,零缓存;断言「无 pageerror / 无 4xx / 无 Failed to load plugins / 侧栏已渲染 / 硬刷新后仍正常」)。
**为什么需要它:**事故当时 host/client/render 三层全绿,界面上却是整个应用打不开——单元测试无法发现"注册 id 与模块表行 id 不匹配"这类跨进程契约错误,只有真的 boot 一次才知道。
八、验证方式(五层,越往下越接近真相)
# 从 GitHub 克隆下来后,直接在克隆目录里跑(测试不依赖绝对路径)
git clone https://github.com/hipigod/dsh-token-monitor.git && cd dsh-token-monitor
node tests/host.test.mjs # 35 项:宿主纯逻辑 + 真实 HTTP 路由(自建 server,不依赖宿主)
node tests/client.test.mjs # 14 项:格式化/桶标签/日期/注册契约(含「id 必须等于包名」)
node tests/render.test.mjs # 21 项:用假 React 直接调用组件函数,断言真实元素树
TOKEN=$(grep -oE 'token=[A-Za-z0-9_-]+' /var/log/dsh-web.log | tail -1)
node tests/e2e-browser.mjs "$TOKEN" # 16 项:无头 Chromium 打开真 GUI,断言真 DOM 几何
第四层是唯一能发现上面 4 个缺陷的一层:前三层全绿的时候,界面上依然是空的。
tests/e2e-browser.mjs 直接断言「模型名与【日志】按钮的矩形不相交」「弹窗卡片宽度 > 900px」「内层不溢出卡片」这类几何事实,而不是截图给人看。
另有宿主侧自检端点(不需要 token、不需要浏览器):
curl -s http://127.0.0.1:3080/plugin-api/token-monitor/doctor | jq
九、实现要点(为什么这么做)
- zstd 多帧:DSH 的会话日志是多帧拼接的 zstd 流,每帧 ~1.8KB(实测 1.38MB / 750 帧)。
zlib.zstdDecompressSync(buffer) 只解第一帧——会得到「1 行日志」;流式解压器则在第二帧边界报 Unknown frame descriptor。因此按帧魔数切分、逐帧解码,并给解码器加 finishFlush: Z_SYNC_FLUSH:不加的话,一个只有帧头的坏数据会静默返回空缓冲,伪装成「这个会话没有用量」。
- 截断判定:zstd 是分块压缩,末尾少几字节往往仍能解出来,所以「能否解压」不足以判断截断。做法是:末帧解不出且前面已解出内容 → 标记截断继续;否则报错。同时用
hasIncompleteTail()(明文末尾没有换行)兜住「残行恰好还能 JSON.parse」的情况。
- 扫描缓存:按
(mtimeMs, size) 做文件级缓存,30 秒轮询不会重复解码历史日志;进程内单飞(同一时刻只跑一次扫描),并清理已删除会话的缓存项。
- 跨插件零依赖:宿主侧只用
node:zlib + node:fs;客户端侧只 require react / react/jsx-runtime / @deepseek-ai/dsh-client-ui-primitives(三者都是 DSH 的 PLATFORM_MODULES 基线外置),不 import 任何其它插件。
- 注册 id 必须等于包名:client-modules 的
arrive() 以「模块表行 id」为键等待 bundle 自注册,__ModuleLoader__.load({id}) 里写别的名字会在浏览器 boot 期直接抛错,表现为「插件不显示」且宿主日志里没有任何痕迹(这是本插件踩过的第一个坑)。
- 挂载点:注册进 ui-sidebar 声明的
sidebar.footer.action(list 槽位),由 ctx.slots.inject(...) 等待该槽位声明后再注册——直接 register 到别人声明的槽位会在加载期报错。order: 100 让本组件贴在设置按钮正上方(同槽位里动作按 order 升序渲染,设置在动作区下方)。
四层测试都是对抗性用例:跨日界、跨月、非法日期(2026-99-99、2026-02-30 必须被拒而不是被 Date.parse 归一)、坏帧、截断帧、越界 hover、空数据、NaN 传播、定时器泄漏、口径一致性(totals == models 求和 == buckets 求和 == conversations 求和)。
十、已知边界
- 第三方客户端插件是"全应用级"风险:一个注册 id 写错的 bundle 会让整个 GUI 不挂载(不是只坏自己)。所以改动客户端代码后必须跑
preflight.sh,别只看单元测试。
- 只统计写进会话日志的用量。如果某个 provider 不回报 usage,那部分不会出现在这里(也无法从日志推断)。
- 「按对话」里同时包含 subagent 会话(如果有的话),会按会话 id 分开列出。
- 历史日志格式升级(当前硬编码
session.v3.jsonl.zstd)后需要同步改路径;doctor 会在模块表层面先暴露异常。
- 日志目录默认取
$DSH_HOME(未设则 ~/.dsh),与 DSH 自身一致。