乐于分享
好东西不私藏

DeepSeek Harness 源码研究(四):工具调用不是函数调用

DeepSeek Harness 源码研究(四):工具调用不是函数调用

工具调用不是函数调用

在最小 Agent Demo 里,模型返回 {name, arguments},程序查表找到函数并执行。这种抽象在没有权限、并发、回放和 UI 的场景里够用;进入真实工作区后,它会迅速失效。

一个文件修改工具需要审批,Bash 需要沙箱,Web 访问可能需要域策略,超时要包裹执行,结果要经过内容归一化,UI 要在实时和回放时显示同一张卡片,多次只读调用希望并发,而 writer 必须形成顺序屏障。

dsh 因此把工具调用设计成一条有明确提交点的管线,而不是一次普通函数调用。

第一层:一次调用要先成为事实,再被允许执行

当 assistant message 含有 tool-call block,Agent Loop 先写入 tool/call Session event。这里保存 call id、工具名和参数。之后才进入执行策略。

这个顺序非常重要。审批 UI、重放和错误诊断必须知道模型究竟请求了什么。若策略可以悄悄改写参数,日志里记录的调用就会与真实副作用不同。因此,dsh 的 tools/pre-execute 可以 allow、deny 或 ask,但不能改写 exec.arguments

ask 会调用 ctx.approval 获取一次性允许;审批服务不存在、无法回答、取消或拒绝时,调用按 deny 处理。工具 body 不会执行,但系统仍生成一条结构化错误结果,让模型知道请求被阻止。

第二层:工具管线有七个具有不同权力的阶段

第一阶段是 tools/pre-execute waterfall。它承载通用 hook、权限与审批策略。Listener 可以调用 next() 委托下游,并包裹返回值。

第二阶段是 monotonic guards。Guard 只能返回拒绝原因或 abstain,不能返回 allow。这意味着 listener 顺序无法把一个 owner policy 的 deny 重新改成允许。身份保护也在这里发挥作用:策略拿到的是只读、冻结的执行身份。

第三阶段是 tools/execute waterfall。它适合超时、重试和 metrics 这类 around-dispatch 关注点。实际 ToolDefinition.execute() body 位于链路底端;例如文件工具还会在更低层触发 fs/write-intent 或 fs/edit-intent

第四阶段是 tools/post-execute。它可以接受结果、替换模型可见 content、替换 canonical JSON value、追加下一请求的上下文,或把纠正性反馈转成 error result。

第五阶段是 registry normalization。返回值必须能无损快照成 JSON;schema 和投影错误被归一为工具失败,而不是让一段非法对象穿过边界。

第六阶段是工具定义拥有的 finalizeContent。它是最后一个只处理 content 的同步不变量。

第七阶段是 tools/result 同步通知和 Session tool/result。此时 outcome 已冻结,成为模型与 UI 的权威结果。additionalContexts 在工具结果之后按顺序进入下一 Step,避免打断 call/result 邻接关系。

UI 展示同样由工具定义提供纯函数 presentCall/presentResult。因为输入只依赖已记录的参数和结果,实时 UI 与历史回放可以构建相同的 terminal、diff、search、read 或 web 卡片,而不在前端硬编码每个工具名。

第三层:并发的关键不是“同时跑”,而是“有序提交”

dsh 允许工具定义通过 isConcurrencySafe(args) 声明某次具体调用可以并发。只有结果精确为 true 的调用进入 parallel;无效参数、没有声明或其他结果一律 exclusive。

Agent Loop 扫描模型给出的调用顺序。连续 parallel 调用进入一个有上限的 rolling pool;exclusive 调用会先排空前面的 pool,单独运行,并持有屏障直到 post-execute 和结果提交完成。屏障之后的 pending call 在启动前重新分类,避免插件热替换后还使用过期安全判断。

这里最精巧的地方是:dispatch/body 可以重叠,但 pre/post policy、durable result 和 additional context 仍按模型顺序 commit。这样既利用了独立只读任务的吞吐,又避免 Session 中出现不可预测的结果顺序。全局 maxParallelToolCalls=1 可以恢复严格串行。

Code Mode 把这套管线再利用了一次。在 mode: code 时,模型只直接看到保留的 run_code transport 和按当前工具生成的 TypeScript/Python SDK。程序中的每个 binding call 仍重新进入完整工具管线,并复用 parallel/exclusive 规则。模型可以用代码聚合中间结果,只有打印和最终 return 回到外层模型上下文。

它不是“普遍省 Token”的免费优化。仓库文档明确把它描述为 schema 与 SDK prompt 的交换,而不是通用缩减承诺。中间 canonical value 不进入 Session,无法从回放重建;普通副作用不会因为程序失败自动回滚;中间值也没有统一的 per-binding byte cap,可能消耗进程或 worker 内存。

对企业采用者,真正要问的不是“有多少工具”,而是:

  • 哪些调用被证明可并行?
  • 权限 deny 是否能被后续策略反转?
  • 参数、实际执行与审计记录是否一致?
  • 超时、取消后还有没有孤儿任务?
  • 工具结果在 UI、模型和持久化中是否同源?
  • Sandbox、approval 与 Provider 是否组成了真实部署闭包?

工具数量决定功能上限,工具管线决定事故下限。

源码核验索引

  • 工具核心:packages/core/tools/src/index.ts
  • 调度与有序提交:packages/core/agent-loop/src/tool-calls.ts
  • 管线图:docs/tool-execution-pipeline.md
  • Code Mode:packages/core/tools/src/code-mode.tspackages/core/tools/README.md
  • Bash 权限边界:packages/shell/tool-bash/README.md

事实边界:工具管线提供策略插入点,不自动保证部署安全。安全取决于 profile 实际装配的 approval、guard、sandbox、FS 和 subprocess Provider。