112 分钟

ToolRegistry —— 让 LLM 学会"用工具"

不讲八股文,用最朴素的方式带你理解 ToolRegistry:LLM 的工具花名册与转接员。

AgentToolLLMPython
进度保存在本机浏览器;验收通过后再点更稳妥

第 1 课:ToolRegistry —— 让 LLM 学会"用工具"

本节目标:理解 ToolRegistry 是什么、为什么需要它、它怎么把 Python 函数变成 LLM 可调用的工具,以及执行兜底为什么是 Agent 系统的生命线。


1. 问题的起点:LLM 其实什么都不会做

LLM 只是一个文本生成器。它不能查当前时间、不能读你的文档、不能发邮件、不能操作数据库。

但用户偏偏会问"帮我查下余额""把上周的报告删掉"这类问题。

工程上的解法:给 LLM 一份"工具说明书",让它决定什么时候用哪个工具——但真正执行的是你的后端代码,不是 LLM。

核心概念:ToolRegistry = 工具花名册 + 转接员。LLM 说"我要用 calculate",Registry 找到这个工具、调它、把结果传回来。LLM 从头到尾不碰真实执行。


2. 心智模型:把它想象成公司前台

Code
LLM:帮我查时间
  │
  ▼
前台(ToolRegistry):
  ├─ 翻花名册 → "get_current_time" 存在
  ├─ 拨分机   → 调 Python 函数
  └─ 转述结果 → "现在是 2026-06-16 14:30(周三)"
  │
  ▼
LLM:基于结果生成自然语言回复给用户

LLM 只需要知道三件事:工具叫什么、能干嘛、要什么参数。至于工具是 Python 函数还是远程 API,它不关心。


3. 工具的"身份证":ToolDefinition

python
@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"]
字段给谁看的?关键点
nameLLMLLM 用名字点名工具
descriptionLLM唯一决定 LLM 调不调的依据
parametersLLMLLM 按这个格式输出参数
func后端实际执行逻辑
risk后端决定要不要人工审批
tags后端分类筛选用

4. 上手:注册一个工具

python
@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. 花名册内部:怎么存、怎么防错

python
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

python
def to_openai_tools(self):
    return [t.to_openai_schema() for t in self._tools.values()]

LLM 实际收到的内容:

json
[{
  "type": "function",
  "function": {
    "name": "get_current_time",
    "description": "当用户询问当前时间、日期、星期几…时使用",
    "parameters": {"type": "object", "properties": {}, "required": []}
  }
}]

关键推论:LLM 只看到 name + description + parameters。改函数实现不影响 LLM 行为,改 description 才会。风险等级 LLM 完全不知情——这是后端的责任。


7. 执行兜底:Agent 系统的生命线 注意:这是整节课最重要的工程实践:

python
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. 完整闭环:一次工具调用的全流程

Code
# 第 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

数据流向

Code
用户问题 → 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、路由、数据库。

python
@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写操作、不可逆、外部通信删文档、发邮件、扣款

本节要点

  1. ToolRegistry = 花名册 + 转接员,LLM 和真实世界的唯一桥梁
  2. description 写给 LLM 看,写触发场景不是函数功能
  3. execute() 永不 raise,错误当工具回答回注让 LLM 自纠错
  4. 风险拦截放 Agent 层,不放 ToolRegistry 层——分层才能复用
  5. 加一个工具只需加一个装饰函数,不改任何基础设施