Session Memory Compaction(会话记忆压缩)机制分析

6 阅读9分钟

源码:src/services/compact/sessionMemoryCompact.ts(631 行) 导出函数:trySessionMemoryCompaction,被 autoCompactIfNeeded 作为路径 A 优先调用


1. 概述

Session Memory Compaction 是 autocompact 的第一种尝试路径。在调用传统的 compactConversation(LLM 全量总结)之前,先尝试利用已有的 会话记忆 Session Memory 来压缩上下文。

设计思路

传统的 autocompact 需要把整个消息列表发给 LLM,让 LLM 重新总结一遍对话。而 Session Memory 在对话过程中已经逐步抽取了结构化的记忆(关键决策、用户偏好、已完成任务等)。SM Compact 的做法是:

  1. 丢弃上次总结点之前的所有原始消息
  2. 保留上次总结点之后的消息(最近的对话上下文)
  3. 用已有的 Session Memory 内容构造一条 summary message 代替被丢弃的历史

省去了一次 LLM 总结调用。

执行位置

autoCompactIfNeeded()
  │
  ├─ trySessionMemoryCompaction()  ← 路径 A(优先)
  │    └─ 成功 → postCompactCleanup → return
  │
  └─ compactConversation()  ← 路径 B(传统 LLM 总结)

2. 配置系统(第 44-130 行)

配置类型(第 47-54 行)

export type SessionMemoryCompactConfig = {
  minTokens: number           // 保留的最小 token 数
  minTextBlockMessages: number  // 保留的最少含文本消息数
  maxTokens: number           // 保留的最大 token 数(硬上限)
}

默认值(第 57-61 行)

export const DEFAULT_SM_COMPACT_CONFIG: SessionMemoryCompactConfig = {
  minTokens: 10_000,    // 最少保留 10k tokens
  minTextBlockMessages: 5,  // 至少保留 5 条含文本消息
  maxTokens: 40_000,    // 最多保留 40k tokens
}

配置加载(第 102-130 行)

async function initSessionMemoryCompactConfig(): Promise<void> {
  if (configInitialized) return  // 每个会话只加载一次
  configInitialized = true

  const remoteConfig = await getDynamicConfig_BLOCKS_ON_INIT<
    Partial<SessionMemoryCompactConfig>
  >('tengu_sm_compact_config', {})
  // 合并远程配置,忽略零值(不使用 0 覆盖默认)
}

初始化策略

  • 懒加载 + 一次性:首次调用 trySessionMemoryCompaction 时加载,后续复用
  • GrowthBook 远程配置:key tengu_sm_compact_config
  • 零值保护:远程配置如果返回 0 或负值,不覆盖默认值(第 117-127 行 > 0 检查)

3. 功能开关(第 403-432 行)

export function shouldUseSessionMemoryCompaction(): boolean {
  if (isEnvTruthy(process.env.ENABLE_CLAUDE_CODE_SM_COMPACT)) return true
  if (isEnvTruthy(process.env.DISABLE_CLAUDE_CODE_SM_COMPACT)) return false

  const sessionMemoryFlag = getFeatureValue_CACHED_MAY_BE_STALE('tengu_session_memory', false)
  const smCompactFlag = getFeatureValue_CACHED_MAY_BE_STALE('tengu_sm_compact', false)
  return sessionMemoryFlag && smCompactFlag
}

三层控制:

层级控制方式优先级
环境变量ENABLE_CLAUDE_CODE_SM_COMPACT最高(强制开启)
环境变量DISABLE_CLAUDE_CODE_SM_COMPACT中(强制关闭)
GrowthBook flag Atengu_session_memory基础(Session Memory 功能开关)
GrowthBook flag Btengu_sm_compact基础(SM Compact 功能开关)

两个 GrowthBook flag 同时为 true 时功能才启用。


4. 核心函数:trySessionMemoryCompaction(第 514-630 行)

export async function trySessionMemoryCompaction(
  messages: Message[],
  agentId?: AgentId,
  autoCompactThreshold?: number,  // autocompact 触发阈值,用于压缩后检查
): Promise<CompactionResult | null>

4.1 前置检查(第 519-543 行)

// 1. 功能开关
if (!shouldUseSessionMemoryCompaction()) return null

// 2. 初始化配置(首次调用时从 GrowthBook 加载)
await initSessionMemoryCompactConfig()

// 3. 等待正在进行的 Session Memory 抽取完成(带超时)
await waitForSessionMemoryExtraction()

