ClaudeCode 的记忆机制:短期记忆、长期记忆与模型输入全链路拆解

ClaudeCode 的记忆机制:短期记忆、长期记忆与模型输入全链路拆解

这篇文章基于 claude-code 的 TypeScript 源码,系统梳理 ClaudeCode 是怎么“记住东西”的。

很多人会直觉把“记忆”理解成一个统一的 memory 模块,但 ClaudeCode 实际上不是这么实现的。它把“记忆”拆成了几层:

  1. 短期记忆:当前会话正在发生的上下文,核心是内存里的消息数组、transcript JSONL,以及 session memory;
  2. 长期记忆:跨会话持久化的 file-based memory system,核心是 MEMORY.md + topic files
  3. Agent 级长期记忆:某个 agent 自己独立的一份长期记忆,按 user / project / local 做 scope。

如果用一句话概括,就是:

ClaudeCode 不是用一个数据库来统一记忆,而是用“内存态消息 + 增量 transcript + 会话摘要 + 文件化长期记忆”拼出一套可恢复、可压缩、可跨会话重用的机制。


1. 先说结论:ClaudeCode 是怎么实现短期记忆和长期记忆的

1.1 短期记忆是什么

ClaudeCode 的短期记忆,不只是“当前 prompt 上下文”。

它至少包含三层:

  1. 运行态内存消息:当前进程里真实参与推理和渲染的 messages
  2. transcript JSONL:把当前会话消息持续增量写盘,用于 --resume、恢复、继续会话、子 agent sidechain;
  3. session memory:当上下文变长后,抽取一份会话级摘要,避免每次都带着整段历史跑。

也就是说,ClaudeCode 的短期记忆并不是单纯“放内存”或者“放磁盘”,而是同时存在:

  • 内存负责实时推理和 UI;
  • transcript负责恢复和留痕;
  • session memory负责压缩和续航。

1.2 长期记忆是什么

ClaudeCode 的长期记忆是一套 文件化 memory system

它不是把长期记忆塞到数据库,也不是直接复用 transcript,而是在项目对应的 memory 目录下维护:

  • 一个 MEMORY.md 作为索引;
  • 多个 topic memory files 作为真正的内容承载;
  • 一个检索与加载机制,在新会话里把相关记忆重新注入 prompt;
  • 一个后台抽取机制,把当前会话里“值得长期保留”的信息写回 memory 文件。

从产品视角看,这是一种“把值得保留的信息,从一次对话里沉淀成未来还可复用的知识文件”的设计。


2. 短期记忆:当前会话到底怎么被记住

2.1 第一层:会话首先是运行在内存里的

ClaudeCode 在运行时并不是每一步都去磁盘读 transcript,而是先把当前消息保存在内存里。

query() 的输入就明确带了 messages: Message[]

export type QueryParams = {
  messages: Message[]
  systemPrompt: SystemPrompt
  userContext: { [k: string]: string }
  systemContext: { [k: string]: string }
  canUseTool: CanUseToolFn
  toolUseContext: ToolUseContext
  ...
}

type State = {
  messages: Message[]
  toolUseContext: ToolUseContext
  ...
}

这段代码在 src/query.ts 里,说明查询主循环天然就是以内存中的 messages 作为输入。

前端 REPL 这边也明确把 messagesRef 当作当前真实状态:

// ref is source of truth, React state is the render projection.
const setMessages = useCallback((action: React.SetStateAction) => {
  const prev = messagesRef.current;
  const next = typeof action === 'function' ? action(messagesRef.current) : action;
  messagesRef.current = next;
  ...
});

这段在 src/screens/REPL.tsx。注释已经说得很直接了:ref 是 source of truth,React state 只是 render projection

所以第一个结论是:

ClaudeCode 当前会话的“活上下文”首先是放在内存里的,不是每次都从 transcript 读回再推理。


2.2 第二层:会话原始历史会持续写到 transcript JSONL

虽然运行态在内存里,但 ClaudeCode 也会把会话历史持续写到磁盘 transcript。

主会话 transcript 路径生成逻辑:

export function getTranscriptPath(): string {
  const projectDir = getSessionProjectDir() ?? getProjectDir(getOriginalCwd())
  return join(projectDir, `${getSessionId()}.jsonl`)
}

子 agent transcript 路径生成逻辑:

