編集者注

1105623876/dsh-bench

The plugin registers two tools: bench run to measure, bench history to compare against past runs. Each trial is a fresh one-shot spawn subagent that sees only the task text — no parent history — so the arms are comparable.

これは DeepSeek Harness(DSH)プラグインです。当サイトは GitHub README、インストール情報、メンテナンス状況、公開セキュリティシグナルをまとめています。

dsh-bench

English · 中文

把同一个任务跑在不同模型、不同工具面、不同上下文配置上,得到一张对照实验表:哪个 arm 真的做对了、各自花了多少、慢的那个慢在哪一层。 当任务设计/结果有问题时,它还会直接提醒你的 agent——比如所有 arm 走出相同轨迹的任务,测的是打字速度,不是能力。

DeepSeek Harness 而写。

插件注册两个工具:bench_run 负责测量,bench_history 负责和历史运行对比。每次试验都是一个全新的一次性 spawn 子 agent,它看得到任务文本、看不到父会话历史——这是各个 arm 可比的前提。

为什么做这个

插件生态里观察单条会话的工具很多——用量面板、上下文构成、轨迹诊断——但没有一个能对比两条。没有对比,就没法回答"换个便宜模型、砍掉一半工具、压低输出预算,结果到底会不会变差",只能凭感觉。

这里的每一个数字 harness 本来就有,插件做的只是驱动矩阵、折叠结果:

指标来源
步数、模型墙钟时间sessionStats session-projection 单元
tokenctx.tokenMeter.measure(session)
工具调用次数与构成子会话持久日志里的 tool/call 事件
终止原因SubagentResult.stopReason

挂载

不用改 $DSH_HOME 里的任何东西——插件自带覆盖层。复制模板,把路径指向你 clone 的位置:

cp bench.patch.yml.example bench.patch.yml
# 然后把 `name:` 那行改成你自己的绝对路径
dsh --profile web --patch /path/to/dsh-bench/bench.patch.yml

先验证装配,这一步不启动任何东西、零成本:

dsh --profile web --patch /path/to/dsh-bench/bench.patch.yml --dump-config

路径坑。 --patch 覆盖层里的相对 name: 是按 profile 目录$DSH_HOME/profiles/<name>)解析的,不是按 patch 文件所在目录——这和 agent preset 的行为相反(preset 里是按 preset 目录解析)。所以 profile 之外的插件必须写绝对路径,而 Windows 上 Node 的 ESM loader 只接受 file:// URL 形式的绝对路径。

插件从 profile 之外加载,因此不在 HMR 的监视根目录内:改动需要重启,热重载不会生效。

使用

bench_run 默认是 dry run,只返回计划、不发送任何请求。传 confirm: true 才会真正消耗 token。

{
  "task": "读一下 package.json,列出所有 script",
  "variants": [
    { "label": "pro" },                                       // 继承调用者的路由
    { "label": "flash", "model": "deepseek-v4-flash" },
    { "label": "no-shell", "model": "deepseek-v4-flash", "deny": ["bash", "pwsh"] }
  ],
  "repeat": 3,
  "check": ["node", "verify-scripts.mjs"],
  "confirm": true
}
Ran 9/9 trials.

**Outcome**

| variant  | provider/model             | runs | pass | completed | outcome |
| ---      | ---                        | ---: | ---: | ---:      | ---     |
| pro      | deepseek/deepseek-v4-pro   | 3    | 3/3  | 3/3       | ok      |
| flash    | deepseek/deepseek-v4-flash | 3    | 3/3  | 3/3       | ok      |
| no-shell | deepseek/deepseek-v4-flash | 3    | 1/3  | 3/3       | ok      |

**Cost & latency** — medians across runs

| variant  | steps | tool calls | tokens | llm ms | ttft ms | tok/s | tool ms | tool mix /run |
| ---      | ---:  | ---:       | ---:   | ---:   | ---:    | ---:  | ---:    | ---           |
| pro      | 7     | 8          | 24448  | 8124   | 620     | 96    | 1180    | glob×3, read×2, grep×1 |
| flash    | 4     | 4          | 11047  | 3011   | 450     | 198   | 640     | read×3, glob×1 |
| no-shell | 6     | 7          | 15220  | 5340   | 470     | 191   | 910     | read×5, glob×2 |

没有界面:bench_run 是给模型调的工具,在输入框里用自然语言让 agent 调它即可。

不只是"慢",而是慢在哪