// 4. 获取上次总结的消息 ID 和 Session Memory 内容
const lastSummarizedMessageId = getLastSummarizedMessageId()
const sessionMemory = await getSessionMemoryContent()

// 5. 没有 Session Memory 文件
if (!sessionMemory) return null

// 6. Session Memory 为空模板(未抽取到实际内容)
if (await isSessionMemoryEmpty(sessionMemory)) return null

三种跳过场景

  1. Session Memory 功能未启用
  2. 没有 session memory 文件(可能从未触发过记忆抽取)
  3. Session memory 是空模板(抽取了但没有实际内容)

4.2 确定总结边界(第 546-566 行)

let lastSummarizedIndex: number

if (lastSummarizedMessageId) {
  // 场景 A:有明确的总结点
  lastSummarizedIndex = messages.findIndex(msg => msg.uuid === lastSummarizedMessageId)
  if (lastSummarizedIndex === -1) {
    // 消息列表已被修改,无法定位总结点 → fall back
    return null
  }
} else {
  // 场景 B:resumed session,无总结点,但有 session memory
  // 将所有消息视为"已总结",从最末尾开始 expand
  lastSummarizedIndex = messages.length - 1
}

两种场景

场景lastSummarizedMessageId行为
正常会话有值从该 ID 之后的消息开始保留
Resume 后首次无值所有消息都可能被丢弃,用 session memory 替代

4.3 计算保留消息的起始位置(第 569-581 行)

const startIndex = calculateMessagesToKeepIndex(messages, lastSummarizedIndex)
const messagesToKeep = messages
  .slice(startIndex)
  .filter(m => !isCompactBoundaryMessage(m))

calculateMessagesToKeepIndex 负责计算保留多少条最近的消息(见第 6 节详解)。

同时过滤掉旧的 compact boundary message——如果保留中包含旧的 <compactBoundary> 消息,REPL 对它们再次 prunning 时会丢弃新的 boundary 和 summary(第 575-578 行注释)。

4.4 构造压缩结果(第 583-614 行)

// 1. 运行 session start hooks(恢复 CLAUDE.md 等上下文)
const hookResults = await processSessionStartHooks('compact', { model: getMainLoopModel() })

// 2. 用 Session Memory 构造 summary message
const compactionResult = createCompactionResultFromSessionMemory(
  messages, sessionMemory, messagesToKeep, hookResults, transcriptPath, agentId,
)

// 3. 构建压缩后的完整消息列表
const postCompactMessages = buildPostCompactMessages(compactionResult)

// 4. 检查压缩后是否仍超阈值
if (autoCompactThreshold !== undefined && postCompactTokenCount >= autoCompactThreshold) {
  return null  // 压缩后仍然超限 → fall back 到传统压缩
}

return { ...compactionResult, postCompactTokenCount, truePostCompactTokenCount }

重要设计:压缩后如果 token 仍然超过 autocompact threshold,返回 null,让 autocompact 走路径 B(传统 LLM 压缩)。这防止了 Session Memory 压缩"不够狠"导致后续 API 调用仍然 413。

4.5 错误处理(第 621-629 行)

catch (error) {
  logEvent('tengu_sm_compact_error', {})  // 只记事件,不写 error log
  if (process.env.USER_TYPE === 'ant') {
    logForDebugging(`Session memory compaction error: ${errorMessage(error)}`)
  }
  return null
}

静默失败:所有错误都返回 null,不抛出异常,不污染错误日志。因为这里的错误(如文件读取失败、路径问题)是"预期的异常",不应触发告警。


5. 构造 CompactionResult(第 437-503 行)

function createCompactionResultFromSessionMemory(
  messages, sessionMemory, messagesToKeep, hookResults, transcriptPath, agentId,
): CompactionResult

