首页
/ learn-claude-code s02:工具分发(Tool Use)——多加一个工具,只加一行 Handler

learn-claude-code s02:工具分发(Tool Use)——多加一个工具,只加一行 Handler

2026-09-04 22:43:52作者:郦嵘贵Just

本篇基于 learn-claude-code 教程的 s02 章节(s02_tool_use/README.zh.md)展开。s02 解决的问题是:s01 的 Agent 只有 bash 一个工具,模型每读一个文件都得自己拼 cat、每改一处都得拼 sed,既浪费 token 又容易拼错。本章把工具从 1 个扩到 5 个,并引入 TOOL_HANDLERS 查表分发机制——读完你可以理解"工具定义 + 处理函数注册"这一最小可扩展工具系统的设计,并能在 s02_tool_use/code.py 上亲手验证模型的多工具并行调用行为。

Tool Dispatch:LLM 的 tool_use 块经 TOOL_HANDLERS 查表分发到 bash/read_file/write_file/edit_file/glob 五个 handler,结果以 tool_result 回传

问题起点:只有 bash 一个工具有多别扭

s01(s01_agent_loop/code.py)的 Agent 唯一的工具是 bash:

TOOLS = [{"name": "bash", ...}]

def run_bash(command): ...

带来的问题是"翻译层"损耗:模型脑子里想的是"读这个文件",输出却要拼出 cat path/to/file;想覆盖写文件要拼 echo "..." > file.py;想改一行要拼 sed。每一次意图→命令的翻译都在消耗 token,还有拼错转义、shell 引号的风险。

s02 的思路是:把"文件读/写/改/找"这类高频操作升级为一等公民工具,让模型直接表达意图,由 harness 负责执行。

核心机制:工具定义 + 分发字典,循环一行不改

s01 的循环被完全保留(LLM 调用、stop_reason 判断、消息追加)。唯一的变动在工具执行那一行:硬编码的 run_bash() 被替换为 TOOL_HANDLERS[block.name]() 查表分发。

给 Agent 加一个工具只需要做两件事:

  1. 定义工具:在 TOOLS 数组里加一条 JSON schema 描述
  2. 注册处理函数:在 TOOL_HANDLERS 字典里加一行映射

从 1 个工具到 5 个工具:完整工具清单

源码 s02_tool_use/code.py#L124-L135 中,TOOLS 从 s01 的 1 条扩到 5 条,每条都是独立的 JSON schema 定义:

TOOLS = [
    {"name": "bash",       "description": "Run a shell command.", ...},
    {"name": "read_file",  "description": "Read file contents.",  ...},
    {"name": "write_file", "description": "Write content to file.", ...},
    {"name": "edit_file",  "description": "Replace text in file once.", ...},
    {"name": "glob",       "description": "Find files by pattern.", ...},
]

源码中每条定义的 input_schema 完整结构如下(决定模型能传什么参数):

工具 参数 必填
bash command: string command
read_file path: stringlimit: integer path
write_file path: stringcontent: string pathcontent
edit_file path: stringold_text: stringnew_text: string 三者全部
glob pattern: string pattern

description 字段写给模型看,input_schema 约束模型可传的参数字段与类型——这就是"告诉模型我能做什么"的完整契约。

五个处理函数的源码细节

s02 新增四个实现函数(s02_tool_use/code.py#L71-L119),每个都自带异常兜底,把错误以字符串形式返回给模型而不是让循环崩溃:

def run_read(path, limit=None):
    lines = safe_path(path).read_text().splitlines()
    if limit:
        lines = lines[:limit]
    return "\n".join(lines)

def run_write(path, content):
    safe_path(path).write_text(content)
    return f"Wrote {len(content)} bytes to {path}"

def run_edit(path, old_text, new_text):
    text = safe_path(path).read_text()
    if old_text not in text:
        return "Error: text not found"
    safe_path(path).write_text(text.replace(old_text, new_text, 1))
    return f"Edited {path}"

def run_glob(pattern):
    import glob as g
    return "\n".join(g.glob(pattern, root_dir=WORKDIR))

对照 s02_tool_use/code.py 的实际实现,有四个值得注意的工程细节:

  1. run_read 的 limit 提示L81-L82):当 limit < len(lines) 时会在截断处追加 ... (N more lines) 标记,让模型知道文件还有后续内容、可以用 offset 思路继续读,而不是以为这就是整个文件。
  2. run_write 自动建父目录L91):file_path.parent.mkdir(parents=True, exist_ok=True),模型写 docs/new/file.py 时目录不存在也不会报错。
  3. run_edit 只替换第一次出现L104):text.replace(old_text, new_text, 1) 的第三个参数 1 限制了替换次数;若 old_text 在文件中不存在,直接返回 Error: text not found in {path},模型据此可以重新读取文件再试。
  4. run_glob 的双重过滤L113-L117):g.glob(pattern, root_dir=WORKDIR) 先限定根目录,再对每个匹配结果做 is_relative_to(WORKDIR) 校验,防止符号链接等情形下结果越界。

safe_path:文件类工具的沙箱边界

所有 file tools 在真正读写前都会先过 safe_paths02_tool_use/code.py#L71-L75):

