第 6 章 工具执行流水线:把权力关进流程里
本章围绕「工具执行流水线」展开,系统拆解一次工具调用从被模型点名到结果回填所经过的每一道关卡:先以最小工具示例建立基准,再给出完整字段表与三道 waterfall 的选择判断表;随后深入单调守卫、决策类型、参数与结果的「物化 + 冻结」、并发调度、沙箱与审批、PTC mode、后台任务与 UI 卡片等关键机制,最后以三句话收束全章要点。
标签:工具执行流水线 单调守卫 waterfall PTC mode 并发调度 沙箱与审批 后台任务
模型能要求做的事里,最危险的就是「调用工具」。删文件、跑命令、发请求——这些都发生在这一步。这一章讲清楚:一次工具调用从被模型点名到结果回填,中间到底过了几道关,每一道关你能挂在哪里。
6.1 先看一个最小的工具
官方给的最小形态是这个样子。别看它短,它是后面所有讨论的基准:
import { readFile } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'my-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'read_file',
description: 'Read a file from disk.', // 模型看到的唯一说明
parameters: {
path: { type: 'string', required: true, description: 'Absolute path' },
limit: { type: 'number' }, // 默认可选
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
// args 已经被校验并推导出类型:{ path: string; limit?: number }
return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
},
}))
}
这里面有几个概念是新手第一眼看不出来的,但都很重要:
| defineTool({…}) | 一个类型化辅助函数。它把「参数推导 + 校验」和 execute 绑在一起,所以 args 是强类型的。你不需要手写输入校验 |
| output.schema + render | 工具必须同时声明两件事:函数体返回的「规范值」长什么样(schema),以及这个值怎么变成模型能读的内容(render)。这个分离是刻意设计的 |
| exec.signal | 取消信号。协议要求你必须遵守它——信号触发时取消进行中的工作 |
| ctx.tools.register() | 注册是副作用。dispose 这个插件的 fiber 即注销该工具 |
为什么要区分「规范值」和「渲染内容」
因为同一个结果有两种读者:程序和模型。程序(尤其是 PTC mode 里模型写的代码)需要结构化、带字段的返回值;模型需要一段可读的文字。把两者混在一起,就只能靠解析自然语言来取 id 和字段——官方明确说了这是要避免的:「工具主体不要返回内容块,也不要迫使调用方从自然语言中解析 id 和字段。」
6.2 完整字段表:一个工具可以说多少事
defineTool 背后是 ToolDefinition。它的完整字段如下。读这张表时请注意最右一列——它回答的是「这个字段模型能看到吗」。
| name / description / parameters | 是 | 协议层字段。description 是模型理解这个工具的唯一依据 | 可见 |
| deferLoading | 否 | 请求把工具定义延迟加载进模型上下文 | 可见 |
| output | 是 | 规范输出声明:schema(强制校验函数体返回值)、render(纯投影成模型内容)、presentationMeta(可回放的展示数据) | 否(render 的产物才进内容) |
| execute | 是 | 真正干活的函数。返回规范 JSON 值,不是内容块 | 否 |
| projectContent | 否 | 在执行后策略之前装入「执行准备好的内容」 | 否(但影响内容) |
| finalizeContent | 否 | 面向模型内容的同步最后一公里变换。注册表对每个归一化结果恰好调用它一次,包括绕过 post-execute 的流水线失败。它必须不抛异常 | 否(但决定内容) |
| timeoutMs | 否 | 工具自己的超时预算。由 timeout 策略插件作为 tools/execute 包装层强制执行。声明它就等于承诺:这个工具会把 exec.signal 转给一个能收敛的合作实现 | 否 |
| isConcurrencySafe(args) | 否 | 纯同步分类器,判断本次调用能否与兄弟调用并行。只有返回严格 true 才算并行 | 否 |
| presentCall(args) | 否 | 怎么在界面里显示「正在调用」的卡片 | 否 |
| presentResult(args, result) | 否 | 怎么显示「已完成」的卡片 | 否 |
关于这张表,官方有两句话值得背下来:
注册表的 schemas() 通过显式允许列表构建面向模型的 ToolSchema[]。
唯独 isConcurrencySafe 的约定是:只有 true 才算并行;省略、抛异常、返回非 true、或 defineTool 参数非法,一律归为「独占」。
反直觉
并发安全默认是关的,而且是「fail-closed」(失败即关闭)。你可能觉得默认值应该宽松一点。但对工具来说,宽松意味着两个并发调用可能互相踩内存、抢同一个文件、产生竞态(两件事抢同一份东西,最后结果取决于谁先到。这类问题往往难以复现)。所以设计者选了一个方向:**想并行,你得主动声明,而且要保证自己干净。**官方还补了一句要求:声明并行的执行不得修改父级拥有的状态;共享状态必须容忍并发分派;只有能交换顺序或失败即关闭的竞态(两件事抢同一份东西,最后结果取决于谁先到。这类问题往往难以复现)才是允许的。
6.3 流水线全貌:一次调用要过几道关
现在讲本章的核心。官方为工具执行画了一张完整的流程图,把它压缩成一句话是:
tools/pre-execute waterfall 首先运行,随后是单调守卫,然后运行 tools/execute 和 tools/post-execute waterfall。