步骤

  1. 创建 compact boundary marker(第 447-451 行):

    const boundaryMarker = createCompactBoundaryMessage('auto', preCompactTokenCount, lastMsgUuid)
    
  2. 提取预发现的工具名称(第 452-457 行):

    const preCompactDiscovered = extractDiscoveredToolNames(messages)
    if (preCompactDiscovered.size > 0) {
      boundaryMarker.compactMetadata.preCompactDiscoveredTools = [...preCompactDiscovered].sort()
    }
    

    把压缩前 model 已经发现的工具名记录下来,压缩后不用重新发现。

  3. 截断过长的 Session Memory(第 461-462 行):

    const { truncatedContent, wasTruncated } = truncateSessionMemoryForCompact(sessionMemory)
    

    防止 Session Memory 内容占用压缩后全部的 token 预算。

  4. 构造 summary message(第 464-474 行):

    let summaryContent = getCompactUserSummaryMessage(truncatedContent, true, transcriptPath, true)
    if (wasTruncated) {
      summaryContent += `\n\nSome session memory sections were truncated...`
    }
    
  5. 创建 summary 消息(第 476-482 行):

    const summaryMessages = [createUserMessage({
      content: summaryContent,
      isCompactSummary: true,
      isVisibleInTranscriptOnly: true,  // 仅转录可见,不展示给用户
    })]
    
  6. 创建 plan attachment(第 484 行)— 如果 agent 有 plan,一起附上。

  7. 组装 CompactionResult(第 487-502 行):

    • boundaryMarker — 带 preservedSegment 标注,标记哪些消息被保留了
    • messagesToKeep — 保留的消息列表
    • preCompactTokenCount — 压缩前 token 数(来自 API 返回的精确值)
    • postCompactTokenCount — 压缩后 token 数(估算值)
    • 注释说明(第 498-501 行):SM-compact 没有 API 调用,所以 postCompactTokenCounttruePostCompactTokenCount 相同

6. 保留消息的计算(第 324-397 行)

export function calculateMessagesToKeepIndex(
  messages: Message[],
  lastSummarizedIndex: number,
): number

这是决定"保留最近多少条消息"的核心算法。

6.1 起始点(第 337-338 行)

let startIndex = lastSummarizedIndex >= 0 ? lastSummarizedIndex + 1 : messages.length

上次总结点的下一条 开始。

6.2 检查当前保留量(第 341-362 行)

// 计算从 startIndex 到末尾的 token 数和含文本消息数
for (let i = startIndex; i < messages.length; i++) { ... }

// 如果超过 maxTokens → 直接用 startIndex
if (totalTokens >= config.maxTokens) { return adjustIndex(...) }

// 如果已满足两个最小值 → 直接用 startIndex
if (totalTokens >= config.minTokens && textBlockMessageCount >= config.minTextBlockMessages) {
  return adjustIndex(...)
}

6.3 向后扩展(第 364-393 行)

// Floor: 上一个 compact boundary 之后
const floor = idx === -1 ? 0 : idx + 1
for (let i = startIndex - 1; i >= floor; i--) {
  totalTokens += estimateMessageTokens([msg])
  if (hasTextBlocks(msg)) textBlockMessageCount++
  startIndex = i
  if (totalTokens >= config.maxTokens) break    // 超过上限 → 停止
  if (totalTokens >= config.minTokens &&
      textBlockMessageCount >= config.minTextBlockMessages) break  // 满足最低 → 停止
}

扩展边界(floor):不能跨越上一个 compact boundary message。因为 boundary 之外的消息在磁盘上有不连续性(第 368-369 行注释),跨 boundary 的保留会导致 loader 的 tail→head 遍历跳过内部的 preserved segment 消息。

6.4 条件总结

条件行为
起始保留 ≥ maxTokens (40k)直接用起始点,不扩展
起始保留 ≥ minTokens (10k) ≥ 5 条文本消息直接用起始点,不扩展
起始保留不足向前扩展,逐个添加消息
扩展中达到 maxTokens停止扩展
扩展中同时满足两个最小值停止扩展
遇到上一个 compact boundary停止扩展(floor)

7. tool_use/tool_result 配对保护(第 232-314 行)

export function adjustIndexToPreserveAPIInvariants(
  messages: Message[],
  startIndex: number,
): number

压缩切分消息时最危险的错误是切断 tool_use/tool_result 配对:保留了 tool_result 但删除了它引用的 tool_use,API 会报错。

步骤 1:tool_use/tool_result 配对(第 244-286 行)

// 收集保留范围内所有 tool_result 的 ID
const allToolResultIds: string[] = []
for (let i = startIndex; i < messages.length; i++) {
  allToolResultIds.push(...getToolResultIds(messages[i]))
}

// 找出保留范围内已存在的 tool_use
const toolUseIdsInKeptRange = new Set<string>()
// 计算缺失的 tool_use
const neededToolUseIds = allToolResultIds.filter(id => !toolUseIdsInKeptRange.has(id))

// 向前查找缺失的 tool_use,扩展 startIndex
for (let i = adjustedIndex - 1; i >= 0 && neededToolUseIds.size > 0; i--) {
  if (hasToolUseWithIds(messages[i], neededToolUseIds)) {
    adjustedIndex = i
  }
}

