Agent 工程体系 · 第 13/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

Agent 工具注册表:能力发现、租户过滤、版本和动态装配

Agent 的工具系统通常从一个静态数组开始:

tools = [search_orders, refund_order, get_weather]

当工具数量增加、多个租户共享平台、工具由不同团队发布、接口需要灰度升级,静态数组很快会暴露出结构性问题:

  • 模型不知道当前有哪些能力可以使用;
  • 同一个工具在不同租户下的可见范围不同;
  • 工具名称相同,但版本、权限或后端实现不同;
  • 某些工具只在特定地区、产品套餐或运行环境可用;
  • 所有工具都塞进每一轮模型上下文,导致上下文变大、选择干扰增加;
  • 注册信息已经更新,但正在运行的 Agent 仍然使用旧 Schema;
  • 工具列表发生变化时,缓存、模型请求和执行器之间出现版本不一致。

工具注册表解决的不是“把函数放到字典里”,而是为 Agent 建立一层可查询、可过滤、可版本化、可装配的能力控制面。

可以把它形式化为:

R:(tenant,principal,context,capability_query)ToolSetR : (tenant, principal, context, capability\_query) \rightarrow ToolSet

其中:

  • tenant 是租户;
  • principal 是用户、服务账号或 Agent 身份;
  • context 是地区、环境、产品、会话、风险等级等运行上下文;
  • capability_query 是本轮任务需要的能力描述;
  • ToolSet 是最终允许传给模型、并且能够被执行器解析的工具集合。

注册表的职责不是简单返回“所有已注册工具”,而是返回:

Tcallable=TregisteredTtenantTprincipalTcontextTversionTpolicyThealthT_{callable} = T_{registered} \cap T_{tenant} \cap T_{principal} \cap T_{context} \cap T_{version} \cap T_{policy} \cap T_{health}

这个交集表达了一个重要事实:工具可见不等于工具可调用,工具可调用也不等于本轮应该装配给模型。


一、先区分四个对象:工具、工具定义、注册项和工具实例

1. 工具是可执行能力

工具是 Agent 可以调用的外部能力,例如:

  • 查询订单;
  • 创建退款;
  • 搜索企业知识库;
  • 查询天气;
  • 发邮件;
  • 执行 SQL;
  • 调用内部 HTTP API。

工具本身包含执行逻辑:

def get_order(order_id: str, tenant_id: str) -> dict:
    ...

但这个 Python 函数还不能直接交给模型。模型需要的是一个工具定义,即名称、描述和参数 Schema。

2. 工具定义是给模型看的接口契约

以 OpenAI Function Calling 为例,函数工具由 JSON Schema 描述参数。模型先返回工具调用请求,应用执行工具,再将结果关联到对应的 call_id 并发送回模型;这是一个多轮的“模型请求—应用执行—工具结果—模型继续推理”流程。(developers.openai.com)

一个工具定义可以表示为:

{
  "type": "function",
  "name": "orders_get",
  "description": "查询当前租户中指定订单的状态、金额和退款状态。",
  "parameters": {
    "type": "object",
    "properties": {
      "order_id": {
        "type": "string",
        "description": "订单 ID,例如 ord_123"
      }
    },
    "required": ["order_id"],
    "additionalProperties": false
  },
  "strict": true
}

这里有三类信息:

  1. 身份信息:工具名称是 orders_get
  2. 语义信息:描述告诉模型何时使用它;
  3. 结构信息:参数 Schema 限定输入形状。

工具定义是可发现性接口,不是权限接口,也不是执行凭证。模型知道一个工具存在,并不能因此获得数据库权限。

3. 注册项是工具的治理记录

注册表中的记录应比模型定义更丰富。例如:

{
  "logical_name": "orders.get",
  "model_name": "orders_get",
  "version": "2.1.0",
  "status": "active",
  "provider": "order-service",
  "tenant_scope": "allowlist",
  "allowed_tenants": ["tenant_a", "tenant_b"],
  "required_scopes": ["orders:read"],
  "regions": ["cn-east-1"],
  "environments": ["prod", "staging"],
  "risk_level": "low",
  "input_schema": {},
  "executor_ref": "order-service.orders.get.v2",
  "schema_hash": "sha256:...",
  "policy_version": "policy-2026-08-12"
}

注册项回答的是:

  • 它是什么;
  • 哪个版本;
  • 谁提供;
  • 对哪些租户开放;
  • 需要哪些权限;
  • 在什么环境可用;
  • 当前是否健康;
  • 如何执行;
  • 模型看到的 Schema 是哪一版。

4. 工具实例是一次装配后的可调用对象

注册表返回的注册项还不能直接执行。系统需要把它绑定到当前请求的租户和身份,形成工具实例:

instance = BoundTool(
    definition=orders_get_v2_definition,
    executor=orders_get_v2_executor,
    tenant_id="tenant_a",
    principal_id="user_42",
    policy_snapshot="policy-2026-08-12"
)

工具实例中的租户和身份不应由模型参数提供。模型可以传 order_id,但不能通过参数伪造 tenant_iduser_id 或数据库连接。


二、注册表的核心数据模型

一个生产注册表至少需要维护五种状态。

1. 规范身份

建议同时保存逻辑名称和模型名称:

