跳到主要内容

会话编排与安全护栏(SessionRuntime)

本章讲什么: 01 章的 AgentRuntime 是无状态的 —— 它不记得上一轮。那「记忆」和「别让 agent 失控」由谁管?答案是 SessionRuntime。这章讲它怎么把无状态内核包成有记忆的会话,以及两道护栏:循环检测(老调同一个工具)和连错熔断(连续犯错)。

1. 它要解决的小问题

AgentRuntime 每次 run() 都从给定消息开始、跑完就忘。但真实会话需要:

  1. 跨轮记忆 —— 用户第二句话要能接上第一句的上下文。
  2. 失控防护 —— agent 可能陷进死循环(反复 read_files 同一个文件),或连续犯错(每次工具都失败)。

SessionRuntime(sdk/packages/core/src/runtime/orchestration/session-runtime-orchestrator.ts:280)就是为这两件事存在的「每会话编排器」。

2. 核心思路:有状态的壳,无状态的核

关键设计(文件顶部注释写得很清楚,session-runtime-orchestrator.ts:1-20):所有跨轮状态活在 SessionRuntime 里,每一轮新造一个一次性的 AgentRuntime

SessionRuntime(活整个会话)
├─ ConversationStore 消息历史(跨轮)
├─ MistakeTracker 连续犯错计数器(跨轮)
├─ LoopDetectionTracker 重复工具调用检测器(跨轮)
├─ MessageBuilder 供应商消息组装缓存
└─ 每个 run ──▶ 新建 AgentRuntime(无状态)
把完整历史当 initialMessages 喂进去,跑完丢弃

为什么每轮新建? 注释里点明:让 OAuth 重试、run 重放变得可行 —— 因为内核不持有状态,重跑一遍是干净的。会话级状态「活得比任何一个 AgentRuntime 都久」。

executeRunInternal(session-runtime-orchestrator.ts:684)的主干:它先把用户消息存进 ConversationStore,然后把整个历史作为 initialMessages 传给新建的 runtime,跑完再用 runResult.messages 把对话存回去(session-runtime-orchestrator.ts:853)。注释还专门记了一个修过的 P1 bug:之前没传 seed 导致历史被覆盖丢失。

3. 护栏一:循环检测

问题: agent 有时会鬼打墙 —— 一模一样的工具调用连发好几次,白烧 token。

检测逻辑(LoopDetectionTracker,sdk/packages/core/src/runtime/safety/loop-detection.ts:125):给每个工具调用算一个「签名」(工具名 + 排序后序列化的参数,toolCallSignatureloop-detection.ts:50),数连续相同的次数:

// 示意,非源码 —— 提炼自 loop-detection.ts:66-87 checkRepeatedToolCall
if (toolName === last.name && signature === last.signature) {
count++; // 和上次一模一样 → 累加
} else {
count = 1; // 不一样 → 归零重数
}
return {
softWarning: count === softThreshold, // 默认 3:温柔提醒
hardEscalation: count >= hardThreshold, // 默认 5:硬熔断
};

两级反应(默认阈值 soft=3 / hard=5,loop-detection.ts:113):

级别触发反应
soft连续 3 次相同往对话里塞一句「检测到重复,换个思路试试」的恢复提示,不阻断
hard连续 ≥5 次相同喂给连错熔断器(forceAtLimit),触发 abort

4. 护栏二:连错熔断

问题: agent 可能连续犯错 —— 每一轮工具都失败,再转下去也是浪费。

计数逻辑(MistakeTracker,sdk/packages/core/src/runtime/safety/mistake-tracker.ts:74):维护 consecutiveMistakes 计数,到上限(默认 6,session-runtime-orchestrator.ts:406)就停。但停之前给一个逃生口 —— onLimitReached 回调可以裁决「继续(给条指导)还是停」(mistake-tracker.ts:110resolveConsecutiveMistakeDecision):

  • 裁决 continue + 有 guidance → 把指导塞进对话当恢复提示,计数清零,接着跑。
  • 裁决 stop(默认无回调时)→ 生成一句解释性停止消息(buildMistakeLimitStopMessage,mistake-tracker.ts:157),结束 run。

什么算一次「错」?turn-finished 时判定(session-runtime-orchestrator.ts:1092):当本轮有工具失败、且没有任何工具成功,记一次错;只要有一个工具成功就 reset() 清零 —— 避免无关的偶发失败累积。

5. 两道护栏怎么挂上去:事件 + 序列化队列

护栏不是写在内核循环里的,而是订阅内核事件挂上去的。SessionRuntime 订阅 AgentRuntime 的事件流(session-runtime-orchestrator.ts:812 runtime.subscribe),在 handleRuntimeEvent(:1008)里:

tool-started ──▶ inspectLoopForToolCall(...) 循环检测在这里跑
tool-finished ──▶ 记录成功/失败计数
turn-finished ──▶ 全失败无成功?→ enqueueMistakeRecord(...)

有个并发细节值得看:内核事件流是同步的,但 MistakeTracker.record()异步的。所以护栏的副作用被串进一个序列化的 promise 链 activeTrackerWork(session-runtime-orchestrator.ts:1242 enqueueMistakeRecord),executeRun 在返回结果前会 await 它排空(:836)。这样既保证顺序(legacy 行为),又能让一个迟到的 abort 仍然送达内核。当熔断决定 stop 时,它把停止消息塞进对话并调 activeRuntime?.abort(...)(:1253)。

6. 巧妙之处

  • 状态分层干净:无状态内核 + 有状态壳,使「每轮重建」成为可能,直接换来 OAuth 重试 / run 重放能力(:1-20 注释)。
  • 护栏是观察者,不是侵入:循环检测和连错熔断完全靠订阅事件实现,内核对它们一无所知 —— 想加新护栏只需再订阅一个事件。
  • 熔断带逃生口:不是粗暴地一到上限就死,onLimitReached 能注入「再给一次机会 + 指导」(mistake-tracker.ts:121)。
  • abort 防误杀守护进程:abort() 里那段长注释(:553-600)记录了一个真实坑 —— hub 模式下取消运行导致的 promise rejection 曾被误判为崩溃,用一个 .catch(() => {}) 安全观察者解决。

7. 边界与局限

  • 循环检测只看连续完全相同的调用 —— 参数稍有变化(哪怕语义一样)就不算重复,绕得过去。
  • 连错只在「整轮全失败」时计数,部分失败 + 部分成功不计 —— 这是为了避免误伤,但也意味着「一半工具一直失败」可能不触发熔断。
  • SessionRuntime 同一时刻只允许一个 run(canStartRun(),:449),并发请求要靠上层排队。

8. 代码地图

主题文件路径符号名
会话编排器sdk/packages/core/src/runtime/orchestration/session-runtime-orchestrator.tsSessionRuntimeexecuteRunInternal
事件处理sdk/packages/core/src/runtime/orchestration/session-runtime-orchestrator.tshandleRuntimeEventenqueueMistakeRecord
循环检测sdk/packages/core/src/runtime/safety/loop-detection.tsLoopDetectionTrackercheckRepeatedToolCalltoolCallSignature
连错熔断sdk/packages/core/src/runtime/safety/mistake-tracker.tsMistakeTrackerbuildMistakeLimitStopMessage
对话存储sdk/packages/core/src/session/stores/conversation-store.tsConversationStore