首页
/ learn-claude-code s02 深度解析:Agent 从单一 Bash 扩展到 5 个工具的 TOOL_HANDLERS 分派机制全源码剖析

learn-claude-code s02 深度解析:Agent 从单一 Bash 扩展到 5 个工具的 TOOL_HANDLERS 分派机制全源码剖析

2026-09-06 11:45:34作者:姚月梅Lane

本篇基于 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 路径沙箱、多工具并行调用的执行顺序,以及新增一个工具所需的“两行代码”。

Tool Dispatch 工具分派架构图:用户 prompt 进入 LLM,LLM 返回的 tool_use 按名称路由到 run_bash / run_read / run_write / run_edit / run_glob,tool_result 回传后循环继续

为什么 s01 的“仅 Bash”不够用

s01 章节(s01_agent_loop/code.py)构建的 Agent 只有一个工具:bash。这意味着模型想读文件必须拼出 cat path/to/file,写文件要写 echo "..." > file.py,编辑文件要靠 sed。问题有三:

  1. 多了一层“翻译”:模型的意图是“读这个文件”,却必须先翻译成 shell 语法,再经 shell 解释执行——多出来的翻译层浪费 token,且容易出错(引号嵌套、转义、特殊字符都会让 sed/echo 失败);
  2. 输出不可控cat 对长文件的截断行为不可预测,错误信息混杂 stdout/stderr;
  3. 安全面不可约束:每次 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 的一条扩展到五条,每条包含 namedescription 与严格的 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_editreplace(..., 1):只替换第一次出现,避免误伤同名文本,匹配不到则明确报错;
  • run_glob 的双重防护:除了 root_dir=WORKDIR,还对每个匹配结果再做一次 resolve() + is_relative_to 检查,防止通配符(如 ../x)逃逸出工作区。

bash 工具本身从 s01 原样继承(s02_tool_use/code.py):内置危险命令黑名单(rm -rf /sudoshutdownreboot> /dev/)、120 秒超时、输出截断到 50000 字符。

4. TOOL_HANDLERS 分派字典:一次查找替代 if/elif 链

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,
}

新增一个工具的全部工作量 = 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})

三个关键机制:

  1. handler(**block.input):模型返回的结构化参数直接解包成函数实参,schema 校验 + 字典解包让参数传递零解析代码;
  2. 未知工具兜底:用 .get() 而非 [] 索引,查不到时回传 "Unknown: {block.name}" 而不是崩溃——模型收到这个 tool_result 后会自行纠正;
  3. 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 记录了该章节背后的三条设计决策,可作为理解工具集规模的依据:

  1. Why Exactly (A Few) Tools:bash + 文件读写编辑覆盖了绝大多数编码任务;工具越多,模型在“选哪个工具”上的认知负担越重,选错概率越高,同时 schema 维护成本和边界情况也越多。bash 已经可以兜底 list_directorysearch_files 之类的场景,专用文件的意义在于给模型结构化的 I/O,避开 bash 引号/转义易错区;
  2. The Model IS the Agent:主循环里没有路由器、决策树或工作流引擎——做什么、何时停、出错如何恢复全由模型决定,代码只是连接模型与工具的“管道”;
  3. 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.txtanthropic>=0.25.0python-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 退出),按文档推荐依次尝试:

  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

观察要点:终端会以黄色高亮打印每次调用的工具名(> 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 的主题:在工具执行前加一道权限闸门——“这个操作安全吗?需要用户确认吗?”

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