工具执行管线
本教程共 32 篇 · 第 15 篇 · 更新于 2026-08-15 · 约 6 分钟阅读
本节目标:看懂模型发出工具调用后,dsh 内部按什么顺序处理它——哪些环节能拦、哪些环节能改结果、并行调用怎么保持顺序、超长结果怎么处理。
从 turn 到工具调用
先复习第 10 章的执行流骨架:
- turn(轮次):一次完整任务处理,
turn/start打开、turn/end关闭; - step(步骤):一次模型请求 + 它请求执行的工具,
step/start到step/end; - 工具调用:模型在 step 内发出
tool/call,执行完成后写回tool/result。
一条典型时序是:
turn/start → step/start → llm/stream → tool/call → 工具执行 → tool/result → step/end → turn/end
模型发出的工具调用会在执行之前先写入会话日志(tool/call 事件),结果回来再写 tool/result。日志是唯一真源,后面所有环节都围绕它转。
管线六段
一次工具调用不是「调一下函数」,而是走一条固定顺序的管线。官方流程图可以简化成六段:
pre-execute → 单调守卫 → execute → 工具主体 → post-execute → finalizeContent → result
第一段 tools/pre-execute:可重排的策略层。监听器返回三种类型化决策之一:
| 决策 | 含义 |
|---|---|
{ kind: 'allow' } | 放行,继续走守卫与后续环节 |
{ kind: 'deny'; reason } | 拒绝,工具主体被跳过,错误物化给模型 |
{ kind: 'ask'; reason? } | 询问用户,只有审批服务返回 allowed-once 才继续 |
它叫「可重排」,是因为监听器可以调用 next() 把决定权传给下一个,多个策略插件的顺序可以在配置里调。沙箱、权限门禁、plan-mode 都挂在这一层。
第二段 单调守卫:ctx.tools.guard() 注册的最终防线。它的返回类型故意没有 allow 分支——返回字符串就是拒绝,返回 undefined 维持现状。因为没人能「放行」,后注册的监听器永远无法把一次拒绝变回允许。要保证「某个工具永远不可用」时用它。
第三段 tools/execute:环绕分发。超时、重试、指标收集都包在这里。它拿到的视图允许替换 exec.signal(但不允许移除),注册表会在调用工具主体前重新融合调用方信号。
第四段 工具主体:注册的 execute() 真正执行。文件类工具的变更还会经过 fs/write-intent、fs/edit-intent 守卫(第 17 章细讲)。
第五段 tools/post-execute:检查或改写结果。三种决策:
accept+ 替换展示内容(保留规范值与元数据);accept+ 替换规范值(会重新校验并重算内容);block+ 反馈:把纠正反馈变成错误结果。
第六段 finalizeContent + tools/result:finalizeContent 是工具定义自己拥有的同步回调,做「最后的仅内容修正」,恰好调用一次。之后注册表物化并冻结结果,触发 tools/result 同步通知。观察者只能「看一眼」冻结的权威结果,改不了,也影响不了主流程。
Tip记忆法:pre-execute 决定「能不能做」,execute 决定「怎么做」,post-execute 决定「结果怎么呈现」,result 只负责「看一眼最终结果」。
并行工具调用
模型一次可以请求多个工具。dsh 怎么保证「并行执行」和「历史可重放」不冲突?
- 并行是选择加入的:工具通过
isConcurrencySafe()声明自己能否与兄弟调用重叠执行。只有返回true的调用才可能并行,其余一律排他(executionMode返回parallel或exclusive)。并行调用的函数体不得修改父级拥有的状态。 - 重叠的是执行,不是提交顺序:调度器按模型给出的数组顺序规划,
parallel组内可以并发跑,但结果按模型顺序提交——后面的工具先完成,也不能越过前面未完成的调用写入会话。模型看到的 call/result 配对永远是确定性的。 - 两个 id 各管一件事:
callId维持模型协议层的配对;事件seq关联日志里的来源(tool/result通过sourceEventSeqs指回tool/call)。 - 取消不留半对:已启动的调用先结算;未启动的调用会按原顺序收到合成错误结果。
结果回填与 spill
工具结果通过 tool/result 事件回填:既写入会话日志,也作为 user 角色消息回到模型。但结果可能很大——grep 整个仓库、web_fetch 抓一篇文章,几十万字不稀奇。全塞进模型上下文既浪费又容易截掉有用的结尾。
dsh 的做法叫 spill(外溢):tools/post-execute 上的 spill 策略把超过 maxInlineBytes 的纯文本结果替换成「首尾预览 + 定位符」,完整文本交给 ctx.spillStore 保存到会话作用域的私有文件。模型拿到定位符和检索提示(retrievalHint),需要时再用 read 或 grep 按需读取。
几个关键性质:
- spill 只作用于模型副本,完整文本始终保存,不丢数据;
- 保存失败(磁盘满、没装后端)只记警告并保留原内联结果,不会把成功的工具调用改判失败;
- 定位符是后端返回的不透明句柄,本地后端渲染成文件路径,远程后端可能是 URI 或键,别假设它一定是路径。
失败处理
工具失败不会终止轮次,而是归一化成结构化错误结果:
- 结果带
isError: true和error: { name, code },code 是稳定、可机器路由的字符串(如UNKNOWN_TOOL、FS_STALE_VERSION),重试、权限、UI 层按 code 分支,不解析文本; - 抛异常的监听器被隔离,不会污染主流程;
- 超时由
tools/execute包装层执行,工具必须转发exec.signal才能配合取消; - 调用方取消后,未开始的调用报
ABORTED_BEFORE_DISPATCH,已开始的调用结算为ABORTED; - 模型侧的失败(如上下文溢出)走
agent/request-error,重试策略在这里决策——重试要求产生持久化的缩减(比如先压缩再重试),不能原地死循环。
Warning工具执行管线里的参数在进入策略前已被冻结。任何监听器都不能改写参数——历史记录、审计、UI 与执行必须保持一致。想限制行为,用 deny 或 block,不要试图改参数。
小结
- 一次工具调用依次经过 pre-execute → 单调守卫 → execute → 主体 → post-execute → finalizeContent → result。
- 可重排的策略用 waterfall,不可撤销的拒绝用 guard。
- 并行调用只重叠执行,提交顺序保持模型原序,callId 与 event seq 各司其职。
- 超大结果 spill 到文件,模型拿到预览 + 定位符按需读取。
- 失败归一化为
isError+ 稳定 code,按 code 路由,不解析文本。