logical_name = orders.get
model_name   = orders_get__v2_1_0

logical_name 面向业务和治理,表示“订单查询”这一能力;model_name 面向具体模型请求,必须在当前装配集合中唯一。

MCP 规定工具名称在单个服务器内应唯一,通常使用 1 到 128 个字符,并建议只使用字母、数字、下划线、连字符和点号。多个 MCP Server 聚合时,客户端可能遇到同名工具,因此需要通过服务器标识或命名空间消歧;服务器的显示名称不保证全局唯一,不能直接作为唯一键。(modelcontextprotocol.io)

因此,聚合器内部不应使用:

tool_name

作为唯一主键,而应使用:

(server_id, tool_name, version)

对自有注册表,也可以使用:

(provider_id, logical_name, version)

2. 生命周期状态

工具通常至少有以下状态:

draft
  ↓
validated
  ↓
active
  ↓
deprecated
  ↓
disabled
  ↓
retired

状态含义不能只靠字符串约定:

  • draft:尚未允许装配;
  • validated:Schema、执行器和策略检查通过;
  • active:可以被符合条件的租户发现和调用;
  • deprecated:仍可调用,但不接受新租户或新 Agent 绑定;
  • disabled:立即从可调用集合移除;
  • retired:历史记录保留,但不能执行。

工具列表缓存不能只缓存工具定义,还要缓存状态版本。否则会出现“模型已经看到工具,但执行器已经禁用工具”的竞态。

3. Schema 版本和实现版本

工具至少存在两种版本:

schema_version      = 2
implementation_version = 2.1.4

Schema 版本表示模型可见输入输出契约发生了兼容性变化;实现版本表示后端代码变化,不一定影响模型契约。

例如:

  • 修复数据库连接泄漏:实现版本从 2.1.32.1.4,Schema 不变;
  • 新增必填参数:Schema 从 23,不能只更新实现版本;
  • amount 从整数分改为浮点元:属于契约语义变化,应升级 Schema 版本;
  • 增加一个可选参数:是否兼容取决于调用方和严格 Schema 规则,不能机械地认为一定是补丁版本。

注册项应保存不可变的 schema_hash

schema_hash=H(canonicalize(inputSchema))schema\_hash = H(canonicalize(inputSchema))

其中 canonicalize 必须对 JSON 对象键进行稳定排序,并统一数组、数字和字符串的序列化形式。否则同一 Schema 仅因为字段排列不同就得到不同哈希。

4. 能力标签

工具名称和描述适合给模型理解,但不适合做可靠过滤。注册表需要结构化能力标签:

{
  "domain": "orders",
  "actions": ["read"],
  "data_classes": ["order_metadata"],
  "side_effect": "none",
  "risk_level": "low",
  "latency_class": "interactive",
  "supports_idempotency": true
}

side_effect 至少应区分:

none
write
external_communication
financial
destructive

这使策略可以表达:

普通问答 Agent 只能使用 side_effect=none;
退款 Agent 可以使用 financial,但必须经过审批;
后台批处理 Agent 可以使用 latency_class=batch。

5. 执行绑定

模型看到的是 model_name,执行器使用的是内部引用:

{
  "model_name": "orders_get__v2_1_0",
  "executor_ref": "grpc://order-service/orders.GetOrder",
  "auth_mode": "delegated",
  "timeout_ms": 1500
}

不要让模型直接控制 executor_ref、URL、表名或函数路径。否则工具调用就从“选择已登记能力”变成了“让模型指定任意代码路径”。


三、能力发现:从“全量暴露”变为“按需得到工具集合”

1. 能力发现的输入和输出

能力发现不是简单的 list_tools(),而是:

discover(
    tenant_id="tenant_a",
    principal_id="user_42",
    task="查询订单 ord_123 的退款状态",
    requested_domains={"orders"},
    environment="prod",
    region="cn-east-1"
)

输出应包含:

DiscoveryResult(
    tools=[...],
    registry_revision="registry-8842",
    policy_revision="policy-193",
    generated_at="2026-09-01T10:00:00Z",
    omitted=[...],
)

其中 omitted 很有价值。它可以记录:

[
  {
    "logical_name": "orders.refund",
    "reason": "missing_scope",
    "required_scopes": ["orders:refund"]
  }
]

生产环境不一定把这些原因全部暴露给最终用户,但应写入审计和诊断日志。

2. 发现和装配必须分成两步

推荐的数据流如下:

flowchart LR
    A[用户请求] --> B[任务解析器]
    B --> C[能力查询]
    C --> D[注册表索引]
    D --> E[租户过滤]
    E --> F[身份与权限过滤]
    F --> G[版本选择]
    G --> H[健康与策略过滤]
    H --> I[Schema 编译]
    I --> J[模型请求]
    J --> K[模型返回工具调用]
    K --> L[执行前再次鉴权]
    L --> M[工具执行器]
    M --> N[工具结果]
    N --> J

发现回答“有哪些候选能力”;装配回答“本轮实际传给模型哪些工具”。

两者不能合并成一个无限制的“获取工具列表”接口:

  • 发现结果可以较宽;
  • 装配结果必须严格受租户、用户、风险和上下文约束;
  • 执行前必须再次鉴权,因为注册表结果可能已经过期;
  • 同一逻辑能力可能有多个版本,但一轮模型请求只能暴露选定版本。