图 20|三道 waterfall 都可以改写一次调用,但它们的位置决定了「改写的时机」和「能看到什么」。
6.4 三道 waterfall 怎么选:一张判断表
这是本章最实用的一张表。官方在工具编写参考里给出了明确的选择规则:
| 可扩展的允许/拒绝/询问策略 | tools/pre-execute | 它是可重排的策略层。可以 deny、可以 ask(走审批)、也可以 cancel |
| 设置最终、不可撤销的拒绝 | ctx.tools.guard() | 单调守卫没有 allow 结果,所以后续监听器无法把拒绝翻回放行 |
| 给分派加截止时间、重试或指标 | tools/execute | 它是环绕分派包装层,可以替换 exec.signal(但不能移除它) |
| 替换展示内容或返回值、阻止结果、附加模型可见的上下文 | tools/post-execute | 它能拿到结果并返回 PostToolDecision |
| 观察不可变的最终结果(审计、指标、捕获) | tools/result | 它是 emit,只观察。监听器失败会被隔离,不会影响结果 |
官方在实操手册里还给了一条很重要的原则:
尽量不要把部署策略内建到工具中。
理由很直接:如果每个工具都自己实现一套权限检查,那么「统一收紧权限」这件事就会变成一个不可能完成的任务。正确做法是工具只管干活,策略统一挂在这几个扩展点上。
6.5 守卫为什么是「单调」的
「单调」这个词在这里有一个非常具体的含义,值得单独讲。
普通的 waterfall 监听器可以返回 allow 或者 deny。这意味着两个监听器可以互相「翻案」:A 说不行,B 说行,最后听谁的取决于顺序。这在权限场景下是不可接受的。
所以守卫被设计成只能缩小权限:
/**
* 单调执行守卫:在每个 tools/pre-execute 监听器之后、工具本体之前求值。
* 返回一个 reason 就拒绝该调用;返回 undefined 表示不改变结果。
* 因为守卫没有 allow 结果,所以监听器顺序无法把一次拒绝翻回允许。
*/
type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
关键
这条设计让「安全」可以被局部推理:你只要证明所有相关守卫都不会放行,就能证明这个调用会被拒绝,而不需要去分析监听器的注册顺序。这是「让不变量不依赖顺序」这一思想在权限上的具体应用——和第 3 章 agent/turn-stopping 用数据而非返回值表决,是同一个思路。
6.6 决策类型:你能返回什么
两个关键 waterfall 的返回值都是类型化的「决策」。下面把选项列全,写插件时直接查这张表。
前置决策 PreToolDecision(tools/pre-execute 返回)
| { kind: 'allow' } | 执行这个调用 |
| { kind: 'deny', reason, info? } | 物化它的面向模型的原因,可选带上结构化的错误身份 |
| { kind: 'cancel' } | 选择「规范的取消结果」,但不呈现为一个策略拒绝(这个区别对用户很重要:取消 ≠ 违规) |
| { kind: 'ask', reason?, displayReason? } | 只有审批服务返回 allowed-once 才继续执行,否则拒绝。reason 是审计用的原因,displayReason 是本地化的提示文案 |
官方特意说明了一点:参数不可被改写,因为「历史记录、审计、UI 和执行必须保持一致」。而且 ask 在没有审批通道、或者没有 agent 的情况下,会直接变成拒绝。
后置决策 PostToolDecision(tools/post-execute 返回)
| { kind: 'accept', content? } | 接受,但替换展示内容。保留规范值和现有元数据 |
| { kind: 'accept', value? } | 替换规范值。会重新校验并重新计算内容与元数据 |
| { kind: 'block', feedback } | 阻止:移除值,转成 isError 结果,并带上纠正性反馈给模型 |
这里有一条很容易被误用的规则,官方专门警告了:
内容替换是展示策略,而非保密策略;需要隐藏程序化值的监听器必须阻止或替换该值。
也就是说:你把内容换掉了,但 value 里的原始数据还在,程序仍然能拿到。真想保密,得用 block 或者替换 value。
6.7 参数与结果:为什么都要「物化 + 冻结」
官方在执行的描述里反复出现「物化」「快照」「冻结」这几个词。它们对应的是一套很扎实的防御设计:
| 参数在策略开始前一次性物化为无损 JSON 并深冻结 | 防「策略看到的值」和「工具收到的值」不一致。也防有状态的 getter 在「校验时」和「存储时」给出两个不同的值 |
| 分配一个不透明的 exec.token(一个 Symbol,只用于身份比较) | 让调用身份无法被伪造。callId、name、arguments、agent、token、调用方的 signal 全程不可变 |
| 包装层可以替换 exec.signal,但注册表在调用本体前会重新融合调用方的 signal | 防「加超时之后,用户按停止就无效了」。替换不能切断调用方的取消能力 |
| 最终产出是一个深度冻结的快照,实时观察者和持久化看到的是同一个 | 防「观察者偷偷改了结果,存到磁盘上的就不一样了」 |
| 失败一律被归一化为 isError,而不是让异常穿透 | 让流水线的任何一环出问题都留下一条可读的结果,而不是让整个轮次炸掉 |
反直觉
「抛出异常或返回无效值意味着 isError」,但官方的建议是:成功的领域结果,即使表示不理想的状态,也应该写入规范值。比如一个命令以非零状态退出——这不是异常,这是一个正常的观察结果,应该作为值返回,由渲染器去解释。只有基础设施故障才该抛异常。这个分界线新手很容易划错,结果是「命令失败 = 工具报错」,让模型误以为工具坏了。
6.8 并发调度:独占屏障与滚动池
一轮里模型可能一次要求调用好几个工具。这些调用怎么调度?官方给的机制是:
agent loop 向注册表查询每个待处理调用的执行模式,并据此形成独占屏障(一道挡住去路的关卡:屏障两侧的执行不能交错,必须先等这一侧做完)和滚动池并行执行。
执行模式只有两种:
type ToolExecutionMode =
| { kind: 'parallel' } // 可以与兄弟调用重叠
| { kind: 'exclusive' } // 单独运行,形成一个排序屏障

