DeepSeek Harness 的工具执行管线全图:从模型出参到结果回填

2026-08-17

先说清楚这篇要回答的问题:你在 DeepSeek Harness 上挂了一个钩子,想拦掉某类工具调用,结果发现有的调用压根没走到你这儿;或者你看到会话里一条工具结果的错误码是 ABORTED_BEFORE_DISPATCH,另一条是 ABORTED,想知道这两个字面差别代表什么。这两个问题的答案都在同一段代码里——packages/core/tools/src/index.ts,这个文件我们数出 1946 行,包名是 @deepseek-ai/dsh-toolspackage.json 里的 description 直接写着「Tool registry and execution pipeline for the DeepSeek Harness」。

开始之前先把限定摆上:该仓库 README 自述处于开发者预览(developer preview)阶段,并且用加粗大写写明「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」。下面提到的事件名、错误码、默认值都可能随版本变动,读的时候请对着你手上那份快照核。

先看图,再看图没画的那一步

仓库里有一张现成的流程图,在 docs/tool-execution-pipeline.md(中文对侧是同名的 .zh.md)。这个文件头部有一行注释:由 scripts/gen-doc-graphs.ts 生成,不要手改。图里的主干是:tool/call 事件先落日志 → tools/pre-execute waterfall → 注册的单调守卫 → tools/execute 环绕 waterfall → 工具体 → tools/post-execute waterfall → 注册表规范化 → ToolDefinition.finalizeContenttools/resulttool/result 事件。

图上没有单独画、但代码里排在最前面的一步,是参数的无损化与冻结createExecution 里先给这次调用铸一个 token,补上 rootCallId(调用方没传就用 callId 顶上),然后把 exec.arguments 过一遍 snapshotJsonValue,拿不到无损 JSON 就直接抛 TypeError,抛出的这句话是 tool execution arguments must be losslessly JSON-serializable;拿到了就 deepFreeze。所以策略层看到的参数已经是冻的——tools/pre-execute 的类型签名里也明说了不提供改写输入的通道,PreToolDecision 只有 allow / deny / ask 三种,源码注释给的理由是参数已经被记录并展示过了(文档自述)。

还有一个分叉更早:如果注册表配置成 mode: 'code'Config.mode 的三个取值是 nativecodeboth,默认 native),模型直呼一个原生工具名会在进入策略管线之前就被否掉。源码注释写得很直白:这种调用注定失败,pre-execute 监听器、审批、守卫都不该看到它,更不该批准它。它返回的是 ToolNotFoundErrorcodeUNKNOWN_TOOL),但消息里额外挂了一句路由提示——「only run_code is callable directly — call <工具名> from inside a run_code program instead」。这一句是给模型看的:名字明明在 prompt 里声明过,只回一个光秃秃的 unknown tool,模型会以为部署坏了而不是纠正自己(这条理由是源码注释自述的)。

pre-execute 是可协商的,守卫不是

这是整条管线最值得单独拎出来的设计。tools/pre-execute 是 waterfall:每个监听器拿到 next(),可以自己拍板,也可以往下传。packages/hooks/hooks-claude-code/src/index.ts 里那个监听器就是典型写法——跑一遍 PreToolUse 钩子点,deny 就返回 { kind: 'deny', reason }ask 就返回 { kind: 'ask' },其余情况 return next()

紧跟其后的是单调守卫(monotonic guard),通过 ctx.tools.guard() 注册,类型是 ToolGuard = (execution) => string | undefined:返回字符串就是拒,返回 undefined 就是不表态。注意它没有 allow 这个返回值——源码注释把这个约束讲得很清楚:因为守卫没有放行结果,监听器的先后顺序就没法把一次否决重新变回许可。想写一条「谁都不许绕过」的策略,就得挂成守卫;挂成 pre-execute 监听器的话,理论上排在你前面的人可以直接 allow 把你跳过去。

守卫的求值顺序也写死了:先全局层,再沿作用域链、最远的先来,任何一层给出理由就短路。用 agent.ctx 注册的守卫只对那个 agent 生效,这是 @deepseek-ai/dsh-scope 的作用域过滤。

顺序上还有一句要记牢:docs/tool-execution-pipeline.md 的正文写明 ctx.approval 是在单调守卫之前解析 ask 的,代码里也是先拿到审批结论、再去求值守卫——所以「人批过了」不等于「守卫会放行」。ask 的落地在 serviceAsk 里,用 ctx.get('approval') 机会性地取审批服务,四种结果一一对应:allowed-once 放行;rejectedcancelledunavailable 都是拒,但三条拒的 reason 文案各不相同,注释说这是为了让模型能分辨「人说了不」和「压根没有审批通道」。另外两种退化路径也要记住:没有装审批服务,ask 直接变 deny;这次执行没有 agent,同样变 deny,理由是没有会话可审计、也没有 UI 可路由。

超时不是异常,是一层包装

tools/execute 是环绕分发(around dispatch)的 waterfall,超时、重试、埋点都挂在这里。仓库里真的这么用的例子是 packages/guard/timeout-policy/,包名 @deepseek-ai/dsh-tool-call-timeout-policy。它的做法是读工具定义上的 timeoutMs 字段:没声明就 return next(),什么也不做;声明了就在 exec.signal 上武装一个 deadline,超时赢了就返回一个 isError 结果,error.messagetool call timed out after <毫秒数>mserror.info{ name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }

timeoutMs 这个字段有个容易忽略的性质:它永远不会发给模型ToolDefinition 的 JSDoc 写明 schemas() 只把 name / description / parameters 三样列入白名单。同一段注释还说了,声明 timeoutMs 等于是在断言这个工具会把 exec.signal 往下传、在信号中止后能收敛到静默。

