Turn / Step 双循环

Turn / Step 双循环

一句话总结

每次 Agent 对话都被结构化为一个嵌套的双循环:一个 Turn 是一次用户请求及其完整解决过程,而每个 Turn 内部包含一个或多个 Step,每个 Step 代表一次 LLM 调用及其工具执行——两个循环都携带严格单调递增的编号,并由 session 不变量强制保证。

为什么需要两个循环?

单循环不够,因为一次用户请求通常需要多次 LLM 调用才能完成:

用户:"列出文件,然后统计每个 .go 文件的行数"
  └─ Turn 0 开始
       ├─ Step 1:LLM 决定调用 `ls` → 工具执行 → 返回结果
       ├─ Step 2:LLM 看到文件列表,决定对每个文件调用 `wc -l` → 工具执行 → 返回结果
       └─ Step 3:LLM 综合最终答案(不再有工具调用)
  └─ Turn 0 结束

Turn 边界回答的问题是:“这次用户请求是否已解决?” Step 边界回答的问题是:“这次 LLM 调用是否产生了需要下一轮的工具调用?”

这种分离使得以下能力成为可能:

  • 按 Turn 取消 — 中止一次用户请求而不丢失会话历史
  • 按 Step 重试 — 重新运行失败的 LLM 调用而不重启整个 Turn
  • 精确的 Token 统计 — 按 Step 统计 Token,按 Turn 汇总
  • 工具调用作用域 — 每个 tool/call 必须属于一个打开的 Step,而 Step 必须属于一个打开的 Turn

四种事件标记

双循环完全通过 session 日志中的四种事件类型来表达:

事件数据含义
turn/start{ turn: uint64 }新的 Turn 开始
turn/end{ turn: uint64, reason: TurnEndReason }当前 Turn 关闭
step/start{ turn: uint64, step: uint64 }当前 Turn 内新的 Step 开始
step/end{ turn: uint64, step: uint64 }当前 Step 关闭

还有第五种辅助事件:

事件数据含义
turn/stopping{ reason?: string }Turn 进入关闭流程(goal-round-driver 等监听器在此挂钩)
 1// pkg/session/session.go
 2type TurnStartData struct {
 3    Turn uint64 `json:"turn"`
 4}
 5
 6type TurnEndData struct {
 7    Turn   uint64        `json:"turn"`
 8    Reason TurnEndReason `json:"reason"`
 9}
10
11type StepStartData struct {
12    Turn   uint64 `json:"turn"`
13    Step   uint64 `json:"step"`
14    StepSeq uint64 `json:"stepSeq,omitempty"` // 旧字段别名
15}
16
17type StepEndData struct {
18    Turn   uint64 `json:"turn"`
19    Step   uint64 `json:"step"`
20    StepSeq uint64 `json:"stepSeq,omitempty"` // 旧字段别名
21}

严格单调编号

这是最重要的不变量——也是最容易被忽略的。

Turn 编号

  • Turn 从 0 开始编号
  • 每个 turn/start 必须携带 turn == nextTurn
  • turn/end 之后,nextTurn 递增 1
turn/start {turn: 0}  → nextTurn 是 0 ✓ → nextTurn 保持 0(turn 打开中)
turn/end   {turn: 0}  → 匹配 openTurn 0 ✓ → nextTurn 变为 1
turn/start {turn: 1}  → nextTurn 是 1 ✓
turn/start {turn: 0}  → ✗ 被拒绝:期望 turn 1,得到 0

Step 编号

  • Step 从 1 开始编号(不是 0)
  • 每个新的 Turn 会将 nextStep 重置为 1
  • 每个 step/start 必须携带 step == nextStep
  • step/end 之后,nextStep 递增 1
turn/start {turn: 0}  → nextStep 重置为 1
step/start {turn:0, step:1}  → nextStep 是 1 ✓ → nextStep 变为 2
step/end   {turn:0, step:1}  → 匹配 openStep 1 ✓
step/start {turn:0, step:2}  → nextStep 是 2 ✓
turn/end   {turn: 0}
turn/start {turn: 1}  → nextStep 再次重置为 1
step/start {turn:1, step:1}  → nextStep 是 1 ✓(重置生效)

为什么要严格?

严格单调编号有三个目的:

  1. 检测丢失的事件 — 如果 turn/end {turn: 3} 之后出现 turn/start {turn: 5},你就知道 Turn 4 丢失了(或者日志已损坏)
  2. 支持确定性重放 — 给定事件序列,你总能无歧义地重建循环状态
  3. 捕获生产者 Bug — 如果 Agent 忘记关闭 Step 就打开新的 Step,编号不匹配会立即暴露

嵌套规则

双循环有严格的嵌套层次:

turn/start
  ├─ (user/message)
  ├─ step/start
  │    ├─ (agent/request)
  │    ├─ (assistant/chunk...)
  │    ├─ (tool/call)
  │    ├─ (tool/result)
  │    └─ (assistant/message)
  ├─ step/end
  ├─ step/start  (可选:下一轮 LLM 调用)
  ├─ step/end
  └─ (turn/stopping)
turn/end

强制不变量

规则违规情况错误信息
turn/start 要求没有打开的 Turn两个 turn/start 之间没有 turn/endturn/start while turn already open
turn/end 要求有打开的 Turn没有 turn/start 就 turn/endturn/end without open turn/start
turn/end 要求没有打开的 StepStep 打开时就 turn/endturn/end while step still open
step/start 要求有打开的 TurnTurn 外就 step/startstep/start without open turn
step/start 要求没有打开的 Step两个 step/start 之间没有 step/endstep/start while step already open
step/end 要求有打开的 Step没有 step/start 就 step/endstep/end without open step/start
tool/call 要求有打开的 StepStep 外就 tool/calltool/call without open step
Turn 编号严格递增turn/end {turn: 0} 之后 turn/start {turn: 0}turn/start expected turn 1, got 0
Step 编号严格递增step/end {step: 1} 之后 step/start {step: 1}step/start expected step 2, got 1
Step 的 turn 匹配打开的 turn打开的 turn 是 0 时 step/start {turn: 5}step/start in turn 5 but open turn is 0

所有这些都在 SessionLog.applyState() 中强制执行——这是唯一的写入路径。没有任何事件能绕过这些检查。

TurnEndReason:完整词汇表

Turn 可以因六种原因关闭:

原因含义典型触发
completedTurn 正常解决Agent 产生了最终答案且没有工具调用
interruptedTurn 在执行中被中断上下文取消、用户中止
abortedTurn 被故意中止Agent.Cancel() 带取消原因
blockedTurn 被安全门阻止审批被拒绝、沙箱升级被拒绝
errorTurn 因内部错误失败LLM API 错误、工具执行 panic
max-tokensTurn 达到 Token 限制LLM 返回 finish_reason: length
 1// pkg/session/session.go
 2const (
 3    ReasonCompleted   TurnEndReason = "completed"
 4    ReasonFinished    TurnEndReason = "finished"    // 旧字段别名,等同于 completed
 5    ReasonInterrupted TurnEndReason = "interrupted"
 6    ReasonAborted     TurnEndReason = "aborted"
 7    ReasonBlocked     TurnEndReason = "blocked"
 8    ReasonError       TurnEndReason = "error"
 9    ReasonMaxTokens   TurnEndReason = "max-tokens"
10)

注意: finished 作为旧字段别名保留以保证向后兼容。新代码应使用 completed。

状态投影:查询循环状态

SessionLog 内部维护完整的循环状态,并暴露只读查询方法:

 1// 下一个 turn 编号(从 0 开始,每次 turn/end 后递增)
 2func (sl *SessionLog) NextTurn() uint64
 3
 4// 当前 turn 内下一个 step 编号(从 1 开始,每次 turn/start 时重置)
 5func (sl *SessionLog) NextStep() uint64
 6
 7// 当前打开的 turn(如果没有打开的 turn,返回 0, false)
 8func (sl *SessionLog) OpenTurn() (uint64, bool)
 9
10// 当前打开的 step(如果没有打开的 step,返回 0, false)
11func (sl *SessionLog) OpenStep() (uint64, bool)

Agent 如何使用它们

Agent 在创建 turn/start 事件之前查询 NextTurn(),确保编号始终正确:

 1// pkg/agent/agent.go — runTurn
 2func (a *Agent) runTurn(req *turnReq) {
 3    turnIdx := a.log.NextTurn()  // 查询当前编号
 4
 5    if _, err := a.log.Append(session.TurnStartData{Turn: turnIdx}); err != nil {
 6        return
 7    }
 8    // ... steps ...
 9    a.log.Append(session.TurnEndData{Turn: turnIdx, Reason: session.ReasonFinished})
10}

这种模式——追加前先查询——是确保严格单调编号的规范方式,无需在 Agent 中维护单独的计数器。

流式块组装:从分片到助手消息

一个 Step 内,模型输出是逐片到达的流。llm.BlockAssembler 是唯一权威的组装算法:一边接收分片、一边把原始分片写入日志,最后组装出完整的助手消息。

  • 连续的 text/reasoning 增量被合并,类型切换时固化为新块;
  • tool-call 分片到达时固化为工具使用块;
  • 输出严格保持流顺序,Message() 直接给出助手消息。

