一文读懂:如何让 Claude Code 拥有"过目不忘"的记忆力

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

核心判断是两条路,满足其一就停:

  1. diminishing returns(收益递减):续杯 3 次以上 + 连续两轮新增都不到 500 token。单次低产出可能是模型还在组织思路,直接判死刑太武断;给 3 轮机会,忍短期波动但不忍没完没了。两轮双重校验,防单次误判。
  2. 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 }

本质是个三选一:

  1. 先试 time-based
  2. 不满足再试 cache-based
  3. 都不满足就跳过

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
    }

这段注释透露了两个重要设计:

  1. 执行顺序:在 Autocompact 之前运行------如果折叠后已经低于阈值,Autocompact 就是空操作,上下文不会被打成粗粒度的摘要。
  2. 持久化机制 :摘要保存在独立的折叠仓库中,不放 REPL 数组里------projectView() 通过重放提交日志在每轮对话进入时重新投影视图。

从调用代码至少能看出几点:

  1. 在 pre-query 中,执行顺序在 Autocompact 之前
  2. 接受 messagesForQuery,用折叠后的版本覆盖
  3. 摘要保存在折叠仓库中

如果 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) 守卫。三条原因,按重要程度排:

  1. 状态污染风险(最要命)------Cached Microcompact 维护全局状态,子 agent 往里注册自己的 tool_result 会污染主线程的映射关系,导致主线程误删不存在的工具。
  2. 没必要(资源效率)------compact、ExtractMemories 的子 agent 生命周期很短,本身不会积累超长上下文,不需要跑完整的治理链路。
  3. 递归风险(安全问题) ------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 的"过目不忘"------不是什么都记,而是该记的记、该忘的忘,用最低成本保住最有用的东西。

简单说就是:

低价值的打包扔掉,高价值的摘出来记好。

相关推荐
黎阳之光2 小时前
黎阳之光:以视频孪生重构智慧医院信息化,打造高标项目核心竞争力
大数据·人工智能·物联网·算法·数字孪生
东风破_2 小时前
Claude Code 实战指南:像带实习生一样让 AI 帮你维护项目
人工智能
常威正在打来福2 小时前
frontend-design入门指南:OpenClaw/Claude Code/Codex 三平台安装教程
人工智能·aigc·ai编程
百度智能云技术站2 小时前
百度 Agent 安全中心:构筑企业智能体的安全底座
人工智能·安全·dubbo
TechPioneer_lp2 小时前
30 岁硕士 Linux C 开发背景,未来想去澳洲就业,研究方向该选 AI、SDN 漏洞还是 Linux 内核?
linux·人工智能·职业规划·澳洲求职
阿里云大数据AI技术3 小时前
Hologres CLI 与 Skills 担当 Agent-Ready 基础设施,共建数仓智能新生态
人工智能·agent
Terrence Shen3 小时前
大模型部署工具对比
人工智能·深度学习·计算机视觉
视觉&物联智能3 小时前
【杂谈】-企业人工智能超越实验:安全拓展的实践路径
人工智能·安全·aigc·agent·agi
ting94520003 小时前
Kirki 深度技术解析:WordPress 自定义控件开发与可视化配置底层原理
人工智能·架构