给 DSH 写了一个 OpenCode Go 用量监控面板:一个会话日志里塞了 826 帧 zstd

发布于 2026 年 9 月 14 日约 8 分钟阅读

打开 DSH 的侧边栏,我能说清这个月用掉了很多,但说不清用在哪。OpenCode Go 的套餐页只给三个整数百分比:rolling、weekly、monthly。它回答还剩多少,不回答花在哪、为什么。

所以我写了一个侧边栏插件,专门回答后面这半句。它叫 dsh-plugin-opencode-go-usage,装在 DSH(DeepSeek Harness)里,点开是一个用量面板。和上一篇写 Knowledge Pipeline 时的取舍一样:缺证据的地方不猜,把数字和口径一起摆出来。


一句话概括

这个插件把 DSH 已经写在本地会话日志里的事实,重新折叠成一张可读的用量面板:逐请求的供应商 token 计数、从 step/start 到消息落地的墙钟时长、重试与失败、多个会话之间的并发关系,再加上供应商自己的套餐配额。

它不算任何指标。token 数取自供应商返回值,时长是两个既有事件的时间差,配额是配额接口的返回值。插件只做组织。所以打开面板本身不发起模型请求,也不消耗 token。


问题不在额度,而在归因

一次比较忙的下午,我同时开着三个会话,都是 deepseek-v4.1-flash。套餐页显示 monthly 从 7% 涨到 9%,rolling 从 0% 跳到 8%。就这些。

我想知道的其实很具体:

  • 这几个会话各花了多少 token,哪个是大头
  • 缓存到底帮上忙没有(这个模型有 cacheRead,长上下文很吃这个)
  • 单次请求从 step/start 到落地要多久,慢在模型还是工具链
  • rolling 窗口被撞满的时候,是不是我自己并发太高
  • 有没有请求悄悄失败、被重试,而我以为是模型变笨了

这些问题的共同点是:答案本来就在本机,只是没人把它摆出来。


数据一开始就在本地躺着

DSH 的会话日志是 append-only 的,每个会话一个 session.v3.jsonl.zstd,放在 $DSH_HOME/sessions/ 下面。每次模型请求落定,它会追加一条 assistant/message

{
  "type": "assistant/message",
  "seq": 27,
  "time": 1789352345996,
  "data": {
    "turn": 1,
    "step": 1,
    "message": {
      "source": { "provider": "opencode-go", "model": "deepseek-v4.1-flash" }
    },
    "usage": {
      "inputTokens": 10881,
      "outputTokens": 300,
      "totalTokens": 12077,
      "cacheReadTokens": 896
    }
  }
}

需要的每一样都在:供应商自己报的 token 数、路由到哪个 provider 和 model、属于哪个 turn 和 step。配合 step/start 的时间戳,还能算出单次请求的墙钟时长。

另外,失败也留了痕迹:llm/retry 事件记录了错误码、原因、provider 与重试序号,可以和最终落定的那条请求对起来看。


一张面板,拆成三个问题

第一版我把所有东西堆在一页:总量、模型、会话、80 条逐请求明细一次铺开。信息是全的,但看不清。现在拆成三个标签页,各自回答一个层级的问题。顶部保留全局控制:实时状态、窗口切换(1h / 6h / 24h / 7d / 30d)和刷新。

汇总:这个窗口总共发生了什么

最上面是供应商的套餐配额,rolling / weekly / monthly 三条进度条带重置倒计时,超过 80% 变红。接着是五组总量:已结算请求、总 tokens、输出 tokens、输入 tokens、缓存命中率。

再往下分两块。性能:平均输出速度、p50 / p90 / p99 / 最长 / 平均时长、空响应 step。并发与失败:当前进行中、窗口并发峰值、失败事件、重试次数、活跃会话、最忙会话。

