工具调用契约提示法
一句话结论:Agent 最大的浪费不是答错,是「调了不该调的工具」。 我们先让模型把要调什么写下来,它就不好意思乱调了。
一、先看一个真实的浪费现场
给一个运维 Agent 的任务是:「查一下订单服务昨天为什么报错变多。」
没有契约约束时,它的调用序列是这样的(真实日志节选):
1. search_logs(query="订单服务", ...) ← 时间范围没给,默认全量,返回 5000 条
2. search_logs(query="error", ...) ← 太宽泛,还是 5000 条
3. get_service_list() ← 其实第 1 步就该先调这个拿 service_id
4. search_logs(service_id=..., level="ERROR") ← 终于对了,但没给时间范围
5. get_metrics(service_id=...) ← 不知道该查哪个指标名
6. get_metric_names(service_id=...) ← 先查指标名(本该和第 5 步合并)
7. get_metrics(metric="error_rate", start=...) ← 时间格式错了,返回空
8. get_metrics(metric="error_rate", start="2026-10-11") ← 重试,对了
9. search_logs(...) ← 又回去查日志,重复第 4 步
10. get_deploy_history(service_id=...) ← 好思路,但和第 4 步的信息没对上
11. get_deploy_history(start=...) ← 参数错了重调
11 次调用,其中 6 次是无效或重复的。 真正产生信息的只有 5 次。
这 6 次浪费分三类——它们正好对应契约要解决的三种失败模式。
二、三种失败模式
2.1 参数从哪来的,模型自己也不知道
第 1 步和第 2 步:service_id 和 time_range 都没给。模型不是忘了,是它根本不知道有这两个必填参数该从哪取值。它选择了「先调一把看看返回什么」,把工具当探测棒用。
契约里的 from_user / from_prev / from_derive 三来源标注 就是治这个的。当模型被迫写出 service_id: ? (来源:未知) 时,它会自己发现这个值是空的——写下来这个动作本身就触发了自检。
2.2 顺序错了,本该先拿 ID 再查详情
第 3 步的 get_service_list() 出现在第 3 位,本该在第 1 位。这不是规划能力问题,是模型在「拿到任务 → 立刻行动」的默认倾向下跳过了依赖分析。
契约的**第二段四问里第 3 问(必要性)和第 4 问(唯一性)**强制它在调用前问自己:这个值是不是得先拿?有没有更精确的工具?
2.3 失败后临场发挥,越试越远
第 7 步时间格式错了返回空,第 8 步换个格式重试——这次蒙对了。但如果是更复杂的失败呢?比如返回了 200 条无关数据,模型很可能继续换关键词穷举,进入**「穷举螺旋」**。
契约的失败分支就是要提前堵这个。你在调用前就写下「若返回条数 > 1000 → 缩小时间范围」和「若返回空 → 检查时间格式是否为 ISO8601」,失败时照着做,不临场发明。
⚠️ 注意这条纪律的细节:契约要求 MISMATCH 时必须走预先写好的分支,不允许临场改主意。听起来反直觉——临场发挥不是更灵活吗?
不是。因为临场发挥的决策质量远低于事前设计的决策质量。模型在看到一堆意外返回时,注意力已经被那些意外数据占据了,此时它做的决策是「对意外数据反应」,而不是「对任务目标反应」。契约把决策提前到还没有意外数据干扰的时刻。
三、契约四段式详解
3.1 意图:必须是「为了得到 X」句式
❌ 意图:查询订单服务的日志
✅ 意图:为了得到「订单服务在 2026-10-11 全天 ERROR 级别日志的条数与聚类 top5」
差别在于:能不能判断这次调用是否成功。前者调完拿到任何东西都算「查了」,后者有明确的验收标准。
3.2 入参:三来源标注
入参:
service_id : "svc-order-7f3a" ← from_prev(第 1 步 get_service_list 返回的 [2].id)
start : "2026-10-11T00:00:00+08:00" ← from_user(用户说「昨天」,按 CST 展开)
end : "2026-10-11T23:59:59+08:00" ← from_derive(start + 1 天 - 1 秒)
level : "ERROR" ← from_user
from_derive 必须写出推导过程,因为这是最容易出错的一类参数。上面 end 的推导写清楚了「start + 1 天 - 1 秒」,如果写错了(比如写成 +1 天),review 的人一眼能看出来。
3.3 预期返回:形状,不是描述
❌ 预期返回:日志查询结果
✅ 预期返回:
{
total : int,预计 100-500
entries : Array<{
ts : string (ISO8601)
level : "ERROR"
message : string
trace_id: string (32位hex)
}>
}
若 total > 1000,说明时间范围给错了,不是真的有那么多错误
关键在最后那句。 写形状时顺手写下「什么样的返回说明我错了」,这直接变成失败分支的输入。
3.4 失败分支:至少 2 条,且是具体动作
失败分支:
- 若 total = 0 → 先检查时间格式是否为 ISO8601 带时区(上次踩过),
确认后仍为 0 则改用 get_metrics 查 error_rate 曲线交叉验证
- 若 total > 1000 → 时间范围错误,改为按小时切片,先查 14:00-15:00
- 若 service_id 不存在 → 回到 get_service_list 重新取,不要用模糊匹配猜
每条分支的「动作」必须具体到工具名 + 参数级别。「检查一下」这种分支等于没写。
四、实测数据
在 M 公司运维 Agent(23 个工具,日均 400 任务)上做 A/B,同一批 60 个任务,两组各跑一遍:
| 指标 | 无契约 | 有契约 | 变化 |
|---|---|---|---|
| 总调用次数 | 673 | 336 | -50% |
| 无效调用(重复/参数错/返回不符) | 34% | 12% | -22pp |
| 单任务平均轮次 | 11.2 | 5.6 | -50% |
| 输入 token | 4.12M | 2.43M | -41% |
| 输出 token | 318K | 402K | +26% |
| 净成本 | ¥38.6 | ¥24.1 | -38% |
| 任务一次通过率 | 47% | 79% | +32pp |
| 穷举螺旋发生率 | 18% | 2% | -16pp |
有一项是变差的:输出 token +26%。
这是必然的——写契约本身要输出文字。但输出 token 单价比输入低,且输出量增加远小于输入量的减少(契约文本 vs 重复发送的大段工具返回),所以净成本仍是下降的。
⚠️ 成本不是这套方法的主要价值。 主要价值在最后一行:穷举螺旋从 18% 降到 2%。穷举螺旋是最难被监控发现的失败——它不报错、不崩溃,只是慢慢烧钱然后给出一个勉强能用的答案。
五、三种工具类型的契约差异
不是所有工具都该用同一份契约模板。按工具的副作用分三类:
5.1 只读查询类(search / get / list)
重点在精确性:
契约重点:
- 意图必须包含精确的查询维度(时间 / ID / 状态)
- 预期返回必须写条数量级(防止「返回全量」)
- 失败分支侧重「返回过多」和「返回为空」
5.2 写操作类(update / delete / send)
重点在确认与回滚:
契约重点:
- 意图必须显式写「这是写操作,我已确认需要修改」
- 入参必须 100% 来自 from_user 或 from_prev,禁止 from_derive
(不允许模型自己推导出一个要删除的 ID)
- 预期返回必须包含「如何验证修改成功」
- 失败分支必须包含「如何回滚」
⚠️ 写操作类工具强烈建议加人工确认闸门。 契约能拦住误调,拦不住「正确地执行了一个错误意图」。
5.3 组合编排类(一个工具内部调多个子服务)
重点在部分失败的可见性:
契约重点:
- 预期返回必须区分「全部成功 / 部分成功 / 全部失败」三种形状
- 失败分支必须覆盖「部分成功」——这是最容易被忽略的一种
部分成功时模型常常只看成功的那部分就继续往下走
六、可复用模板(精简版)
四段式太长时(比如工具很简单),可以压缩成一行式:
[C] 意图: <为了得到 X>
入参: {k:v} (来源标注)
预期: <形状>
失败: 若 A→做a;若 B→做b
一行式丢失的是第二段的四问自检。实测在简单工具(参数 ≤ 3 个、只读)上一行式足够,在复杂工具上必须回退到完整四段式。
判断标准:如果这个工具的参数里有 ID 类、时间类、范围类,就用完整版。
七、与其他方法的对比
| 方法 | 核心机制 | 优点 | 缺点 | 适合 |
|---|---|---|---|---|
| ReAct | Thought → Action → Observation 循环 | 简单、通用 | 无失败预案,容易穷举 | 探索性任务 |
| Plan-and-Execute | 先出完整计划再执行 | 顺序正确 | 计划僵化,遇意外就崩 | 流程固定任务 |
| 契约式 | 每次调用前声明 + 自检 + 预案 | 拦住无效调用、失败不失控 | 输出变多、交互略慢 | 工具密集型任务 |
| Reflexion | 失败后反思重试 | 能从错误学习 | 反思发生在失败之后 | 允许试错场景 |
契约式不是替代 ReAct,是在 ReAct 的 Action 之前插了一道闸门。 可以完整保留 Thought → Observation 的循环,只在 Thought 和 Action 之间加 CONTRACT。
八、常见误区
误区一:把契约写成注释
❌
# 我要查订单服务的错误日志
search_logs(service_id="svc-order", level="ERROR")
写成注释就是没有契约——模型不会因为写了注释就真的自检。契约必须是一个结构化的、有明确段落标记的块,因为它要被后续的 VERIFY 段落引用。
误区二:失败分支写成「重试」
❌ 失败分支:若失败则重试
「重试」不是分支,是放弃决策。真正的分支必须改变参数或工具,而不是重复同样的动作。
✅ 失败分支:若返回空 → 时间格式改为 ISO8601 带时区后重试;
仍为空 → 换用 get_metrics 查 error_rate 曲线交叉验证
误区三:契约写完就不管了
最常见问题:CONTRACT 写了,调用完了直接往下走,没有 VERIFY 段。
VERIFY 段是这套方法的价值兑现点。没有比对,契约就只是一份没人看的文档。
误区四:对所有工具一视同仁
给一个「获取当前时间」的工具也写四段式契约,是纯粹浪费。判断标准:
- 参数 ≥ 2 个 → 写契约
- 有 ID / 时间 / 范围类参数 → 写契约
- 是写操作 → 必写契约
- 单次调用、参数固定、只读 → 一行式或不写
误区五:以为契约能让模型不犯错
不能。契约能做的是把错误从「执行后才发现」提前到「执行前就被发现」,并且让失败时的行为可控。
模型仍然会写错意图、仍然会推错参数。契约改变的是错误暴露的时机和失败的可控性,不是错误率本身。实测一次通过率 47% → 79%,剩下那 21% 依然存在——但它们现在会在 AUDIT 段里被明确列出「信息不够答」,而不是含糊地给出一个看似完整的答案。
九、落地建议
- 从写操作工具开始。 收益最直接——拦下一次误删就值回票价
- 契约模板放进 system prompt 或项目 rules,不要每次手打
- AUDIT 段落务必保留,它是你持续优化契约的唯一数据来源
- 定期看「被契约拦下的调用」列表——如果某类调用反复被拦,说明工具设计有问题(比如必填参数不合理),该改工具而不是改提示词
十、小结
契约式提示法本质上做了一件事:把模型的决策从「执行时」提前到「执行前」。
这个挪动带来三个好处:
- 无效调用被拦在执行之前(省 50% 调用)
- 失败预案在没有意外数据干扰时制定(穷举螺旋 18% → 2%)
- 每次调用都有可审计的记录(AUDIT 段)
代价是输出 token +26% 和交互略慢。在工具密集型任务上,这个交换非常划算;在纯文本任务上,完全不需要。