Claude Code 02:Tool Use——分发字典与工具扩展

在上一篇中,我们通过一个极简循环和一个 bash 工具跑通了 Agent 的最小可运行闭环。

在日常使用中,很多人会产生疑问:既然 Bash 本身就是图灵完备的万能工具,直接让大模型通过 cat、sed、echo 完成所有文件操作不就行了吗?为什么专业的 Coding Agent 还要费尽周折地实现专用的文件读写与编辑工具?

当所有操作都交给裸 Shell 时,系统会面临三个严峻的工程隐患:

  1. 大文件容易把上下文撑爆:cat 一个 2,000 行的源文件,大段无关代码会瞬间消耗几万 Token;
  2. 文本替换极其脆弱:使用 sed 或临时 Python 脚本替换代码时,遇到引号嵌套、特殊正则表达式符号或未转义反斜杠,经常导致文件内容损坏;
  3. 缺乏物理路径沙箱:模型在 Shell 里可以直接越界访问 ~/.ssh/、/etc/ 或系统敏感路径,安全完全失控。

本篇我们将为 Agent 引入专用的 read_file、write_file 和 edit_file 工具,并重构核心循环:采用 Dispatch Map(分发字典)模式,实现“增加新工具不改动一行核心循环代码”的开闭原则。


核心设计:Dispatch Map 字典路由

在初学者的代码中,工具执行往往写成冗长的 if/elif 语句:

1
2
3
4
5
6
7
# 反模式:每增加一个工具,就要修改一次 agent_loop 核心循环代码
if block.name == "bash":
output = run_bash(block.input["command"])
elif block.name == "read_file":
output = run_read(block.input["path"])
elif block.name == "write_file":
output = run_write(block.input["path"], block.input["content"])

这种写法严重违反了软件工程的开闭原则(Open-Closed Principle)。

优雅的架构是将所有工具的执行逻辑注册到一个哈希分发字典(Dispatch Map)中,循环体内只需要一行通用的字典检索:

1
2
3
4
5
6
7
8
9
10
11
# 优雅设计:Dispatch Map 路由
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read_file(kw["path"], kw.get("limit", 200)),
"write_file": lambda **kw: run_write_file(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit_file(kw["path"], kw["old_string"], kw["new_string"]),
}

# 循环体内一行搞定分发:
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Error: 未知工具 {block.name}"

无论后续扩展到 10 个还是 50 个工具,Agent Loop 的核心调度代码始终保持纯净稳定,零侵入性。


专用文件工具的原子化实现

下面是用 Python 实现的带路径沙箱校验的核心文件工具集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import os
import subprocess

WORKSPACE_ROOT = os.path.abspath("./workspace")

def validate_safe_path(rel_path: str) -> str:
"""物理路径沙箱校验:严格禁止跳出工作区根目录"""
full_path = os.path.abspath(os.path.join(WORKSPACE_ROOT, rel_path))
if not full_path.startswith(WORKSPACE_ROOT):
raise PermissionError(f"越权访问阻断:路径 '{rel_path}' 试图跳出工作区根目录!")
return full_path

def run_read_file(path: str, limit: int = 200) -> str:
"""支持行号显示与上限保护的文件读取"""
try:
real_path = validate_safe_path(path)
if not os.path.exists(real_path):
return f"Error: 文件 '{path}' 不存在。"

