工具调用为什么要走管线——DeepSeek Harness的单调守卫、并行调用的fail closed与大输出spill

我们的底座里,自己写的强类型 function calling 和通过 MCP 接进来的工具,走的是同一条管线:参数校验、超时、失败原因回传给模型。

即便如此,线上还是踩过两个和工具有关的坑。一个是工具的缓存掩盖了真实的重试,一个是提示词里的 few-shot 示例和工具的 schema 对不上,导致线上的衔接失效。两个问题都不报错,都是"看起来没问题",查起来很费劲。

带着这两个坑去读 DeepSeek Harness(dsh)的工具管线,发现它对"工具该管什么、不该管什么"划得非常清楚。这是 dsh 系列的第四篇。

一、工具只做两件事

dsh 里一个工具由这几部分组成:给模型看的 schema、必须声明的规范输出、execute 本身、宿主侧的调度元数据,以及可选的 UI 展示。

但对工具的作者来说,真正要关心的只有两件事:

  1. 返回一个符合 output.schema 的规范 JSON 值;
  2. 把 exec.signal 转发给底层的 I/O,让调用可以被取消。
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: (_a, v) => [{ type: 'text', text: v }] },
    isConcurrencySafe: () => true, // 只读,允许和其他调用并行
    async execute(args, exec) {
      return readFile(args.path, { encoding: 'utf8', signal: exec.signal }) // 转发 signal
    },
  }),
)

至于超时多久、要不要重试、有没有权限、输出太长怎么办,全都不是工具的事,而是部署时的策略,挂在管线上。工具本身保持纯粹。

二、一条固定的管线

每一次工具调用,都要穿过同一条管线:

tool/call                   执行前先落日志
-> tools/pre-execute        可重排的策略:hooks、权限、沙箱,给出 allow / deny / ask
-> 单调守卫                  只能拒绝
-> tools/execute            包在执行外面:超时、重试、指标
   -> 工具的 execute()
-> projectContent           把规范值渲染成模型内容
-> tools/post-execute       接受、阻断、替换结果,或者附加上下文
-> finalizeContent          最后的内容不变式
-> tools/result             冻结的权威结果

这条管线有两条很重要的规矩。

第一,管线里任何一个环节抛了异常,都变成一个"工具出错"的结果,永远不会变成整个 turn 的失败。工具挂了,模型会收到一个错误结果,它可以换个方法再试;而不是整轮对话直接崩掉。

第二,每一个工具调用都必须有一个模型可见的结果。被拒绝了、审批没通过,工具体不会执行,但投影和收尾的环节照样要走,给模型一个明确的"被拒绝了"。模型发出了调用,却什么都没收到,它只能自己去猜。

三、单调守卫:只能往拒绝的方向转

tools/pre-execute 是一个 waterfall,后面的监听者可以改写前面的决定。这对可扩展的策略很友好,但有一个问题:如果有一条策略是"无论如何都不能放行"的呢?后面挂上来的插件,完全可以把它的拒绝改回允许。

所以 dsh 在 waterfall 之后加了一道单调守卫:

type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined

返回一个字符串表示拒绝(和拒绝的原因),返回 undefined 表示弃权。注意,它的返回类型里刻意没有"允许"这个选项。守卫只能维持或者收紧权限,任何后来的监听者都没法把一次拒绝翻回成允许。

可以把整条管线想象成一个洋葱,外加一个只能往"拒绝"方向转的棘轮。可重排的策略放在洋葱里,不可撤销的策略交给棘轮。

四、超时是策略,不是参数

我见过不少 agent 的工具 schema 里有一个 timeout_ms 参数,让模型自己决定等多久。dsh 明确反对这种做法:超时是部署的策略,不应该暴露给模型。

工具在自己的配置里声明一个 timeoutMs,这个字段永远不会进入模型的请求。然后由一个独立的超时策略插件,挂在 tools/execute 上,给这次调用派生一个带截止时间的 signal:

  1. 策略插件把 exec.signal 替换成带截止时间的 signal,然后调用 next();
  2. 时间到了,底层的 I/O 因为 abort 抛错,被管线归一化成一个错误结果;
  3. 策略插件认出这是自己的超时,把错误替换成一个明确的 TOOL_TIMEOUT;
  4. 最后在 finally 里把原来的 signal 恢复回去。

模型拿到的是"工具调用在 30000ms 后超时"这样明确的错误。没有声明 timeoutMs 的工具,完全不受影响。

五、并行调用:按单次调用 fail closed

模型一次发出好几个工具调用时,能不能并行执行?

dsh 的做法是,每个工具可以提供一个同步的纯函数 isConcurrencySafe(args),根据这一次的参数来判断。只有显式返回 true 的才参与并行;没提供、参数不合法、抛了异常、返回了别的值,一律当作独占执行。未知的工具也是独占的。

这里有两个细节。

一是判断是"一元"的,只看这一次调用本身,不去比较同一批里的其他调用。像"写不同路径的文件可以并行"这种需要比较的规则,一律按独占处理。规则一旦需要比较,就很容易出错。

