专题篇章 · 拆开 DeepSeek Harness

工具执行流水线:三段瀑布与单调 Guard

pre-execute 到 post-execute 的三段管线,Guard 只能收紧不能放行

本页解决的问题

先给结论

「工具执行流水线:三段瀑布与单调 Guard」要解决的关键问题是什么?

pre-execute 到 post-execute 的三段管线,Guard 只能收紧不能放行

判断标准

让这个结论先证明自己值得留下。 把这一页当成决策工具,而不是需要背下来的定义。把概念连到一个真实任务、一个可观察结果,以及一个能改变你判断的失败上。

下一步

写下一个问题:试完这个方法后,你能用什么证据回答它?

常见误区

结论听起来很完整,却没有检查最关键的假设。

课程目标读完你能说清三件事:一次工具调用在 DSH 里要过哪三段瀑布,每段各管什么;Guard 为什么在类型上就没有放行这个选项,插件顺序怎么排都翻不了案;被拒绝的调用不会消失,它会物化成一条模型看得见的错误结果,继续走完流水线。
交互演示 · 流水线闯关
bash: rm -rf build/
第 1 段 · pre-execute 瀑布
进门前表态:allow / deny / ask,监听器可重排
Guard 层 · 单调守卫
只能给拒绝理由或弃权,类型上没有放行
第 2 段 · execute 瀑布
围着执行包一层:超时、重试、指标
第 3 段 · post-execute 瀑布
出门前改写:accept / block,换内容或换值
选择情景后点「播放」,或滚动到此处自动播放情景 A。
演示为教学化模拟:监听器与守卫的名字是课程化举例,段与段的顺序、Guard 的单调语义对应 packages/core/tools/src/index.tsdocs/tool-execution-pipeline.zh.md。玩的时候盯住一件事:只要有一个环节给出拒绝理由,后面谁也翻不了案。
机制拆解 · 三段各管什么

先说清问题。权限检查、人工审批、超时、结果改写、UI 渲染,全都想挂进工具执行这一个动作里。如果让每个工具自己处理,40 个工具就有 40 份权限代码。DSH 的做法是把工具执行做成一条流水线,策略全部住在流水线的固定工位上,工具本体只做一件事:执行并返回值。

流水线的顺序写在 docs/tool-execution-pipeline.zh.md 第 8 行:tools/pre-execute 先跑,随后是单调守卫,然后是 tools/executetools/post-execute。瀑布(waterfall)是 DSH 的监听器排队模式:每个监听器拿到 (exec, next),可以调 next() 把决定权交给下一位,也可以直接返回一个决定当场定案。

三段的分工很清楚。第 1 段 pre-execute 在工具跑之前表态,返回值只有三种:allow 放行、deny 拒绝、ask 转人工审批。ask 只有拿到审批服务的 allowed-once 才继续,没接审批通道就当 deny 处理。第 2 段 execute 是环绕式包装,超时策略、重试、指标都在这里给真正的执行包一层,它能替换取消信号但动不了调用身份。第 3 段 post-execute 在结果出来之后检查:原样接受、换掉内容、换掉值,或者 block 把结果改写成一条纠正性错误。

拒绝不是沉默

被 deny 的调用会物化成 Error: 理由 的 isError 结果,而且照样走 post-execute 和 tools/result。模型能看到自己为什么被拒,循环不会因为一次拒绝卡死。

参数改不了

pre-execute 可以否决但不能改写参数。因为 tool/call 事件在执行前就落了日志,UI 的待执行卡片也已经按原参数渲染,改参数会让历史、界面、执行三方对不上(index.ts 第 583 至 586 行的类型注释写明了这条排除)。

Guard 是同步终审

Guard 在 pre-execute 全部表态之后、工具本体之前跑,签名是同步函数:返回字符串就是拒绝理由,返回 undefined 就是弃权。全局 Guard 先问,再沿 agent 的作用域链从远到近问(index.ts 第 1118 至 1127 行)。

