拆.
主循环——AI 怎么'思考'一轮对话 · 第 02

一次 turn 的完整生命周期

从用户输入到完成回应的 9 个阶段

4300读完约 22 分钟craft:B+发布于 2026-06-21

一、原理:一个 turn 是个状态机

主循环的"一轮"——从发请求到收到 LLM 响应到执行 tool 到再发请求——内部其实有更细的颗粒度。这个颗粒度叫 turn

一个 turn 是从"用户给 AI 一个输入"到"AI 给用户一个完整回应"的单元。用户视角看是"我说一句话 AI 回我一段话",内部其实是一个完整的"小循环"——可能跑了 10 轮 LLM 调用、5 次 tool 执行、几次 background fork。

把 turn 拆开看,它有几个明确的阶段:

阶段 1:输入接收。用户敲下 Enter,UI 把输入打包成一个 user message,发给 server。Server 把消息塞进 session 的 messages 历史。

阶段 2:环境快照。Server 检测当前环境状态——工作目录、git 状态、AGENTS.md 内容、当前 agent(plan/build)、当前 model。这些会进入 system prompt。

阶段 3:主循环开始。runLoop 拉起来。Compaction 检查(context 是不是太满)、system prompt 拼装、tool 清单准备。

阶段 4:LLM 调用。每次循环都是一次 LLM API 请求。请求体包括 system prompt + messages + tools + 模型参数。

阶段 5:流式响应。LLM 用 SSE 流回 token。每个 token 都触发事件 → UI 实时渲染"打字效果"。同时 server 端边收边解析——是文本还是 tool call?

阶段 6:Tool 执行(如果有 tool call)。检测到 tool call → 校验参数 → 检查权限 → 执行 → 把结果塞回 messages → 回到阶段 4。

阶段 7:自然结束。LLM 不再请求 tool call,输出最终回复 → 主循环跳出。

阶段 8:后处理。Title agent 给 session 起名字(如果是第一个 turn)、summary agent 算 diff 摘要(如果有 file 改动)、UI 更新状态为 idle。

阶段 9:状态持久化。所有 messages 和 parts 写入 SQLite。Session 元数据更新(最后活跃时间、总 cost、总 token)。

这 9 个阶段加起来构成一次完整 turn 的生命周期。理解每个阶段你才能 debug AI 产品的奇怪行为——某个 message 没显示?可能是阶段 5 的事件没发;某个 tool 没执行?可能是阶段 6 的权限被拒;某个状态没更新?可能是阶段 9 的持久化失败。

每个阶段都是产品设计的着力点。后续章节我们会看 opencode 在每个阶段做的具体选择。这一章我们看 opencode 怎么把这 9 个阶段串起来——核心是 processor.ts

二、案例:opencode 的 processor.ts 处理链

packages/opencode/src/session/processor.ts 是 opencode 处理一次 turn 的核心。这文件有 1500+ 行——是 opencode 最长的单文件之一。我们看它的结构。

processor.ts 的入口是一个 Effect.fn 包装的函数 process()。它接收一个 StreamInput(包含 sessionID、messages、model、tools 等),返回一个 Effect 描述整个 turn 的处理。

process() 内部的主循环大致是这样:

process(streamInput):
  yield* Effect.gen(function* () {
    yield* status.set(sessionID, "busy")
    
    const stream = llm.stream(streamInput)
    
    yield* stream.pipe(
      Stream.tap(event => handleEvent(event)),  // 处理每个 event
      Stream.takeUntil(() => ctx.needsCompaction),  // 满足条件停止
      Stream.runDrain,
    )
  }).pipe(
    Effect.onInterrupt(() => 
      Effect.gen(function* () {
        aborted = true
        if (!ctx.assistantMessage.error) {
          yield* halt(new DOMException("Aborted", "AbortError"))
        }
      })
    ),
    Effect.catchCauseIf(
      cause => !Cause.hasInterruptsOnly(cause),
      cause => Effect.fail(Cause.squash(cause)),
    ),
    Effect.retry(SessionRetry.policy({ ... })),
    Effect.catch(halt),
    Effect.ensuring(cleanup()),
  )

注意几个细节。

细节 1:状态机驱动。Session 有几个明确状态——idle、busy、compacting、error、aborted。每个状态对应不同的 UI 显示。status.set() 是这些状态的入口——每次切换都触发事件让 UI 更新。

细节 2:流式处理Stream.tap(event => handleEvent(event)) 这一行是核心。handleEvent 处理 LLM 发来的每个 event——可能是 text delta、tool call、reasoning chunk、metadata。每种事件有不同处理路径。

细节 3:takeUntil 提前停止Stream.takeUntil(() => ctx.needsCompaction) 让 stream 在满足某条件时提前停。比如检测到 context 即将 overflow,立刻停下来让 compaction 跑。

