类型化工具接口

工具调用 解释了为什么每次工具调用都必须经过运行时的验证和授权才能生效,智能体沙箱 则解释了已获授权的调用在运行后如何受到约束。本附录汇集了工程师构建这一边界所需的可复用材料:模式规则与工具定义参考示例、将输出约束为符合模式的盈亏平衡分析、用于幂等键和端点结算的参考代码、用于能力检查及其衰减的参考代码,以及采用小型通用工具目录背后的计算分析。本附录是构建工具接口的工作台,每一节都指向论证相应设计的章节。

如何使用本附录

根据你在工具边界处遇到的故障,查阅相应章节。

  • 当工具调用未通过验证、编造参数或混淆单位时,使用 节 1.1 中的模式规则和定义参考示例。
  • 当模型生成格式错误调用的频率高到使修复轮次的成本超过预防成本时,使用 节 1.2 中的盈亏平衡分析,判断是否应约束解码。
  • 当超时、连接中断或模型重试可能导致副作用重复发生时,使用 节 1.3 中的键派生方法和端点记录。
  • 当工具调用或受委派的子智能体需要有界权限时,使用 节 1.3 中的能力检查和衰减代码。
  • 当工具目录占据每次上下文的很大一部分,或模型选错工具时,使用 节 1.4 中的词元计算分析。

工具定义的模式规则

工具模式会被读取两次。模型将其中的名称和描述作为上下文的一部分来读取,并据此提出参数;运行时则读取其中的类型和边界,在授权之前验证每个拟议调用(工具接口模式)。只服务于其中一方的模式会无法满足另一方的需要。以下五条规则可使模式兼顾双方。

  1. 封闭属性集合(additionalProperties: false)。 如果模式允许,模型就会提出看似合理、但工具并不接受的参数,例如 dry_run、verbose 或 user_id。封闭模式使运行时拒绝未声明的输入,而不是默默将其传递下去,也使受语法约束的解码器能够在生成这些输入之前将其排除(文法引导解码)。
  2. 在 required 中列出所有必填字段。 在描述中说明某个参数为必填项,只是给模型的建议;required 数组才是验证器强制执行的规则。
  3. 为每种类型设定边界。 使用 minimum 和 maximum 约束数值,使用 pattern、enum 或 maxLength 约束字符串,使用 minItems 和 maxItems 约束数组。不设边界的输入可能使一次调用携带的参数超出工具或记录该参数的上下文所能容纳的大小。
  4. 在名称和描述中明确单位与约定。 使用 timeout_ms 而非 timeout,说明行号是否从 1 开始,并说明可选字段缺失时的含义。
  5. 为每个联合类型添加标签。 缺少判别字段的 anyOf 或 oneOf 会让模型只能猜测自己正在填充哪个分支,也使验证器无法报告哪个分支未通过验证。

同一模式还承载副作用标注,告知运行时如何处理调用(只读、幂等或破坏性);该标注映射到权限级别 \(A_0\) 至 \(A_3\),并决定调用是否需要幂等键、补偿器或审批(工具接口模式)。

工具定义参考示例

代码 1 定义了一个遵循上述全部五条规则的精确替换编辑工具。

列表 1: Reference Tool Definition: A JSON Schema (Draft 2020-12) for an exact string replacement tool with a closed property set, bounded strings, and failure behavior stated in the description.
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "name": "file_edit",
  "description": "Replaces an exact block of text in an existing file. old_str must match exactly one contiguous block unless allow_multiple is true; if it matches zero blocks, or more than one when allow_multiple is false, the file is left unchanged and the call returns an error naming the match count.",
  "parameters": {
    "type": "object",
    "properties": {
      "path": {
        "type": "string",
        "description": "Absolute path to the target file, inside the workspace root.",
        "pattern": "^/([a-zA-Z0-9_.-]+/)*[a-zA-Z0-9_.-]+$",
        "maxLength": 1024
      },
      "old_str": {
        "type": "string",
        "description": "The exact block to replace, including indentation and newlines.",
        "minLength": 1
      },
      "new_str": {
        "type": "string",
        "description": "The replacement text. May be empty to delete old_str."
      },
      "allow_multiple": {
        "type": "boolean",
        "description": "If true, replace every match; if false, fail unless exactly one match exists.",
        "default": false
      }
    },
    "required": ["path", "old_str", "new_str"],
    "additionalProperties": false
  }
}

