专栏 Agent 工程

工具调用:意图、schema 与副作用

我问它: 这个文件里还有几处 TODO?

工具调用:意图、schema 与副作用

我问它:”这个文件里还有几处 TODO?”

它没有读文件。它输出的是一段 JSON:

{"name": "shell_execute", "arguments": {"command": "rg -c TODO src/main.kt"}}

这段话不是答案,是一个请求。真正读文件、数行数、把结果变成文字的,是另一个进程。

循环转起来之后,下一个问题是:模型凭什么知道有哪些工具可用,一段 JSON 又是怎么变成真实动作的。

一、模型看到的是一份说明书

模型对工具的全部认知,来自调用请求里附带的一段定义。它长这样:

{
  "name": "shell_execute",
  "description": "在 Linux 沙箱中执行 shell 命令……",
  "parameters": {
    "type": "object",
    "properties": {
      "command": {"type": "string", "description": "要执行的命令"}
    },
    "required": ["command"]
  }
}

三个字段各司其职。name 是调用时的标识;description 决定它什么时候会想起这个工具;parameters 约束它怎么填参数。

这里最容易误解的是 description。它不是给人看的注释,是模型判断”该不该用这个工具”的唯一依据。写得含糊,模型就会在该用的时候不用、不该用的时候乱用;写得清楚,很多所谓的”提示词调优”根本不需要。

参数的 schema 描述的是意图,不是实现。city: string 背后的实现可能是查数据库、调 API 或者读本地缓存,模型完全不需要知道。它只需要知道”这里要填一个城市名”。

二、一份定义,三种投影

不同厂商的工具调用协议长得不一样。Anthropic 叫 input_schema,Gemini 叫 function_declarations,OpenAI 叫 function calling,字段位置和大小写都有出入。

如果每个工具各写三份,改一个参数要改三处,迟早对不上。所以宿主里维护的是一份与厂商无关的中间表示:

data class AgentToolDefinition(
    val name: String,
    val description: String,
    val parameters: Map<String, AgentToolParam>,
    val required: List<String> = emptyList(),
)

再由三个适配方法投影出去:toAnthropicJson()、toGeminiJson()、toOpenAIJson()。

flowchart LR
    D[AgentToolDefinition] --> A[Anthropic input_schema]
    D --> G[Gemini function_declarations]
    D --> O[OpenAI function calling]
    A --> M[模型]
    G --> M
    O --> M

这个设计不复杂,但它决定了一件事:工具的能力是宿主定义的,不是厂商定义的。换模型只需要换投影,工具本身不动。

三、执行发生在另一个世界

模型输出 JSON 之后,宿主要做四件事:找到同名工具、解析参数、真正执行、把结果包成一条消息。

真正执行的那一端,和模型隔着一整个世界。以 shell_execute 为例,参数 command 最终会变成沙箱里的一个进程;以 android-weather 为例,它会变成一次系统 API 调用。模型对这一切一无所知,它只知道自己发了一个请求,然后收到了一段文字。

这带来一个必须提前想清楚的问题:副作用能不能撤销。

读文件、查天气、搜索网页,这些没有副作用,失败了重试就是。写文件、发消息、改日历、执行命令,这些有副作用,重试可能造成二次伤害。所以工具在设计上天然分成两类,宿主对它们的容错策略也应该不同——这条线会在后面几篇反复出现。

四、开关装在读取处,不装在界面上

工具不是全都该一直开着。浏览器、子 Agent、向用户提问,这三类能力在设置里各有开关。

实现上有个细节值得单独说。开关的判断没有放在设置页,而是放在每一次读取工具列表的地方:

enum class AgentToolSwitch(val key: String, val defaultValue: Boolean) {
    BROWSER("agent.tools.browser.enabled", true),
    AGENTS("agent.tools.agents.enabled", true),
    USER_ASK(UserAskSettingsSpec.ENABLED_KEY, UserAskSettingsSpec.DEFAULT_ENABLED),
}

源码注释的原话是:这个闸门装在读取处,而不是只装在界面上;构建 schema、循环分发、CLI 桥接三条路径都会调用 isToolEnabled,所以一个存下来的 true 没法绕过设置页把能力重新打开。

这句话翻译过来是一条通用原则:权限判断要放在数据出口,不要放在用户界面。界面只是若干条路径中的一条,任何一条没走界面的路径都会成为漏洞。

五、模型不总是听话

schema 是一份契约,但签约方经常违约。

最常见的是参数类型漂移。某些 OpenAI 兼容网关会把嵌套数组塞成字符串:本该是 [{...}],实际发来的是 "[{...}]"。宿主如果直接按数组解析就崩,如果直接拒绝又太苛刻——模型的意思其实很清楚。

Minis 的处理是在执行前做一次强制转换:按 schema 声明逐个检查,能转的就转,转不了的保持原样交给工具自己报错。源码里记着触发频率:某类模型七次调用里有五次发的是字符串。

比类型漂移更危险的是参数被截断。模型生成到一半撞上输出上限,JSON 在字符串中间断了。自动补一个引号能让 JSON 合法,但补出来的内容不是模型想写的。所以实现里有一条硬规则:

写操作(file_write、file_edit)遇到截断参数一律拒绝执行,只读工具和 shell 继续放行。

理由是源码注释里那句话:对写操作来说,半个文件落盘是静默的数据损坏,比不写更糟。

这条规则的价值不在具体实现,在于它承认了一件事:修复模型的错误,要看错误的后果。能无损修复的就修,修了会有副作用的就停。一刀切地”尽力修复”或者”一律拒绝”都不对。

六、工具面就是能力面

回到最初的问题:为什么同一个模型,在这个 App 里能改文件、查天气、读通知,在网页版里只能聊天?

因为工具清单不一样。模型本身没有变化,变的是它手里有什么。

这带来一个判断:评估一个 Agent,先看它的工具清单,再看它的模型。清单决定了能力的上界——没有 shell_execute,它永远只能描述命令而不能执行;没有写文件的工具,它永远只能建议你改哪一行。

但工具不是越多越好。每加一个工具,就多一份说明书要占用上下文,多一条路径要写权限,多一种副作用要想清楚怎么撤销。更麻烦的是”万能工具”:一个 shell_execute 几乎能做所有事,也几乎能造成所有后果。它省掉了设计十个专用工具的成本,也把判断风险的责任全部推给了权限层。

所以工具设计真正的取舍不是”多与少”,是接口的窄与宽。窄接口安全但笨,宽接口灵活但危险,中间那条线画在哪里,取决于你愿意为每个动作承担多少后果。


下一篇往回收一步:这些东西每多一轮,就要重新塞进模型一次。上下文是怎么被塞满的,又是怎么被腾出来的。