KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

04. 数据库里的一行静态记录,怎样变成可调用的 function? — keel 龙骨

前面几章里,工具的 handler 一直是一个已经存在的 Python 函数:tools.py 里写好,build_registry() 直接把它塞进注册表。这条路径很自然,但它有一个前提——写代码的人事先知道有哪些工具。

前面几章里,工具的 handler 一直是一个已经存在的 Python 函数:tools.py 里写好,build_registry() 直接把它塞进注册表。这条路径很自然,但它有一个前提——写代码的人事先知道有哪些工具。

一旦工具变成可以被用户在后台配置、可以为不同租户启用不同集合、可以在运行时上架和下架,这个前提就塌了:工具元数据落到数据库的 tools 表里,代码里不再是「某个函数」,而是「某一行」。这时你会撞上一个具体问题:

function = toolkit.functions.get(tool_name)
return await cls._call_entrypoint(function.entrypoint, ...)

这两行是很常见的框架写法。它看起来平平无奇,但藏着一个必须回答的问题:function.entrypoint 是什么东西?它是怎么从数据库里那一行静态数据,变成 Python 内存里一个可以被 await 调用的对象的?

先看错的那一步

最直觉的答案是「把函数存进数据库」。比如:

import pickle

cursor.execute("UPDATE tools SET entrypoint = ? WHERE name = ?", (pickle.dumps(fn), name))

这条路走不通,而且不是工程取舍问题,是定义问题。一个函数对象在当前进程里有意义,是因为它同时带着三样东西:

① 它所在模块的全局命名空间(import 时就绪,进程重启即消失)
② 它捕获的闭包引用(数据库连接、配置、HTTP client……)
③ 它所属的那份代码版本(部署的产物,不在数据库里)

把这三者抽掉之后剩下的字节码没有任何可解释性——它脱离进程、脱离依赖、脱离版本,pickle 出来的那串字节里没有任何一处写着「这个函数属于哪个 commit」。

所以正确的一句话是:

数据库里存的从来不是函数本身,而是重建调用能力的配方(recipe)。

「配方」这个词是这一章的关键。它是一个很小的数据结构,回答的问题是「去哪里能找到/造出一个能被调用的东西」。下面的代码(可运行切片,resolution.py)就是它的类型:

@dataclass(frozen=True)
class ToolRow:
    """模拟 `tools` 表里的一行。真实实现里这是 ORM 模型或查询结果。"""
    name: str
    description: str
    input_schema: dict[str, Any]
    kind: str                      # "local" | "remote"
    locator: str                   # 同进程:限定名;跨进程:endpoint
    extra: dict[str, Any] = field(default_factory=dict)

locator 这一列就是配方本体。它的取值决定了后面两条完全不同的路。

配方按「目标在哪」分三种

配方内容 目标在哪 还原方式 适用面
限定名 pkg.module:func 本进程,代码随包体部署 importlib + getattr 自己仓库里实现的工具
路由信息 (endpoint, tool_name) 另一个进程 / 另一台机器 动态生成一个转发器 callable MCP 工具、内部 RPC、第三方 HTTP 能力
代码文本 需要先 exec 才能得到对象 exec / eval ❌ 不要用

前两种配方覆盖了实际会遇到的主要场景。第三种是大量安全事故的共同源头,原因在后面单独一节说。

这里有个容易被跳过的事实:第二种配方不会产生「真正的业务函数」。import 出来的那个是真的业务函数,转发器不是——它是在运行时被创造出来的 callable,只是长得像、调用起来也像。把「找到那个函数」和「造一个长得像它的东西」区分开,这一章就过了一半。

配方一:目标在本进程,用限定名 import

目标函数本来就写在你的包里、跟代码一起部署。数据库只是记录了「它在哪」。还原过程只有两步:importlib.import_module 拿到模块,getattr 拿出属性。

def resolve_local(locator, *, allowed_prefixes=ALLOWED_MODULE_PREFIXES):
    """可运行切片:tool_calling_course/resolution.py"""
    module_name, attr = _locator_parts(locator)

    # ① 白名单检查必须在 import 之前
    if not module_name.startswith(allowed_prefixes):
        raise ToolExecutionError("module_not_allowed", ...)

    try:
        module = importlib.import_module(module_name)
    except ImportError as exc:
        raise ToolExecutionError("module_import_failed", ...) from exc

    target = getattr(module, attr, None)
    if target is None:
        raise ToolExecutionError("attribute_missing", ...)
    if not callable(target):
        raise ToolExecutionError("not_callable", ...)
    return target

白名单为什么必须排在 import 之前

这一处顺序不是代码风格问题。locator 是数据库里的一列,而数据库里的数据可能来自注入、误操作或者被攻破的后台写权限。如果先 import 再检查白名单,那么在被拒绝之前,模块级副作用已经发生了:注册钩子、建立连接、读写文件系统都可以写在模块顶层。

正确的顺序是把数据当作不可信输入,在动用解释器之前先判定。ALLOWED_MODULE_PREFIXES 就是这个判定:

ALLOWED_MODULE_PREFIXES: tuple[str, ...] = ("tool_calling_course.",)

