主线引擎:AgentLoop + AgentRunner
这一章讲 nanobot 那条「小核心」到底怎么转一圈。先看顶层的
AgentLoop(管一整轮对话的事务),再钻进AgentRunner(管和模型来回的内循环)。这是整个项目最值得读透的部分。
1. 为什么要拆成两层?
一轮对话其实有两种完全不同的关切混在一起:
- 面向渠道的事务:这条消息属于哪个会话?工作区在哪?历史要不要先压缩?是不是斜杠命令?跑完怎么存盘、怎么把答复发回去?
- 面向模型的事务:把消息发给哪个 provider?模型要调工具吗?工具结果怎么喂回去?什么时候算「答完了」?撞上迭代上限怎么收尾?
nanobot 把前者放进 AgentLoop,后者放进 AgentRunner。调试时这条线很有用:渠道路由/会话键/存盘的问题去 loop.py;模型调用/工具/流式/迭代上限的问题去 runner.py(docs/architecture.md:36-54)。
2. AgentLoop:一整轮 = 一个 7 态状态机
2.1 它要解决的小问题
一轮对话有很多步骤,而且任何一步都可能抛异常或需要中断。如果写成一 长串顺序代码,容错和「断点续跑」会很难。nanobot 把它建模成一个显式状态机:每个状态是一个 async 处理函数,返回一个事件字符串,驱动器查表决定下一个状态。
2.2 状态与转移
七个状态(外加终态 DONE)定义在 TurnState(loop.py:82-90):
RESTORE → COMPACT → COMMAND →(dispatch)→ BUILD → RUN → SAVE → RESPOND → DONE
└─(shortcut)──────────────────────────────────→ DONE
各状态干什么:
| 状态 | 处理函数 | 职责 |
|---|---|---|
| RESTORE | _state_restore | 载入会话、恢复运行时检查点/未完成的用户轮 |
| COMPACT | _state_compact | 必要时按 token 预算压缩历史(见 04 章) |
| COMMAND | _state_command | 是斜杠命令就内联处理并走 shortcut 提前结束 |
| BUILD | _state_build | 取历史、拼初始消息、提前持久 化用户消息 |
| RUN | _state_run | 调 _run_agent_loop → AgentRunner |
| SAVE | _state_save | 算时延、存这一轮、后台触发压缩 |
| RESPOND | _state_respond | 组装 OutboundMessage |
转移用一张纯数据表 _TRANSITIONS 描述,驱动器只是查表(loop.py:175-184):
# 示意,非源码:状态机驱动的核心就这么点
while ctx.state is not TurnState.DONE:
handler = getattr(self, f"_state_{ctx.state.name.lower()}")
event = await handler(ctx) # 处理函数返回一个事件字符串
ctx.state = self._TRANSITIONS[(ctx.state, event)] # 查表 → 下一态
# 重点看:状态、转移、处理函数三者解耦,加一步只改表 + 加一个 _state_xxx
真实驱动器在 loop.py:1297-1342(_process_message),还顺手给每个状态记了耗时 StateTraceEntry 做诊断。event 没有对应转移就直接抛 RuntimeError——宁可显式炸,也不静默走错。
2.3 BUILD 这步:上下文怎么进来的
_state_build(loop.py:1450-1494)是「把世界塞进一次模型调用」的地方:取出受 token 预算约束的历史 get_history(...),再交给 _build_initial_messages → ContextBuilder(系统提示 = 身份 + 记忆 + 技能 + 近期历史,见 02 章)。它还做了一件容易忽略但很重要的事:提前持久化用户消息 _persist_user_message_early——这样即使后面崩了,你这句话也不会丢。
2.4 主消费循环
状态机是「处理一条消息」。把消息源源不断喂进来的是 AgentLoop.run(loop.py:882):一个 while 循环不停 consume_inbound(),为每条消息起处理。这就是图里「总线 → AgentLoop」那根箭头的实体。
3. AgentRunner:面向模型的内循环
RUN 状态最终调到 AgentRunner.run → _run_core。这是真正和大模型来回拉锯的地方。
3.1 思路:一个带上限的「问—用工具—再问」循环
核心直觉非常朴素:
# 示意,非源码:agent 内循环的灵魂
for iteration in range(max_iterations):
resp = await provider.chat(messages, tools) # 问模型
if resp 要调工具:
results = await 执行这些工具(resp.tool_calls)
messages += [助手消息(含 tool_calls), *每个工具的结果消息]
continue # 带着结果再问一遍
else:
return resp.content # 模型给了最终答复,收工
# 重点看:工具结果作为 role="tool" 消息追加回去,下一轮模型就能看到
真实实现是 _run_core(runner.py:341-706),围绕这个骨架加了大量容错,下面拆开讲。
3.2 何时算「该执行工具」
不是「模型给了 tool_calls 就执行」。LLMResponse.should_execute_tools(providers/base.py:172-178)要求 finish_reason 是 tool_calls/function_call/stop 之一——在 refusal/content_filter/error 下,网关注入的工具调用会被拒绝执行(防 #3220 那类注入)。