OpenCode Compaction 上下文压缩 和 清理旧工具输出

OpenCode Compaction 上下文压缩 和 清理旧工具输出

1. 概述:为什么需要 Compaction?

大模型有上下文长度限制(如 Claude 的 200K tokens),当对话历史过长时:

  1. 无法继续对话:超过模型限制会导致 API 报错
  2. 成本增加:每次请求都发送完整历史,token 消耗巨大
  3. 效率降低:模型处理大量无关历史信息

Compaction 机制通过生成对话摘要来解决这些问题,将长对话压缩为简洁的上下文摘要。

2. 触发条件详解:完整的数据流转链路

理解 isOverflow 触发条件,需要追踪两个关键数据的完整流转:

  1. 模型限制 (model.limit):从配置到使用的完整链路
  2. 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 的目的:在真正达到输入限制之前就压缩,留出安全余量:

  1. 新消息可能随时进来
  2. 避免"刚好踩线"的风险
  3. 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:561prompt.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 的含义

  1. task = tasks.pop():取出最新一条待处理的 part(compaction 或 subtask)
  2. 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:561prompt.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-601src/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 不为空的情况

  1. API 调用失败:网络错误、认证错误、速率限制等
  2. 上下文溢出ContextOverflowError
  3. 输出长度超限OutputLengthError
  4. 用户中止AbortedError
  5. 其他不可重试的错误

可重试的错误不会导致 stop,会自动重试。

6. 压实的是什么数据

6.1 压实的粒度

压实的是对话历史,通过 Message 的 summary 字段标记分界点。

不是删除或修改原始数据,而是:

  1. 创建新的摘要消息summary: true 的助手消息)
  2. 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
}

工作流程

  1. 倒序遍历消息(最新在前)
  2. 遇到 summary: true && finish 的助手消息 → 记录其 parentID
  3. 遇到用户消息有 compaction part 且 parentID 已完成 → 停止
  4. 反转返回(时间正序)

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 只返回压缩点之后的历史
posted @ 2026-02-19 21:53  不歪歪  阅读(2418)  评论(0)    收藏  举报