learn claude code S02 工具使用详解笔记
本文分析了s02_tool_use.py源码的设计思路与改进。相比仅支持bash工具的s01版本,s02新增了read_file、write_file和edit_file三个专用文件工具,通过dispatch map路由到对应的handler函数,核心改进包括: 安全机制:引入safe_path()路径沙箱,通过几何约束防止路径逃逸工作目录,比黑名单更可靠; 文件操作:专用工具避免了bash操作的
基于
s02_tool_use.py源码逐行分析,配合s02-tool-use.md设计思路。
一、问题:只有 bash,文件操作笨拙且不安全
s01 只有一个 bash 工具。这意味着所有操作都走 shell:
读文件 → cat file.py # 大文件截断不可预测
写文件 → echo "..." > f.py # 特殊字符转义地狱
编辑文件 → sed -i 's/old/new/' f.py # 遇到 / 或 & 就崩
更严重的是安全问题:bash 是一个不受约束的执行面。cat ../../etc/passwd 可以读取工作目录之外的系统文件——s01 的黑名单只能拦截已知的危险模式,但无法约束"去哪里"。
加工具不需要改循环。 这是 s02 最核心的洞察——循环的 while 结构、stop_reason 检查、消息追加逻辑,全部不变。只加工具定义和对应的 handler 函数。
二、解决方案:dispatch map + 专用文件工具 + 路径沙箱
s02 新增 3 个文件工具(read_file、write_file、edit_file),加上原有的 bash,共 4 个。所有工具通过 dispatch map(一个字典)路由到对应的 handler:
模型返回 tool_use block
↓
block.name = "read_file"
↓
TOOL_HANDLERS["read_file"] → lambda **kw: run_read(kw["path"], kw.get("limit"))
↓
run_read(path, limit) → 读文件 → 返回内容
每个文件操作都经过 safe_path() 校验——确保路径不逃逸工作目录。这是 s02 引入的最重要的安全机制。
三、和 s01 相比,多了什么?
| 组件 | s01 | s02 |
|---|---|---|
| 工具数量 | 1 (bash) | 4 (bash, read_file, write_file, edit_file) |
| 工具调度 | 硬编码 run_bash(block.input["command"]) |
TOOL_HANDLERS 字典 |
| 路径安全 | 无 | safe_path() 沙箱 |
| Agent loop | while + stop_reason | 完全相同,一行没改 |
四、源码逐行分析
4.1 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
初学者:这 4 行代码是文件安全的基础。
WORKDIR / p:用/运算符拼接路径。Path对象重载了/运算符——Path("/home") / "foo.txt"得到Path("/home/foo.txt")。这是 Python 的运算符重载,让路径拼接看起来像文件系统路径.resolve():解析为绝对路径,同时消除..和符号链接。Path("/project/../etc/passwd").resolve()→Path("/etc/passwd")。没有 resolve,..不会真正被"评估".is_relative_to(WORKDIR):检查路径是否在工作目录之下。Path("/etc/passwd").is_relative_to(Path("/project"))→False
攻击面分析:假设模型(或被诱导的模型)调用 read_file("../../../etc/passwd")。WORKDIR / "../../../etc/passwd" → /project/../../../etc/passwd → .resolve() → /etc/passwd。is_relative_to(WORKDIR) → False → 抛出 ValueError。这个防御不是基于黑名单,而是基于几何约束——只要 resolve 后的路径不在工作目录下,就拒绝。无法绕过。
4.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] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)[:50000]
limit 参数是可选的——模型可以指定"只看前 50 行",避免大文件撑爆上下文。当文件超过 limit 行时,追加一行提示 "... (234 more lines)",模型知道还有更多内容,需要时可以滚动读取。
[:50000] 是最后一道防线——即使模型不传 limit,输出也不会超过 50000 字符。这和 s01 的 bash 输出截断一致。
4.3 run_write() — 写文件
def run_write(path: str, content: str) -> str:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
fp.parent.mkdir(parents=True, exist_ok=True) 自动创建父目录——模型说"写到 src/utils/helpers.py",即使 src/utils/ 不存在也能自动创建。parents=True 递归创建所有中间目录,exist_ok=True 目录已存在时不报错。
返回值的格式是刻意设计的:"Wrote 1024 bytes to src/utils/helpers.py"——包含字节数和路径,让模型确认操作成功。如果返回空字符串或不明确的信息,模型可能不确定文件是否真的写入了。
4.4 run_edit() — 编辑文件
def run_edit(path: str, old_text: str, new_text: str) -> str:
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}"
为什么是 str.replace(old, new, 1) 而不是 sed? 因为这是精确字符串替换——你用 Python 的字符串方法,避免了 shell 的特殊字符转义问题。content.replace(old_text, new_text, 1) 中的 1 表示只替换第一次出现,避免意外修改多处。
old_text not in content 的预检很重要:如果没有这个检查,文件内容不变(replace 找不到匹配时原样返回),但模型以为改成功了。明确的错误信息让模型能修正重试。
4.5 TOOL_HANDLERS — 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"]),
}
初学者:lambda **kw 是什么?
**kw 是 Python 的"关键字参数解包"——** 收集所有命名参数到一个字典 kw 中。当模型调用 read_file 时传入 {"path": "foo.py", "limit": 50},lambda 接收到 kw = {"path": "foo.py", "limit": 50},然后从中提取需要的值传给真正的处理函数。
为什么用 lambda 而不是直接引用函数?
# 不能这样写,因为参数名不匹配:
TOOL_HANDLERS = {
"bash": run_bash, # run_bash 需要 command,但 block.input 是 {"command": "ls"}
}
# lambda 做了一层适配——从统一的 **kw 字典中取出正确的参数:
"bash": lambda **kw: run_bash(kw["command"])
模型传入的参数字典 block.input 可能包含额外字段(模型有时会自发添加),也可能缺少可选字段。lambda 做适配层:从 kwargs 中提取需要的参数,映射到 handler 函数的签名。
dispatch map 替代 if/elif 链:不用 dispatch map 的话,代码会是这样的:
if block.name == "bash":
output = run_bash(block.input["command"])
elif block.name == "read_file":
output = run_read(block.input["path"], block.input.get("limit"))
elif block.name == "write_file":
output = run_write(block.input["path"], block.input["content"])
elif block.name == "edit_file":
output = run_edit(block.input["path"], block.input["old_text"], block.input["new_text"])
dispatch map 用一个字典查找替代了 if/elif 链。加新工具 = 在字典中加一行,而不是在 if/elif 链中加一个分支。更清晰,更可扩展。
4.6 agent_loop 中的工具调度
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})
handler(**block.input) 中的 ** 是 Python 的"字典解包"——把字典的键值对展开为关键字参数。如果 block.input = {"path": "foo.py", "limit": 50},那么 handler(**block.input) 等价于 handler(path="foo.py", limit=50)。** 和 lambda 的 **kw 是配对使用的:一个展开字典,一个收集参数。
TOOL_HANDLERS.get(block.name) 用 .get() 而非 [block.name]——当模型调用了一个不存在的工具时(虽然正常情况下不会,但模型可能"幻觉"出一个工具名),.get() 返回 None,后续 if handler 检查给出明确的错误信息。防御性编程。
五、完整流程走读
场景:用户说 “把 foo.py 里的 greet 函数改名为 hello”。
第 1 轮
- 用户输入追加到 history
- API 调用:模型看到"改名为 hello",决定先看看文件内容
- 返回:
stop_reason = "tool_use",content包含ToolUseBlock(name="read_file", input={"path": "foo.py"}) - dispatch:
TOOL_HANDLERS["read_file"]→lambda **kw: run_read(kw["path"], kw.get("limit"))→run_read("foo.py", None) - 结果:
"def greet():\n print('hi')" - 追加 tool_result
第 2 轮
- API 调用:模型看到文件内容,定位
def greet():,决定调用 edit_file - 返回:
ToolUseBlock(name="edit_file", input={"path": "foo.py", "old_text": "def greet():", "new_text": "def hello():"}) - dispatch:
run_edit("foo.py", "def greet():", "def hello():") content.replace("def greet():", "def hello():", 1)→ 替换成功- 结果:
"Edited foo.py" - 追加 tool_result
第 3 轮
- API 调用:模型看到编辑成功,决定验证一下
- 返回:
ToolUseBlock(name="read_file", input={"path": "foo.py"}) - 结果:
"def hello():\n print('hi')"← 验证通过 - 追加 tool_result
第 4 轮
- API 调用:模型确认修改成功
- 返回:
stop_reason = "end_turn",text = “已将 greet 改名为 hello” - 退出循环
注意模型的行为模式:读→改→验证。不是一步到位的,而是分步进行,每一步的决策都基于上一步的结果。这就是 agent 循环的价值——模型可以试错、验证、调整。
六、设计洞察
6.1 dispatch map 是"开闭原则"的最小实践
开闭原则:对扩展开放,对修改关闭。TOOL_HANDLERS 字典完美体现了这一点——加新工具只在字典里加一行,agent_loop 的代码零改动。这个模式从 s02 开始一直用到 s12——后面章节的工具越来越多,但循环不变。
6.2 lambda 作为适配层
lambda 在这里的角色是阻抗匹配——模型提供的参数格式({"command": "ls", "extra_field": "ignored"})和 handler 函数的签名(run_bash(command: str))不完全匹配。lambda 桥接了这个差异。在更大的系统中,这可能是一个专门的参数校验和转换层(如 Pydantic 模型),但在 s02 这个阶段,一行 lambda 足够。
6.3 路径沙箱的安全性来自几何约束
safe_path 不是黑名单(“禁止访问 /etc”),而是白名单(“只能访问 /project 之下”)。黑名单永远有遗漏——你不可能穷举所有危险路径。白名单只有一条规则,无法绕过。这是安全设计中最重要的原则之一:用允许列表替代拒绝列表。
6.4 工具设计影响模型行为
read_file 的 limit 参数、write_file 自动创建父目录、edit_file 的 old_text not in content 预检——这些不是随意加的功能。它们各自引导模型的某种行为:limit 鼓励"先读一点再决定",自动创建目录减少"目录不存在"的摩擦,预检错误让模型快速修正。好的工具设计不是"能做更多",而是"引导更好的使用方式"。
更多推荐


所有评论(0)