☰
从零手写 ClaudeCode 实战笔记(2)Tool Use:用 dispatch map 搭出可复制的工具调用骨架
2026/9/28 18:21:43 网站建设 项目流程

1. 为什么单靠 bash 工具会让 Agent 越写越慌

如果你正在跟着 learn-claude-code 这个项目从零手写一个 ClaudeCode 风格的 AI Agent,走到第二章 Tool Use 时大概率会卡在同一个地方:s01 里只有一个 bash 工具,模型想读文件就cat,想写文件就echo >,想改内容就上sed。跑几个简单 prompt 看着挺顺,一旦让它处理真实项目里的文件,问题就全冒出来了。

我实测下来最典型的三类翻车:一是cat大文件时输出被截断,模型拿到半截内容就开始瞎猜;二是sed碰到路径里有空格、内容里有引号或正则元字符时直接报错,甚至误改别的行;三是所有操作都走 shell,等于把整个工作目录甚至更上层路径都暴露给模型,它一句rm或者cd ..就能跑出你划定的范围。这不是模型笨,是工具设计的问题——你只给了它一把万能锤子,它自然看什么都像钉子。

Tool Use 这一章要解决的核心,就是把「万能 bash」拆成一组职责明确的专用工具:read_file、write_file、edit_file,再配一个路径沙箱safe_path兜底。而把这些工具串起来的关键结构,就是 dispatch map——一个{工具名: 处理函数}的字典。它的价值在于:加一个新工具,只需要加一个 handler 和一份 schema,Agent 主循环一行都不用动。这篇就围绕 learn-claude-code 里 s02 的落地角度,把可复制的工具注册表和 dispatch map 骨架拆给你,最后附一段验证动作,确认新增工具真的能被路由和调用。

2. 前置准备:TaoToken 接入与项目环境

在动手改 dispatch map 之前,得先让 Agent 能稳定调到大模型。learn-claude-code 默认走 Anthropic 的 Messages API 协议,Tool Use 的tools参数、tool_use/tool_result这些 block 结构都依赖这套协议。我这边用的是 TaoToken 做接入,它兼容 Anthropic 的接口形态,改一下 base_url 和 key 就能跑,不用动业务代码。

你需要准备两样东西:一个可用的 API Key,以及确认接入地址。API 端点是https://taotoken.net/api,注意这个地址后面不要加多余的路径,SDK 会自己拼/v1/messages。Key 在控制台的 API Keys 页面创建,建议单独建一个给这个项目用,方便后面排查调用量。

环境侧确认三件事:Python 3.10 以上(safe_path里用到了Path.is_relative_to,3.9 才有,但 3.10 更稳)、项目已经 clone 到本地、依赖装好。如果你还没拿到 key,可以先到模型对话页面手动发一条带 tools 的请求,确认账号和模型权限没问题,再回到代码里接。

git clone https://github.com/shareAI-lab/learn-claude-code.git cd learn-claude-code python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt

然后把 key 写进环境变量,别硬编码进源码:

export ANTHROPIC_API_KEY="你的_taotoken_key" export ANTHROPIC_BASE_URL="https://taotoken.net/api"

注意:不同 SDK 读取 base_url 的环境变量名可能不一样,有的用ANTHROPIC_BASE_URL,有的要在客户端初始化时显式传base_url。如果跑起来报 404 或连接错误,先检查这一项,而不是怀疑 key 失效。

3. 可复制的 dispatch map 与工具注册表骨架

这一节是全文的核心。s02 相对 s01 的改动可以浓缩成一句话:循环不变,工具变多,靠 dispatch map 做路由。下面这份骨架你可以直接抄进agents/s02_tool_use.py,我按「沙箱 → 处理函数 → 注册表 → 循环」四层拆开讲。

3.1 路径沙箱 safe_path:所有文件工具的第一道闸

from pathlib import Path WORKDIR = Path.cwd().resolve() 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

这里有个容易忽略的点:resolve()必须在判断之前调用。因为../secret.txt这种路径,只有解析成绝对路径后才能看出它逃出了 WORKDIR。如果你先判断字符串再 resolve,a/../../b这类写法就能绕过检查。is_relative_to是 Python 3.9 引入的,比手写str.startswith安全得多,后者在/work和/work-evil这种前缀相似的路径上会误判。

3.2 三个专用处理函数

def run_read(path: str, limit: int = None) -> str: text = safe_path(path).read_text(encoding="utf-8") lines = text.splitlines() if limit and limit < len(lines): lines = lines[:limit] return "\n".join(lines)[:50000] def run_write(path: str, content: str) -> str: target = safe_path(path) target.parent.mkdir(parents=True, exist_ok=True) target.write_text(content, encoding="utf-8") return f"Wrote {len(content)} chars to {path}" def run_edit(path: str, old_text: str, new_text: str) -> str: target = safe_path(path) text = target.read_text(encoding="utf-8") if old_text not in text: return f"Edit failed: old_text not found in {path}" target.write_text(text.replace(old_text, new_text, 1), encoding="utf-8") return f"Edited {path}"

run_read里那个[:50000]是硬上限,防止模型一次拉进超大文件把上下文撑爆。run_edit用replace(..., 1)只替换第一处,避免模型给的 old_text 在文件里出现多次时被批量改掉——这是我在真实项目里踩过的坑,一次误替换把配置文件里所有同名 key 都改了。

3.3 dispatch map:一行查表替代 if/elif 链

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是个中间层,作用是接住大模型发来的任意参数结构。模型返回的block.input是个 dict,字段名和 schema 里定义的一致,但不同工具的字段不同,用**kw统一收口,再在 lambda 里按名字取,比给每个工具写一个if name == ...干净得多。kw.get("limit")用 get 是因为 limit 是可选参数,模型可能不传。

3.4 循环体:和 s01 完全一致

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, })

注意TOOL_HANDLERS.get(block.name)而不是[]。模型偶尔会幻觉出一个不存在的工具名,用[]会直接 KeyError 崩掉整个循环,用get返回 None 后走Unknown tool分支,把错误当成 tool_result 回传给模型,它下一轮往往能自己纠正。这个容错设计在长对话里特别值。

3.5 工具 schema 也要同步注册

dispatch map 只管执行侧,模型侧还得知道有哪些工具可用。schema 和 handler 是一一对应的,加 handler 必须加 schema:

TOOLS = [ { "name": "read_file", "description": "Read a text file inside the workspace.", "input_schema": { "type": "object", "properties": { "path": {"type": "string"}, "limit": {"type": "integer"}, }, "required": ["path"], }, }, # write_file / edit_file / bash 同理 ]

safe_path的沙箱只作用于文件类工具,bash本身不受它约束——这是 s02 的一个已知边界。如果你要更严,可以在run_bash里也做命令白名单,但那超出本章范围,先记住这个口子。

4. 验证:新增工具能否被正确路由与调用

骨架搭完,别急着跑复杂任务,先用一个最小验证确认 dispatch map 真的在工作。我习惯分两步:先离线测 handler,再在线测路由。

4.1 离线单测:直接调 handler

# 在项目根目录建一个临时文件 Path("requirements.txt").write_text("anthropic\nrich\n", encoding="utf-8") print(run_read("requirements.txt")) # 预期输出:anthropic\nrich print(run_read("../etc/passwd")) # 预期:抛 ValueError: Path escapes workspace

这一步能确认safe_path的沙箱生效。如果第二行没报错反而读出了内容,说明你的resolve()或is_relative_to写错了,先修这里再往下走。

4.2 在线验证:让模型自己选工具

启动 Agent,依次输入这几个 prompt,观察它是否调用了正确的工具名:

python agents/s02_tool_use.py
Read the file requirements.txt Create a file called greet.py with a greet(name) function Edit greet.py to add a docstring to the function Read greet.py to verify the edit worked

判断成功的标准不是「任务完成了」,而是日志里出现的 tool_use block 的 name 字段:第一条应该是read_file,第二条write_file,第三条edit_file,第四条又是read_file。如果它还在用bash加cat,说明你的 schema description 没写清楚,模型没意识到有专用工具可用——把 description 写得更具体,比如「Use this instead of bash cat for reading files」。

4.3 加一个新工具验证「循环不变」

这是最能说明 dispatch map 价值的验证。给注册表加一个list_dir:

def run_list_dir(path: str = ".") -> str: target = safe_path(path) return "\n".join(sorted(p.name for p in target.iterdir())) TOOL_HANDLERS["list_dir"] = lambda **kw: run_list_dir(kw.get("path", "."))

再往TOOLS里补一份对应 schema,然后重启 Agent 输入List the files in the current directory。如果它调用了list_dir并返回了文件列表,而你的主循环一行没改,就证明这套骨架是可扩展的。加工具 = 加 handler + 加 schema,这个等式成立,Tool Use 这章就算真正落地了。

5. 本篇常见报错排查

报错一:ValueError: Path escapes workspace。先确认你传的 path 是不是真的越界了。如果传的是绝对路径比如/tmp/x.txt,WORKDIR / p在 pathlib 里遇到绝对路径会直接丢弃 WORKDIR,结果就是越界。正确做法是让模型只传相对路径,schema 的 description 里明确写「relative to workspace root」。

报错二:KeyError: 'command'或KeyError: 'path'。模型返回的 input 字段名和你的 lambda 取值对不上。常见原因是 schema 里写的是file_path,lambda 里取的是kw["path"]。两边必须严格一致,改完 schema 记得同步改 handler。

报错三:Unknown tool: xxx反复出现。要么是工具名拼写不一致(schema 里叫read_file,注册表里写成read),要么是模型幻觉。前者改一致即可,后者可以接受,因为容错分支已经把它变成 tool_result 回传,模型下一轮通常会改用正确工具。

报错四:调用返回 401 或 404。先查 base_url 是不是写成了https://taotoken.net/api/v1/messages这种带路径的形式,SDK 会重复拼接。再查 key 有没有多余空格。这两项排除后再看模型名是否在账号权限范围内。

报错五:edit_file返回old_text not found。模型给的 old_text 和文件实际内容有细微差异,比如缩进、换行符、全角半角。可以在返回信息里附上文件前若干行,帮模型下一轮对齐。别直接抛异常,让它有机会自我修正。

6. 继续往下走:从 Tool Use 到可长期运行的 Agent

把 dispatch map 跑通之后,你会发现 Tool Use 这层其实很薄——真正让 Agent 变复杂的是工具变多之后的上下文管理、错误恢复和多轮编排。learn-claude-code 后面的章节会陆续处理这些,但前提是你现在这套注册表骨架是干净的。

如果你打算把这个 Agent 长期跑在本地做编码辅助,建议直接上 Coding Plan,按量计费比单次调用更适合高频的 tool_use 往返;日常调试和验证模型行为,用模型对话页面手动发带 tools 的请求最快;接入细节和参数说明都在接入文档里,遇到 401、404、字段不匹配这类问题先翻它。API Key 统一在 API Keys 页面管理,给项目单独建一个,出问题好定位。

最后留一个我自己的习惯:每加一个新工具,先写离线单测确认 handler 和沙箱,再写一句 prompt 确认路由,两步都过了才接进主流程。这样出问题时你能立刻判断是工具本身错了,还是模型选错了工具——这两类问题的修法完全不同。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询