Tool Calling 与工程化

从运行时校验到模型可见契约:如何减少 Agent 的无效工具调用

让 Tool Schema 同时服务模型理解、运行时校验、文档和回归测试。

Tool SchemaContractQwen Code

一个 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 工具包含 nameplan_mode_requiredread_only 等与 Agent Team 有关的参数。

修改前,read_only 的描述大意是:开启后,具名 teammate 只能检查代码并使用团队协作工具,Shell、写文件、Memory、Schedule 和嵌套 Agent 等能力会被执行白名单阻止。

这段描述说明了参数的效果,但没有明确说明两个前置条件

  1. 必须提供 teammate 的 name
  2. 当前必须存在 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_onlyplan_mode_required 互斥,并在两个参数的描述中采用对称表达。这样,无论模型正在判断哪一个参数,都能看到这条关系。

这个案例说明,参数描述至少需要回答三个不同问题:

  • 它有什么效果?
  • 它在什么条件下有效?
  • 它与哪些参数不能同时使用?

只回答第一个问题,还不能构成完整契约。

问题二:“默认前台”不等于“禁止后台”

另一个问题出现在 working_dirrun_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 的实际调用过程看,它同时承担了三种职责:

  1. 结构约束:字段类型、枚举、必填项;
  2. 语义约束:前置条件、互斥关系、生命周期要求;
  3. 决策提示:帮助模型判断什么时候应该调用、应该选择哪个参数组合。

其中一部分关系可以用 JSON Schema 的 if/thenoneOfnot 等结构表达。但在真实项目中,约束常常依赖运行上下文,例如“当前是否存在 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:哪些状态下才有效;
  • rejecteddowngraded:失败、忽略和降级是否被准确区分;
  • 资源所有权:后台任务、文件、worktree、连接由谁负责回收。

每发现一条 guard,都问一句:模型在调用之前能从 Schema 得知它吗?

第二步:区分默认值和硬约束

“默认关闭”不能替代“禁止开启”,“通常在前台”也不能替代“后台调用会被拒绝”。描述需要明确是:

  • 默认行为;
  • 可覆盖配置;
  • 强制约束;
  • 静默降级;
  • 显式拒绝。

这些词看起来接近,对模型产生的调用决策却完全不同。

第三步:检查所有模型可见入口

同一规则可能同时出现在:

  • 参数 Schema;
  • 工具级 usage notes;
  • subagent 配置说明;
  • 用户文档;
  • 开发者文档。

只修改其中一处,其他入口仍可能继续向模型或开发者传递旧语义。这次 PR 的 review 就发现了多处平行描述,并逐步统一了措辞。

第四步:用正反两组证据锁定契约

至少验证两件事:

  1. 合法组合仍然成功;
  2. 非法组合以 Schema 所描述的方式失败或降级。

如果改动声称“不改变运行时行为”,还应对比修改前后的实际结果,而不是仅仅运行修改后的测试。

哪些问题不能只靠改 Schema 解决?

模型可见契约可以减少无效调用,但它不是形式化正确性的保证。

首先,模型可能忽略描述、误解长文本,或者在上下文过长时遗漏某条限制。因此,运行时校验永远不能删除。

其次,如果工具包含大量跨字段规则,仅靠自然语言堆叠会让 Schema 变得难以阅读。能够结构化表达的约束,应优先考虑 JSON Schema;无法静态表达的上下文规则,再通过简洁、邻近参数的描述补充。

最后,如果错误来自运行时行为本身,例如错误地拒绝了合法组合,那么改描述只是在记录 bug,而不是修复 bug。必须先判断究竟是:

  • 行为正确、描述缺失;
  • 描述正确、行为错误;
  • 两者都无法代表期望设计。

Qwen Code #9514 属于第一种,所以合理的修复重点是契约对齐,而不是重新设计执行逻辑。

总结

Agent 工具的可靠性,不只取决于运行时能否阻止非法调用,还取决于模型能否在调用前理解规则。

Qwen Code #9580 所做的事情并不复杂:它没有增加新的 Agent 能力,也没有改变原有执行语义,而是把已经存在的规则,从运行时 guard 前移到了模型可见的 Tool Schema,并通过文档和测试保持一致。

这类改动的通用价值在于建立一条更完整的契约链:

让模型提前知道如何正确调用
        +
让运行时拒绝仍然出现的非法调用
        +
让测试持续检查两者没有发生漂移

下一次设计 Agent 工具时,不妨从运行时的每一条参数校验反问:这条规则,模型在失败之前看得见吗?

参考资料

陈涛 · Agent Application Developer

杭州 · 2026