一次 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。

前置知识

学习目标

完成本阶段后,你应当能够:

  • 解释为什么 nanobot 把产品级 Turn 和模型执行拆成两层;
  • 解释消息如何归属到 Session;
  • 画出 AgentLoop 的状态机;
  • 说明 ContextBuilder 为模型构造了什么;
  • 说明 AgentRunner 如何在模型和 Tool 之间迭代;
  • 找到流式输出、错误、停止原因和保存位置;
  • 区分模型消息、Session 消息、OutboundMessage 和 Runtime Event。
  • 根据改动目的判断应该进入 Loop、Runner、Context、Provider、Tool、Session 还是运行表面。

阅读顺序

  1. 消息、Session Key 与入口
  2. AgentLoop 状态机
  3. ContextBuilder 与模型上下文
  4. AgentRunner 的模型—工具循环
  5. Tool 注册、校验与执行
  6. 流式输出、错误与停止
  7. 保存、响应与 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

这张图有两个关键异步边界:

  1. 入口经 MessageBus 投递时,AgentLoop.run() 从 inbound queue 消费并创建处理任务;
  2. 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,不解释消息内容
AgentLoopSession 归属、Turn 状态机、命令、上下文准备、保存、响应和运行事件不实现 Provider 协议细节
ContextBuilder选择并组织 system prompt、历史、当前消息、媒体和 runtime metadata不调用模型,不保存 Session
AgentRunnerLLM 迭代、Tool Call、Tool Result、上下文治理和 stop_reason不决定 Channel 路由、Session 文件或 WebUI 展示
LLMProvider把不同模型后端归一为 LLMResponse,处理 Provider 级 stream/retry不决定产品级 Turn 状态
ToolRegistry 与 Tool暴露 schema,精确查找、类型转换、参数校验和能力执行不决定何时继续或结束模型循环
SessionManagerJSONL 加载、缓存、修复和原子保存不构造模型 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_reason

AgentLoop 的“循环”是状态机循环;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
historyBUILD 阶段生成从 Session 中筛出的可回放历史
initial_messages调用 Runner 前生成system + history + current user/runtime context
messages_for_modelRunner 的单次迭代对 messages 做临时修复、裁剪和预算治理
AgentRunResult.messagesRunner 完成时返回本次执行的完整工作消息列表
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 → DONE

slash 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_respondSession、命令、保存和投递不会泄漏到 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 contextnanobot/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.py
  • tests/agent/test_runner_core.py
  • tests/agent/test_runner_tool_execution.py
  • tests/agent/test_context_builder.py
  • tests/agent/test_loop_save_turn.py
  • tests/bus/test_runtime_events.py

学习方法

第一遍只跟踪普通文本回复。第二遍再跟踪一个 Tool Call。第三遍才加入 stream、取消、注入和 Goal。

不要一次打开所有高级分支

AgentRunner 同时处理 retry、fallback、reasoning、tool result budget、injection 和 max iteration。先确认主循环,再逐个补分支。

每读完一篇,至少回答三个问题:

  1. 输入对象在这里增加、删除或变换了什么?
  2. 这个函数会产生磁盘、网络、队列或运行状态副作用吗?
  3. 失败后异常是继续抛出、转换成结果,还是回填给模型?

下一步

阅读 消息、Session Key 与入口。