3. 全量装配为什么会失败

设模型可见工具数为 nn,每个工具定义平均占用 ss 个 token,则工具上下文成本近似为:

Ctools=n×sC_{tools} = n \times s

但问题不只是 token 成本。工具选择错误率也会随着名称相似、描述相近和候选过多而上升。假设任务相关工具数为 kk,全量集合为 nn,则模型需要在更大的候选空间中完成选择:

P(correct)=f(description,schema,context,n)P(correct) = f(description, schema, context, n)

通常不是简单的线性关系;当 n 增大时,描述冲突和工具语义重叠会成为主要干扰。

因此,动态装配的目标不是盲目减少工具数量,而是使:

工具集合足够覆盖任务,同时尽可能排除无关、越权和不健康工具。

四、租户过滤:可见性、权限和数据隔离不是一回事

1. 租户是过滤条件,不是模型参数

多租户 Agent 中,至少要区分:

  • tenant_id:数据和资源归属;
  • principal_id:当前用户或服务主体;
  • agent_id:哪个 Agent 正在运行;
  • session_id:会话;
  • request_id:单次请求;
  • scopes:授权范围。

错误设计通常是把租户参数放进 Schema:

{
  "properties": {
    "tenant_id": {"type": "string"},
    "order_id": {"type": "string"}
  },
  "required": ["tenant_id", "order_id"]
}

这让模型可以选择任意租户。即使执行器之后忽略该字段,Schema 也传达了错误的安全边界。

正确设计是把租户从可信请求上下文注入执行器:

def execute_orders_get(args: dict, ctx: RequestContext) -> dict:
    order_id = args["order_id"]
    return order_repository.get(
        tenant_id=ctx.tenant_id,
        order_id=order_id,
        principal_id=ctx.principal_id,
    )

模型只提供业务参数;租户、用户和权限来自服务端已经验证过的上下文。

2. 租户过滤的判定公式

定义:

  • scope(t)scope(t):工具允许的租户集合;
  • tenanttenant:当前请求租户;
  • scopes(p)scopes(p):主体权限集合;
  • required(t)required(t):工具所需权限集合;
  • ctxctx:运行环境和区域上下文。

工具 tt 可以进入候选集合,当且仅当:

visible(t)=tenantscope(t)required(t)scopes(p)contextMatch(t,ctx)status(t)=activevisible(t) = tenant \in scope(t) \land required(t) \subseteq scopes(p) \land contextMatch(t, ctx) \land status(t) = active

例如:

def is_visible(tool, request):
    tenant_ok = (
        tool.tenant_scope == "all"
        or request.tenant_id in tool.allowed_tenants
    )

    scope_ok = set(tool.required_scopes) <= set(request.scopes)

    context_ok = (
        request.environment in tool.environments
        and request.region in tool.regions
    )

    return (
        tool.status == "active"
        and tenant_ok
        and scope_ok
        and context_ok
    )

这只是“发现阶段”的过滤。执行阶段仍需重新检查:

def authorize_execution(tool, request, args):
    if tool.status != "active":
        raise ToolUnavailable("tool is no longer active")

    if not policy_engine.allow(
        principal=request.principal_id,
        tenant=request.tenant_id,
        tool=tool.logical_name,
        arguments=args,
    ):
        raise PermissionDenied("execution denied")

3. 为什么需要执行前二次鉴权

假设:

  1. 10:00:00 发现阶段,用户拥有 orders:refund
  2. 10:00:02 管理员撤销权限;
  3. 10:00:03 模型返回退款工具调用;
  4. 执行器仅相信 10:00:00 的工具快照。

此时工具列表本身没有错,但授权已经失效。注册表快照只能证明“曾经允许”,不能证明“此刻仍允许”。

所以安全条件应是:

authorizeexecute(t,p,tenant,args,now)=trueauthorize_{execute}(t, p, tenant, args, now)=true

而不是:

authorizediscover(t,p,tenant,t0)=trueauthorize_{discover}(t, p, tenant, t_0)=true


五、版本选择:逻辑工具、模型工具名和后端实现必须解耦

1. 一个逻辑能力可以对应多个版本

例如:

orders.get
├── v1.3.0  旧订单服务
├── v2.0.0  新订单服务
└── v2.1.0  增加退款状态字段

模型不能同时看到三个都叫 orders_get 的工具,否则名称冲突。注册表需要先选出一个版本,再生成模型工具定义:

selected = resolver.select(
    logical_name="orders.get",
    tenant_id="tenant_a",
    agent_version="agent-4",
    schema_policy="compatible",
)

模型名称可以包含版本:

orders_get__v2_1_0

也可以使用稳定名称:

orders_get

然后由服务端通过绑定表解析到具体版本:

orders_get -> orders.get@2.1.0

两种方案各有边界:

  • 模型名带版本:审计清楚,回放稳定,但升级会改变模型上下文;
  • 模型名稳定:上下文和提示词更稳定,但必须把版本绑定写入请求快照,否则回放时可能解析到新实现。

生产系统通常采用:

模型名称稳定 + 请求级不可变绑定 + 注册表版本快照

即:

{
  "model_name": "orders_get",
  "logical_name": "orders.get",
  "resolved_version": "2.1.0",
  "schema_hash": "sha256:abc...",
  "executor_ref": "order-service.orders.GetOrder.v2"
}

2. 版本选择的兼容性判定

设旧 Schema 为 SoS_o,新 Schema 为 SnS_n。对输入契约而言,升级兼容的基本条件是:

L(Sn)L(So)L(S_n) \supseteq L(S_o)

其中 L(S)L(S) 表示 Schema 接受的输入集合。新版本接受旧版本所有合法输入时,才有可能保持输入兼容。

反例:

旧版本:

{
  "type": "object",
  "properties": {
    "order_id": {"type": "string"}
  },
  "required": ["order_id"],
  "additionalProperties": false
}

新版本新增必填字段:

{
  "type": "object",
  "properties": {
    "order_id": {"type": "string"},
    "reason": {"type": "string"}
  },
  "required": ["order_id", "reason"],
  "additionalProperties": false
}

旧调用:

{"order_id": "ord_123"}

在新 Schema 中不再合法,因此这不是向后兼容升级。

如果新字段业务上确实可选,在严格模式下可以将其表达为“必填但允许 null”:

{
  "type": ["string", "null"]
}

OpenAI 的严格函数调用要求对象设置 additionalProperties: false,并要求 properties 中的字段都列入 required;可选语义可以通过允许 null 表达。若 Schema 不满足严格模式约束,请求可能被拒绝,或在未显式设置严格模式时退回尽力而为的非严格调用。(developers.openai.com)

因此,工具版本升级不能只比较字符串版本号,至少应检查:

  1. required 字段是否增加;
  2. 字段类型是否收窄;
  3. 枚举值是否删除;
  4. additionalProperties 策略是否变化;
  5. 描述中的业务语义是否变化;
  6. 输出结构是否仍能被后续 Agent 步骤消费。

3. 枚举扩展也有边界

旧版本:

{
  "status": {
    "type": "string",
    "enum": ["pending", "paid", "cancelled"]
  }
}

新版本增加:

"enum": ["pending", "paid", "cancelled", "refunded"]

对能透传未知状态的客户端,这可能兼容;对使用穷举分支的客户端,它可能造成未处理分支:

if status == "pending":
    ...
elif status == "paid":
    ...
elif status == "cancelled":
    ...
else:
    raise ValueError("unknown status")

所以“枚举增加一定是兼容变更”是常见误解。Schema 兼容性与消费者实现有关,注册表应允许声明:

compatibility = strict | tolerant | manual_review

六、动态装配:把候选工具编译成一次模型请求的工具集合

1. 动态装配的定义

动态装配是指在每次 Agent 运行或每个推理阶段,根据请求上下文从注册表解析出当前可用工具,并转换为模型供应商所需的工具格式。

它不是运行时随意拼接 JSON,而是一个带版本快照的编译过程:

Assemble(q,Rv,Pv)(Tools,Binding,Digest)Assemble(q, R_v, P_v) \rightarrow (Tools, Binding, Digest)

其中:

  • qq 是请求上下文;
  • RvR_v 是注册表版本;
  • PvP_v 是策略版本;
  • Tools 是发送给模型的工具定义;
  • Binding 是模型工具名到执行器的绑定;
  • Digest 是整个装配结果的摘要。

2. 装配器的输入和输出

from dataclasses import dataclass
from typing import Any

@dataclass(frozen=True)
class RequestContext:
    tenant_id: str
    principal_id: str
    scopes: frozenset[str]
    environment: str
    region: str
    agent_version: str

@dataclass(frozen=True)
class ToolBinding:
    model_name: str
    logical_name: str
    version: str
    schema_hash: str
    executor_ref: str

@dataclass(frozen=True)
class AssembledTools:
    definitions: list[dict[str, Any]]
    bindings: dict[str, ToolBinding]
    registry_revision: str
    policy_revision: str

装配过程:

def assemble_tools(
    registry,
    policy_engine,
    ctx: RequestContext,
    requested_domains: set[str],
) -> AssembledTools:
    candidates = registry.query(
        domains=requested_domains,
        environment=ctx.environment,
        region=ctx.region,
    )

    definitions = []
    bindings = {}

    for tool in candidates:
        if tool.status != "active":
            continue

        if tool.tenant_scope == "allowlist":
            if ctx.tenant_id not in tool.allowed_tenants:
                continue

        if not set(tool.required_scopes) <= ctx.scopes:
            continue

        if not policy_engine.allow_discovery(ctx, tool):
            continue

        model_name = tool.model_name
        if model_name in bindings:
            raise RuntimeError(f"duplicate model tool name: {model_name}")

        definitions.append({
            "type": "function",
            "name": model_name,
            "description": tool.description,
            "parameters": tool.input_schema,
            "strict": True,
        })

        bindings[model_name] = ToolBinding(
            model_name=model_name,
            logical_name=tool.logical_name,
            version=tool.version,
            schema_hash=tool.schema_hash,
            executor_ref=tool.executor_ref,
        )

    return AssembledTools(
        definitions=definitions,
        bindings=bindings,
        registry_revision=registry.revision(),
        policy_revision=policy_engine.revision(),
    )

这里有两个容易遗漏的安全点:

  • allow_discovery 只控制是否把工具暴露给模型;
  • allow_execution 必须在收到模型调用后再次执行。