export function getAgentTranscriptPath(agentId: AgentId): string {
  const projectDir = getSessionProjectDir() ?? getProjectDir(getOriginalCwd())
  const sessionId = getSessionId()
  const subdir = agentTranscriptSubdirs.get(agentId)
  const base = subdir
    ? join(projectDir, sessionId, 'subagents', subdir)
    : join(projectDir, sessionId, 'subagents')
  return join(base, `agent-${agentId}.jsonl`)
}

sidechain 写入入口:

export async function recordSidechainTranscript(
  messages: Message[],
  agentId?: string,
  startingParentUuid?: UUID | null,
) {
  await getProject().insertMessageChain(
    cleanMessagesForLogging(messages),
    true,
    agentId,
    startingParentUuid,
  )
}

这几段分别来自:

  • src/utils/sessionStorage.ts
  • src/utils/sessionStorage.ts
  • src/utils/sessionStorage.ts

所以第二个结论是:

ClaudeCode 会把主会话和子 agent 会话分别写到各自的 transcript JSONL 中,形成可恢复的原始历史流。


2.3 transcript 会不会放在内存里?

这个问题很容易误解。

答案不是“只放磁盘”,而是:

  • 会放在内存里,因为当前推理循环直接依赖 messages
  • 也会持续写盘,因为需要恢复、继续会话、崩溃留痕、sidechain 独立记录。

更准确地说,ClaudeCode 的做法是“双写”:

  1. 当前轮消息先进入内存里的 mutableMessages / messagesRef
  2. 然后按规则增量写到 transcript JSONL。

例如工具执行时:

if (!alreadyPresent) {
  mutableMessages.push(assistantMessage)
  if (persistSession) {
    await recordTranscript(mutableMessages)
  }
}

...

if (update.message) {
  mutableMessages.push(update.message)
  if (persistSession) {
    await recordTranscript(mutableMessages)
  }
}

这段在 src/utils/queryHelpers.ts

所以短期记忆里的 transcript 更像一份 增量日志副本,不是替代内存,而是补足恢复能力。


2.4 transcript 是每次聊天都会加上吗?

是,但要更精确地说,是“持续追加”,而不是“每轮结束后整段重写”。

2.4.1 什么时候真正创建 session file

ClaudeCode 刚启动时,并不会立刻创建 .jsonl transcript 文件。

关键逻辑在 materializeSessionFile()

/**
 * Create the session file, write cached startup metadata, and flush
 * buffered entries. Called on the first user/assistant message.
 */
private async materializeSessionFile(): Promise {
  if (this.shouldSkipPersistence()) return
  this.ensureCurrentSessionFile()
  this.reAppendSessionMetadata()
  if (this.pendingEntries.length > 0) {
    const buffered = this.pendingEntries
    this.pendingEntries = []
    for (const entry of buffered) {
      await this.appendEntry(entry)
    }
  }
}

真正触发它的判断:

// First user/assistant message materializes the session file.
// Hook progress/attachment messages alone stay buffered.
if (
  this.sessionFile === null &&
  messages.some(m => m.type === 'user' || m.type === 'assistant')
) {
  await this.materializeSessionFile()
}

这两段都在 src/utils/sessionStorage.ts

这里的 materialize session file 可以理解成:

本来只是内存里准备着的 session 持久化对象,在第一次真正发生对话时,才正式创建对应的 transcript 文件并开始写盘。

具体意思是:

  1. 在会话刚启动时,它不会立刻创建 .jsonl transcript 文件;
  2. 一些启动阶段的元数据、状态更新会先缓存在内存里;
  3. 第一次出现真正的 userassistant 消息时,它才:
    • 创建 session transcript 文件;
    • 把之前缓存的元数据补写进去;
    • 再把缓冲区里的 entry flush 到文件。

这么设计主要是为了避免生成很多“只有元数据、没有真实对话内容”的空壳 session 文件。

2.4.2 什么叫“真正的 user / assistant 消息”

这里指的是进入消息模型的正式对话消息,不是所有 UI 状态。

通常会发生在这些时刻:

  1. 你在 REPL 中按回车提交一条问题,生成 user message;
  2. 模型开始正式回答,生成 assistant message;
  3. 工具执行过程中的后续链路继续产生正式消息。

而像这些则不一定单独触发 materialize:

  • progress
  • attachment
  • system
  • queue operation