llm ms 分不清"在等"和"在生成",而这两者的解法完全相反。所以它被拆开了,用的还是 harness 自己的数:

  • ttft ms —— 每步首 token 的平均延迟。token 量相当的前提下这里有差距,就是 provider 排队:换个网关、换个时段可能就没了。
  • tok/s —— 解码吞吐。这里的差距主要来自模型——但共享 batching 在高负载下同样会拉低吞吐,所以它是上界而不是常量(相隔一小时的两次运行里,一个 arm 的吞吐掉了 17%,另一个涨了 19%)。
  • tool ms —— 花在工具内部的墙钟时间,完全在模型之外。

测量值为 0 时渲染成 :真实试验不可能花 0 token、不可能等 0 毫秒出首 token,所以字面的 0 只意味着"没测到",绝不是"瞬间完成"。

报告接着会把每个较慢 arm 相对最快 arm 多花的时间做归因,因为"首 token 慢 2.6 倍、吞吐低三分之一"这句话并不告诉你该动哪一半:

**Where the extra time went**

- **mimo** spent 11427ms more than `flash`: 48% waiting (+5432ms), 52% generating (+5995ms)

这个差距里大约一半可能换个网关、换个时段就没了,另一半则永远不会。两个分量之和不等于 llm ms 的原始差值——框架开销和每步的其他成本不在这两者之内——所以百分比是"已归因部分"的占比。

历史台账

每一次确认执行的运行都会通过 storage seam 记录下来(json backend 下的 $DSH_HOME/storages/bench_runs.json),bench_history 负责读回:

{ "limit": 10, "task": "package.json" }   // 两个参数都可选

它按时间倒序列出历史运行,并对跑过不止一次的任务报告每个 arm 的漂移:

**Drift since the previous run of the same task**

- `mimo` on "find the bigger package.json" — ttft -58%, tok/s = (08-16 23:40 → 08-17 09:15)

这一行就是台账存在的全部理由。 单次测量分不清"端点本来就慢"和"那一分钟正好挤"——两次就能分清。吞吐稳住而延迟变动,是 provider 排队;两者一起动,说明负载已经压到解码路径,或者路由换了。而当两个 arm 在同一时间窗内朝相反方向漂移时,原因就不在你的网络——是某一个端点自己的负载。

dry run 不写台账。storage seam 缺失时运行照常完成,并明确告知没有被记录。

消融轴

每个变体都支持 suppress:一个 agent/pre-step 上下文来源的列表,这些注入会在该 arm 的试验里被剥掉。它回答的是每个 harness 用户天天在盲判的问题——这段注入的上下文,值不值它占的 token?

"variants": [
  { "label": "full" },
  { "label": "no-skills", "suppress": ["skill-catalog"] },
  { "label": "no-agents", "suppress": ["agent-instructions"] },
  { "label": "bare",      "suppress": ["skill-catalog", "agent-instructions"] }
]
**Context ablation**

- `full` — full context
- `no-skills` — suppressed skill-catalog: 1 message(s) stripped per run
- `bare` — suppressed skill-catalog, agent-instructions: 2 message(s) stripped per run

常见的来源类型是 skill-catalog(可用技能提醒)和 agent-instructions(AGENTS.md / CLAUDE.md 摘要);具体装配可能还会注入别的。

有两条性质比这个功能本身更重要:

  • 匹配不到任何东西时会大声报错。 消融的失败模式是静默的——不报错,只是某个 arm 悄悄带着完整上下文跑完,然后被当成证据。所以一旦请求的来源类型剥掉了 0 条消息,报告会明说,并列出它实际见到的类型,避免一个拼写错误伪装成"没有差异"的结论。
  • 只影响本次运行的子 agent。 过滤依据是子会话持久化的 parentSession,不是一个全局开关,所以一次 bench 绝不会剥掉另一个工作区并发会话的上下文。

要配合 check 一起用,否则这些数字只能说明"上下文越少越便宜"——而这件事从来没人怀疑过。

当问题出在任务本身

如果所有 arm 走出完全相同的轨迹——步数相同、工具调用相同——报告会直接说出来。那样一张表很容易被读成"这几个模型能力相当",但它通常只意味着任务没给它们分化的余地:把文件路径写死,任何模型都退化成一次 read,这一轮测的只剩打字速度。歧义才是让策略显形的东西;一个没有歧义的任务不具备判别力,无论它的表看起来多好看。

成本不等于质量

completed 只表示子 agent 把这一轮跑完了,不表示它把任务做对了check 命令是这里唯一的质量信号:一个 argv(不经 shell 解释,需要 shell 就自己传),每次试验结束后在工作区执行,退出码 0 记为通过。它能从 $DSH_BENCH_OUTPUT 拿到该次试验的最终文本,另有 $DSH_BENCH_LABEL$DSH_BENCH_RUN$DSH_BENCH_STOP_REASON

