ClaudeCode 的记忆机制:短期记忆、长期记忆与模型输入全链路拆解
这篇文章基于 claude-code 的 TypeScript 源码,系统梳理 ClaudeCode 是怎么“记住东西”的。
很多人会直觉把“记忆”理解成一个统一的 memory 模块,但 ClaudeCode 实际上不是这么实现的。它把“记忆”拆成了几层:
- 短期记忆:当前会话正在发生的上下文,核心是内存里的消息数组、transcript JSONL,以及 session memory;
- 长期记忆:跨会话持久化的 file-based memory system,核心是
MEMORY.md + topic files; - Agent 级长期记忆:某个 agent 自己独立的一份长期记忆,按
user / project / local做 scope。
如果用一句话概括,就是:
ClaudeCode 不是用一个数据库来统一记忆,而是用“内存态消息 + 增量 transcript + 会话摘要 + 文件化长期记忆”拼出一套可恢复、可压缩、可跨会话重用的机制。
1. 先说结论:ClaudeCode 是怎么实现短期记忆和长期记忆的
1.1 短期记忆是什么
ClaudeCode 的短期记忆,不只是“当前 prompt 上下文”。
它至少包含三层:
- 运行态内存消息:当前进程里真实参与推理和渲染的
messages; - transcript JSONL:把当前会话消息持续增量写盘,用于
--resume、恢复、继续会话、子 agent sidechain; - 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.tssrc/utils/sessionStorage.tssrc/utils/sessionStorage.ts
所以第二个结论是:
ClaudeCode 会把主会话和子 agent 会话分别写到各自的 transcript JSONL 中,形成可恢复的原始历史流。
2.3 transcript 会不会放在内存里?
这个问题很容易误解。
答案不是“只放磁盘”,而是:
- 会放在内存里,因为当前推理循环直接依赖
messages; - 也会持续写盘,因为需要恢复、继续会话、崩溃留痕、sidechain 独立记录。
更准确地说,ClaudeCode 的做法是“双写”:
- 当前轮消息先进入内存里的
mutableMessages/messagesRef; - 然后按规则增量写到 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 文件并开始写盘。
具体意思是:
- 在会话刚启动时,它不会立刻创建
.jsonltranscript 文件; - 一些启动阶段的元数据、状态更新会先缓存在内存里;
- 第一次出现真正的
user或assistant消息时,它才:- 创建 session transcript 文件;
- 把之前缓存的元数据补写进去;
- 再把缓冲区里的 entry flush 到文件。
这么设计主要是为了避免生成很多“只有元数据、没有真实对话内容”的空壳 session 文件。
2.4.2 什么叫“真正的 user / assistant 消息”
这里指的是进入消息模型的正式对话消息,不是所有 UI 状态。
通常会发生在这些时刻:
- 你在 REPL 中按回车提交一条问题,生成
usermessage; - 模型开始正式回答,生成
assistantmessage; - 工具执行过程中的后续链路继续产生正式消息。
而像这些则不一定单独触发 materialize:
progressattachmentsystemqueue 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。
这意味着:
- 它不是把整段历史重写一遍;
- 它会先过滤,只保留可记录消息;
- 再检查哪些消息已经落过盘;
- 只把新的消息 append 进去。
所以如果你问:
持久化再写到磁盘 transcript 这里是每次聊天都会加上吗?
更准确的回答是:
- 是,正常对话过程中会持续追加到 transcript;
- 但不是等整轮结束才统一写;
- 也不是界面上所有临时状态都一条不落写进去;
- 它会过滤、去重、按消息类型决定。
一句话总结:
这个 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。
它记录了三件很关键的事:
- 什么时候开始初始化 session memory;
- 上次摘要推进到了哪条 message;
- 距离上次摘要后又增长了多少上下文。
也就是说,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。
这说明长期记忆的结构设计非常明确:
MEMORY.md只是索引,不承载完整记忆正文;- 真正的记忆写在 topic files 里;
MEMORY.md在新会话里总是优先加载;- 索引文件有行数和字节数上限,防止它自己失控膨胀。
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。
这里有两个关键点:
- ClaudeCode 不是“等用户提到记忆”才去读,而是会在 system prompt 层把 memory 机制接入;
- 长期记忆目录会在会话开始时准备好,这样模型后续可以直接写,不需要先
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。
这意味着长期记忆的召回策略是:
MEMORY.md索引常驻;- 具体 topic files 并不是全量注入;
- 当前问题来了以后,先筛头部描述,再只挑相关的 memory files;
- 最多挑少量高相关记忆,避免 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。
这个设计非常值得注意:
- 它不是把 transcript 全部无脑转成长记忆;
- 它会等一轮完整 query 结束后再跑;
- 它走的是 forked agent 模式,不阻塞主线程;
- 它沉淀的是 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 的记忆机制总结成下面这张逻辑图:
当前会话先跑在内存里
messagesRef / mutableMessages / query state.messages当前会话持续写 transcript JSONL
主会话与子 agent 各自写自己的 transcript会话过长后抽出 session memory
用于 compact、续跑和上下文压缩会话结束后抽取 durable memories
进入MEMORY.md + topic files新会话再自动加载长期记忆
MEMORY.md常驻,相关 topic files 按需召回特定 agent 还能有自己的独立 memory scope
user / project / local
这套设计比“统一数据库存 memory”复杂,但也更符合 ClaudeCode 这种本地 agent 工具的实际需求:
- 可离线;
- 可恢复;
- 可解释;
- 可按项目隔离;
- 可按 agent 隔离;
- 可压缩;
- 可增量追加。
7. 最后的总结
ClaudeCode 的记忆机制,最值得学的地方不是某一个函数,而是它把“记忆”做成了一套分层系统:
- 短期记忆不是只有内存,而是内存态消息、transcript JSONL、session memory 三层协作;
- 长期记忆不是 transcript 回放,而是结构化、文件化、可筛选、可沉淀的 memory system;
- transcript 更像增量日志流,不是“每轮结束后的一次性快照”;
- materialize session file 这类实现细节很工程化,说明它非常在意“不要过早创建空壳 session 文件”;
- agent memory scope 让“不同 agent 拥有不同长期记忆边界”成为可能。
如果用一句话收尾:
ClaudeCode 的“记忆”并不是一个模块,而是一套跨运行态、持久化、摘要层和文件知识库的分层设计。
这也是为什么它既能在当前会话里保持上下文,又能在未来会话里“记得你是谁、你偏好什么、这个项目曾经发生过什么”。
8. 这些记忆最终是怎么用到大模型里的
前面讲的是“记忆怎么存”。这一节补上“记忆怎么喂给模型”。
这是理解 ClaudeCode 很关键的一层,因为很多人会问:
- 这些 memory 会不会全部原样塞给模型?
- 是用一个专门的 memory 参数传进去,还是拼到消息里?
- 到模型 API 那一层,记忆有没有
user / assistant这样的标记?
先说结论:
ClaudeCode 不会把所有记忆全量塞给模型。它会把不同层级的记忆,分别以
systemPrompt和meta 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。
这说明:
- 一部分上下文是走
systemPrompt; - 一部分上下文是走
messages; - 最终模型 API 并不是“直接读内存目录”,而是读 ClaudeCode 组装好的 prompt 和消息序列。
8.2 长期记忆不会全量输出给模型
不会。
ClaudeCode 对长期记忆是分层注入的:
MEMORY.md索引:作为 memory prompt 的一部分,进入systemPrompt;- 相关 topic memory files:只挑与当前问题相关的少量文件,转成 attachment,再进消息序列;
- 已经注入过的 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.ts 的 memoryFilesToAttachments() 里。
而真正送 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 那一层,最终只剩:
UserMessageAssistantMessage
attachment 不会以“attachment”这种内部类型直接出现在 API 请求里。
更具体一点,memory attachment 会被转成带 isMeta: true 的 user 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。
所以答案是:
- 有
user / assistant标记; - memory attachment 最终会被包装成 meta user message;
- 而且通常会包在
<system-reminder>这一类包装里,让模型知道这是一段辅助上下文,而不是用户此刻的新提问。
如果把这层工程化描述再说得更直白一点,就是:
- 到模型 API 之前,ClaudeCode 内部当然有很多消息类型,比如
attachment、progress、system; - 但真正发给模型时,会先归一化成标准的
user / assistant对话结构; - 其中记忆类内容通常不是普通用户提问,而是
isMeta: true的usermessage; - 同时它经常被包在
<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.ts 的 prependUserContext() 里。
这也进一步说明:
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 的记忆使用方式大致是:
memory system 规则 + MEMORY.md 索引
主要进入systemPrompt相关长期记忆 / nested memory / session memory
先变成内部 attachment,再在 API 归一化时转成meta user message最终发给模型的消息形态
只有UserMessage | AssistantMessage不是全量输出
而是“索引常驻 + 相关内容按需召回 + 已读去重”
所以如果要用一句非常工程化的话概括:
ClaudeCode 不是把 memory 当作一个数据库表直接扔给模型,而是把 memory 分成“system prompt 常驻索引”和“按需注入的 meta user messages”两类,再通过 normalize 流程组装成标准的模型输入。