环绕包装可以替换 exec.signal,但替换不了取消dispatchToolBody 里会把调用方原始信号和包装器换上的信号 fuseToolSignals 融成一个再交给工具体,finally 里再还回去。ToolDefinition.execute 的 JSDoc 里有一句我认为整份代码里最该被引用的自陈:注册表不会抛弃这个 promise,但它 cannot hard-kill same-process code——同进程里的代码,它硬杀不掉。所以别把「取消」理解成「立刻停」。

ABORTED 和 ABORTED_BEFORE_DISPATCH 的分界线

回到开头那个问题。这两个常量就在同一个文件里:TOOL_ABORTED = 'ABORTED'TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'。选哪个,由取消状态里的一个布尔 bodyInvoked 决定——工具体有没有被真正调起来。cancellationResult 就这一行逻辑。

管线里检查调用方取消的点不止一处:进 pre-execute 之前一次;审批返回 cancelled 之后一次;守卫判完之后一次;分发结束之后一次;post-execute 结束之后还有一次。有一个细节别看漏:分发结束与 post-execute 结束这后两处,替换条件都带了 !result.isError——取消只顶替成功结果。工具已经开跑并且自己失败了,它那份带结构化错误信息的结果会被保留下来,不会被统一抹成 ABORTED

post-execute 能改结果,但改不了「谁的 context」

tools/post-executePostToolDecision 有三种形态:accept 且替换 contentaccept 且替换 valueblock 把纠正性 feedback 变成一个 isError 结果。acceptcontentvalue 同时给会抛 TypeError;对一个已经失败的结果替换 value 也抛 TypeError

这里有条语义值得写进你自己的备忘:工具体通过 ctx.deferContext() 挂的上下文,在结果被 accept 时能活下来,但被 block 时会被丢掉——被 block 的调用只会暴露 blocking 决策自己显式提供的 additionalContexts。这一句是源码注释里明写的。

再往后是注册表的外层规范化ToolDefinition.finalizeContent。后者的捕获时机很反直觉:capturedFinalizer 是在参数无损化之前就绑定好的,注释解释了原因——一个带 getter 的 arguments 可能在 snapshotJsonValue 执行期间把已注册的回调换掉或清掉。契约上它必须是全函数、同步、不许抛,返回 undefined 表示保留原内容,其余结果字段一律归注册表所有。

packages/jobs/tool-jobs/src/index.ts 用的正是这套组合技:它挂在 tools/pre-execute 上(而且带 { prepend: true }),但不做任何准入判断,只是借这个时机把可见输出上限记进一张表然后 next(),真正裁内容的动作放在 finalizeContent 里。这是一个「pre-execute 未必是用来拦人的」的现成反例。

最后一站是只读的

tools/result 是 emit,不是 waterfall——观察,不参与决策。notifyResult 里先 Object.freeze(exec),然后逐个调观察者;观察者抛异常或返回的 promise 被拒,都只走 ctx.logger.warn,不影响结果。想在这里改东西是改不动的。

这条不变式还有代码在守。packages/core/tools/src/invariant.ts 注册了一个伴生插件,用 internal/dispatch 盯着事件流跑一台小状态机:tools/pre-execute 对同一次执行重复触发就报 tools/pre-execute repeated for one executiontools/execute 必须跟在 pre 之后;tools/post-execute 必须跟在 pre 或 execute 之后;到 tools/result 时再校验 exec、result、result.content 三者是否都已冻结,以及 namecallId 是否非空。

一处文档与仓库现状的差异

流程图里 tools/pre-execute 那个节点的副标题写的是「hooks, permission, sandbox」。而在 packages/ 下按 .on('tools/...') 这种字面写法、排除 tests/ 目录数一遍,我们数到的注册点是 14 处:tools/pre-execute 3 处(两个 hooks 适配包,加上面说的 tool-jobs)、tools/execute 2 处(超时策略与会话检查点策略)、tools/post-execute 6 处、tools/result 2 处、tools/code-dispatch-log 1 处。数出来的 pre-execute 三处里没有独立的 permission 或 sandbox 插件。

与之对应,packages/shell/tool-bash/src/index.ts 的模块头写着 TODO(permissions): deployment policy belongs in 'tools/pre-execute' and sandboxing executorspackages/shell/bash-local/src/index.ts 的模块头也写着执行策略归属 tools/pre-execute 或一个 sandboxing executor。两处并列摆在这里,以我们实读的源码为准;至于该怎么解读,我们不推断。

顺带提醒一句边界:dsh 的 bash 类工具会在你本机起子进程执行命令,这条管线提供的是拒绝、审批与超时的挂载点,不等于「有管线所以安全」。你要不要在部署里补一层准入,得自己评估。

还有一条平行的支线

mode 设成 code 时,模型只能调 run_code,其余工具从生成的 SDK 里调。这些子调用同样走这条管线,但有几点不同:携带父级 token、记录 tool/code-dispatch 事件、拒绝以具有约束力的驳回形式返回、并且省略 additionalContexts(文档自述理由是保持调用与结果相邻)。子调用的并发上限由 Config.maxParallelSubCalls 控制,schema 里写的是 z.natural().min(1).default(10),代码里的兜底也是 value ?? 10,设成 1 就退回严格串行。

另外还有一个只影响日志的 waterfall:tools/code-dispatch-log。它能改的只是某次 run_code 子调用持久日志副本里的内容——程序本身已经拿到完整值,模型两边都看不到;监听器抛异常会被兜住,退回记录原始内容。第一次读到这儿容易误会成「能改程序拿到的东西」,不能。


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。 安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。