learn-claude-code s02 工具分发:给 Agent 加一个工具,只需加一行代码
本文基于 learn-claude-code 课程第 s02 章(Tool Use)文档与配套源码展开,讲解如何在一个已有的 Agent 循环上,通过“工具定义数组 + 分发映射表”两个数据结构,把工具集从 1 个 bash 扩展到 5 个专用工具(读、写、改、glob)。读完后你将掌握:如何为 LLM Agent 编写带 JSON Schema 的工具定义、如何用一张 TOOL_HANDLERS 字典完成工具调用分发、如何用 safe_path 约束文件类工具的活动范围,以及模型一次返回多个 tool_use 时的执行顺序约定。
为什么只靠 Bash 不够
s01(Agent Loop)阶段的 Agent 只有一个工具:bash。它想读文件得拼 cat path/to/file,想写文件得拼 echo "..." > file.py,想改文件得拼 sed。文档指出的核心问题是:模型脑子里想的是“读这个文件”,却必须把意图翻译成一条 shell 命令——这层额外的翻译既浪费 token,又容易出错(引号转义、特殊字符、shell 方言差异)。
s02 的解法就是课程标题所概括的:
"Add a tool, add just one handler" —— 循环保持不变,把新工具注册进分发表就完事。
本章属于整个课程 Harness 层的 Tool Dispatch(工具分发)环节,主题是“扩展模型能够触及的范围”。
从 1 个工具到 5 个工具:工具定义层
s01 中工具定义只有一个 bash:
TOOLS = [{"name": "bash", ...}]
def run_bash(command): ...
s02 将其扩展为 5 个工具,每个工具都是“一份 JSON 定义 + 一个独立的实现函数”:
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.", ...},
]
完整的工具定义在 s02_tool_use/code.py 中,每一项都是标准的 Anthropic Messages API 工具格式(name + description + input_schema)。以 read_file 为例:
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"}, "limit": {"type": "integer"}},
"required": ["path"]}},
结合源码可以看到每个工具的实现细节与边界行为:
| 工具 | 实现函数 | 参数 | 源码中的关键行为 |
|---|---|---|---|
bash |
run_bash | command(必填) |
危险命令黑名单(rm -rf /、sudo、shutdown、reboot、> /dev/)直接拦截;subprocess.run 超时 120s;stdout+stderr 合并后截断到 50000 字符 |
read_file |
run_read | path(必填)、limit(可选) |
超过 limit 行时追加 ... (N more lines) 提示,避免大文件撑爆上下文 |
write_file |
run_write | path、content(均必填) |
自动 mkdir(parents=True, exist_ok=True) 创建父目录;返回 Wrote N bytes to path |
edit_file |
run_edit | path、old_text、new_text(均必填) |
用 text.replace(old_text, new_text, 1) 精确替换一次;old_text 不存在时返回 Error: text not found in {path} 而非静默成功 |
glob |
run_glob | pattern(必填) |
用 glob.glob(pattern, root_dir=WORKDIR) 限定搜索根目录,并对每个匹配结果二次校验是否仍在工作区内;无匹配返回 (no matches) |
注意两个实现层面的设计取舍:
- 所有工具失败时都返回字符串错误(
Error: ...)而不是抛异常。异常若直接向上抛会打断agent_loop;返回错误字符串则能让模型“看到”失败信息并自行修正重试——这是 Agent 工具设计的常见范式。 run_bash只做了极轻量的危险命令拦截(从 s01 原样继承),真正的权限门控留到了 s03 Permission 章节。文档在结尾明确提示:文件类工具有safe_path保护,但 bash 基本不设防,rm -rf /依然会被执行(只被那行黑名单挡住字面匹配)。
工具分发:TOOL_HANDLERS 映射表
s02 唯一的结构性新增是一张分发映射表(s02_tool_use/code.py):
TOOL_HANDLERS = {
"bash": run_bash,
"read_file": run_read,
"write_file": run_write,
"edit_file": run_edit,
"glob": run_glob,
}
Agent 循环里原来硬编码的 run_bash() 调用被替换成一次字典查表(agent_loop):
# s01: output = run_bash(block.input["command"])
# s02: output = TOOL_HANDLERSblock.name
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":
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})
对照 s01_agent_loop/code.py 中的原始循环,可以逐行确认文档的说法“循环一个字都没改”:while True、client.messages.create、stop_reason 判断、assistant 消息追加、tool_result 回传,全部原样保留;差异仅在两处——
- 执行语句从
run_bash(block.input["command"])变为TOOL_HANDLERS.get(block.name)查表后再以handler(**block.input)调用,参数按模型返回的block.input字典解包传入; - 用
.get()而非[]索引,查不到 handler 时返回Unknown: {name}字符串,保证即使模型幻觉出一个不存在的工具名,循环也能带着错误信息继续而不是崩溃。
因此“给 Agent 加工具”的成本被压缩成固定两步:在 TOOLS 数组里加一个条目(告诉模型能做什么),在 TOOL_HANDLERS 里加一行映射(告诉运行时怎么执行)。循环本身对工具数量零感知。
路径安全:safe_path 约束文件类工具
s02 相比 s01 的另一项实质变化是给文件类工具加上了工作区边界校验(s02_tool_use/code.py):
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(WORKDIR) 断言解析结果必须仍在工作区内。这样 ../../etc/passwd、指向工作区外的符号链接等逃逸手段都会在拼接阶段被 ValueError 拦下,再由 run_read 等函数外层的 except 转成 Error: Path escapes workspace: ... 返回给模型。
从源码结构看,该保护只覆盖 read_file/write_file/edit_file 以及 glob 的结果校验(run_glob 中每个匹配都再次检查 is_relative_to(WORKDIR)),run_bash 不受此约束——这正是文档在 “What's Next” 中引向 s03 Permission 的动机:在工具执行前插入一道“这个操作安全吗?需要用户批准吗?”的关卡。
多个工具调用:一次响应,顺序执行
文档指出一个实际观察到的行为模式:模型经常在单次响应中返回多个 tool_use 块,例如“读 a.py 和 b.py,再列出所有 .py 文件”。源码中的处理逻辑是:遍历 response.content 中所有 type == "tool_use" 的块,按它们在 response.content 中的原始顺序逐一执行,把每个结果以 {"type": "tool_result", "tool_use_id": block.id, "content": output} 的格式收集进 results,最后作为一条 user 消息整体回传。
这里有两个可验证的细节:
- 每个
tool_result通过tool_use_id与对应的tool_use块一一绑定,模型回看历史时能明确区分“哪个调用产生了哪个结果”; - 执行是串行且保序的,多个工具之间没有并行、没有乱序——这一点可以用文档给出的验证 prompt 实测确认。
动手验证
运行前提(参考 根 README 的 Quick Start):
cd learn-claude-code
pip install -r requirements.txt
cp .env.example .env # 配置 ANTHROPIC_API_KEY(另需 MODEL_ID 环境变量,见 code.py 中 os.environ["MODEL_ID"])
python s02_tool_use/code.py
进入交互式 REPL(s02 >> 提示符,输入 q 退出)后,文档建议用以下四个 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
观察要点:模型什么时候只调用一个工具,什么时候一次调用多个?多个工具调用是否按正确顺序执行?控制台会打印每个工具名(黄色 > read_file)和输出前 200 字符,便于逐次核对。
s01 → s02 变更清单
文档给出的对比表完整继承如下:
| 组件 | 之前(s01) | 之后(s02) |
|---|---|---|
| 工具数量 | 1(bash) | 5(+read、write、edit、glob) |
| 工具执行 | 硬编码 run_bash() |
TOOL_HANDLERS 分发查表 |
| 路径安全 | 无 | safe_path 校验(仅文件类工具) |
| Agent 循环 | while True + stop_reason 判断 |
与 s01 完全一致 |
概念速查表:
| 概念 | 一句话解释 |
|---|---|
TOOL_HANDLERS |
工具名 → 处理函数的字典;加工具 = 加一行映射 |
工具定义(TOOLS) |
用 JSON Schema 告诉模型“我能做什么” |
| 多工具调用 | 模型可能一次返回多个 tool_use;按原始顺序逐一执行 |
| 循环不变 | s01 的 while True 循环一行未改 |
对照阅读:12 课旧版实现
仓库中另有一条 legacy 12 课轨道,对应实现是 agents/s02_tool_use.py。它与本章 s02_tool_use/code.py 的差异可用于理解同一模式的演进:旧版只有 4 个工具(没有 glob),且分发映射用的是 lambda 适配层(agents/s02_tool_use.py):
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
...
}
新版改为直接绑定函数、以 handler(**block.input) 解包调用,少了一层 lambda 中转。两条实现的分发思想完全一致,印证了本章的核心结论:“循环一点没变,我只是加了工具”。
小结与下一步
s02 用最小改动回答了 Agent 工程中一个高频问题——如何以 O(1) 的成本扩展工具集:工具定义层(TOOLS)负责模型侧的能力声明,分发表(TOOL_HANDLERS)负责运行时的路由,两者加在一起就是全部增量,Agent 循环对工具数量保持无感知。文件类工具经 safe_path 被约束在工作区内,但 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 StartedRust0622
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