Tool Calling 会把模型输出变成一份可供软件执行的提议。这份提议可能是查询天气,也可能是退款、删除文件、数据库操作、发送邮件、购买商品或执行代码。JSON 不是安全边界,执行器才是

生产系统应采用这样的心智模型:

模型提出一个有类型的操作;确定性代码认证 Principal、校验参数、授权精确影响,在受限能力下执行,并把不可信结果返回给模型。

本文建立这份执行契约,解释当前常见供应商接口的边界,并提供一个可运行的执行器核心。技术结论根据 OpenAI、Anthropic 与 MCP 文档核验于 2026 年 7 月 16 日。

核心结论

  • Tool Call 是不可信模型输出,即使严格 Schema 模式保证了参数形状。
  • User、Tenant、权限、价格和所有权必须来自可信服务,不能来自模型参数。
  • 将读工具与写工具分开;提交副作用前先生成预览。
  • 让副作用具备幂等性,并把确认绑定到精确的规范化操作。
  • 只选择性重试读操作;绝不能盲目重试结果未知的写操作。
  • 只有独立、只读或安全幂等的调用才适合并行。
  • Tool Result 可能包含提示词注入、畸形数据、秘密或超大 Payload。
  • 在步骤数、调用数、墙钟时间、Token、费用和重复调用上设置硬预算。
  • 分别评测工具选择、参数语义、鉴权、执行、恢复和最终回答。

Tool Calling 是协议,不是执行

基础循环是:

text
用户请求
  -> 模型获得可用工具契约
  -> 模型输出零个或多个调用提议
  -> 应用校验并鉴权
  -> 执行器调用已批准的工具
  -> 应用返回类型化结果
  -> 模型继续处理或生成最终回答

模型不会自行调用 Python 函数。供应商 API 只是序列化选择和参数,真正决定下一步的是你的应用。

这会产生三个独立的正确性问题:

  1. 选择: 是否选对了工具?
  2. 参数: 参数值是否表达了用户真正的意图?
  3. 授权: 这个 Principal 是否有权产生精确的副作用?

Schema 能帮助解决第二个问题中的语法部分,但不能回答第三个问题。

结构化输出、Tool Calling 与 MCP

结构化输出

当最终产物是数据时使用结构化输出:

  • 提取发票字段;
  • 分类工单;
  • 生成供人工审核的类型化计划;
  • 将文本转换为固定 Schema。

它本身不表示外部操作;Schema 有效也不代表事实一定正确。

Tool Calling

当模型需要选择能力或向执行器提供参数时使用 Tool Calling。模型通常还需要等待结果,因此会形成多轮循环。

MCP

Model Context Protocol 定义了 Tool、Resource、Prompt、能力协商与传输的客户端—服务端接口。它不是模型函数调用的升级版。MCP Host 仍需要:

  • 判断哪些 Server 与 Tool 可信;
  • 向模型只暴露任务所需的受限子集;
  • 保留 User 与 Tenant 身份;
  • 对每次调用执行鉴权;
  • 校验 Server 输出;
  • 管理同意、取消、超时与审计。

供应商 Tool 格式和 MCP 解决的是相邻的不同层。

不同供应商的接口边界

不要混用 Wire Format。

关注点 OpenAI Responses API OpenAI Chat Completions Anthropic Messages
工具定义 扁平的 Function Tool 字段 嵌套的 function 对象 namedescriptioninput_schema
模型调用项 function_call 输出项 message.tool_calls[] tool_use 内容块
结果项 call_idfunction_call_output tool_call_idtool 消息 tool_result 内容块
严格 Schema Function Tool 支持 strict: true 支持 支持 Strict Tool Use

应针对选定的 Endpoint 和模型阅读供应商文档。模型名、支持的 JSON Schema 子集、流式事件和状态续接 API 的变化速度,通常快于执行器架构。

严格 Schema 模式很有价值:它可以保证生成的参数符合受支持的 Schema,但不能保证:

  • 城市真实存在;
  • 订单属于当前用户;
  • 商品有库存;
  • 价格或汇率是权威最新值;
  • 请求安全且已授权;
  • Tool Result 值得信任。