3. 发送给模型时还要限制调用集合

模型工具集合和允许调用集合可以是两个概念。OpenAI 的 tool_choice 支持自动选择、强制调用、强制某个函数,以及限制为允许工具子集;allowed_tools 可以在不修改完整工具列表的情况下限制当前请求可调用的工具。(developers.openai.com)

例如,系统可以缓存一组稳定工具定义,但当前阶段只允许查询工具:

{
  "tool_choice": {
    "type": "allowed_tools",
    "mode": "auto",
    "tools": [
      {"type": "function", "name": "orders_get"},
      {"type": "function", "name": "orders_list"}
    ]
  }
}

这并不替代服务端鉴权。它只是在模型侧减少错误选择,真正的权限边界仍在执行器。

4. 并行调用和副作用

支持并行工具调用的模型可能在一个响应中返回多个函数调用;OpenAI 文档说明可以通过 parallel_tool_calls: false 禁止并行,从而保证一次最多零个或一个工具调用。(developers.openai.com)

只有满足以下条件时,工具才适合并行:

parallel(t1,t2)=independent(t1,t2)no_conflict(t1,t2)safe_to_retry(t1,t2)parallel(t_1, t_2) = independent(t_1,t_2) \land no\_conflict(t_1,t_2) \land safe\_to\_retry(t_1,t_2)

例如:

查询订单 A + 查询订单 B       可以并行
查询余额 + 发起扣款             通常不能并行
创建退款 + 发送退款通知         需要明确事务和幂等关系

不能因为模型一次返回多个调用,就直接使用线程池执行所有副作用操作。执行器还应检查:

  • 是否共享同一资源;
  • 是否有写写冲突;
  • 是否具备幂等键;
  • 是否需要审批;
  • 是否要求顺序;
  • 一个调用失败时是否应该取消其他调用。

七、MCP 中的发现与注册表:协议目录不是租户策略中心

MCP 是一种让服务器向 LLM 应用暴露工具、数据和其他能力的协议。MCP 的工具发现接口是 tools/list,调用接口是 tools/call

1. MCP 的 tools/list

MCP 工具包含名称、标题、描述和 inputSchema 等元数据。tools/list 支持分页,并可以通过 nextCursor 获取后续结果。服务器支持工具时,需要声明 tools 能力;如果工具列表会变化,还可以声明 listChanged。(modelcontextprotocol.io)

MCP 工具列表有一个重要约束:在底层集合不变时,服务器应以确定性顺序返回工具,以便客户端缓存列表并提高包含工具定义时的提示词缓存命中率。工具集合可以因请求所带授权而变化,但不能依赖连接状态任意变化。(modelcontextprotocol.io)

因此,MCP Server 的 tools/list 可以参与租户过滤,但不应把它误认为完整的企业工具注册表。企业注册表还需要维护:

  • 多服务器聚合;
  • 租户授权;
  • Agent 版本绑定;
  • 灰度比例;
  • 健康状态;
  • 审计;
  • 风险等级;
  • 模型供应商格式转换;
  • 执行前策略检查。

可以采用两层结构:

企业工具注册表
    ↓ 选择服务器、租户和版本
MCP Client / Gateway
    ↓ tools/list
MCP Server
    ↓ tools/call
具体执行器

2. MCP 的 tools/call

MCP 客户端通过以下逻辑调用工具:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": {
      "location": "New York"
    }
  }
}

工具自身执行失败时,应在结果中返回 isError: true,而不是把所有业务失败都当成协议层错误;找不到工具、服务器不支持工具调用等协议异常,才应返回 MCP 错误响应。这样模型能够看到业务错误并尝试自我修正。(modelcontextprotocol.io)

这对应两类故障:

协议错误:
  tools/call 不存在
  工具名称无法解析
  请求格式非法

工具业务错误:
  订单不存在
  库存不足
  下游超时
  当前用户没有资源权限

二者必须区分,否则 Agent 会把“业务条件不满足”误判成“协议不可用”,或者把“工具根本不存在”当作可以重试的业务错误。

3. MCP 规范版本必须锁定

截至 2026 年 9 月的工程基线,MCP 官方规范页面指向 2026-07-28 版本。该版本移除了协议级会话和 Mcp-Session-Id,列表类接口不再依赖连接级状态;跨调用状态应通过显式句柄传递。(modelcontextprotocol.io)

这会影响注册表设计:

错误做法:

连接建立时获取一次 tools/list
之后永久认为工具列表不变

更可靠的做法:

注册表维护本地 revision
MCP Gateway 缓存 tools/list
服务器 listChanged 或 TTL 到期时失效
每次执行检查 binding revision 和工具状态

如果系统仍兼容旧版 MCP 实现,必须把协议版本作为连接配置的一部分,而不是假定所有 Server 都采用 2026-07-28 行为。


八、一个可运行的本地示例:注册、过滤、装配和执行

下面的示例不依赖模型 API,展示注册表的最小闭环。它可以直接保存为 registry_demo.py 后运行。

from dataclasses import dataclass
from typing import Any


@dataclass(frozen=True)
class ToolRecord:
    logical_name: str
    model_name: str
    version: str
    description: str
    input_schema: dict[str, Any]
    executor_ref: str
    status: str
    allowed_tenants: frozenset[str]
    required_scopes: frozenset[str]


