本文整理自 CNB 上的开源课程 《Vibe Agent:从零搭建 Coding Agent》。课程讲义、示例代码与练习仓库均在 CNB 上公开,文中示例出自该仓库对应章节。

接入第三个工具的时候,重复开始变得扎眼。每个工具都要维护两份定义:一份是函数本体,一份是给模型的 schema。两份写的是同一件事,工具名、参数名、参数类型、哪些必填。改函数签名忘了改 schema,模型就会拿着旧参数去调新函数,报错发生在运行时,而且错得莫名其妙。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def get_weather(city: str) -> str:
return f"{city} 晴 25°C"

TOOLS = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的当前天气",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名"}},
"required": ["city"],
},
},
}]

函数签名里的 city: str 已经说明了参数名和类型。schema 只是把这件事换了一种格式再说一遍。工具从三个涨到十个,这份手写列表的长度跟着翻倍,漏改的概率也一起涨。

装饰器只做一次登记

ToolRegistry 的做法是让 schema 由函数签名推出来,注册动作压到一行。

1
2
3
4
5
registry = ToolRegistry()

@registry.tool("查询指定城市的当前天气")
def get_weather(city: str) -> str:
return f"{city} 晴 25°C"

这一行等价于 get_weather = registry.tool("查询指定城市的当前天气")(get_weather)。带参数的装饰器先被调用一次,返回真正的装饰器,函数接着传进去。装饰器把函数存进内部的字典,然后把函数原样返回,没有包装、没有改签名。所以 get_weather() 照常能调,registry 也记住了它。

登记用的键是 func.__name__,同名函数后注册的会覆盖先注册的。两个模块各写一个 search,注册表里只会剩一个,而且没有报错。工具集变大以后,这个约定需要在命名上守住。

调用侧只需要替换两处:生成 schema 和按名调度。

1
2
3
4
5
6
7
8
9
resp = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=registry.to_schemas(),
)

for tc in resp.choices[0].message.tool_calls:
args = json.loads(tc.function.arguments)
result = registry.invoke(tc.function.name, args)

从签名到 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import inspect
import types
from typing import Any, Callable, Union, get_args, get_origin, get_type_hints

class ToolRegistry:
_TYPE_MAP = {
str: "string", int: "integer", float: "number",
bool: "boolean", list: "array", dict: "object",
}

def __init__(self) -> None:
self._funcs: dict[str, Callable] = {}
self._descs: dict[str, str] = {}

def tool(self, description: str):
def deco(func):
self._funcs[func.__name__] = func
self._descs[func.__name__] = description
return func
return deco

def _json_schema(self, annotation: Any) -> dict:
"""把注解翻译成 schema 片段,Optional 拆掉 None 后按内层类型处理。"""
origin = get_origin(annotation)
if origin in (Union, types.UnionType):
args = [a for a in get_args(annotation) if a is not type(None)]
if len(args) == 1:
return self._json_schema(args[0])
if origin in (list, tuple):
args = get_args(annotation)
return {"type": "array", "items": self._json_schema(args[0]) if args else {"type": "string"}}
if origin is dict:
return {"type": "object"}
return {"type": self._TYPE_MAP.get(annotation, "string")}

def to_schemas(self) -> list[dict]:
out = []
for name, func in self._funcs.items():
sig = inspect.signature(func)
hints = get_type_hints(func) # 注解可能是字符串,这里还原成真实类型
properties, required = {}, []
for pname, p in sig.parameters.items():
properties[pname] = {
"description": f"参数 {pname}",
**self._json_schema(hints.get(pname, str)),
}
if p.default is inspect.Parameter.empty:
required.append(pname)
out.append({
"type": "function",
"function": {
"name": name,
"description": self._descs[name],
"parameters": {
"type": "object",
"properties": properties,
"required": required,
},
},
})
return out

def invoke(self, name: str, args: dict[str, Any]) -> str:
if name not in self._funcs:
return f"未知工具: {name}"
try:
return str(self._funcs[name](**args))
except Exception as e:
return f"工具 {name} 执行出错: {e}"

设计上的取舍

有一处刻意的选择是 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 需要哪些边界条件。