跳至主要内容
工具调用协议:从 Schema 到幂等

工具调用协议:从 Schema 到幂等

一个工具的 JSON Schema 写得好不好,我一般是看它能不能拦住一次真实的误调用。之前有个 query_orders 工具,参数 status 我写成自由字符串,模型某次填了 "not cancelled",代码把这个字符串透传给 SQL,查回一个空集,模型据此告诉用户「没有任何订单」。接口没报错,Schema 也过了,错的是我把本该是枚举的东西留成了字符串。

从那以后我把工具调用当成一条协议来设计,而不是一组函数签名。

本系列第 3/15 篇 · 上一篇:规划器:任务分解与可校验计划 · 下一篇:拆开一条 RAG 链路

协议里到底传了什么

主流模型侧的 function calling / tool use,本质是一次受约束的结构化输出:请求里带一组工具定义,模型返回 tool_calls 数组,每项含 id、name、arguments(通常是 JSON 字符串),调用方执行后把结果以 role=tool 的消息回灌,带上对应的 id。模型并不执行任何东西,它只负责「选哪个、填什么」。

这条链路里有四层独立的关注点,混在一起写迟早出事:

层 负责什么 典型实现
声明层 告诉模型有哪些能力、边界在哪 name / description / JSON Schema / strict 模式
生成层 模型产出结构化调用 受限解码、tool_choice、并行调用
校验层 拦住不合法或不安全的参数 类型、枚举、范围、权限、业务约束
执行层 真正改变外部世界 超时、重试、幂等、副作用账本

很多问题都来自把校验层省略了,指望「Schema 会约束模型」。Schema 是给模型的提示,不是给系统的保证:字段名可能拼错、参数值可能越界、模型也可能在不支持严格模式的接口上自由发挥。校验层必须是服务端代码,且必须在执行之前。

工具声明:写描述比写类型更花时间

name 和 description 决定了模型会不会选对工具,parameters 决定它能不能填对。几个我踩过之后固定下来的做法:

用枚举代替自由字符串。 状态、排序方向、单位、时间粒度,能列尽就列尽。开头的 status 改成 enum: ["pending","paid","cancelled","all"] 之后,那类误调用直接消失了。

把边界写进描述。 「返回最近 N 条订单,limit 上限 100,默认 20;不传 user_id 时只能查当前用户」这类信息,模型只能从描述里拿到。类型系统表达不了业务语义。

参数粒度对齐一次动作。 一个工具如果既支持按 ID 查、又支持按时间范围查、还支持全文搜索,模型就得在三种模式里猜。我更愿意拆成三个工具,每个参数少而明确。工具数量多一点不是问题,语义重叠才是——模型在近义工具之间选错,比参数填错更难查。

不要暴露实现细节。 参数里出现 sql_where、raw_filter 这类字段,等于把注入面直接交给模型。工具应该是业务动作,不是数据库代理。

JSON15 行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"name": "search_orders",
"description": "按状态和时间范围检索订单。只返回订单摘要,不含支付信息。时间范围最大 90 天,超过请分批查询。",
"parameters": {
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["pending", "paid", "cancelled", "all"]},
"start_date": {"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$"},
"end_date": {"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$"},
"limit": {"type": "integer", "minimum": 1, "maximum": 100}
},
"required": ["status", "start_date", "end_date", "limit"],
"additionalProperties": false
}
}

required 把所有字段都列上,是为了避免模型省略 limit 后由服务端默认值悄悄改变语义。additionalProperties: false 配合严格模式,能拦住模型自创字段。如果接口支持 structured output / strict 模式,我会打开;不支持时,就在校验层做等价的强校验。

工具集本身也需要做减法。我删过一批「看起来有用」的工具:能覆盖现有工具组合的快捷版、只在极少数场景用到的冷门接口、以及返回结构不稳定的调试工具。工具越多,选错的概率越高,而且每多一个工具定义,每次请求都要重复付一遍 token。现在我的判断标准是:如果两个工具在描述里需要用超过一句话才能说清区别,就该合并或砍掉一个。

校验层与执行层必须分开

校验层是纯函数,不产生副作用;执行层才碰外部世界。分开之后,重试、审计、模拟执行都好做。

PYTHON25 行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def invoke(call, ctx):
spec = REGISTRY.get(call.name)
if spec is None:
return ToolError("unknown_tool", recoverable=True)
try:
args = json.loads(call.arguments)
jsonschema.validate(args, spec.schema) # 声明层约束
args = spec.coerce(args) # 归一化:日期、大小写、去空格
spec.authorize(ctx.user, args) # 权限,绝不信模型传的 user_id
spec.check_business(args, ctx.state) # 时间跨度、预算、依赖检查
except (JSONDecodeError, ValidationError, AuthError) as e:
return ToolError("invalid_args", detail=str(e), recoverable=True)