@dataclass(frozen=True)
class RequestContext:
    tenant_id: str
    principal_id: str
    scopes: frozenset[str]


class Registry:
    def __init__(self, tools: list[ToolRecord]):
        self.tools = tools
        self.revision = "registry-2026-09-01-001"

    def discover(self, ctx: RequestContext) -> list[ToolRecord]:
        result = []

        for tool in self.tools:
            if tool.status != "active":
                continue

            if (
                tool.allowed_tenants
                and ctx.tenant_id not in tool.allowed_tenants
            ):
                continue

            if not tool.required_scopes <= ctx.scopes:
                continue

            result.append(tool)

        return sorted(result, key=lambda x: x.model_name)


def get_order(args: dict[str, Any], ctx: RequestContext) -> dict[str, Any]:
    # 真实系统中这里应访问带 tenant_id 条件的数据层。
    return {
        "tenant_id": ctx.tenant_id,
        "order_id": args["order_id"],
        "status": "paid",
    }


def assemble(
    registry: Registry,
    ctx: RequestContext,
) -> tuple[list[dict[str, Any]], dict[str, ToolRecord]]:
    visible = registry.discover(ctx)
    definitions = []
    bindings = {}

    for tool in visible:
        if tool.model_name in bindings:
            raise RuntimeError(
                f"duplicate model name: {tool.model_name}"
            )

        definitions.append({
            "type": "function",
            "name": tool.model_name,
            "description": tool.description,
            "parameters": tool.input_schema,
            "strict": True,
        })
        bindings[tool.model_name] = tool

    return definitions, bindings


def execute(
    tool: ToolRecord,
    args: dict[str, Any],
    ctx: RequestContext,
) -> dict[str, Any]:
    # 关键:执行前再次检查,而不是相信旧的 discover 结果。
    if tool.status != "active":
        raise RuntimeError("tool is no longer active")

    if not tool.required_scopes <= ctx.scopes:
        raise PermissionError("scope revoked before execution")

    if tool.executor_ref == "orders.get.v2":
        return get_order(args, ctx)

    raise LookupError(f"unknown executor: {tool.executor_ref}")


registry = Registry([
    ToolRecord(
        logical_name="orders.get",
        model_name="orders_get",
        version="2.1.0",
        description="查询当前租户中指定订单的状态。",
        input_schema={
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "订单 ID,例如 ord_123",
                }
            },
            "required": ["order_id"],
            "additionalProperties": False,
        },
        executor_ref="orders.get.v2",
        status="active",
        allowed_tenants=frozenset({"tenant_a"}),
        required_scopes=frozenset({"orders:read"}),
    ),
    ToolRecord(
        logical_name="orders.refund",
        model_name="orders_refund",
        version="1.4.0",
        description="为指定订单创建退款请求。",
        input_schema={
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "订单 ID,例如 ord_123",
                }
            },
            "required": ["order_id"],
            "additionalProperties": False,
        },
        executor_ref="orders.refund.v1",
        status="active",
        allowed_tenants=frozenset({"tenant_a"}),
        required_scopes=frozenset({"orders:refund"}),
    ),
])

ctx = RequestContext(
    tenant_id="tenant_a",
    principal_id="user_42",
    scopes=frozenset({"orders:read"}),
)

definitions, bindings = assemble(registry, ctx)

print("assembled tools:")
for item in definitions:
    print("-", item["name"])

tool = bindings["orders_get"]
result = execute(
    tool=tool,
    args={"order_id": "ord_123"},
    ctx=ctx,
)

print("execution result:")
print(result)

预期输出:

assembled tools:
- orders_get
execution result:
{'tenant_id': 'tenant_a', 'order_id': 'ord_123', 'status': 'paid'}

为什么 orders_refund 没有被装配?

因为当前主体只有:

orders:read

而退款工具要求:

orders:refund

这说明租户过滤和权限过滤共同作用:

tenant_a 允许该工具
但 principal 缺少 orders:refund
所以工具不可见

如果直接把 orders_refund 也传给模型,再依赖执行器返回“没有权限”,模型就会看到一个本不应知道的能力。对于高风险工具,最小暴露面通常比执行时报错更安全。


九、工具调用循环中的状态、错误和恢复

一个完整的 Agent 工具回路至少有以下状态:

DISCOVERED
  ↓
ASSEMBLED
  ↓
MODEL_REQUESTED
  ↓
TOOL_CALL_RECEIVED
  ↓
VALIDATED
  ↓
AUTHORIZED
  ↓
EXECUTING
  ↓
TOOL_RESULT_RETURNED
  ↓
MODEL_CONTINUES

失败路径不能只返回一个字符串:

SCHEMA_INVALID
TOOL_NOT_FOUND
TENANT_DENIED
SCOPE_DENIED
STALE_BINDING
EXECUTOR_TIMEOUT
EXECUTOR_REJECTED
TOOL_BUSINESS_ERROR
MODEL_RETRY_EXHAUSTED

1. Schema 错误

收到调用后,先解析 JSON,再按绑定时的 Schema 校验:

args = json.loads(raw_arguments)
validate_json_schema(binding.input_schema, args)