所以如果只有启动提示、hook 进度、附件提示,而没有真正的问答,它不会建 transcript 文件。

2.4.3 真正写盘时,是不是每次整段重写

不是。ClaudeCode 这里更像一条 增量日志流

recordTranscript()

export async function recordTranscript(
  messages: Message[],
  teamInfo?: TeamInfo,
  startingParentUuidHint?: UUID,
  allMessages?: readonly Message[],
): Promise {
  const cleanedMessages = cleanMessagesForLogging(messages, allMessages)
  const sessionId = getSessionId() as UUID
  const messageSet = await getSessionMessages(sessionId)
  const newMessages: typeof cleanedMessages = []
  ...
  for (const m of cleanedMessages) {
    if (messageSet.has(m.uuid as UUID)) {
      ...
    } else {
      newMessages.push(m)
      seenNewMessage = true
    }
  }
  if (newMessages.length > 0) {
    await getProject().insertMessageChain(
      newMessages,
      false,
      undefined,
      startingParentUuid,
      teamInfo,
    )
  }
}

再看 cleanMessagesForLogging()

export function cleanMessagesForLogging(
  messages: Message[],
  allMessages: readonly Message[] = messages,
): Transcript {
  const filtered = messages.filter(isLoggableMessage) as Transcript
  return getUserType() !== 'ant'
    ? transformMessagesForExternalTranscript(
        filtered,
        collectReplIds(allMessages),
      )
    : filtered
}

这两段都在 src/utils/sessionStorage.ts

这意味着:

  1. 它不是把整段历史重写一遍;
  2. 它会先过滤,只保留可记录消息;
  3. 再检查哪些消息已经落过盘;
  4. 只把新的消息 append 进去。

所以如果你问:

持久化再写到磁盘 transcript 这里是每次聊天都会加上吗?

更准确的回答是:

  1. ,正常对话过程中会持续追加到 transcript;
  2. 但不是等整轮结束才统一写;
  3. 也不是界面上所有临时状态都一条不落写进去;
  4. 它会过滤、去重、按消息类型决定。

一句话总结:

这个 transcript 更像“增量日志流”,不是“每轮结束后存一份快照”。


2.5 第三层:session memory 是短期记忆的压缩层

当会话变长时,仅靠完整 transcript 和当前内存消息不够高效,ClaudeCode 又加了一层 session memory

这层本质上是“同一会话内的摘要记忆”,它并不是长期知识库,而是为了解决上下文膨胀。

先看配置:

export const DEFAULT_SESSION_MEMORY_CONFIG: SessionMemoryConfig = {
  minimumMessageTokensToInit: 10000,
  minimumTokensBetweenUpdate: 5000,
  toolCallsBetweenUpdates: 3,
}

let lastSummarizedMessageId: string | undefined
let extractionStartedAt: number | undefined
let tokensAtLastExtraction = 0
let sessionMemoryInitialized = false

读取 session memory 文件:

export async function getSessionMemoryContent(): Promise {
  const fs = getFsImplementation()
  const memoryPath = getSessionMemoryPath()

  try {
    const content = await fs.readFile(memoryPath, { encoding: 'utf-8' })
    logEvent('tengu_session_memory_loaded', {
      content_length: content.length,
    })
    return content
  } catch (e: unknown) {
    if (isFsInaccessible(e)) return null
    throw e
  }
}

这段在 src/services/SessionMemory/sessionMemoryUtils.ts

它记录了三件很关键的事:

  1. 什么时候开始初始化 session memory;
  2. 上次摘要推进到了哪条 message;
  3. 距离上次摘要后又增长了多少上下文。

也就是说,session memory 并不是“固定每轮都重做”,而是一个带阈值、带游标、带更新间隔的会话摘要机制。

2.5.1 compact 时为什么优先尝试 session memory compaction

/compact 的逻辑里,ClaudeCode 优先尝试 session memory compaction:

if (!customInstructions) {
  const sessionMemoryResult = await trySessionMemoryCompaction(
    messages,
    context.agentId,
  )
  if (sessionMemoryResult) {
    getUserContext.cache.clear?.()
    runPostCompactCleanup()
    ...
    return {
      type: 'compact',
      compactionResult: sessionMemoryResult,
      displayText: buildDisplayText(context),
    }
  }
}