key = f"{ctx.task_id}/{call.id}/{spec.name}/{stable_hash(args)}"
if spec.side_effect != "recomputable" and ledger.seen(key):
return ToolResult(ledger.get(key), cached=True) # 幂等命中

ledger.mark(key, "intent")
try:
out = spec.run(args, timeout=spec.timeout_s)
except TimeoutError:
ledger.mark(key, "unknown") # 不能当失败直接重试
return ToolError("timeout", recoverable=False)
ledger.mark(key, "committed", out)
return ToolResult(truncate(out, spec.max_chars))

几个点值得展开。

spec.coerce 做参数归一化,这是重复动作检测的前提。带时间戳、随机 ID 的参数每次看起来都不同,不归一化就检测不出「同一个动作在原地打转」。

spec.authorize 里我从不使用模型传来的 user_id、tenant_id。身份来自会话上下文,模型提供的身份字段一律忽略或校验一致性。这是越权最常见的入口。

ledger 的三态 intent → committed / unknown 是第 1 篇副作用账本的落地。超时之后进程可能已经把请求发出去了,重试等于重放;标成 unknown 就必须先查外部系统对账,查不到交给人工。很多重复副作用不是重试太多,而是系统把「不确定」当成了「失败」。

truncate 属于上下文工程,但放在执行层最合适:一次查询返回三万行,直接回灌会瞬间吃掉窗口,还可能把关键字段挤出去。我一般保留结构化摘要 + 前若干条样本,并明确告诉模型「结果已截断,共 N 条」。

错误回灌:把失败变成可学习的观测

工具报错怎么回给模型,比想象中影响大。直接抛异常栈,模型会开始瞎改参数试探;只回「失败」,它会在原地反复调用同一动作。有效的错误信息应该包含三件事:错在哪一类、能不能重试、下一步该改什么。

JSON8 行
1
2
3
4
5
6
7
8
{
"status": "error",
"kind": "invalid_args",
"recoverable": true,
"message": "end_date 早于 start_date",
"hint": "请交换两个日期后重试;若确实要查跨年数据,分两次查询",
"raw": null
}

recoverable 让上层决定是回灌给模型重试、还是走重规划(第 2 篇的触发条件之一)。不可恢复的错误(权限不足、资源不存在、外部系统 5xx)直接回灌给模型重试没有意义,只会烧 token。

还有一类特别容易漏:空结果不是错误,但必须带语义。开头那个事故的根因就在这里。我现在所有可能返回空集合的工具,都把结果包成 {"status":"empty","meaning":"查询条件内无匹配记录,不代表数据不存在或无限制","raw":[]}。meaning 这行是写给模型看的,它比在 system prompt 里写十遍「注意空数组」有效得多,因为它出现在观测发生的位置。

并行调用与顺序约束

模型可以在一次回复里给出多个 tool_calls。我只在确认它们彼此无依赖、且都是只读动作时才并发执行。判断依据不靠模型声明,靠工具的元数据:side_effect 为 recomputable 或 readonly 才允许进并发组;带写入的动作一律串行,按 tool_calls 顺序执行。

并发还有个成本问题:多个结果一起回灌,上下文会一次性膨胀。我给并发结果设一个总字符预算,超了就先做摘要再合并。另外,tool_call_id 必须严格对应,乱序回灌在某些模型实现里会让后续推理错位。

优劣与边界

结构化工具调用相对「让模型输出一段自然语言,再用正则解析」的优势是明显的:可校验、可鉴权、可审计、可并行,参数错误能在执行前拦住。代价是灵活性下降——模型只能做你声明过的动作,遇到没覆盖的需求会退化成反复试探或胡编一个工具名。所以工具集要和真实任务分布一起迭代:从日志里统计「模型想调但调不到」的动作,比凭空加工具靠谱。

另一个代价是 token。每个工具定义每轮都要重新发送,十几个工具带上详细 Schema,固定开销就不小。长会话里我会按当前 phase 动态裁剪工具集,只暴露这一步可能用到的工具,既省钱也降低选错率。这件事有风险:裁掉了模型确实需要的工具,它会卡住。我的做法是保留一个 request_capability 的兜底工具,让模型能显式申请扩容,而不是干脆失败。

适用边界:动作集合有限、参数可枚举、需要权限和可审计的企业场景,这套协议几乎是必须的。开放式探索、需要即兴组合命令行或写代码的任务,把动作限制成固定函数反而更别扭,那时更适合给一个受沙箱约束的代码执行环境。两者不冲突,我通常把「安全、高频、结构化」的操作做成工具,把「一次性、组合型」的操作放进沙箱。

到这里,Agent 侧的循环、规划、动作三块拼完了。但工具里最常见也最难做的那个是「检索知识库」——它背后是一整套离线索引与在线召回的流水线。下一篇把 RAG 链路整个拆开,看看这个工具内部到底该长什么样。你现在注册的工具里,有多少个的 description 写清楚了「什么情况下不该用它」?

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#Agent #工具调用 #工程

评论