核心视觉 · 一次调用的完整路径
tool/call 落日志 UI 同步渲染待执行卡 pre-execute 瀑布 allow / deny / ask ctx.approval 审批 仅 allowed-once 继续 单调 Guard deny 或弃权,无 allow execute 瀑布 超时 / 重试 / 工具本体 post-execute accept / block deny 物化为 Error 结果 跳过工具本体,仍走 post-execute finalizeContent 后 tools/result 冻结定稿
教学化结构图:路径对应 docs/tool-execution-pipeline.zh.md 的官方流程图,节点文案经过课程化整理。
Guard 的单调性 · 为什么类型里没有 allow

先看边界问题:两个 pre-execute 监听器,一个想 allow 一个想 ask,最终听谁的?答案是排在前面的那个。瀑布是短路的,第一个不调 next() 直接返回决定的监听器就定了案。所以 pre-execute 天然顺序敏感,插件加载顺序一变,安全结论就可能跟着变。

DSH 的解法是在 pre-execute 后面加一层顺序不敏感的终审。Guard 的返回类型只有两种:一个字符串(拒绝理由),或者 undefined(弃权)。没有任何返回值能表达同意。这样一来,注册十个 Guard 还是一百个,随便怎么排,结论只可能更严不可能更松。类型定义就是证据:

packages/core/tools/src/index.ts第 703 至 711 行
/**
 * A monotonic execution guard evaluated after every `tools/pre-execute`
 * listener and before the tool body. Returning a reason denies the call;
 * returning `undefined` leaves it unchanged. Because guards have no allow
 * result, listener ordering cannot turn a denial back into permission.
 * @param execution - the identity-protected call after extensible pre-execute policy completed.
 * @returns a final denial reason, or `undefined` to leave the call allowed.
 */
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
源码快照说明:依据本地仓库 deepseek-harness-master,核对文件 packages/core/tools/src/index.ts,核对日期 2026-08-13。代码块保留源码原文。

注释里那句原话值得抄下来:「guards have no allow result, listener ordering cannot turn a denial back into permission」。恶意插件想放行一个被拒的调用,不需要防,因为它在类型系统里就写不出这个动作。这比在运行时检查放行权限干净得多,问题类别直接被消灭了。

再看拒绝之后发生什么。调度器的定案逻辑分两步:只有 pre-execute 的决定是 allow(含审批通过的 ask),才轮到 Guard 逐个表态;pre-execute 的拒绝理由和 Guard 的拒绝理由汇到同一个变量里,任何一方给出理由,调用就地物化成一条 Error: 理由 的错误结果。工具本体连碰都不碰,但这个结果带着 post-result 标记继续交给 post-execute 和最终观察者。

所以审计插件、上下文注入插件在拒绝场景下照常工作,拒绝对流水线的其余部分只是一种普通结果。工具抛异常、找不到工具(UNKNOWN_TOOL)也走同样的归一化路径。

出处:定案与物化在 packages/core/tools/src/index.ts 第 1486 至 1499 行,异常与 UNKNOWN_TOOL 的归一化在第 1546 至 1555 行,核对日期 2026-08-13。

横向对比 · 同一个位置,三家三种答案

Claude Code 把权限判断分散在 Tool 接口的方法上:每个工具自带 checkPermissionsvalidateInputisReadOnly,BashTool 还要再串白名单和 ML 分类器(书稿 study/chapters/02-tool-system.md 第 350 至 376 行)。外挂扩展走 PreToolUse / PostToolUse hooks。有意思的是 DSH 自己实现了一个 CC hooks 桥接插件 packages/hooks/hooks-claude-code,把 CC 的 hook 挂到 DSH 的瀑布上跑,桥接文档顺手暴露了两个协议差异:

packages/hooks/hooks-claude-code/README.zh.md · 第 92 行(已知限制)
PreToolUse 只支持部分功能:denyask 决策可用;allow 不会预审批,不支持 deferadditionalContext 会被忽略,updatedInput 会被记录 + 警告但不应用」