这段在 src/commands/compact/compact.ts

这说明 ClaudeCode 的 compact 思路不是“粗暴清历史”,而是优先走一条更精细的路径:

  • 保留会话级摘要;
  • 保留必要边界;
  • 压缩旧上下文;
  • 让当前会话还能自然续跑。

2.5.2 query 时怎么把 session memory 再注回当前轮

query() 里有 memory prefetch consume:

if (
  pendingMemoryPrefetch &&
  pendingMemoryPrefetch.settledAt !== null &&
  pendingMemoryPrefetch.consumedOnIteration === -1
) {
  const memoryAttachments = filterDuplicateMemoryAttachments(
    await pendingMemoryPrefetch.promise,
    toolUseContext.readFileState,
  )
  for (const memAttachment of memoryAttachments) {
    const msg = createAttachmentMessage(memAttachment)
    yield msg
    toolResults.push(msg)
  }
  pendingMemoryPrefetch.consumedOnIteration = turnCount - 1
}

这段在 src/query.ts

这说明 session memory 和 memory files 并不是只存在磁盘里,它们会在合适的时机被重新转成 attachment,注回当前轮推理上下文。


3. 长期记忆:ClaudeCode 为什么不用 transcript 直接做长期记忆

先说结论:transcript 不是长期记忆。

transcript 的职责是“完整留痕”,而长期记忆的职责是“保留未来会复用的高价值知识”。

这两者并不一样:

  • transcript 太长、太原始、太噪音;
  • 长期记忆需要按主题组织、可筛选、可更新、可删除;
  • transcript 记录的是“发生过什么”;
  • 长期记忆记录的是“未来还值得知道什么”。

所以 ClaudeCode 单独设计了一套 file-based memory system。


4. 长期记忆的实现:file-based memory system

4.1 memory 目录怎么组织

ClaudeCode 的 auto memory 默认落到:

export const getAutoMemPath = memoize(
  (): string => {
    const override = getAutoMemPathOverride() ?? getAutoMemPathSetting()
    if (override) {
      return override
    }
    const projectsDir = join(getMemoryBaseDir(), 'projects')
    return (
      join(projectsDir, sanitizePath(getAutoMemBase()), AUTO_MEM_DIRNAME) + sep
    ).normalize('NFC')
  },
  () => getProjectRoot(),
)

这段在 src/memdir/paths.ts

默认路径可以理解成:

~/.claude/projects//memory/

这很关键,因为它意味着长期记忆是按项目 git root 做隔离的,而不是全局混在一起。


4.2 长期记忆的结构不是一个文件,而是索引 + 主题文件

ClaudeCode 明确要求 memory 用 MEMORY.md + topic files 的结构来维护。

核心提示构造在 buildMemoryLines()

'Saving a memory is a two-step process:',

'**Step 1** — write the memory to its own file...',

`**Step 2** — add a pointer to that file in \`${ENTRYPOINT_NAME}\`. \`${ENTRYPOINT_NAME}\` is an index, not a memory ... Never write memory content directly into \`${ENTRYPOINT_NAME}\`.`,

`- \`${ENTRYPOINT_NAME}\` is always loaded into your conversation context ...`,

以及:

if (entrypointContent.trim()) {
  const t = truncateEntrypointContent(entrypointContent)
  ...
  lines.push(`## ${ENTRYPOINT_NAME}`, '', t.content)
} else {
  lines.push(
    `## ${ENTRYPOINT_NAME}`,
    '',
    `Your ${ENTRYPOINT_NAME} is currently empty. When you save new memories, they will appear here.`,
  )
}

这段在 src/memdir/memdir.ts

这说明长期记忆的结构设计非常明确:

  1. MEMORY.md 只是索引,不承载完整记忆正文;
  2. 真正的记忆写在 topic files 里;
  3. MEMORY.md 在新会话里总是优先加载;
  4. 索引文件有行数和字节数上限,防止它自己失控膨胀。

4.3 长期记忆是怎么在新会话中自动加载的

ClaudeCode 会把 memory prompt 放进 system prompt。

src/constants/prompts.ts

return [
  ...
  await loadMemoryPrompt(),
  envInfo,
  ...
].filter(s => s !== null)

...

const dynamicSections = [
  systemPromptSection('memory', () => loadMemoryPrompt()),
  ...
]

