循环从未改变——我们只是往 dispatch map 里多塞了几个函数。这一章揭示 Agent 能力边界是如何以近乎零成本扩张的。

上一篇我们用一个 while 循环 + 一把 bash 工具,搭出了 AI Agent 的最小骨架。但在实际使用中,你很快会发现:所有操作都走 shell,既危险,又笨拙

这一章要解决的,正是这个问题。而解法出乎意料地简单——循环不动,只扩展工具层。

为什么不能只靠 bash

用 bash 当唯一工具,表面上万能,实则暗藏三个隐患:

解决思路是:用专用工具替代通用 shell。为读文件、写文件、编辑文件各建一个有明确约束的工具,同时保留 bash 处理其他杂项命令。

架构:Dispatch Map 模式

新的架构引入了一个核心概念——Dispatch Map(分发表):一个将工具名映射到处理函数的字典,代替原来硬编码的 if/elif 链。

关键在于:模型输出的 tool_use 块里包含 name 字段(如 "read_file"),循环用这个名字在字典里查一次,就能路由到正确的处理函数。加新工具 = 往字典加一行,循环体永远不变。

为什么 Dispatch Map 优于 if/elif

对比:IF/ELIF VS DISPATCH MAP
# ❌ if/elif 写法:每次加工具都要改循环
if block.name == "bash":
    output = run_bash(...)
elif block.name == "read_file":
    output = run_read(...)
elif block.name == "write_file":
    output = run_write(...)
# ... 8 个 elif,每次加工具改这里

# ✅ Dispatch Map:加工具只加字典,循环永远不动
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"

"加一个工具,只加一个 handler"

——工具层扩展对循环层零侵入

Dispatch Map 的好处不只是代码简洁——它把"工具注册"和"工具分发"分离成两个关注点。工具越来越多时,循环逻辑依然是两行,不会因工具数量膨胀而变复杂。这是 开放-封闭原则 在 Agent 设计中的实践:对扩展开放,对修改封闭。

四个工具,各司其职

S01 → S02:到底变了什么?

先看一张对比图,绿色是新增,红色是删除,灰色是不变

S01                              S02
─────────────────────────────    ─────────────────────────────
                                 ✅ safe_path()        ← 新增
                                 ✅ run_read()          ← 新增
  run_bash()    (不变)           ✅ run_write()         ← 新增
                                 ✅ run_edit()          ← 新增
                                 ✅ TOOL_HANDLERS 字典  ← 新增
                                 ✅ TOOLS 数组扩展到4个  ← 修改

  agent_loop()  (几乎不变)       agent_loop()          ← 仅改 2 行
─────────────────────────────    ─────────────────────────────

核心论点:循环体没动,只是把"硬编码的 bash 调用"换成了"字典查找的分发"。

二、新增部分解析

WORKDIR—— 工作区锚点

WORKDIR = Path.cwd()
# s01 中:
cwd=os.getcwd()          # 每次调用 run_bash 时传字符串
# s02 中:
WORKDIR = Path.cwd()     # 启动时确定一次,Path 对象
对比	os.getcwd() (字符串)	Path.cwd() (Path对象)
类型	str	pathlib.Path
路径拼接	os.path.join(cwd, p)	WORKDIR / p
解析相对路径	需要额外调用	.resolve() 一行搞定
判断子路径	字符串匹配,易出错	.is_relative_to() 精确判断
💡 改用 Path 不是风格偏好,而是为了后面 safe_path() 的精确路径校验做准备。

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

第一步:拼接 + 解析

path = (WORKDIR / p).resolve()
WORKDIR = Path("/home/user/project")
p = "../secrets/passwords.txt"
# WORKDIR / p  →  Path("/home/user/project/../secrets/passwords.txt")
# .resolve()   →  Path("/home/user/secrets/passwords.txt")  ← 自动解析了 ..

.resolve() 会:

  • 解析所有 ..(上级目录)
  • 解析所有 .(当前目录)
  • 解析符号链接(symlinks)
  • 返回绝对路径

没有 resolve(),../ 攻击就防不住。

第二步:判断是否逃逸

if not path.is_relative_to(WORKDIR):
    raise ValueError(f"Path escapes workspace: {p}")
# WORKDIR = Path("/home/user/project")

