RSS

第 2 章 / 共 21 章

工具调度表:从一把钥匙到一串钥匙

约 6 分钟 更新于

上一章留下的问题:只有 bash 一把工具时,模型想读一个文件,得先在脑子里把”读文件”翻译成 cat path/to/file,再把命令写出来。这层翻译既费 token,又容易出错——路径带空格、需要转义、想读前 50 行还得再套 head。更麻烦的是,bash 是一把万能钥匙,你没法对”读文件”和”删除整个磁盘”施加不同的管控。

2.1 加一个工具,只加一行

解法朴素到有点反高潮:**把”工具名 → 处理函数”做成一张字典。**加一个工具 = 在数组里加一条定义,在字典里加一行映射。

循环里唯一变化的是这两行:

# 第1章
output = run_bash(block.input["command"])

# 第2章
handler = TOOL_HANDLERS[block.name]
output = handler(**block.input)

while True、stop_reason 判断、消息追加,全都原封不动。这就是第2章要教给你的全部结构性洞察:能力横向扩展,内核纵向不动。

第2章:工具调度表
图 2.1:循环只认识一张表。要看的是中间那个”调度”节点——它把”有多少工具”这个问题从循环里彻底拿走了,所以第2章到第19章工具从 1 个涨到 30 多个,循环那段代码一直没动。

2.2 五把工具

给它一组常用的:

TOOLS = [
    {"name": "bash",       "description": "执行一条 shell 命令。", "input_schema": {...}},
    {"name": "read_file",  "description": "读取文件内容,可用 limit 限制行数。", "input_schema": {...}},
    {"name": "write_file", "description": "把内容写入文件,覆盖已有内容。", "input_schema": {...}},
    {"name": "edit_file",  "description": "在文件中把 old 替换为 new,只替换一次。", "input_schema": {...}},
    {"name": "glob",       "description": "按通配符查找文件,如 **/*.py。", "input_schema": {...}},
]

处理函数各自很短:

from pathlib import Path

WORKDIR = Path("/tmp/agent-sandbox").resolve()

def safe_path(path: str) -> Path:
    """把相对路径解析到工作目录内,并拒绝越界。"""
    p = (WORKDIR / path).resolve()
    if not p.is_relative_to(WORKDIR):
        raise ValueError(f"路径越界:{path}")
    return p

def run_read(path: str, limit: int | None = None) -> str:
    lines = safe_path(path).read_text().splitlines()
    if limit:
        lines = lines[:limit]
    return "\n".join(lines)

def run_write(path: str, content: str) -> str:
    p = safe_path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(content)
    return f"已写入 {path}({len(content)} 字符)"

def run_edit(path: str, old: str, new: str) -> str:
    p = safe_path(path)
    text = p.read_text()
    if text.count(old) != 1:
        return f"替换失败:old 在文件中出现 {text.count(old)} 次,需要恰好 1 次。"
    p.write_text(text.replace(old, new, 1))
    return f"已修改 {path}"

def run_glob(pattern: str) -> str:
    import glob as g
    return "\n".join(g.glob(pattern, root_dir=WORKDIR)) or "(无匹配)"

TOOL_HANDLERS = {
    "bash": run_bash,
    "read_file": run_read,
    "write_file": run_write,
    "edit_file": run_edit,
    "glob": run_glob,
}

run_edit 里那个”必须恰好出现一次”的检查值得单独说一句。它看起来像是在给模型添堵,实际上是在逼出一个明确的失败:与其在文件里悄悄替换掉三处、其中两处是误伤,不如直接告诉模型”你的定位不够唯一,请把上下文写长一点”。让工具在模糊的时候报错,比让它猜要安全得多。

2.3 走一遍:同样的任务,省下多少事

还是”统计 Python 文件并看看最大的三个”,对比两版的动作:

第1章(只有 bash)第2章(五把工具)
找文件bash: find . -name '*.py'glob: **/*.py
读某个文件前 50 行bash: head -n 50 src/main.pyread_file: path=src/main.py, limit=50
改一行配置bash: sed -i 's/old/new/' cfg.py(还得担心转义和 macOS/Linux 的 -i 差异)edit_file: path=cfg.py, old=..., new=...
路径越界保护无safe_path 直接拒绝

专用工具让模型少写一层翻译,也让你多了一个可以单独管控的接缝。

2.4 工程现实:并行与顺序

模型在一条回复里可以同时返回多个 tool_use 块——比如一次性读三个文件。教学版按原顺序逐个执行,简单可读。生产实现会区分工具是否”并发安全”:读文件、grep、glob 这类只读操作可以并发跑,写文件和 bash 通常保持串行。

不管并发还是串行,有一条约束不能破:**这一轮所有的 tool_result 必须放进同一条 user 消息里。**拆成多条 user 消息,模型会慢慢学会”不要并行调工具了”,你的吞吐反而掉下去。

2.5 常见坑

2.6 给调度表加第六把工具

2.7 不改循环体能否加一把新工具