Tool Use:Agent 是如何调用工具的

我们已经知道 AgentLoop 通过一个循环让 LLM 持续行动(参见博客主页《AgentLoop:一个循环如何让 LLM 持续行动》)。循环中有一行关键代码——执行工具。但我们从来没细说过:LLM 输出的只是一段文字,“我要调用 bash 工具,执行 ls -la”,这和真正执行命令之间,还差着十万八千里。

问题来了:LLM 的输出里只写了工具的名字和参数,代码是怎么知道该调用哪个函数的?

打个比方,你是一个新员工,老板说"用公司的报销系统提交这笔费用"。你知道要"报销",但你不知道报销系统的入口在哪、该怎么操作。这时候你需要一本《员工手册》,上面写着:报销系统 → 找财务部的张三 → 填写报销单。你拿着手册找到对应的人,把事情办了。

Agent 调用工具的过程,和这个一模一样。那本"员工手册",就是工具注册表

工具注册表:一本"员工手册"

LLM 能力很强,但它输出的只是一个工具调用请求——“我要用 xxx 工具,参数是 yyy”。它不知道这个工具具体怎么执行,就像新员工知道要"报销"但不知道找谁。

所以我们需要一个中间层,把工具名实际执行函数对应起来。这个中间层就是工具注册表,本质上就是一个字典:

TOOL_REGISTRY = {
    "bash": execute_bash,
    "read_file": read_file,
    "write_file": write_file,
}

key 是工具的名字(字符串),value 是对应的执行函数。就这么简单。

当 LLM 说"我要调用 bash",我们拿着 "bash" 去字典里查,找到 execute_bash 函数,然后调用它。这就是工具调用的全部核心逻辑。

三步走:从定义到执行

整个工具调用的过程可以拆成三步:

LLM 输出 "调用 bash,参数 ls -la"
          │
          ▼
    第一步:定义工具(注册到注册表)
          │
          ▼
    第二步:查找工具(从注册表中取出函数)
          │
          ▼
    第三步:执行工具(调用函数,拿到结果)

下面一步一步来看。

第一步:定义工具

定义工具分两件事:写一个执行函数,把它注册到注册表。

import subprocess

# 1. 写执行函数:接收参数,做实际的事
def execute_bash(command: str) -> str:
    result = subprocess.run(command, shell=True, capture_output=True, text=True)
    return result.stdout or result.stderr

# 2. 注册到注册表:工具名 → 函数
TOOL_REGISTRY = {
    "bash": execute_bash,
}

注意这里其实有两层"定义"。一层是给 LLM 看的——告诉模型有哪些工具可用、每个工具需要什么参数(这部分我们在上一篇 AgentLoop 里见过,就是 tools=[bash_tool] 里的 JSON Schema)。另一层是给代码看的——告诉代码"当 LLM 要调 bash 时,该执行哪个函数"。

我们这里重点讲的是第二层,也就是注册表这一层。

第二步:查找工具

LLM 返回了一个工具调用请求,里面带着工具名。我们拿着这个名字去注册表里查:

tool_name = "bash"          # LLM 输出的工具名
handler = TOOL_REGISTRY.get(tool_name)  # 从注册表中查找
# handler 现在就是 execute_bash 函数

一行代码搞定。如果工具名写错了或者不存在,get() 会返回 None,我们可以做兜底处理。

第三步:执行工具

拿到函数之后,把 LLM 提供的参数传进去调用就行:

command = "ls -la"              # LLM 输出的参数
output = handler(command)       # 等价于 execute_bash("ls -la")
# output 就是命令的执行结果

三步合在一起,完整的工具调用过程是这样的:

import subprocess

# 定义执行函数
def execute_bash(command: str) -> str:
    result = subprocess.run(command, shell=True, capture_output=True, text=True)
    return result.stdout or result.stderr

# 注册表
TOOL_REGISTRY = {
    "bash": execute_bash,
}

# 模拟一次工具调用
tool_name = "bash"                   # LLM 说要调 bash
tool_input = {"command": "ls -la"}   # LLM 给的参数