注意这个前缀说的是「哪些包是工具实现的合法位置」。它和第 09 章里控制「工具能否被调用」的 scope 是两个不同的东西:一个管能不能把工具解析出来,一个管能不能调用这个工具。别把它们合并成一次判断,否则「locator 不合法」和「调用者没有权限」这两种性质完全不同的错误会被挤进同一个错误码,恢复动作也就无从选择。

import 是横向拓展点,也是脆弱点

getattr 拿不到、拿到的不可调用,这两种情况绝大多数时候不是数据库脏,而是快照与代码不同步:代码重构了函数名,数据库里的旧限定名还在。attribute_missing 这个错误码故意写成这个语义,运维看到它第一反应应该是比对代码版本,而不是检查网络。

顺带说清「懒加载」的确切含义:importlib.import_module 只在第一次调用某个工具时发生。你不会因为数据库里有 300 行工具记录,就在启动时支付 300 次 import 的成本。代价是第一次调用有隐性延迟,而且失败推迟到运行时才暴露。

配方二:目标不在本进程,动态造一个转发器

这是整个问题的核心,也是前面那段框架代码里最难理解的部分。

远端工具的实际执行体在另一台机器上。你的进程里根本没有 search_incidents 这个函数——不是找不到,是不存在。所以问题不是「怎么找到它」,而是:

能不能造一个东西,让调用方把它当成 search_incidents 来用?

能。做法是把「发请求」这个统一动作包成一个 callable:

def make_forwarder(endpoint, tool_name, transport=None):
    """可运行切片:tool_calling_course/resolution.py"""
    send = transport or _default_transport

    async def forward(**arguments: Any) -> Any:
        payload = {
            "jsonrpc": "2.0",
            "method": "tools/call",
            "params": {"name": tool_name, "arguments": arguments},
        }
        return send(endpoint, payload)

    forward.__name__ = f"forward__{tool_name}"   # traceback 里能看出是哪个工具
    forward.__qualname__ = f"forward__{tool_name}"
    return forward

这个 forward 函数体里没有一行业务逻辑。它只做三件事:把 **kwargs 打包成协议请求、发出去、把响应返回。所有远端工具共用这一份转发逻辑,工具之间的差异全部落在闭包捕获的 endpoint 和 tool_name 上。

由此产生的两个性质,缺一不可

性质一:目标在构造时就被绑定了。

tool_name 和 endpoint 是闭包变量,在 make_forwarder 调用时就确定了。这意味着拿到这个 callable 之后,谁都不需要再知道工具在哪。回到开头那段框架代码:

function = toolkit.functions.get(tool_name)
return await cls._call_entrypoint(function.entrypoint, ...)

.entrypoint 之所以能直接被传给 _call_entrypoint 而不需要额外告知「这是哪个 MCP Server 的什么工具」,就是因为地址已经在构造时写进了闭包。function 对象在这里扮演的是「元数据 + 已解析的 callable」的组合体。

性质二:签名只能是泛化的。

转发器不知道目标工具有哪些参数,所以它只能写成 **kwargs。这一条有直接后果——inspect.signature 反推不出任何参数信息:

本地函数签名:   (service_name: str) -> dict
转发器签名:     (**arguments: 'Any') -> 'Any'

于是 schema 必须由数据库那一列提供——在这条路径上 input_schema 不是可选优化,而是参数形状的必需来源。框架里那些「跳过签名自动推断、改用手工 schema」的开关存在的理由就在这里(一些聚合多来源工具的运行时把它做成形如 skip_entrypoint_processing 的开关):显式接受手工 schema,不要试图从 **kwargs 里推断。如果搞错这一点,模型会拿到一个没有参数的工具描述,表现为「模型就是不肯调用这个工具」——看起来像模型选得不好,实际是参数形状没给它。

统一入口解决最后一个小麻烦:本地工具是普通函数,转发器是 async def,调用方不该为了写不写 await 去分辨它们。

async def call_entrypoint(entrypoint, arguments):
    """可运行切片:resolution.py,对应框架的 cls._call_entrypoint"""
    try:
        result = entrypoint(**arguments)
    except TypeError as exc:
        raise ToolExecutionError("arguments_mismatch", ...) from exc

    if inspect.isawaitable(result):
        result = await result
    return result

TypeError 单独翻译成 arguments_mismatch 是有意的:模型把字段名填错是高频事件,它属于可恢复类别(可以把错误信息回给模型让它重试),而不是内部错误。

配方三:为什么不用 exec

既然目标是「从一行文本得到 callable」,那么允许数据库存一段 Python 源码、执行它来得到对象是其中最灵活的方案——也是最糟的方案。它把写数据库的权限等价于写代码执行的权限:

能写 tools 表  ==  能以你的进程身份执行任意代码

这条等价一旦成立,SQL 注入、后台越权、内部人员误操作的后果全部升级为远程代码执行。而且 exec 出来的对象更难观测:没有有意义的名字、没有可追溯的模块、traceback 指向 <string>。