"/home/user/project/src/main.py"  .is_relative_to(WORKDIR)  → True  ✅ 允许
"/home/user/secrets/passwords"    .is_relative_to(WORKDIR)  → False ❌ 拦截
"/etc/passwd"                     .is_relative_to(WORKDIR)  → False ❌ 拦截

攻击防御示例

模型尝试: read_file(path="../../etc/passwd")
         │
         ▼ safe_path 处理
WORKDIR / "../../etc/passwd" → resolve() → "/etc/passwd"
         │
         ▼ is_relative_to 检查
"/etc/passwd".is_relative_to("/home/user/project") → False
         │
         ▼ 抛出异常
ValueError: Path escapes workspace: ../../etc/passwd
         │
         ▼ run_read 捕获异常
返回 "Error: Path escapes workspace: ../../etc/passwd"

这就是为什么需要专用工具而不是全部走 bash——bash 里的 cat ../../etc/passwd 很难完美拦截,但 read_file 在入口处就一把锁住。

三个新工具处理函数

run_read()—— 读文件

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}"
机制	作用
safe_path(path)	第一道防线:路径沙箱
limit 参数	模型可以只读前 N 行,避免把 10000 行的文件全塞进上下文
"... (N more lines)"	明确告知模型"文件没读完",让它知道还有内容
[:50000]	第二道防线:即使 limit 没设,也硬截断
try/except	文件不存在、权限不足等异常全部优雅处理

对比 bash 方式读文件的痛点:

# bash 方式的问题:
cat file.py                    # 大文件直接爆炸
head -100 file.py              # 截断不可预测(按字节不是按行)
sed -n '1,100p' file.py       # 特殊字符可能出错

run_write()—— 写文件

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}"
# 关键行解析:
fp.parent.mkdir(parents=True, exist_ok=True)
参数	含义
parents=True	递归创建所有不存在的父目录(类似 mkdir -p)
exist_ok=True	目录已存在时不报错

💡 这样模型不需要先 mkdir 再写文件,一步到位。

run_edit()—— 精确编辑文件

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

这个工具的设计哲学是 “查找-替换” 而非 “行号编辑”

# ❌ 行号方式(Cursor、早期 Copilot 的做法):
# "把第 10 行改成 xxx"
# 问题:如果前面加了/删了行,行号全错

# ✅ 文本匹配方式(Claude Code 的做法):
# "把 'def hello():' 替换成 'def hello(name):'"
# 精确匹配,不怕行号漂移
content.replace(old_text, new_text, 1)
#                                     ↑
#                          count=1,只替换第一个匹配
#                          防止文件中多处相同文本被全部替换
if old_text not in content:
    return f"Error: Text not found in {path}"
这行很重要——如果模型"幻觉"了要替换的文本(文件里根本没有),立即报错让它重试,而不是静默失败。

TOOL_HANDLERS—— 分发字典(架构核心)

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 包一层?

直接写 "bash": run_bash 不行吗?不行,因为参数签名不匹配:

# LLM 调用工具时,传入的是统一格式的字典:
block.input = {"command": "ls -la"}      # bash
block.input = {"path": "a.py", "limit": 50}  # read_file

# 如果直接映射函数:
handler = TOOL_HANDLERS["bash"]
handler(block.input)
# 等价于 run_bash({"command": "ls -la"})
# 但 run_bash 期望的签名是 run_bash(command: str)
# 会报错:TypeError: run_bash() got an unexpected keyword argument 'command'
# 等等...其实这个例子刚好能工作

# 但 read_file 就有问题了:
handler = TOOL_HANDLERS["read_file"]
handler(block.input)
# 等价于 run_read(path="a.py", limit=50)  ← 这个其实也能工作

# 那为什么要 lambda?主要是为了:
# 1. 参数重命名(如果 LLM 参数名和函数参数名不一致)
# 2. 参数校验和默认值处理(如 kw.get("limit") 处理可选参数)
# 3. 统一的调用接口:所有 handler 都接受 **kw

对比S01的硬编码方式

# s01:硬编码 bash
for block in response.content:
    if block.type == "tool_use":
        output = run_bash(block.input["command"])   # 写死了 bash

# s02:字典分发
for block in response.content:
    if block.type == "tool_use":
        handler = TOOL_HANDLERS.get(block.name)     # 按名字查找
        output = handler(**block.input)              # 统一调用

