导学阶段-ToolRegistry 自动生成工具 Schema
本文整理自 CNB 上的开源课程 《Vibe Agent:从零搭建 Coding Agent》。课程讲义、示例代码与练习仓库均在 CNB 上公开,文中示例出自该仓库对应章节。
接入第三个工具的时候,重复开始变得扎眼。每个工具都要维护两份定义:一份是函数本体,一份是给模型的 schema。两份写的是同一件事,工具名、参数名、参数类型、哪些必填。改函数签名忘了改 schema,模型就会拿着旧参数去调新函数,报错发生在运行时,而且错得莫名其妙。
1 | def get_weather(city: str) -> str: |
函数签名里的 city: str 已经说明了参数名和类型。schema 只是把这件事换了一种格式再说一遍。工具从三个涨到十个,这份手写列表的长度跟着翻倍,漏改的概率也一起涨。
装饰器只做一次登记
ToolRegistry 的做法是让 schema 由函数签名推出来,注册动作压到一行。
1 | registry = ToolRegistry() |
这一行等价于 get_weather = registry.tool("查询指定城市的当前天气")(get_weather)。带参数的装饰器先被调用一次,返回真正的装饰器,函数接着传进去。装饰器把函数存进内部的字典,然后把函数原样返回,没有包装、没有改签名。所以 get_weather() 照常能调,registry 也记住了它。
登记用的键是 func.__name__,同名函数后注册的会覆盖先注册的。两个模块各写一个 search,注册表里只会剩一个,而且没有报错。工具集变大以后,这个约定需要在命名上守住。
调用侧只需要替换两处:生成 schema 和按名调度。
1 | resp = client.chat.completions.create( |
从签名到 schema
inspect.signature() 给出参数名与默认值,typing.get_type_hints() 把类型注解还原成真实类型。这两样信息正好对应 schema 里的 properties、type 和 required。
| 函数签名 | 生成的 schema |
|---|---|
city: str |
properties.city.type = "string",进入 required |
count: int |
properties.count.type = "integer",进入 required |
limit: int | None = None |
properties.limit.type = "integer",不进 required |
score: float = 0.5 |
properties.score.type = "number",不进 required |
flag: bool |
properties.flag.type = "boolean" |
类型翻译靠一张固定的映射表:str 对 "string",int 对 "integer",float 对 "number",bool 对 "boolean",list 对 "array",dict 对 "object"。int | None 这类注解要先把 None 拆掉,再按内层类型映射,否则 limit 这种可空参数会退化成 string。判定必填的规则只有一条:默认值是 inspect.Parameter.empty 的参数进 required,有默认值的参数不进。required 列表因此不需要手写。
实现不到八十行,全部逻辑就是遍历参数、查表、拼字典。
1 | import inspect |
设计上的取舍
有一处刻意的选择是 description 必须写在装饰器里,不读函数的 docstring。docstring 通常写给读代码的人,交代实现细节;模型需要的 description 是另一类文本,回答”什么时候该用我、不能用来做什么”。两件事混在一起,歧义会传给模型。强制写一份专门给模型看的说明,代价是多打一行字。
所有工具统一返回字符串。tool 消息的 content 字段本身就是字符串,工具返回 dict 或 int 还得再序列化一次。约定成 str,调用侧就少一处判断。
invoke 捕获所有异常,把错误信息转成字符串返回,不向上抛。工具失败不应该让整个 Agent 崩掉。错误会变成一条正常结果回填进 messages,模型看到”未知工具””参数缺失””网络超时”之后会自己决定重试、换参数还是放弃。这条约定决定了后面整个 Agent 的容错路径。
一个会让映射静默失效的坑
模块顶部写了 from __future__ import annotations 时,函数注解在运行时全部变成字符串。inspect.signature() 拿到的 p.annotation 是 'str' 这个字符串,不是 str 类型,映射表查不到,于是所有参数都回退成 "string"。
city: str 看不出问题,count: int 就被标成了 string。模型按 schema 传进来一个字符串 "3",工具内部再做一次隐式转换,或者干脆报类型错。这类错误没有异常堆栈,只有模型行为变得奇怪。
上面的实现直接调用 typing.get_type_hints(),就是为了绕开这个坑:在查映射表之前先把注解还原成真实类型。另一种做法是删掉那行 import,让注解在运行时保持真实类型,代价是模块里不能再用前向引用写法。
这套实现还有成本上的选择。运行时反射在每次 to_schemas() 时重新读一遍签名,工具数量少的时候开销可以忽略。工具上百,或者想让参数写错的问题在启动阶段就暴露,可以改成在导入期把 schema 算好缓存。pydantic 的 TypeAdapter(Args).json_schema() 是常见做法,代价是多一层依赖和更复杂的实现。
对搭建 Agent 意味着什么
注册表把工具集变成一组普通函数。加工具的成本降到写一个函数加一行装饰器,工具的描述和函数的行为放在同一个文件里,改动的距离短了,漂移的机会也就少了。
description 是模型判断该不该调的唯一线索。工具的实现再完善,描述写得含糊,这个工具在模型眼里就等于不存在。
错误回填那条约定则决定了 Agent 出错时的样子:报错不中断程序,它成为模型下一轮推理的输入。
下一节把这个注册表放进循环,看一个最小 Agent 需要哪些边界条件。