手工编写模式容易导致模型所见的模式与实际运行的代码之间出现偏离。基于参数的类型化模型生成模式,可以使二者保持一致。代码 2 以 Python 数据验证库为例,展示了一种实现方式;任何类型化模式生成器都能达到同样的目的。

列表 2: Generating a Schema from Typed Arguments: A typed argument model that forbids extra fields and exports the JSON Schema the runtime publishes to the model and validates against.
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, StringConstraints

class FileEditInput(BaseModel):
    """Arguments for the exact-replacement edit tool."""
    model_config = ConfigDict(extra="forbid", frozen=True)

    path: Annotated[
        str,
        StringConstraints(strip_whitespace=True, max_length=1024,
                          pattern=r"^/([a-zA-Z0-9_.-]+/)*[a-zA-Z0-9_.-]+$"),
        Field(description="Absolute path to the target file, inside the workspace root."),
    ]
    old_str: Annotated[str, StringConstraints(min_length=1),
                       Field(description="Exact block to replace.")]
    new_str: Annotated[str, Field(description="Replacement text; may be empty.")]
    allow_multiple: Annotated[bool, Field(default=False,
                              description="Replace every match instead of exactly one.")]

def tool_descriptor() -> dict:
    """The descriptor the runtime publishes to the model and validates against."""
    return {
        "name": "file_edit",
        "description": "Replaces an exact block of text in an existing file.",
        "inputSchema": FileEditInput.model_json_schema(),
    }

模式反模式

从现有服务接口改造而来的工具,往往保留了适合程序员从代码中调用的模式,却会误导根据描述提出调用的模型。工具模式反模式 列出了常见的此类模式。

工具模式反模式:误导模型或使验证器失效的常见模式、各自的加固形式,以及它们对边界的影响。
反模式 问题所在 加固形式 效果
无类型参数包(args: dict) 模型凭空生成键;验证器无从检查 使用 additionalProperties: false 的显式类型化字段 无效调用被拒绝,或在约束解码下根本不会生成
无界输入(string,不设限制) 单次调用携带的参数超过工具或上下文所能容纳的大小 maxLength、maxItems 和显式窗口参数 在分发之前限制参数大小
无标签联合类型(anyOf,不设判别字段) 模型混用多个分支的字段;错误信息无法指明所属分支 设置判别字段,例如 "kind",其值为 "file" 或 "dir" 每次调用显式选择一个分支
隐式单位(timeout: 30) 秒、毫秒和分钟相互混淆 在名称中标明单位(timeout_ms),并设定取值范围 单位错误转化为验证错误
整文件重写(content: str) 输出长度随文件增长,长文件被截断,无关行发生改动 精确替换一个文本块(old_str、new_str) 输出 token 数随改动规模增长,而非随文件大小增长

最后一行对延迟的影响最大,因为在智能体的关键路径上,输出 token 的成本很高(加速器服务延迟)。整文件重写需要为每一行未改动的内容付出解码时间,而 接口基准测试 给出了一个具体示例:重写未能满足单次调用的截止时间,而带锚点的差异补丁满足了这一要求。

何时约束解码

自由采样的模型可能生成格式错误的调用:未转义的换行符、末尾多余的逗号,或缺失的必填字段。语法约束解码通过将每一步采样限制在模式语法当前状态所允许的 token 范围内,防止此类错误(文法引导解码)。解码循环机制 估算了掩码表的大小,并解释了为什么掩码操作必须在采样器旁执行;确定性有限自动机与语法约束 Logit 解码 则证明了结果始终可以解析。本节进一步讨论何时采用约束才划算,因为另一种做法——让格式错误的调用在验证时失败,再要求模型修复——也有其自身的成本。

