AI 编程 Agent(比如 Claude Code)用久了,对话上下文会越来越臃肿------读过的代码、搜索结果、工具调用、思考过程全堆在一起。不治理的话,这些东西很快塞满上下文窗口,每次请求都更贵,真正重要的信息反而被淹没了。
Claude Code 的思路很朴素:把上下文治理拆成多层管线,先用便宜手段清理低价值内容,不行再上更重的摘要机制,同时把值得跨 session 保留的信号写到记忆文件里。
这篇文章从 Claude Code 源码出发,把整套体系捋一遍。
本文分析基于npm 分发包快照,部分功能通过
feature()做条件编译,仓库中可能没有对应源文件。
全文结构
- [1. 上下文膨胀是一个双向问题](#1. 上下文膨胀是一个双向问题 "#1%E4%B8%8A%E4%B8%8B%E6%96%87%E8%86%A8%E8%83%80%E6%98%AF%E4%B8%80%E4%B8%AA%E5%8F%8C%E5%90%91%E9%97%AE%E9%A2%98")
- [1.1 为什么上下文会持续膨胀](#1.1 为什么上下文会持续膨胀 "#11-%E4%B8%BA%E4%BB%80%E4%B9%88%E4%B8%8A%E4%B8%8B%E6%96%87%E4%BC%9A%E6%8C%81%E7%BB%AD%E8%86%A8%E8%83%80")
- [1.2 为什么不能只靠更大的上下文窗口](#1.2 为什么不能只靠更大的上下文窗口 "#12-%E4%B8%BA%E4%BB%80%E4%B9%88%E4%B8%8D%E8%83%BD%E5%8F%AA%E9%9D%A0%E6%9B%B4%E5%A4%A7%E7%9A%84%E4%B8%8A%E4%B8%8B%E6%96%87%E7%AA%97%E5%8F%A3")
- [1.3 Claude Code 如何解决这个问题](#1.3 Claude Code 如何解决这个问题 "#13-claude-code%E5%A6%82%E4%BD%95%E8%A7%A3%E5%86%B3%E8%BF%99%E4%B8%AA%E9%97%AE%E9%A2%98")
- [2. 实时 5 层管理](#2. 实时 5 层管理 "#2%E5%AE%9E%E6%97%B65%E5%B1%82%E7%AE%A1%E7%90%86")
- [2.1 第一层:Token Budget](#2.1 第一层:Token Budget "#21-%E7%AC%AC%E4%B8%80%E5%B1%82token-budget")
- [2.2 第二层:Snip --- 移除僵尸消息](#2.2 第二层:Snip — 移除僵尸消息 "#22-%E7%AC%AC%E4%BA%8C%E5%B1%82snip--%E7%A7%BB%E9%99%A4%E5%83%B5%E5%B0%B8%E6%B6%88%E6%81%AF")
- [2.3 第三层:Microcompact --- 清理 tool_result](#2.3 第三层:Microcompact — 清理 tool_result "#23-%E7%AC%AC%E4%B8%89%E5%B1%82microcompact--%E6%B8%85%E7%90%86tool_result")
- [2.4 第四层:Context Collapse --- 基于投影的折叠视图](#2.4 第四层:Context Collapse — 基于投影的折叠视图 "#24-%E7%AC%AC%E5%9B%9B%E5%B1%82context-collapse--%E5%9F%BA%E4%BA%8E%E6%8A%95%E5%BD%B1%E7%9A%84%E6%8A%98%E5%8F%A0%E8%A7%86%E5%9B%BE")
- [2.5 第五层:Auto-compact --- 最后兜底](#2.5 第五层:Auto-compact — 最后兜底 "#25-%E7%AC%AC%E4%BA%94%E5%B1%82auto-compact--%E6%9C%80%E5%90%8E%E5%85%9C%E5%BA%95%E4%BD%86%E4%B8%8D%E6%80%BB%E6%98%AF%E8%B5%B0%E5%90%8C%E4%B8%80%E6%9D%A1%E8%B7%AF")
- [3. 后台记忆处理 --- 把高价值信号沉到下一次会话](#3. 后台记忆处理 — 把高价值信号沉到下一次会话 "#3%E5%90%8E%E5%8F%B0%E8%AE%B0%E5%BF%86%E5%A4%84%E7%90%86-%E6%8A%8A%E9%AB%98%E4%BB%B7%E5%80%BC%E4%BF%A1%E5%8F%B7%E6%B2%89%E5%88%B0%E4%B8%8B%E4%B8%80%E6%AC%A1%E4%BC%9A%E8%AF%9D")
- [3.1 ExtractMemories](#3.1 ExtractMemories "#31-extractmemories")
- [3.2 AutoDream](#3.2 AutoDream "#32-autodream")
- [4. 补充](#4. 补充 "#4%E8%A1%A5%E5%85%85")
- [4.1 为什么偏爱 Forked Agent?](#4.1 为什么偏爱 Forked Agent? "#41-%E4%B8%BA%E4%BB%80%E4%B9%88%E5%81%8F%E7%88%B1-forked-agent%E8%80%8C%E4%B8%8D%E6%98%AF%E6%89%8B%E5%86%99%E4%B8%80%E6%9D%A1%E5%85%A8%E6%96%B0-api-%E8%AF%B7%E6%B1%82")
- [4.2 为什么很多保护只对主线程生效?](#4.2 为什么很多保护只对主线程生效? "#42-%E4%B8%BA%E4%BB%80%E4%B9%88%E5%BE%88%E5%A4%9A%E4%BF%9D%E6%8A%A4%E5%8F%AA%E5%AF%B9%E4%B8%BB%E7%BA%BF%E7%A8%8B%E7%94%9F%E6%95%88")
- [4.3 为什么有些层可以叠加,有些层必须互斥?](#4.3 为什么有些层可以叠加,有些层必须互斥? "#43-%E4%B8%BA%E4%BB%80%E4%B9%88%E6%9C%89%E4%BA%9B%E5%B1%82%E5%8F%AF%E4%BB%A5%E5%8F%A0%E5%8A%A0%E6%9C%89%E4%BA%9B%E5%B1%82%E5%BF%85%E9%A1%BB%E4%BA%92%E6%96%A5")
- [5. 总结](#5. 总结 "#5%E6%80%BB%E7%BB%93")
1.上下文膨胀是一个双向问题
1.1 为什么上下文会持续膨胀
一个 session 里,占大头的往往不是用户的提问,而是 agent 自己不断追加的内容:
- assistant 文本回复
- think 块(思考过程)
- tool_use 块(工具调用指令)
- 工具返回结果(Read、WebFetch 等)
比如 Claude Code 读几个文件、搜几次代码,这些内容动不动就占几千 token。不治理的话,历史消息越滚越多,每次请求更贵,有价值的信息也被埋得越来越深。
1.2 为什么不能只靠更大的上下文窗口
模型提供更大的上下文窗口当然有用,但不能从根本上解决问题:
- 输入越长,单次请求越贵
- 长前缀拖慢模型响应速度
- 长上下文里低价值片段会稀释模型注意力
- 只要 agent 还在干活,窗口迟早还是会被填满
所以真正要解决的不是"怎么不超限",而是怎么保住高价值上下文,同时降低低价值上下文的成本。
1.3 Claude Code 如何解决这个问题
Claude Code 没有做一个"万能摘要器",而是分层治理:
| 策略 | 对应机制 | 做什么 |
|---|---|---|
| 少说废话 | Token Budget | 让模型在预算花完前持续产出,但产出质量一降就停 |
| 多记笔记 | ExtractMemories + AutoDream | 把跨 session 的高价值信号写进 memory 文件 |
| 定期忘记 | Snip → Microcompact → Context Collapse → Auto-compact | 从轻到重,逐层丢掉低价值上下文 |
这背后是五层实时管线 + 一套后台记忆处理系统。
插播:feature() 和 GrowthBook 是什么
文章里会反复出现 feature('XXX') 这种东西。它是 Bun 的构建时宏------为 false 时,整个 require() 调用树在构建阶段就被死代码消除了。Feature flag 的值由 GrowthBook 远程配置服务下发,ant 用户有专门配置通道,外部构建中这些功能不可见。这就是为什么很多文件在仓库里找不到------条件编译把它们剔除了。后面不再赘述。
2.实时5层管理
2.1 第一层:Token Budget
Token Budget 说白了就是不断鞭策模型,榨干它的能力。
怎么个榨法
每轮模型回答完,如果还有预算,Claude Code 就往对话里塞一条消息,告诉模型"你还有很多额度,接着干":
java
Stopped at 40% of token target (200,000 / 500,000). Keep working --- do not summarize
实现就一行:
typescript
export function getBudgetContinuationMessage(
pct: number,
turnTokens: number,
budget: number,
): string {
const fmt = (n: number): string => new Intl.NumberFormat('en-US').format(n)
return `Stopped at ${pct}% of token target (${fmt(turnTokens)} / ${fmt(budget)}). Keep working --- do not summarize.`
}
预算怎么设
提问时带上就行,比如:
text
+500k
use 500k tokens
spend 500k tokens
代码里通过正则匹配解析:
typescript
// utils/tokenBudget.ts
const MULTIPLIERS: Record<string, number> = {
k: 1_000,
m: 1_000_000,
b: 1_000_000_000,
}
function parseBudgetMatch(value: string, suffix: string): number {
return parseFloat(value) * MULTIPLIERS[suffix.toLowerCase()]!
}
export function parseTokenBudget(text: string): number | null {
const startMatch = text.match(SHORTHAND_START_RE)
if (startMatch) return parseBudgetMatch(startMatch[1]!, startMatch[2]!)
const endMatch = text.match(SHORTHAND_END_RE)
if (endMatch) return parseBudgetMatch(endMatch[1]!, endMatch[2]!)
const verboseMatch = text.match(VERBOSE_RE)
if (verboseMatch) return parseBudgetMatch(verboseMatch[1]!, verboseMatch[2]!)
return null
}
什么时候继续?什么时候停?
Token Budget 在模型不再使用工具时触发:
typescript
// query.ts
if (!needsFollowUp) {
if (feature('TOKEN_BUDGET')) {
const decision = checkTokenBudget(
budgetTracker!,
toolUseContext.agentId,
getCurrentTurnTokenBudget(),
getTurnOutputTokens(),
)
}
}
这引出一个问题:如果不涉及工具使用的对话,一定会触发 Token Budget。那模型会不会真的用 100 万 token 来回答"今天天气如何"?
不会。Claude Code 不会无脑催模型续杯,它用几个条件判断模型是不是已经没什么可说的了:
typescript
// query/tokenBudget.ts
const COMPLETION_THRESHOLD = 0.9
const DIMINISHING_THRESHOLD = 500
const isDiminishing =
tracker.continuationCount >= 3 && // 已经续杯三次
deltaSinceLastCheck < DIMINISHING_THRESHOLD && // 本轮新增小于500 token
tracker.lastDeltaTokens < DIMINISHING_THRESHOLD // 上轮新增小于500 token
核心判断是两条路,满足其一就停:
- diminishing returns(收益递减):续杯 3 次以上 + 连续两轮新增都不到 500 token。单次低产出可能是模型还在组织思路,直接判死刑太武断;给 3 轮机会,忍短期波动但不忍没完没了。两轮双重校验,防单次误判。
- 90% 硬上限:即使没触发 diminishing returns,只要当前 token 消耗超过预算的 90%,直接停。逻辑很简单------预算花完了就别续了。
另外,Token Budget 有个 agentId 守卫:只对主线程生效,forked agent 不走这个逻辑。这和后面 4.2 节要讨论的"保护只对主线程生效"是同一个原则------子 agent 生命周期短,不值得管。
2.2 第二层:Snip --- 移除僵尸消息
Snip 是 pre-query 管线里最轻量的第一步,在 Microcompact、Context Collapse、Autocompact 之前执行。
它的行为(从命名和调用上下文推断,snipCompact.ts 源文件被 feature gate 排除,仓库中不可见):连续的只读搜索/读取工具调用(Grep、Glob、Read),在 assistant 给出文本回复后,那些原始的 tool_use 和 tool_result 就没用了,直接删掉。
调用入口在 query.ts 中看得见:
typescript
// query.ts --- pre-query 管线的第一步
const snipModule = feature('HISTORY_SNIP')
? (require('./services/compact/snipCompact.js') as typeof import('./services/compact/snipCompact.js'))
: null
let snipTokensFreed = 0
if (feature('HISTORY_SNIP')) {
queryCheckpoint('query_snip_start')
const snipResult = snipModule!.snipCompactIfNeeded(messagesForQuery)
messagesForQuery = snipResult.messages // 裁剪后的消息列表
snipTokensFreed = snipResult.tokensFreed // 释放的 token 数
if (snipResult.boundaryMessage) {
yield snipResult.boundaryMessage // UI 渲染分隔线
}
queryCheckpoint('query_snip_end')
}
snipTokensFreed 为什么必须向下透传?
Snip 移除消息后,autocompact 需要判断"是不是还超阈值"。但 autocompact 依赖的 token 估算 API 只能看到当前消息列表------它不知道 Snip 已经删掉了什么。不减掉 snipTokensFreed,autocompact 会高估当前 token 占用,触发没必要的压缩:
typescript
tokenCountWithEstimation(messagesForQuery) - snipTokensFreed
2.3 第三层:Microcompact --- 清理 tool_result
Microcompact 是第一层真正"删除内容"的机制。目标很明确:旧工具结果往往是长上下文里体积最大、价值最低的东西。
typescript
export async function microcompactMessages(
messages: Message[],
toolUseContext?: ToolUseContext,
querySource?: QuerySource,
): Promise<MicrocompactResult> {
clearCompactWarningSuppression()
// Time-based trigger 先跑,命中就短路返回。
// 如果距离上条 assistant 消息超过阈值,server cache 已过期,
// 整个前缀都会重写------不如趁现在清理旧 tool_result,减少重写的 token。
const timeBasedResult = maybeTimeBasedMicrocompact(messages, querySource)
if (timeBasedResult) {
return timeBasedResult
}
// Cached MC 只给主线程用,防止 forked agent(session_memory、prompt_suggestion 等)
// 往全局 cachedMCState 里注册自己的 tool_result,
// 导致主线程尝试删除自己会话里根本不存在的工具。
if (feature('CACHED_MICROCOMPACT')) {
const mod = await getCachedMCModule()
const model = toolUseContext?.options.mainLoopModel ?? getMainLoopModel()
if (
mod.isCachedMicrocompactEnabled() &&
mod.isModelSupportedForCacheEditing(model) &&
isMainThreadSource(querySource)
) {
return await cachedMicrocompactPath(messages, querySource)
}
}
// 两条路都不通,什么都不做。autocompact 会在后面接盘。
return { messages }
本质是个三选一:
- 先试 time-based
- 不满足再试 cache-based
- 都不满足就跳过
Time-based Microcompact
触发条件:
arduino
const TIME_BASED_MC_CONFIG_DEFAULTS: TimeBasedMCConfig = {
enabled: false, // 默认关闭
gapThresholdMinutes: 60, // 距离最近的 assistant 超过 60 分钟
keepRecent: 5, // 保留最近 5 个工具结果
}
为什么是 60 分钟?因为 Anthropic 的 prompt cache 保留时间就是 1 小时左右。超过 1 小时 cache 过期,整个前缀要重写。与其等着重写一大堆旧 tool_result,不如提前把它们清成占位文本,减少重写的 token 开销------既要 prompt cache 的便宜,又要 cache 过期后的便宜,很细。
不过要注意:这个机制默认关闭,只有 ant 用户通过 GrowthBook(tengu_slate_heron)远程配置才可能开启,外部构建这条路径永远不会触发。
触发后,旧 tool_result 被替换为:
sql
[Old tool result content cleared]
typescript
export const TIME_BASED_MC_CLEARED_MESSAGE = '[Old tool result content cleared]'
let tokensSaved = 0
const result: Message[] = messages.map(message => {
if (message.type !== 'user' || !Array.isArray(message.message.content)) {
return message
}
let touched = false
const newContent = message.message.content.map(block => {
if (
block.type === 'tool_result' &&
clearSet.has(block.tool_use_id) &&
block.content !== TIME_BASED_MC_CLEARED_MESSAGE
) {
tokensSaved += calculateToolResultTokens(block)
touched = true
return { ...block, content: TIME_BASED_MC_CLEARED_MESSAGE }
}
return block
})
if (!touched) return message
return {
...message,
message: { ...message.message, content: newContent },
}
})
并不是所有工具结果都能被清理
Microcompact 有一个 COMPACTABLE_TOOLS 白名单,只有这些工具的结果才会被清理:
typescript
const COMPACTABLE_TOOLS = new Set<string>([
FILE_READ_TOOL_NAME,
...SHELL_TOOL_NAMES,
GREP_TOOL_NAME,
GLOB_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
FILE_EDIT_TOOL_NAME,
FILE_WRITE_TOOL_NAME,
])
Read、Shell、Grep、Glob、WebSearch、WebFetch、FileEdit、FileWrite------都是结果体积大、时效性短的。像 TodoWrite 这类工具的结果不会被清理。
Cached Microcompact
如果 time-based 没触发,系统会考虑 Cached Microcompact。需要满足三个条件:
scss
if (feature('CACHED_MICROCOMPACT')) {
const mod = await getCachedMCModule()
const model = toolUseContext?.options.mainLoopModel ?? getMainLoopModel()
if (
mod.isCachedMicrocompactEnabled() && // 开关打开
mod.isModelSupportedForCacheEditing(model) && // 模型支持 cache editing
isMainThreadSource(querySource) // 限定主线程
) {
return await cachedMicrocompactPath(messages, querySource)
}
}
Cached Microcompact 最大的优势是不用修改本地消息,直接改模型服务端的 prompt cache。
2.4 第四层:Context Collapse --- 基于投影的折叠视图
这个功能也是 ant 的内部功能,核心代码被死代码消除了,/services/contextCollapse/index.js 在仓库中不存在。我们只能从调用代码推断它的行为。
typescript
const contextCollapse = feature('CONTEXT_COLLAPSE')
? (require('./services/contextCollapse/index.js') as typeof import('./services/contextCollapse/index.js'))
: null
在 agent-loop 中有明确的调用:
typescript
// Project the collapsed context view and maybe commit more collapses.
// Runs BEFORE autocompact so that if collapse gets us under the
// autocompact threshold, autocompact is a no-op and we keep granular
// context instead of a single summary.
//
// Nothing is yielded --- the collapsed view is a read-time projection
// over the REPL's full history. Summary messages live in the collapse
// store, not the REPL array. This is what makes collapses persist
// across turns: projectView() replays the commit log on every entry.
// Within a turn, the view flows forward via state.messages at the
// continue site (query.ts:1192), and the next projectView() no-ops
// because the archived messages are already gone from its input.
if (feature('CONTEXT_COLLAPSE') && contextCollapse) {
const collapseResult = await contextCollapse.applyCollapsesIfNeeded(
messagesForQuery,
toolUseContext,
querySource,
)
messagesForQuery = collapseResult.messages
}
这段注释透露了两个重要设计:
- 执行顺序:在 Autocompact 之前运行------如果折叠后已经低于阈值,Autocompact 就是空操作,上下文不会被打成粗粒度的摘要。
- 持久化机制 :摘要保存在独立的折叠仓库中,不放 REPL 数组里------
projectView()通过重放提交日志在每轮对话进入时重新投影视图。
从调用代码至少能看出几点:
- 在 pre-query 中,执行顺序在 Autocompact 之前
- 接受 messagesForQuery,用折叠后的版本覆盖
- 摘要保存在折叠仓库中
如果 413(上下文过长)了,还会紧急折叠:
typescript
// query.ts
if (isWithheld413) {
if (state.transition?.reason !== 'collapse_drain_retry') {
const drained = contextCollapse.recoverFromOverflow(messagesForQuery, querySource)
if (drained.committed > 0) {
const next: State = {
messages: drained.messages,
transition: {
reason: 'collapse_drain_retry',
committed: drained.committed,
},
}
state = next
continue
}
}
}
具体怎么折叠、基于什么规则,我们看不到源码。从暴露出的接口看,这是一个可以折叠消息 + 恢复消息的系统,推测是为了保证每次摘要都基于全部消息,尽量少丢信息。
Context Collapse 和 Auto-compact 为什么不能共存
javascript
// Context-collapse mode: same suppression. Collapse IS the context
// management system when it's on --- the 90% commit / 95% blocking-spawn
// flow owns the headroom problem. Autocompact firing at effective-13k
// (~93% of effective) sits right between collapse's commit-start (90%)
// and blocking (95%), so it would race collapse and usually win, nuking
// granular context that collapse was about to save. Gating here rather
// than in isAutoCompactEnabled() keeps reactiveCompact alive as the 413
// fallback (it consults isAutoCompactEnabled directly) and leaves
// sessionMemory + manual /compact working.
if (feature('CONTEXT_COLLAPSE')) {
const { isContextCollapseEnabled } =
require('../contextCollapse/index.js') as typeof import('../contextCollapse/index.js')
if (isContextCollapseEnabled()) {
return false
}
}
这段注释把互斥的原因讲得很清楚:
- Collapse 的提交起点是 90% 上下文容量,阻塞点是 95%
- Autocompact 在 ~93% 触发,刚好卡在两者中间
- 同时开的话,Autocompact 会先动手压缩,Collapse 本来要保留的细粒度上下文就没了(原注释用词是 "nuking")
- 所以直接关掉 Autocompact,留 reactiveCompact 当 413 兜底,手动
/compact和 sessionMemory 继续能用
2.5 第五层:Auto-compact --- 最后兜底,但不总是走同一条路
Auto-compact 分两条路:Session Memory Extraction 和 Forked Compact Agent。
Session Memory Extraction 像做笔记------时刻维护一份摘要,需要时直接拿来用,不用调 API。Forked Compact Agent 是应急用的,调 API 一次性压缩所有上下文。
触发阈值
typescript
// services/compact/autoCompact.ts
const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000 // 摘要预留空间
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000 // 缓冲空间
export function getEffectiveContextWindowSize(model: string): number {
const reservedTokensForSummary = Math.min(
getMaxOutputTokensForModel(model),
MAX_OUTPUT_TOKENS_FOR_SUMMARY,
)
let contextWindow = getContextWindowForModel(model, getSdkBetas())
const autoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
if (autoCompactWindow) {
const parsed = parseInt(autoCompactWindow, 10)
if (!isNaN(parsed) && parsed > 0) {
contextWindow = Math.min(contextWindow, parsed)
}
}
return contextWindow - reservedTokensForSummary
}
export function getAutoCompactThreshold(model: string): number {
const effectiveContextWindow = getEffectiveContextWindowSize(model)
const autocompactThreshold = effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS
// ...
return autocompactThreshold
}
触发阈值 = 上下文窗口 - 摘要预留空间 - 缓冲空间。
两条路怎么选
优先走 Session Memory Extraction,走不通才用 Forked Compact Agent:
javascript
// EXPERIMENT: Try session memory compaction first
const sessionMemoryResult = await trySessionMemoryCompaction(
messages,
toolUseContext.agentId,
recompactionInfo.autoCompactThreshold,
)
if (sessionMemoryResult) {
setLastSummarizedMessageId(undefined)
runPostCompactCleanup(querySource)
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
notifyCompaction(querySource ?? 'compact', toolUseContext.agentId)
}
markPostCompaction()
return {
wasCompacted: true,
compactionResult: sessionMemoryResult,
}
}
try {
const compactionResult = await compactConversation(
messages,
toolUseContext,
cacheSafeParams,
true, // 抑制用户提问
undefined, // 无自定义指令
true, // 标记为 autocompact
recompactionInfo,
)
熔断机制
连续压缩失败 3 次后,autocompact 熔断,不再重试:
typescript
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
源码注释解释:上下文已经不可恢复地超限(比如 prompt_too_long),重试只会浪费 API 调用。注释提到这个熔断每天能节省约 250K 次 API 调用。
typescript
if (
tracking?.consecutiveFailures !== undefined &&
tracking.consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES
) {
return { wasCompacted: false }
}
递归守卫
压缩的子 agent 不能再触发压缩------否则就无限套娃了:
typescript
if (querySource === 'session_memory' || querySource === 'compact') {
return false
}
这和 4.2 节讨论的递归风险直接对应。压缩本身就是为了解决上下文膨胀,如果压缩的子 agent 又触发压缩,就成死循环了。
Session Memory Extraction 怎么运作的
它维护一份结构化的笔记,长这样(源码中的模板实际是英文的,以下是翻译版):
bash
# Session Title
一个简短、有辨识度的 5~10 词描述性标题。信息密度极高,无废话。
# Current State
当前正在积极进行的工作是什么?尚未完成的待办。即刻的下一步。
# Task specification
用户要求构建什么?设计决策或说明性背景。
# Files and Functions
哪些是重要文件?简述内容及为何相关。
# Workflow
通常按什么顺序运行哪些 bash 命令?如何解读输出?
# Errors & Corrections
遇到的错误及修复方法。用户纠正了什么?哪些方法失败了不该再试?
# Codebase and System Documentation
有哪些重要的系统组件?它们如何运作/协同?
# Learnings
哪些方法有效?哪些无效?应避免什么?不要重复其他部分已有的内容。
# Key results
用户要求的输出(答案、表格、文档等),在此处重复给出确切结果。
# Worklog
逐步记录,尝试了什么、做了什么。每一步极简摘要。
模板每个字段都精准对应 coding 中最需要记录的东西:当前状态防断点后丢上下文,错误与修正防重蹈覆辙,工作流积累可复用模式。自己做 agent 的话,这套笔记结构可以直接抄。
什么时候触发笔记更新
typescript
export function shouldExtractMemory(messages: Message[]): boolean {
const currentTokenCount = tokenCountWithEstimation(messages)
if (!isSessionMemoryInitialized()) {
if (!hasMetInitializationThreshold(currentTokenCount)) {
return false
}
markSessionMemoryInitialized()
}
const hasMetTokenThreshold = hasMetUpdateThreshold(currentTokenCount)
const toolCallsSinceLastUpdate = countToolCallsSince(messages, lastMemoryMessageUuid)
const hasMetToolCallThreshold = toolCallsSinceLastUpdate >= getToolCallsBetweenUpdates()
const hasToolCallsInLastTurn = hasToolCallsInLastAssistantTurn(messages)
const shouldExtract =
(hasMetTokenThreshold && hasMetToolCallThreshold) ||
(hasMetTokenThreshold && !hasToolCallsInLastTurn)
if (shouldExtract) {
const lastMessage = messages[messages.length - 1]
if (lastMessage?.uuid) {
lastMemoryMessageUuid = lastMessage.uuid
}
return true
}
return false
}
第一次触发:token > 10000
后续触发需要同时满足两个条件:
- 比上次提取 token 增长 > 5000
- 加上其一:新增工具调用 > 3 次,或本轮没有工具调用
新增工具调用 > 3 次说明 agent 做了实质性工作。没调工具一般是在跟用户纯文本交流或在等答案------这些是安全检查点,不会打断正在执行的任务。
全量输入、增量更新
每次更新把当前笔记 + 全量对话一起喂给模型,但提示词要求只做增量编辑。核心指令:
diff
重要提示:此消息及这些指令并非实际用户对话的一部分。
请勿在笔记内容中提及"记笔记"、"会话笔记提取"或这些更新指令。
根据上述用户对话(不包括此记笔记指令消息,以及系统提示词、claude.md 条目
或任何历史会话摘要),更新会话笔记文件。
你唯一的任务是使用 Edit 工具更新笔记文件,然后停止。
你可以进行多次编辑------在一条消息中并行发起所有 Edit 工具调用。不要调用任何其他工具。
编辑的关键规则:
- 文件必须保持其精确结构,所有部分、标题和斜体描述必须原样保留
- 绝不要修改或删除斜体部分描述行(模板指令,必须原样保留)
- 只更新每个现有部分中出现在斜体部分描述下方的实际内容
- 不要在现有结构之外添加任何新部分、摘要或信息
- 不要在笔记中任何地方提及此记笔记过程或这些指令
- 为每个部分撰写详细、信息密度高的内容------包含文件路径、函数名、错误信息、精确命令等
- 不要包含上下文中 CLAUDE.md 文件里已有的信息
- 重要:务必更新"当前状态"以反映最近的工作------这对压缩后保持连续性至关重要
你只更新这两行保留内容之后出现的实际内容。切记:并行使用 Edit 工具并停止。
完成编辑后不要继续。
几个值得注意的点:结构保留 (header + italic description 不能改)、增量更新 (没新东西就跳过)、信息密度(别填废话)。说白了就是把"记笔记"也变成一个受约束的编辑任务。
3.后台记忆处理 --- 把高价值信号沉到下一次会话
3.1 ExtractMemories
每轮对话结束后 fork 一个 agent 来提取记忆,不阻塞主流程。
但如果主 agent 在对话过程中已经直接写了 memory 文件,就跳过:
typescript
if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) {
logForDebugging(
'[extractMemories] skipping --- conversation already wrote to memory files',
)
const lastMessage = messages.at(-1)
if (lastMessage?.uuid) {
lastMemoryMessageUuid = lastMessage.uuid
}
logEvent('tengu_extract_memories_skipped_direct_write', {
message_count: newMessageCount,
})
return
}
四种记忆类型:
| 类型 | 含义 | 示例 |
|---|---|---|
| user | 用户是谁(角色、偏好、知识背景) | "用户是资深 Go 开发者,刚接触 React" |
| feedback | 用户怎么教你的(纠正 + 确认) | "不要 mock 数据库------上次 mock 通过但线上迁移失败" |
| project | 代码之外的项目上下文(决策、截止日) | "认证中间件重写是合规需求,不是技术债清理" |
| reference | 外部资源指针(Grafana、Linear) | "流水线 bug 跟踪在 Linear 项目 INGEST 中" |
3.2 AutoDream
AutoDream 在后台定期运行,把多个 session 积累的记忆整合、去重、精简。
什么时候触发
先过前置门控:
typescript
function isGateOpen(): boolean {
if (getKairosActive()) return false // KAIROS 模式有自己的 /dream
if (getIsRemoteMode()) return false // 远程模式不支持
if (!isAutoMemoryEnabled()) return false // auto memory 未启用
return isAutoDreamEnabled() // GrowthBook 或用户设置开关
}
四个条件全部通过才算开门。然后才是时间门------距离上次整合超过 24 小时:
typescript
runner = async function runAutoDream(context, appendSystemMessage) {
const cfg = getConfig()
const force = isForced()
if (!force && !isGateOpen()) return
// --- Time gate ---
let lastAt: number
try {
lastAt = await readLastConsolidatedAt()
} catch (e: unknown) {
logForDebugging(
`[autoDream] readLastConsolidatedAt failed: ${(e as Error).message}`,
)
return
}
const hoursSince = (Date.now() - lastAt) / 3_600_000
if (!force && hoursSince < cfg.minHours) return // minHour 默认 24 小时
还有一个 session 数量门:超过 minSessions 个 session(默认 5,由 GrowthBook 配置 tengu_onyx_plover 下发,可调整)。
扫描节流
当时间门过了但 session 数量不够时,不是每轮都去扫描 session 列表------而是 10 分钟内最多扫描一次:
typescript
const SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000 // 10 分钟
因为锁文件的 mtime 不会前进,时间门每轮都会过,不加节流会导致频繁 I/O。
怎么整合
AutoDream 遵循四阶段流程,prompt 定义在 consolidationPrompt.ts:
typescript
export function buildConsolidationPrompt(
memoryRoot: string,
transcriptDir: string,
extra: string,
): string {
return `# Dream: Memory Consolidation
You are performing a dream --- a reflective pass over your memory files.
Synthesize what you've learned recently into durable, well-organized
memories so that future sessions can orient quickly.
Memory directory: \`${memoryRoot}\`
${DIR_EXISTS_GUIDANCE}
Session transcripts: \`${transcriptDir}\`
(large JSONL files --- grep narrowly, don't read whole files)
---
## Phase 1 --- Orient
- \`ls\` the memory directory to see what already exists
- Read \`${ENTRYPOINT_NAME}\` to understand the current index
- Skim existing topic files so you improve them rather than creating duplicates
- If \`logs/\` or \`sessions/\` subdirectories exist (assistant-mode layout),
review recent entries there
阶段 1 --- 定向 :先摸清记忆目录里已有内容。ls 看文件 → 读 MEMORY.md 掌握索引全貌 → 快速过现有主题文件(新信息应合并而非另建)→ 有 logs/ 或 sessions/ 子目录则扫一眼最近条目。
typescript
## Phase 2 --- Gather recent signal
Look for new information worth persisting. Sources in rough priority order:
1. **Daily logs** (\`logs/YYYY/MM/YYYY-MM-DD.md\`) if present ---
these are the append-only stream
2. **Existing memories that drifted** --- facts that contradict
something you see in the codebase now
3. **Transcript search** --- if you need specific context (e.g.,
"what was the error message from yesterday's build failure?"),
grep the JSONL transcripts for narrow terms:
\`grep -rn "<narrow term>" ${transcriptDir}/
--include="*.jsonl" | tail -50\`
Don't exhaustively read transcripts.
Look only for things you already suspect matter.
阶段 2 --- 收集近期信号 :按优先级从三个来源提取待持久化信息------① 每日日志(logs/YYYY/MM/YYYY-MM-DD.md,追加式流水记录);② 已漂移的记忆(文件内容与代码库现状矛盾);③ 对话记录精准搜索(grep 关键词,不全量读 JSONL)。
typescript
## Phase 3 --- Consolidate
For each thing worth remembering, write or update a memory file
at the top level of the memory directory. Use the memory file format
and type conventions from your system prompt's auto-memory section ---
it's the source of truth for what to save, how to structure it,
and what NOT to save.
Focus on:
- Merging new signal into existing topic files rather than
creating near-duplicates
- Converting relative dates ("yesterday", "last week") to
absolute dates so they remain interpretable after time passes
- Deleting contradicted facts --- if today's investigation disproves
an old memory, fix it at the source
阶段 3 --- 整合:对每个值得记住的事,在记忆目录顶层写/更新文件。合并到已有主题文件而非创建近重复项;相对日期转绝对日期;推翻的旧事实直接从源头修正。
typescript
## Phase 4 --- Prune and index
Update \`${ENTRYPOINT_NAME}\` so it stays under ${MAX_ENTRYPOINT_LINES}
lines AND under ~25KB. It's an **index**, not a dump --- each entry
should be one line under ~150 characters:
\`- [Title](file.md) --- one-line hook\`.
Never write memory content directly into it.
- Remove pointers to memories that are now stale, wrong, or superseded
- Demote verbose entries: if an index line is over ~200 chars, it's
carrying content that belongs in the topic file --- shorten the line,
move the detail
- Add pointers to newly important memories
---
Return a brief summary of what you consolidated, updated, or pruned.
If nothing changed (memories are already tight), say so.
${extra ? `\n\n## Additional context\n\n${extra}` : ''}`
}
阶段 4 --- 精简与索引 :更新 MEMORY.md,控制 200 行以内、约 25KB 以内。每行 ~150 字符的索引条目,不直接写记忆内容。移除过时指针、精简过长条目、添加新记忆指针。
4.补充
4.1 为什么偏爱 Forked Agent,而不是手写一条全新 API 请求?
Auto-compact、ExtractMemories、AutoDream 都用的是 Forked Agent。原因很简单------省钱。Forked agent 复用主线程的 cacheSafeParams(包含 systemPrompt、userContext、systemContext、toolUseContext 和 forkContextMessages),这些恰好是构成 prompt cache key 的全部要素,所以 forked agent 能命中主线程的 cache,不用冷启动。
4.2 为什么很多保护只对主线程生效?
很多上下文治理机制都有 isMainThreadSource(querySource) 守卫。三条原因,按重要程度排:
- 状态污染风险(最要命)------Cached Microcompact 维护全局状态,子 agent 往里注册自己的 tool_result 会污染主线程的映射关系,导致主线程误删不存在的工具。
- 没必要(资源效率)------compact、ExtractMemories 的子 agent 生命周期很短,本身不会积累超长上下文,不需要跑完整的治理链路。
- 递归风险(安全问题) ------Section 2.5 提到的那行
querySource === 'session_memory'守卫就是防止无限套娃。
4.3 为什么有些层可以叠加,有些层必须互斥?
- Snip 和 Microcompact 可以连续执行
- Context Collapse 在 Auto-compact 之前运行
- 只有当 Context Collapse 开启时,Auto-compact 才会被主动抑制
Auto-compact 内部也不是多路并发:
- 先试 Session Memory Extraction
- 不行再退回 Forked Compact Agent
总结一下:
- 能协同的层:顺序叠加,先做便宜、精细的整理
- 会相互干扰的层:显式互斥
- 同一目标的两条路:按成本从低到高排优先级
5.总结
Claude Code 做的不是一个万能摘要器,而是把上下文治理拆成了几个成本和损伤不同的层:
- 先用 Token Budget 决定这一轮还值不值得继续拉长
- 再用 Snip、Microcompact 这类便宜手段,把低价值上下文从消息数组里清掉
- 还不够,就上 Context Collapse 或 Auto-compact 这类更重的机制
- 与此同时,把跨 session 真正有用的信号记到 memory 文件里,再通过 AutoDream 定期整理
这就是 Claude Code 的"过目不忘"------不是什么都记,而是该记的记、该忘的忘,用最低成本保住最有用的东西。
简单说就是:
低价值的打包扔掉,高价值的摘出来记好。