系列导读
这是**《12课拆解Claude Code架构》**系列的第 2 课。
上一课我们用 20 行 Python 造出了一个能操作真实世界的 Agent:一个 while True 循环 + 一个 Bash 工具。那个循环是所有 Agent 的骨架,后面 11 课都不会碰它一行。
第 2 课的格言:
"加一个工具,只加一个 handler" —— 循环不用动,新工具注册进 dispatch map 就行。
Bash 万能,但不够好
第 1 课只有一个 bash 工具。理论上 Bash 能做一切——读文件用 cat,写文件用 echo >,编辑文件用 sed。但 "能做" 和 "做得好" 之间差着三个坑:
坑一:cat 截断不可预测。 文件太大时,模型收到的是截断后的内容,但它不知道被截了多少。它以为看到了全部,实际只看了一半。后续决策建立在不完整的信息上。
坑二:sed 遇特殊字符就崩。 文件内容里有 /、&、\ 这些字符时,模型需要在脑子里做正则转义——这不是 LLM 擅长的事。一个简单的文本替换,经常因为转义问题失败两三轮。
坑三:每次 bash 调用都是不受约束的安全面。 Bash 能做任何事,也意味着它能做任何危险的事。模型可以读 /etc/passwd,可以写 ~/.ssh/authorized_keys,可以 curl 一个恶意脚本然后执行。你的防线只有一个字符串黑名单。
专用工具解决这三个问题:read_file 可以精确控制截断并告知模型;edit_file 用精确字符串匹配替代 sed 正则;路径沙箱 safe_path() 让工具只能访问工作目录。
关键洞察:加工具不需要改循环。

核心架构:dispatch map
+--------+ +-------+ +------------------+
| User | ---> | LLM | ---> | Tool Dispatch |
| prompt | | | | { |
+--------+ +---+---+ | bash: run_bash |
^ | read: run_read |
| | write: run_wr |
+-----------+ edit: run_edit |
tool_result | } |
+------------------+
对比第 1 课的架构图,唯一变化是右边:从一个固定的 run_bash 函数,变成了一个字典查找。 模型返回工具名,字典返回对应的处理函数。
这个模式叫 dispatch map。它的威力在于:无论你有 4 个工具还是 40 个,循环里的代码都是同一行:
handler = TOOL_HANDLERS.get(block.name)
四步拆解:从 Bash-only 到多工具 Agent
第一步:路径沙箱 safe_path()
所有文件操作工具的第一道防线:
WORKDIR = Path.cwd()
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
三行代码,做了一件事:确保所有路径操作都在工作目录内。
模型传来 ../../etc/passwd?resolve() 会把它解析为绝对路径,然后 is_relative_to 检查发现它不在 WORKDIR 下,直接拒绝。
这比 Bash 的字符串黑名单强得多。黑名单是 "列举你不能做什么",沙箱是 "定义你只能在哪做"。前者永远有漏网之鱼,后者天然封闭。
第二步:四个工具处理函数
每个工具一个函数,各司其职:
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.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:
fp = safe_path(path)
content = fp.read_text()
if old_text not in content:
return f"Error: Text not found in {path}"
fp.write_text(content.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
注意几个设计选择:
run_read有limit参数:模型可以只读前 N 行,避免大文件撑爆上下文。截断时主动告知还有多少行没显示。run_write自动创建父目录:mkdir(parents=True)让模型不需要先mkdir -p再写文件。run_edit用精确字符串匹配:old_text not in content直接判断,不需要正则。替换只替换第一个匹配(replace(..., 1)),避免误伤。- 所有路径操作都经过
safe_path():Bash 保留了独立的黑名单防护,文件工具用沙箱防护。
第三步: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"]),
}
一个字典,四个条目。新增工具 = 新增一个函数 + 字典里加一行。 不需要碰循环,不需要加 if-elif,不需要改任何已有代码。
配套的工具定义(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 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"]}},
]
第四步:循环集成
循环本身和第 1 课一模一样,唯一变化是工具执行那几行:
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})
对比第 1 课,变化只有一处:
# 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}"
两行替换一行。循环结构、退出条件、消息管理,全部不变。