loadMemoryPrompt() 会返回 auto memory 的说明与内容:

if (autoEnabled) {
  const autoDir = getAutoMemPath()
  await ensureMemoryDirExists(autoDir)
  return buildMemoryLines(
    'auto memory',
    autoDir,
    extraGuidelines,
    skipIndex,
  ).join('\n')
}

这段在 src/memdir/memdir.ts

这里有两个关键点:

  1. ClaudeCode 不是“等用户提到记忆”才去读,而是会在 system prompt 层把 memory 机制接入;
  2. 长期记忆目录会在会话开始时准备好,这样模型后续可以直接写,不需要先 mkdir

4.4 长期记忆不是全量加载,而是“索引常驻 + 相关记忆按需召回”

ClaudeCode 对长期记忆的处理很克制,并不是每次把所有 topic files 全塞进去。

它先扫 memory headers,再让一个 side query 去选相关记忆:

/**
 * Find memory files relevant to a query by scanning memory file headers
 * and asking Sonnet to select the most relevant ones.
 */
export async function findRelevantMemories(
  query: string,
  memoryDir: string,
  signal: AbortSignal,
  recentTools: readonly string[] = [],
  alreadySurfaced: ReadonlySet = new Set(),
): Promise {
  const memories = (await scanMemoryFiles(memoryDir, signal)).filter(
    m => !alreadySurfaced.has(m.filePath),
  )
  if (memories.length === 0) {
    return []
  }
  ...
  return selected.map(m => ({ path: m.filePath, mtimeMs: m.mtimeMs }))
}

这段在 src/memdir/findRelevantMemories.ts

这意味着长期记忆的召回策略是:

  1. MEMORY.md 索引常驻;
  2. 具体 topic files 并不是全量注入;
  3. 当前问题来了以后,先筛头部描述,再只挑相关的 memory files;
  4. 最多挑少量高相关记忆,避免 prompt 继续膨胀。

从工程视角看,这是一个很合理的折中:

  • 不用把所有记忆带上;
  • 也不是完全依赖模型盲搜;
  • 而是把“结构化索引 + relevance selection”结合起来。

4.5 长期记忆怎么从当前会话里沉淀出来

ClaudeCode 有一个后台的 extract memories 机制。

文件开头已经把意图写得很清楚:

/**
 * Extracts durable memories from the current session transcript
 * and writes them to the auto-memory directory (~/.claude/projects//memory/).
 *
 * It runs once at the end of each complete query loop (when the model produces
 * a final response with no tool calls) via handleStopHooks in stopHooks.ts.
 *
 * Uses the forked agent pattern (runForkedAgent) — a perfect fork of the main
 * conversation that shares the parent's prompt cache.
 */

并且它只看“模型可见消息”:

function isModelVisibleMessage(message: Message): boolean {
  return message.type === 'user' || message.type === 'assistant'
}

这段在 src/services/extractMemories/extractMemories.ts

这个设计非常值得注意:

  1. 它不是把 transcript 全部无脑转成长记忆;
  2. 它会等一轮完整 query 结束后再跑;
  3. 它走的是 forked agent 模式,不阻塞主线程;
  4. 它沉淀的是 durable memories,而不是所有对话碎片。

换句话说,长期记忆不是“自动把聊天记录存一份”,而是“自动从聊天记录中抽取值得长期保留的部分”。


5. Agent 级长期记忆:某个 agent 自己也可以有 memory

除了项目级 auto memory,ClaudeCode 还支持 agent 自己带一份长期记忆。

agent memory 支持三种 scope:

// Persistent agent memory scope: 'user' (~/.claude/agent-memory/), 'project' (.claude/agent-memory/), or 'local' (.claude/agent-memory-local/)
export type AgentMemoryScope = 'user' | 'project' | 'local'

目录规则:

export function getAgentMemoryDir(
  agentType: string,
  scope: AgentMemoryScope,
): string {
  const dirName = sanitizeAgentTypeForPath(agentType)
  switch (scope) {
    case 'project':
      return join(getCwd(), '.claude', 'agent-memory', dirName) + sep
    case 'local':
      return getLocalAgentMemoryDir(dirName)
    case 'user':
      return join(getMemoryBaseDir(), 'agent-memory', dirName) + sep
  }
}

加载 prompt:

export function loadAgentMemoryPrompt(
  agentType: string,
  scope: AgentMemoryScope,
): string {
  ...
  const memoryDir = getAgentMemoryDir(agentType, scope)
  void ensureMemoryDirExists(memoryDir)
  return buildMemoryPrompt({
    displayName: 'Persistent Agent Memory',
    memoryDir,
    extraGuidelines: ...,
  })
}

这段在 src/tools/AgentTool/agentMemory.ts

这说明 ClaudeCode 不只是“用户和项目有记忆”,而是:

某个 agent 也可以拥有自己的长期记忆目录,并按 scope 决定它是全局共享、项目共享,还是本地私有。


6. 把整条链路串起来:ClaudeCode 的记忆分层模型

如果把整条链路合并来看,我会把 ClaudeCode 的记忆机制总结成下面这张逻辑图:

  1. 当前会话先跑在内存里
    messagesRef / mutableMessages / query state.messages

  2. 当前会话持续写 transcript JSONL
    主会话与子 agent 各自写自己的 transcript

  3. 会话过长后抽出 session memory
    用于 compact、续跑和上下文压缩

  4. 会话结束后抽取 durable memories
    进入 MEMORY.md + topic files

  5. 新会话再自动加载长期记忆
    MEMORY.md 常驻,相关 topic files 按需召回

  6. 特定 agent 还能有自己的独立 memory scope
    user / project / local

这套设计比“统一数据库存 memory”复杂,但也更符合 ClaudeCode 这种本地 agent 工具的实际需求:

  • 可离线;
  • 可恢复;
  • 可解释;
  • 可按项目隔离;
  • 可按 agent 隔离;
  • 可压缩;
  • 可增量追加。

7. 最后的总结

ClaudeCode 的记忆机制,最值得学的地方不是某一个函数,而是它把“记忆”做成了一套分层系统:

  1. 短期记忆不是只有内存,而是内存态消息、transcript JSONL、session memory 三层协作;
  2. 长期记忆不是 transcript 回放,而是结构化、文件化、可筛选、可沉淀的 memory system;
  3. transcript 更像增量日志流,不是“每轮结束后的一次性快照”;
  4. materialize session file 这类实现细节很工程化,说明它非常在意“不要过早创建空壳 session 文件”;
  5. agent memory scope 让“不同 agent 拥有不同长期记忆边界”成为可能。

如果用一句话收尾:

ClaudeCode 的“记忆”并不是一个模块,而是一套跨运行态、持久化、摘要层和文件知识库的分层设计。

这也是为什么它既能在当前会话里保持上下文,又能在未来会话里“记得你是谁、你偏好什么、这个项目曾经发生过什么”。


8. 这些记忆最终是怎么用到大模型里的

前面讲的是“记忆怎么存”。这一节补上“记忆怎么喂给模型”。

这是理解 ClaudeCode 很关键的一层,因为很多人会问:

  1. 这些 memory 会不会全部原样塞给模型?
  2. 是用一个专门的 memory 参数传进去,还是拼到消息里?
  3. 到模型 API 那一层,记忆有没有 user / assistant 这样的标记?

先说结论:

ClaudeCode 不会把所有记忆全量塞给模型。它会把不同层级的记忆,分别以 systemPromptmeta user message 两种方式送给模型。

8.1 模型调用入口长什么样

真正调用模型时,ClaudeCode 传进去的是两块:

for await (const message of deps.callModel({
  messages: prependUserContext(messagesForQuery, userContext),
  systemPrompt: fullSystemPrompt,
  thinkingConfig: toolUseContext.options.thinkingConfig,
  tools: toolUseContext.options.tools,
  ...
})) {

这段在 src/query.ts

这说明:

  1. 一部分上下文是走 systemPrompt
  2. 一部分上下文是走 messages
  3. 最终模型 API 并不是“直接读内存目录”,而是读 ClaudeCode 组装好的 prompt 和消息序列。

8.2 长期记忆不会全量输出给模型

不会。

ClaudeCode 对长期记忆是分层注入的:

  1. MEMORY.md 索引:作为 memory prompt 的一部分,进入 systemPrompt
  2. 相关 topic memory files:只挑与当前问题相关的少量文件,转成 attachment,再进消息序列;
  3. 已经注入过的 memory:会去重,不会每轮都重复塞。

所以它不是:

  • 把整个 memory/ 目录打包发给模型;
  • 也不是把全部 topic files 每次全量重放。

而是:

  • 索引常驻;
  • 具体记忆按需召回;
  • 已读记忆做去重。

这点从 findRelevantMemories() 就能看出来:

/**
 * Find memory files relevant to a query by scanning memory file headers
 * and asking Sonnet to select the most relevant ones.
 *
 * Returns absolute file paths + mtime of the most relevant memories
 * (up to 5). Excludes MEMORY.md (already loaded in system prompt).
 */
export async function findRelevantMemories(...) { ... }

这里最关键的一句其实是注释:

Excludes MEMORY.md (already loaded in system prompt).

也就是说:

  • MEMORY.md 已经在 system prompt 里;
  • 额外召回的相关记忆最多只取少量候选;
  • 不是全量。

8.3 哪些记忆是通过 system prompt 进入模型的

项目级 auto memory 的“机制说明 + MEMORY.md 索引”主要是走 systemPrompt

prompts.ts

return [
  ...
  await loadMemoryPrompt(),
  envInfo,
  ...
].filter(s => s !== null)

const dynamicSections = [
  systemPromptSection('memory', () => loadMemoryPrompt()),
  ...
]

loadMemoryPrompt() 里会返回:

  • memory system 的规则;
  • memory 目录路径;
  • MEMORY.md 当前内容或占位提示。

所以你可以把它理解成:

ClaudeCode 会先把“怎么使用记忆系统 + 当前索引内容”放进 system prompt,让模型在整轮对话里都带着这份长期记忆索引。

这也是为什么 MEMORY.md 不应该写太长。它本质上是一份常驻的 prompt 资产,而不是无限增长的原始知识库。


8.4 哪些记忆是通过消息 attachment 进入模型的

更细的记忆内容,比如相关 topic files、nested memory,并不是放进 systemPrompt,而是转成 attachment,再在 API 预处理阶段转成 user message

先看 attachment 是怎么创建的:

export function createAttachmentMessage(
  attachment: Attachment,
): AttachmentMessage {
  return {
    attachment,
    type: 'attachment',
    uuid: randomUUID(),
    timestamp: new Date().toISOString(),
  }
}

这段在 src/utils/attachments.ts

再看 memory files 怎么变 attachment:

attachments.push({
  type: 'nested_memory',
  path: memoryFile.path,
  content: memoryFile,
  displayPath: relative(getCwd(), memoryFile.path),
})

这段在 src/utils/attachments.tsmemoryFilesToAttachments() 里。

而真正送 API 前,attachment 会被 normalizeMessagesForAPI() 转成普通消息:

case 'attachment': {
  const rawAttachmentMessage = normalizeAttachmentForAPI(
    message.attachment,
  )
  ...
  result.push(...attachmentMessage)
  return
}

这段在 src/utils/messages.ts

所以 attachment 本身不是 API 终态。它只是 ClaudeCode 内部的一种消息中间形态。


8.5 记忆在 API 层有没有 user / assistant 这样的标记

有。

ClaudeCode 在真正发给模型之前,会把内部消息归一化成:

export function normalizeMessagesForAPI(
  messages: Message[],
  tools: Tools = [],
): (UserMessage | AssistantMessage)[] { ... }

也就是说,到模型 API 那一层,最终只剩:

  • UserMessage
  • AssistantMessage

attachment 不会以“attachment”这种内部类型直接出现在 API 请求里。

更具体一点,memory attachment 会被转成带 isMeta: trueuser message。

比如 nested_memory

case 'nested_memory': {
  return wrapMessagesInSystemReminder([
    createUserMessage({
      content: `Contents of ${attachment.content.path}:\n\n${attachment.content.content}`,
      isMeta: true,
    }),
  ])
}

相关记忆 relevant_memories 也是:

case 'relevant_memories': {
  return wrapMessagesInSystemReminder(
    attachment.memories.map(m => {
      const header = m.header ?? memoryHeader(m.path, m.mtimeMs)
      return createUserMessage({
        content: `${header}\n\n${m.content}`,
        isMeta: true,
      })
    }),
  )
}

这两段都在 src/utils/messages.ts

所以答案是:

  1. user / assistant 标记
  2. memory attachment 最终会被包装成 meta user message
  3. 而且通常会包在 <system-reminder> 这一类包装里,让模型知道这是一段辅助上下文,而不是用户此刻的新提问。

如果把这层工程化描述再说得更直白一点,就是:

  • 到模型 API 之前,ClaudeCode 内部当然有很多消息类型,比如 attachmentprogresssystem
  • 但真正发给模型时,会先归一化成标准的 user / assistant 对话结构;
  • 其中记忆类内容通常不是普通用户提问,而是 isMeta: trueuser message;
  • 同时它经常被包在 <system-reminder> ... </system-reminder> 里,明确告诉模型“这是辅助上下文,不是此刻用户的新问题”。

所以如果你在工程上问“ClaudeCode 是不是把 memory 当成一段普通聊天文本直接拼进去”,答案也是否定的。它更像是:

把记忆包装成带角色、带 meta 标记、带 system reminder 包裹的上下文消息,再送入标准的消息数组。


8.6 userContext 也是以 meta user message 的方式进模型的

除了长期记忆 attachment,ClaudeCode 还有一类上下文会直接 prepend 到消息最前面:

return [
  createUserMessage({
    content: `\nAs you answer the user's questions, you can use the following context:\n${...}\n\n`,
    isMeta: true,
  }),
  ...messages,
]

这段在 src/utils/api.tsprependUserContext() 里。

这也进一步说明:

ClaudeCode 很多“辅助上下文”并不是挂在独立 memory channel 上,而是被包装成带 isMeta: true 的 user message,再和普通对话消息一起送给模型。


8.7 session memory 怎么进入模型

session memory 的消费逻辑在 query.ts 里:

if (
  pendingMemoryPrefetch &&
  pendingMemoryPrefetch.settledAt !== null &&
  pendingMemoryPrefetch.consumedOnIteration === -1
) {
  const memoryAttachments = filterDuplicateMemoryAttachments(
    await pendingMemoryPrefetch.promise,
    toolUseContext.readFileState,
  )
  for (const memAttachment of memoryAttachments) {
    const msg = createAttachmentMessage(memAttachment)
    yield msg
    toolResults.push(msg)
  }
  pendingMemoryPrefetch.consumedOnIteration = turnCount - 1
}

这说明 session memory 并不是只存在于磁盘摘要文件里。它在合适时机会转成 attachment,再像其他辅助上下文一样进入当前轮消息序列。

同时这里还有一层重要约束:

  • filterDuplicateMemoryAttachments() 会先去重;
  • readFileState 会记录已经注入过的内容;
  • 所以不会每一轮都把同一份记忆反复重放给模型。

8.8 最后的工程化总结

如果从“输入给大模型的最终形态”来总结,ClaudeCode 的记忆使用方式大致是:

  1. memory system 规则 + MEMORY.md 索引
    主要进入 systemPrompt

  2. 相关长期记忆 / nested memory / session memory
    先变成内部 attachment,再在 API 归一化时转成 meta user message

  3. 最终发给模型的消息形态
    只有 UserMessage | AssistantMessage

  4. 不是全量输出
    而是“索引常驻 + 相关内容按需召回 + 已读去重”

所以如果要用一句非常工程化的话概括:

ClaudeCode 不是把 memory 当作一个数据库表直接扔给模型,而是把 memory 分成“system prompt 常驻索引”和“按需注入的 meta user messages”两类,再通过 normalize 流程组装成标准的模型输入。


文章作者: 小风雷
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 小风雷 !
评论
 上一篇
ClaudeCode 的执行机制:ReAct 与 Plan-and-Execute 在 TypeScript 源码中的实现 ClaudeCode 的执行机制:ReAct 与 Plan-and-Execute 在 TypeScript 源码中的实现
基于 ClaudeCode TypeScript 源码,拆解它如何用 query 主循环实现 ReAct,如何通过 plan mode、plan file、approval flow 叠加出 Plan-and-Execute,以及这两套机制分别在什么条件下触发、判断依据是什么、如何持续传递给大模型。
2026-04-06
下一篇 
代码->PRD阅读器的Agent小试牛刀 代码->PRD阅读器的Agent小试牛刀
从 code-as-prd-agent 项目出发,系统拆解 Agent 本质、流式交互价值,以及“克隆代码+交给大模型”为什么能成立。
2026-04-05
  目录