工具调用为什么要走管线——DeepSeek Harness的单调守卫、并行调用的fail closed与大输出spill
我们的底座里,自己写的强类型 function calling 和通过 MCP 接进来的工具,走的是同一条管线:参数校验、超时、失败原因回传给模型。
即便如此,线上还是踩过两个和工具有关的坑。一个是工具的缓存掩盖了真实的重试,一个是提示词里的 few-shot 示例和工具的 schema 对不上,导致线上的衔接失效。两个问题都不报错,都是"看起来没问题",查起来很费劲。
带着这两个坑去读 DeepSeek Harness(dsh)的工具管线,发现它对"工具该管什么、不该管什么"划得非常清楚。这是 dsh 系列的第四篇。
一、工具只做两件事
dsh 里一个工具由这几部分组成:给模型看的 schema、必须声明的规范输出、execute 本身、宿主侧的调度元数据,以及可选的 UI 展示。
但对工具的作者来说,真正要关心的只有两件事:
- 返回一个符合
output.schema的规范 JSON 值; - 把
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:
- 策略插件把
exec.signal替换成带截止时间的 signal,然后调用next(); - 时间到了,底层的 I/O 因为 abort 抛错,被管线归一化成一个错误结果;
- 策略插件认出这是自己的超时,把错误替换成一个明确的
TOOL_TIMEOUT; - 最后在
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 把这件事拆成了三层,每层只管一件事:
- 保留库:一个纯函数库,只回答"保留了什么、精确地省略了多少"。比如 bash 的输出保留尾部,搜索结果保留前面若干条。调用方把所有观察到的内容都喂进去,所以省略的计数是精确的,不是一个"等等";
- Spill 存储:一个极简的接口,只做一件事,把文本原样保存下来,返回一个不透明的定位符和一句"怎么取回"的提示。本地实现返回的是文件路径,换成远程存储,可以是一个 URI 或者一个 key;
- 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 直接就红了。
总结
- 工具的作者只做两件事:返回规范的 JSON 值、转发取消信号,超时、权限、重试、溢出都是挂在管线上的部署策略;
- 管线里任何异常都归一化为工具错误结果,不会变成 turn 失败,每个调用都必须有一个模型可见的结果;
- 可重排的策略用 waterfall,不可撤销的拒绝用没有"允许"分支的单调守卫;
- 并行按单次调用 fail closed,判断函数必须同步且纯,结果按模型给出的顺序提交;
- 大输出拆成保留、存储、策略三层,定位符是不透明的;横切逻辑要挂在管线上显式组合,一个事实只能有一个家。