引发的 bug 场景(第 205-218 行注释):

压缩前:
  N:   assistant, message.id: X, [tool_use: ORPHAN_ID]
  N+1: assistant, message.id: X, [tool_use: VALID_ID]
  N+2: user, [tool_result: ORPHAN_ID, tool_result: VALID_ID]

如果 startIndex = N+1:
  - 旧代码只检查 N+1  tool_results,没找到,返回 N+1
  - 压缩后: assistant[tool_use: VALID_ID], user[tool_result: ORPHAN_ID, tool_result: VALID_ID]
  - API 错误: 孤立的 ORPHAN_ID tool_result 找不到对应的 tool_use

步骤 2:thinking block 合并保护(第 288-311 行)

流式 API 可能将同一个 message.id 的 assistant 回复拆成多条消息(一条 thinking,一条 tool_use)。如果切分落在中间,normalizeMessagesForAPI 合并时会丢失 thinking block。

// 收集保留范围内所有 assistant 的 message.id
const messageIdsInKeptRange = new Set<string>()
// 向前查找相同 message.id 的消息
for (let i = adjustedIndex - 1; i >= 0; i--) {
  if (message.type === 'assistant' && message.message.id &&
      messageIdsInKeptRange.has(message.message.id)) {
    adjustedIndex = i  // 包含 thinking block
  }
}

8. 完整的执行流程图

trySessionMemoryCompaction()
  │
  ├─ shouldUseSessionMemoryCompaction()? → falsereturn null
  │
  ├─ initSessionMemoryCompactConfig()     ← 从 GrowthBook 加载配置(仅首次)
  │
  ├─ waitForSessionMemoryExtraction()     ← 等待正在进行的抽取完成
  │
  ├─ 获取 lastSummarizedMessageId + sessionMemory
  │
  ├─ 无 sessionMemory? → return null
  │
  ├─ sessionMemory 为空模板? → return null
  │
  ├─ 确定 lastSummarizedIndex:
  │    ├─ 有 lastSummarizedMessageId → findIndex
  │    │    └─ 未找到 → return null
  │    └─ 无 → messages.length - 1(resume 场景)
  │
  ├─ calculateMessagesToKeepIndex()
  │    ├─ 从 lastSummarizedIndex+1 开始
  │    ├─ 检查是否已满足 min/max
  │    ├─ 向后扩展到 floor(上一个 compact boundary)
  │    └─ adjustIndexToPreserveAPIInvariants()
  │         ├─ 保护 tool_use/tool_result 配对
  │         └─ 保护 thinking block 合并
  │
  ├─ 过滤旧 compact boundary → messagesToKeep
  │
  ├─ processSessionStartHooks()           ← 恢复 CLAUDE.md 等
  │
  ├─ createCompactionResultFromSessionMemory()
  │    ├─ 创建 boundary marker
  │    ├─ truncateSessionMemoryForCompact()
  │    ├─ getCompactUserSummaryMessage()
  │    ├─ createPlanAttachmentIfNeeded()
  │    └─ 组装 CompactionResult
  │
  ├─ buildPostCompactMessages()            ← 构建最终消息列表
  │
  ├─ 压缩后仍 ≥ autoCompactThreshold? → return null(fall back)
  │
  └─ return CompactionResult

9. 与传统压缩的对比

维度Session Memory Compact传统 compactConversation
调用 LLM 总结否(使用已有的 Session Memory)是(将消息发给 LLM 重新总结)
API 调用0 次1 次 LLM 调用
处理速度快(纯本地操作)慢(等待 LLM 响应)
适用条件Session Memory 已存在且非空无条件
关键风险Session Memory 内容可能不完整每次都要重新"理解"历史
配置tengu_session_memory + tengu_sm_compact无额外配置
执行位置autocompact 路径 A(优先尝试)autocompact 路径 B(兜底)

为什么优先尝试 SM Compact?

  1. 省成本:不需要额外的 LLM 调用
  2. 省时间:纯本地操作,毫秒级完成
  3. 结果一致:Session Memory 在对话过程中逐步抽取,比一次性总结更准确

什么情况下 fall back 到传统压缩?

  1. Session Memory 功能未启用(两个 GrowthBook flag)
  2. 没有 session memory 文件
  3. Session memory 内容是空模板
  4. lastSummarizedMessageId 在消息列表中找不到
  5. 压缩后的 token 数仍然超过 autocompact 阈值
  6. 任何异常(静默处理,返回 null)