这两条限制另有原因:流水线的不变式在挡路。CC 原生 hook 可以 allow 预审批、可以用 updatedInput 改写工具参数;DSH 的桥接把前者降级、把后者只记日志不执行,因为放行权在 DSH 里不外借,参数在 tool/call 落日志之后不可变。同一份 CC hook 配置,换个宿主,能做的事就变少了,这正好量出了两套协议的表达力边界。另外多个 CC hook 在桥接里按最严格方式折叠(deny 优先于 ask 优先于 allow),折叠结果与顺序无关(README 第 49 行),和 Guard 的单调思路一脉相承。

Grok Build 的 hooks 系统(crates/codegen/xai-grok-hooks)只有 pre_tool_use 一个点能拦截,决策类型是 Allow 或 Deny 两个值(src/result.rs 第 5 至 10 行),而且模块注释直接写明了失败语义:

grok-build-main/crates/codegen/xai-grok-hooks/src/lib.rs · 第 16 至 17 行
「- pre_tool_use hooks can deny/allow (blocking); all others are non-blocking
- Fail-open by default: hook failures do not block normal operation」

Fail-open 的意思是 hook 自己崩了、超时了,调用照常放行。DSH 反过来:pre-execute 监听器抛异常,这次调用直接归一化成错误结果,宁可错杀。两种取向都讲得通,Grok 把 hooks 当外挂增强,不让用户脚本拖垮主流程;DSH 把策略当流水线的正式工位,工位塌了调用就不该过。Grok 工具系统的注册表与只读语义,站内 ToolKind 提供默认只读语义 一课有完整拆解。

课堂练习
01

手推一次 rm -rf 的完整路径

部署里注册了两个 pre-execute 监听器(先 CC hooks 桥接,配置了一条 ask 规则;后一个白名单插件,对 rm 直接返回 allow)和一个沙箱 Guard(对写出工作区的命令返回理由)。模型发起 bash: rm -rf /tmp/x。第一问:审批弹窗会不会出现?第二问:把两个 pre-execute 监听器对调注册顺序,答案变不变?第三问:沙箱 Guard 的结论受这个顺序影响吗?为什么?(提示:瀑布短路 + 第 1486 行的 denialReason 只在 allow 之后才问 Guard。)

Takeaway:三段瀑布各管一段:进门前表态、围着执行包一层、出门前改写结果。顺序敏感的扩展放瀑布里,顺序不敏感的否决权交给 Guard,Guard 的类型里没有 allow,拒绝一旦成立谁也翻不了案。拒绝也是一等结果:物化成 Error 文本给模型,流水线照常走完。

「交互演示 · 流水线闯关」的能力藏在每次交接里

「先说清问题。权限检查、人工审批、超时、结果改写、UI 渲染,全都想挂进工具执行这一个动作里。如果让每个工具自己处理,40 个工具就有 40 份权限代码。DSH 的做法是把工具执行做成一条流水线,策略全部住在流水线的固定工位上,工具本体只做一件事:执行并返回值」说明,Agent 的表现不只由模型决定。模型、上下文、工具、状态、权限和人之间的每次交接,都会改变任务能否继续以及出了问题能否恢复。

先写清状态,再增加能力

从「流水线的顺序写在 docs/tool-execution-pipeline.zh.md 第 8 行: tools/pre-execute 先跑,随后是单调守卫,然后是 tools/execute 和 tools/post-execute 。瀑布(waterfall)是 DSH 的监听器排队模式:每个监听器拿到 (exec, next) ,可以调 next()…」出发,可以把流程拆成起始状态、下一步动作、工具返回、状态更新和停止条件。这样调试时找的是第一处丢失信息或权限的位置,而不是笼统地说“模型变笨了”。

成功路径不能代表系统可靠

用「部署里注册了两个 pre-execute 监听器(先 CC hooks 桥接,配置了一条 ask 规则;后一个白名单插件,对 rm 直接返回 allow)和一个沙箱 Guard(对写出工作区的命令返回理由)。模型发起 bash: rm -rf /tmp/x 。第一问:审批弹窗会不会出现?第二问:把两个 pre-execute 监听器对调注册顺序,答案变不变?…」重放一次成功和一次失败,记录每一轮真正传入的上下文、工具结果和负责人;只要第二个人能复述这条链,系统才有可维护性。

