learn-claude-code s02:工具分发(Tool Use)——多加一个工具,只加一行 Handler
本篇基于 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 上亲手验证模型的多工具并行调用行为。
问题起点:只有 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 加一个工具只需要做两件事:
- 定义工具:在
TOOLS数组里加一条 JSON schema 描述 - 注册处理函数:在
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: string,limit: integer |
path |
write_file |
path: string,content: string |
path、content |
edit_file |
path: string,old_text: string,new_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 的实际实现,有四个值得注意的工程细节:
run_read的 limit 提示(L81-L82):当limit < len(lines)时会在截断处追加... (N more lines)标记,让模型知道文件还有后续内容、可以用 offset 思路继续读,而不是以为这就是整个文件。run_write自动建父目录(L91):file_path.parent.mkdir(parents=True, exist_ok=True),模型写docs/new/file.py时目录不存在也不会报错。run_edit只替换第一次出现(L104):text.replace(old_text, new_text, 1)的第三个参数1限制了替换次数;若old_text在文件中不存在,直接返回Error: text not found in {path},模型据此可以重新读取文件再试。run_glob的双重过滤(L113-L117):g.glob(pattern, root_dir=WORKDIR)先限定根目录,再对每个匹配结果做is_relative_to(WORKDIR)校验,防止符号链接等情形下结果越界。
safe_path:文件类工具的沙箱边界
所有 file tools 在真正读写前都会先过 safe_path(s02_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 走的是 subprocess,safe_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-L169 的 agent_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_file、read_file、glob 三个块。
分发逻辑(s02_tool_use/code.py#L161-L169)是同步串行的:for block in response.content 按 response.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.0、python-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:
Read the file README.md and tell me what this project is aboutCreate a file called test.py that prints "hello", then read it backFind all Python files in this directoryRead 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/)会在工具执行之前加一道门:这个操作安全吗?需要用户批准吗?工具分发回答了"模型能触达什么",权限系统回答"哪些触达要先过问"。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00