learn-claude-code s02 深度解析:工具分发机制——加一个工具,只加一个 handler
本文基于 learn-claude-code 仓库的课程文档 docs/zh/s02-tool-use.md 与配套源码 agents/s02_tool_use.py、s02_tool_use/code.py 展开,讲解 Agent Harness 层中最核心的「工具分发(Tool Dispatch)」设计:如何在完全不改动 s01 Agent 循环的前提下,把单一 bash 工具扩展为多个带路径沙箱的专用文件工具。读完后,你将能够理解 TOOLS 定义、TOOL_HANDLERS 查表分发、safe_path 工作区沙箱三者的协作关系,并掌握「加一个工具 = 加一条 schema + 加一个 handler」的扩展方法。
一、问题:只有一个 bash 工具不够用
在 s01(Agent Loop)中,Agent 只有 bash 一个工具:读文件要 cat,写文件要 echo "..." > file.py,改文件要 sed。课程文档 s02_tool_use/README.zh.md 指出了这套做法的三个具体痛点:
- 翻译损耗:模型想的是"读这个文件",却必须拼出
cat path/to/file这样的 shell 命令,多了一层翻译,浪费 token,还容易拼错; - 行为不可预测:
cat的截断行为不可预测,sed遇到特殊字符就会崩; - 安全面过大:每一次 bash 调用都是一个不受约束的安全面。
因此 s02 引入专用工具(read_file、write_file 等),目的是在工具层面做路径沙箱——文件读写被强制限制在工作区内,而不是依赖 shell 命令"自觉"。
文档给出了本节的关键洞察:
加工具不需要改循环。 新工具注册进 dispatch map 就行,循环体一字不动。
这一点可以在源码中得到验证:对比 agents/s01_agent_loop.py 与 agents/s02_tool_use.py,两者 agent_loop 的差异只有一行——
# s01(硬编码执行):
output = run_bash(block.input["command"])
# s02(查表分发):
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
二、解决方案:工具分发架构
文档给出的整体架构如下(User prompt → LLM → Tool Dispatch,tool_result 回灌模型):
+--------+ +-------+ +------------------+
| User | ---> | LLM | ---> | Tool Dispatch |
| prompt | | | | { |
+--------+ +---+---+ | bash: run_bash |
^ | read: run_read |
| | write: run_wr |
+-----------+ edit: run_edit |
tool_result | } |
+------------------+
The dispatch map is a dict: {tool_name: handler_function}.
One lookup replaces any if/elif chain.
核心思想只有一句话:dispatch map 就是一个 {tool_name: handler_function} 的字典,一次查表替代任何 if/elif 链。工具名由模型在 tool_use block 中给出,循环按名称查表调用对应 handler,把输出包装成 tool_result 回传。
三、工作原理:从沙箱到分发的三层拆解
3.1 第一层:safe_path() 路径沙箱
文档「工作原理」第 1 步指出:每个工具有一个处理函数,路径沙箱防止逃逸工作区。对应 agents/s02_tool_use.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 / p:把模型给出的(可能相对的)路径拼接到工作目录下,WORKDIR = Path.cwd()即运行命令时所在的目录;.resolve():解析..、符号链接等,得到真实绝对路径——这一步是关键,没有它../可以直接绕过前缀检查;is_relative_to(WORKDIR):解析后仍必须位于工作区内,否则抛出ValueError("Path escapes workspace: ...")。
注意抛出的异常会被各 handler 的 try/except 捕获并转成 Error: Path escapes workspace: ... 这样的字符串返回给模型(见 agents/s02_tool_use.py 中 run_read 的写法),模型读到错误信息后会自行换路径重试——沙箱不是"拦截后崩溃",而是把越界变成一个可读的反馈。
文档在「接下来」一节明确提示了这套沙箱的边界:safe_path 只保护文件类工具,bash 本身不受限制(rm -rf / 仍会被 shell 执行,仅被关键词黑名单挡下),这正是 s03 Permission 要解决的问题。
3.2 第二层:各工具的处理函数
以 run_read 为例(文档原样代码):
def run_read(path: str, limit: int = None) -> str:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit]
return "\n".join(lines)[:50000]
结合 agents/s02_tool_use.py 与 s02_tool_use/code.py 两个版本的源码,s02 的工具实现细节如下:
| 工具 | 实现函数 | 关键行为(源码确认) |
|---|---|---|
bash |
run_bash(command) |
关键词黑名单 ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"] 命中即返回 Error: Dangerous command blocked;subprocess.run(shell=True, cwd=WORKDIR, timeout=120),超 120 秒返回 Error: Timeout (120s);输出截断至 50000 字符,无输出时返回 (no output) |
read_file |
run_read(path, limit) |
经 safe_path 校验;limit 截取前 N 行;结果截断至 50000 字符;s02_tool_use/code.py 版本还会追加 ... (N more lines) 提示剩余行数 |
write_file |
run_write(path, content) |
经 safe_path 校验;parent.mkdir(parents=True, exist_ok=True) 自动创建父目录;返回 Wrote N bytes to path |
edit_file |
run_edit(path, old_text, new_text) |
经 safe_path 校验;old_text not in text 时返回 Error: text not found;replace(old_text, new_text, 1) 保证只替换第一处,与 schema 描述 "Replace exact text in file once" 一致 |
glob(章节扩展版) |
run_glob(pattern) |
s02_tool_use/code.py 中 g.glob(pattern, root_dir=WORKDIR),且对每个匹配结果再次做 is_relative_to(WORKDIR) 过滤,防止符号链接逃出工作区;无匹配返回 (no matches) |
关于工具数量:课程文档 docs/zh/s02-tool-use.md 与 agents/s02_tool_use.py 以 4 个工具(bash、read_file、write_file、edit_file)讲解;而章节完整代码 s02_tool_use/code.py 额外加入了第 5 个
glob工具。两者的循环与分发机制完全相同,glob正是「加一个工具只加两行」这一论点的最小示例。
所有 handler 都遵循同一个错误约定:不抛异常给循环,而是把 Error: ... 作为字符串返回。这样工具失败只是一次普通的 tool_result,模型可以阅读错误并决定下一步,循环本身不会被异常打断。
3.3 第三层:TOOLS 定义与 input_schema
文档强调"加工具 = 加 handler + 加 schema"。schema 是告诉模型"我能做什么"的 JSON 定义,agents/s02_tool_use.py 中四个工具的完整定义:
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 file.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"content": {"type": "string"}},
"required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"old_text": {"type": "string"},
"new_text": {"type": "string"}},
"required": ["path", "old_text", "new_text"]}},
]
各参数含义与约束:
| 参数 | 所属工具 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
command |
bash | string | 是 | 要执行的 shell 命令,在 WORKDIR 下以 shell=True 运行 |
path |
read_file / write_file / edit_file | string | 是 | 相对 WORKDIR 的路径,会被 safe_path 沙箱校验 |
limit |
read_file | integer | 否 | 最多返回的行数;缺省读全文(仍受 50000 字符截断约束) |
content |
write_file | string | 是 | 完整文件内容,直接覆盖写入(父目录自动创建) |
old_text |
edit_file | string | 是 | 要被精确匹配替换的原文片段,匹配不到则报错 |
new_text |
edit_file | string | 是 | 替换文本,仅替换 old_text 的第一处出现 |
3.4 分发映射:TOOL_HANDLERS
文档第 2 步给出 dispatch map:
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"],
kw["new_text"]),
}
agents/s02_tool_use.py 采用 lambda 包装,作用是把模型传入的 block.input 字典(参数名各不相同)统一解包成各 handler 的位置参数;而 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,
}
两种写法等价,dict.get(name) 一次查表取代了硬编码分支。字典键必须与 TOOLS 中的 name 严格一致——TOOLS 负责让模型"知道"工具有什么,TOOL_HANDLERS 负责让循环"知道"工具怎么执行,两者构成一对。
3.5 循环中按名称查找:s01 循环零改动
文档第 3 步说明循环中按名称查找处理函数,循环体本身与 s01 完全一致:
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler \
else f"Unknown tool: {block.name}"
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
放入完整上下文(agents/s02_tool_use.py),s02 的 agent_loop 是:
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":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
results.append({"type": "tool_result",
"tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
几个值得注意的实现事实:
- 未知工具兜底:
TOOL_HANDLERS.get(block.name)查不到时不抛KeyError,而是返回Unknown tool: {name}字符串,保证消息序列仍然合法; - 多个工具调用按序执行:模型一次响应可能包含多个
tool_useblock(例如"读一下 a.py 和 b.py,然后列出所有 .py 文件")。for block in response.content按response.content中的原始顺序逐个执行,所有结果打包成一条user消息(content为tool_result列表)一次性回灌; - 回灌协议:每个
tool_result通过tool_use_id与模型发出的调用一一配对,这是 Anthropic Messages API 的约定,agents/s01_agent_loop.py 中同样的配对逻辑在 s01 就已存在,s02 没有改变消息协议。
四、相对 s01 的变更
完整继承文档的变更对照表:
| 组件 | 之前 (s01) | 之后 (s02) |
|---|---|---|
| Tools | 1 (仅 bash) | 4 (bash, read, write, edit) |
| Dispatch | 硬编码 bash 调用 | TOOL_HANDLERS 字典 |
| 路径安全 | 无 | safe_path() 沙箱 |
| Agent loop | 不变 | 不变 |
与 s02_tool_use/README.zh.md 的速查表互相印证:
| 概念 | 一句话 |
|---|---|
TOOL_HANDLERS |
工具名 → 处理函数的字典。加工具 = 加一行映射 |
| 工具定义 | 告诉模型"我能做什么"的 JSON schema |
| 多工具调用 | 模型可一次返回多个 tool_use,并按原始顺序逐个执行 |
| 循环不变 | s01 的 while True 循环一行都没改 |
对照两份源码可以确认"循环不变"并非修辞:agents/s01_agent_loop.py 与 agents/s02_tool_use.py 的 agent_loop 在 LLM 调用参数(max_tokens=8000)、stop_reason != "tool_use" 退出判断、assistant 消息追加、tool_result 回灌结构上逐行一致,唯一分叉点就是工具执行那一行。
五、动手运行
环境准备(依据 requirements.txt):
pip install anthropic>=0.25.0 python-dotenv>=1.0.0
# 配置 .env:必须设置 MODEL_ID,可选 ANTHROPIC_BASE_URL(指向兼容 API 的代理时,
# 程序会自动 pop 掉 ANTHROPIC_AUTH_TOKEN,见 agents/s02_tool_use.py L29-L36)
按文档「试一试」一节运行(交互模式,输入 q 退出):
cd learn-claude-code
python agents/s02_tool_use.py
也可以运行包含第 5 个 glob 工具的章节完整版:
python s02_tool_use/code.py
文档给出的测试 prompt(英文 prompt 对 LLM 效果更好,也可以用中文):
Read the file requirements.txtCreate a file called greet.py with a greet(name) functionEdit greet.py to add a docstring to the functionRead greet.py to verify the edit worked
章节 README 另外补充了针对 glob 与多工具并发的 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
观察重点(来自 s02_tool_use/README.zh.md):模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确?终端中每次 tool_use 都会打印 > 工具名: 与输出前 200 字符(agents/s02_tool_use.py),便于核对执行顺序。
六、边界与下一步
s02 交付后的 Agent 状态与遗留问题(文档「接下来」一节的原文结论):
- 文件类工具受
safe_path保护,bash 不受限制——黑名单只挡rm -rf /、sudo等关键词,绕开词面的危险命令仍能执行; - 工具输出统一截断在 50000 字符、bash 超时 120 秒,属于朴素但有效的护栏,尚无审批机制。
由此自然过渡到 s03 Permission:在工具执行之前加一道门——"这个操作安全吗?需要用户批准吗?"相关实现见 s03_permission/。
本篇小结:s02 的全部增量可以压缩成三条——TOOLS 数组加一条 schema、TOOL_HANDLERS 字典加一行映射、文件类 handler 统一过 safe_path 沙箱。循环、消息协议、回灌机制全部复用 s01,这正是课程反复强调的 Harness 工程原则:把可变性集中在注册表上,让核心循环保持稳定。
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 StartedRust0625
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