深入
agent/loop.py,看 nanobot 如何实现"思考→行动→观察"的智能闭环
1. 引言
在上一篇文章中,我们俯瞰了 nanobot 的整体架构和启动流程,知道了消息最终会通过消息总线(MessageBus)派发到 AgentLoop。那么,AgentLoop 究竟是如何"思考"并决定下一步行动的? 这就是本篇要解答的核心问题。
如果把 nanobot 比作一个人,那么:
channels/是五官,负责接收外界信息bus/是神经网络,传递信号- AgentLoop 就是大脑皮层------它整合信息、做出决策、指挥行动
今天我们就打开这个"大脑",一探究竟。
2. AgentLoop 的核心职责
agent/loop.py 中的 AgentLoop 类承担了以下关键职责:
- 接收消息:从消息总线订阅并处理传入的用户消息
- 构建上下文:将用户消息、对话历史、长期记忆等组装成发给 LLM 的提示
- 调用 LLM:向配置的模型提供商发送请求,获取推理结果
- 解析工具调用:如果 LLM 返回工具调用请求,则提取并执行
- 管理迭代:控制"推理→行动→观察"的循环次数,避免无限循环
- 返回响应:将最终答案或错误信息通过总线返回给用户
下面我们逐项深入分析。
3. 核心循环的源码实现
3.1 类定义与初始化
python
# agent/loop.py (简化版)
class AgentLoop:
def __init__(self, llm_provider, tool_registry, config, message_bus):
self.llm = llm_provider
self.tools = tool_registry
self.config = config
self.bus = message_bus
self.context_builder = ContextBuilder(config)
# 订阅消息总线,处理用户消息
self.bus.subscribe("user_message", self.process_message)
初始化时,AgentLoop 订阅了消息总线上类型为 "user_message" 的事件,这样每当有用户消息到达,process_message 就会被调用。
3.2 核心方法:process_message
这是 AgentLoop 的心脏,完整实现了 ReAct 循环。下面我附上简化后的代码,并逐段解释:
python
async def process_message(self, event: MessageEvent):
"""处理单条用户消息的主入口"""
message = event.message
session_id = message.session_id
max_iterations = self.config.max_iterations or 20
current_iter = 0
# 1. 构建初始上下文
context = await self.context_builder.build(message)
while current_iter < max_iterations:
# 2. 调用 LLM 进行推理
response = await self.llm.complete(
messages=context.messages,
tools=self.tools.get_tool_schemas(), # 将工具描述传给 LLM
tool_choice="auto"
)
# 3. 检查是否有工具调用请求
if response.tool_calls:
# 3.1 执行工具调用
tool_results = await self._execute_tool_calls(response.tool_calls)
# 3.2 将工具结果作为观察添加到上下文
context.add_observation(tool_results)
# 3.3 迭代计数+1,继续循环
current_iter += 1
continue
else:
# 4. 没有工具调用,直接返回最终回答
final_answer = response.content
break
else:
# 5. 达到最大迭代次数,返回超时提示
final_answer = "我思考太久了,请稍后再试。"
# 6. 将响应发回消息总线
await self.bus.publish("bot_response", ResponseEvent(
session_id=session_id,
content=final_answer
))
这段代码虽然简短,却完整地体现了 ReAct 模式的精髓:
- 推理(Reason):调用 LLM 获取下一步行动指令
- 行动(Act):如果 LLM 要求调用工具,则执行工具
- 观察(Observe):将工具执行结果加入上下文,继续推理
- 重复,直到 LLM 直接给出最终答案
3.3 工具调用的执行:_execute_tool_calls
当 LLM 返回工具调用请求时,AgentLoop 会调用 _execute_tool_calls 来实际执行工具:
python
async def _execute_tool_calls(self, tool_calls):
"""并发执行多个工具调用"""
tasks = []
for tc in tool_calls:
tool = self.tools.get(tc.name)
if not tool:
results.append({"error": f"Tool {tc.name} not found"})
continue
# 准备参数
kwargs = tc.arguments
# 执行工具(支持异步)
tasks.append(tool.execute(**kwargs))
# 并发执行所有工具
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
# 格式化结果
formatted = []
for i, result in enumerate(raw_results):
if isinstance(result, Exception):
formatted.append({"error": str(result)})
else:
formatted.append({"result": result})
return formatted
这里有两个值得注意的设计:
- 并发执行:如果 LLM 一次性请求多个工具,nanobot 会并发执行它们,提高效率
- 异常处理:工具执行可能失败,nanobot 会将异常信息包装成正常结果返回给 LLM,让 LLM 决定如何处理(例如重试或告知用户)
4. 消息处理的全流程时序图
为了更直观地展示整个消息处理流程,下面是一张详细的时序图:
工具系统 LLM提供商 AgentLoop 消息总线 渠道(Telegram) 用户 工具系统 LLM提供商 AgentLoop 消息总线 渠道(Telegram) 用户 alt [返回工具调用] [返回文本] loop [最多 max_iterations 次] 发送消息 发布 user_message 事件 调用 process_message 构建上下文(ContextBuilder) 调用 complete (包含工具描述) 返回 tool_calls 并发执行工具 返回执行结果 将结果加入上下文 返回 content 跳出循环 发布 bot_response 事件 路由响应 发送消息
这张图清晰地展示了为什么 AgentLoop 被称为"大脑"------它处于整个信息流的中心,协调着各个模块的协作。
5. 迭代控制与安全机制
为了防止 Agent 陷入无限循环(例如反复调用同一个工具而不给出答案),nanobot 设置了多重安全机制:
5.1 最大迭代次数
在 config.json 中可以配置 max_iterations,默认为 20。当循环次数达到这个上限时,Agent 会强制终止并返回一条友好的错误信息。
5.2 工具调用频率限制
某些工具可能不适合频繁调用(例如发送邮件)。nanobot 在工具注册时可以设置调用限制:
python
@tool(max_calls_per_minute=5)
def send_email(recipient, subject, body):
"""发送邮件"""
# ...
ToolRegistry 会在执行前检查调用频率,超出限制时会返回错误,避免滥用。
5.3 超时控制
LLM 调用和工具执行都设置了超时时间,防止某个操作卡死整个循环。超时设置同样来自配置:
python
response = await asyncio.wait_for(
self.llm.complete(...),
timeout=self.config.llm_timeout
)
6. 上下文构建器:如何组装 Prompt?
ContextBuilder 是 AgentLoop 的重要辅助组件,负责将各类信息组装成发给 LLM 的 messages 数组。它的核心逻辑如下:
python
class ContextBuilder:
def __init__(self, config):
self.config = config
self.memory_store = MemoryStore(config.memory_path)
async def build(self, message: Message) -> Context:
messages = []
# 1. 系统提示:人格设定 + 工具描述
system_prompt = self._build_system_prompt()
messages.append({"role": "system", "content": system_prompt})
# 2. 长期记忆(从 MEMORY.md 读取)
memory = await self.memory_store.get_long_term()
if memory:
messages.append({"role": "system", "content": f"长期记忆:{memory}"})
# 3. 对话历史(短期记忆)
history = await self.session_manager.get_history(message.session_id)
messages.extend(history)
# 4. 当前用户消息
messages.append({"role": "user", "content": message.content})
return Context(messages=messages)
def _build_system_prompt(self):
"""构建系统提示,包含工具描述"""
prompt = self.config.system_prompt_template
tool_descriptions = self.tool_registry.get_descriptions()
return prompt.replace("{{tools}}", tool_descriptions)
这种分层构建的方式保证了 prompt 的清晰性和可维护性。特别值得一提的是,工具描述是通过 JSON Schema 自动生成的,这为 LLM 理解工具调用提供了标准化的格式。
7. 错误处理与健壮性
在生产环境中,各种意外都可能发生。nanobot 在核心循环中加入了多层错误处理:
7.1 LLM 调用失败
如果 LLM 提供商返回错误(如 API 超时、认证失败),AgentLoop 会捕获异常并返回一条错误消息给用户,同时记录日志以便排查。
7.2 工具执行异常
工具代码可能抛出任何异常,_execute_tool_calls 中通过 return_exceptions=True 捕获所有异常,并将异常信息作为正常结果返回给 LLM。这样 LLM 可以"看到"工具执行失败,并可能采取替代方案(例如告诉用户"我暂时无法执行这个操作")。
7.3 消息总线异常
如果发布响应时消息总线出现问题,AgentLoop 会记录错误,但不会影响后续消息的处理(因为每个消息都是独立的任务)。
8. 小结:AgentLoop 的设计智慧
回顾整个 AgentLoop 的实现,我们可以总结出几个关键的设计智慧:
| 设计要点 | 作用 | 源码体现 |
|---|---|---|
| 事件驱动 | 解耦消息来源与处理逻辑 | 订阅消息总线事件 |
| ReAct 循环 | 实现复杂的多步推理与行动 | while 循环 + LLM 调用 |
| 工具 Schema 自动生成 | 降低 LLM 调用工具的难度 | get_tool_schemas() 方法 |
| 并发工具执行 | 提升效率,支持多工具并行 | asyncio.gather |
| 多层安全控制 | 防止死循环和资源滥用 | 最大迭代、超时、频率限制 |
| 上下文分层构建 | 确保 prompt 结构清晰 | ContextBuilder 类 |
正是这些精心设计的细节,使得 nanobot 能够在 4000 行代码内实现一个功能完备的 AI Agent。
下篇预告
在下一篇文章中,我们将探讨 nanobot 的插件系统(工具系统)------这是它灵活性的核心。你将看到:
- 工具如何注册和发现
@tool装饰器的实现原理- MCP 协议的集成方式
- 如何编写自己的工具
敬请期待:《万能接口 ------ 插件系统设计与实现》
本文基于 nanobot v0.1.3 版本撰写,实际代码可能随项目迭代有所变化,建议结合最新源码阅读。