设计窄化的工具契约

好的 Tool Contract 暴露业务意图,而不是基础设施能力。

优先:

text
get_my_order(order_reference)
request_refund(order_reference, reason_code)
preview_subscription_change(plan_id)

避免:

text
run_sql(query)
http_request(url, headers, body)
execute_shell(command)
update_record(table, id, fields)

窄化契约让服务器代码能够从可信上下文推导客户身份并执行业务规则。

不要让模型提供可信字段

处理订单时:

  • 从认证 Session 推导 customer_id
  • 从商品目录加载价格;
  • 在代码中计算税费和折扣;
  • 在服务端解析订单所有权;
  • 在服务端生成幂等 Key;
  • 绝不接受模型输出的 is_adminapprovedrole

模型参数表达用户意图,不表达身份或权限。

支持可靠性的 JSON Schema

建议使用:

  • 明确的 required
  • 在严格模式要求或支持时使用 additionalProperties: false
  • 有边界的字符串和数组;
  • 对稳定业务概念使用 Enum;
  • 金额优先使用整数而不是浮点数;
  • 每个字段只有一个含义;
  • 在描述中写清前置条件和非目标。

示例:

json
{
  "type": "object",
  "properties": {
    "order_reference": {
      "type": "string",
      "pattern": "^[A-Z0-9-]{6,32}$",
      "description": "用户可见的订单号,不是内部数据库 ID。"
    },
    "reason_code": {
      "type": "string",
      "enum": ["duplicate", "damaged", "not_received", "other"]
    }
  },
  "required": ["order_reference", "reason_code"],
  "additionalProperties": false
}

不要让模型重复应用已经持有的值,直接把这些值注入执行器上下文。

生产级执行器流水线

每份提议都应经过:

text
解析 -> Schema 校验 -> 解析 Tool -> 认证 -> 对象鉴权
    -> 业务状态校验 -> 风险分类 -> 必要时确认
    -> 预留幂等键 -> 带超时执行 -> 规范化结果
    -> 审计 -> 返回不可信结果

安全解析与分发

  • 拒绝畸形 JSON;
  • 对未知 Tool 返回错误,不能盲目用函数名索引字典;
  • 使用有版本的 Tool Registry;
  • 拒绝额外参数;
  • 限制参数和结果大小;
  • 区分模型错误、政策拒绝和工具故障。

认证与鉴权

传入可信执行上下文:

text
principal_id, tenant_id, roles, request_id, trace_id, locale

模型不能构造这份上下文。对象级鉴权必须在加载资源后执行,不能只检查 Endpoint 级权限。

提交前预览

对高影响写操作:

  1. 解析权威值;
  2. 生成精确影响的预览;
  3. 展示给用户;
  4. 将批准绑定到规范化参数和资源版本的 Digest;
  5. 只有 Digest 仍匹配时才提交。

这样可以防止把对一个接收人、金额或资源的批准复用于另一个对象。

可运行的执行器核心

下面的标准库示例实现一个服务端订单备注 Tool。模型可以提供订单号和备注,身份和所有权则来自可信上下文。

python
from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from hashlib import sha256
import json


class Status(str, Enum):
    OK = "ok"
    DENIED = "denied"
    INVALID = "invalid"
    DUPLICATE = "duplicate"


@dataclass(frozen=True)
class ExecutionContext:
    principal_id: str
    tenant_id: str
    request_id: str


@dataclass(frozen=True)
class Order:
    reference: str
    tenant_id: str
    owner_id: str


@dataclass(frozen=True)
class ToolResult:
    status: Status
    code: str
    data: dict[str, object]


class OrderRepository:
    def __init__(self, orders: list[Order]) -> None:
        self.orders = {order.reference: order for order in orders}
        self.notes: dict[tuple[str, str], str] = {}

    def get(self, reference: str) -> Order | None:
        return self.orders.get(reference)

    def add_note_once(
        self,
        *,
        order: Order,
        note: str,
        idempotency_key: str,
    ) -> bool:
        key = (order.reference, idempotency_key)
        if key in self.notes:
            return False
        self.notes[key] = note
        return True


