KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

05. Registry 和 Executor 怎样形成白名单? — keel 龙骨

第 01 章说过:模型返回的工具名是不可信输入。这一章把那句话落成两件事——工具只能从一个显式表里取出来,以及每次调用都走同一条固定检查顺序。

第 01 章说过:模型返回的工具名是不可信输入。这一章把那句话落成两件事——工具只能从一个显式表里取出来,以及每次调用都走同一条固定检查顺序。

现场:一次"顺手写"的动态查找

某次迭代里有人觉得「每个工具都注册一遍太啰嗦」,于是把分发写成一行:

handler = globals()[call.name]        # 模型说什么名字,就找什么函数
result = handler(**call.arguments)

上线后发生的事,按严重程度排:

  1. 模型一次幻觉,输出名字恰好命中模块里的内部函数 _flush_cache,缓存被清空;
  2. 有人从来不知道有个工具叫 sync_to_prod,因为没有一份清单,评审时也没人会提到它;
  3. 事后复盘问「当时模型能看到哪些工具」,答案是「取决于那个模块 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
}

有两处细节值得停下看:

这四个实验完全不需要模型:白名单、参数校验、副作用策略都是程序不变量,它们是否成立不能取决于「模型这次有没有乱说」。

为什么不让函数自己处理所有错误

对照这个设计看另一种常见写法:直接 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 有两个真相来源。

本章检查点

现在能解释什么

你能画出从 ToolCall 到 ToolResult 的每一步检查,并说明为什么顺序不可交换。你也知道这四道关卡(注册、schema、策略、输出)都不依赖模型——所以在第 06 章把结果放回模型面前之前,链路已经有了确定的边界。

上一章:数据库里的一行静态记录,怎样变成可调用的 function? · 下一章:工具结果为什么要回到下一轮?

进入 keel 阅读