Agent 工程体系 · 第 13/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。
Agent 工具注册表:能力发现、租户过滤、版本和动态装配
Agent 的工具系统通常从一个静态数组开始:
tools = [search_orders, refund_order, get_weather]
当工具数量增加、多个租户共享平台、工具由不同团队发布、接口需要灰度升级,静态数组很快会暴露出结构性问题:
- 模型不知道当前有哪些能力可以使用;
- 同一个工具在不同租户下的可见范围不同;
- 工具名称相同,但版本、权限或后端实现不同;
- 某些工具只在特定地区、产品套餐或运行环境可用;
- 所有工具都塞进每一轮模型上下文,导致上下文变大、选择干扰增加;
- 注册信息已经更新,但正在运行的 Agent 仍然使用旧 Schema;
- 工具列表发生变化时,缓存、模型请求和执行器之间出现版本不一致。
工具注册表解决的不是“把函数放到字典里”,而是为 Agent 建立一层可查询、可过滤、可版本化、可装配的能力控制面。
可以把它形式化为:
其中:
tenant是租户;principal是用户、服务账号或 Agent 身份;context是地区、环境、产品、会话、风险等级等运行上下文;capability_query是本轮任务需要的能力描述;ToolSet是最终允许传给模型、并且能够被执行器解析的工具集合。
注册表的职责不是简单返回“所有已注册工具”,而是返回:
这个交集表达了一个重要事实:工具可见不等于工具可调用,工具可调用也不等于本轮应该装配给模型。
一、先区分四个对象:工具、工具定义、注册项和工具实例
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
}
这里有三类信息:
- 身份信息:工具名称是
orders_get; - 语义信息:描述告诉模型何时使用它;
- 结构信息:参数 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_id、user_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.3到2.1.4,Schema 不变; - 新增必填参数:Schema 从
2到3,不能只更新实现版本; - 将
amount从整数分改为浮点元:属于契约语义变化,应升级 Schema 版本; - 增加一个可选参数:是否兼容取决于调用方和严格 Schema 规则,不能机械地认为一定是补丁版本。
注册项应保存不可变的 schema_hash:
其中 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. 全量装配为什么会失败
设模型可见工具数为 ,每个工具定义平均占用 个 token,则工具上下文成本近似为:
但问题不只是 token 成本。工具选择错误率也会随着名称相似、描述相近和候选过多而上升。假设任务相关工具数为 ,全量集合为 ,则模型需要在更大的候选空间中完成选择:
通常不是简单的线性关系;当 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. 租户过滤的判定公式
定义:
- :工具允许的租户集合;
- :当前请求租户;
- :主体权限集合;
- :工具所需权限集合;
- :运行环境和区域上下文。
工具 可以进入候选集合,当且仅当:
例如:
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. 为什么需要执行前二次鉴权
假设:
10:00:00发现阶段,用户拥有orders:refund;10:00:02管理员撤销权限;10:00:03模型返回退款工具调用;- 执行器仅相信
10:00:00的工具快照。
此时工具列表本身没有错,但授权已经失效。注册表快照只能证明“曾经允许”,不能证明“此刻仍允许”。
所以安全条件应是:
而不是:
五、版本选择:逻辑工具、模型工具名和后端实现必须解耦
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 为 ,新 Schema 为 。对输入契约而言,升级兼容的基本条件是:
其中 表示 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)
因此,工具版本升级不能只比较字符串版本号,至少应检查:
- required 字段是否增加;
- 字段类型是否收窄;
- 枚举值是否删除;
additionalProperties策略是否变化;- 描述中的业务语义是否变化;
- 输出结构是否仍能被后续 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,而是一个带版本快照的编译过程:
其中:
- 是请求上下文;
- 是注册表版本;
- 是策略版本;
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)
只有满足以下条件时,工具才适合并行:
例如:
查询订单 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 工具
再尝试判断用户是否有权限
正确流程:
先生成当前主体可发现的工具目录
再在这个目录内做搜索
形式化地说:
后者容易在搜索索引、缓存、日志和提示词中泄露不应暴露的能力名称或描述。
十二、常见误解和失败表现
误解一:工具列表就是权限列表
工具列表只能表达“当前请求可以考虑的能力”。真正授权必须在执行前基于可信身份、租户和参数重新判断。
失败表现:
模型看到了工具
用户随后权限被撤销
执行器仍然成功完成高风险操作
误解二: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"
}
诊断顺序应是:
- 发现阶段:工具是否存在于注册表;
- 租户阶段:租户是否在允许范围;
- 身份阶段:主体是否拥有所需 Scope;
- 上下文阶段:区域、环境、产品是否匹配;
- 版本阶段:是否选出了唯一版本;
- Schema 阶段:模型传入参数是否符合绑定 Schema;
- 授权阶段:执行前策略是否仍允许;
- 执行阶段:执行器是否可达;
- 业务阶段:下游是否返回业务成功;
- 回传阶段:结果是否带回正确的
call_id。
OpenAI 的工具调用流程要求工具输出关联特定工具调用标识,应用将工具定义、原始输入、模型工具调用和工具输出继续发送给模型。(developers.openai.com) 如果 call_id 错配,模型可能无法把结果对应到正确动作,表现为重复调用、声称工具没有返回或生成错误结论。
十四、注册表的最小正确边界
一个可以长期演进的 Agent 工具注册表,至少应保证以下不变量:
不变量一:模型名称在一次装配中唯一
不变量二:模型可见工具一定经过租户和权限过滤
不变量三:执行器绑定来自服务端注册信息
模型输入只能提供业务参数,不能指定执行位置。
不变量四:执行时再次验证状态和授权
不变量五:工具定义和执行绑定来自同一快照
如果模型看到 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/list 和 tools/call 提供了跨系统发现与调用的协议基础,但它们不会自动替代企业注册表中的租户策略、版本治理和执行控制。真正可靠的工具系统,必须同时管理:
能力目录
+ 可见性
+ 权限
+ 版本
+ Schema
+ 执行绑定
+ 缓存快照
+ 并发更新
+ 错误分类
+ 审计证据
缺少其中任何一层,工具数量一旦增长,Agent 就会从“可以调用工具”变成“无法解释自己为什么看到了某个工具、为什么调用了某个版本,以及为什么这个调用最终能够执行”。
系列导航与关联阅读
- 系列入口:Agent 工程完整路线:从运行循环、记忆与协议到安全、评测和生产交付
- 上一篇:Agent 并行工具调用:依赖图、只读并发、写入串行和合并
- 下一篇:Agent 写操作确认:参数预览、确认令牌、过期和防重放
- 延伸:Agent 工具 Schema:命名、描述、参数、枚举和可发现性
- 延伸:MCP Tools:发现、Schema、调用、结果、错误和安全边界
官方资料
本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论