Schema 校验失败通常不应重试执行器,因为请求根本没有到达合法输入状态。可以把结构化错误反馈给模型:

{
  "error": {
    "code": "invalid_arguments",
    "field": "order_id",
    "message": "must be a non-empty string"
  }
}

2. 工具不存在

模型可能返回一个过期或伪造的工具名称:

orders_refund_v9

如果当前 bindings 中没有该名称,必须拒绝执行。不能根据字符串相似度自动猜测并执行:

if tool_name not in bindings:
    raise LookupError("unknown tool in this assembly snapshot")

自动猜测会把模型输出错误转化为任意工具执行风险。

3. 绑定过期

模型请求使用的是:

registry_revision = registry-100
schema_hash = abc

执行时当前注册表已经变成:

registry_revision = registry-101
schema_hash = def

处理方式取决于风险:

  • 只读工具:可以重新发现并重试一次;
  • 写操作:通常停止并要求重新确认;
  • 金融或破坏性操作:必须拒绝旧绑定,不能自动升级;
  • Schema 不变、仅实现版本更新:可以根据不可变绑定继续执行,但必须保留原始执行器引用。

4. 工具业务错误

工具执行成功到达后端,但业务结果失败,例如订单不存在。应返回可供模型理解的结构化结果:

{
  "is_error": true,
  "code": "ORDER_NOT_FOUND",
  "retryable": false,
  "message": "订单 ord_123 不存在或不属于当前租户"
}

retryable 不应由模型自行推断。重试策略应由执行层基于错误码控制:

timeout             可有限重试
rate_limited        延迟后重试
validation_error    不重试
permission_denied   不重试
not_found           通常不重试
conflict            视幂等和业务语义决定

十、缓存和并发:注册表是控制面,装配结果是数据面快照

1. 为什么需要多级缓存

工具发现通常跨越:

本地注册表缓存
→ 租户策略缓存
→ MCP tools/list 缓存
→ 模型请求上下文缓存
→ 执行绑定缓存

缓存的价值是降低发现延迟,但缓存越多,版本不一致的可能性越高。

每个装配结果应携带:

registry_revision
policy_revision
server_revision
schema_hash
generated_at
expires_at

不要只缓存:

cache["tenant_a"] = tools

因为这无法判断工具列表对应哪次注册表变更。

2. 失效策略

可以使用“主动通知 + TTL + 执行前检查”的组合:

注册表更新
  ↓
revision + 1
  ↓
发布 invalidation 事件
  ↓
各节点清理租户相关缓存
  ↓
MCP listChanged 或 TTL 触发重新发现
  ↓
新请求使用新绑定

MCP 支持在工具列表变化时通过 notifications/tools/list_changed 通知客户端;服务器只有声明相应能力时,客户端才应依赖这种变化通知。(modelcontextprotocol.io)

通知不是一致性保证。网络分区、客户端离线或通知丢失时,TTL 和执行前状态检查仍然必须存在。

3. 并发更新的竞态

考虑以下时序:

T1  请求 A 装配 orders_get@2.0
T2  注册表发布 orders_get@2.1
T3  请求 B 装配 orders_get@2.1
T4  请求 A 的模型返回调用
T5  请求 A 执行

请求 A 是否应该执行 2.0,取决于系统是否支持请求级绑定:

  • 如果允许旧请求完成,A 应使用不可变的 2.0 执行绑定;
  • 如果工具已被紧急禁用,执行前状态检查必须阻断 A;
  • 如果只是常规升级,不能在执行时静默切换到 2.1,否则模型看到的 Schema 和实际执行器不一致。

因此,版本升级和紧急禁用是两个不同操作

upgrade:新请求使用新版本,旧请求按策略完成
disable:新旧请求都在执行前被阻断

十一、动态工具搜索和注册表装配的关系

当工具数量很多时,可以先只暴露工具搜索能力,再按模型需求加载少量工具。OpenAI 文档将 Tool Search 用于延迟加载不常用工具,并说明该能力适用于 gpt-5.4 及之后的模型。(developers.openai.com)

这与注册表的关系可以表示为:

注册表
  ↓ 租户与权限过滤
工具目录
  ↓ 语义检索或标签检索
候选工具
  ↓ Schema 和策略校验
当前可调用工具
  ↓
模型

关键边界是:工具搜索结果不能绕过注册表过滤。

错误流程:

先全局搜索出 refund 工具
再尝试判断用户是否有权限

正确流程:

先生成当前主体可发现的工具目录
再在这个目录内做搜索

形式化地说:

Search(VisibleTools(q))Filter(Search(AllTools(q)))Search(VisibleTools(q)) \neq Filter(Search(AllTools(q)))

后者容易在搜索索引、缓存、日志和提示词中泄露不应暴露的能力名称或描述。


十二、常见误解和失败表现

误解一:工具列表就是权限列表

工具列表只能表达“当前请求可以考虑的能力”。真正授权必须在执行前基于可信身份、租户和参数重新判断。

失败表现:

模型看到了工具
用户随后权限被撤销
执行器仍然成功完成高风险操作

误解二:strict: true 就等于安全

严格模式主要约束参数是否符合 Schema,不保证:

  • 用户有权访问该订单;
  • 工具没有副作用;
  • 参数业务上合理;
  • URL、SQL 或文件路径安全;
  • 工具执行器没有越权。