s01 的扩展方式:                    s02 的扩展方式:
                                   ┌─────────────────────┐
if block.name == "bash":           │ TOOL_HANDLERS = {   │
    run_bash(...)                  │   "bash": ...,      │
elif block.name == "read":         │   "read_file": ..., │
    run_read(...)                  │   "write_file": ...,│
elif block.name == "write":        │   "edit_file": ..., │
    run_write(...)                 │   # 加新工具?       │
elif block.name == "edit":         │   "grep": ...,      │  ← 只加一行
    run_edit(...)                  │ }                   │
else:                             └─────────────────────┘
    error                         O(1) 查找,一行扩展
# O(n) 的 if-elif 链
# 加一个工具要加一个 elif

TOOLS—— 工具定义数组(告诉 LLM 有什么工具)

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

四个工具的职责划分:

┌─────────────┬──────────────────────────────────┬──────────────┐
│ 工具         │ 职责                             │ 安全机制      │
├─────────────┼──────────────────────────────────┼──────────────┤
│ bash        │ 通用:ls, git, pip, pytest 等    │ 关键词黑名单  │
│ read_file   │ 专精:读文件内容                  │ 路径沙箱      │
│ write_file  │ 专精:创建/覆写文件               │ 路径沙箱      │
│ edit_file   │ 专精:精确局部修改                │ 路径沙箱+匹配  │
└─────────────┴──────────────────────────────────┴──────────────┘

设计原则:
- 能用专用工具就不用 bash(更安全、更精确)
- bash 作为兜底,处理专用工具覆盖不了的场景

每个工具的 input_schema 中:

  • required 列表中的参数是必填
  • 不在 required 中的参数(如 limit)是可选
# read_file 的 schema:
"required": ["path"]     # path 必填
# limit 不在 required 中 → 可选
# 模型可以调用 read_file(path="a.py") 或 read_file(path="a.py", limit=50)

agent_loop()—— 循环体的微妙变化

# s01                                          # s02
for block in response.content:                 for block in response.content:
    if block.type == "tool_use":                   if block.type == "tool_use":
        print(f"\033[33m$ {block.input              handler = TOOL_HANDLERS.get(block.name)
            ['command']}\033[0m")                    output = handler(**block.input) if handler \
        output = run_bash(block.input                      else f"Unknown tool: {block.name}"
            ["command"])                             print(f"> {block.name}:")
        print(output[:200])                           print(output[:200])
        results.append({...})                          results.append({...})

变化的只有工具执行部分,循环结构完全一致:

s01:  硬编码 run_bash(block.input["command"])
s02:  handler = TOOL_HANDLERS.get(block.name)     ← 字典查找
      output = handler(**block.input)              ← 统一调用
      if handler else "Unknown tool"               ← 优雅降级

新增的 else f"Unknown tool: {block.name}" 是防御性编程——如果模型"幻觉"了一个不存在的工具名,不会崩溃,而是返回错误信息让它重试。

三、用一个实际例子走完整个流程

用户输入:Edit greet.py to add a docstring to the function

假设 greet.py 当前内容:

def greet(name):
    print(f"Hello, {name}!")
循环第 1 轮
────────────────────────────────────────────────
messages = [{ role: "user", content: "Edit greet.py to add a docstring..." }]
    │
    ▼ 发给 LLM(TOOLS 包含 4 个工具)
LLM 思考:"我需要先读文件看看当前内容"
    │
    ▼ 响应
response.stop_reason = "tool_use"
response.content = [
    { type: "tool_use", name: "read_file", input: { path: "greet.py" } }
]
    │
    ▼ 分发执行
handler = TOOL_HANDLERS["read_file"]  →  run_read
    │
    ▼ safe_path 检查
"greet.py" → resolve → "/home/user/project/greet.py" ✅ 在工作区内
    │
    ▼ 执行
> read_file:
def greet(name):
    print(f"Hello, {name}!")
    │
    ▼ 结果追加到 messages
messages = [..., assistant: [tool_use: read_file], user: [tool_result: 文件内容]]

循环第 2 轮
────────────────────────────────────────────────
    │
    ▼ 发给 LLM(现在模型看到了文件内容)
LLM 思考:"我看到了函数,用 edit_file 在 def 下方插入 docstring"
    │
    ▼ 响应
