Tool Calling 与工程化
从运行时校验到模型可见契约:如何减少 Agent 的无效工具调用
让 Tool Schema 同时服务模型理解、运行时校验、文档和回归测试。
一个 Agent 调用工具时,传入了一组看起来合理的参数。类型正确、字段齐全,JSON Schema 校验也没有报错,但请求到达运行时后,仍然被拒绝:
Parameter "read_only" requires a named teammate via "name".
问题在于,read_only 的工具描述只告诉模型“开启后会发生什么”,却没有明确告诉它“什么情况下才允许开启”。对使用者来说,这可能只是少读了一段文档;对依赖 Tool Schema 规划调用的模型来说,却意味着一条无法事先得知的规则。
这正是 Qwen Code Issue #9514 描述的问题,也是 PR #9580 解决的核心:运行时校验已经正确,但同一套约束没有完整暴露给模型。
这次修改没有改变 Agent 的运行逻辑,而是让 Tool Schema、运行时校验、开发者文档和测试对同一组参数契约形成一致表达。它揭示了 Agent 工具设计中一个容易被低估的事实:
对传统 API 而言,描述文字往往只是辅助文档;对 Agent 工具而言,Schema 描述会参与模型决策,因此也是可执行接口契约的一部分。
为什么运行时校验“正确”仍然不够?
传统程序调用 API 时,开发者通常会阅读文档、编写代码、运行测试。一次参数错误可以在开发阶段被发现并修复。
Agent 的调用链不同。模型往往直接根据工具名称、参数类型和描述,在当前推理过程中生成一次调用:
sequenceDiagram
participant U as 用户
participant M as 模型
participant S as Tool Schema
participant R as 运行时
U->>M: 提交任务
S-->>M: 提供参数定义与描述
M->>R: 生成工具调用
R->>R: 校验跨字段约束
alt 契约对模型可见
R-->>M: 执行工具
else 契约只存在于运行时
R-->>M: 拒绝调用并返回错误
M->>R: 理解错误后重新调用
end
如果约束只存在于运行时,模型只能通过“调用失败”来学习它。这会带来额外的工具往返、推理消耗和错误恢复分支。更麻烦的是,模型未必能从错误中稳定地构造出正确的下一次调用。
因此,Agent 工具至少存在两个校验时点:
| 校验层 | 发生时间 | 作用 |
|---|---|---|
| 模型可见契约 | 生成工具调用之前 | 帮助模型选择合法参数 |
| 运行时校验 | 工具真正执行之前 | 阻止非法状态进入系统 |
前者降低错误发生的概率,后者保证系统安全。它们不是替代关系。
问题一:描述了参数效果,却没有描述前置条件
Qwen Code 的 Agent 工具包含 name、plan_mode_required 和 read_only 等与 Agent Team 有关的参数。
修改前,read_only 的描述大意是:开启后,具名 teammate 只能检查代码并使用团队协作工具,Shell、写文件、Memory、Schedule 和嵌套 Agent 等能力会被执行白名单阻止。
这段描述说明了参数的效果,但没有明确说明两个前置条件:
- 必须提供 teammate 的
name; - 当前必须存在 active team。
运行时其实早已强制执行这些规则。下面是等价的简化示意:
if (params.read_only && !params.name) {
return 'Parameter "read_only" requires a named teammate via "name".';
}
if (params.read_only && !teamManager) {
return 'Parameter "read_only" requires an active team.';
}
模型只看 Schema 时,却只能从“the named teammate”这样的措辞中猜测:这是强制要求,还是单纯描述某类使用场景?如果把 read_only: true 用在普通的匿名 subagent 上,运行时究竟会拒绝,还是忽略这个字段?
PR 最终把契约直接写入参数描述:
Only valid with a named teammate in an active team.
Cannot be combined with plan_mode_required.
这里还补上了另一个既有约束:read_only 与 plan_mode_required 互斥,并在两个参数的描述中采用对称表达。这样,无论模型正在判断哪一个参数,都能看到这条关系。
这个案例说明,参数描述至少需要回答三个不同问题:
- 它有什么效果?
- 它在什么条件下有效?
- 它与哪些参数不能同时使用?
只回答第一个问题,还不能构成完整契约。
问题二:“默认前台”不等于“禁止后台”
另一个问题出现在 working_dir 与 run_in_background 的组合上。
当一个匿名 Agent 使用调用者提供的 working_dir 时,调用者负责该 worktree 的生命周期。如果 Agent 仍在后台运行,调用者却已经删除了 worktree,就可能破坏正在执行的任务。因此运行时会限制这种组合。
修改前,Schema 将这种情况描述为:
Unnamed caller-owned working_dir launches default to foreground.
“默认在前台运行”很容易被理解成:默认值是 false,但调用者仍然可以显式传入 run_in_background: true 覆盖它。
实际行为却不是这样。对于匿名、调用者持有的 working_dir,显式请求后台执行会被拒绝。这是一个约束,而不是一个可覆盖的默认值。
两者的语义差异很明确:
| 表达 | 模型可能推导出的行为 |
|---|---|
| 默认在前台运行 | 可以显式改成后台 |
| 显式后台执行会被拒绝 | 后台组合不合法 |
最终描述把不同来源的后台配置也区分开来:
- 显式
run_in_background: true:拒绝; - subagent definition 中配置的
background: true:顶层调用拒绝; - 同样的配置默认值出现在嵌套调用中:降级为前台执行;
- 具名 teammate 使用调用者提供的 worktree:可以并发运行,但删除 worktree 前必须先停止 teammate。
这部分在多轮 review 中不断被收紧,因为“显式参数”“配置默认值”“顶层调用”“嵌套调用”和“具名 teammate”走的并不是同一条路径。把它们笼统写成“不能后台运行”,反而会产生新的错误。
Tool Schema 为什么可以视为执行链路的一部分?
从形式上看,Schema 只是一段描述工具输入的数据;从 Agent 的实际调用过程看,它同时承担了三种职责:
- 结构约束:字段类型、枚举、必填项;
- 语义约束:前置条件、互斥关系、生命周期要求;
- 决策提示:帮助模型判断什么时候应该调用、应该选择哪个参数组合。
其中一部分关系可以用 JSON Schema 的 if/then、oneOf、not 等结构表达。但在真实项目中,约束常常依赖运行上下文,例如“当前是否存在 active team”“调用是否发生在嵌套 Agent 中”“worktree 由谁管理”。这些条件未必能完全编码进静态 Schema。
因此,自然语言描述并不是可有可无的注释。它需要把无法由字段类型表达的运行时语义,准确地提前暴露给模型。
可以把完整的 Agent 工具契约理解为四个相互校验的层次:
模型可见的 Tool Schema
↓
运行时参数校验与执行逻辑
↓
开发者文档与用户文档
↓
Schema 断言与行为回归测试
任何一层发生变化,其他层都需要重新核对。否则,系统可能“运行得正确”,但对模型表达错误;也可能文档写得正确,运行时却没有真正执行承诺。
如何为模型可见契约编写测试?
这次 PR 使用了两类互补测试。
1. Schema 文本断言
测试直接检查生成的参数描述是否包含关键契约:
expect(readOnly.description).toContain(
'named teammate in an active team',
);
expect(readOnly.description).toContain(
'Cannot be combined with plan_mode_required',
);
通常我们会谨慎对待“断言文案”的测试,因为它可能让措辞调整变得麻烦。但这里的文字并非普通 UI 文案,而是模型可见的接口内容。关键句消失,意味着功能契约对模型退化,因此为核心语义建立文本回归保护是合理的。
不过,文本断言只能证明“Schema 说了什么”,不能证明运行时真的这样做。
2. 运行时行为测试
因此还需要验证既有校验规则:
expect(validate({ read_only: true }))
.toMatch(/named teammate/i);
expect(validate({
name: 'reader',
read_only: true,
plan_mode_required: true,
}))
.toMatch(/cannot be used together/i);
这类测试证明 Schema 中宣称的前置条件和互斥关系确实由运行时执行。
最终,维护者还进行了独立的 A/B 验证:对比修改前后的真实 CLI 请求,确认新增契约句已经进入发送给模型的工具 Schema;在屏蔽这些描述差异后,其余 Schema 保持一致。同时,多个非法调用场景在修改前后的运行时错误保持一致,进一步证明该 PR 没有改变执行行为。
理想的验证关系不是“新增描述,所以测试描述”,而是:
Schema 声明规则 ─────┐
├─ 两者必须表达同一个契约
运行时执行规则 ──────┘
一套可复用的契约检查方法
当你为 Agent 设计或审查工具时,可以按下面的顺序检查。
第一步:从运行时 guard 反查 Schema
搜索参数校验、异常分支和提前返回,重点关注:
requires:依赖哪些字段或上下文;cannot be combined:哪些参数互斥;only valid when:哪些状态下才有效;rejected与downgraded:失败、忽略和降级是否被准确区分;- 资源所有权:后台任务、文件、worktree、连接由谁负责回收。
每发现一条 guard,都问一句:模型在调用之前能从 Schema 得知它吗?
第二步:区分默认值和硬约束
“默认关闭”不能替代“禁止开启”,“通常在前台”也不能替代“后台调用会被拒绝”。描述需要明确是:
- 默认行为;
- 可覆盖配置;
- 强制约束;
- 静默降级;
- 显式拒绝。
这些词看起来接近,对模型产生的调用决策却完全不同。
第三步:检查所有模型可见入口
同一规则可能同时出现在:
- 参数 Schema;
- 工具级 usage notes;
- subagent 配置说明;
- 用户文档;
- 开发者文档。
只修改其中一处,其他入口仍可能继续向模型或开发者传递旧语义。这次 PR 的 review 就发现了多处平行描述,并逐步统一了措辞。
第四步:用正反两组证据锁定契约
至少验证两件事:
- 合法组合仍然成功;
- 非法组合以 Schema 所描述的方式失败或降级。
如果改动声称“不改变运行时行为”,还应对比修改前后的实际结果,而不是仅仅运行修改后的测试。
哪些问题不能只靠改 Schema 解决?
模型可见契约可以减少无效调用,但它不是形式化正确性的保证。
首先,模型可能忽略描述、误解长文本,或者在上下文过长时遗漏某条限制。因此,运行时校验永远不能删除。
其次,如果工具包含大量跨字段规则,仅靠自然语言堆叠会让 Schema 变得难以阅读。能够结构化表达的约束,应优先考虑 JSON Schema;无法静态表达的上下文规则,再通过简洁、邻近参数的描述补充。
最后,如果错误来自运行时行为本身,例如错误地拒绝了合法组合,那么改描述只是在记录 bug,而不是修复 bug。必须先判断究竟是:
- 行为正确、描述缺失;
- 描述正确、行为错误;
- 两者都无法代表期望设计。
Qwen Code #9514 属于第一种,所以合理的修复重点是契约对齐,而不是重新设计执行逻辑。
总结
Agent 工具的可靠性,不只取决于运行时能否阻止非法调用,还取决于模型能否在调用前理解规则。
Qwen Code #9580 所做的事情并不复杂:它没有增加新的 Agent 能力,也没有改变原有执行语义,而是把已经存在的规则,从运行时 guard 前移到了模型可见的 Tool Schema,并通过文档和测试保持一致。
这类改动的通用价值在于建立一条更完整的契约链:
让模型提前知道如何正确调用
+
让运行时拒绝仍然出现的非法调用
+
让测试持续检查两者没有发生漂移
下一次设计 Agent 工具时,不妨从运行时的每一条参数校验反问:这条规则,模型在失败之前看得见吗?