动机:为什么需要工具系统
上一课的 agent 只会一件事:跑 bash。如果你想加 read_file,得改循环里的硬编码。加 write_file 又改一遍。
真实的 agent 有几十个工具。OpenClaw 有 bash、web_search、memory、image、TTS、Discord/Telegram/Slack 操作等。它们不可能全部硬编码在循环里。
我们需要三样东西:
- 一个基类 (Tool) 定义工具的形状——name、description、parameters、execute
- 一个注册表 (ToolRegistry) 管理所有工具——字典查找,
register()/execute() - 循环里只需要
registry.execute(name, params)——不关心具体是哪个工具
Anthropic 在 Building Effective Agents 中强调:“工具设计要投入和人机交互一样多的精力”(Agent-Computer Interface)。
整个流程如下:
LLM 返回 tool_use 消息,Registry 按 name 字典查找到具体的 Tool 实例,调用 execute(),把结果追加回 messages。循环里不需要知道具体是哪个工具。
Ground Truth:真实的工具系统
Tool System——跨代码库对比
Tool 基类和 Registry 在不同代码库中的实现
1class Tool(ABC):2 """Abstract base class for agent tools."""34 @property5 @abstractmethod6 def name(self) -> str: ...78 @property9 @abstractmethod10 def description(self) -> str: ...1112 @property13 @abstractmethod14 def parameters(self) -> dict[str, Any]: ...1516 @abstractmethod17 async def execute(self, **kwargs) -> str: ...1819 def to_schema(self) -> dict:20 return {21 "type": "function",22 "function": {23 "name": self.name,24 "description": self.description,25 "parameters": self.parameters,26 },27 }
1class ToolRegistry:2 """Registry for dynamic tool management."""34 def __init__(self):5 self._tools: dict[str, Tool] = {}67 def register(self, tool: Tool) -> None:8 self._tools[tool.name] = tool910 def get(self, name: str) -> Tool | None:11 return self._tools.get(name)1213 def get_definitions(self) -> list[dict]:14 return [tool.to_schema()15 for tool in self._tools.values()]1617 async def execute(self, name: str, params: dict) -> str:18 tool = self._tools.get(name)19 if not tool:20 return f"Error: Tool '{name}' not found."21 return await tool.execute(**params)
1class LocalEnvironment:2 """Tool = Environment in mini-swe-agent.3 Single interface: execute(action) -> output.4 The entire environment IS the tool.5 """67 def execute(self, action: dict) -> str:8 command = action.get("command", "")9 result = subprocess.run(10 command, shell=True,11 capture_output=True, text=True,12 )13 return result.stdout + result.stderr1415 def get_template_vars(self) -> dict:16 return {"working_dir": str(self.working_dir)}
关键观察:
- nanobot 的设计最经典:
Tool抽象基类 +ToolRegistry字典查找 to_schema()把 Python 类转换成 OpenAI function calling 格式——这是 LLM API 的约定- mini-swe-agent 走了极简路线:整个环境就是一个工具(只有 bash)
- 两种思路都有效——取决于你需要多少工具
构建:Tool 基类和 Registry
从 s01 到 s02 的代码变化:
- # s01: 硬编码的 bash 工具- TOOLS = [{"name": "bash", ...}]+ # s02: 可插拔的 Tool System+ class Tool(ABC):+ @property+ @abstractmethod+ def name(self) -> str: ...+ def description(self) -> str: ...+ def parameters(self) -> dict: ...+ @abstractmethod+ def execute(self, **kwargs) -> str: ...- def run_bash(command: str) -> str:- result = subprocess.run(command, ...)- return result.stdout + result.stderr+ class ToolRegistry:+ def register(self, tool: Tool): ...+ def get_definitions(self) -> list[dict]: ...+ def execute(self, name, params) -> str: ...- def agent_loop(query: str):+ def agent_loop(query: str, registry: ToolRegistry):messages = [{"role": "user", "content": query}]while True:response = client.messages.create(- model=MODEL, tools=TOOLS, messages=messages,+ model=MODEL,+ tools=registry.get_definitions(),+ messages=messages,)...for block in response.content:if block.type == "tool_use":- output = run_bash(block.input["command"])+ output = registry.execute(+ block.name, block.input+ )results.append(...)
变化总结:
- 引入
Tool抽象基类——4 个抽象属性:name、description、parameters、execute - 引入
ToolRegistry——字典查找,register()/execute()/get_definitions() - 循环从
run_bash(...)变成registry.execute(name, params)——不再关心具体工具
Anthropic 的四条工具设计建议:
- 格式贴近模型见过的——用自然的 JSON schema,不要发明 DSL
- 消除格式开销——别让模型维护行号之类的计数
- 防错设计 (Poka-yoke)——参数命名让犯错更难
- 给思考空间——max_tokens 要够用
测试:多工具协作
现在 agent 有 bash、read_file、write_file 三个工具。场景:“创建一个 Python 文件,写入内容,然后读取验证”。
预期执行流程:
- Agent 调用
write_file写入文件 - Agent 调用
read_file读取验证 - Agent 调用
bash运行验证 - Agent 返回结果
注意模型自己选择了用哪个工具——我们没有硬编码调用顺序。
变更内容
| 组件 | 之前 (s01) | 之后 (s02) |
|---|---|---|
| 工具定义 | 硬编码 bash | Tool 抽象基类 |
| 工具管理 | 无 | ToolRegistry 注册表 |
| 工具数量 | 1 个工具 | 3 个工具 |
本课代码: agents/s02_tool_system.py — 130 行 (从 s01 的 69 行新增 61 行)
试一试
cd public/code
python agents/s02_tool_system.py "创建并读取一个文件"
可以尝试的提示:
- “创建并读取一个文件”
- “写入 3 个文件然后用 bash 验证”
- “读取当前目录所有 .py 文件的第一行”
距离生产
参数验证: Tool.validate_params()
nanobot 的 Tool 基类有一个我们省略的方法:validate_params()。它在 execute() 之前用 JSON Schema 验证参数的类型和必填字段。
为什么需要? 因为 LLM 有时会生成格式错误的参数——比如把 line_count 传成字符串 "10" 而不是整数 10,或者漏掉必填字段。没有验证的话,错误会在工具执行深处才暴露,错误信息对模型来说很难理解。提前验证可以返回清晰的 “参数 X 类型错误” 信息,让模型自我修正。
工厂模式: createOpenClawCodingTools()
OpenClaw 的 pi-tools.ts 用工厂函数 createOpenClawCodingTools() 创建工具实例。这个函数接收 policy(安全策略)和 workspace(工作区路径)作为参数,注入到每个工具中。我们的实现里工具是无状态的——但生产环境的工具需要知道”我在哪个工作区执行”和”当前的安全策略是什么”。
其他差距
- 无并行执行——一次只能调一个工具,生产系统支持并行 tool call
- 无工具超时——工具执行可以永远挂住,生产系统有超时和取消机制
- 无工具结果大小限制——巨大的文件内容直接塞进 context,生产系统会截断
第一性原理思考
为什么工具定义要用 JSON Schema 而不是自由文本?因为 structured output 让模型生成可机器解析的调用,减少了 “string 格式不对” 的失败。这是 Agent-Computer Interface 设计的核心思路——工具的接口不是给人用的,是给模型用的。好的工具接口应该让模型很难犯错(防错设计),而 JSON Schema 的类型约束正好提供了这层保护。