基于 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_filewrite_fileedit_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 行代码是文件安全的基础。

  1. WORKDIR / p:用 / 运算符拼接路径。Path 对象重载了 / 运算符——Path("/home") / "foo.txt" 得到 Path("/home/foo.txt")。这是 Python 的运算符重载,让路径拼接看起来像文件系统路径
  2. .resolve():解析为绝对路径,同时消除 .. 和符号链接。Path("/project/../etc/passwd").resolve()Path("/etc/passwd")。没有 resolve,.. 不会真正被"评估"
  3. .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/passwdis_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 轮

  1. 用户输入追加到 history
  2. API 调用:模型看到"改名为 hello",决定先看看文件内容
  3. 返回:stop_reason = "tool_use"content 包含 ToolUseBlock(name="read_file", input={"path": "foo.py"})
  4. dispatch:TOOL_HANDLERS["read_file"]lambda **kw: run_read(kw["path"], kw.get("limit"))run_read("foo.py", None)
  5. 结果:"def greet():\n print('hi')"
  6. 追加 tool_result

第 2 轮

  1. API 调用:模型看到文件内容,定位 def greet():,决定调用 edit_file
  2. 返回:ToolUseBlock(name="edit_file", input={"path": "foo.py", "old_text": "def greet():", "new_text": "def hello():"})
  3. dispatch:run_edit("foo.py", "def greet():", "def hello():")
  4. content.replace("def greet():", "def hello():", 1) → 替换成功
  5. 结果:"Edited foo.py"
  6. 追加 tool_result

第 3 轮

  1. API 调用:模型看到编辑成功,决定验证一下
  2. 返回:ToolUseBlock(name="read_file", input={"path": "foo.py"})
  3. 结果:"def hello():\n print('hi')" ← 验证通过
  4. 追加 tool_result

第 4 轮

  1. API 调用:模型确认修改成功
  2. 返回:stop_reason = "end_turn",text = “已将 greet 改名为 hello”
  3. 退出循环

注意模型的行为模式:读→改→验证。不是一步到位的,而是分步进行,每一步的决策都基于上一步的结果。这就是 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_filelimit 参数、write_file 自动创建父目录、edit_fileold_text not in content 预检——这些不是随意加的功能。它们各自引导模型的某种行为:limit 鼓励"先读一点再决定",自动创建目录减少"目录不存在"的摩擦,预检错误让模型快速修正。好的工具设计不是"能做更多",而是"引导更好的使用方式"。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