dsh-bench
Run one task across several model/config variants in DeepSeek Harness and compare the trajectories side by side
安装
此插件尚未提供可验证的 bundle,或兼容性检查未通过。请先阅读仓库说明。 阅读完整 README ↗
说明文档
阅读完整 README ↗dsh-bench
English · 中文
在 DeepSeek Harness 里把同一个任务跑在多个模型/配置变体上,并排对比它们的轨迹。
插件注册两个工具:bench_run 负责测量,bench_history 负责和历史运行对比。每次试验都是一个全新的一次性 spawn 子 agent,它只看得到任务文本、看不到父会话历史——这是各个 arm 可比的前提。
为什么做这个
插件生态里观察单条会话的工具很多——用量面板、上下文构成、轨迹诊断——但没有一个能对比两条。没有对比,就没法回答"换个便宜模型、砍掉一半工具、压低输出预算,结果到底会不会变差",只能凭感觉。
这里的每一个数字 harness 本来就有,插件做的只是驱动矩阵、折叠结果:
| 指标 | 来源 |
|---|---|
| 步数、模型墙钟时间 | sessionStats session-projection 单元 |
| token | ctx.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/)解析的,不是按 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 缺失时运行照常完成,并明确告知没有被记录。
当问题出在任务本身
如果所有 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 还能不能做成"变成表里的一行,而不是靠猜。
提问类工具(默认 ask_user_question,由 denyTools 配置)在所有试验里都被禁用:一次性的 bench 子 agent 背后没有人,提问只会把试验挂住、把墙钟时间灌水——而且一个跑去提问的 arm 根本没尝试任务,却会和一个老老实实做完的 arm 并排出现在同样的列里。
汇总口径是中位数 + 计数,不用均值。轨迹结果的分布更接近伯努利而不是高斯,对双峰样本取均值会报出一个任何一次运行都没产生过的数。repeat 小于 3 时,报告会明确提示那几列"中位数"只是单个样本、不构成趋势。
配置
| 键 | 默认值 | 含义 |
|---|---|---|
providerName | spawn | 子 agent provider。用 fork 会继承调用者的对话历史,破坏可比性。 |
maxTrials | 24 | 单次调用 variants × repeat 的硬上限。 |
trialTimeoutMs | 300000 | 单次试验的截止时间。没有它,一条卡死的路由能把整个矩阵一直挂着——曾经有一条坏路由,在总共 6 分钟的运行里独占了 5 分钟的重试退避。 |
checkTimeoutMs | 60000 | 单次 check 的截止时间。 |
denyTools | ["ask_user_question"] | 无视变体设置,在所有试验里一律禁用。 |
有意为之的限制
- 试验串行执行。 并发会让排队延迟污染
llmMs,使墙钟时间列失去可比性。 - 没有 preset 轴。
SubagentStartRequest不带 preset 覆盖——子 agent 会 join 父 agent 的 preset——所以这条轴得绕开 subagent seam,改用ctx.agents.create()。 - 还没有 context 消融轴(按 arm 抑制
agent/pre-step注入)。钩子的可行性已被$DSH_HOME/.agent-presets/anchored-standard/tool-bootstrap.mjs证明。 - 首个试验失败即致命。 provider 名字写错、模型路由不存在、递归深度超限属于装配问题,直接让整次调用失败,而不是拿同一个错误把整个矩阵烧完。之后的失败记为 note 并继续跑。
- 超时的试验照样计入成本。 token 已经花掉了,只有
completed和outcome会把它标成没做完。
状态
P0,已于 2026-08-15 用一次真实的 4 试验运行(2 个 arm × repeat 2)验证。
那次运行确认了:
- 装配、模块加载、工具注册、prompt assembly 的 JSON Schema 校验、output 校验、报告渲染,全链路通。
- 没有审批死锁。 子 agent 执行了 12 次工具调用(
glob、read、grep),没有任何审批门卡住父会话——委派策略确实像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× 的延迟差距变得无法归因。 check命令。 那次运行只能得出"flash 更便宜",永远得不出"flash 更好",因为没有任何东西验证过两边的答案。- 单试验超时,起因是更早那次 6 分钟的运行里有 5 分钟耗在一条坏路由的重试退避上。
- 报表口径修正。 工具混用那列原本是多次运行的总和,却和旁边单次的工具调用中位数并排——一张表里两种单位。现在统一成每次运行。
repeat小于 3 时,报告也会声明那个"中位数"只是单个样本。
再后来一轮对比换成了带真实定位环节的任务。两个模型都 3/3 通过,步数和工具调用完全相同、token 差距在 6% 以内,只有墙钟时间有差别——这提出了 llmMs 回答不了的问题,于是有了上面的 ttft/吞吐拆分和判别力警告。那一轮同时也淘汰了一个假设:最早那次运行里某个 arm 的大量 shell 探索始终没有复现,它是一个未经重复验证的单样本,而不是一种策略。
路线图
- P2 —— context 消融轴:按 arm 抑制
agent/pre-step注入,量化每一段注入上下文到底值不值它占的 token。 - P3 —— 轨迹 diff、经由
ctx.agents.create()的 preset 轴、并发执行。
许可
MIT