OpenCode 源码解析
第 18 讲:Session 处理器——Prompt 编排、Compaction 与工具执行循环
基于 dev 分支源码 · 2026-07-26
一、Session 模块:OpenCode 的 AI 大脑
前十七讲覆盖了 OpenCode 从 CLI 到事件总线的核心能力。这一讲深入Session 会话模块——OpenCode 的 AI 大脑,负责 Prompt 编排、LLM 流式调用、工具执行循环和上下文压缩。Session 模块横跨 21 个文件、约 7,129 行代码,是整个系统最复杂的业务逻辑层。
📦 本讲核心文件
session/prompt.ts(1630 行)— Prompt 编排与会话入口
session/processor.ts(716 行)— LLM 流式事件处理器
session/compaction.ts(562 行)— 上下文压缩引擎
session/session.ts(1018 行)— 会话 CRUD 与 Token 计费
二、SessionProcessor:12 种流式事件的管道
processor.ts(716 行)是 Session 的核心事件处理器。LLM 的流式响应被拆解为 12 种事件类型,每个事件触发对应的状态更新。
1. ProcessorContext:处理器上下文
📄 session/processor.ts (第 67-75 行)
interface ProcessorContext extends Input {
toolcalls: Record<string, ToolCall> // 活跃工具调用表
shouldBreak: boolean // 是否中断循环
snapshot: string | undefined // 文件系统快照
blocked: boolean // 是否被权限阻塞
needsCompaction: boolean // 是否需要压缩
currentText: SessionV1.TextPart | undefined
reasoningMap: Record<string,
SessionV1.ReasoningPart> // 推理过程映射
}
// 工具调用句柄
type ToolCall = {
partID: SessionV1.ToolPart["id"]
messageID: SessionV1.ToolPart["messageID"]
sessionID: SessionV1.ToolPart["sessionID"]
done: Deferred.Deferred<void> // 完成信号
}
上下文设计:ProcessorContext 维护了整个 LLM 处理过程的状态。toolcalls 是一个活跃工具调用表,每个工具调用都有一个 Deferred 完成信号。reasoningMap 跟踪推理过程的增量更新。snapshot 记录文件系统状态,用于 step-finish 时生成 diff。
2. handleEvent:事件分发器
📄 session/processor.ts (第 276-509 行)
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
switch (value.type) {
// --- 推理事件 ---
case "reasoning-start":
// 创建推理 Part,记录开始时间
ctx.reasoningMap[value.id] = {
id: PartID.ascending(),
type: "reasoning", text: "",
time: { start: Date.now() },
}
yield* session.updatePart(ctx.reasoningMap[value.id])
return
case "reasoning-delta":
// 增量更新推理文本(不重建整个 Part)
ctx.reasoningMap[value.id].text += value.text
yield* session.updatePartDelta({
field: "text", delta: value.text,
})
return
case "reasoning-end":
yield* finishReasoning(value.id)
return
// --- 工具输入事件 ---
case "tool-input-start":
case "tool-input-delta":
case "tool-input-end":
if (ctx.assistantMessage.summary) {
throw new Error(
`Tool call not allowed while generating summary: ${value.name}`)
}
yield* ensureToolCall(value)
return
// --- 工具调用事件 ---
case "tool-call":
yield* ensureToolCall(value)
yield* updateToolCall(value.id, (match) => ({
...match,
state: { status: "running",
input, time: { start: Date.now() } }
}))
// 死循环检测(见下文)
return
// --- 工具结果事件 ---
case "tool-result":
const rawOutput = toolResultOutput(value)
// 图片附件自动 resize
const normalized = yield* Effect.forEach(
rawOutput.attachments ?? [], (attachment) =>
attachment.mime.startsWith("image/")
? image.normalize(attachment)
: Effect.succeed(Exit.succeed(attachment)))
yield* completeToolCall(value.id, output)
return
// --- 步骤事件 ---
case "step-start":
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
yield* session.updatePart({ type: "step-start" })
return
case "step-finish":
// 计算 token 用量和成本
const usage = Session.getUsage({
model: ctx.model, usage: value.usage,
metadata: value.providerMetadata,
})
ctx.assistantMessage.cost += usage.cost
ctx.assistantMessage.tokens = usage.tokens
// 生成文件 diff
if (ctx.snapshot) {
const patch = yield* snapshot.patch(ctx.snapshot)
if (patch.files.length) {
yield* session.updatePart({
type: "patch", hash: patch.hash,
files: patch.files,
})
}
}
// 检查是否需要压缩
if (isOverflow({ cfg, tokens: usage.tokens, model })) {
ctx.needsCompaction = true
}
return
}
})
事件管道详解:handleEvent 是一个 12 种事件类型的 switch 分发器。推理事件(reasoning-start/delta/end)跟踪模型思考过程;工具事件(tool-input/tool-call/tool-result/tool-error)管理工具生命周期;步骤事件(step-start/step-finish)标记一轮 LLM 调用的边界;文本事件(text-start/text-delta)处理流式文本输出。
三、死循环检测:DOOM_LOOP_THRESHOLD 机制
当 LLM 连续 3 次调用相同工具且参数完全相同时,触发死循环检测。
检测逻辑
📄 session/processor.ts (第 354-378 行)
const DOOM_LOOP_THRESHOLD = 3
// 在 tool-call 事件中检测
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
if (
recentParts.length !== DOOM_LOOP_THRESHOLD ||
!recentParts.every(
(part) =>
part.type === "tool" &&
part.tool === value.name && // 相同工具
part.state.status !== "pending" &&
JSON.stringify(part.state.input) === // 相同参数
JSON.stringify(input)
)
) {
return // 未触发死循环
}
// 触发死循环:请求用户授权
const agent = yield* agents.get(ctx.assistantMessage.agent)
yield* permission.ask({
permission: "doom_loop",
patterns: [value.name],
sessionID: ctx.assistantMessage.sessionID,
metadata: { tool: value.name, input },
always: [value.name],
ruleset: agent.permission,
})
检测条件:最近 3 个 Part 必须都是 tool 类型、相同工具名、非 pending 状态、输入参数 JSON 完全一致。四个条件同时满足才触发。触发后通过 Permission 服务请求用户授权,用户可以选择 Allow Always 或拒绝。
四、Compaction:上下文压缩引擎
compaction.ts(562 行)实现了三级压缩策略:Prune(工具输出清理)→ Select(消息选择)→ Summarize(LLM 摘要)。
1. 压缩参数矩阵
📄 session/compaction.ts (第 28-34 行)
export const PRUNE_MINIMUM = 20_000 // 清理阈值:至少省 20K token
export const PRUNE_PROTECT = 40_000 // 保护阈值:保留最近 40K token
const TOOL_OUTPUT_MAX_CHARS = 2_000 // 工具输出截断到 2K 字符
const PRUNE_PROTECTED_TOOLS = ["skill"] // skill 工具输出受保护
const DEFAULT_TAIL_TURNS = 2 // 默认保留最后 2 轮对话
const MIN_PRESERVE_RECENT_TOKENS = 2_000 // 最小保留 2K token
const MAX_PRESERVE_RECENT_TOKENS = 8_000 // 最大保留 8K token
预算计算:保留预算 = min(8000, max(2000, 可用窗口 × 0.25))。即模型可用上下文窗口的 25%,范围在 2K-8K token 之间。
2. Turn 分割算法
📄 session/compaction.ts (第 87-128 行)
// 将消息按用户消息分割成 Turn
function turns(messages: SessionV1.WithParts[]) {
const result: Turn[] = []
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.info.role !== "user") continue
// 跳过已压缩的消息
if (msg.parts.some((part) => part.type === "compaction")) continue
result.push({ start: i, end: messages.length, id: msg.info.id })
}
// 计算每个 Turn 的结束边界
for (let i = 0; i < result.length - 1; i++) {
result[i].end = result[i + 1].start
}
return result
}
// 在预算约束下找到分割点
function splitTurn(input) {
return Effect.gen(function* () {
if (input.budget <= 0) return undefined
if (input.turn.end - input.turn.start <= 1) return undefined
// 线性扫描找到预算内的分割点
for (let start = input.turn.start + 1; start < input.turn.end; start++) {
const size = yield* input.estimate({
messages: input.messages.slice(start, input.turn.end),
model: input.model,
})
if (size > input.budget) continue
return { start, id: input.messages[start]!.info.id }
}
return undefined
})
}
算法流程:turns() 将消息按用户消息分割成 Turn 数组,每个 Turn 包含一个用户消息和对应的助手回复。splitTurn() 在预算约束下线性扫描找到最佳分割点——从 Turn 末尾往前找,找到第一个 token 数在预算内的分割位置。
3. Prune 工具输出清理
📄 session/compaction.ts (第 243-287 行)
const prune = Effect.fn("SessionCompaction.prune")(
function* (input: { sessionID: SessionID }) {
const cfg = yield* config.get()
if (!cfg.compaction?.prune) return
yield* Effect.logInfo("pruning")
const msgs = yield* session.messages({ sessionID: input.sessionID })
let total = 0, pruned = 0, turns = 0
const toPrune: SessionV1.ToolPart[] = []
// 从后往前遍历
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "user") turns++
if (turns < 2) continue // 保护最近 2 轮
if (msg.info.role === "assistant" && msg.info.summary) break loop // 遇到摘要停止
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
if (part.type !== "tool") continue
if (part.state.status !== "completed") continue
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue // 保护 skill
if (part.state.time.compacted) break loop // 已压缩过
const estimate = Token.estimate(part.state.output)
total += estimate
if (total <= PRUNE_PROTECT) continue // 保留 40K
pruned += estimate
toPrune.push(part)
}
}
// 只有当清理量 > 20K token 时才执行
if (pruned > PRUNE_MINIMUM) {
for (const part of toPrune) {
part.state.time.compacted = Date.now()
yield* session.updatePart(part)
}
}
})
Prune 策略详解:从后往前遍历消息,保护最近 2 轮对话(turns < 2)、遇到摘要消息停止、保护 skill 工具输出。累积工具输出超过 40K token 后开始标记清理,只有当清理量超过 20K token 时才执行,避免频繁清理。
4. 压缩流程:Summarize + Auto-continue
📄 session/compaction.ts (第 343-509 行)
// 插件钩子:允许注入上下文或替换压缩 prompt
const compacting = yield* plugin.trigger(
"experimental.session.compacting",
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const nextPrompt = compacting.prompt ?? buildPrompt({
previousSummary, context: compacting.context
})
// 创建 compaction 模式的 Assistant 消息
const msg: SessionV1.Assistant = {
mode: "compaction", agent: "compaction",
summary: true, // 标记为摘要消息
// ...
}
yield* session.updateMessage(msg)
// 通过 Processor 调用 LLM 生成摘要
const processor = yield* processors.create({
assistantMessage: msg, sessionID: input.sessionID, model,
})
const result = yield* processor.process({
user: userMessage, agent, sessionID: input.sessionID,
tools: {}, system: [], // 压缩时不传工具
messages: [
...modelMessages, // 压缩后的历史消息
{ role: "user", content: [{ type: "text", text: nextPrompt }] },
],
model,
})
// 压缩完成后自动继续
if (result === "continue" && input.auto) {
if (replay) {
// Replay:重新发送被压缩的用户消息
const replayMsg = yield* session.updateMessage({
role: "user", sessionID: input.sessionID,
})
for (const part of replay.parts) {
if (part.type === "compaction") continue
yield* session.updatePart({ ...replayPart,
messageID: replayMsg.id,
})
}
} else {
// Auto-continue:发送继续指令
const text = "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
yield* session.updatePart({
type: "text", synthetic: true,
metadata: { compaction_continue: true },
text,
})
}
}
压缩流程详解:1) 插件钩子允许自定义压缩 prompt;2) 创建 compaction 模式的 Assistant 消息;3) 通过 Processor 调用 LLM 生成摘要(不传工具);4) 压缩完成后 replay 被压缩的用户消息或发送 auto-continue 指令。
五、Token 计费与成本计算
session.ts 中的 getUsage 函数实现了复杂的 Token 计费和成本计算,支持多层 Provider 的缓存 token 统计。
计费逻辑
📄 session/session.ts (第 338-406 行)
export function getUsage(input: {
model: Provider.Model; usage: Usage;
metadata?: ProviderMetadata
}) {
// 从多个 Provider 元数据中获取缓存 token
const cacheWriteInputTokens = safe(
input.usage.cacheWriteInputTokens ??
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? 0
)
// AI SDK v6 将缓存 token 计入了 inputTokens,需要减去
const adjustedInputTokens = safe(
inputTokens - cacheReadInputTokens - cacheWriteInputTokens)
// 成本计算:使用 Decimal 避免浮点精度问题
return {
cost: new Decimal(0)
.add(new Decimal(tokens.input)
.mul(costInfo?.input ?? 0).div(1_000_000))
.add(new Decimal(tokens.output)
.mul(costInfo?.output ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.read)
.mul(costInfo?.cache?.read ?? 0).div(1_000_000))
.add(new Decimal(tokens.cache.write)
.mul(costInfo?.cache?.write ?? 0).div(1_000_000))
.add(new Decimal(tokens.reasoning)
.mul(costInfo?.output ?? 0).div(1_000_000))
.toNumber(),
tokens: { input, output, reasoning,
cache: { read, write } },
}
}
计费设计:支持 Anthropic、Vertex、Bedrock、Venice 等多个 Provider 的缓存 token 统计。使用 Decimal.js 避免浮点精度问题。Reasoning token 按 output 价格计费。成本按每百万 token 计算。
六、总结
🔹 ProcessorContext 维护 7 个状态字段:toolcalls/shouldBreak/snapshot/blocked/needsCompaction/currentText/reasoningMap
🔹 handleEvent 处理 12 种事件:reasoning-start/delta/end、tool-input-start/delta/end、tool-call、tool-result、tool-error、step-start/finish、text-start/delta
🔹 DOOM_LOOP 检测:连续 3 次相同工具+相同参数 → 请求用户授权
🔹 Compaction 三级策略:Prune(40K/20K 阈值)→ Select(Turn 分割)→ Summarize(LLM 摘要)
🔹 Auto-continue:压缩完成后自动 replay 用户消息或发送继续指令
🔹 Token 计费:Decimal.js 精度 + 多 Provider 缓存 token 支持
🔹 21 个文件、7,129 行代码构成 OpenCode 最复杂的业务模块
← 系列导航 →
← 第 17 讲:Event 事件总线 | 第 19 讲:Agent 核心系统 →
关注公众号获取更多 OpenCode 源码解析干货
源码:https://github.com/opencode-ai/opencode
夜雨聆风