def make_idempotency_key(
    context: ExecutionContext,
    tool_name: str,
    arguments: dict[str, object],
) -> str:
    payload = {
        "arguments": arguments,
        "principal_id": context.principal_id,
        "request_id": context.request_id,
        "tenant_id": context.tenant_id,
        "tool_name": tool_name,
    }
    canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    return sha256(canonical.encode()).hexdigest()


def add_order_note(
    arguments: dict[str, object],
    *,
    context: ExecutionContext,
    repository: OrderRepository,
) -> ToolResult:
    if set(arguments) != {"order_reference", "note"}:
        return ToolResult(Status.INVALID, "invalid_arguments", {})

    reference = arguments["order_reference"]
    note = arguments["note"]
    if not isinstance(reference, str) or not isinstance(note, str):
        return ToolResult(Status.INVALID, "invalid_types", {})
    if not 1 <= len(note) <= 500:
        return ToolResult(Status.INVALID, "invalid_note_length", {})

    order = repository.get(reference)
    if order is None:
        return ToolResult(Status.DENIED, "not_found_or_forbidden", {})
    if (
        order.tenant_id != context.tenant_id
        or order.owner_id != context.principal_id
    ):
        return ToolResult(Status.DENIED, "not_found_or_forbidden", {})

    key = make_idempotency_key(context, "add_order_note", arguments)
    created = repository.add_note_once(
        order=order,
        note=note,
        idempotency_key=key,
    )
    if not created:
        return ToolResult(Status.DUPLICATE, "already_applied", {})

    return ToolResult(
        Status.OK,
        "note_added",
        {"order_reference": order.reference},
    )

统一返回 not_found_or_forbidden,可以避免泄露其他用户的订单是否存在。

幂等与写操作语义

网络超时会产生歧义:服务器可能已提交写操作,但客户端没有收到响应。盲目重试可能重复支付、邮件或订单。

每个写 Tool 都应:

  • 接受或派生稳定的幂等 Key;
  • 与副作用原子地存储结果和状态;
  • 对重复 Key 返回原结果;
  • 明确定义 Create、Replace、Append 或 Compare-and-Set 语义;
  • 使用资源版本处理并发更新;
  • 明确取消语义。

重试取决于故障类别:

故障 通常策略
参数或鉴权失败 不重试
Rate Limit 遵循服务器提示并受预算约束地重试
临时读超时 有界指数退避
写操作歧义超时 先按幂等 Key 查询,再决定是否重试
上游永久错误 停止或采用已批准的降级路径

不要把原始 Stack Trace、秘密或内部拓扑直接回传给模型。

并行调用与依赖关系

多个提议不代表可以自动并行执行。

只有在以下条件成立时才并行:

  • 调用相互独立;
  • 调用只读或具备安全幂等性;
  • 不争抢同一配额或资源版本;
  • 顺序不具有业务含义;
  • 部分失败有明确结果。

例如:

  • 查询两个城市天气:通常独立;
  • 先预留库存再创建订单:有依赖;
  • 连续更新同一文档:存在冲突;
  • 发送三封邮件:技术上独立,但有副作用且需要确认。

使用 DAG 表达依赖,限制并发,传播取消信号并保留每个 Call ID。“模型返回了数组”不是并发策略。

Tool Result 是不可信输入

Tool Output 可能包含:

  • 来自网页或邮件的 Prompt Injection;
  • 上游 Bug 导致的其他租户数据;
  • 畸形或异常大的字段;
  • Active HTML、Markdown 链接、代码或文件路径;
  • 秘密和个人数据;
  • 过期记录。

应校验结果 Schema、限制响应大小、脱敏字段、附加来源和时间戳,并让 Tool Data 中的指令保持最低信任级别。不能因为模型要求,就执行 Result 中发现的代码。

有界 Tool Loop

Agent Loop 必须设置硬预算:

  • 最大模型轮数;
  • 总调用数和单 Tool 调用数;
  • 墙钟截止时间;
  • 输入/输出 Token;
  • 费用;
  • 重复相同调用检测;
  • 最大 Result 字节数;
  • 用户取消。

budget_exhaustedrepeated_callapproval_required 等结构化原因停止。不能让模型自行决定循环上限是否生效。