这样既保留了原始分片的回放保真度,又得到了可直接入史的结构化消息。组装/历史的规模由 tokenmeter.EstimateMessage 按固定密度(4 字符/token)估算,累计超阈值时触发压缩,形成"组装 → 估算 → 压缩"的闭环。

崩溃修复:孤儿 Turn

如果进程在 Turn 中途崩溃,持久化的日志将包含一个 turn/start 而没有匹配的 turn/end。持久层的 repairOrphanTurn 函数会检测到这种情况,并追加一个合成的 turn/end {reason: interrupted}:

 1// pkg/persistence/jsonl.go — repairOrphanTurn
 2func repairOrphanTurn(events *[]session.SessionEvent) int {
 3    // 扫描所有事件,判断末尾是否有打开的 turn
 4    turnOpen := false
 5    var openTurn uint64
 6    for _, ev := range *events {
 7        switch ev.Type {
 8        case session.EventTurnStart:
 9            turnOpen = true
10            if td, ok := ev.Data.(session.TurnStartData); ok {
11                openTurn = td.Turn  // 捕获 turn 编号
12            }
13        case session.EventTurnEnd:
14            turnOpen = false
15        }
16    }
17    if !turnOpen {
18        return 0
19    }
20    // 追加合成的 turn/end,携带正确的 turn 编号
21    last := (*events)[len(*events)-1]
22    repaired := session.SessionEvent{
23        Seq:  last.Seq + 1,
24        Time: last.Time,
25        Type: session.EventTurnEnd,
26        Data: session.TurnEndData{Turn: openTurn, Reason: session.ReasonInterrupted},
27    }
28    *events = append(*events, repaired)
29    return 1
30}

修复保留了正确的 turn 编号——它不是简单地追加一个裸的 turn/end。这确保修复后的日志仍然通过所有编号不变量。

与其他子系统的交互

工具流水线

每个 tool/call 和 tool/result 都必须发生在打开的 Step 内部。工具流水线本身不管理 Turn/Step——它依赖 Agent 在调用工具之前已经打开了适当的 Step。

审批

审批请求(approval/request、approval/decided)可以发生在 Step 内部。审批系统不强制 Turn/Step 嵌套——那是 session 不变量的职责。

持久化

JSONL 后端原样持久化所有 Turn/Step 事件。加载时,它运行 repairOrphanTurn 来修复任何崩溃导致的孤儿。分片/异步后端(H02)批量处理事件但保留顺序和编号。

取消

Agent.Cancel(cause) 记录取消原因,Turn 以 aborted 关闭(如果取消来自上下文则为 interrupted)。取消原因通过 ExtractCancelCause(events) 提取,扫描带有取消标记的 turn/stopping 事件。

好处与代价

好处

  • 确定性重放 — 任何日志都可以被重放以重建精确的循环状态
  • 早期 Bug 检测 — 编号不匹配在写入时就捕获生产者 Bug,而不是几小时后
  • 清晰的取消边界 — Turn 边界给出了自然的中止点,不会损坏状态
  • 精确的指标 — 按 Turn 和按 Step 的 Token/延迟统计变得微不足道
  • 崩溃可恢复 — 孤儿 Turn 可以被确定性地检测和修复

代价

  • 生产者复杂度 — 每个事件创建者都必须知道正确的 turn/step 编号
  • 不支持乱序写入 — 事件必须按严格的循环顺序追加;不能稍后"回填"一个 Step
  • 迁移负担 — 更改编号方案(例如 Step 从 0 而不是 1 开始)需要完整的日志迁移
  • 测试面 — 每个创建 Turn/Step 事件的测试都必须使用正确的编号,否则不变量会拒绝它们

源码对照

概念Go 实现官方 TypeScript
事件数据结构pkg/session/session.go — TurnStartData、TurnEndData、StepStartData、StepEndDatapackages/core/session/src/types.ts — SessionEventMap
TurnEndReasonpkg/session/session.go — TurnEndReason 常量块packages/core/session/src/types.ts — TurnEndReasonMap
配对与嵌套不变量pkg/session/session.go — applyState()packages/core/session/src/invariant.ts — validateEvent()
状态计数器pkg/session/session.go — sessionState.nextTurn/nextStep/openTurn/openSteppackages/core/session/src/invariant.ts — SessionTrace
查询方法pkg/session/session.go — NextTurn()、NextStep()、OpenTurn()、OpenStep()(官方未暴露;内部维护)
Agent 循环pkg/agent/agent.go — runTurn()、runStep()packages/core/agent/src/ — agent 循环
崩溃修复pkg/persistence/jsonl.go — repairOrphanTurn()packages/core/session/src/repair.ts
流式块组装pkg/llm/assembler.go — BlockAssemblerpackages/llm/llm/src/assembler.ts

下一步