例如:

{"order_id": "ord_other_tenant"}

完全可以符合 Schema,但仍然违反租户隔离。

误解三:注册表版本号就是工具版本

注册表整体版本:

registry_revision = 8842

和工具契约版本:

orders.get schema_version = 2

不是一回事。前者表示目录状态,后者表示单个工具契约。执行绑定还可能有独立的实现版本。

误解四:发现失败和执行失败可以统一处理

以下错误的恢复方式不同:

tools/list 超时       → 可以使用短期缓存或降级
工具不存在             → 检查版本和命名冲突
参数校验失败           → 修正调用,不重试执行器
权限不足               → 不重试
下游临时超时           → 有限重试或异步化
业务拒绝               → 反馈模型,不当成系统故障

统一返回:

{"error": "tool failed"}

会使模型、监控和运维都无法判断下一步动作。

误解五:所有工具都应该放进每轮请求

工具数量少、租户单一、版本固定时,全量装配可以接受;但在大型系统中,全量装配会同时增加上下文成本、选择歧义、权限泄露面和版本冲突概率。

动态装配不是为了复杂化系统,而是把原本隐含在执行器中的选择逻辑显式化。


十三、生产诊断:先定位是发现、装配还是执行出了问题

建议每次工具调用记录一条关联完整的事件:

{
  "request_id": "req_123",
  "tenant_id": "tenant_a",
  "principal_id": "user_42",
  "agent_id": "support-agent",
  "registry_revision": "registry-8842",
  "policy_revision": "policy-193",
  "model_name": "orders_get",
  "logical_name": "orders.get",
  "tool_version": "2.1.0",
  "schema_hash": "sha256:abc",
  "call_id": "call_789",
  "decision": "allowed",
  "latency_ms": 84,
  "result_code": "OK"
}

诊断顺序应是:

  1. 发现阶段:工具是否存在于注册表;
  2. 租户阶段:租户是否在允许范围;
  3. 身份阶段:主体是否拥有所需 Scope;
  4. 上下文阶段:区域、环境、产品是否匹配;
  5. 版本阶段:是否选出了唯一版本;
  6. Schema 阶段:模型传入参数是否符合绑定 Schema;
  7. 授权阶段:执行前策略是否仍允许;
  8. 执行阶段:执行器是否可达;
  9. 业务阶段:下游是否返回业务成功;
  10. 回传阶段:结果是否带回正确的 call_id

OpenAI 的工具调用流程要求工具输出关联特定工具调用标识,应用将工具定义、原始输入、模型工具调用和工具输出继续发送给模型。(developers.openai.com) 如果 call_id 错配,模型可能无法把结果对应到正确动作,表现为重复调用、声称工具没有返回或生成错误结论。


十四、注册表的最小正确边界

一个可以长期演进的 Agent 工具注册表,至少应保证以下不变量:

不变量一:模型名称在一次装配中唯一

ti,tjTassembled,ijname(ti)name(tj)\forall t_i,t_j \in T_{assembled}, i \neq j \Rightarrow name(t_i) \neq name(t_j)

不变量二:模型可见工具一定经过租户和权限过滤

tTassembledtenantAllowed(t)scopeAllowed(t)t \in T_{assembled} \Rightarrow tenantAllowed(t) \land scopeAllowed(t)

不变量三:执行器绑定来自服务端注册信息

executor(t)Inputmodelexecutor(t) \notin Input_{model}

模型输入只能提供业务参数,不能指定执行位置。

不变量四:执行时再次验证状态和授权

callable(t,now)active(t,now)authorized(t,principal,tenant,args,now)callable(t, now) \Rightarrow active(t, now) \land authorized(t, principal, tenant, args, now)

不变量五:工具定义和执行绑定来自同一快照

schema(T)=schema(Binding(T))schema(T) = schema(Binding(T))

如果模型看到 orders_get@2.1,执行器却解析到 orders_get@1.4,Schema 校验即使通过,也可能产生错误业务结果。

不变量六:失败结果必须保留可诊断分类

至少区分:

not_found
invalid_arguments
unauthorized
forbidden
stale_binding
unavailable
timeout
business_error
protocol_error

工具注册表的本质,是把 Agent 的“能力边界”从代码中的隐式条件,提升为一个可以查询、过滤、版本化和审计的显式系统。

能力发现解决“有哪些工具”;租户过滤解决“当前主体能看到哪些工具”;版本解析解决“本轮使用哪一个契约和实现”;动态装配解决“如何把这些决定转换为模型可调用的工具集合”;执行前鉴权则确保模型看到的历史快照不会突破当前安全边界。

MCP 的 tools/listtools/call 提供了跨系统发现与调用的协议基础,但它们不会自动替代企业注册表中的租户策略、版本治理和执行控制。真正可靠的工具系统,必须同时管理:

能力目录
+ 可见性
+ 权限
+ 版本
+ Schema
+ 执行绑定
+ 缓存快照
+ 并发更新
+ 错误分类
+ 审计证据

缺少其中任何一层,工具数量一旦增长,Agent 就会从“可以调用工具”变成“无法解释自己为什么看到了某个工具、为什么调用了某个版本,以及为什么这个调用最终能够执行”。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。