一次 Turn 学习导航
“一次 Turn”是 nanobot 的主要 Agent 架构章节,也是理解整个项目的最短主线:
从一条入站消息开始,直到产生响应、保存状态并发布运行事件。
Turn 不等于一次模型请求
一个 Turn 可以包含多次 LLM 请求、多次 Tool 执行、重试、注入或 continuation。Turn 是产品层的一次完整处理;模型请求只是其中一个步骤。
对应源码
| 架构职责 | 建议同时打开的源码 |
|---|---|
| Turn 状态和产品级编排 | nanobot/agent/loop.py::TurnState、TurnContext、AgentLoop._process_message、AgentLoop._state_* |
| 模型—工具执行循环 | nanobot/agent/runner.py::AgentRunSpec、AgentRunResult、AgentRunner.run、AgentRunner._run_core |
| 模型上下文构建 | nanobot/agent/context.py::ContextBuilder.build_system_prompt、ContextBuilder.build_messages |
| 入站和出站边界 | nanobot/bus/events.py::InboundMessage、OutboundMessage,nanobot/bus/queue.py::MessageBus |
| Provider 归一化契约 | nanobot/providers/base.py::ToolCallRequest、LLMResponse、LLMProvider |
| Tool 注册、校验和执行 | nanobot/agent/tools/registry.py::ToolRegistry,nanobot/agent/tools/ |
| Session 和持久化 | nanobot/session/manager.py::Session、SessionManager |
| 进程内运行状态通知 | nanobot/bus/runtime_events.py::RuntimeEventBus、RuntimeEventPublisher |
第一次只打开四个文件
按
events.py → loop.py → context.py → runner.py阅读。等普通文本 Turn 走通后,再加入registry.py、manager.py和runtime_events.py。
前置知识
- 能看懂 Python
dataclass、Enum、async def、await和asyncio.Queue;达到“知道对象保存什么、协程在哪里暂停”的程度即可。参见 Python 与异步编程最低要求。 - 知道 LLM 的
system/user/assistant/tool消息和 Tool Calling 基本闭环。参见 LLM 消息、流式输出与 Tool Calling。 - 会按类名、函数名搜索调用方,并能在断点处查看局部变量。参见 源码阅读与调试最低要求。
学习目标
完成本阶段后,你应当能够:
- 解释为什么 nanobot 把产品级 Turn 和模型执行拆成两层;
- 解释消息如何归属到 Session;
- 画出 AgentLoop 的状态机;
- 说明 ContextBuilder 为模型构造了什么;
- 说明 AgentRunner 如何在模型和 Tool 之间迭代;
- 找到流式输出、错误、停止原因和保存位置;
- 区分模型消息、Session 消息、OutboundMessage 和 Runtime Event。
- 根据改动目的判断应该进入 Loop、Runner、Context、Provider、Tool、Session 还是运行表面。
阅读顺序
- 消息、Session Key 与入口
- AgentLoop 状态机
- ContextBuilder 与模型上下文
- AgentRunner 的模型—工具循环
- Tool 注册、校验与执行
- 流式输出、错误与停止
- 保存、响应与 Runtime Event
全链路总览
sequenceDiagram participant E as 入口 participant L as AgentLoop participant S as SessionManager participant C as ContextBuilder participant R as AgentRunner participant P as Provider participant T as ToolRegistry participant B as Bus/回调 E->>L: InboundMessage / process_direct L->>S: 取得 Session L->>L: RESTORE → COMPACT → COMMAND L->>S: 读取可回放历史 L->>C: 构建 system + history + current C-->>L: initial_messages L->>R: AgentRunSpec loop 模型—工具迭代 R->>P: messages + tool schemas P-->>R: text / tool_calls / error opt Tool Call R->>T: 解析、校验、执行 T-->>R: tool result end end R-->>L: AgentRunResult L->>S: 保存新消息和 metadata L->>B: OutboundMessage + Runtime Event
这张图有两个关键异步边界:
- 入口经
MessageBus投递时,AgentLoop.run()从 inbound queue 消费并创建处理任务; AgentRunner调用 Provider 或 Tool 时会await外部工作,但同一个有效 Session Key 仍由 Session lock 串行保护。
process_direct() 不走 inbound/outbound queue,却仍使用相同的 Session lock,并最终进入 _process_message()。所以它是另一种传输路径,不是另一套 Agent 核心。
架构职责与依赖方向
| 组件 | 它拥有的职责 | 它刻意不拥有的职责 |
|---|---|---|
| Channel、CLI、API、SDK、WebUI | 接收输入、选择传输方式、展示结果 | 不实现模型—工具循环 |
MessageBus | 在 Channel 与 Agent 核心之间传递通用消息 | 不保存 Session,不解释消息内容 |
AgentLoop | Session 归属、Turn 状态机、命令、上下文准备、保存、响应和运行事件 | 不实现 Provider 协议细节 |
ContextBuilder | 选择并组织 system prompt、历史、当前消息、媒体和 runtime metadata | 不调用模型,不保存 Session |
AgentRunner | LLM 迭代、Tool Call、Tool Result、上下文治理和 stop_reason | 不决定 Channel 路由、Session 文件或 WebUI 展示 |
LLMProvider | 把不同模型后端归一为 LLMResponse,处理 Provider 级 stream/retry | 不决定产品级 Turn 状态 |
ToolRegistry 与 Tool | 暴露 schema,精确查找、类型转换、参数校验和能力执行 | 不决定何时继续或结束模型循环 |
SessionManager | JSONL 加载、缓存、修复和原子保存 | 不构造模型 prompt |
RuntimeEventBus | 向进程内订阅者发布可观察状态 | 不承担用户消息投递 |
flowchart LR Surface["运行表面<br/>Channel / CLI / API / SDK / WebUI"] --> Bus["MessageBus 或 direct call"] Bus --> Loop["AgentLoop<br/>产品级 Turn"] Loop --> Context["ContextBuilder"] Loop --> Runner["AgentRunner<br/>模型执行"] Runner --> Provider["LLMProvider"] Runner --> Registry["ToolRegistry / Tool"] Loop --> Session["SessionManager"] Loop --> Runtime["RuntimeEventBus"] Loop --> Surface Context -.只返回 messages.-> Loop Provider -.只返回 LLMResponse.-> Runner Registry -.只返回 Tool Result.-> Runner
依赖方向的核心是:外围入口依赖 Agent 核心,Agent 核心依赖稳定契约;具体 Provider、Tool 和 UI 不应反过来把自己的协议细节塞进 AgentRunner。
两层循环
初学者最容易把 AgentLoop 和 AgentRunner 看成重复实现。实际上它们处于不同层级:
AgentLoop:一次产品级 Turn
├─ Session
├─ Context
├─ Command
├─ Runtime Event
├─ Persistence
└─ Outbound delivery
AgentRunner:一次模型执行
├─ 调用 Provider
├─ 解析 Tool Call
├─ 执行 Tool
├─ 回填 Tool Result
├─ 上下文治理
└─ 决定 stop_reasonAgentLoop 的“循环”是状态机循环;AgentRunner 的“循环”是最多 max_iterations 次模型迭代。二者的交界是两个数据类:
@dataclass(slots=True)
class AgentRunSpec:
initial_messages: list[dict[str, Any]]
tools: ToolRegistry
model: str
max_iterations: int
max_tool_result_chars: int
# 其余字段提供 hook、stream、checkpoint、injection、timeout 和 Goal 等策略
@dataclass(slots=True)
class AgentRunResult:
final_content: str | None
messages: list[dict[str, Any]]
tools_used: list[str] = field(default_factory=list)
usage: dict[str, int] = field(default_factory=dict)
stop_reason: str = "completed"
error: str | None = None
tool_events: list[dict[str, str]] = field(default_factory=list)
had_injections: bool = False以上节选来自 nanobot/agent/runner.py。AgentRunSpec 把 Loop 准备好的输入交给 Runner;AgentRunResult 把执行结果还给 Loop。这个边界使 Runner 可以复用模型—工具能力,同时不知道消息来自 Telegram、CLI 还是 WebUI。
核心对象如何变化
flowchart TD IM["InboundMessage<br/>channel / chat_id / content / metadata"] --> TC["TurnContext<br/>本 Turn 的可变工作台"] TC --> H["Session.get_history()<br/>history"] H --> CB["ContextBuilder.build_messages()"] IM --> CB CB --> INIT["initial_messages"] INIT --> SPEC["AgentRunSpec"] SPEC --> RUNNER["AgentRunner.run()"] RUNNER --> RESULT["AgentRunResult"] RESULT --> TC2["回填 TurnContext<br/>final_content / all_messages / stop_reason"] TC2 --> SAVE["Session.messages + metadata<br/>JSONL"] TC2 --> OUT["OutboundMessage"] TC2 --> EVT["Runtime Event"]
| 对象 | 生命周期 | 主要用途 |
|---|---|---|
InboundMessage | 入站边界 | 统一 Channel、direct call 等入口的输入字段 |
TurnContext | 单个产品级 Turn | 在状态处理器之间传递可变状态和 trace |
history | BUILD 阶段生成 | 从 Session 中筛出的可回放历史 |
initial_messages | 调用 Runner 前生成 | system + history + current user/runtime context |
messages_for_model | Runner 的单次迭代 | 对 messages 做临时修复、裁剪和预算治理 |
AgentRunResult.messages | Runner 完成时 | 返回本次执行的完整工作消息列表 |
Session.messages | 跨 Turn 持久化 | 保存经过持久化过滤的会话记录 |
OutboundMessage | 出站边界 | 向用户所在运行表面交付最终内容和 metadata |
| Runtime Event | 进程内通知 | 告知 WebUI 等订阅者 Session、运行状态和完成事件 |
四个消息列表不是同一个东西
Session.messages、history、initial_messages和messages_for_model的来源、寿命和过滤规则不同。调试“模型为什么看到这些内容”时,必须先说明你正在观察哪一个。
AgentLoop 状态机骨架
nanobot/agent/loop.py 把产品级 Turn 明确建模成状态,而不是在一个超长函数中隐式前进:
class TurnState(Enum):
RESTORE = auto()
COMPACT = auto()
COMMAND = auto()
BUILD = auto()
RUN = auto()
SAVE = auto()
RESPOND = auto()
DONE = auto()
_TRANSITIONS = {
(TurnState.RESTORE, "ok"): TurnState.COMPACT,
(TurnState.COMPACT, "ok"): TurnState.COMMAND,
(TurnState.COMMAND, "dispatch"): TurnState.BUILD,
(TurnState.COMMAND, "shortcut"): TurnState.DONE,
(TurnState.BUILD, "ok"): TurnState.RUN,
(TurnState.RUN, "ok"): TurnState.SAVE,
(TurnState.SAVE, "ok"): TurnState.RESPOND,
(TurnState.RESPOND, "ok"): TurnState.DONE,
}_process_message() 根据状态名查找 _state_restore()、_state_build() 等处理器;处理器返回事件字符串,再由 _TRANSITIONS 决定下一状态。这样可以单独观察每个阶段的耗时、输入和副作用。
普通 Turn 的主路是:
RESTORE → COMPACT → COMMAND --dispatch→ BUILD → RUN → SAVE → RESPOND → DONEslash command 可以走 COMMAND --shortcut→ DONE。它跳过 BUILD/RUN/SAVE,因此 _state_command() 必须自行处理需要保留的命令消息。
AgentRunner 循环骨架
Runner 内部每一轮先构造只给模型看的临时消息,再请求 Provider:
for iteration in range(spec.max_iterations):
messages_for_model = self._drop_orphan_tool_results(messages)
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
messages_for_model = self._microcompact(messages_for_model)
messages_for_model = self._apply_tool_result_budget(spec, messages_for_model)
messages_for_model = self._snip_history(spec, messages_for_model)
response = await self._request_model(spec, messages_for_model, hook, context)
if response.should_execute_tools:
# 追加 assistant tool_calls,执行工具,再追加 role="tool" 的结果
...
continue
# 处理最终文本、错误、空响应、length recovery 或 injection
...
break节选来自 nanobot/agent/runner.py::AgentRunner._run_core,注释和省略号只压缩了分支。最重要的设计点是:上下文治理作用于新的 messages_for_model,不会用临时修复去改写稍后用于持久化边界的 messages。
七条架构不变量
| 不变量 | 源码证据 | 为什么重要 |
|---|---|---|
| 同一有效 Session Key 串行,不同 Session 可并发 | AgentLoop._dispatch() 使用 per-session asyncio.Lock,外层任务可并发 | 防止同一会话交错写状态,同时保留跨会话吞吐 |
| bus 和 direct 入口汇合到同一核心 | _dispatch() 与 process_direct() 都调用 _process_message() | 不为每个运行表面复制 Agent 逻辑 |
| Loop 拥有产品级生命周期 | _state_restore 到 _state_respond | Session、命令、保存和投递不会泄漏到 Runner |
| Runner 不拥有产品层问题 | AgentRunner 的类注释和 AgentRunSpec/Result | 模型—工具循环可复用、可独立测试 |
| ContextBuilder 只构造输入 | build_system_prompt()、build_messages() 只返回字符串或消息列表 | Prompt 组织与模型调用、持久化解耦 |
| Provider 返回统一响应 | LLMResponse 统一 text、tool calls、finish reason、usage 和错误信息 | Runner 不必按每个后端分叉主循环 |
| Tool 参数在主机侧校验 | ToolRegistry.prepare_call() 先查找、转换、校验,之后才执行 | 模型生成的参数不能被当成可信输入直接运行 |
这些“不变量”是从当前源码归纳出的设计解释,不代表维护者发布过同名正式规范;如果未来修改它们,应先检查对应测试和所有调用方。
改动应该落在哪里
| 想改变的行为 | 首先检查 |
|---|---|
| 新增或调整 Turn 阶段 | nanobot/agent/loop.py 的状态、处理器和转换表 |
| 改变模型—工具迭代或停止条件 | nanobot/agent/runner.py |
| 改变 system prompt、历史或 runtime context | nanobot/agent/context.py |
| 适配新的模型协议 | 具体 Provider 与 nanobot/providers/base.py 契约 |
| 新增能力或参数校验 | nanobot/agent/tools/、ToolRegistry |
| 改变 Session 文件和回放规则 | nanobot/session/manager.py |
| 改变用户投递或 UI 展示 | Channel、API/SDK/WebUI 适配层和 Runtime Event,而不是 Runner |
核心测试
tests/agent/test_loop_runner_integration.pytests/agent/test_runner_core.pytests/agent/test_runner_tool_execution.pytests/agent/test_context_builder.pytests/agent/test_loop_save_turn.pytests/bus/test_runtime_events.py
学习方法
第一遍只跟踪普通文本回复。第二遍再跟踪一个 Tool Call。第三遍才加入 stream、取消、注入和 Goal。
不要一次打开所有高级分支
AgentRunner同时处理 retry、fallback、reasoning、tool result budget、injection 和 max iteration。先确认主循环,再逐个补分支。
每读完一篇,至少回答三个问题:
- 输入对象在这里增加、删除或变换了什么?
- 这个函数会产生磁盘、网络、队列或运行状态副作用吗?
- 失败后异常是继续抛出、转换成结果,还是回填给模型?