真正需要「用户自定义逻辑」时,正确做法是把它放到进程外面去——容器、WASM 沙箱、单独的 worker 进程——再通过配方二接进来。这看起来多绕一层,但它把「执行用户代码」和「接入一个远端工具」变成了同一件事,而后者已经有成熟的协议、鉴权和审计可用。

跑一次,看真实输出

不需要数据库服务,示例用 SQLite 内存库模拟那张表:

python courses/foundation/tool-calling/course/project/examples/07_dynamic_resolution.py

第一段先确认「数据库里到底存了什么」:

  [ local] get_service_status   locator = tool_calling_course.tools:get_service_status
  [remote] incident.search      locator = https://mcp.acme.internal:8443/mcp

  注意:没有任何一列存的是函数本体 —— 函数对象无法落库。

第二段是 local 分支的结果,请注意 identity 那一行(第三个输出项):

  entrypoint:
    <function get_service_status at 0x000001A056FDF560>
  与直接 import 得到的是同一个对象吗:
    True
  source:
    import
  调用结果:
    {"service_name": "auth-api", "status": "degraded", "owner": "identity-team"}

is 比较返回 True——pickle 出来的一定拿不到这个结果,得到的会是另一个对象。这就是「限定名配方」的本质:它恢复的是同一个对象,不是一个副本。这个性质很重要,因为它意味着模块级的单例(连接池、缓存)不会被重复创建。

第三段是 remote 分支:

  entrypoint:
    <function forward__search_incidents at 0x000001A056FDF4C0>
  它的名字:
    forward__search_incidents
  是协程函数吗:
    True
  转发器发出的 wire payload:
    {"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "search_incidents", "arguments": {"service": "payments"}}}

注意 incident.search(我们给它的内部名)和 search_incidents(远端认识的名)是两回事,映射关系放在 extra["origin_name"]。这种「内部命名空间 vs 外部命名空间」的分离是必要的:同一个远端工具挂到不同租户下可以有不同的内部名和不同的权限策略,但 endpoint 上的名字只有一个。

主动破坏:让数据库里的一行去导向任意模块

第四段往 tools 表里塞四条恶意/错误数据:

  越界模块         os.path:join                                         -> 拒绝: module_not_allowed
  格式错          tool_calling_course.tools.get_service_status         -> 拒绝: bad_locator
  属性不存在        tool_calling_course.tools:no_such_function           -> 拒绝: attribute_missing
  不可调用对象       tool_calling_course.tools:INCIDENTS                  -> 拒绝: not_callable

四条全部被拒。这里要观察的不是「有没有被拦住」,而是拦在哪里:第一条 module_not_allowed 在 importlib 之前抛出,os 虽然一定已经被加载过,但换成任何未被加载的第三方包,同样的检查顺序保证它的模块级代码不会被执行。

另外三个错误码各自指向不同的运维动作:bad_locator 是数据格式问题(写入侧缺校验)、attribute_missing 是版本漂移(比对代码 commit)、not_callable 是配置项填错(填成了常量而不是函数)。

生产替换点

这一层有三个位置在生产系统里必须被换掉:

教学实现 生产替换 不换会怎样
ALLOWED_MODULE_PREFIXES 硬编码元组 按租户/环境加载的策略,最好带包签名校验 白名单本身在扩容时成为瓶颈
_default_transport 假传输 MCP Streamable HTTP client / stdio 子进程 / 带鉴权的 HTTP 显然:什么都不会发生
ToolRow dataclass + SQLite ORM 模型 + 带版本的工具表 + 上线审批流 无法回答「这个工具昨天是怎么配的」

替换 transport 时请注意替换的位置:换的是 _default_transport 这个函数,转发器逻辑一行都不用动。这是把「函数体只做转发」这条约束当作设计目标的直接回报。

还需要补一件教学代码里省掉的事:解析结果要缓存。resolve_row 每次调用都会重新 import(命中 sys.modules 所以便宜)或重新生成转发器(便宜但会产生新对象)。对转发器尤其要注意——每次都能拿到一个新对象,所以上层的注册表必须持有同一个实例,否则你认为的「同一工具的多次调用」实际上是多个互不相识的 callable,per-tool 的限流、熔断、埋点全部失效。

本章练习与验收

  1. 给 tools 表加一行 kind=local 的工具,locator 指向 tool_calling_course.tools 里一个真实存在的函数,跑通 resolve_row;
  2. 把某一行的 locator 改成 tool_calling_course.resolution:resolve_row,观察它是否会被拒绝——如果不会,说明白名单前缀配错了;
  3. 用 inspect.signature 分别打印一个本地工具和一个转发器,确认前者带参数、后者只有 **kwargs;
  4. 把 input_schema 那一列清空,思考模型会看到什么(这就是属性二失效的表现);
  5. 给 _default_transport 换成一个真的会发请求的版本(哪怕打到本地 HTTP server),确认转发器代码无需改动。

验收标准只剩一句话:你能解释「造一个 callable」和「找到那个函数」的区别,并说出每一种配方被错误处理时会得到的那个具体错误码。

本章检查点

下一章:Registry 和 Executor 怎样形成白名单?

进入 keel 阅读