二是这个函数必须是同步、纯的,不能做 I/O。否则判断的那一刻和真正执行的那一刻之间就有时间差,判断的结果可能已经过期了。

调度时,按模型给出的顺序扫描,连续的可并行调用成一组,每个独占调用自己成一组,并且充当一道屏障:

[并行 read(A), 并行 read(B), 独占 write(A), 并行 read(C)]
→ [read(A), read(B)] → [write(A)] → [read(C)]

结果用一个提交游标,按模型给出的顺序追加到日志里。哪个先跑完不重要,模型看到的顺序永远和它发出的顺序一致。

还有一点,只有工具本身的执行是并行的,pre-execute 和 post-execute 这两个环节仍然按顺序执行,因为挂在上面的中间件可能持有和顺序相关的状态。

六、规范输出和渲染分开

工具的 execute 只返回 output.schema 声明的 JSON 值。由注册表快照、校验、冻结之后,再交给 output.render 渲染成给模型看的内容。

为什么要分开?因为同一个值有好几个消费者:模型要看渲染后的文本,UI 要展示,dsh 还支持一种"程序化工具调用",让模型写一段代码,在代码里直接 await tools.xxx(args),这时候代码拿到的是规范的 JSON 值,而不是渲染出来的一段文本。

所以 dsh 说,要把 output.schema 当成一个程序化的 API 来设计。不要从工具体里直接返回一段给模型看的文本,更不要让调用方从一段散文里去解析 id。

七、大输出:保留、存储、策略三件事分开

工具输出太大,不能整份塞进下一次请求,但完整的结果之后可能还有用。dsh 把这件事拆成了三层,每层只管一件事:

  1. 保留库:一个纯函数库,只回答"保留了什么、精确地省略了多少"。比如 bash 的输出保留尾部,搜索结果保留前面若干条。调用方把所有观察到的内容都喂进去,所以省略的计数是精确的,不是一个"等等";
  2. Spill 存储:一个极简的接口,只做一件事,把文本原样保存下来,返回一个不透明的定位符和一句"怎么取回"的提示。本地实现返回的是文件路径,换成远程存储,可以是一个 URI 或者一个 key;
  3. Spill 策略:挂在 tools/post-execute 上,按部署的预算,把超大的结果替换成预览加定位符。

注意第 2 点里的"不透明"。消费者不应该假设定位符一定是一个文件路径、一定能用 read 去读,而是要照着那句取回提示来渲染。存储换了实现,上层什么都不用改。

还有一个小细节:模型主动调用 read 读回来的结果,不受这个策略约束。否则就会出现"读 → 太大被 spill → 再读 → 又被 spill"的死循环。

八、回头看我们踩过的两个坑

1. 工具缓存掩盖了真实的重试

我们有一个查询类的工具,为了省调用,加了一层结果缓存。问题出在两点:缓存层套在了重试层的里面,而且把失败的结果也缓存了。

于是第一次调用失败以后,后面的重试拿到的永远是缓存里那次失败。日志里看起来重试了三次,实际上下游只被调了一次。重试形同虚设,监控上还一切正常。

回头看 dsh 的设计,这个问题本质上是横切逻辑散落在了工具内部,彼此的顺序是隐式的。缓存、重试、超时、指标都是横切的关注点,在 dsh 里它们统一挂在 tools/execute 这个包装层上,谁包着谁是显式组合出来的,一眼就能看清。我们后来也把缓存从工具里挪了出来,作为管线上的一层,放在重试的外面,并且只缓存成功的结果。

2. few-shot 和 schema 对不上

另一个坑更隐蔽。有一次工具的参数结构调整了,schema 改了,但系统提示词里的 few-shot 示例还是旧的写法。模型很"听话",照着示例去传参,结果一部分调用校验失败,一部分虽然通过了,但和后续流程衔接不上。

这让我想到 dsh 反复强调的一个原则:一个事实只有一个家。工具的参数长什么样,这个事实应该只有 schema 一个来源,few-shot 示例只是它的派生物。

我们现在的做法是,把提示词里所有的工具调用示例都抽出来,在 CI 里用对应工具的 schema 校验一遍:

import jsonschema


def test_few_shot_examples_match_schema():
    for example in load_tool_examples_from_prompts():
        schema = tool_registry.get(example.tool_name).parameters_schema
        # 示例和 schema 不一致,直接让 CI 失败,而不是等到线上才发现
        jsonschema.validate(instance=example.arguments, schema=schema)

schema 一改,示例没跟着改,CI 直接就红了。

总结

  1. 工具的作者只做两件事:返回规范的 JSON 值、转发取消信号,超时、权限、重试、溢出都是挂在管线上的部署策略;
  2. 管线里任何异常都归一化为工具错误结果,不会变成 turn 失败,每个调用都必须有一个模型可见的结果;
  3. 可重排的策略用 waterfall,不可撤销的拒绝用没有"允许"分支的单调守卫;
  4. 并行按单次调用 fail closed,判断函数必须同步且纯,结果按模型给出的顺序提交;
  5. 大输出拆成保留、存储、策略三层,定位符是不透明的;横切逻辑要挂在管线上显式组合,一个事实只能有一个家。

相关资料