架构决策:为什么这样设计
为什么用 dict 不用 if-elif
# 不要这样写
if block.name == "bash":
output = run_bash(block.input["command"])
elif block.name == "read_file":
output = run_read(block.input["path"])
elif block.name == "write_file":
output = run_write(block.input["path"], block.input["content"])
# ... 每加一个工具,循环里多两行
if-elif 的问题不是性能,是耦合。每次加工具,你要改循环代码。循环是 Agent 的核心,改核心就要重新测试核心。dispatch map 把 "有哪些工具" 和 "怎么执行循环" 解耦了。
这也是 Claude Code 真实的架构选择。看它的源码,工具注册和循环执行是两个完全独立的模块。
为什么要路径沙箱
你可能觉得 "教学代码,安全不重要"。但想一下这个场景:
你在 ~/projects/my-app/ 跑 Agent,让它 "把 config.json 里的数据库密码改成新密码"。模型决定先读文件看看当前内容,调用 read_file("../../.ssh/id_rsa")——
没有沙箱?它就读到了你的 SSH 私钥。
沙箱不是 "生产级安全",而是最低限度的隔离。即使在教学场景,也不应该让模型访问工作目录以外的任何东西。
为什么 edit 用精确匹配不用正则
Claude Code 的 Edit 工具也是精确字符串替换,不是正则。原因很简单:
- LLM 不擅长写正则。让模型构造正则表达式,然后用这个正则去改你的代码——这是在不擅长的事情上叠加风险。
- 精确匹配可预测。
old_text在文件里要么存在要么不存在,没有 "部分匹配" 的歧义。 - 只替换第一个。
replace(old_text, new_text, 1)避免批量误伤。如果文件里有多处相同文本,模型需要提供更长的上下文来精确定位。
五分钟跑起来
# 克隆仓库
git clone https://github.com/shareAI-lab/learn-claude-code
cd learn-claude-code
# 安装依赖
pip install -r requirements.txt
# 配置 API Key
cp .env.example .env
# 编辑 .env,填入你的 ANTHROPIC_API_KEY 和 MODEL_ID
# 启动第二课
python agents/s02_tool_use.py
启动后你会看到 s02 >> 提示符。以下是真实的运行记录:
任务 1:读取文件
s02 >> Read the file requirements.txt
> read_file:
anthropic>=0.25.0
python-dotenv>=1.0.0
pyyaml>=6.0
The `requirements.txt` file contains three dependencies:
1. **anthropic>=0.25.0** - The Anthropic Python SDK (for interacting with Claude API)
2. **python-dotenv>=1.0.0** - For loading environment variables from `.env` files
3. **pyyaml>=6.0** - For parsing and working with YAML files
模型直接选择了 read_file 而不是 bash → cat。给了专用工具,模型自然会用。
任务 2:创建文件
s02 >> Create a file called greet.py with a greet(name) function
> write_file:
Wrote 46 bytes to greet.py
The file `greet.py` has been created with a `greet(name)` function that takes
a name as input and returns a greeting string like `"Hello, {name}!"`.
任务 3:编辑文件
s02 >> Edit greet.py to add a docstring to the function
> edit_file:
Edited greet.py
Done! A docstring has been added to the `greet` function describing its purpose.
注意模型没有先 read_file 再 edit_file——它在上一轮创建文件时已经知道内容,直接精确定位并插入 docstring。Agent 有记忆,不做多余的事。
任务 4:验证修改
s02 >> Read greet.py to verify the edit worked
> read_file:
def greet(name):
"""Return a greeting message for the given name."""
return f"Hello, {name}!"
The edit worked correctly. The `greet.py` file now contains the `greet(name)` function
with the docstring included.
对比第 1 课的行为变化:s01 里模型会用 bash → cat 读文件、bash → sed 编辑文件。现在它直接选择专用工具——更精确、更安全、更省 token。你不需要告诉它 "用 read_file 而不是 cat"。
总结:从 s01 到 s02 变了什么
| 组件 | 之前 (s01) | 之后 (s02) |
|---|---|---|
| 工具数量 | 1 (仅 bash) | 4 (bash, read, write, edit) |
| 工具分发 | 硬编码 run_bash() 调用 | TOOL_HANDLERS 字典查找 |
| 路径安全 | 无 | safe_path() 沙箱 |
| 文件读取 | bash → cat(截断不可控) | read_file(精确截断 + 行数提示) |
| 文件编辑 | bash → sed(正则易崩) | edit_file(精确匹配替换) |
| Agent loop | while True + stop_reason | 不变 |
| 新增工具成本 | 改循环代码 | 加一个函数 + 字典加一行 |
核心信息:循环是稳定的,工具是可扩展的。 dispatch map 让这两件事彻底解耦。

下一课预告
现在 Agent 有了多个工具,能更精确地读写文件。但它仍然是 "走到哪算哪"——没有计划,不知道自己完成了多少,容易在复杂任务中迷失方向。
第 3 课:TodoWrite —— 给 Agent 一个规划系统。核心是一个 TodoManager,让 Agent 先列步骤再动手。循环里加一个 nag reminder,催促 Agent 按计划推进。
# 预告:s03 的 TodoManager
class TodoManager:
def add(self, task: str, priority: str) -> str: ...
def complete(self, task_id: str) -> str: ...
def get_summary(self) -> str: ... # "3/7 done"
没有计划的 Agent 走哪算哪。有了 TodoWrite,完成率直接翻倍。
这是《12课拆解Claude Code架构:从零掌握Agent Harness工程》系列的第 2 课。关注Claw开发者,不错过后续更新。
完整代码和交互式学习平台:github.com/shareAI-lab…
如果这篇文章对你有帮助,欢迎转发给你的技术团队。
系列目录
- 第1课:用20行Python造出你的第一个AI Agent
- 第2课:给Agent加工具 —— dispatch map模式详解(本文)
- 第3课:TodoWrite —— 让Agent先想后做:规划系统
- 第4课:Subagent —— 拆解大任务,上下文隔离
- 第5课:按需加载领域知识——Skill机制
- 第6课:无限对话——上下文压缩三层策略
- 第7课:任务持久化——文件级DAG任务图
- 第8课:后台执行——异步任务与通知队列
- 第9课:Agent Teams——多Agent协作:团队与邮箱系统
- 第10课:团队协议——状态机驱动的协商
- 第11课:自治Agent——自组织任务认领
- 第12课:终极隔离——Worktree并行执行
📌 本文原始链接:第2课:给Agent加工具 —— dispatch map模式详解
🔗 更多 AI Agent 开发实战教程,访问 HuanCode
💻 完整代码仓库:github.com/shareAI-lab…