response.stop_reason = "tool_use"
response.content = [
    { type: "tool_use", name: "edit_file",
      input: {
        path: "greet.py",
        old_text: "def greet(name):\n    print(f\"Hello, {name}!\")",
        new_text: "def greet(name):\n    \"\"\"Greet someone by name.\"\"\"\n    print(f\"Hello, {name}!\")"
      }
    }
]
    │
    ▼ 分发执行
handler = TOOL_HANDLERS["edit_file"]  →  run_edit
    │
    ▼ safe_path 检查 ✅
    │
    ▼ 精确匹配检查
old_text 在文件内容中? → ✅ 找到了
    │
    ▼ 替换(只替换第一个匹配)
> edit_file:
Edited greet.py
    │
    ▼ 结果追加

循环第 3 轮
────────────────────────────────────────────────
    │
    ▼ 发给 LLM
LLM 思考:"编辑成功了,任务完成"
    │
    ▼ 响应
response.stop_reason = "end_turn"   ← 不再是 "tool_use"!
response.content = [
    { type: "text", text: "Added a docstring to the greet function." }
]
    │
    ▼ 退出循环,输出最终回复
"Added a docstring to the greet function."

3 轮循环,调用了 2 个不同的工具(read_file → edit_file),这正是多工具分发的价值。

四、安全机制全景图

                    用户输入
                       │
                       ▼
              ┌─── LLM 决定调用工具 ───┐
              │                        │
              ▼                        ▼
         bash 工具                 文件工具
              │                  (read/write/edit)
              ▼                        │
    ┌─── dangerous 检查 ───┐           │
    │ rm -rf /            │           ▼
    │ sudo                │    ┌── safe_path 检查 ──┐
    │ shutdown            │    │ resolve() 解析 ..  │
    │ reboot              │    │ is_relative_to()   │
    │ > /dev/             │    │ 拒绝逃逸工作区     │
    └─────────┬───────────┘    └────────┬───────────┘
              │                        │
              ▼                        ▼
    ┌─── timeout 120s ───┐    ┌── 输出截断 50000 ──┐
    └─────────┬───────────┘    │ edit: 匹配检查     │
              │                └────────┬───────────┘
              │                        │
              └──────────┬─────────────┘
                         │
                         ▼
                   工具结果返回给 LLM

两层安全防御:

层级 bash 文件工具
入口检查 关键词黑名单(粗糙但快速) 路径沙箱(精确且可靠)
出口保护 超时 120s + 输出截断 输出截断 + 精确匹配

这就是 s02 说的"专用工具在工具层面做路径沙箱"——不是所有操作都走 bash 的粗糙黑名单,而是针对文件操作做精确的路径隔离。

五、扩展性:加一个新工具需要改什么?

假设要加一个 grep 工具,只需 两步

# 第一步:写处理函数
def run_grep(pattern: str, path: str = ".") -> str:
    try:
        sp = safe_path(path)
        if sp.is_file():
            text = sp.read_text()
            matches = [l for l in text.splitlines() if pattern in l]
            return "\n".join(matches) if matches else "(no matches)"
        # 目录处理省略...
    except Exception as e:
        return f"Error: {e}"

# 第二步:注册到分发字典 + 工具定义
TOOL_HANDLERS["grep"] = lambda **kw: run_grep(kw["pattern"], kw.get("path", "."))

TOOLS.append({
    "name": "grep",
    "description": "Search for a pattern in files.",
    "input_schema": {
        "type": "object",
        "properties": {
            "pattern": {"type": "string"},
            "path": {"type": "string"}
        },
        "required": ["pattern"]
    }
})

# agent_loop?一行都不用改。
加工具 = 写 handler + 注册字典 + 加 schema
循环永远不变 ←←←←←←←←←←←←←←← 这是 s02 最重要的设计原则

到这里,你已经掌握了 Agent 工具扩展的核心设计模式:Dispatch Map + 路径沙箱 + 专用工具。循环从未改变,改变的只是它能调用的工具集合。

本节你学会了 agent 如何调用工具,那么模型如何管理多任务,对话越长,越容易忘记上下文,这些都是后期我们要介绍的内容了。

更多transformer,VIT,swin tranformer
参考头条号:人工智能研究所
v号:人工智能研究Suo, 启示AI科技

 动画详解transformer  在线视频教程 

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