图 21|并行是为了快,但结果的呈现顺序依然是确定的。这两件事被分开了。
6.9 沙箱与审批:谁能拦住一次调用
工具执行这条流水线上,有几个专门的「安全部件」,它们各自负责一个维度:
| 沙箱 | ctx.sandbox | 消费方交出即将执行 spawn 的确切 argv;后端按每次调用的策略包装该 argv,并报告强制执行情况 |
| 沙箱策略 | ctx.sandboxPolicy | 统一保存部署默认模式和工作区根目录。只有沙箱执行器和提供方读它,这样 bash 和 fs 不会限制到不同的根目录 |
| 审批 | ctx.approval | 一次性权限决策,通过 approval/request waterfall 分派。没有回答方时,以 unavailable 关闭失败(也就是拒绝) |
| 权限预设 | ctx.permissionPresets | 面向用户的预设表(workspace-write/danger-full-access),把沙箱模式和审批策略选项组合在一起。一次切换写一个 permission/preset 事件,并贯通到两个选项事件 |
| 文件系统观测策略 | fs-observation-policy | 通过 fs/* 事件门禁贡献基于观测状态的检查。官方的说法是「文件系统的先读后编辑检查位于 tool-fs 之下」 |
关于审批,有一个设计细节很值得学:ask 的语义是「allowed-once」——一次性放行。这意味着每次询问只对当前这一次调用有效,不会变成一条永久规则。而如果没有回答方(比如在没有界面的 headless 环境里),它不会卡住,而是以 unavailable 关闭失败。
关键
「没有回答方 = 拒绝」这个默认值方向很重要。如果默认是放行,那么只要你把界面拿掉,所有审批就形同虚设。安全类的默认值必须往严的方向偏。
6.10 PTC mode:让模型写代码来调工具
PTC 是这一章里概念最新的一块。它的思路是:与其让模型一次次单独调用工具,不如让它写一段代码,在代码里批量调用。
官方对它的描述是:
在 PTC mode 中,每个可见的已注册工具都可以通过 await tools.<name>(args) 调用,无需额外集成。生成的 ToolArgsMap 和 ToolOutputMap 会根据同一组 schema 分别派生精确的参数类型与规范返回类型,调用则重新进入正常的执行流水线。
几个关键事实:
-
成功调用会解析为策略处理后的最终规范 JSON 值,而不是渲染后的自然语言内容。这就是前面强调「规范值与渲染内容分离」的原因。
-
失败调用会以真正的 ToolCallError reject;程序只能检查它的 name、toolName 和可读的 message,拿不到内部错误代码。
-
传输层是 run_code。桥接层在策略之前只记录配对 id、名称与规范化参数——不序列化描述、参数 schema 或 schema 字段。
-
子调用携带父级 token,记录 tool/ptc-dispatch 事件,把拒绝呈现为有约束力的驳回,并省略 additionalContexts(以保持调用与结果相邻)。
官方还给工具作者提了一条很实用的建议,关于怎么设计 output.schema:
请把 output.schema 设计为实用的程序化 API:直接返回句柄与字段;当标量、数组或 null 确实就是结果时,允许采用相应的根类型;将面向人类的解释放入 output.render。
以及一个容易忽略的资源事实:**中间值只存在于执行期间,不会被持久化,也不会按提示词上限截断,而且不设字节上限。**所以「如实声明的采集边界和进程内存」仍然是你自己的责任——只有外层 run_code 的日志和结果会受到输出上限和面向模型的 spill 流水线约束。

图 22|PTC mode 不是「绕过安全」,而是「换一种调用形态」——流水线照样走。
6.11 后台任务:工具跑了很久怎么办
有些工具不是「几秒钟出结果」,而是「跑几分钟」。官方为这种情况提供了一套后台任务机制,挂在一个独立的服务上。
注册任务的方式是:
ctx.jobs.start({ kind, label, owner: exec.agent, run })
官方描述的关键语义有几个必须记住:
| 预先中止的调用算失败 | 注册表会在进入 producer 主体前,把「signal 已经中止」的调用判为失败——因为此时根本没有任务,它的 id 无法满足成功输出的 schema |
| 发布 id 之后,用任务自己的取消信号 | ctx.jobs.start() 发布 id 后,应该使用任务自有的取消信号,而不是 exec.signal。因为之后取消外层调用只应该停止「等待本次调用」,不应该终止已经发布的工作 |
| 前台工作仍然与 exec.signal 耦合 | 不走后台的调用,仍然跟着调用方的取消信号走 |
| 生命周期归谁管 | 已发布工作的生命周期归 job_kill、owner dispose 和服务 teardown 所有 |
而且后台任务不是「扔出去就不管了」:
-
模型侧有控制工具(job_*)可以读取、列出、终止任务。
-
成功的后台分支返回类型化的规范句柄,比如 { kind: 'background', jobId }。
-
官方的警告很到位:**PTC mode 绝不能通过解析「started background job bash-1」这种文本去取得 id。**要拿 id,拿结构化字段。
6.12 UI 卡片:一层独立的渲染意图
这一节讲一个常被忽视但很有价值的分离。工具在界面里长什么样,和它给模型什么内容,是两件独立的事。
官方的做法是让工具返回一个「card 标签的渲染意图」——一个可辨识联合类型,UI 桥接层据此分发。可用的卡片类型有这么几种:
| generic | 待执行 & 已完成 | 默认卡片。可以带 kind(图标)和 locations(涉及的文件,让编辑器能跟随跳转) |
| terminal | 待执行 & 已完成 | shell 命令。已完成时携带原始输出、退出码、信号。dsh-tool-bash 用它 |
| diff | 待执行 & 已完成 | 文件创建/修改的行内 diff。dsh-tool-fs 的 write/edit 用它 |
| read | 仅已完成 | 带行号、可选语法高亮的代码窗口 |
| search | 仅已完成 | 发现型搜索的结果:按文件分组的匹配,或扁平路径列表 |
| web | 仅已完成 | web 检索:kind: 'search' 或 'fetch' |
注意 read/search/web 没有「待执行」版本。官方的解释很直白:内容只在 execute 之后才存在,所以它们的 pending 状态保持为 generic 卡片。
关于这层的写法,官方给了几条硬性规则,都是「违反会出问题」级别的:
-
**必须是纯函数。**这些方法在「实时流式输出」和「会话日志回放」两种场景下都会跑。所以不能做 I/O、不能读会话状态、不能用时钟或随机数。
-
**UI 格式不进入模型结果。**围栏代码块、diff、相对化路径都不应该仅为服务界面而进入规范值或模型内容。
-
**defineTool 对展示路径做软校验。**格式错误或来自旧版日志的参数会让包装器返回 undefined(走通用回退),而不是抛异常——展示绝不能导致回放崩溃。
还有一个实用提醒:**内置 Web Client 不消费 presentCall 或 presentResult。**它读的是 page 与 follow 运输的原始 tool/call 和 tool/result 事件,由客户端插件在 keyed slot 里注册自己的组件。所以「只定义 Host 展示方法不会增加专用 Web 卡片」。
6.13 这一章要带走的三句话
这一章要带走的三句话
**三道 waterfall + 一道单调守卫,就构成完整的策略层。**工具只管干活,策略统一挂在这几个点上。
**守卫只能拒绝,不能强制放行。**这让安全不依赖监听器顺序。
**规范值和渲染内容是两件事。**前者给程序,后者给模型,展示卡片给界面——三个读者,三套产物。
网硕互联帮助中心




评论前必须登录!
注册