KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05. Registry 和 Executor 怎样形成白名单? — keel 龙骨
第 01 章说过:模型返回的工具名是不可信输入。这一章把那句话落成两件事——工具只能从一个显式表里取出来,以及每次调用都走同一条固定检查顺序。
第 01 章说过:模型返回的工具名是不可信输入。这一章把那句话落成两件事——工具只能从一个显式表里取出来,以及每次调用都走同一条固定检查顺序。
现场:一次"顺手写"的动态查找
某次迭代里有人觉得「每个工具都注册一遍太啰嗦」,于是把分发写成一行:
handler = globals()[call.name] # 模型说什么名字,就找什么函数
result = handler(**call.arguments)
上线后发生的事,按严重程度排:
- 模型一次幻觉,输出名字恰好命中模块里的内部函数
_flush_cache,缓存被清空; - 有人从来不知道有个工具叫
sync_to_prod,因为没有一份清单,评审时也没人会提到它; - 事后复盘问「当时模型能看到哪些工具」,答案是「取决于那个模块 import 了什么」——审计只能去追代码。
这三件事的共同点是:没有一份「有哪些工具」的权威清单。有了清单,第 2、3 条立刻消失;再配上白名单查找,第 1 条也不可能发生。
Registry 存什么:名字到定义的映射
@dataclass(frozen=True)
class ToolDefinition:
name: str
description: str
version: str
effect: ToolEffect
arguments_model: type[BaseModel]
result_model: type[BaseModel]
handler: Callable[..., Any]
ToolRegistry 只做三件事:register()(重复名字直接抛 ValueError)、get()(查不到返回 None)、ollama_schemas()(生成给模型的描述)。它没有 lookup_from_module(),也没有 getattr——名字到实现的映射只能来自注册,不能来自解析。
这一点决定了审计的形状:想知道模型能看到什么,读一遍 build_registry() 就够了。生成模型的工具描述和执行器允许的工具集是同一个来源,两边不可能漂移。
顺带说一句 frozen=True:定义为不可变对象,是为了防止执行过程中有人「临时改一下 effect」。工具定义在注册那一刻就应该定稿。
Executor 的固定顺序
flowchart TD
A[ToolCall] --> B{注册?}
B -->|否| C[unknown_tool]
B -->|是| D[输入 schema]
D -->|失败| E[invalid_arguments]
D -->|通过| F[副作用/审批策略]
F -->|拒绝| G[approval_required]
F -->|允许| H[调用 handler]
H --> I{业务成功?}
I -->|否| J[结构化 ToolError]
I -->|是| K[输出 schema]
K -->|失败| L[invalid_tool_output]
K -->|通过| M[ToolResult ok=true]
顺序本身就是设计。把「调用 handler」挪到「输入校验」之前,等于让非法参数有机会产生副作用;把输出校验放在最后而不是丢掉,等于承认残缺数据可以进上下文。第 03 章那次输出违约的事故,正是靠这里的最后一道 K 拦下的。
注意没有任何一个箭头指向「回到模型让模型自己改」——是否需要修正,由 Harness 决定,不由 executor 决定。Executor 只负责产出一个稳定可判断的结果。
跑一遍:四种拒绝各是什么样
python courses/foundation/tool-calling/course/project/examples/02_validated_dispatch.py
四条 ToolCall 打同一个 ToolExecutor,输出整理如下(字段来自示例真实输出):
| call_id | name | version | ok | code |
|---|---|---|---|---|
call-valid |
lookup_incident |
1.0 |
true | — |
call-unknown |
delete_prod |
null |
false | unknown_tool |
call-invalid |
lookup_incident |
1.0 |
false | invalid_arguments |
call-write |
create_ticket |
1.0 |
false | approval_required |
成功的那一条长这样:
{
"call_id": "call-valid",
"tool_name": "lookup_incident",
"tool_version": "1.0",
"ok": true,
"data": {
"incident_id": "INC-001",
"service": "auth-api",
"severity": "high",
"symptom": "登录接口持续返回 502",
"evidence": ["auth-api timeout", "database pool exhausted"]
},
"error": null
}
有两处细节值得停下看:
call-unknown的tool_version是null。工具没注册,连版本号都无从谈起。这不是偷懒,而是在告诉调用方:这条记录无法对应到任何已发布的工具版本。invalid_arguments里没有具体哪个字段错了。这是有意的取舍——ValidationError里带有路径和内部类型信息,把它整段塞进去,会把内部实现细节交给模型。要修参数,应该让 Model 自己对照 schema 重试(第 06 章的自修复实验),而不是靠错误文本泄露内部信息。
这四个实验完全不需要模型:白名单、参数校验、副作用策略都是程序不变量,它们是否成立不能取决于「模型这次有没有乱说」。
为什么不让函数自己处理所有错误
对照这个设计看另一种常见写法:直接 handler(**arguments),然后 except Exception 兜住一切。问题是三种错误被压成了同一种:
| 错误性质 | 该由谁负责 | 压成通用异常后的后果 |
|---|---|---|
| 调用方违反了契约 | Executor | 无法区分「参数错」和「服务挂了」,重试策略没法写 |
| 工具明确报告的业务失败 | 工具实现 | 模型看不到稳定的 code,只能读 message 猜 |
| 工具实现的 bug | 代码所有者 | 堆栈进了模型上下文,还会被当成内容泄漏 |
所以 executor 里是一次显式分流:ToolExecutionError 转成带 code 的失败结果,其余异常转成 tool_internal_error 并把堆栈留在受保护日志里。
失败注入:改四处看它会不会被抓到
改 project/src/tool_calling_course/executor.py 后重跑 02_validated_dispatch.py:
| 改法 | 现象 | 说明 |
|---|---|---|
把 unknown_tool 分支改成 getattr(self.handlers, name) |
模型可以控制查找范围 | 白名单退化成动态解析,第 01 章的坑回来了 |
| 把 schema 校验挪到策略之后 | 非法参数也可能走到审批分支 | 顺序一改,审批记录会塞满无意义的噪音 |
在异常分支里返回 str(exc) |
内部路径进入模型上下文 | 信息泄漏 |
| 删掉输出校验 | 残缺结果一路进下一轮 | 第 03 章的事故重现 |
生产替换点
| 教学实现 | 生产替换 |
|---|---|
内存 dict 注册表 |
版本化的工具清单服务 / manifest,启动时校验签名与 hash |
register() 抛 ValueError |
发布门禁:重复或未经评审的工具无法进入清单 |
ExecutionContext.allow_side_effects |
独立策略引擎(OPA / Cedar / 自研),按租户和动作实时判定 |
tool_internal_error 只留 message |
结构化日志 + 追踪 ID,call_id 与 trace 打通 |
| 进程内函数调用 | stdio / Streamable HTTP 边界,超时和取消由传输层负责 |
练习与验收
练习:给 Registry 加一个 describe_tools(),返回「模型可见清单」与「执行器内部字段」两份视图,写断言保证两份视图的工具名集合完全相同。
验收标准:把一个工具从注册表里删掉,模型清单和执行清单必须同时少一项;任何只少一边的结果,都说明你的 registry 有两个真相来源。
本章检查点
- 为什么白名单必须是「先注册后查找」,不能是「先查找再判断合不合法」?
tool_version = null在 unknown_tool 场景里传达了什么信息?如果这里填一个默认版本号会有什么后果?- 值格式正确但没有权限,应该在哪一步拒绝?把这道检查放进
arguments_model行不行,为什么? - 如果 Executor 自己决定是否重试,会带来什么问题?
现在能解释什么
你能画出从 ToolCall 到 ToolResult 的每一步检查,并说明为什么顺序不可交换。你也知道这四道关卡(注册、schema、策略、输出)都不依赖模型——所以在第 06 章把结果放回模型面前之前,链路已经有了确定的边界。