DSH Runtime Tuning
DeepSeek Harness(DSH)主程序内部的调优插件:工具执行、文件工具、沙箱与凭据保护、聊天界面的修复和优化,每一项都用真实会话数据验证。任务提醒、托盘启动和崩溃防护在 dsh-sentinel。
这里既是一组可以安装的 DSH 插件,也是这个过程的记录。随着分析推进,插件会不断增加。
起点:一份 9.7 小时的会话
我把自己日常使用 DSH 的一份会话导出来,按事件逐条统计。这份会话有 119 轮对话、2347 次工具调用,跨度 9.7 小时,内容主要是一个项目的反复发行(测试、编译、归档、上传)。
时间大致花在这些地方:
| 去处 | 时间 | 备注 |
|---|
| 模型生成 | 约 4.3 小时 | 每步中位数约 6 秒 |
阻塞式等待后台任务(job_output wait: true) | 约 100 分钟 | 152 次,最长一次 240 秒 |
| 等人审批 | 约 58 分钟 | 24 次,最长一次 44.7 分钟 |
| 提问后等人回答 | 约 79 分钟 | 这段时间模型什么也做不了 |
前台 sleep | 约 20 分钟 | 33 次,比如 sleep 90; echo waited |
几个值得注意的现象:
- 有工具调用的步骤里,87% 只调用了一个工具。每多一步,就多一次重读上下文和生成。
- 同一个后台任务被反复查询,最多一个查了 13 次。
- 模型会从文件里 grep 出 GitHub 令牌,再用 curl 调 API,令牌因此出现在命令行和日志里。
发现一:机制都在,只是没人强制
读了 DSH 的源码之后发现,DSH 其实已经做得不错:
- 后台任务结束时会通知模型:模型忙时插进下一步,空闲时直接开启新的一轮;
- 前台命令超时后不会被杀掉,而是转到后台;
- 系统提示词里明确写了"不要轮询、不要 sleep"。
问题在于模型并不总是照做:它还是会 wait: true 加上 120 到 600 秒的超时,或者直接前台 sleep 90。等待期间整轮对话被占住,用户插话只能排队。
所以第一个插件 wait-guard 做的事很简单:在工具执行前强制执行这条规则,拦截前台长 sleep 和顶层 agent 的长时间阻塞等待,并告诉模型应该怎么做。
装上第一版之后,同一个发行流程的对比:
| 改动前 A | 改动前 B | 装插件后 |
|---|
| 对话被占住 | 22.1 分 | 19.6 分 | 4.6 分 |
| 阻塞等待 | 13.0 分 | 15.2 分 | 0.2 分 |
| 自动唤醒 | 0 次 | 0 次 | 3 次,都在任务结束的同一秒 |
要说清楚一点:收益是"不占住对话",不是"更快"。这次总耗时也变短了,但主要是因为那次编译本身快。以前的阻塞等待在任务结束时也会立刻返回,并没有"白等"。真正的变化是编译期间用户随时可以插话,而不是对着一个卡住的对话。
发现二:通知没来怎么办
让模型结束一轮去等通知,就要回答一个问题:如果任务卡死,通知永远不来呢?
wait-guard 为此加了一个 task_status 工具。模型在一轮的最后声明自己是 done、waiting(在等后台任务,附上预计时间)还是 asking(在等用户回答)。声明 waiting 的一轮正常结束后,插件开始计时;超过两倍预计时间还没有新一轮,就主动唤醒模型去检查。
几个边界是刻意设计的:
- 最多连续唤醒 2 次,用户发消息后重新计数,避免无人值守时无限循环;
- 用户按停止结束的一轮不计时:用户叫停的事,插件不应该再拉起来;
- 实测:一个
sleep 300 的任务,模型预计 1 分钟,插件在第 2 分钟准时唤醒。模型看了一眼输出,重新声明预计 3 分钟;任务结束后模型被正常唤醒并声明完成,兜底计时随之取消。
后来在一次真实发行里发现,模型并不总会声明:它把编译放进后台就直接结束了一轮,没有调 task_status,兜底计时也就没开。于是加了一道硬保底:只要一轮结束时后台还有任务在跑,又没有声明 waiting,就按默认 30 分钟计时,到点唤醒模型去检查。开发服务器这类故意留着的任务,模型可以在 services 里列出来,它们不计时。下一节会讲到,这道硬保底最后成了主角。
发现三:提问会把模型卡住
ask_user_question 默认是阻塞的:问出去之后,在用户回答之前,模型什么也做不了,哪怕手上还有不依赖这个回答的事。
DSH 其实已经实现了非阻塞的 timed 模式(超时返回 pending,用户的回答之后作为插话送进来),但默认关闭,而且这个工具嵌在 agent preset 内部,用户的配置补丁改不到它。具体的复现过程我发在了 DSH 讨论 #8547。
插件 ask-nonblocking 的绕法是:在每个顶层 agent 创建时,用宿主里同一份官方模块在 agent 自己的作用域里注册一个 timed 版本,遮蔽掉 preset 里的阻塞版。实测提问约 1 秒后放行,72 秒后的回答作为新的一轮送了进来。
这个绕法依赖 DSH 的内部接口,所以失败时只写日志、退回阻塞版,不影响任务。
发现四:令牌是模型自己要来的
同一份会话里,同一个 GitHub 令牌出现在 73 条命令(共 83 处)和 74 条模型回复里。往回找它第一次出现的地方,是模型用 ask_user_question 让用户把令牌贴进来,还承诺"会存到权限 0600 的本地文件里"。可令牌一旦作为回答进入对话,就已经在上下文里了:之后每次请求都会把它发给模型服务商,它也会永久留在会话记录里,模型再把它直接写进 curl -H "Authorization: …"。
插件 secret-guard 分三道防线:
- 拒绝向用户索要令牌、密码、API key 的提问,引导改用已经登录好的
gh,或者请用户自己运行 gh auth login。这一道最关键:在非阻塞提问下,用户迟到的回答是以插话送进来的,后面的打码管不到;
- 拒绝参数里带完整令牌的工具调用,以及
gh auth token;
- 工具结果交给模型之前,把其中格式明确的凭据替换成
[REDACTED:…]。
管不到的是用户直接在聊天框里粘贴的令牌:那不是工具结果。
发现五:别靠模型自己报告状态
task_status 的设计前提是模型会照做。数据说明它不会:
| 会话 | 调用 task_status 的轮次 | 备注 |
|---|
| 日常使用的长会话 | 0 / 9 | 工具和提示词都在系统提示里,模型照样不调 |
| 新会话 | 3 / 3 | 但每次都多一步:先调 task_status,再请求一次模型把答复重复一遍 |
| 新会话,加上"答复和声明写在同一条回复里" | 1 / 3 | 那一次确实省了一步 |
| 新会话,换成提示词统一的专用预设 | 2 / 3 | 两次都单独占一步 |
长会话里一次都不调,可能是因为它前面一百多轮都没有调用的先例,模型更倾向于沿用自己历史里的做法,而不是系统提示里的一句话。另外,官方提示词要求"给最终答复前收齐所有后台任务",和"任务还在跑时可以结束这一轮"互相矛盾。
试过的几种办法:
- 让
task_status 和最终答复写在同一条回复里时直接结束这一轮(DSH 的 concludeTurn),省掉重复答复的那一步。机制有效,但模型经常不这么写;而且 DSH 的聊天界面只把不带工具调用的最后一步当作回答,这样结束的一轮,答复会被收进折叠里,用户得点开才看得到。所以插件包带了一个聊天界面的小修复(chat-answer-fix),修复生效时才这样结束;
- 做一个专用预设,把两段矛盾的提示合成一段。提示改写生效了,模型的行为没有明显变化,而且预设只对新会话有效,所以没有放进插件包;
- 最后的做法是换个思路:状态由 runtime 按事实判断,不靠模型声明。一轮结束时还有本次请求启动的后台任务在跑,就是在等;有提问没回答,就是在等用户。硬保底和 dsh-task-notify 的完成通知都按这个判断。
task_status 降为可选,只在任务会跑超过 30 分钟、或者要标记服务时才需要。
验证:同样启动一个 60 秒的后台任务,模型没有调用 task_status,结束那一轮没有发通知,硬保底照常计时;任务结束、模型回复输出后,才收到一次"本轮已完成"。
发现六:沙箱里看不见的东西
一次真实任务里(主代理加 3 个子代理做批量检索),模型在命令里用 ps 找自己启动的后台扫描,没找到,就以为扫描已经退出,重新跑了一遍。旧的那个其实还活着,空转了将近 40 分钟,直到硬保底在最后一轮结束 30 分钟后把模型唤醒,模型才用 job_list 和 job_kill 两步处理掉。
原因在 DSH 的 Linux 沙箱:每条命令都单独起一个 bwrap,带 --unshare-pid 和 --tmpfs /tmp。所以:
- 命令里的
ps 只能看到这条命令自己,看不到任何后台任务;
- 上一条命令写进
/tmp 的文件,下一条就没了。翻了 90 个会话,有 25 个出现过这种报错,共 54 次。
模型更相信自己看到的 ps,而不是 runtime 的任务表。所以这次的思路是:把任务表里的事实放到模型眼前,并要求它对每个后台任务做出选择,有点像"写文件之前必须先读":
- 收尾清点:一轮结束时还有没认领的后台任务,1 秒后唤醒一次,列出任务 id、已运行时间和命令,要求逐个选择:在等、是服务,还是
job_kill;
- 任务事实:查进程、重复启动同一个脚本、没有新输出还在查时,在结果后面附上任务表里的事实;
- session-tmp:把每条命令的
--tmpfs /tmp 换成同一个对话专用的目录,子代理跟着父对话走。别的对话和宿主的 /tmp 仍然看不到。
ps 看不到后台任务这件事本身没有改:要让同一个对话的命令共用一个进程命名空间,需要改 DSH 核心(非特权的 bwrap 不能加入已有的 PID 命名空间),插件做不到。
验证(DSH 0.2.1-rc.5):模型执行 ps 后,回复里写的是"每个命令在独立沙箱中运行,看不到其他后台任务;任务表中该任务仍在运行";故意不表态就结束一轮,1 秒后被唤醒,回复一句话交代了任务的去向;子代理能读到父对话写进 /tmp 的文件。第一次实测还发现,附加给模型的上下文必须是一条完整的消息,传字符串会让这一轮报错中断,单元测试没有测出来。
顺带:时间到底花在哪
为了判断"一步只调一个工具"值不值得优化,用 4 个小任务(大量独立读取、发行前检查、先找后读、后台任务加并行工作)在 standard 和 ptc 两种预设下各跑一遍,共 8 次:
| 去处 | 占比 |
|---|
| 等模型开始出字 | 44% |
| 模型出字 | 52% |
| 工具执行 | 4% |
工具本身很快,时间几乎都在模型请求上。少一步只省一次"等开始出字",正常时约 2 秒,服务端慢的时候约 8 秒。所以只在提示词里加了一句"互不依赖的读取、检查、修改放进同一条回复并行调用",不再单独为它写机制。
DSH 自带的 ptc 预设(模型写脚本、在脚本里调工具)在这些任务上总步数从 17 步降到 15 步,但被占住的时间和输入 token 反而略多,因为脚本跑完模型还是要再请求一次去读结果。它适合大量重复查询、需要在中间过滤结果的任务,日常任务里看不出收益。插件的钩子在 ptc 下照样生效:脚本里调用的每个工具都会经过 DSH 的执行前后钩子。
还在看的问题
| 问题 | 数据 | 状态 |
|---|
| 等人审批时没有提醒,人不在就一直停着 | 58 分钟,最长一次 44.7 分钟 | 已在 dsh-task-notify(现为 dsh-sentinel)里加上"需要审批 / 需要你回答"的提醒 |
| 令牌进入上下文 | 1 个令牌,73 条命令、74 条回复 | secret-guard |
| 前台命令设了过长的超时 | 139 条前台命令超时设到 5 分钟以上,但真正跑超过 1 分钟的只有 2 条 | 一开始以为插件只能拒绝、不能改参数,拒绝 139 次多花的时间比省下的还多,所以没做;后来发现 tools/execute 阶段可以改参数,改成了静默封顶到 30 秒(到点转入后台),不多花一步 |
| 一步只调一个工具 | 87% 的步骤 | 只加了一句提示:每步省的只是一次请求延迟,收益小(见上一节) |
| 反复 tail 日志、重读同一文件 | 同一文档同轮最多重读 7 次 | 收益同上,暂缓 |
| 模型响应变慢 | 某天首字延迟中位数从 1.2 秒升到 8.3 秒,出字速度不变 | 服务端问题,插件能做的只有减少请求次数 |
| 回答输出一个词后不再出内容,界面一直停在"思考" | 服务端降级时一次连续 10 分钟,期间每 10 秒左右一个心跳 | 官方的流空闲超时把心跳当成活动,永远不触发,重试也就不启动。stream-stall 按"没有新内容"判断,断开后官方自动重试 |
| 审批会卡住整个 step | — | DSH 核心行为,插件做不到(#8916) |
| 插话打断不了正在运行的工具 | 插话排队最长 128 秒 | DSH 核心行为,插件做不到(#11005) |
目前包含的插件
| 插件 | 作用 |
|---|
wait-guard | 拦截前台长 sleep 和长时间阻塞等待;一轮结束时要求模型对还在跑的后台任务逐个表态,没回报时兜底唤醒;在结果后附上任务表里的事实(沙箱里 ps 看不到的后台任务、重复启动、无效轮询);可选的 task_status 工具 |
ask-nonblocking | 让 ask_user_question 问完不阻塞 |
secret-guard | 不让令牌进入模型的上下文 |
secret-files | 沙箱里的命令和文件工具都读不到凭据文件(云服务密钥、DSH 的 API key 等) |
session-tmp | Linux 上让同一个对话(包括子代理)的命令共用一个 /tmp,文件工具读写 /tmp 时看到的也是同一个 |
fs-view | edit 前文件没用 read 读过、或读后被 shell 改过时,按当前版本照常替换(原文对不上照样失败),省掉"读一遍再重试";read_image 按文件头认格式 |
chat-answer-fix | 带着工具调用结束的一轮,答复照常显示,不被收进折叠里(替换聊天界面里的一个判定) |
调优预设 tuned(默认关闭) | 一份固定的预设:去掉日常编码用不到的工具,加一段用好工具的规则(能一起拿的一起拿、能一步改完的一步改完),不限制思考,用来和 standard 对比把活干完的总耗时 |
net-log | 连不上模型服务时记下底层原因(DSH 自己的日志里只有一句 transport failed) |
stream-stall | 模型回答开始输出后只剩心跳、不再出内容时,断开这次请求,交给官方重试(官方的空闲超时会被心跳重置,永远等下去) |
安装、配置项、日志和卸载见 docs/configuration.md。
开发
npm test
测试只用 Node.js 自带的 node:test,不需要安装依赖。
许可证
MIT