不传它,这一轮就只测了成本,报告里会明说。一个便宜三倍但 check 不过的 arm 不是更便宜的那个,是错的那个。

工具面也是一条轴

每个变体都支持 allow(只保留这些)和 deny(去掉这些),于是"这个模型没有 shell 还能不能做成"变成表里的一行,而不是靠猜。

收窄的实现方式是过滤已装配的工具目录,外加一道 guard 兜底拒绝执行。这里刻意没有SubagentStartRequest.toolFilter:它会变成 tools.restrict(),而后者只遮蔽全局工具、遇到 scope-local 名字直接失败——而在标准装配里,所有有意思的工具(bashpwshreadglobtodo_writeweb_searchask_user_question)都是由 agent preset 注册的,不是全局注册的。基于 restrict 的过滤器根本碰不到它们。

默认在所有试验里禁用三个工具(denyTools 可配):

  • ask_user_question —— 一次性的 bench 子 agent 背后没有人,提问只会把试验挂住、把墙钟时间灌水;而且一个跑去提问的 arm 根本没尝试任务,却会和一个老老实实做完的 arm 并排出现在同样的列里。
  • bench_runbench_history —— 试验本身就是一个普通 agent,能看到完整目录,包括这两个。子 agent 自己再开一个矩阵,花的是 maxTrials 管不到的钱——那个上限管的是单次调用,不是一棵调用树。

这是护栏,不是禁令。"比较两个模型驾驭 benchmark 的能力"是个正当实验,所以只要在某个 arm 的 allow 里点名,就能把该工具从默认 deny 里捞出来——明确表达的意图压过安全默认,没表达的则不然。同一个 arm 里既 allow 又 deny 同一个名字会直接报错,而不是悄悄替你决定。

某个名字如果从没被提供给试验,会逐名报出来,并附上完整的可用目录——正是这条检查抓住了"在 shell 工具叫 pwsh 的机器上 deny: [\"bash\"]"这种情况。

汇总口径是中位数 + 计数,不用均值。轨迹结果的分布更接近伯努利而不是高斯,对双峰样本取均值会报出一个任何一次运行都没产生过的数。repeat 小于 3 时,报告会明确提示那几列"中位数"只是单个样本、不构成趋势。

配置

默认值含义
providerNamespawn子 agent provider。用 fork 会继承调用者的对话历史,破坏可比性。
maxTrials24单次调用 variants × repeat 的硬上限。
trialTimeoutMs300000单次试验的截止时间。没有它,一条卡死的路由能把整个矩阵一直挂着——曾经有一条坏路由,在总共 6 分钟的运行里独占了 5 分钟的重试退避。
checkTimeoutMs60000单次 check 的截止时间。
denyTools["ask_user_question"]无视变体设置,在所有试验里一律禁用。

有意为之的限制

  • 试验串行执行。 并发会让排队延迟污染 llmMs,使墙钟时间列失去可比性。
  • 没有 preset 轴。 SubagentStartRequest 不带 preset 覆盖——子 agent 会 join 父 agent 的 preset——所以这条轴得绕开 subagent seam,改用 ctx.agents.create()
  • 首个试验失败即致命。 provider 名字写错、模型路由不存在、递归深度超限属于装配问题,直接让整次调用失败,而不是拿同一个错误把整个矩阵烧完。之后的失败记为 note 并继续跑。
  • 超时的试验照样计入成本。 token 已经花掉了,只有 completedoutcome 会把它标成没做完。

状态

P0,已于 2026-08-15 用一次真实的 4 试验运行(2 个 arm × repeat 2)验证。

那次运行确认了:

  • 装配、模块加载、工具注册、prompt assembly 的 JSON Schema 校验、output 校验、报告渲染,全链路通。
  • 没有审批死锁。 子 agent 执行了 12 次工具调用(globreadgrep),没有任何审批门卡住父会话——委派策略确实像 child-agent.ts 暗示的那样往下传递。
  • 一个真 bug,已修: cordis 门禁管的是服务访问,不只是插件激活。没声明依赖就读 ctx.tokenMeter 会抛 cannot get property "tokenMeter" without inject,于是防御性 try/catch 把所有指标列降级成了 0。现在可选 seam 统一走 ctx.inject(…) 获取;缺失时仍然能报出工具构成和完成计数,并明确写出缺了什么。
  • 路由一旦损坏,重试退避会主导墙钟时间。 一个失败的 arm 吃掉了整轮 6 分钟里的约 5 分钟:2 次 repeat × 每次 2 轮 llm/retry,中间还有退避。单试验超时应该进 P1。

