上一篇文章我们讲了 Agent Loop,今天聊一个很实用的设计:工具分发(Tool Dispatch)。
上一章留下的问题
上一章的最小 Agent 只有一把瑞士军刀:bash。
但用 bash 干活有很多问题:
# cat 截断不可预测
cat large_file.py # 可能输出几十屏
# sed 遇到特殊字符就崩
sed -i 's/old/new/' file.txt # 中文、特殊符号全挂
# 每次都是不受约束的安全面
rm -rf / # 危险命令也能执行
怎么办?
解决方案:专用工具 + 分发地图
这一章的核心思路很简单:
不要用 bash 做所有事,而是给 Agent 添加专用工具。
专用工具的好处:
- 语义清晰,模型知道什么时候该用
- 可以加安全校验
- 行为更可预测
但问题是:每加一个工具就要改一次循环吗?
答案:不用。
Tool 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"]),
}
调用工具时,只需按名字查找:
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else "Unknown tool"
# ...
关键结论:
- 加新工具 = 加 schema + 加 handler
- 循环代码完全不用动
三个核心概念
1. Tool Schema(工具描述)
给模型看的「工具说明书」:
{
"name": "read_file",
"description": "读取文件内容",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"limit": {"type": "integer"}, # 可选参数
},
"required": ["path"],
},
}
2. Handler(处理函数)
工具的「实际执行逻辑」:
def run_read(path: str, limit: int = None) -> str:
content = safe_read(path)
if limit:
lines = content.splitlines()[:limit]
content = "\n".join(lines)
return content
3. tool_result(工具结果)
结果的「回流机制」:
results.append({
"type": "tool_result",
"tool_use_id": block.id, # 关联到哪次调用
"content": output,
})
路径安全:防止工具「逃逸」
如果 Agent 能读写任意文件,系统就危险了。
所以要给工具加「沙箱」:
def safe_path(path_str: str) -> Path:
workdir = Path.cwd()
target = (workdir / path_str).resolve()
# 检查是否在工作目录内
if not target.is_relative_to(workdir):
raise ValueError(f"禁止访问工作目录外的文件: {path_str}")
return target
这样 Agent 只能操作当前项目下的文件,安全性大大提升。
消息规范化:API 的硬性要求
当系统变复杂后,内部 messages 列表可能会出现 API 不接受的格式。
API 有三条硬性约束:
- 每个 tool_use 必须有匹配的 tool_result
- user/assistant 消息必须严格交替
- 只接受协议定义的字段
所以每次调用 API 前,需要规范化:
def normalize_messages(messages: list) -> list:
# 1. 剥离内部字段
# 2. 补齐缺失的 tool_result
# 3. 合并连续同角色消息
return normalized
关键洞察:messages 是内部状态,API 看到的是规范化后的副本,两者不是同一个东西。
对比:s01 vs s02
| 组件 | s01 | s02 |
|---|---|---|
| 工具数 | 1 (bash) | 4 (bash, read, write, edit) |
| 分发方式 | 硬编码 | TOOL_HANDLERS 字典 |
| 路径安全 | 无 | safe_path() 沙箱 |
| 循环代码 | - | 完全不变 |
怎么给 Agent 加新工具
以「搜索文件」工具为例:
第一步:写处理函数
def run_grep(path: str, pattern: str) -> str:
matches = subprocess.run(
["grep", "-n", pattern, path],
capture_output=True, text=True
)
return matches.stdout or "(no matches)"
第二步:注册到分发地图
TOOL_HANDLERS = {
# ... 现有工具 ...
"grep": lambda **kw: run_grep(kw["path"], kw["pattern"]),
}
第三步:添加 Schema
{
"name": "grep",
"description": "在文件中搜索匹配的行",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"pattern": {"type": "string"},
},
"required": ["path", "pattern"],
},
}
完成!循环代码零改动。
进阶:工具层不只是「分发表」
学到这里,工具对你来说已经是一张「handler map」。
但实际上,当系统继续变大,工具层还会长出更多能力:
- 权限环境(什么情况下禁止调用)
- 上下文注入(工具能访问当前状态)
- 调用日志和追踪
- 缓存机制
也就是说,完整的工具层会更像一条「工具控制平面」。
不过那是后面的内容,先把今天的基础打牢。
一句话总结
Tool Dispatch Map 让「加工具」变成「注册到字典」,循环代码永远不用改。
下一篇文章我们会聊:如何让 Agent 记住「当前在做什么」,不再走一步忘一步。
往期回顾: