Phase 1 / The Core Loop / s02

Tool System

Tool Registry、Base Class、Dispatch——让 agent 的能力可插拔

动机:为什么需要工具系统

上一课的 agent 只会一件事:跑 bash。如果你想加 read_file,得改循环里的硬编码。加 write_file 又改一遍。

真实的 agent 有几十个工具。OpenClaw 有 bash、web_search、memory、image、TTS、Discord/Telegram/Slack 操作等。它们不可能全部硬编码在循环里。

我们需要三样东西:

Anthropic 在 Building Effective Agents 中强调:“工具设计要投入和人机交互一样多的精力”(Agent-Computer Interface)。

整个流程如下:

Tool System 分发流程
LLM Responsetool_use: "bash"input: {"cmd": "ls"}nameToolRegistry_tools = {"bash": BashTool"read": ReadFileTool"write": WriteFileTool}.execute()BashToolsubprocess.run"ls" → stdouttool_result → append to messages[]1. LLM 返回 tool_use2. Registry 按 name 查找3. 执行并返回结果

LLM 返回 tool_use 消息,Registry 按 name 字典查找到具体的 Tool 实例,调用 execute(),把结果追加回 messages。循环里不需要知道具体是哪个工具。

Ground Truth:真实的工具系统

Tool System——跨代码库对比

Tool 基类和 Registry 在不同代码库中的实现

pythonagent/tools/base.py
1class Tool(ABC):
2 """Abstract base class for agent tools."""
3
4 @property
5 @abstractmethod
6 def name(self) -> str: ...
7
8 @property
9 @abstractmethod
10 def description(self) -> str: ...
11
12 @property
13 @abstractmethod
14 def parameters(self) -> dict[str, Any]: ...
15
16 @abstractmethod
17 async def execute(self, **kwargs) -> str: ...
18
19 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 }
pythonagent/tools/registry.py
1class ToolRegistry:
2 """Registry for dynamic tool management."""
3
4 def __init__(self):
5 self._tools: dict[str, Tool] = {}
6
7 def register(self, tool: Tool) -> None:
8 self._tools[tool.name] = tool
9
10 def get(self, name: str) -> Tool | None:
11 return self._tools.get(name)
12
13 def get_definitions(self) -> list[dict]:
14 return [tool.to_schema()
15 for tool in self._tools.values()]
16
17 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)
pythonenvironments/local.py
1class LocalEnvironment:
2 """Tool = Environment in mini-swe-agent.
3 Single interface: execute(action) -> output.
4 The entire environment IS the tool.
5 """
6
7 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.stderr
14
15 def get_template_vars(self) -> dict:
16 return {"working_dir": str(self.working_dir)}

关键观察:

构建:Tool 基类和 Registry

从 s01 到 s02 的代码变化:

s01 → s02: 添加 Tool Systempython
- # 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(...)

变化总结:

  1. 引入 Tool 抽象基类——4 个抽象属性:name、description、parameters、execute
  2. 引入 ToolRegistry——字典查找,register() / execute() / get_definitions()
  3. 循环从 run_bash(...) 变成 registry.execute(name, params)——不再关心具体工具

Anthropic 的四条工具设计建议:

  1. 格式贴近模型见过的——用自然的 JSON schema,不要发明 DSL
  2. 消除格式开销——别让模型维护行号之类的计数
  3. 防错设计 (Poka-yoke)——参数命名让犯错更难
  4. 给思考空间——max_tokens 要够用

测试:多工具协作

现在 agent 有 bash、read_file、write_file 三个工具。场景:“创建一个 Python 文件,写入内容,然后读取验证”。

预期执行流程:

  1. Agent 调用 write_file 写入文件
  2. Agent 调用 read_file 读取验证
  3. Agent 调用 bash 运行验证
  4. Agent 返回结果

注意模型自己选择了用哪个工具——我们没有硬编码调用顺序。

变更内容

组件之前 (s01)之后 (s02)
工具定义硬编码 bashTool 抽象基类
工具管理ToolRegistry 注册表
工具数量1 个工具3 个工具

本课代码: agents/s02_tool_system.py — 130 行 (从 s01 的 69 行新增 61 行)

试一试

cd public/code
python agents/s02_tool_system.py "创建并读取一个文件"

可以尝试的提示:

  1. “创建并读取一个文件”
  2. “写入 3 个文件然后用 bash 验证”
  3. “读取当前目录所有 .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(工作区路径)作为参数,注入到每个工具中。我们的实现里工具是无状态的——但生产环境的工具需要知道”我在哪个工作区执行”和”当前的安全策略是什么”。

其他差距

第一性原理思考

为什么工具定义要用 JSON Schema 而不是自由文本?因为 structured output 让模型生成可机器解析的调用,减少了 “string 格式不对” 的失败。这是 Agent-Computer Interface 设计的核心思路——工具的接口不是给人用的,是给模型用的。好的工具接口应该让模型很难犯错(防错设计),而 JSON Schema 的类型约束正好提供了这层保护。