with open(real_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()

total = len(lines)
sliced = lines[:limit]
numbered = [f"{i+1:4d} | {line}" for i, line in enumerate(sliced)]

res = "".join(numbered)
if total > limit:
res += f"\n... [已截断,后续仍有 {total - limit} 行未展示] ..."
return res
except Exception as e:
return f"读取文件失败: {str(e)}"

def run_write_file(path: str, content: str) -> str:
"""全量创建或覆盖文件"""
try:
real_path = validate_safe_path(path)
os.makedirs(os.path.dirname(real_path), exist_ok=True)
with open(real_path, "w", encoding="utf-8") as f:
f.write(content)
return f"成功写入文件 '{path}'(共 {len(content)} 字符)。"
except Exception as e:
return f"写入文件失败: {str(e)}"

def run_edit_file(path: str, old_string: str, new_string: str) -> str:
"""
精确局部替换:必须命中唯一目标段落,拒绝任何不确定的模糊替换
"""
try:
real_path = validate_safe_path(path)
if not os.path.exists(real_path):
return f"Error: 目标文件 '{path}' 不存在。"

with open(real_path, "r", encoding="utf-8") as f:
content = f.read()

count = content.count(old_string)
if count == 0:
return f"替换失败:在 '{path}' 中未找到待匹配的 old_string。请检查缩进或先调用 read_file 核对最新内容。"
if count > 1:
return f"替换失败:在 '{path}' 中找到了 {count} 处匹配段落,存在歧义!请增加上下文行使其具有唯一性。"

new_content = content.replace(old_string, new_string, 1)
with open(real_path, "w", encoding="utf-8") as f:
f.write(new_content)

return f"成功在 '{path}' 中完成精确替换。"
except Exception as e:
return f"编辑文件失败: {str(e)}"

为什么 edit_file 必须强校验唯一性?

在真实生产中,模型常常想修改某个常用的函数名或闭合标签 </div>。如果源码中有 10 处同名代码,单纯的替换就会把其它无辜逻辑也一同覆写。
因此,count != 1 时立即报错并要求模型补齐上下文行,是保障 Agent 编写代码不引发连锁崩溃的关键护栏。


生产源码探秘:Claude Code 的工具注册中心

在 Claude Code 的底层源码中,工具注册机制被推演到了工业级规模。

src/tools.ts:特征门控与条件注册

在 src/tools.ts 中,getAllBaseTools() 通过数组展开和动态特性开关组装工具注册表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/tools.ts(生产源码架构示意)
export function getAllBaseTools(): Tool[] {
return [
AgentTool,
TaskOutputTool,
BashTool,
// 如果系统开启了内嵌符号索引,则不再下发低效的 Glob/Grep
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
FileReadTool,
FileEditTool,
FileWriteTool,
NotebookEditTool,
WebFetchTool,
TodoWriteTool,
WebSearchTool,
SkillTool,
AskUserQuestionTool,
EnterPlanModeTool,
ExitPlanModeV2Tool,
// 实验性主动唤醒工具
...(isProactiveSleepEnabled() ? [SleepTool] : []),
// 多 Agent 工作树模式工具
...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : []),
// V2 磁盘任务图工具集
...(isTaskV2Enabled() ? [TaskCreateTool, TaskGetTool, TaskUpdateTool, TaskListTool] : []),
]
}

这种设计的精妙之处在于:同一个编译打包产物,可以根据环境变量或用户角色权限,动态计算出当前会话可见的工具子集,模型从根本上无法感知未被授权的工具。


Claude Code 的核心工具矩阵

Claude Code 内部共维护了超过 40 个专属工具,主要分为以下梯队:

每个工具都必须严格实现标准接口契约:

1
2
3
4
5
6
7
8
export interface Tool<TArgs = any, TOutput = any> {
name: string
description: string
inputSchema: ZodSchema<TArgs>
call(args: TArgs, context: ToolUseContext): Promise<TOutput>
isReadOnly(): boolean // 是否只读(免审批判断)
needsApproval(): boolean // 是否涉及危险修改需要用户回车确认
}

总结

在工具系统的构建中,我们确立了两条核心法则:

  1. 用专有原子工具代替模糊的 Shell 指令:通过带行号的 read_file 压缩 Token,通过唯一性校验的 edit_file 防止代码篡改;
  2. 用 Dispatch Map 解耦控制流: Agent 循环的骨架应当坚固且不可变,所有能力的生长都通过分发字典外挂注入。

然而,当 Agent 拥有了强大的工具集后,新的工程难题随即产生:面对一个需要修改 10 个文件的复杂任务,模型做着做着就会忘记最初的目标,或者反复修改同一个文件。

下一篇我们将探讨如何给 Agent 装上工作记忆:TodoWrite 规划层与防迷航机制。