第 2 章:工具系统¶
让 Agent 从"只会说话"变成"能做事"。这是 chatbot 和 agent 的分水岭。
相对上一章,只新增了什么¶
如果你刚从第 1 章过来,先不要急着看完整代码。本章其实只新增了 3 个东西:
Tool:把“能做什么”声明给模型ToolRegistry:把工具的注册和执行入口统一起来ReAct Loop:让模型可以“想一下 -> 调工具 -> 看结果 -> 再想一下”
你可以先把本章当成是在回答一个问题:为什么同样是聊天,加入工具后它就成了 Agent?
核心概念¶
"聊天机器人"只能生成文字。"Agent"能采取行动——执行命令、读写文件、搜索网页。
这靠的是 Function Calling(工具调用):LLM 不直接输出结果,而是输出"我想调用某个工具",我们执行工具后把结果反馈给 LLM,LLM 再继续思考。
这就是 ReAct 循环(Reasoning + Acting)——Agent 的核心。
第一步:定义工具基类¶
每个工具需要三样东西:名字、描述(告诉 LLM 什么时候该用)、参数格式(JSON Schema)。
nanobot v0.2.2 的 Tool 基类还包含参数校验和类型转换,但核心仍是工具名称、描述、参数和执行入口。
第二步:实现具体工具¶
exec 工具——执行 Shell 命令¶
这里只展示工具调用主线,不要复制运行
黑名单挡不住 Shell 组合语法,create_subprocess_shell 也不能限制绝对路径、符号链接或网络访问。下面三个工具只用于看懂接口;可运行练习请使用加固后的配套示例,它使用临时工作区、无 Shell 参数执行和只读命令白名单。即使如此,教学防护也不是生产沙箱。
read_file 工具——读取文件¶
write_file 工具——写入文件¶
第三步:工具注册表¶
管理所有工具的容器。教学版对应 v0.2.2 的 ToolRegistry:
设计要点:注册表不关心具体工具是什么。它只负责"根据名字找到工具并执行"。新增工具只需要 registry.register(MyNewTool()),不需要改任何其他代码。
第四步:ReAct 循环——Agent 的核心¶
这是整个教程最关键的代码。下面的 agent_loop() 是便于独立阅读的缩小模型;在 nanobot v0.2.2 中,同一类执行闭环主要由 AgentRunner负责,而不是全部堆在 AgentLoop 中。
先说明一个教学上的妥协:下面的
agent_loop写成了async,但示例里仍然直接调用同步版OpenAI客户端。这足够帮助你看懂 ReAct 循环,却不代表它已经是非阻塞实现。真实项目里应改用异步客户端,或把这类阻塞 I/O 丢进线程池。
这就是 Agent 的全部秘密。 把它拆开看:
- 调 LLM:带上工具定义,让 LLM 知道它"能做什么"
- 检查返回:
- 如果 LLM 返回了
tool_calls→ 它想调用工具 → 我们执行 → 把结果喂回去 → 回到第 1 步 - 如果 LLM 没有
tool_calls→ 它认为可以直接回答了 → 返回文本 - 循环:最多循环 N 次,防止无限调用
v0.2.2 的真实职责边界¶
flowchart LR
channel[Channel / MessageBus] --> loop[AgentLoop]
loop -->|AgentRunSpec| runner[AgentRunner]
runner --> provider[LLMProvider 流式或普通响应]
runner --> registry[ToolRegistry]
registry --> tool[具体 Tool]
tool --> registry
registry --> runner
runner -->|AgentRunResult| loop
loop --> session[保存 Session]
loop --> outbound[OutboundMessage]
| 真实组件 | 负责什么 | 不负责什么 |
|---|---|---|
AgentRunner |
接收 AgentRunSpec;请求 Provider;汇集流式文本、reasoning 和工具调用;执行工具;追加工具结果;控制迭代、超时与最终结果 |
不选择 Channel,不创建业务 Session,不决定回复发到哪里 |
AgentLoop |
从 MessageBus 接收入站消息;解析会话键和工作区;构建 Bootstrap、Memory、Skills 与历史上下文;把配置装进 AgentRunSpec;保存新消息;组装 OutboundMessage |
不再自己实现 Provider/工具的底层迭代算法 |
LLMProvider |
把不同模型接口规范化为 LLMResponse / ToolCallRequest,提供普通或流式请求 |
不执行工具 |
ToolRegistry |
暴露工具 Schema,并按名字分派实际工具 | 不调用模型,也不管理会话 |
具体调用顺序是:
AgentLoop的 turn 状态依次恢复 Session、压缩历史、处理命令并构建上下文。AgentLoop._run_agent_loop()创建AgentRunSpec,然后调用self.runner.run(...)。这个同名方法是产品层适配器,不是底层 ReAct 实现。AgentRunner._request_model()根据 Hook 能力选择 Provider 的chat_stream_with_retry()或chat_with_retry();流式正文、reasoning 和工具调用增量通过 Hook/进度回调交还 Channel。AgentRunner._run_core()判断规范化响应是否请求工具;若请求,就追加 assistant tool-call 消息并进入_execute_tools()。- 工具结果以
role: "tool"追加到消息,再进入下一次 Provider 请求;无工具调用时形成AgentRunResult。 AgentLoop保存本轮新增消息,更新 Session,并把最终内容组装成与原 Channel 对应的出站消息。
这种拆分很重要:CLI、WebUI、Telegram 或定时任务可以复用相同执行核心,同时各自保留会话、工作区和回复目标。
如果你只读 5 分钟,就先看这 3 段¶
Tool.to_schema():模型如何知道有哪些工具ToolRegistry.execute():程序如何根据名字找到并执行工具agent_loop():工具调用结果如何重新回到下一轮推理
完整代码¶
把以上所有部分组合起来:
完整代码块仍是架构讲解版
它保留了上面的宽权限 Shell/文件工具,不能直接用于真实目录或不受信任输入。实际运行请使用本章末尾的加固配套文件;该文件会在入口显示安全警告,并拒绝工作区越界。
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 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 | |
试一试¶
它能做事了! 从"聊天机器人"升级成了"AI Agent"。
关键设计对比¶
| 我们的代码 | nanobot 的代码 | 区别 |
|---|---|---|
Tool 基类 |
agent/tools/base.py |
nanobot 增加参数校验、类型转换和运行上下文 |
ToolRegistry |
agent/tools/registry.py |
nanobot 统一 Schema 暴露、分派和错误反馈 |
agent_loop 的 Provider/工具迭代 |
agent/runner.py 的 AgentRunner |
nanobot 支持流式增量、Hook、并发工具、检查点、错误恢复与迭代预算 |
main() 的会话和输入输出编排 |
agent/loop.py 的 AgentLoop |
nanobot 连接 MessageBus、工作区、Session、上下文构建和出站消息 |
ExecTool |
agent/tools/shell.py |
nanobot 增加超时、输出预算、工作区守卫和可选执行沙箱 |
还缺什么?¶
- 重启就失忆——没有持久化记忆
- 没有个性——system prompt 太简单
- messages 无限增长——会撑爆上下文窗口
本章你真正学到的抽象¶
这一章新增的核心不是某个具体工具,而是 Agent 的闭环:
Tool Schema:把“能做什么”声明给模型Tool Registry:把工具定义和执行入口解耦ReAct Loop:让模型可以在“思考”和“行动”之间多轮往返
真正让 chatbot 变成 agent 的,不是 exec 这个工具本身,而是“模型可以请求行动,程序执行后再把结果喂回去”这个模式。
最小验证步骤¶
建议至少做下面 4 个验证:
- 问一个不需要工具的问题,确认模型能直接回答
- 问一个明显需要命令行的问题,比如“当前目录下有哪些文件?”
- 使用加固配套示例,让它在临时教学工作区写一个简单文件,再读取它
- 故意让它执行一个会失败的命令,观察错误是否能回传给模型
你应该观察到的现象:
- 直接问答时不会强行调用工具
- 需要行动时会打印工具调用痕迹
- 工具结果会进入下一轮推理,而不是只打印在终端
常见失败点¶
- 模型从不调用工具:通常是模型能力不足、接口不支持 function calling,或
tools=没正确传入 json.loads(tc.function.arguments)报错:某些模型会生成不合法 JSON,需要先打印原始参数排查- 工具执行了但最终回答很差:通常是工具输出太长、太脏,模型没有拿到高质量观察结果
exec工具不稳定:shell 环境、平台差异、命令超时都会影响效果;这也是为什么生产实现要做更多限制和适配
配套示例¶
下一章解决这些问题。