OpenCode Compaction 上下文压缩 和 清理旧工具输出
OpenCode Compaction 上下文压缩 和 清理旧工具输出
1. 概述:为什么需要 Compaction?
大模型有上下文长度限制(如 Claude 的 200K tokens),当对话历史过长时:
- 无法继续对话:超过模型限制会导致 API 报错
- 成本增加:每次请求都发送完整历史,token 消耗巨大
- 效率降低:模型处理大量无关历史信息
Compaction 机制通过生成对话摘要来解决这些问题,将长对话压缩为简洁的上下文摘要。
2. 触发条件详解:完整的数据流转链路
理解 isOverflow 触发条件,需要追踪两个关键数据的完整流转:
- 模型限制 (
model.limit):从配置到使用的完整链路 - Token 统计 (
tokens):从 API 返回到判断溢出的完整链路
2.1 触发条件的完整逻辑
位置: src/session/prompt.ts:556-568
if (
lastFinished && // 条件1: 存在已完成的助手消息
lastFinished.summary !== true && // 条件2: 不是压缩摘要消息本身
(await SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model }))
) // 条件3: token 超出可用空间
{
await SessionCompaction.create({
sessionID,
agent: lastUser.agent,
model: lastUser.model,
auto: true, // 标记为自动压缩
})
continue // 跳过本轮,下轮循环会处理 compaction part
}
三个条件的含义:
| 条件 | 含义 | 为什么需要 |
|---|---|---|
lastFinished |
存在已完成的助手消息 | 需要有 token 统计数据才能判断 |
lastFinished.summary !== true |
不是压缩摘要消息 | 压缩消息本身不应该再触发压缩 |
isOverflow(...) |
token 超出可用空间 | 实际的溢出判断 |
注意:Token.estimate() 函数不用于判断溢出,只在 prune 函数中用于估算工具调用输出的 token 数。
2.2 完整调用链路图
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 第一步:模型限制数据的获取链路 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ models.dev API │
│ https://models.dev/api.json │
│ │ │
│ ▼ │
│ ModelsDev.get() [src/provider/models.ts:101-104]│
│ 解析 JSON,返回 Provider 列表 │
│ │ │
│ ▼ │
│ fromModelsDevModel() [src/provider/provider.ts:669-734]│
│ 提取 model.limit: { context, input, output } │
│ │ │
│ ▼ │
│ Provider.getModel(providerID, modelID) [获取特定模型] │
│ │ │
│ ▼ │
│ loop() 中的: [src/session/prompt.ts:350-361]│
│ const model = await Provider.getModel(lastUser.model.providerID, │
│ lastUser.model.modelID) [:350] │
│ │ │
│ ▼ │
│ isOverflow({ tokens, model }) [:559] │
│ └── model.limit.context 被使用 │
│ └── model.limit.input 被使用 │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 第二步:Token 统计数据的获取链路 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 模型 API 响应 │
│ Anthropic/OpenAI/etc. │
│ │ │
│ ▼ │
│ Vercel AI SDK: streamText() [src/session/llm.ts:176-260]│
│ 返回 StreamTextResult 对象 │
│ │ │
│ ▼ │
│ stream.fullStream [AI SDK 提供] │
│ 异步迭代器,产生各种事件(text-delta, tool-call, finish-step 等) │
│ │ │
│ ▼ │
│ processor.process() 循环 [src/session/processor.ts:55]│
│ for await (const value of stream.fullStream) │
│ │ │
│ ▼ │
│ case "finish-step": [:244-285] │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ const usage = Session.getUsage({ │ │
│ │ model: input.model, │ │
│ │ usage: value.usage, // ← 来自 AI SDK 的原始 usage 数据 │ │
│ │ metadata: value.providerMetadata │ │
│ │ }) │ │
│ │ │ │
│ │ input.assistantMessage.tokens = usage.tokens // ← 存入消息对象 │ │
│ │ await Session.updateMessage(input.assistantMessage) // 持久化 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 数据库 MessageTable │
│ assistantMessage.tokens 被持久化 │
│ │ │
│ ▼ │
│ 下轮 loop() 循环 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ msgs = await MessageV2.filterCompacted(MessageV2.stream()) │ │
│ │ // 从数据库读取消息历史,包含 tokens 字段 │ │
│ │ │ │
│ │ for (let i = msgs.length - 1; i >= 0; i--) { │ │
│ │ if (!lastFinished && msg.info.role === "assistant" && │ │
│ │ msg.info.finish) │ │
│ │ lastFinished = msg.info // ← 包含 .tokens 属性 │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ isOverflow({ tokens: lastFinished.tokens, model }) [:559] │
│ └── tokens.total 被使用 │
│ └── tokens.input/output/cache 被使用 │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
在loop函数中,isOverflow的model参数是通过Provider.getModel返回的。
2.3 模型限制的配置和读取详解
配置源头:https://models.dev/api.json
OpenCode 启动时会从 models.dev 拉取模型数据库:
// src/provider/models.ts:87-99
export const Data = lazy(async () => {
const file = Bun.file(Flag.OPENCODE_MODELS_PATH ?? filepath)
const result = await file.json().catch(() => {})
if (result) return result // 优先使用本地缓存
// ...
const json = await fetch(`${url()}/api.json`).then((x) => x.text())
return JSON.parse(json)
})
数据结构(models.dev 返回):
// src/provider/models.ts:52-56
limit: z.object({
context: z.number(), // 总上下文限制,如 200000
input: z.number().optional(), // 输入限制(部分模型有单独限制)
output: z.number(), // 输出限制,如 8192
})
转换到内部格式:
// src/provider/provider.ts:701-705
limit: {
context: model.limit.context,
input: model.limit.input,
output: model.limit.output,
}
使用位置:在 loop() 函数中通过 lastUser.model 获取:
// src/session/prompt.ts:350-361
const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID)
// model.limit 现在可用
2.4 Token 统计的获取详解
原始来源:模型 API 的响应
各模型提供商(Anthropic、OpenAI 等)在响应中返回 usage 字段:
{
"usage": {
"input_tokens": 50000,
"output_tokens": 3000,
"cache_read_input_tokens": 10000,
"cache_creation_input_tokens": 5000
}
}
Vercel AI SDK 封装:
AI SDK 将这些数据标准化为 LanguageModelV2Usage 类型,并在 finish-step 事件中提供:
// AI SDK 内部类型
interface LanguageModelV2Usage {
inputTokens?: number
outputTokens?: number
reasoningTokens?: number
cachedInputTokens?: number
totalTokens?: number
}
OpenCode 处理:Session.getUsage() 函数将原始 usage 转换为标准格式
// src/session/index.ts:682-758
export const getUsage = fn(
z.object({
model: z.custom<Provider.Model>(),
usage: z.custom<LanguageModelV2Usage>(),
metadata: z.custom<ProviderMetadata>().optional(),
}),
(input) => {
const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
const cacheWriteInputTokens = safe(
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? 0
)
// 计算总 token
const total = iife(() => {
if (input.model.api.npm === "@ai-sdk/anthropic" || ...) {
return adjustedInputTokens + outputTokens + cacheReadInputTokens + cacheWriteInputTokens
}
return input.usage.totalTokens
})
return {
cost: /* 根据定价计算 */,
tokens: {
total,
input: adjustedInputTokens,
output: outputTokens,
reasoning: reasoningTokens,
cache: {
write: cacheWriteInputTokens,
read: cacheReadInputTokens,
},
},
}
}
)
存储到消息:
// src/session/processor.ts:250-263
input.assistantMessage.finish = value.finishReason
input.assistantMessage.cost += usage.cost
input.assistantMessage.tokens = usage.tokens // ← 存入消息
// ...
await Session.updateMessage(input.assistantMessage) // ← 持久化到数据库
从历史中读取:
// src/session/prompt.ts:317-322
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i]
// ...
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish)
lastFinished = msg.info as MessageV2.Assistant // ← 包含 .tokens
}
2.5 isOverflow 函数详解
现在我们理解了数据来源,再看 isOverflow 函数就清晰了:
// src/session/compaction.ts:32-48
export async function isOverflow(input: {
tokens: MessageV2.Assistant["tokens"] // ← 来自 lastFinished.tokens
model: Provider.Model // ← 来自 Provider.getModel()
}) {
const config = await Config.get()
if (config.compaction?.auto === false) return false // 用户禁用了自动压缩
// 1. 获取模型的总上下文限制
const context = input.model.limit.context // 如 200000
if (context === 0) return false // 某些模型没有限制
// 2. 计算当前已使用的 token 总数
const count =
input.tokens.total || // 优先使用 total
input.tokens.input + input.tokens.output +
input.tokens.cache.read + input.tokens.cache.write // 否则累加各部分
// 3. 计算需要保留的缓冲空间
const reserved = config.compaction?.reserved ??
Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
// 默认保留 min(20000, 最大输出tokens) 作为缓冲
// 4. 计算总共可用的输入空间上限
const usable = input.model.limit.input
? input.model.limit.input - reserved // 如果有单独的输入限制
: context - ProviderTransform.maxOutputTokens(input.model) // 否则用总限制减输出
// 5. 判断是否溢出
return count >= usable // 当前使用量 >= 可用空间 → 需要压缩
}
关键变量含义:
const count = input.tokens.total || ... // count: 当前已使用的 token 总数
const usable = ... // usable: 可用输入空间的上限(阈值)
return count >= usable // 已使用量 >= 上限 → 需要压缩
注意:usable 是"总共可用的输入空间上限",不是"剩余可用空间"。
| 变量 | 含义 | 类比 |
|---|---|---|
count |
当前已使用量 | 水桶里已有的水量 |
usable |
可用空间上限 | 水桶的安全容量线 |
usable - count |
剩余可用空间 | 还能装多少水 |
2.5.1 为什么需要保留缓冲空间?
核心原因:如果输入 tokens + 预期输出 tokens > 模型上下文限制,大模型 API 会直接报错(如 context_length_exceeded),导致对话中断。
所以必须在接近边界之前主动压缩,为输出预留空间。
两种模型限制模式:
不同模型对上下文的限制方式不同,因此计算 usable 的逻辑也不同:
| 模式 | 限制方式 | 例子 | 特点 |
|---|---|---|---|
| 情况1 | 输入/输出分别限制 | input=180K, output=8K | 两个独立限制 |
| 情况2 | 总上下文限制 | context=200K | 输入+输出共享池子 |
const usable = input.model.limit.input
? input.model.limit.input - reserved // 情况1:有单独的输入限制
: context - ProviderTransform.maxOutputTokens(input.model) // 情况2:无单独限制
情况1详解:模型有单独的输入限制
input.limit = 180000 (输入最多 180K)
output.limit = 8192 (输出最多 8K)
为什么用 input.limit - reserved?
| 计算方式 | usable 值 | 触发压缩时机 |
|---|---|---|
| 不减 reserved | 180000 | count >= 180000 时 |
| 减 reserved (20000) | 160000 | count >= 160000 时 |
减 reserved 的目的:在真正达到输入限制之前就压缩,留出安全余量:
- 新消息可能随时进来
- 避免"刚好踩线"的风险
- Token 计数可能有误差
情况2详解:模型只有总上下文限制
context = 200000 (输入+输出 总共 200K)
output.limit = 8192
为什么用 context - maxOutputTokens 而不再减 reserved?
因为 maxOutputTokens 本身已经是一个"安全"的缓冲值,它代表了模型最大能输出的 token 数,足够容纳任何合法的输出。
总上下文空间 (context: 200000)
├── 可用输入空间 = context - maxOutputTokens (如 200000 - 8192 = 191808)
│ └── 当 count >= 191808 时触发压缩
└── 输出预留空间 (maxOutputTokens: 8192)
└── 确保模型有足够空间生成响应
两种情况的本质相同:都是在"边界之前"触发压缩,只是计算边界的方式因模型限制模式不同而异。
3. Compaction 创建流程
3.1 SessionCompaction.create 函数
位置: src/session/compaction.ts:231-260
export const create = fn(
z.object({
sessionID: Identifier.schema("session"),
agent: z.string(),
model: z.object({ providerID: z.string(), modelID: z.string() }),
auto: z.boolean(),
}),
async (input) => {
// 1. 创建用户消息
const msg = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
model: input.model,
sessionID: input.sessionID,
agent: input.agent,
time: { created: Date.now() },
})
// 2. 创建 compaction part
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: msg.id,
sessionID: msg.sessionID,
type: "compaction",
auto: input.auto, // 记录是否自动压缩
})
},
)
执行结果:
- 向
message表插入一条用户消息 - 向
part表插入一条compaction类型的 part
3.2 循环如何获取 compaction 数据
continue 后,下轮循环开始时:
let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
MessageV2.stream() 从数据库读取所有消息(包括刚插入的 compaction 消息),然后 filterCompacted 过滤已压缩的历史。
3.3 tasks 数组的收集
位置: src/session/prompt.ts:315-328
let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i]
// ... 查找 lastUser, lastAssistant, lastFinished
// 收集未完成前的所有 compaction/subtask parts
const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
if (task && !lastFinished) {
tasks.push(...task)
}
}
const task = tasks.pop() // 取最新一个待处理任务
3.4 compaction 的两种创建方式
所有 compaction part 都通过 SessionCompaction.create 插入,但有两种触发方式:
| 方式 | 触发位置 | auto 值 |
触发条件 |
|---|---|---|---|
| 自动压缩 | prompt.ts:561 或 prompt.ts:721 |
true |
token 溢出(isOverflow 返回 true) |
| 手动压缩 | session.ts:530 |
false(默认) |
用户调用 /compact 命令或 API |
手动压缩的 API 入口:
位置:src/server/routes/session.ts:508-542
.post(
"/:sessionID/summarize",
validator("json", z.object({
providerID: z.string(),
modelID: z.string(),
auto: z.boolean().optional().default(false), // ← 默认 false
})),
async (c) => {
const body = c.req.valid("json")
await SessionCompaction.create({
sessionID,
agent: currentAgent,
model: { providerID: body.providerID, modelID: body.modelID },
auto: body.auto, // ← 用户传入,默认 false
})
await SessionPrompt.loop({ sessionID })
}
)
auto 参数对后续处理的影响:
auto 值 |
压缩完成后的行为 |
|---|---|
true |
创建 "Continue if you have next steps..." 合成消息,模型自动继续 |
false |
不创建合成消息,直接返回,等待用户下一步输入 |
4. Compaction 处理流程
4.1 处理入口
位置: src/session/prompt.ts:543-553
if (task?.type === "compaction") {
const result = await SessionCompaction.process({
messages: msgs,
parentID: lastUser.id,
abort,
sessionID,
auto: task.auto, // 从 part 中读取 auto 标记
})
if (result === "stop") break
continue
}
4.2 task 的含义
task = tasks.pop():取出最新一条待处理的 part(compaction 或 subtask)task.auto:标记这是自动触发还是手动触发的压缩true:由isOverflow自动触发false:用户手动调用/compact命令
4.3 为什么传递 msgs 而不是 task
SessionCompaction.process 需要:
- 完整的历史消息
msgs:用于生成对话摘要 - 不需要 task 参数:因为
parentID已经标识了触发压缩的用户消息
task 只是用于判断类型和读取 auto 标记,处理函数内部通过 parentID 找到对应的用户消息。
5. 压实函数 SessionCompaction.process 详解
5.1 compaction part 的产生和作用
什么时候产生 compaction part?
通过 SessionCompaction.create 创建,有两种触发方式:
| 方式 | 触发位置 | auto 值 |
触发条件 |
|---|---|---|---|
| 自动压缩 | prompt.ts:561 或 prompt.ts:721 |
true |
token 溢出(isOverflow 返回 true) |
| 手动压缩 | session.ts:530 |
false(默认) |
用户调用 /compact 命令或 API |
compaction part 的作用是什么?
compaction part 是一个标记/信号,告诉 loop 循环"这里需要执行压缩操作"。
完整流程:
SessionCompaction.create()
→ 插入 compaction part 到数据库
→ continue 跳过本轮
→ 下轮循环读取消息
→ 检测到 compaction part
→ 调用 SessionCompaction.process() 生成摘要
5.2 函数签名
位置: src/session/compaction.ts:101-229
export async function process(input: {
parentID: string // 触发压缩的用户消息 ID
messages: MessageV2.WithParts[] // 完整历史消息
sessionID: string
abort: AbortSignal
auto: boolean // 是否自动压缩
})
5.3 compacting.prompt 和 compacting.context 的生成
// 1. 允许插件注入或修改压缩提示词
const compacting = await Plugin.trigger(
"experimental.session.compacting",
{ sessionID: input.sessionID },
{ context: [], prompt: undefined }, // 默认值
)
// 2. 默认提示词模板
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
Focus on information that would be helpful for continuing the conversation...
When constructing the summary, try to stick to this template:
---
## Goal
[What goal(s) is the user trying to accomplish?]
## Instructions
[What important instructions did the user give you...]
## Discoveries
[What notable things were learned...]
## Accomplished
[What work has been completed...]
## Relevant files / directories
[Construct a structured list of relevant files...]
---`
// 3. 合并提示词
const promptText = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
生成逻辑:
- 如果插件提供了
prompt,使用插件的 - 否则使用
defaultPrompt+ 插件提供的context数组
5.4 压缩摘要的生成
// 1. 找到触发压缩的用户消息
const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info
// 2. 获取 compaction agent
const agent = await Agent.get("compaction")
// 3. 创建助手消息(用于存放摘要)
const msg = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "assistant",
parentID: input.parentID,
sessionID: input.sessionID,
mode: "compaction",
agent: "compaction",
summary: true, // ★ 关键:标记为压缩摘要消息
// ...
})
// 4. 调用模型生成摘要
const processor = SessionProcessor.create({ assistantMessage: msg, ... })
const result = await processor.process({
user: userMessage,
agent,
messages: [
...MessageV2.toModelMessages(input.messages, model), // 历史消息
{ role: "user", content: [{ type: "text", text: promptText }] }, // 压缩提示
],
tools: {}, // 压缩时不使用工具
system: [],
model,
})
5.5 result === "continue" 的情况
processor.process() 返回 "continue" 的条件:
位置: src/session/processor.ts:412-415
if (needsCompaction) return "compact"
if (blocked) return "stop"
if (input.assistantMessage.error) return "stop"
return "continue" // ★ 默认返回 continue
返回 "continue":模型正常完成摘要生成,没有错误,没有被阻止。
5.6 result === "continue" && input.auto 的处理
if (result === "continue" && input.auto) {
// 1. 创建新的用户消息
const continueMsg = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: input.sessionID,
agent: userMessage.agent,
model: userMessage.model,
time: { created: Date.now() },
})
// 2. 创建文本 part
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
synthetic: true, // ★ 标记为合成消息
text: "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.",
time: { start: Date.now(), end: Date.now() },
})
}
"stop and ask for clarification" 如何工作?
这段文本不是直接添加到提示词,而是通过插入一条合成的用户消息到数据库,下轮循环读取后发送给模型。
添加位置:src/session/compaction.ts:202-224
if (result === "continue" && input.auto) {
// 1. 创建一条新的用户消息
const continueMsg = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: input.sessionID,
agent: userMessage.agent,
model: userMessage.model,
})
// 2. 创建一个 text part,标记为合成消息
await Session.updatePart({
messageID: continueMsg.id,
type: "text",
synthetic: true, // ← 标记为合成消息(不是用户真实输入)
text: "Continue if you have next steps, or stop and ask for clarification...",
})
}
完整数据流程:
compaction 完成摘要生成 (result === "continue")
↓
创建合成用户消息,内容是 "Continue if you have next steps..."
↓
持久化到数据库 (MessageTable + PartTable)
↓
下一轮 loop 循环
↓
MessageV2.filterCompacted() 读取消息历史
↓
包含这条合成消息
↓
MessageV2.toModelMessages() 转换
↓
发送给模型
模型看到的对话历史:
[
{ "role": "user", "content": [...] }, // 触发压缩的用户消息(有 compaction part)
{ "role": "assistant", "content": "## Goal\n..." }, // 压缩摘要(summary: true)
{ "role": "user", "content": "Continue if you have next steps, or stop and ask for clarification..." } // 合成消息
]
模型根据提示决定:
- 继续执行下一步
- 或停下来询问用户(通过 question 工具或直接
finish: "stop")
不是强制暂停,而是让模型自己判断是否需要用户确认。
5.7 updateMessage 和 updatePart 的行为
位置: src/session/index.ts:581-601 和 src/session/index.ts:646-666
// 使用 SQLite 的 UPSERT 语法
db.insert(MessageTable)
.values({...})
.onConflictDoUpdate({ target: MessageTable.id, set: { data } })
.run()
行为:
- 新 ID:插入新记录
- 已存在的 ID:更新现有记录
在 compaction 中的使用:
- 创建的压缩摘要消息使用新 ID,所以是插入
- 后续循环通过
MessageV2.stream()读取到这些新数据
5.8 processor.message.error 什么时候不为空
位置: src/session/processor.ts:350-377
} catch (e: any) {
const error = MessageV2.fromError(e, { providerID: input.model.providerID })
// 如果不是可重试的错误
const retry = Session_retry.retryable(error)
if (retry !== undefined) {
// 重试逻辑...
continue
}
// 记录错误
input.assistantMessage.error = error
Bus.publish(Session.Event.Error, { sessionID, error })
SessionStatus.set(sessionID, { type: "idle" })
}
// 返回检查
if (input.assistantMessage.error) return "stop"
error 不为空的情况:
- API 调用失败:网络错误、认证错误、速率限制等
- 上下文溢出:
ContextOverflowError - 输出长度超限:
OutputLengthError - 用户中止:
AbortedError - 其他不可重试的错误
可重试的错误不会导致 stop,会自动重试。
6. 压实的是什么数据
6.1 压实的粒度
压实的是对话历史,通过 Message 的 summary 字段标记分界点。
不是删除或修改原始数据,而是:
- 创建新的摘要消息(
summary: true的助手消息) filterCompacted读取时截断历史
6.2 filterCompacted 处理压实数据
位置: src/session/message-v2.ts:794-813
export async function filterCompacted(stream: AsyncIterable<MessageV2.WithParts>) {
const result = [] as MessageV2.WithParts[]
const completed = new Set<string>()
for await (const msg of stream) {
result.push(msg)
// 情况1: 遇到 compaction part,且其父消息已完成 → 停止收集
if (
msg.info.role === "user" &&
completed.has(msg.info.id) &&
msg.parts.some((part) => part.type === "compaction")
)
break
// 情况2: 助手消息有 summary 和 finish → 标记父消息完成
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish)
completed.add(msg.info.parentID)
}
result.reverse()
return result
}
工作流程:
- 倒序遍历消息(最新在前)
- 遇到
summary: true && finish的助手消息 → 记录其parentID - 遇到用户消息有
compactionpart 且parentID已完成 → 停止 - 反转返回(时间正序)
6.3 压实后的数据结构
压缩前的历史(被截断,不发送给模型):
┌─────────────────────────────────────────┐
│ User: "帮我实现功能A" │
│ Assistant: "好的,我来..." │
│ User: "继续" │
│ Assistant: "功能A已完成..." │
│ ...(大量历史) │
└─────────────────────────────────────────┘
压缩后发送给模型的历史:
┌─────────────────────────────────────────┐
│ User: [compaction part] │
│ Assistant (summary: true): │
│ "## Goal │
│ 实现功能A │
│ ## Accomplished │
│ 功能A已完成... │
│ ## Next │
│ 需要继续实现功能B..." │
│ User (synthetic): │
│ "Continue if you have next steps..." │
└─────────────────────────────────────────┘
6.4 为什么需要 summary 和 finish 两个条件
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish)
completed.add(msg.info.parentID)
summary: true:标记这是压缩摘要消息finish:确保模型已经完成摘要生成(不是中断的)
两个条件都满足,才认为这个压缩点是有效的,可以截断历史。
finish 字段何时被设置?
位置:src/session/processor.ts:250
case "finish-step":
// ...
input.assistantMessage.finish = value.finishReason // ← 从 AI SDK 获取
// ...
await Session.updateMessage(input.assistantMessage) // 持久化到数据库
数据流程:
模型 API 响应
↓
AI SDK (streamText) 返回 finishReason
↓
finish-step 事件触发
↓
processor.process() 处理
↓
assistantMessage.finish = value.finishReason
↓
Session.updateMessage() 持久化
finishReason 的可能值:
| 值 | 含义 |
|---|---|
"stop" |
正常结束(没有工具调用) |
"tool-calls" |
模型调用了工具 |
"length" |
达到最大 token 限制 |
"content-filter" |
内容被过滤 |
所以 finish 是在模型完成响应时由 API 返回并设置的,表示模型结束的原因。对于 compaction,通常 finish 值为 "stop"。
7. 完整的 Compaction 流程图
┌─────────────────────────────────────────────────────────────────────────┐
│ loop() 主循环 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 检查是否触发压缩 │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ if (lastFinished && │ │
│ │ lastFinished.summary !== true && │ │
│ │ isOverflow({ tokens, model })) │ │
│ │ { │ │
│ │ SessionCompaction.create({ auto: true }) │ │
│ │ continue │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 2. 下轮循环检测到 compaction part │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ if (task?.type === "compaction") │ │
│ │ SessionCompaction.process({ auto: task.auto }) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 3. process() 内部 │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ a. 创建 summary: true 的助手消息 │ │
│ │ b. 调用 compaction agent 生成摘要 │ │
│ │ c. 如果 auto,创建 "Continue..." 合成用户消息 │ │
│ │ d. return "continue" │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 4. 下轮循环 filterCompacted() 截断历史 │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 只返回: │ │
│ │ - 压缩摘要消息 │ │
│ │ - "Continue..." 用户消息 │ │
│ │ - 压缩后的新消息 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 5. 正常处理流程继续... │
│ │
└─────────────────────────────────────────────────────────────────────────┘
8. prune 函数:清理旧工具输出
位置: src/session/compaction.ts:58-99
除了压缩摘要,还有一个 prune 函数用于清理旧的工具调用输出:
export async function prune(input: { sessionID: string }) {
// 从后向前遍历,保留最近 40000 tokens 的工具输出
for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
if (part.type === "tool" && part.state.status === "completed") {
const estimate = Token.estimate(part.state.output)
total += estimate
if (total > PRUNE_PROTECT) { // 40000
toPrune.push(part)
}
}
}
}
// 标记为已压缩
for (const part of toPrune) {
part.state.time.compacted = Date.now()
await Session.updatePart(part)
}
}
清理的是什么?
| 维度 | 答案 |
|---|---|
| 操作级别 | Part 级别(tool 类型的 part) |
| 设置什么 | part.state.time.compacted 字段(时间戳) |
| 原始数据 | 保留在数据库中,不删除 |
| 实际效果 | 转换为模型消息时,用占位符替换输出内容 |
Session.updatePart 如何更新数据?
位置:src/session/index.ts:646-667
export const updatePart = fn(UpdatePartInput, async (part) => {
const { id, messageID, sessionID, ...data } = part // ← 解构,data 包含所有其他字段
Database.use((db) => {
db.insert(PartTable)
.values({
id,
message_id: messageID,
session_id: sessionID,
data, // ← 整个 data 对象被序列化存储
})
.onConflictDoUpdate({ target: PartTable.id, set: { data } })
.run()
})
})
...data 解构的作用:
const { id, messageID, sessionID, ...data } = part
id,messageID,sessionID→ 存到数据库的独立列- 剩余所有字段(
type,state,state.time.compacted等)→ 打包到data对象 data被序列化为 JSON 存储到数据库的data列
数据库存储结构:
PartTable:
├── id (列)
├── message_id (列)
├── session_id (列)
├── time_created (列)
└── data (列) ← JSON 字段,包含 { type, state: { time: { compacted: ... } } }
所以 updatePart 不需要逐个读取字段,而是把整个 part 对象(除了 ID 字段)序列化后直接存储。
实际清理发生在哪里?
位置:src/session/message-v2.ts:620-621
// 转换为模型消息时
const outputText = part.state.time.compacted
? "[Old tool result content cleared]" // 已清理:用占位符替换
: part.state.output // 未清理:原样输出
const attachments = part.state.time.compacted
? [] // 已清理:清空附件
: (part.state.attachments ?? []) // 未清理:原样保留
这是一种"软删除"机制:原始数据保留在数据库中,但在发送给模型时被替换为占位符,减少上下文 token 消耗。
与 compaction 的区别:
- compaction:生成对话摘要,通过
summary标记截断历史 - prune:清理旧工具输出,减少上下文中的冗余内容
9. 配置选项
{
"compaction": {
"auto": true, // 是否自动压缩(默认 true)
"reserved": 20000, // 保留的缓冲空间(默认 min(20000, maxOutputTokens))
"prune": true // 是否启用工具输出清理(默认 true)
}
}
10. 总结
| 问题 | 答案 |
|---|---|
| 模型限制在哪里配置 | models.dev 数据库 + 本地 opencode.json 覆盖 |
| 限制针对什么 | 整个对话历史,通过最近助手消息的 tokens 统计 |
| Token 如何计算 | API 返回的实际值,不是字符估算 |
| create 做什么 | 插入一条用户消息 + 一条 compaction part |
| 循环如何获取 | filterCompacted 从数据库读取,检测 compaction part |
| task 来自哪里 | 只有 create 插入,但有两种触发方式:自动(token 溢出)或手动(/compact 命令) |
| task.auto 含义 | true = 自动压缩,创建合成消息继续;false = 手动压缩,等待用户输入 |
| 为什么传 msgs | 需要完整历史来生成摘要 |
| prompt 如何生成 | 默认模板 + 插件可覆盖/扩展 |
| result === "continue" | 模型正常完成,无错误 |
| "stop and ask" | 发送给模型的提示,让模型决定是否需要用户确认 |
| update 行为 | Upsert(插入或更新),compaction 中是插入新数据 |
| error 何时非空 | API 错误、中止、溢出等不可恢复错误 |
| 压实什么 | Message(通过 summary 标记),不删除原始数据 |
| 压实后效果 | filterCompacted 只返回压缩点之后的历史 |

浙公网安备 33010602011771号