Tool Discovery 与最小暴露

过大的 Tool Catalog 会增加选择错误、Prompt 成本和攻击面。模型看到定义之前,应先按任务路由到专用子集。

Tool Discovery 不能绕过政策:

  • Registry 元数据不等于授权;
  • MCP Server 已安装不代表其中每个 Tool 都可信;
  • Tool 描述和 Server Result 都是供应链输入;
  • 应使用允许的 Server 身份、尽可能固定版本、范围化凭证和逐 Tool 政策。

不泄露秘密的可观测性

记录:

  • Model、Prompt 和 Tool Schema 版本;
  • 脱敏后的 Tool Name 与参数;
  • Policy Decision 与原因;
  • Principal、Tenant、Resource、Call ID 和幂等 Key Hash;
  • 起止时间、重试、超时和 Result Code;
  • 确认与取消事件;
  • 最终结果及 Token/成本。

在 Model、Executor 和下游服务之间使用分布式 Trace。不要记录凭证、完整个人数据或不受限的 Tool Result。

评测

分别评测各层:

选择

  • 是否选对 Tool;
  • 是否正确决定不调用;
  • 相似 Tool 之间的混淆;
  • 存在无关 Tool 时的表现。

参数

  • Schema 是否符合;
  • 字段级语义准确性;
  • 缺少信息时的行为;
  • 是否编造 ID 和值;
  • Locale、日期、货币与单位处理。

执行与安全

  • 对象级鉴权;
  • 跨租户拒绝;
  • 确认绑定;
  • 重复调用幂等;
  • Tool Result 注入;
  • 超时、重试与部分失败;
  • 循环终止。

端到端

  • 用户任务成功率;
  • 最终回答是否以 Tool Result 为依据;
  • 失败后不能声称成功;
  • p50/p95 延迟;
  • 每个成功任务的 Tool Call、Token 与成本。

使用真实匿名化工作流、对抗输入、供应商/模型升级和故障注入进行测试。

生产检查清单

  • [ ] Tool 暴露窄化业务操作,而不是通用基础设施。
  • [ ] 在支持的地方启用严格 Schema。
  • [ ] Executor 再次校验所有参数。
  • [ ] 身份、Tenant、所有权、价格与角色来自可信系统。
  • [ ] 每次调用都执行对象级鉴权。
  • [ ] 写操作具备幂等与明确冲突语义。
  • [ ] 高影响操作使用预览与 Digest 绑定确认。
  • [ ] 并行调用已证明相互独立。
  • [ ] Tool Result 有大小限制、经过校验、脱敏且默认不可信。
  • [ ] 循环、时间、费用和重试预算在模型外执行。
  • [ ] MCP Server 与 Discovery Tool 均被范围化并鉴权。
  • [ ] Trace 支持事故还原且不暴露秘密。

常见问题

可以让模型生成 SQL 或 Shell 作为 Tool 参数吗?

应避免。暴露参数化的领域操作。如果确实需要执行代码,应放入没有环境默认凭证、具备严格资源和网络限制的隔离 Sandbox。

Tool 失败结果应该回传给模型吗?

当模型可以安全恢复时,回传经过规范化和脱敏的错误 Code。不要暴露 Stack Trace,也不要让鉴权失败触发重试。

每个读操作都应该并行吗?

不应该。要考虑下游配额、依赖、一致性、取消和结果预算。受控并发是执行器决定的。

严格 Schema 是否消除了再次校验的必要?

没有。它只能消除受支持 Schema 的一类形状错误;业务、鉴权、新鲜度和跨字段约束仍然必须在服务端处理。

总结

可靠的 Tool Calling 是应用运行时工程,不是 Prompt 技巧。模型提供灵活的意图理解,执行器提供身份、政策、事务、限制和证据。

设计每个 Tool 时都假设:一个不可信但很有说服力的调用者会用完全合法的 JSON 调用它。如果在这个条件下后端仍然安全且正确,这份 Tool Contract 才适合交给模型。

输出形状控制可参考约束解码指南;恶意元数据、依赖、Tool 更新和返回指令的风险,应使用 AI Agent 工具安全威胁模型处理。

一手资料