源码:
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 的做法是:
- 丢弃
上次总结点之前的所有原始消息 - 保留
上次总结点之后的消息(最近的对话上下文) - 用已有的 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 A | tengu_session_memory | 基础(Session Memory 功能开关) |
| GrowthBook flag B | tengu_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
三种跳过场景:
- Session Memory 功能未启用
- 没有 session memory 文件(可能从未触发过记忆抽取)
- 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
步骤
-
创建 compact boundary marker(第 447-451 行):
const boundaryMarker = createCompactBoundaryMessage('auto', preCompactTokenCount, lastMsgUuid) -
提取预发现的工具名称(第 452-457 行):
const preCompactDiscovered = extractDiscoveredToolNames(messages) if (preCompactDiscovered.size > 0) { boundaryMarker.compactMetadata.preCompactDiscoveredTools = [...preCompactDiscovered].sort() }把压缩前 model 已经发现的工具名记录下来,压缩后不用重新发现。
-
截断过长的 Session Memory(第 461-462 行):
const { truncatedContent, wasTruncated } = truncateSessionMemoryForCompact(sessionMemory)防止 Session Memory 内容占用压缩后全部的 token 预算。
-
构造 summary message(第 464-474 行):
let summaryContent = getCompactUserSummaryMessage(truncatedContent, true, transcriptPath, true) if (wasTruncated) { summaryContent += `\n\nSome session memory sections were truncated...` } -
创建 summary 消息(第 476-482 行):
const summaryMessages = [createUserMessage({ content: summaryContent, isCompactSummary: true, isVisibleInTranscriptOnly: true, // 仅转录可见,不展示给用户 })] -
创建 plan attachment(第 484 行)— 如果 agent 有 plan,一起附上。
-
组装 CompactionResult(第 487-502 行):
boundaryMarker— 带preservedSegment标注,标记哪些消息被保留了messagesToKeep— 保留的消息列表preCompactTokenCount— 压缩前 token 数(来自 API 返回的精确值)postCompactTokenCount— 压缩后 token 数(估算值)- 注释说明(第 498-501 行):SM-compact 没有 API 调用,所以
postCompactTokenCount和truePostCompactTokenCount相同
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()? → false → return 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?
- 省成本:不需要额外的 LLM 调用
- 省时间:纯本地操作,毫秒级完成
- 结果一致:Session Memory 在对话过程中逐步抽取,比一次性总结更准确
什么情况下 fall back 到传统压缩?
- Session Memory 功能未启用(两个 GrowthBook flag)
- 没有 session memory 文件
- Session memory 内容是空模板
lastSummarizedMessageId在消息列表中找不到- 压缩后的 token 数仍然超过 autocompact 阈值
- 任何异常(静默处理,返回 null)