我自己最常看的是用量时间线。柱子是每个时间桶的 token 量,深色是输出,浅色是输入(含缓存读),红色小块标出这个桶里出现过失败,悬停能看到该桶明细。今天什么时候在猛烧,一眼就能看出来。

会话:并发使用的时候到底谁在花

这一页是我做这个插件最初就想要的,也是最花心思的一页。每行是一个会话:

含义
会话 / 工作区 / preset 会话 id 短号、工作区目录名、agent preset
请求、总 tokens 请求数带一根占比条,直接看出哪个会话是大头
输出 / 输入 / 缓存读 三个 token 桶分开列,缓存读单独一列是因为它便宜得多
缓存命中 cacheRead /(未缓存输入 + cacheRead)
p50 / p90 / tok/s 该会话自己的时长分位与输出速度
活跃时长 首个已结算请求到最后一个的时间差,包含中间空闲
平均间隔 活跃时长 /(请求数 − 1)
失败 该会话的失败与重试次数
活动 一根活动条,每格 1 分钟起,看这个会话是持续在跑还是脉冲式

活跃时长和平均间隔是我加上去之后才意识到有用的两个字段。同样是 50 次请求,一个会话是 5 分钟内连着跑完,另一个是断续跑了两小时。这两种情况的成本结构完全不同:前者在撞 rolling 配额,后者是正常的交互节奏。

活动条也返工过一次。第一版按整个窗口 32 等分,结果一个 30 分钟的会话在 24 小时窗口里被压进最后一格,看起来像一条实心块。现在跨度由观测到的会话时长中位数 ×2决定(下限 15 分钟,上限不超过窗口),典型会话会横跨大半条,形状才看得出来。

活动:单次请求级别

最后一页是逐请求的:最近请求画像(样本量、最慢一次、平均时长、时长区间)、失败与重试(事件数、重试合计、错误码分布、空响应 step)、实时(正在进行的请求、正在重试的请求)、最近 80 条明细、失败明细。

某个下午觉得模型特别慢,先来这里看时长区间和最慢那条,就知道是普遍现象还是个别请求。


三个绕不过去的工程细节

细节一:会话日志不是一个 zstd 文件

Node 的 zlib 能解 zstd,但只解一帧。而 DSH 的会话日志是每 flush 一次追加一帧的拼接流。我手上一个 1.73 MiB 的会话日志里塞了 826 帧,用 Node 内置解码器读,第一帧之后立刻报 ZSTD_error_prefix_unknown

这一点用 zstd 命令行可以直接核出来:

$ zstd -lv session.v3.jsonl.zstd
# Zstandard Frames: 826
# Compressed Size: 1.73 MiB (1812180 B)

解法是走子进程 zstd -dc -q -T1,用 -T1 保证输出有序(增量解析依赖顺序),然后按行流式折叠,不需要的事件类型直接跳过。一个小日志整条链路 8ms 左右;本机 23 个会话日志冷解析约 360ms,之后按 mtime + size 增量复用。

这里有个刻意的选择:找不到 zstd 时不静默显示 0,而是在面板顶部弹一条明确的降级提示。空面板和真的零用量必须能区分开。

细节二:轮询面板不能自己变成成本

面板打开时每 4 秒拉一次数据。如果每次都重新解压全部日志,那就是自己给自己造负载。三条约束:

  • 会话日志按 mtime + size 缓存,只有变化的文件才重新解析
  • 并发请求合并,同一时刻只跑一次刷新
  • 配额接口单独 45 秒 TTL,失败时保留上一次成功值并标记为 stale

面板也只在文档可见时轮询,切到别的窗口就停。

细节三:口径要写在界面上

有一类 bug 特别隐蔽:数字看起来是对的,但口径是错的。自测时我发现实时区块把已经完成但重试过的请求也列成了重试中,因为条件写成了重试次数大于 0 或者已经落定。真实语义应该是:进行中是有 step/start 没有落定;重试中是最近有 llm/retry 事件。修完之后,历史重试归到活动页的失败统计里。

