learn-claude-code s02 深度解析:Agent 从单一 Bash 扩展到 5 个工具的 TOOL_HANDLERS 分派机制全源码剖析
本篇基于 learn-claude-code 仓库的 s02 Tool Use 章节文档(s02_tool_use/README.ja.md)及其配套实现(s02_tool_use/code.py)撰写。核心主题是:如何在不改动 s01 已建立的 Agent 主循环(while True + stop_reason 判定)的前提下,通过一张“工具名 → 处理函数”的查找字典(TOOL_HANDLERS),把 Agent 从仅有一个 bash 工具扩展到 5 个专用工具(bash / read_file / write_file / edit_file / glob)。读完后你将掌握 Claude Code 风格“工具分派(Tool Dispatch)”这一 Harness 层的完整实现:工具 JSON Schema 的定义规范、safe_path 路径沙箱、多工具并行调用的执行顺序,以及新增一个工具所需的“两行代码”。
为什么 s01 的“仅 Bash”不够用
s01 章节(s01_agent_loop/code.py)构建的 Agent 只有一个工具:bash。这意味着模型想读文件必须拼出 cat path/to/file,写文件要写 echo "..." > file.py,编辑文件要靠 sed。问题有三:
- 多了一层“翻译”:模型的意图是“读这个文件”,却必须先翻译成 shell 语法,再经 shell 解释执行——多出来的翻译层浪费 token,且容易出错(引号嵌套、转义、特殊字符都会让
sed/echo失败); - 输出不可控:
cat对长文件的截断行为不可预测,错误信息混杂 stdout/stderr; - 安全面不可约束:每次 bash 调用都是不受限的执行面,无法在工具层面做路径沙箱。
s02 的解法就是文档的核心论点——“加一个工具 = 加一条 schema + 加一个 handler”,循环本身一行不改。
架构总览:工具分派替换硬编码调用
s01 到 s02 的唯一结构性变化发生在工具执行那一行:
# s01: 硬编码 —— 只认 bash
output = run_bash(block.input["command"])
# s02: 查找分派 —— 按名字路由到任意工具
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown: {block.name}"
对应源码位置:s01 的硬编码调用在 s01_agent_loop/code.py,s02 的分派调用在 s02_tool_use/code.py。除此之外,LLM 调用(client.messages.create(...))、stop_reason != "tool_use" 的退出判定、消息追加逻辑逐字保持不变——这正是该课程“每个章节只增加一个 Harness 机制”的设计原则。
从 1 个工具到 5 个工具:完整源码解析
1. 五个工具的 Schema 定义(告诉模型“能做什么”)
s02_tool_use/code.py 中,TOOLS 数组从 s01 的一条扩展到五条,每条包含 name、description 与严格的 input_schema:
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"limit": {"type": "integer"}},
"required": ["path"]}},
{"name": "write_file", "description": "Write content to a file.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"content": {"type": "string"}},
"required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"old_text": {"type": "string"},
"new_text": {"type": "string"}},
"required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
"input_schema": {"type": "object",
"properties": {"pattern": {"type": "string"}},
"required": ["pattern"]}},
]
各参数要点:
| 工具 | 必填参数 | 可选参数 | 说明 |
|---|---|---|---|
bash |
command |
— | 执行 shell 命令(继承自 s01) |
read_file |
path |
limit(整数) |
读文件;limit 截断行数 |
write_file |
path, content |
— | 创建/覆盖写文件 |
edit_file |
path, old_text, new_text |
— | 精确字符串一次性替换(非正则) |
glob |
pattern |
— | 按通配符查找文件 |
这里体现了一个重要的设计决策(见 web/src/data/annotations/s02.json 中 “JSON Schemas for Every Tool” 条目):每个工具都定义严格的 JSON Schema,API 会在执行前对入参做 schema 校验,模型无法传入格式错误的参数;edit_file 要求 old_text 是精确字符串而非正则,消除了“模型到底想改什么”的解析歧义。
2. safe_path:文件工具的路径沙箱
所有文件类工具(read/write/edit/glob)在执行前都经过 safe_path:
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
实现细节:先把相对路径拼到 WORKDIR 下并 resolve()(规范化 .. 与符号链接),再用 is_relative_to 检查是否逃逸工作区,逃逸则抛 ValueError。注意这一层保护只覆盖文件工具——bash 仍然不受路径限制(这正是 s03 Permission 章节要解决的问题)。
3. 四个新工具的实现函数
def run_read(path: str, limit: int | None = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
file_path = safe_path(path)
file_path.parent.mkdir(parents=True, exist_ok=True) # 自动建父目录
file_path.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
file_path = safe_path(path)
text = file_path.read_text()
if old_text not in text:
return f"Error: text not found in {path}" # 精确匹配才允许改
file_path.write_text(text.replace(old_text, new_text, 1)) # 只替换第一处
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
def run_glob(pattern: str) -> str:
import glob as g
try:
results = []
for match in g.glob(pattern, root_dir=WORKDIR):
if (WORKDIR / match).resolve().is_relative_to(WORKDIR): # 二次过滤逃逸结果
results.append(match)
return "\n".join(results) if results else "(no matches)"
except Exception as e:
return f"Error: {e}"
几个值得注意的实现细节:
- 错误即返回字符串:每个 handler 都把异常捕获后以
"Error: {e}"字符串返回,而不是抛出——因为错误文本会作为tool_result回传给模型,让模型自己决定如何恢复(这是“模型即 Agent”哲学的直接体现); run_read的截断提示:超出行数限制时追加... (N more lines),让模型知道文件还有后续,可以带 offset 再读;run_edit的replace(..., 1):只替换第一次出现,避免误伤同名文本,匹配不到则明确报错;run_glob的双重防护:除了root_dir=WORKDIR,还对每个匹配结果再做一次resolve()+is_relative_to检查,防止通配符(如../x)逃逸出工作区。
bash 工具本身从 s01 原样继承(s02_tool_use/code.py):内置危险命令黑名单(rm -rf /、sudo、shutdown、reboot、> /dev/)、120 秒超时、输出截断到 50000 字符。
4. TOOL_HANDLERS 分派字典:一次查找替代 if/elif 链
TOOL_HANDLERS = {
"bash": run_bash,
"read_file": run_read,
"write_file": run_write,
"edit_file": run_edit,
"glob": run_glob,
}
新增一个工具的全部工作量 = TOOLS 数组加一条 schema + TOOL_HANDLERS 字典加一行映射。 主循环永远不需要感知“有哪些工具”——它只知道“按名字查表、解包参数、回传结果”。
主循环中的分派逻辑与未知工具兜底
完整循环见 s02_tool_use/code.py:
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use": # 与 s01 完全相同的退出判定
return
results = []
for block in response.content:
if block.type == "tool_use":
print(f"\033[33m> {block.name}\033[0m")
handler = TOOL_HANDLERS.get(block.name) # 查找
output = handler(**block.input) if handler else f"Unknown: {block.name}"
print(str(output)[:200])
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": output})
messages.append({"role": "user", "content": results})
三个关键机制:
handler(**block.input):模型返回的结构化参数直接解包成函数实参,schema 校验 + 字典解包让参数传递零解析代码;- 未知工具兜底:用
.get()而非[]索引,查不到时回传"Unknown: {block.name}"而不是崩溃——模型收到这个 tool_result 后会自行纠正; tool_use_id绑定:每个结果通过block.id与对应的tool_use块一一对应,多个工具结果在同一轮 user 消息中各自归位。
多个工具调用:按原始顺序逐个执行
模型经常一次返回多个 tool_use 块,例如“读 a.py 和 b.py,并列出所有 .py 文件”。s02 的策略是:这些调用按它们在 response.content 中出现的原始顺序,在一个 for 循环里逐个同步执行,全部结果收集进同一个 results 列表后一次性回传(messages.append({"role": "user", "content": results}))。这里没有并行执行,也没有优先级调度——顺序确定、实现简单,符合该课程“最小可行 Harness”的定位。观察这一行为是文档给出的动手实验要点之一(见下文“试跑”)。
一个对照:agents/ 目录下的 lambda 风格变体
仓库中另有一份实现 agents/s02_tool_use.py,与章节版(s02_tool_use/code.py)是同一机制的两种写法,值得对照理解:
- 工具数量不同:
agents/版只有 4 个工具(无glob,见 agents/s02_tool_use.py),而章节版有 5 个;官方文档 docs/en/s02-tool-use.md 描述的也是 4 工具版本; - 分派映射风格不同:
agents/版用 lambda 做参数名适配(如"bash": lambda **kw: run_bash(kw["command"]),见 agents/s02_tool_use.py),章节版则直接存函数引用run_bash,依赖**block.input解包时参数名自然对齐。
从源码结构看,两种写法等价,章节版更简洁(函数签名与 schema 参数名一一对应时无需中间层),agents/ 版展示了当 handler 参数名与 schema 字段名不一致时用 lambda 适配的通用技巧。
设计决策:为什么工具恰好是这几个
web/src/data/annotations/s02.json 记录了该章节背后的三条设计决策,可作为理解工具集规模的依据:
- Why Exactly (A Few) Tools:bash + 文件读写编辑覆盖了绝大多数编码任务;工具越多,模型在“选哪个工具”上的认知负担越重,选错概率越高,同时 schema 维护成本和边界情况也越多。
bash已经可以兜底list_directory、search_files之类的场景,专用文件的意义在于给模型结构化的 I/O,避开 bash 引号/转义易错区; - The Model IS the Agent:主循环里没有路由器、决策树或工作流引擎——做什么、何时停、出错如何恢复全由模型决定,代码只是连接模型与工具的“管道”;
- JSON Schemas for Every Tool:用严格 schema 换可靠性,杜绝“自由文本 + 正则解析”的脆弱解析路径。
与 s01 的差异总览
| 组件 | s01 之前 | s02 之后 |
|---|---|---|
| 工具数 | 1(bash) | 5(+ read_file, write_file, edit_file, glob) |
| 工具执行 | 硬编码 run_bash() |
TOOL_HANDLERS 字典查找分派 |
| 路径安全 | 无 | safe_path 校验(仅文件工具) |
| Agent 循环 | while True + stop_reason |
与 s01 完全一致,一行未改 |
动手试跑
环境准备(依赖见 requirements.txt:anthropic>=0.25.0、python-dotenv>=1.0.0):
pip install anthropic python-dotenv
export ANTHROPIC_API_KEY=... # 或在 .env 中配置
export MODEL_ID=... # 代码通过 os.environ["MODEL_ID"] 读取模型名
# 可选:export ANTHROPIC_BASE_URL=... 使用兼容端点
运行(章节版脚本以当前目录为工作区,WORKDIR = Path.cwd()):
cd learn-claude-code
python s02_tool_use/code.py
进入交互式提示符 s02 >>(输入 q 退出),按文档推荐依次尝试:
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
观察要点:终端会以黄色高亮打印每次调用的工具名(> read_file 等)。重点对比模型只调一个工具与一次并发多个工具两种情况——多个工具调用是否按原始顺序逐个执行、tool_result 是否都正确回传并驱动下一轮推理。第 4 条 prompt 最容易触发一次响应内多个 tool_use。
速查
| 概念 | 一句话 |
|---|---|
TOOL_HANDLERS |
工具名 → 处理函数的字典;加工具 = 加一行映射 |
| 工具定义 | 传给模型的 JSON Schema,声明“能做什么、参数是什么” |
| 多工具调用 | 模型可一次返回多个 tool_use,按原始顺序逐个执行 |
| 循环不变 | s01 的 while True + stop_reason 判定一行未动 |
safe_path |
文件工具的路径沙箱:resolve() 后必须 is_relative_to(WORKDIR) |
小结与下一步
s02 验证了 Harness 工程的扩展性:Agent 的“能力半径”由工具集决定,而工具集的增长对主循环是零侵入的——一张查找表吸收了所有变化。但安全边界只覆盖了文件工具:safe_path 挡住了 read/write/edit/glob 越权,bash 却仍是无限制的执行面(黑名单之外的破坏性命令依然放行)。这正是下一节 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