从「交互演示 · 流水线闯关」走到「机制拆解 · 三段各管什么」

「交互演示 · 流水线闯关」先把问题落在「情景 A · 一路放行 情景 B · Guard 拦下 情景 C · 恶意放行插件 播放 单步 重置 bash: rm -rf build/ 第 1 段 · pre-execute 瀑布 进门前表态:allow / deny / ask,监听器可重排 Guard 层 · 单调守卫 只能给拒绝理由或弃权,类型上没有放行 第 2 段 · execute 瀑布 围着执行包一层:超时、重试、指标 第 3 段 · post-e…」上;到了「机制拆解 · 三段各管什么」,讨论继续推进到「先说清问题。权限检查、人工审批、超时、结果改写、UI 渲染,全都想挂进工具执行这一个动作里。如果让每个工具自己处理,40 个工具就有 40 份权限代码。DSH 的做法是把工具执行做成一条流水线,策略全部住在流水线的固定工位上,工具本体只做一件事:执行并返回值」。两段连起来,重点就不只是记住一个结论,而是看清它成立所依赖的条件。

把这条判断带到下一个场景

分析 Agent 时,沿着状态、动作、工具结果和下一步的顺序走一遍;每次交接都要能说明信息从哪里来、由谁确认、失败时停在哪里。

  • 「交互演示 · 流水线闯关」:情景 A · 一路放行 情景 B · Guard 拦下 情景 C · 恶意放行插件 播放 单步 重置 bash: rm -rf build/ 第 1 段 · pre-execute 瀑布 进门前表态:allow / deny / ask,监听器可重排 Guard 层 · 单调守卫 只能给拒绝理由或弃权,类型上没有放行 第 2 段 · execute 瀑布 围着执行包一层:超时、重试、指标 第 3 段 · post-e…
  • 「机制拆解 · 三段各管什么」:先说清问题。权限检查、人工审批、超时、结果改写、UI 渲染,全都想挂进工具执行这一个动作里。如果让每个工具自己处理,40 个工具就有 40 份权限代码。DSH 的做法是把工具执行做成一条流水线,策略全部住在流水线的固定工位上,工具本体只做一件事:执行并返回值
  • 「最后的要点」:DSH 的解法是在 pre-execute 后面加一层顺序不敏感的终审。Guard 的返回类型只有两种:一个字符串(拒绝理由),或者 undefined (弃权)。没有任何返回值能表达同意。这样一来,注册十个 Guard 还是一百个,随便怎么排,结论只可能更严不可能更松。类型定义就是证据

最后的「最后的要点」把讨论落到「DSH 的解法是在 pre-execute 后面加一层顺序不敏感的终审。Guard 的返回类型只有两种:一个字符串(拒绝理由),或者 undefined (弃权)。没有任何返回值能表达同意。这样一来,注册十个 Guard 还是一百个,随便怎么排,结论只可能更严不可能更松。类型定义就是证据」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。

标记为已学完 阅读进度会自动记录
← 上一篇下一篇 →

继续阅读

同一条线上的下一篇。

文章讨论

读到这里,留下一个判断。

把刚想明白的地方、还没想通的问题,留给下一位一起学习的人。

正在讨论 工具执行流水线:三段瀑布与单调 Guard 拆开 DeepSeek Harness
3条讨论文章讨论 · 与共学社区同步
在共学社区查看
AM
Asha Morgan内容编辑
观点实践记录

我把这篇文章里的一个判断改写成了今天可以验证的小实验。比记住结论更有用的是,知道下一步要观察什么。

文章讨论7 有帮助
LH
Lin Harper独立开发者
观点观点

读完以后我先回头找它成立的条件,而不是直接把方法搬进项目。这个顺序让后面的取舍清楚很多。

文章讨论5 有帮助
KM
Kiki Moore产品运营
问题问题

如果把这个判断放到真实工作里,最先需要补的约束是什么?我想知道从阅读到第一次实践之间,哪一步最值得先做。

文章讨论4 有帮助