细节 4:中断处理Effect.onInterrupt() 捕获中断信号——用户 Ctrl+C 或者 abort API 调用。中断时 aborted = true 标记被设置、所有 in-flight tool 调用被取消、cleanup 函数执行。

细节 5:错误重试Effect.retry(SessionRetry.policy(...)) 是错误重试层。如果是网络错误、provider rate limit 错误,自动按 retry policy 重试。如果是不可重试的错误(context overflow、invalid input),直接 fail。

细节 6:cleanupEffect.ensuring(cleanup()) 保证不管成功还是失败,cleanup 都会跑。cleanup 做的事:清当前 currentText、等所有 tool 完成(250ms 超时)、标记未完成 tool 为 "interrupted"、更新 message 完成时间。

这 6 个细节叠加,让 processor.ts 在各种异常情况下都能保持 session 状态一致。这是 AI Agent 工程化最重要的部分之一——不是"主循环跑起来",是"主循环在各种异常下都不留下脏状态"。

processor.ts 还有一个值得提的设计——part 状态机

一个 message 不是单一字符串,是多个 part 组成的(part 是 message 内部的颗粒)。比如 LLM 的输出可能是:

Message {
  parts: [
    { type: "text", state: "completed", content: "Let me check the file." },
    { type: "tool-read", state: "completed", input: {...}, output: "..." },
    { type: "text", state: "completed", content: "Found the bug. Fixing now." },
    { type: "tool-edit", state: "completed", input: {...}, output: "..." },
    { type: "text", state: "completed", content: "Fixed and tested." },
  ]
}

每个 part 有 state——pending(创建但未开始)、running(执行中)、completed(成功)、error(失败)、interrupted(中断)。这种细粒度的状态让 UI 能精确显示"现在在做什么"——"正在读文件"还是"读完了正在编辑"还是"编辑失败了"。

每次 part 状态变化都通过 EventV2 总线发布事件,UI 端订阅这些事件实时更新。这是后面 9.3 章会专门讲的 Event Stream 系统的基础。

三、设计启示:观察 turn 内部能学到的设计选择

这一章的核心论点:一个 turn 不是黑箱——它是个有 9 个阶段、几十种事件、复杂状态机的工程对象

如果你做 AI 产品,关于 turn 内部你要做的几件事:

1. 把 turn 拆成可观测的阶段。每个阶段都应该有事件、有日志、有状态。让你(和用户)能看到 turn 内部"现在到哪一步了"。

2. 状态机要清晰。session 状态(idle / busy / error)、part 状态(pending / running / completed / error / interrupted)都是状态机。状态转换要显式——不要让"状态自己悄悄变了"。

3. cleanup 必须 robust。不管 turn 成功还是失败、不管是不是被中断,cleanup 都要跑。不 cleanup 的 turn 留下脏状态——下次 turn 看到上次没完成的 tool call 会很迷惑。

4. 错误分类要明确。可重试错误(network、rate limit)、不可重试错误(context overflow、permission denied)、可恢复错误(tool failed but session OK)——分清楚才能正确处理。

5. 部分完成是常态。一个 turn 可能执行了 5 个 tool,第 6 个失败。前 5 个的工作是真的发生了——文件改了、命令跑了。不要因为第 6 个失败就 rollback 前 5 个(除非用户明确要求)。设计 turn 时假设"部分成功"是常态。

6. 中断设计要彻底。用户按 Ctrl+C 必须立刻停。不能"等当前 tool 跑完再停"——当前 tool 可能是个 30 秒的 bash 命令。要从 Stream 层、Effect runtime 层、tool 实现层都支持 cancellation。

7. 持久化在 turn 结束时做。每个 part 完成时就该写入数据库——不要等到整个 turn 结束才统一写入。这样即使 server 崩了,已经完成的 part 也不会丢。

8. UI 渲染分两个通道。流式 delta(实时显示打字效果)和 完成事件(持久化的最终状态)应该用不同的事件类型。第 9 篇 9.4 章会展开 Durable vs Ephemeral 的设计。

最后一个观察。Turn 的生命周期看似复杂,但它的复杂度是必要的。如果你简化它——"我就同步等 LLM 出完整结果"——你会失去流式响应(用户体验差);如果你不做 cleanup——"我直接退出就好"——你会留下脏状态(下次 session 启动失败);如果你不分 part 状态——"就一个 message 字段"——你失去细粒度可观测性。

AI Agent 不是简单的 "wrap LLM API"——它是 distributed system 设计。Turn 是这个 distributed system 的基本工作单元。理解它的复杂度,你才能做出真正生产级的 AI 产品。

下一章 2.3 我们看 turn 跑起来后的"持续成本"问题——token 经济学。一个 turn 的复杂度决定了它消耗多少 token、花多少钱。