def safe_path(p: str) -> Path:
    path = (WORKDIR / p).resolve()
    if not path.is_relative_to(WORKDIR):
        raise ValueError(f"Path escapes workspace: {p}")
    return path

resolve() 先把路径完全展开(解析 .. 和符号链接),再用 is_relative_to 检查是否仍在工作目录内。这意味着 ../../etc/passwd 这类路径穿越会被 ValueError 拦下,最终由各 run_* 函数的 except 转成 Error: Path escapes workspace: ... 字符串回传模型。注意这个保护只覆盖 file tools,不覆盖 bash——rm -rf 走的是 subprocesssafe_path 管不到它,这正是下一章要解决的问题。

分发字典:循环里唯一改动的一行

TOOL_HANDLERS = {
    "bash":       run_bash,
    "read_file":  run_read,
    "write_file": run_write,
    "edit_file":  run_edit,
    "glob":       run_glob,
}

# 循环里只改了一行——从硬编码 run_bash 变成查表:
for block in response.content:
    if block.type == "tool_use":
        handler = TOOL_HANDLERS[block.name]    # 查表
        output = handler(**block.input)         # 调用
        results.append(...)

对应 s02_tool_use/code.py#L149-L169agent_loop 实际实现,与 s01(s01_agent_loop/code.py#L85-L113)逐行对比,结构完全一致:while True 循环、client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000)、先追加 assistant 消息、stop_reason != "tool_use" 时返回、逐个执行 tool_use 块、把 tool_result 作为 user 消息追加回传。差异只在两处:

  • 执行行从 output = run_bash(block.input["command"]) 变成 output = TOOL_HANDLERSblock.name,工具名到函数指针的映射完全由字典驱动;
  • 源码用了更稳健的写法 handler = TOOL_HANDLERS.get(block.name)L164-L165):若模型幻觉出一个未注册的工具名,不会 KeyError 崩溃,而是回传 Unknown: {block.name},让模型自己纠正。

加一个工具 = 在 TOOLS 数组加一条 + 在 TOOL_HANDLERS 字典加一行。循环不变。 这就是 s02 的核心论点:"加一个工具,只加一个 handler"。

多个工具调用:一次响应,按原始顺序逐个执行

模型经常一次返回多个 tool_use 块,比如对 prompt"读一下 a.py 和 b.py,然后列出所有 .py 文件",response.content 里会同时出现 read_fileread_fileglob 三个块。

分发逻辑(s02_tool_use/code.py#L161-L169)是同步串行的:for block in response.contentresponse.content 中的原始顺序逐个执行,每个结果 {"type": "tool_result", "tool_use_id": block.id, "content": output} 通过 tool_use_id 与发起的 tool_use 块一一对应,最后打包成一个 user 消息一次性回传给模型。也就是说,harness 不需要做并发调度——顺序执行天然满足模型对"先读后列"这类依赖关系的期望,而多个独立调用也只是被连续执行而已。

速查

概念 一句话
TOOL_HANDLERS 工具名 → 处理函数的字典。加工具 = 加一行映射
工具定义 告诉模型"我能做什么"的 JSON schema
多工具调用 模型可一次返回多个 tool_use,并按原始顺序逐个执行
循环不变 s01 的 while True 循环一行都没改

相对 s01 的变更

组件 之前 (s01) 之后 (s02)
工具数量 1 (bash) 5 (+read, write, edit, glob)
工具执行 硬编码 run_bash() TOOL_HANDLERS 查表分发
路径安全 safe_path 校验(仅 file tools)
循环 while True + stop_reason 与 s01 完全一致

从源码结构看,s02 还顺手增强了 bash 自身的防护边界(s02_tool_use/code.py#L53-L66):危险命令黑名单从简单的字符串包含判断扩展为 ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]subprocess.run 增加了 encoding="utf-8", errors="replace" 防止输出解码崩溃,超时上限仍为 120 秒,输出截断到 50000 字符——这条 50KB 的输出上限对控制回传 token 量很关键。

试一下

运行前提:requirements.txt 声明了依赖 anthropic>=0.25.0python-dotenv>=1.0.0(另有 pyyaml>=6.0),需要配置 ANTHROPIC_API_KEY(支持 ANTHROPIC_BASE_URL 指向兼容网关,见 s02_tool_use/code.py#L40-L46),并通过 MODEL_ID 环境变量指定模型。

cd learn-claude-code
python s02_tool_use/code.py

试试这些 prompt:

  1. Read the file README.md and tell me what this project is about
  2. Create a file called test.py that prints "hello", then read it back
  3. Find all Python files in this directory
  4. Read both README.md and requirements.txt, then create a summary file

观察重点:模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确?终端里每个工具调用会以黄色 > 工具名 前缀打印,结果截取前 200 字符展示,方便你核对分发顺序与回传内容。

接下来:工具边界已扩展,权限边界还没建

现在 Agent 有 5 个专用工具,file tools 受 safe_path 保护,但 bash 不受限制——rm -rf 这类命令依然能跑(黑名单只是很粗的字符串匹配)。

下一章 s03 Permission(s03_permission/)会在工具执行之前加一道门:这个操作安全吗?需要用户批准吗?工具分发回答了"模型能触达什么",权限系统回答"哪些触达要先过问"。

登录后查看全文
热门项目推荐
相关项目推荐