设一次工具调用包含 \(L_{\text{gen}}\) 个输出 token,每个 token 耗时 \(t_{\text{decode}}\);掩码操作为每个 token 增加 \(t_{\text{mask}}\) 的耗时;\(p_{\text{err}}\) 为无约束调用无法解析的比例;一次修复轮次的延迟成本为 \(T_{\text{repair}}\),其中包括重新发送上下文和重新生成调用的耗时。在不施加约束且允许一次修复的情况下,期望延迟为

\[\mathbb{E}[T_{\text{free}}] = L_{\text{gen}} \, t_{\text{decode}} + p_{\text{err}} \, T_{\text{repair}}\]

施加约束时,期望延迟为

\[T_{\text{constrained}} = L_{\text{gen}} \, (t_{\text{decode}} + t_{\text{mask}})\]

令二者相等,得到盈亏平衡错误率

\[p_{\text{break}} = \frac{L_{\text{gen}} \, t_{\text{mask}}}{T_{\text{repair}}} \tag{1}\]

只要观测到的格式错误率超过 \(p_{\text{break}}\),采用约束就更划算。该错误率取决于具体的模型、模式和提示,必须根据工具自身的调用流量进行测量。例如,对于包含 80 个 token 的调用,若每个 token 的掩码开销为 0.5 毫秒,一次修复轮次耗时 1.8 秒,则 公式 1 得到 \(p_{\text{break}} = 40\ \text{ms} / 1{,}800\ \text{ms} \approx 2.2\)%,因此,如果某工具的调用无法解析的频率高于约每四十次一次,施加约束的成本就更低。延迟只是考虑因素之一。修复轮次还会向上下文添加错误文本,使下一次针对同一对象提出的调用更可能重复该错误(**上下文污染动力学**)。

约束只保证语法正确。可以解析的调用仍可能指定不存在的文件、传入超出任务权限范围的参数,或以正确的执行方式完成错误的操作。因此,在约束解码之后,验证、授权和执行包络仍须保留(文法引导解码)。

幂等键与能力

两项机制使已获授权的调用能够安全发送。幂等键使重复调用不会产生额外影响,因此,当运行时无法判断一个已超时的调用是否执行过时,可以核实并确定其状态,而不必猜测(幂等动作执行)。能力限制调用执行后能够访问的范围,因此,即使调用提议受到诱导或出现错误,其操作也不会超出任务允许的范围(权能与凭据)。各章论证了这两项机制的必要性。本节给出各自的参考形式。

推导键

键必须标识逻辑调用,而非某次尝试,因此运行时根据轨迹以确定性的方式推导该键。幂等动作执行 使用

\[k_{\text{idem}} = \mathcal{H}\big(\text{trajectory\_id} \,\|\, \text{turn} \,\|\, \text{call\_id} \,\|\, \text{tool} \,\|\, \text{canonical\_args}\big)\]

其中,\(\mathcal{H}\) 是密码学哈希;当键不能被端点的其他客户端猜出时,则使用带密钥的哈希。在计算哈希之前,必须对参数进行规范化序列化。普通 JSON 序列化不会固定键的顺序或空白字符,因此 {"path": "/a", "force": true} 和 {"force": true, "path": "/a"} 会生成不同的哈希键,导致去重失效。规范形式,例如 RFC 8785 的 JSON 规范化方案,会对键排序,并固定数字和字符串的编码方式。由于键是推导得出的,而非随机生成的,运行时在崩溃恢复后重新发送调用时,会重新生成相同的键,端点便能识别出重复调用(意图先于效果)。

端点记录

在端点上,每个键都映射到一条记录,记录具有四种状态之一;正是这些状态之间的转换保证了重试的安全性。

  1. 未见过。 端点通过“仅在不存在时设置”操作原子性地认领该键,并设置一个租约,其有效期长于最坏情况下的执行时间、调用方超时时间以及任何时钟偏差之和(幂等动作执行),随后执行调用。
  2. 执行中。 如果重复调用在首次执行仍在进行时到达,则拒绝该调用或使其等待。绝不并行执行重复调用。
  3. 已完成。 对已完成调用的重复请求,返回已存储的结果,不再执行。
  4. 状态存疑。 如果执行失败且其影响无法确定,例如执行器在执行中途崩溃,记录会保持已认领状态,并标记为状态存疑。在核对探测检查环境并将记录确定为已完成或未执行之前,拒绝重复调用。

