第 1 课:ToolRegistry —— 让 LLM 学会"用工具"
本节目标:理解 ToolRegistry 是什么、为什么需要它、它怎么把 Python 函数变成 LLM 可调用的工具,以及执行兜底为什么是 Agent 系统的生命线。
1. 问题的起点:LLM 其实什么都不会做
LLM 只是一个文本生成器。它不能查当前时间、不能读你的文档、不能发邮件、不能操作数据库。
但用户偏偏会问"帮我查下余额""把上周的报告删掉"这类问题。
工程上的解法:给 LLM 一份"工具说明书",让它决定什么时候用哪个工具——但真正执行的是你的后端代码,不是 LLM。
核心概念:ToolRegistry = 工具花名册 + 转接员。LLM 说"我要用 calculate",Registry 找到这个工具、调它、把结果传回来。LLM 从头到尾不碰真实执行。
2. 心智模型:把它想象成公司前台
LLM:帮我查时间
│
▼
前台(ToolRegistry):
├─ 翻花名册 → "get_current_time" 存在
├─ 拨分机 → 调 Python 函数
└─ 转述结果 → "现在是 2026-06-16 14:30(周三)"
│
▼
LLM:基于结果生成自然语言回复给用户
LLM 只需要知道三件事:工具叫什么、能干嘛、要什么参数。至于工具是 Python 函数还是远程 API,它不关心。
3. 工具的"身份证":ToolDefinition
@dataclass
class ToolDefinition:
name: str # 唯一标识,LLM 用它"点名"
description: str # 写"什么时候用",不是"做什么"!
parameters: dict # JSON Schema:参数名、类型、必填
func: Callable # 真正执行的 Python 函数
risk: RiskLevel # LOW / MEDIUM / HIGH(出厂就定好)
tags: list[str] # 标签,如 ["read"] ["write"]
| 字段 | 给谁看的? | 关键点 |
|---|---|---|
name | LLM | LLM 用名字点名工具 |
description | LLM | 唯一决定 LLM 调不调的依据 |
parameters | LLM | LLM 按这个格式输出参数 |
func | 后端 | 实际执行逻辑 |
risk | 后端 | 决定要不要人工审批 |
tags | 后端 | 分类筛选用 |
4. 上手:注册一个工具
@tool_registry.register(
name="get_current_time",
description="当用户询问当前时间、日期、星期几、几点几分、今天是几号时使用",
parameters={"type": "object", "properties": {}, "required": []},
risk=RiskLevel.LOW,
tags=["read"],
)
def get_current_time(**_ignored):
now = datetime.now()
return f"{now.strftime('%Y-%m-%d %H:%M:%S')}(周{'一二三四五六日'[now.weekday()]})"
三个要点:
- 装饰器即注册:函数写完,自动进
_tools字典,不需要额外配置 - description 写触发条件:不是"返回时间字符串",而是"当用户问时间时使用"
- parameters 即使是空的也要写:
{"type": "object", "properties": {}, "required": []}
5. 花名册内部:怎么存、怎么防错
class ToolRegistry:
def __init__(self):
self._tools: dict[str, ToolDefinition] = {} # 全局单例
def register(self, *, name, description, parameters, risk, tags):
def _decorator(func):
if name in self._tools:
raise ValueError(f"tool '{name}' already registered") # 安全边界
self._tools[name] = ToolDefinition(...)
return func
return _decorator
三个设计决策:① 全局单例,整个进程只有一份;② 重名报错,防止不小心覆盖高风险工具的实现;③ 装饰器模式,定义即注册,不改函数本身。
6. LLM 看不到代码,只看 JSON
def to_openai_tools(self):
return [t.to_openai_schema() for t in self._tools.values()]
LLM 实际收到的内容:
[{
"type": "function",
"function": {
"name": "get_current_time",
"description": "当用户询问当前时间、日期、星期几…时使用",
"parameters": {"type": "object", "properties": {}, "required": []}
}
}]
关键推论:LLM 只看到
name+description+parameters。改函数实现不影响 LLM 行为,改 description 才会。风险等级 LLM 完全不知情——这是后端的责任。
7. 执行兜底:Agent 系统的生命线 注意:这是整节课最重要的工程实践:
def execute(self, name, arguments) -> str:
"""任何异常都不抛出,统一以 [ERROR] 字符串回注 LLM"""
# 第 1 层:工具存在吗?
try:
tool = self.get(name)
except KeyError:
return f"[ERROR] tool '{name}' not found"
# 第 2 层:参数能解析吗?
try:
kwargs = json.loads(arguments) if isinstance(arguments, str) else arguments
except json.JSONDecodeError:
return f"[ERROR] invalid JSON args"
# 第 3 层:函数能跑通吗?
try:
result = tool.func(**filtered_kwargs)
return str(result)[:8000] # 截断,防爆上下文
except Exception as e:
return f"[ERROR] {e}"
为什么绝对不能 raise?
- 场景 A:用户问"1/0 等于多少"→ LLM 调 calculate → 除零错误。raise → 500 服务器错误。返回
[ERROR]→ LLM 解释说"除数不能为零",用户拿到有意义的回答 - 场景 B:LLM 幻觉调了一个不存在的工具。raise → Agent 死。返回
[ERROR] not found→ LLM 老实换真正的工具
铁律:工具失败 ≠ Agent 失败。把错误当成"工具的回答"传回 LLM,让它自己纠错。
8. 完整闭环:一次工具调用的全流程
# 第 1 轮:LLM 决策
tools = tool_registry.to_openai_tools()
resp1 = await llm.call_with_tools(messages, tools)
tool_calls = resp1.choices[0].message.tool_calls or []
# 没有工具调用 → 直接返回
if not tool_calls:
return resp1.choices[0].message.content
# 执行工具并回注结果
for tc in tool_calls:
result = tool_registry.execute(tc.function.name, tc.function.arguments)
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
# 第 2 轮:LLM 基于工具结果生成最终回复
resp2 = await llm.call(messages)
return resp2.choices[0].message.content
数据流向:
用户问题 → LLM 决策(要不要调工具?调哪个?)
→ Registry.execute() 执行
→ 结果回注消息列表
→ LLM 基于结果生成自然语言回复
→ 返回用户
9. 一句话记住 ToolRegistry
ToolRegistry 是 LLM 与真实世界之间唯一的桥。 它把 Python 函数翻译成 LLM 能理解的 JSON,再把 LLM 的工具调用翻译回 Python 函数执行,并兜底所有异常。
10. 自测:5 个问题检验理解
问题 1:description 写"返回当前时间字符串"和写"当用户询问当前时间、日期、星期几时使用",效果有什么不同?为什么?
问题 2:如果 execute() 把异常 raise 出去而不是返回 [ERROR],会发生什么?(举两个场景)
问题 3:LLM 决定调不调工具的依据是什么?它能看到 Python 源码吗?
问题 4:delete_document 标了 risk=HIGH,但 execute() 里没有任何 risk 判断。那拦截在哪一层做的?为什么?
问题 5:要新增 query_user_balance(user_id) 工具,需要改哪些文件?description 怎么写?risk 选哪个?
答案与解析
问题 1:description 的写法决定 LLM 的触发率
LLM 只看 description 决定要不要调用。写"返回当前时间字符串"描述的是函数功能,LLM 不觉得"用户问今天几号"和这个工具有关。写"当用户询问当前时间、日期、星期几……时使用"描述的是触发场景,LLM 能精确匹配。
实战技巧:description 里多枚举用户可能的说法("几点了""今天周几""现在什么时间"),召回率明显提升。
问题 2:raise 的后果
| 场景 | raise 的后果 | 返回 [ERROR] 的结果 |
|---|---|---|
LLM 调 calculate("1/0") 除零 | 500 错误,用户看到服务器异常 | LLM 解释"0 不能做除数",用户得到答案 |
| LLM 幻觉调不存在的工具 | Agent 直接崩溃 | LLM 换真正存在的工具重试 |
问题 3:LLM 看不到代码
LLM 看到的是 to_openai_tools() 返回的 JSON 数组——只有 name、description、parameters。Python 源码、函数实现、风险等级、标签——LLM 一概不知。
问题 4:risk 拦截在 Agent 层
execute() 是底层执行器,单一职责是"把函数安全跑起来"。HIGH 风险的拦截在 Agent 多步循环里做:发现 risk==HIGH → 不调 execute() → 插入审批记录 → 状态变 waiting_approval → 用户批准后才真正执行。
这样设计的好处:同一套工具可以被不同 Agent 用不同审批策略复用。单元测试也能绕过审批直接测工具。
问题 5:只需改 1 个文件
在工具注册文件末尾加一个装饰函数即可,不需要改 registry、Agent、路由、数据库。
@tool_registry.register(
name="query_user_balance",
description="当用户询问账户余额、可用金额、当前余额、还剩多少钱时使用。只读操作。",
parameters={
"type": "object",
"properties": {"user_id": {"type": "string", "description": "用户唯一标识"}},
"required": ["user_id"],
},
risk=RiskLevel.MEDIUM, # 只读但有隐私数据
tags=["read", "user_data"],
)
def query_user_balance(user_id: str) -> str:
return f"用户 {user_id} 当前余额:{user_repo.get_balance(user_id)} 元"
risk 决策速查:
| 等级 | 特征 | 例子 |
|---|---|---|
| LOW | 只读、无副作用、无隐私 | 查时间、算数 |
| MEDIUM | 只读但涉及隐私或费用 | 查余额、查订单 |
| HIGH | 写操作、不可逆、外部通信 | 删文档、发邮件、扣款 |
本节要点
- ToolRegistry = 花名册 + 转接员,LLM 和真实世界的唯一桥梁
- description 写给 LLM 看,写触发场景不是函数功能
- execute() 永不 raise,错误当工具回答回注让 LLM 自纠错
- 风险拦截放 Agent 层,不放 ToolRegistry 层——分层才能复用
- 加一个工具只需加一个装饰函数,不改任何基础设施