现在三页都留了口径说明。会话页那张卡片会逐条写清每个字段来自哪个事件、怎么算的;面板底部常驻一行:只统计 provider 为 opencode-go 且已结算的请求,窗口内另有 N 次其它 provider 请求未计入。把排除项写出来,比让它悄悄消失要诚实。


技术上是朴素的

宿主半侧是普通 Node 代码,在共享通道上注册两个只读路由:

GET /api/opencode-go-usage/metrics?window=24h[&force=1][&quota=1]
GET /api/opencode-go-usage/health

浏览器半侧是手写的 lazy-CJS bundle,只依赖宿主提供的 react,没有构建步骤。改完刷新页面就生效,只有改 cordis.patch.yml(插件装配行)才需要完全重启 DSH Desktop。仓库结构:

dsh-plugin-opencode-go-usage/
├── index.js                   # 宿主半侧:路由、缓存、配额探针装配
├── client.js                  # 浏览器半侧:三个标签页的面板
├── lib/
│   ├── metrics.js             # 会话日志解析、折叠、窗口聚合
│   └── quota.js               # 配额接口探针与缓存
├── test/
│   ├── plugin.test.mjs        # 12 项回归测试
│   ├── render-smoke.mjs       # 用真实 payload 渲染三页 + 降级状态
│   └── react-dom-server-stub.cjs
├── README.md
└── LICENSE

API key 不走代码也不落盘:插件通过 ctx.credentials 按请求解析 OPENCODE_GO_API_KEY,只用它调一次额度接口,不打印、不缓存值。


三个我没想到的收获

第一,缓存命中率是最值得盯的指标。我自己这套用法下它长期在 98% 到 99% 之间,也就是说绝大部分输入 token 是命中缓存的。一旦它掉到 90% 以下,通常意味着我改了 system prompt、换了工具集,或者上下文压得不够稳。这个信号比总量上涨更早,也更可行动。

第二,并发峰值比总量更能解释为什么被限速。有一次 rolling 配额突然从 0 跳到 8%,我以为是哪个长任务在烧,看了时间线才发现是三个会话在同一分钟里都发了请求。总量没变多少,峰值很尖。

第三,本地观测和供应商口径本来就不可能精确对齐。供应商给账户级整数百分比,本地是逐请求 token 归档,两者互不替代:想知道还剩多少看配额,想知道花在哪看面板。这句话我直接印在配额卡片下面,因为我自己一开始也被为什么面板数字和套餐页对不上困惑过,答案是它们量的不是同一件事。


当前边界

  • 只做观测,不做限流。它不会阻止你并发跑十个会话,只是让你看见代价
  • 时长是从 step/start 到消息落地的墙钟时间,包含工具等待和重试,不是纯推理时间
  • 活动条固定 32 格、跨度按会话时长中位数自适应,更早的活动会落在条外显示为空,这是刻意的取舍
  • 会话日志解析依赖 zstd 命令行(macOS 用 brew install zstd)
  • 只覆盖 OpenCode Go provider;其它 provider 的用量计入未计入计数,但不参与统计

结语

这个插件代码量不大,但它让我第一次对自己的用法有准数:哪些会话在花、缓存在帮多少忙、慢在哪一层、失败有没有被掩盖。我本机 24 小时窗口现在是 1010 次已结算请求、15 个活跃会话、1.3 亿输入输出 tokens,缓存命中 99.0%。这些数字以前只存在于日志文件里。

如果你也在用 DSH 加 OpenCode Go,可以直接克隆装上看:

git clone https://github.com/ylouis8/dsh-plugin-opencode-go-usage.git \
  ~/.dsh/plugins/opencode-go-usage

仓库地址:github.com/ylouis8/dsh-plugin-opencode-go-usage(MIT)

分享:

Leave a Reply

Your email address will not be published. Required fields are marked *

Scroll to Top