第四种状态是简单实现通常遗漏的状态。异常发生后释放键,会使下一次重试再次执行,而这正是设立该键所要防止的重复执行,因为异常并不能证明操作未产生影响。代码 3 实现了全部四种状态。

列表 3: Endpoint Deduplication with Settlement: A reference endpoint that claims each idempotency key atomically, returns the stored result for completed keys, refuses repeats while a call is in flight, and marks a failed execution as in doubt rather than releasing its key.
import hashlib, json, time
from typing import Any, Callable, Dict

def canonical(args: Dict[str, Any]) -> str:
    """Stable serialization: sorted keys, fixed separators."""
    return json.dumps(args, sort_keys=True, separators=(",", ":"), ensure_ascii=True)

def derive_key(trajectory_id: str, turn: int, call_id: str, tool: str,
               args: Dict[str, Any]) -> str:
    material = f"{trajectory_id}|{turn}|{call_id}|{tool}|{canonical(args)}"
    return hashlib.sha256(material.encode("utf-8")).hexdigest()

class DedupEndpoint:
    """Executes each logical call at most once per idempotency key."""

    def __init__(self, store, lease_s: float):
        self.store, self.lease_s = store, lease_s   # store offers set_if_absent/get/put

    def handle(self, key: str, execute: Callable[[], Dict[str, Any]]) -> Dict[str, Any]:
        claimed = self.store.set_if_absent(
            key, {"state": "IN_FLIGHT", "until": time.time() + self.lease_s})
        if not claimed:
            record = self.store.get(key)
            if record["state"] == "COMPLETED":
                return record["result"]                       # repeat: stored result
            return {"status": record["state"], "retry": True}  # IN_FLIGHT or IN_DOUBT
        try:
            result = execute()
        except Exception as exc:                              # effect unknown
            self.store.put(key, {"state": "IN_DOUBT", "error": str(exc)})
            return {"status": "IN_DOUBT", "retry": False}     # settle by probe first
        self.store.put(key, {"state": "COMPLETED", "result": result})
        return result

若工具的端点无法保留这样的记录,就需要采用另一条结算路径:通过读取环境的对账探测,在任何重试之前判断该操作是否已产生效果(幂等动作执行)。

能力检查

权能与凭据 将能力表示为 \(C = (R, O, P, E)\),并使用仅由运行时持有的密钥进行签名:\(R\) 是权限集合(读取、写入、执行、连接),\(O\) 是该能力所指定的对象(路径前缀、主机名、仓库),\(P\) 是约束参数和成本的谓词,\(E\) 是到期时间。为某条轨迹签发且受 \(E\) 约束的能力,是该轨迹对能力中所列资源的租约(每条轨迹独享工作区租约,按租户重置)。只有在签名验证通过、操作属于权限集合、目标位于指定对象范围内、成本满足谓词且能力尚未到期时,运行时才会批准提议。

委派工作时,子级能力必须由父级能力衰减而来。用 \(C_c \preceq C_p\) 表示“子级持有的能力不超过父级”,

\[C_c \preceq C_p \iff R_c \subseteq R_p \;\land\; O_c \sqsubseteq O_p \;\land\; P_c \Rightarrow P_p \;\land\; E_c \le E_p\]

因此,子级绝不能持有父级不具备的权限、访问父级范围之外的对象、支出超过父级的上限,或在父级授权到期后继续持有授权(衰减能力委派;原则 21)。代码 4 实现了检查和衰减,为简洁起见,将谓词简化为成本上限。

列表 4: Capability Check and Attenuation: A reference implementation of the capability \(C = (R, O, P, E)\), with the predicate reduced to a cost ceiling, a runtime-held signing key, and an attenuation step that refuses to widen any component.
import hashlib, hmac, time
from dataclasses import dataclass
from typing import FrozenSet

