从一份 9.7 小时的真实会话开始:用插件给 Agent Runtime 做调优的工程复盘
把一份 9.7 小时的 DeepSeek Harness 真实会话按事件拆解,在不修改 DSH 源码的前提下用插件做运行时调优,并复盘实验证伪过程、边界防御设计,以及从插件层倒推出来的 Agent Runtime 架构思考。
- 发布于
- 2026年10月29日
- 标签
- DeepSeek Harness · Agent Runtime · 插件 · 工程复盘
日常高频使用 Agent 工具(这里用的是 DeepSeek Harness,简称 DSH,版本 0.2.1-rc.4,运行在 Windows 11 + WSL2、Node.js 22.23.4 的 web profile 下)时,总会遇到一些让人烦躁的时刻:编译任务跑着跑着整轮对话被占住没法插话、AI 提了个问题就原地卡死、切去别的窗口忘了回来看审批结果,甚至不知不觉间把本地的 API 令牌带进了上下文里。
与其凭感觉调提示词,我决定把一份日常使用的真实会话日志导出来,按事件逐条拆解,看看时间到底花在了哪里、哪些消耗是白白浪费的。在此基础上,在不修改 DSH 源码的前提下,参考并借鉴了当前主流 Agent Harness 中我觉得比较优秀的设计思路,完全利用 DSH 的插件扩展点做了两个开源插件包——dsh-runtime-tuning(v0.1.0)和 dsh-task-notify(v0.0.4),并且每一项改动都用真实数据验证。作为一个长期的运行时调优实验,这两个插件包也会随着日常使用与新特性的探索持续迭代更新。
这篇文章不仅是对这两个插件的工程记录,更想重点复盘背后的实验证伪过程、边界防御设计,以及从插件层倒推出来的 Agent Runtime 架构思考。
一、起点账单:一份 9.7 小时的真实会话里,时间去哪了?
我导出的这份基准会话跨度 9.7 小时,包含 119 轮对话(turn)、2347 次工具调用,工作内容主要是反复发行一个桌面应用(跑测试、编译、归档、上传)。按事件逐条统计后,时间的去向如下:
| 时间去处 | 累计耗时 | 细节数据 |
|---|---|---|
| 模型生成 | 约 4.3 小时 | 每步(step)中位数约 6 秒 |
阻塞等待后台任务(job_output wait: true) |
约 100 分钟 | 共 152 次,单次最长 240 秒 |
提问后等人回答(ask_user_question) |
约 79 分钟 | 提问期间整个回合挂起,模型什么也做不了 |
| 等人审批(approval) | 约 58 分钟 | 共 24 次,单次最长 44.7 分钟 |
前台 sleep |
约 20 分钟 | 共 33 次(例如 sleep 90; echo waited) |
除了耗时分布,日志里还有三个非常扎眼的现象:
- 串行调用习惯:在所有包含工具调用的步骤里,87% 的步骤只调用了一个工具。每多拆一步,就意味着多一次重读上下文和请求生成的开销。
- 高频轮询:同一个后台任务最多被模型反复查询了 13 次。
- 凭据泄漏进上下文:同一个 GitHub 令牌出现在了 73 条命令(共 83 处) 和 74 条模型回复里。往回追溯源头,竟然是模型主动调
ask_user_question让用户把令牌贴进来的。
要在不改 DSH 源码的前提下修掉这些问题,我用到的全部是 DSH 暴露的插件扩展点:
| DSH 扩展点 | 插件中的用途 | 关键约束与特性 |
|---|---|---|
tools/pre-execute(exec, next) |
拦截长 sleep、长阻塞 job_output、索要凭据提问、明文令牌命令与 gh auth token |
只能返回 allow / deny / ask / cancel,不能修改参数 |
tools/post-execute(exec, result, next) |
工具结果交给模型前把凭据打码为 [REDACTED:…] |
配合 { prepend: true } 在洋葱模型最外层拦截模型最终看到的内容 |
session/event |
跟踪 turn、step、job/registered、job/settled、approval/*、提问事件 |
宿主事实来源(Source of Truth) |
ctx.tools.register + exec.concludeTurn() |
注册 task_status 工具;满足条件时直接结束当前轮次 |
省去调完收尾工具后再请求一次模型重复答复的额外 step |
ctx.systemPrompt.section |
往系统提示词注入规则段落 | 全局生效(但 minimal 预设的 complete persona 会替换所有段落) |
agent/created + agent.ctx |
在单个顶层 Agent 作用域内注册工具,遮蔽 preset 里的同名工具 | 用于 ask-nonblocking 绕过 preset 限制开启 timed 模式 |
二、wait-guard:当提示词管不住模型时,如何做执行前拦截与状态兜底?
在动手写代码前,我先读了一遍 DSH 的源码。有意思的是,DSH 的底层异步机制其实已经写得很完善了:
- 后台任务结束时,宿主会自动通知模型(模型忙时插进下一步,空闲时直接唤醒开启新的一轮);
- 前台命令如果超时,不会被粗暴杀掉,而是自动转入后台继续跑;
- 官方系统提示词里明明白白写着“不要轮询、不要 sleep”。
但问题是,系统提示词只是“建议”,模型并不总是照做。 在长流程里,它依然会习惯性地给 job_output 传 wait: true 配上 120 到 600 秒的超时,或者干脆在前台跑 sleep 90。等待期间,整轮对话被死死占住,用户想插话只能排队。
1. 执行前拦截:收益是“不占住对话”,而不是“更快”
第一个子插件 wait-guard 在 tools/pre-execute 钩子里把软规范变成了硬拦截:
- 解析
bash/pwsh非后台命令中的sleep(含s/m/h/d单位)和 PowerShellStart-Sleep(含-Seconds/-Milliseconds),超过 5 秒(maxSleepSeconds)直接deny; - 对顶层 Agent(
delegationDepth === 0)调用的job_output,当wait === true且超时时间(未传timeout_ms时按 DSH 默认值 30000ms 计算)超过 15 秒(maxBlockingWaitMs)时直接deny,并在拒绝理由中引导模型先做其他独立工作,或结束当前轮次等待完成通知; - 安全联动开关:如果配置了
wakeup: false,整个插件直接不生效——因为没有后续唤醒机制的配合,单方面拦截等待只会让任务彻底停住。
装上第一版后,跑同一个发行流程的实测对比:
| 指标 | 改动前 A | 改动前 B | 装插件后 |
|---|---|---|---|
| 对话被占住时长 | 22.1 分钟 | 19.6 分钟 | 4.6 分钟 |
| 阻塞等待时长 | 13.0 分钟 | 15.2 分钟 | 0.2 分钟 |
| 任务结束自动唤醒 | 0 次 | 0 次 | 3 次(均在任务结束同一秒触发) |
这里必须诚实地说明口径:wait-guard 的核心收益是“不占住对话”,而不是让编译本身“更快”。 这次总耗时变短主要是因为当次编译本身快;以前的阻塞等待在任务结束的那一秒也会立刻返回,并没有多等冤枉时间。真正的变化是:以前十几分钟编译期间对话是卡死的,现在把长等待拆成“后台运行 → 结束本轮 → 完成事件唤醒新一轮”后,中间十几分钟对话完全空闲,用户随时可以插话。
2. 通知没来怎么办?状态机与兜底唤醒的边界防御
让模型结束当前轮次去等后台通知,立刻会引出一个工程可靠性问题:如果后台任务死锁、卡死,完成通知永远不来怎么办?
为此,我给 wait-guard 增加了 task_status 工具和基于 session/event 的兜底计时器(createTracker)。模型在一轮结束前可以声明 done、waiting(附带 waiting_for 和预计耗时 expected_minutes,上限 maxExpectedMinutes: 240)或 asking。一旦声明了 waiting 的轮次正常结束,插件就按 Math.ceil(expected_minutes * backstopFactor)(默认 2 倍时间)设置定时器,到点后如果还没有开启新一轮,且 Agent 处于 idle 状态,就通过 agent.followup(...) 注入一条 [dsh-wait-guard backstop] 唤醒消息,让模型用 job_output (wait: false) 去检查任务。
在一个真实状态机的实现里,真正考验工程水平的不是“正常路径能跑通”,而是异常与边界条件下的防呆设计:
// wait-guard.mjs:状态机在 turn/end 与 user/message 时的关键边界处理
if (event.type === 'user/message' && event.data?.source?.kind === 'user') { s.wakes = 0; return }
if (event.type === 'job/registered') { if ((event.data?.owner ?? session.id) === session.id) s.jobs.add(event.data?.jobId); return }
if (event.type === 'job/settled') { s.jobs.delete(event.data?.jobId); s.services.delete(event.data?.jobId); return }
if (event.type !== 'turn/end') return
const reason = event.data?.reason?.kind
const declared = s.declared?.state === 'waiting' ? s.declared : undefined
const pending = pendingJobs(s)
if (declared === undefined && (c.undeclaredMinutes <= 0 || pending.length === 0)) return
// 边界 1:用户按停止(cancelled)或报错结束的一轮,绝不启动计时,防止用户刚叫停插件又把模型拉起来
if (reason !== 'completed') return note({ event: 'not-armed', session: session.id, reason })
这里有四处刻意设计的防御边界:
- 人的意图高于自动化(
reason === 'completed'):如果用户手动点击了“停止”中断本轮,或者本轮因异常终止,turn/end的reason.kind不是completed,插件绝不计时——用户叫停的事,插件不该自作主张再拉起来。 - 防无人值守死循环(
maxBackstopWakes: 2):连续兜底唤醒最多 2 次。只有当检测到user/message且source.kind === 'user'(即真人发消息,而非后台任务通知或系统插话)时,才把s.wakes重置为0。 - 测试解耦与进程退出安全:
createTracker把setTimer、clearTimer、getAgent全部设计为依赖注入参数,不仅让 0 外部依赖的node:test可以用假时钟毫秒级跑完所有异步分支,而且在真实注入时调用了t.unref?.(),确保悬空的保底定时器不会阻止 Node.js 进程正常退出。 - 30 分钟硬保底与
services豁免:后来在一次真实发行里发现,模型把编译丢进后台就直接结束了本轮,根本没调task_status。于是我加了一道硬保底:只要turn/end时s.jobs里还有未 settle 的当前会话后台任务(排除模型在services数组里显式标记为常驻开发服务器/watcher 的任务),即便declared === undefined,也按默认 30 分钟(undeclaredMinutes: 30)启动保底计时;如果在到点前所有后台任务已经完成(pending.length === 0),触发时自动跳过(backstop-skipped)。
验证范围说明:显式声明的兜底唤醒已在真实环境中实测通过(一个
sleep 300的任务,模型预计 1 分钟,插件在第 2 分钟准时唤醒;模型看了一眼输出后改报预计 3 分钟;第 5 分钟任务正常结束唤醒模型,兜底计时自动取消)。30 分钟硬保底在真实发行中已验证自动触发计时(armed, undeclared: true),其“到点触发唤醒”分支目前由单元测试完整覆盖。
三、打通交互与安全边界:作用域遮蔽、跨插件时序漏洞与洋葱模型
解决了后台任务阻塞后,9.7 小时会话里剩下的大头——提问等待(79 分钟)、令牌泄漏(73 条命令 / 74 条回复)以及审批等待(58 分钟),在代码层面暴露出了更有意思的技术细节。
1. ask-nonblocking:利用 Cordis 作用域遮蔽绕过 Preset 硬编码
DSH 默认的 ask_user_question 是阻塞的:问题发出去之后,在用户回答之前模型什么也做不了,哪怕手头还有其他不依赖该回答的独立工作。
排查源码后发现,DSH 官方的 @deepseek-ai/dsh-tool-ask-user 模块其实已经实现了非阻塞的 timed 模式(超时返回 { pending: true },用户的迟到回答作为 user-question-reply 插话送入),但这个工具被直接嵌在各个 agent preset 的 config.plugins 内部,用户在外部 cordis.patch.yml 里写的配置补丁根本够不到它(复现细节见 DSH 讨论 #8547)。
怎么在不改源码、也不复制粘贴官方工具代码的前提下把 timed 模式打开?ask-nonblocking 用了一个“借力打力”的做法:
// ask-nonblocking.mjs:从宿主 pnpm 仓库定位同一份官方模块,在顶层 agent 作用域内遮蔽注册
export async function findOfficialAskUser(entryScript, read = readdir, resolve = realpath) {
const bin = await resolve(entryScript)
const at = bin.indexOf('/.pnpm/')
if (at < 0) throw new Error('host is not a pnpm install; cannot locate dsh-tool-ask-user')
const store = bin.slice(0, at + '/.pnpm'.length)
const dirs = (await read(store)).filter((n) => n.startsWith('@deepseek-ai+dsh-tool-ask-user@'))
if (dirs.length !== 1) throw new Error(`expected one dsh-tool-ask-user in the host store, found ${dirs.length}`)
return join(store, dirs[0], 'node_modules/@deepseek-ai/dsh-tool-ask-user/lib/index.js')
}
ctx.on('agent/created', async ({ agent }) => {
if (!isTopLevelAgent(agent)) return // 子代理不直接面对用户
try {
agent.ctx.plugin(await load(), { mode: 'timed', timeout: c.timeout })
} catch (error) {
official = undefined
record({ event: 'failed', session: agent.id, error: String(error).slice(0, 300) })
}
})
这里有两个关键技术点:
- 作用域遮蔽(Scope Shadowing):监听
agent/created,对delegationDepth === 0的顶层 Agent,直接在其专属的子上下文agent.ctx上用{ mode: 'timed', timeout: 1 }重新加载宿主的同一份官方插件。子作用域注册的同名工具会自然遮蔽掉父级 preset 里的阻塞版。 - 对内部接口的防御性降级:通过
process.argv[1]经realpath解析/.pnpm/目录属于依赖宿主内部结构的 trick。因此外层包了严密的try/catch:一旦将来 DSH 升级改变了打包方式,插件只会写一条failed诊断日志并静默退回原有的阻塞版提问,绝不让 Agent 崩溃。
实测效果:提问发出约 1 秒后即返回 pending 放行;72 秒后我提交的回答作为新的一轮顺利送进对话。
2. secret-guard:非阻塞提问带来的跨模块漏洞与 { prepend: true } 洋葱打码
在那份 9.7 小时会话里,同一个 GitHub 令牌之所以出现在 73 条命令(共 83 处) 和 74 条模型回复里,源头是模型用 ask_user_question 让用户把令牌贴进对话,还承诺“会存到权限 0600 的本地文件里”。可令牌一旦作为回答进入对话,就已经在上下文里了:之后每次请求都会发给模型服务商,也会永久留在会话记录里,模型再把它直接写进 curl -H "Authorization: …"。
如果只是防工具读取本地文件泄漏令牌,在 tools/post-execute 里做正则打码似乎就够了。但一旦和上面的 ask-nonblocking 组合在一起,就会出现一个隐蔽的架构漏洞:
在 timed 非阻塞提问模式下,ask_user_question 在 1 秒后就返回了 { pending: true }(此时工具执行已经结束,走完了 post-execute);用户 72 秒后粘贴的令牌,是通过会话层的 user-question-reply 插话事件直接进入上下文的,根本不经过 tools/post-execute!
看清这层跨插件的时序关系后,secret-guard 的三道防线就缺一不可了:
- 第一道(
pre-execute拒问,最关键):在ask_user_question发出前,用CREDENTIAL×SOLICIT双重正则检测问题文本是否在向用户索要令牌、密码或 API Key。一旦命中直接deny,引导模型改用已认证的ghCLI 或请用户自己运行gh auth login——把漏洞堵死在提问发出之前。 - 第二道(
pre-execute拒调):拒绝任何参数中包含明确格式凭据(github_pat_、gh[pousr]_、sk-)的工具调用,同时拦截试图提取明文令牌的gh auth token命令(提示改用gh api)。 - 第三道(
post-execute+{ prepend: true }最终视图打码):
// secret-guard.mjs:使用 { prepend: true } 站在洋葱模型最外层
if (c.redact) ctx.on('tools/post-execute', async (exec, result, next) => {
const decision = await next()
const r = redactDecision(decision, result)
if (r === undefined) return decision
record({ event: 'redacted', tool: exec.name, count: r.count, session: exec.agent?.session?.id })
return r.decision
}, { prepend: true })
为什么这里必须加 { prepend: true } 并且在 await next() 之后才对 decision 打码?
因为在 DSH 的钩子调度链(洋葱模型)里,工具原始输出可能很大,DSH 内置的 spill 等处理会在 post-execute 阶段把超长输出落盘并替换成一段截断预览。{ prepend: true } 让 secret-guard 最先进入中间件链、最后拿到下游处理完的返回值 decision(同时覆盖 decision.kind === 'accept' 的 content 和 decision.kind === 'block' 的 feedback)。这样打码的才是模型真正看到的最终文本。同时,诊断日志只记录 { event, tool, count, session },绝不记录凭据原文。
实测读取一个带假令牌的 hosts.yml 文件,工具结果被替换为 [REDACTED:github-token],会话记录中原文出现 0 次。(唯一管不到的是用户不经提问、主动在聊天框里粘贴令牌。)
3. dsh-task-notify:去抖、跨轮次任务追踪与宽限期兜底
当后台任务和提问都不再阻塞对话后,人就可以切去别的窗口做事,靠 dsh-task-notify 在关键时刻把人叫回来(优先走浏览器 Web 通知,失败或页面关闭时在 WSL2 下自动通过 detached + unref() 的子进程调用 Windows PowerShell 5.1 / pwsh + BurntToast 发送原生系统通知,并提供可选的 Windows 托盘图标)。
为了让通知“既不漏报、也不瞎报”,里面处理了三个很细的状态机问题:
- 5 秒去抖(
ATTENTION_DELAY_MS = 5000):监听approval/asked与ask_user_question的tool/call时,不立刻发“需要审批 / 需要你回答”通知,而是挂一个 5 秒定时器。如果用户在页面前 5 秒内就点了审批(approval/decided)或回了问题(非pending的tool/result或user-question-reply),定时器直接取消,绝不弹多余的通知。 - 只追踪“本次请求”启动的后台任务:在
background-jobs.mjs中,每当收到真人发来的新消息(user/message且source.kind === 'user'),都会清空当前追踪集合s.jobs.clear()。这样,更早轮次留下的后台进程不会把后续无关轮次的完成通知静音掉;同时识别task_status传入的services列表予以豁免。 - 10 秒静默宽限期(
SETTLE_GRACE_MS = 10_000):如果某一轮结束时因为还有后台任务在跑而静默跳过通知(markQuiet),正常情况下任务结束会唤醒模型开启新一轮,由新一轮结束时发通知。但万一最后一个任务job/settled时,DSH 没有开启新一轮(比如唤醒预算耗尽),插件会在!s.inTurn && !running(s)时启动 10 秒宽限期定时器:10 秒内没开新一轮,就直接补发一次“本轮已完成”通知,确保任务结果不会石沉大海。
四、最值得复盘的实验:为什么“靠模型自己报状态”走不通?
如果说前面几个模块是把想法落地,那么围绕 task_status 依从性的一系列实验,则是这次收获最大的一次**“假设 → 实测证伪 → 推翻重来”**。
最初我的假设很简单:既然有了 task_status 工具,也在系统提示词里写明了“每轮结束前必须调用”,模型自然会乖乖汇报 done / waiting / asking。然而真实跑下来的数据完全不是这么回事:
| 测试会话与实验条件 | 调用 task_status 的轮次(注明样本量) |
实测现象与额外代价 |
|---|---|---|
| 日常使用的长会话(已有 100+ 轮历史) | 0 / 9 | 工具定义和提示词都在系统提示里,模型一次都没调 |
| 全新会话(默认提示词) | 3 / 3 | 每次都调了,但每次都多花一步:先调工具,再请求一次模型把答复重复一遍 |
| 全新会话 + 要求“最终答复与声明写在同一条回复里” | 1 / 3 | 只有 1 次写在同一条回复里触发了 concludeTurn 省下一步,后 2 次又没声明 |
| 全新会话 + 合并改写官方提示词的专用预设 | 2 / 3 | 消除了提示词冲突,但 2 次调用依然各自单独占了一步(每轮仍需 3 步) |
围绕这组数据,我依次做了三步排查与尝试:
第一步:用 exec.concludeTurn() 解决“多花一步”的问题
在新会话里,模型调完 task_status 后之所以还要多请求一次,是因为工具返回后默认会进下一个 step 让模型继续说话。于是我在 wait-guard 里加了 canConclude 判断,满足条件时直接调用 DSH 的 exec.concludeTurn() 立即终止当前轮次:
// wait-guard.mjs:只有当这条回复既有给用户看的文字、且 task_status 是唯一工具调用时,才直接结束本轮
canConclude(id, callId) {
const reply = sessions.get(id)?.reply
return reply !== undefined && reply.hasText && reply.calls.length === 1 && reply.calls[0] === callId
}
注意这里的两个严苛条件:
- 必须
reply.hasText === true:如果模型只调了task_status没写正文,直接concludeTurn()会导致用户看不到最终答复; - 必须
reply.calls.length === 1:如果模型在同一条回复里并发调用了其他工具(比如一边bash一边task_status),直接结束本轮会导致模型看不到另一个工具的执行结果。
这个机制本身是有效的(实测命中的那 1 次确实把步数从 3 步压到了 2 步),但模型只有 1 / 3 的概率把文字和工具写在同一条回复里。
第二步:用专用预设消融验证“是不是提示词冲突”
为什么在 100+ 轮的长会话里调用率是 0 / 9?我注意到 DSH 官方 TOOL_JOBS 提示词里有一句“给最终答复前收齐所有后台任务结果”,和插件要求的“任务还在跑时可以先结束本轮并声明 waiting”互相矛盾。
为了验证是不是这句冲突导致的,我利用 system-prompt/assemble(作用域 waterfall 钩子)专门写了一个实验性预设,把官方那句删掉、合成一段完全统一的提示词。结果在新会话里测试(样本量 3 轮),虽然改写成功生效,模型也只调了 2 / 3 次,且两次全都单独占了一步。更致命的是,DSH 出于会话一致性设计,锁定了已开始会话的预设(agent-preset/locked,因为预设决定了工具集与系统提示词,中途切换会破坏上下文契约),这意味着专用预设对现有的长会话完全无效。因此,我果断把这段预设实验代码从主分支 revert 掉了。
第三步:真正的根因与架构转向——状态由 Runtime 按事实判断
复盘长会话 0 / 9 和新会话 3 / 3 的巨大反差,真正的根因其实在模型的注意力机制本身:在积累了一百多轮的上下文里,“前 100 多轮从来没调过 task_status”的 Few-shot 历史惯性,远远压倒了 System Prompt 里的一段话。
想通这一点后,解法瞬间清晰了:别靠模型自己报告状态,状态应该由 Runtime 按客观事实判断。
- 一轮结束时,只要本次请求启动的后台任务还没 settle,客观事实就是**“在等后台任务”**;
- 只要发出了提问还没收到回答,客观事实就是**“在等用户回答”**。
把硬保底计时和 dsh-task-notify 全部切换到基于 session/event 的事实判断后,task_status 被降级为纯可选工具——系统提示词也随之精简为:只在后台任务预计超过 30 分钟,或者需要把常驻开发服务器标记进 services 时才需要调用。
实测验证:启动一个 60 秒的后台任务,模型全程没有调用 task_status 就结束了本轮;插件准确识别出后台任务未结束,没有误发完成通知,硬保底正常开始计时;60 秒后任务完成唤醒模型,模型输出最终结果后,才发出了唯一一次“本轮已完成”通知。
五、算清干预的 ROI:时间到底花在哪?为什么有些优化我决定“不做”?
工程调优不仅要知道“做什么”,更要靠数据算清楚**“什么不值得做”**。
1. 拆解 87% 的“单工具步”与 PTC 模式实测
针对开头发现的“87% 有工具调用的步骤只调了一个工具”,到底值不值得专门写机制去强制合并?或者干脆切到 DSH 自带的 ptc(Programmatic Tool Calling,模型写 JS 脚本在沙箱里批量调工具)预设?
我设计了 4 个典型小任务(大量独立读取、发行前检查、先搜索后读取、后台任务加并行工作),在 standard 和 ptc 两种预设下各跑一遍(共 8 次实验),耗时分布如下:
| 耗时去处 | 占比 | 实测细节 |
|---|---|---|
| 等模型开始出字(TTFT 首字延迟) | 44% | 正常中位数约 1.2 秒;某天服务端高峰期升至 8.3 秒 |
| 模型生成出字 | 52% | 出字速度稳定在 161 → 164 token/秒 |
| 本地工具执行 | 4% | DSH 本地框架调度开销仅约 16 毫秒 |
同时对比 standard 与 ptc 在这组任务上的总账:
- 总步数:
ptc从 17 步降到了 15 步; - 占用总时长:
ptc反而从 74 秒微增到 77 秒; - 输入 Token:
ptc从 192k 涨到了 212k。
原因很直观:在日常小任务上,本地工具执行本来就只占 4%,ptc 让模型写一段脚本虽然合并了几次调用,但脚本跑完后模型依然要再发一次请求去读脚本输出,多出来的代码生成量和上下文体积反而抵消了省掉的步数。(顺带验证了一个重要事实:在 ptc 模式下,run_code 脚本里调用的每一个工具依然会经过 DSH 调度器的 pre/post-execute,因此前面的插件钩子在 ptc 下依然 100% 生效。)
既然每少一步本质上只省一次 TTFT 首字延迟,为它做复杂的拦截完全得不偿失,最终我只在系统提示词里顺手加了一句“把互不依赖的读取、检查、修改放进同一条回复并行调用”。
2. 为什么放弃“限制前台命令超时上限”?
在 9.7 小时会话里,模型有 139 条前台命令把超时参数设到了 5 分钟以上,乍一看非常危险。但我拉出这 139 条命令的真实执行时长后发现:真正跑超过 1 分钟的只有 2 条!
结合 DSH 的扩展点约束——tools/pre-execute 只能返回 deny 拒绝调用,不能就地修改参数——这笔账一下子就算清楚了:
如果插件设一个超时上限去 deny 这 139 次调用,模型就必须收到报错、重读上下文、改小参数再重发 139 次。按每次请求好几秒计算,拒绝 139 次多花的时间,比拦截那 2 次真正慢命令所省下的时间还要多得多。识别出这种“负 ROI 优化”并果断放弃,比盲目加规则重要得多。
六、延伸讨论:从插件实践倒推 Agent Runtime 的设计准则
跳出 DSH 具体插件的实现,这一轮实测数据与踩坑经历,其实指向了 Agent Harness / Runtime 架构设计中五个具有普适性的核心问题:
1. Runtime“按事实判断”与“让模型声明”的边界在哪?
最核心的准则是:凡是 Runtime 自己能通过进程表、事件流、状态机确切知道的客观事实,绝不要做成工具让模型去汇报。
后台有没有未退出的子任务(job/registered vs job/settled)、当前有没有未回答的提问,宿主一清二楚。让模型去报,不仅在长会话里会因为历史惯性漏报(0 / 9),在新会话里还会多耗一步推理 step(3 / 3 多一步)。真正应该留给模型通过工具(如 task_status)声明的,只有那些仅存在于模型语义理解中、Runtime 无法从外部推算的主观意图——比如“这个任务预计要跑 90 分钟(expected_minutes),别按默认 30 分钟催我”,或者“这个后台进程是我故意留着的开发服务器(services),不用等它退出”。
2. 在“只拒不改”的钩子模型下,什么干预才值得做?
出于可审计性与上下文一致性考虑,许多 Agent 框架的 pre-execute 钩子只允许裁决(allow / deny),不允许插件静默篡改模型传入的参数(否则模型以为自己传了 A,实际执行了 B,后续推理会错乱)。但这也意味着每一次 deny 的固定成本就是一整轮额外的模型请求(TTFT + 生成耗时 + 输入 Token)。因此,只有当“放行导致的阻塞/安全代价”远大于“一次重试请求的代价”(如前台长 sleep、长阻塞等待、明文凭据泄漏)时,用 Hook 拦截才是正收益;对低触发率的防御性参数毛病(如 139 次长超时里仅 2 次真慢),用 deny 纠正就是典型的负优化。
3. 长会话的“历史惯性”对提示词评估意味着什么?
很多开发者验证新 Prompt 或新工具时,习惯开一个干净的新会话跑两三轮,看到 3 / 3 成功就认为上线没问题了。但这次长会话 0 / 9 与新会话 3 / 3 的鲜明对比证明:在长周期工程会话里,上百轮历史轨迹构成的隐式 Few-shot 先例,对模型行为的支配力远远强于 System Prompt 里的规则声明。 任何面向生产环境的 Agent 机制,如果只在新会话里做基准测试,结论大概率是失真的;而在长会话里推不动的规则,最终必须下沉到 Runtime 事件或硬钩子层解决。
4. 插件层的天花板:Runtime 核心调度器应该如何演进?
不改源码只写插件能解决很多问题,但也清晰地撞到了宿主核心架构的两块天花板:
- 审批会卡住整个 step(DSH 讨论 #8916):当模型在同一条回复里并发发出多个工具调用时,只要其中一个需要人工审批,整个 step 都会停住,其他不需要审批的只读工具也得陪着干等;
- 用户插话打断不了正在运行的工具(DSH 讨论 #11005):在 9.7 小时会话里,用户的插话最长排队等了 128 秒,必须等当前工具执行完毕才能在下一步送入。
这两点插件层无法绕过,也指明了 Agent Runtime 核心调度器的设计方向:工具执行不应是粗粒度的“整步阻塞同步环”,而应做成按工具调用粒度(per-call)独立调度的异步图——不需要审批的工具先行并发执行,需要审批的节点单独挂起;同时为工具执行上下文提供分级中断信号(软插话排入下一轮、紧急纠正直接触发 AbortSignal 抢占当前工具)。
5. 与其他 Harness 的横向对比:并行调用 vs. Code Mode
放眼整个社区对工具调用效率的探索,目前主要有两条技术路线:
- 原生并行工具调用:例如 OpenAI 的
parallel_tool_calls参数、Claude Code 在提示词中对并行只读/检查操作的强引导,核心是在单次模型输出里打包多个独立动作,直接省掉中间轮次的 TTFT(占总耗时的 44%); - 程序化工具调用 / Code Mode:从学术界的 CodeAct 论文,到 Anthropic 的 PTC、Cloudflare 的 Code Mode,以及 DSH 内置的
ptc预设,让模型写代码在沙箱里用循环和条件分支编排工具。从我的实测(步数17 → 15,但耗时74s → 77s、输入 Token192k → 212k)可以看出,Code Mode 并非放之四海皆准:它最擅长需要在沙箱内部做大规模循环查询或中间结果过滤的重度任务;而在日常轻量级开发任务中,二次读取脚本结果的额外请求反而会吃掉步数减少的收益。
这套插件吸收和借鉴了不同 Agent Harness 中我觉得比较优秀的工程设计(如事件驱动唤醒、非阻塞交互、上下文凭据脱敏与并行工具引导),并且会随着后续真实会话的分析持续迭代更新。本文涉及的两个插件均已开源,包含完整的实测记录与零外部依赖的 node:test 单元测试:
- dsh-runtime-tuning (
v0.1.0):https://github.com/herenfor/dsh-runtime-tuning - dsh-task-notify (
v0.0.4):https://github.com/herenfor/dsh-task-notify