# 查找 + 执行
handler = TOOL_REGISTRY.get(tool_name)
output = handler(**tool_input)       # execute_bash(command="ls -la")

print(output)

从 LLM 的文字输出,到真正执行命令,中间就是查了一张表。 没有什么魔法。

完整示例:多工具注册

一个 Agent 不可能只有一个工具。我们来实现一个支持多个工具的注册表,然后把它放进 AgentLoop 里跑一圈。

import subprocess
import json

# ========== 工具执行函数 ==========

def execute_bash(command: str) -> str:
    """执行 bash 命令"""
    result = subprocess.run(command, shell=True, capture_output=True, text=True)
    return result.stdout or result.stderr

def read_file(file_path: str) -> str:
    """读取文件内容"""
    with open(file_path, "r", encoding="utf-8") as f:
        return f.read()

def write_file(file_path: str, content: str) -> str:
    """写入文件"""
    with open(file_path, "w", encoding="utf-8") as f:
        f.write(content)
    return f"写入成功:{file_path}"

# ========== 工具注册表 ==========

TOOL_REGISTRY = {
    "bash": execute_bash,
    "read_file": read_file,
    "write_file": write_file,
}

# ========== 工具执行器 ==========

def dispatch_tool(tool_name: str, tool_input: dict) -> str:
    """根据工具名查找并执行"""
    handler = TOOL_REGISTRY.get(tool_name)
    if not handler:
        return f"未知工具:{tool_name}"
    return handler(**tool_input)

dispatch_tool 是整个工具调用的核心函数。它做的事情用一句话概括:拿着工具名去注册表查到函数,把参数传进去执行,返回结果。

来测试一下:

# LLM 想执行 bash 命令
result = dispatch_tool("bash", {"command": "echo hello"})
print(result)  # 输出: hello

# LLM 想读文件
result = dispatch_tool("read_file", {"file_path": "test.txt"})
print(result)  # 输出: 文件内容

# LLM 想调一个不存在的工具
result = dispatch_tool("fly", {"where": "moon"})
print(result)  # 输出: 未知工具:fly

放进 AgentLoop 里

把工具注册表和 AgentLoop 结合起来,整个流程就完整了。回顾一下 AgentLoop 的结构:

在这里插入图片描述

dispatch_tool 嵌入循环,代码是这样的:

from anthropic import Anthropic

client = Anthropic()

# 工具定义(给 LLM 看的)
tools_definition = [
    {
        "name": "bash",
        "description": "执行 bash shell 命令",
        "input_schema": {
            "type": "object",
            "properties": {
                "command": {"type": "string", "description": "要执行的命令"}
            },
            "required": ["command"]
        }
    },
    {
        "name": "read_file",
        "description": "读取文件内容",
        "input_schema": {
            "type": "object",
            "properties": {
                "file_path": {"type": "string", "description": "文件路径"}
            },
            "required": ["file_path"]
        }
    }
]

# 工具注册表(给代码用的)
TOOL_REGISTRY = {
    "bash": execute_bash,
    "read_file": read_file,
}

def dispatch_tool(tool_name: str, tool_input: dict) -> str:
    handler = TOOL_REGISTRY.get(tool_name)
    if not handler:
        return f"未知工具:{tool_name}"
    return handler(**tool_input)

# AgentLoop 主循环
def agent_loop(query: str):
    messages = [{"role": "user", "content": query}]

    for turn in range(20):
        resp = client.messages.create(
            model="claude-sonnet-4-20250514",
            messages=messages,
            tools=tools_definition,
            max_tokens=4096,
        )
        messages.append({"role": "assistant", "content": resp.content})

        # 没调工具,任务完成
        if resp.stop_reason != "tool_use":
            break

        # 处理每一个工具调用
        tool_results = []
        for block in resp.content:
            if block.type == "tool_use":
                # 关键:从注册表查找并执行
                output = dispatch_tool(block.name, block.input)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output,
                })

        messages.append({"role": "user", "content": tool_results})

流程走一遍:

用户: "帮我看下 test.txt 有没有内容"
    │
    ▼
LLM 第一轮: 调用 read_file(file_path="test.txt")
    │
    ▼
dispatch_tool("read_file", {"file_path": "test.txt"})
  → TOOL_REGISTRY["read_file"]  →  read_file 函数
  → 返回文件内容
    │
    ▼
LLM 第二轮: "文件内容是 xxx"(不调工具,结束)

关键就在 dispatch_tool 这一行。 LLM 不知道 read_file 函数在哪、怎么实现的,它只需要说出工具名。代码端通过注册表查找到对应的函数并执行。两者通过"工具名"这个字符串作为桥梁连接起来。

两层定义的关系

到这里你可能发现了一个问题:工具似乎定义了两遍?tools_definition 里定义了一遍(给 LLM 看的 JSON Schema),TOOL_REGISTRY 里又定义了一遍(给代码用的函数映射)。

确实如此。这两层定义各管各的:

给谁看 内容 作用
JSON Schema LLM 工具名、描述、参数格式 让模型知道有什么工具、怎么用
注册表 代码 工具名 → 执行函数 让代码知道该调哪个函数

它们通过工具名这个字符串关联起来。LLM 输出的 block.name 和注册表的 key 必须一致,否则就查不到。

为什么不能合并成一个?因为 LLM 需要的是描述性的信息(这个工具干什么、参数是什么类型),而代码需要的是可执行的函数引用。两者的格式完全不同,所以必须分开定义。当然,实际框架中会做一些封装让两者保持同步,但从原理上理解,它们就是两个独立的东西。

没有注册表会怎样

你可能会想:不用注册表行不行?直接写一堆 if-else 不也一样?

if tool_name == "bash":
    output = execute_bash(**tool_input)
elif tool_name == "read_file":
    output = read_file(**tool_input)
elif tool_name == "write_file":
    output = write_file(**tool_input)
else:
    output = "未知工具"

能跑,但有几个问题:

扩展性差。 每加一个工具,就得改一次 if-else。工具多了之后,这个函数会变成一坨又长又难维护的面条代码。

注册表的本质是策略模式。 把"选择执行哪个函数"这件事从代码逻辑中剥离出来,变成一张表。新增工具只需要往表里加一行,不需要改执行逻辑。这和我们用字典代替 switch-case 是同一个思路。

方式 新增工具 可维护性 可读性
if-else 改主逻辑 工具少还行,多了就乱
注册表 加一行 KV 清晰,一眼看到所有工具

和真实 Agent 框架的对应

Claude Code 的源码里也有类似的结构。它用 TypeScript 写的,但思路一模一样:

工具定义(给 LLM)   →  tools 数组,包含 JSON Schema
工具注册表(给代码)  →  TOOL_HANDLERS 对象,包含执行函数
工具调度器            →  根据 tool_use.name 查找 handler 并执行

你在各种 Agent 框架(LangChain、AutoGPT、Claude Code)里看到的 Tool Use 实现,剥开封装,核心都是这个:一个注册表 + 一个查找执行的过程

小结

Agent 调用工具的过程,说白了就三步:定义、查找、执行。LLM 负责决定"用什么工具、传什么参数",注册表负责"根据名字找到对应的函数",代码负责"调用函数拿到结果"。三者通过工具名这个字符串串在一起。

从软件工程的角度看,工具注册表就是策略模式的一个简单应用——把"做什么"和"怎么做"解耦,通过一张映射表连接。这个模式在后端开发中随处可见:路由注册、中间件注册、事件监听……Agent 的工具注册表,和 Express 的 app.get('/api', handler) 本质上是同一件事。

理解了工具注册表,你就理解了 Agent 调用工具的全部机制。下次看到 Agent 说"我要调用某某工具"的时候,你就知道它背后发生了什么:查了一张表,调了一个函数,仅此而已。

注:本文参考了 GitHub 开源学习项目:learn-claude-code。这是一个非常优秀的学习资源,推荐读者结合项目内容一同学习。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