@dataclass(frozen=True)
class Capability:
    rights: FrozenSet[str]     # R: e.g. {"read", "write"}
    objects: FrozenSet[str]    # O: path or host prefixes
    max_cost: float            # P: reduced here to a cost ceiling
    expires_at: float          # E: epoch seconds
    sig: str = ""

    def body(self) -> bytes:
        return repr((sorted(self.rights), sorted(self.objects),
                     self.max_cost, self.expires_at)).encode()

def sign(cap: Capability, key: bytes) -> Capability:
    return Capability(cap.rights, cap.objects, cap.max_cost, cap.expires_at,
                      hmac.new(key, cap.body(), hashlib.sha256).hexdigest())

def within(target: str, objects: FrozenSet[str]) -> bool:
    return any(target == o or target.startswith(o.rstrip("/") + "/") for o in objects)

def approve(cap: Capability, key: bytes, op: str, target: str, cost: float) -> bool:
    good_sig = hmac.compare_digest(
        cap.sig, hmac.new(key, cap.body(), hashlib.sha256).hexdigest())
    return (good_sig and op in cap.rights and within(target, cap.objects)
            and cost <= cap.max_cost and time.time() < cap.expires_at)

def attenuate(parent: Capability, key: bytes, *, rights, objects,
              max_cost, expires_at) -> Capability:
    """Issue a child capability; refuse any component wider than the parent's."""
    if not (set(rights) <= parent.rights
            and all(within(o, parent.objects) for o in objects)
            and max_cost <= parent.max_cost and expires_at <= parent.expires_at):
        raise PermissionError("attenuation may only narrow a capability")
    return sign(Capability(frozenset(rights), frozenset(objects),
                           max_cost, expires_at), key)

检查在模型下层的运行时和沙箱驱动程序中执行,每次访问都会触发。模型写入的任何内容都无法扩大能力权限,因为模型从不持有签名密钥;如果提案指定的目标超出能力权限范围,它会在任何宿主操作执行之前失败。

少量通用工具构成的目录

一种自然而直接的初始目录设计,是将现有服务的每个端点各自封装为一个工具:git_status、git_diff、git_add、git_commit,并以此类推,覆盖智能体接触的每个系统。结果便是数十乃至数百个专用工具。Ousterhout 将接口相对于其背后功能过大的模块称为浅模块 (Ousterhout 2018年);专用工具目录恰好也在这一意义上是浅的,而智能体每一轮都要为此付出代价。工具集粒度划分 论证了这一设计选择。本节给出具体的数量分析。

Ousterhout, John. 2018年. A Philosophy of Software Design. Yaknyam Press.

目录的 token 成本

每个工具的定义都会随每次可能使用该工具的调用一同发送。对于包含 \(K\) 个工具、每个工具定义平均占 \(S\) 个 token 的目录,一条包含 \(N\) 轮的轨迹会发送

\[T_{\text{catalog}} = N \cdot K \cdot S \tag{2}\]

个工具定义 token。例如,一个包含 80 个专用工具、每个工具约占 350 个 token 的目录,每轮需要 28,000 个 token,在一条 40 轮轨迹中总计需要 1,120,000 个 token。四个通用工具,每个约占 250 个 token,每轮需要 1,000 个 token,在同样的轨迹中总计需要 40,000 个 token,减少约 96%。只要目录保持逐字节一致且位于固定位置,前缀缓存就能降低重复发送定义的费用,但不会降低这些定义在上下文预算中所占的比例;轨迹进行期间对目录的任何修改,都会使缓存的前缀失效(编排下一次调用)。随着目录扩大、定义相互重叠,工具选择的表现也会下降;函数调用基准通过改变目录规模来衡量这一趋势(工具集粒度划分)。

专用工具目录与通用工具目录对比 从这些成本以及权限的强制执行位置两个方面,并列比较了这两种目录形态。