那次运行同时产出了这个工具的第一个真实发现,也正是它存在的意义:某个 arm 拿到 0/2 完成、零工具调用,其子会话全部以 turn/end → error, "Stream ended without finish_reason" (TRANSPORT) 结束。坏掉的 provider 路由在日常聊天里是隐形的——只让人觉得"今天有点慢"——但摆在一个正常 arm 旁边就无所遁形。

P1 补上的,正是第二次真实运行暴露出来的缺口:

  • 默认禁用提问类工具。 那次两个 arm 都调了 ask_user_question——一次性子 agent 背后没有人,这纯粹是墙钟时间的污染,也让 5.8× 的延迟差距变得无法归因。(第一版实现走的是 toolFilter,后来的运行证明它什么也没禁掉,见消融那一节。)
  • check 命令。 那次运行只能得出"flash 更便宜",永远得不出"flash 更好",因为没有任何东西验证过两边的答案。
  • 单试验超时,起因是更早那次 6 分钟的运行里有 5 分钟耗在一条坏路由的重试退避上。
  • 报表口径修正。 工具混用那列原本是多次运行的总和,却和旁边单次的工具调用中位数并排——一张表里两种单位。现在统一成每次运行。repeat 小于 3 时,报告也会声明那个"中位数"只是单个样本。

再后来一轮对比换成了带真实定位环节的任务。两个模型都 3/3 通过,步数和工具调用完全相同、token 差距在 6% 以内,只有墙钟时间有差别——这提出了 llmMs 回答不了的问题,于是有了上面的 ttft/吞吐拆分和判别力警告。那一轮同时也淘汰了一个假设:最早那次运行里某个 arm 的大量 shell 探索始终没有复现,它是一个未经重复验证的单样本,而不是一种策略。

P2 加上了消融轴,而它的第一次真机运行立刻暴露了两个构造上就是静默的失败:

  • 工具收窄从来就没生效过。 SubagentStartRequest.toolFilter 会变成 tools.restrict(),后者只遮蔽全局工具,而标准装配里所有有意思的工具都是由 agent preset 注册的。那次运行报出 ask_user_question is not registered,可子 agent 一直在调用它——之前"P1 的 deny 生效了"这个判断是错的:它不再出现是因为任务换了,与功能无关。现在收窄改为过滤已装配的目录,后面还有一道 guard 拒绝执行。
  • 归因在分量异号时算崩了。 它拿两个分量之和当分母,于是给一个"等得更久但生成更快"的 arm 印出了 292% waiting (+-852ms)。现在异号时不给百分比,直接报带符号的毫秒。

那次运行同时说明了,"什么都没匹配上"的诊断比两条轴本身更重要:skill-catalog 根本不是本装配会注入的来源类型,于是一个标着 no-skills 的 arm 悄悄带着完整上下文跑完了——没有那条警告,它会被读成"技能目录没用"的证据。同一轮里还把 bench_runbench_history 设为试验默认禁用,因为可用工具清单显示一次试验能自己再开一个矩阵。

验证到哪一步了

每个功能都是对着真实运行写出来的,但不是每条路径都在真机上跑过。失败路径目前只有 mock 覆盖,这里如实标注而不含糊带过:

能力状态
子 agent 试验、指标折叠、报告渲染真机
check 通过/失败、台账写入、bench_history 漂移真机
ttft / 吞吐 / 工具耗时拆分、归因真机,含异号那种情况
上下文消融(suppress真机——每次运行剥掉 3 条消息
两条轴的"什么都没匹配上"诊断真机——抓到了本装配根本不注入的 skill-catalog,以及在 shell 是 pwsh 的机器上 deny: ["bash"]
工具收窄(allow / deny真机执行过pwsh 在过滤前的目录里并被剥掉),但还没有任何一次运行显示它改变了行为:那道题两个 arm 本来就不用 shell。换一道非用 shell 不可的题才能坐实。
单试验超时、check 失败、seam 缺失、provider 不支持该能力仅 mock——真机还没撞上过

路线图

  • P3 —— 轨迹 diff、经由 ctx.agents.create() 的 preset 轴、并发执行。

许可

MIT

REPOSITORY SIGNALS

セキュリティとインストールエビデンス

このスコアは公開リポジトリメタデータと当サイトに登録されたインストールエビデンスのみに基づくもので、コードセキュリティ監査とは異なります。

出所の追跡可能性

公開プラグインカタログから取得し、公開 GitHub リポジトリにリンクしています。

ライセンス

リポジトリは MIT ライセンスを宣言しています。

メンテナンス活動

過去180日以内にコードの更新があります。

インストールエビデンス

再現可能な正確なインストールメタデータはまだ登録されていません。リポジトリの説明に従って手動で確認してください。

インストールライフサイクルスクリプト

確認したパッケージメタデータにインストールライフサイクルスクリプトは宣言されていません。