专用工具目录与通用工具目录对比:目录形态如何影响定义 token 数、工具选择、观测以及权限的强制执行位置。
维度 大量专用工具 少量通用工具 影响
接口规模 数十到数百个端点封装 少数几种通用操作 模型需要阅读和从中选择的定义更少
定义 token 数 每轮随 \(K\) 增长(公式 2) 少且稳定 为任务保留更多上下文预算
选择 名称和参数相互重叠,容易选错工具 选项少且区分明确 选错工具的调用更少;对各工具描述的依赖更大
观测 每个工具的结果结构各不相同 统一的结果结构(输出、错误、退出状态) 网关中只需一条观测整形路径
强制执行 授权逻辑分散在多个处理程序中 通过隔离边界和能力权限强制执行权限约束 只需在一处执行约束和审计,代价是工具的功能范围更广

最后一行体现了通用设计的代价。通用 shell 工具所能执行的操作远多于专用工具,因此,限定每项操作范围的责任便从工具代码转移到了隔离边界及其运行时所受的能力权限约束上(智能体沙箱)。只有当隔离边界足以约束工具所能执行的最大范围操作时,通用目录才是安全的。

编码智能体的参考目录

许多编码智能体最终都采用了四种通用操作。shell 在沙箱内运行构建、测试、版本控制和包管理器。窗口式文件查看器返回文件中范围受限的片段,以免一次读取就淹没上下文。精确替换编辑器遵循 代码 1,每次修改一个文本块。搜索工具返回结构化的文件和行匹配结果,避免 shell 引号处理带来的风险。代码 5 给出了它们的定义。其他领域也有各自规模较小的通用操作集,例如数据智能体的查询、读取和范围受限的写入操作,同样的数量分析也适用于它们。

列表 5: A Four-Tool Catalog for Coding Agents: Definitions for a sandboxed shell, a windowed file viewer, an exact-replacement editor, and a structured search, each with a closed property set and bounded inputs.
{
  "tools": [
    {
      "name": "bash_execute",
      "description": "Runs a shell command in the task's sandbox and returns stdout, stderr, and the exit status, truncated with a marker if long.",
      "parameters": {
        "type": "object",
        "properties": {
          "command": {"type": "string", "maxLength": 8192, "description": "Shell command line to run."},
          "timeout_ms": {"type": "integer", "default": 30000, "minimum": 1000, "maximum": 300000}
        },
        "required": ["command"], "additionalProperties": false
      }
    },
    {
      "name": "file_view",
      "description": "Returns a window of lines from a text file.",
      "parameters": {
        "type": "object",
        "properties": {
          "path": {"type": "string", "description": "Absolute path inside the workspace."},
          "offset": {"type": "integer", "minimum": 1, "description": "First line to return, 1-indexed."},
          "limit": {"type": "integer", "minimum": 1, "maximum": 500, "description": "Maximum lines to return."}
        },
        "required": ["path", "offset", "limit"], "additionalProperties": false
      }
    },
    {
      "name": "file_edit",
      "description": "Replaces exactly one matching block of text in a file.",
      "parameters": {
        "type": "object",
        "properties": {
          "path": {"type": "string", "description": "Absolute path inside the workspace."},
          "old_str": {"type": "string", "minLength": 1, "description": "Exact block to replace."},
          "new_str": {"type": "string", "description": "Replacement text."}
        },
        "required": ["path", "old_str", "new_str"], "additionalProperties": false
      }
    },
    {
      "name": "grep_search",
      "description": "Searches files for a regular expression and returns matching paths and line numbers.",
      "parameters": {
        "type": "object",
        "properties": {
          "pattern": {"type": "string", "maxLength": 512, "description": "Regular expression to match."},
          "path": {"type": "string", "description": "Directory or file to search."},
          "glob": {"type": "string", "description": "Optional file filter, such as '*.py'."}
        },
        "required": ["pattern", "path"], "additionalProperties": false
      }
    }
  ]
}

一组精简的通用工具既能保留任务上下文,又能减少模型可选的工具数量,使各选项之间的区别更加明确,并将约束的执行集中到一处。这也使每个工具的能力更强,因此,对智能体可执行操作的限制必须由执行边界和能力权限来承载,而不能依赖工具列表。

回到顶部
← 三卷书架