Home Gallery AISPA Paper GitHub Follow

how-claude-code-works system prompt

Category: Coding agents. Audited against the AISPA standard.

1 Prompts on record
0 Flagged instructions
AI audit Audit source
D1 · Identity Transparency D2 · Truthfulness & Information Integrity D3 · Privacy & Data Protection D4 · Tool/Action Safety D5 · User Agency & Manipulation Prevention D6 · Unsafe Request Handling D7 · Harm Prevention & User Safety D8 · Fairness, Inclusion & Neutrality

how-claude-code-works - docs 08 memory system

21845 characters

# 第 6 章:记忆系统 > 没有记忆的 Agent 每次对话都是初见——记忆让 Claude Code 从"无状态工具"进化为"跨会话学习的编程伙伴"。 ## 6.1 为什么 Agent 需要记忆? 想象这样的场景:你连续三天和 Claude Code 在同一个项目上协作。第一天你告诉它"不要在响应末尾总结",第二天你又说了一遍,第三天你开始烦躁——为什么它记不住? 这就是没有记忆的 Agent 的根本问题:**每次会话都从零开始**。用户偏好丢失、项目上下文重置、之前的纠正被遗忘。 Claude Code 的记忆系统解决这个问题,但它不是一个简单的"把所有信息存下来"的系统。它有一个核心约束: > **只记忆不可从当前项目状态推导的信息。** 这个约束不是为了省存储空间,而是为了**防止记忆与现实漂移**。如果记忆记录了"认证模块在 `src/auth/`",一次代码重构就会让这条记忆变成误导。代码模式、架构、git 历史等信息是**自描述的**——从代码本身读取永远比从记忆中回忆更准确。 ### 记忆 vs CLAUDE.md:互补而非竞争 | 维度 | CLAUDE.md | 记忆系统 | |------|-----------|---------| | 性质 | 静态配置文件 | 动态知识库 | | 维护方式 | 用户手动编辑,签入 Git | Agent 自动写入或 `/remember` | | 作用范围 | 团队共享(项目级)或用户全局 | 个人私有(可选团队共享) | | 内容类型 | 项目规范、编码约定、CI 配置 | 用户偏好、行为纠正、项目动态 | | 加载方式 | 每次会话完整加载 | 索引预加载 + 语义召回按需加载 | 两者互补:CLAUDE.md 存"项目是什么",记忆存"和这个人协作时要注意什么"。 关键文件:`src/memdir/` ## 6.2 四种记忆类型:封闭分类法 记忆系统使用**封闭的四类型分类法**(closed taxonomy),每种类型有明确的职责边界和结构要求: ```mermaid graph TB subgraph 个人记忆 ["个人记忆(始终私有)"] direction TB User[user 用户记忆<br/>角色/目标/偏好/知识领域] Feedback[feedback 反馈记忆<br/>用户对行为的纠正与指导<br/>结构:规则 + Why + How to apply] User ~~~ Feedback end subgraph 共享记忆 ["共享记忆(通常团队共享)"] direction TB Project[project 项目记忆<br/>进行中的工作/目标/截止日期<br/>决策与原因<br/>相对日期 → 绝对日期转换] Reference[reference 引用记忆<br/>外部系统指针<br/>信息定位] Project ~~~ Reference end 个人记忆 ~~~ 共享记忆 ``` | 类型 | 记什么 | 示例 | 触发时机 | |------|--------|------|---------| | **user** | 用户身份、偏好、知识背景 | "用户是数据科学家,专注可观测性" | 了解到用户角色/偏好时 | | **feedback** | 对 Agent 行为的纠正 | "不要在响应末尾总结,用户能自己看 diff" | 用户纠正行为时("不要..."、"别再...") | | **project** | 项目进展、决策、截止日期 | "2026-03-05 合并冻结,移动端发布" | 了解到谁在做什么、为什么、截止日期时 | | **reference** | 外部系统的定位信息 | "管道 Bug 追踪在 Linear INGEST 项目" | 了解到外部系统中信息位置时 | **为什么是四种类型而非自由标签?** 封闭分类法强制 Agent 做出明确的语义分类,避免标签膨胀导致召回时的模糊匹配。每种类型有不同的保存结构和使用方式——这让模型在写入和读取时都有明确的行为指引。 ### feedback 类型深度分析:不只记录失败 源码 `memoryTypes.ts` 中 feedback 类型的定义揭示了一个微妙的设计决策——feedback 不仅记录用户的纠正,还记录用户的肯定: ``` Guidance or correction the user has given you. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. ``` 为什么同时记录成功和失败?源码注释中有一段关键解释(意译): > 如果你只保存纠正,你会避免过去的错误,但会偏离用户已经验证过的好方法,并可能变得过于谨慎。 这是一个深刻的观察。假设用户说"这次的代码风格很好,以后就这样写",如果不记录这个正面反馈,Agent 可能在下次会话中"改进"代码风格——结果反而偏离了用户满意的方向。 ### feedback 和 project 的结构化要求 这两种类型要求特定的正文结构: ```markdown 规则或事实本身。 **Why:** 用户给出这个反馈的原因——通常是一个过去的事故或强烈偏好。 **How to apply:** 什么时候/在哪里应用这条指导。 ``` **为什么需要 Why?** 源码提示词中明确说明:"Knowing *why* lets you judge edge cases instead of blindly following the rule." 举个例子:如果记忆只记录"不要 mock 数据库",Agent 会在所有测试中避免 mock。但如果记忆还包含"Why: 上季度 mock 测试通过但生产环境迁移失败",Agent 就能判断——这条规则适用于集成测试,单元测试中的轻量级 mock 可能没问题。 ### project 类型:相对日期 → 绝对日期 project 类型有一个特殊要求:**必须将相对日期转换为绝对日期**。 当用户说"周四之后合并冻结",记忆必须存为"2026-03-05 后合并冻结"。原因很简单:记忆可能在几周后被另一次会话读取,此时"周四"已经毫无意义。 ### 什么不该保存 记忆系统有一个明确的排除列表,来自源码中的 `WHAT_NOT_TO_SAVE_SECTION`: ``` - 代码模式、约定、架构、文件路径、项目结构——读当前代码即可获得 - Git 历史、最近的改动、谁改了什么——git log / git blame 是权威来源 - 调试方案或修复步骤——修复在代码里,上下文在 commit 消息中 - 已经记录在 CLAUDE.md 中的内容 - 临时任务细节:进行中的工作、临时状态、当前对话上下文 ``` 关键设计点:这些排除规则**即使用户明确要求保存也生效**。如果用户说"记住这个 PR 列表",Agent 应该引导用户思考"这个列表中有什么是不可推导的?是关于它的某个决策、某个意外发现,还是某个截止日期?" ### 记忆决策流程 ```mermaid flowchart TD Input[获取到一条信息] --> Q1{能否从代码/Git/文档<br/>直接获取?} Q1 -->|能| Skip[不保存] Q1 -->|不能| Q2{已经在 CLAUDE.md 中?} Q2 -->|是| Skip Q2 -->|否| Q3{属于哪种类型?} Q3 -->|用户身份/偏好| User[保存为 user] Q3 -->|行为纠正/肯定| FB[保存为 feedback<br/>必须含 Why + How to apply] Q3 -->|项目动态/决策| Proj[保存为 project<br/>相对日期→绝对日期] Q3 -->|外部系统位置| Ref[保存为 reference] Q3 -->|都不是| Skip ``` ## 6.3 存储架构 ### 目录结构 记忆文件存储在项目特定目录中: ``` ~/.claude/projects/{project-hash}/memory/ ├── MEMORY.md ← 索引文件(每次会话自动加载) ├── user_role.md ← 用户记忆 ├── feedback_terse.md ← 反馈记忆 ├── project_freeze.md ← 项目记忆 └── reference_linear.md ← 引用记忆 ``` ### 路径解析:三级优先 记忆目录的位置通过三级优先级链确定(`src/memdir/paths.ts`): | 优先级 | 来源 | 用途 | |--------|------|------| | 1 | `CLAUDE_COWORK_MEMORY_PATH_OVERRIDE` 环境变量 | Cowork/SDK 集成,完全绕过标准路径 | | 2 | `autoMemoryDirectory` in settings.json | 用户自定义记忆存储位置(支持 `~/` 展开) | | 3 | `~/.claude/projects/{sanitized-git-root}/memory/` | 默认路径 | **安全决策:为什么 projectSettings 被排除?** `getAutoMemPathSetting()` 只从 user/managed settings 读取,**不**从 projectSettings 读取。原因是安全:projectSettings 来自项目的 `.claude/settings.json` 文件,它是被签入代码仓库的。一个恶意的仓库可以设置 `autoMemoryDirectory: "~/.ssh"`,让 Claude Code 的记忆写入操作(Edit/Write 工具)获得对用户 SSH 密钥目录的写访问权限。这与权限系统中"不信任项目级设置用于安全敏感路径"的原则一致。 ### 存储格式 每条记忆是独立的 Markdown 文件,带 YAML frontmatter: ```markdown --- name: 简洁回复偏好 description: 用户不希望在响应末尾看到总结 type: feedback --- 不要在每次响应末尾总结已完成的操作。 **Why:** 用户明确表示可以自己阅读 diff。 **How to apply:** 所有响应保持简洁,省略尾部总结。 ``` 关键设计:`description` 字段不仅是元数据,它是**召回系统的核心依据**。当 Sonnet 模型在选择相关记忆时,主要依赖 description 判断相关性,因此 description 必须足够具体——"用户偏好"太泛,"用户不希望在响应末尾看到总结"才够精确。 ### Git Worktree 共享 `findCanonicalGitRoot()` 确保同一仓库的所有 Git worktree 共享同一个记忆目录。如果不这样做,`git worktree add` 创建的新工作目录会生成一个独立的记忆空间,导致记忆"孤岛化"——在主工作目录中保存的偏好在 worktree 中消失。 ### 目录预创建:避免浪费模型回合 系统通过 `ensureMemoryDirExists()` 在会话开始时保证目录存在。这一步是幂等的——底层的 `fs.mkdir` 自动处理 `EEXIST`,整个路径链在一次调用中创建。 **为什么要保证目录预创建?** 实践中发现,Claude 会浪费回合执行 `ls` / `mkdir -p` 来检查目录是否存在。系统提示词中会注入 `DIR_EXISTS_GUIDANCE`,明确告诉模型: > "This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence)." 这是一个典型的"用系统设计消除模型低效行为"的例子——与其期望模型学会不检查目录,不如直接预创建并明确告知。 ### 是否启用记忆:五级优先 `isAutoMemoryEnabled()` 的判断链: ``` CLAUDE_CODE_DISABLE_AUTO_MEMORY 环境变量 → 禁用 --bare 启动标志 → 禁用 远程模式(无持久化存储) → 禁用 settings.json 中 autoMemoryEnabled → 按配置 以上都不满足 → 默认启用 ``` ## 6.4 MEMORY.md:索引而非容器 `MEMORY.md` 是记忆系统的**入口点**(entrypoint),扮演两个角色: 1. **索引**:列出所有可用的记忆文件及其简短描述,供模型快速定位相关记忆 2. **快速检查**:每次会话启动时,MEMORY.md 的内容会通过 `getClaudeMds()` 自动加载到用户上下文中(与 CLAUDE.md 同一批次加载),让模型在第一个回合就知道有哪些记忆可用 正因为 MEMORY.md 每次会话都完整加载,它必须保持紧凑——它是索引,不是记忆容器。每个条目应为一行链接: ```markdown - [用户角色](user_role.md) — 数据科学家,专注可观测性 - [简洁回复偏好](feedback_terse.md) — 不要尾部总结 - [合并冻结](project_freeze.md) — 2026-03-05 移动端发布冻结 - [Bug 追踪](reference_linear.md) — 管道 Bug 在 Linear INGEST 项目 ``` **为什么是索引而非容器?** 类比数据库:MEMORY.md 是索引,记忆文件是数据行。索引必须紧凑——因为 MEMORY.md **每次会话都完整加载到系统提示词中**,它的大小直接挤占有效上下文空间。实际的记忆内容只有被 Sonnet 选中时才按需读取。 ### 双层截断机制 MEMORY.md 有严格的大小限制,由 `truncateEntrypointContent()` 实现: ```typescript // src/memdir/memdir.ts export const MAX_ENTRYPOINT_LINES = 200 export const MAX_ENTRYPOINT_BYTES = 25_000 // ~125 chars/line at 200 lines export function truncateEntrypointContent(raw: string): EntrypointTruncation { const contentLines = trimmed.split('\n') const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES // 第一步:按行截断(自然边界) let truncated = wasLineTruncated ? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join('\n') : trimmed // 第二步:如果仍超过字节上限,在最后一个换行处截断(不切断行中间) if (truncated.length > MAX_ENTRYPOINT_BYTES) { const cutAt = truncated.lastIndexOf('\n', MAX_ENTRYPOINT_BYTES) truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES) } // 追加警告信息 return { content: truncated + `\n\n> WARNING: MEMORY.md is ${reason}. Only part of it was loaded.`, lineCount, byteCount, wasLineTruncated, wasByteTruncated, } } ``` **为什么有两层截断?** - **行截断**(200 行):正常情况——索引条目太多,按行截断保持完整条目。 - **字节截断**(25KB):防御措施——捕捉行数在 200 以内但单行极长的异常索引。实际观察到 p100 场景:197KB 在 200 行内(有人把整篇文档作为单行条目)。 返回的元数据(`wasLineTruncated` / `wasByteTruncated`)用于遥测追踪,帮助团队了解用户的索引增长模式。 **警告消息的设计**:截断时追加的警告不只是报告问题,还**教模型如何修复**——提示模型"keep index entries to one line under ~200 chars; move detail into topic files"。这体现了一个设计原则:错误消息应该包含修复指引。 ### skipIndex 模式 一个实验性的 feature gate(`tengu_moth_copse`)正在测试移除 MEMORY.md 索引要求。启用后,记忆提取 Agent 直接写记忆文件而不更新 MEMORY.md。 为什么测试这个?两步保存流程(写文件 + 更新索引)是记忆系统中**最容易出错的部分**——模型可能写了文件但忘了更新索引,或者索引格式错误。如果 skipIndex 模式的召回质量不下降(因为 `scanMemoryFiles()` 直接扫描目录而非依赖索引),就可以简化整个保存流程。 ## 6.5 记忆召回:语义检索 当用户提交查询时,系统自动寻找相关记忆。这个过程分为扫描、评估、过滤三个阶段: ```mermaid flowchart TD Input[用户输入 + 最近工具使用] --> Scan["1. scanMemoryFiles()<br/>扫描记忆目录所有 .md 文件<br/>只读每个文件前 30 行 frontmatter<br/>按 mtime 降序排列<br/>保留最新 200 个"] Scan --> Format["2. formatMemoryManifest()<br/>格式化为清单:<br/>[type] filename (timestamp): description"] Format --> Eval["3. selectRelevantMemories()<br/>sideQuery() + Sonnet 模型<br/>输入:query + 清单 + recentTools<br/>输出:最多 5 个文件名"] Eval --> Filter["4. 过滤<br/>去除已展示的记忆(alreadySurfaced)<br/>验证文件名存在于已知集合"] Filter --> Return["5. 返回 RelevantMemory[]<br/>包含 path + mtimeMs"] ``` ### scanMemoryFiles():单次遍历优化 `src/memdir/memoryScan.ts` 中的扫描实现采用了一个巧妙的性能优化——**单次遍历**(read-then-sort)而非传统的两步法(stat-sort-read): ```typescript export async function scanMemoryFiles(memoryDir: string, signal: AbortSignal) { const entries = await readdir(memoryDir, { recursive: true }) const mdFiles = entries.filter(f => f.endsWith('.md') && basename(f) !== 'MEMORY.md') // 并行读取所有文件的 frontmatter(只读前 30 行) const headerResults = await Promise.allSettled( mdFiles.map(async (relativePath) => { const { content, mtimeMs } = await readFileInRange(filePath, 0, FRONTMATTER_MAX_LINES) const { frontmatter } = parseFrontmatter(content, filePath) return { filename: relativePath, filePath, mtimeMs, description, type } }) ) // 单次遍历:读取后排序,而非 stat-排序-读取 return headerResults .filter(r => r.status === 'fulfilled') .map(r => r.value) .sort((a, b) => b.mtimeMs - a.mtimeMs) .slice(0, MAX_MEMORY_FILES) // 保留最新 200 个(MAX_MEMORY_FILES = 200) } ``` 注意 `MAX_MEMORY_FILES = 200` 不是扫描上限,而是**返回结果数限制**。`readdir` 会读取目录中所有 `.md` 文件,每个都读取 frontmatter 并获取 mtime,然后按修改时间降序排列,最后 `.slice(0, 200)` 只保留最新的 200 个。如果记忆目录中有 500 个文件,500 个都会被扫描,但只有最新的 200 个会参与后续的语义召回。 **为什么这样更快?** 传统方法是: 1. `stat()` 所有文件获取 mtime → N 次 syscall 2. 按 mtime 排序,取前 200 3. `read()` 前 200 个文件的 frontmatter → 200 次 syscall 4. 总计:N + 200 次 syscall 单次遍历方法是: 1. `read()` 所有文件的前 30 行(`readFileInRange` 同时返回 mtime)→ N 次 syscall 2. 排序并保留最新 200 个 3. 总计:N 次 syscall 对常见场景(N ≤ 200),syscall 数量减半。代价是多读了一些最终被丢弃的文件的 frontmatter,但每个文件只读 30 行,开销极小。 **FRONTMATTER_MAX_LINES = 30**:只读前 30 行是因为 frontmatter 始终在文件顶部。读取完整文件对召回来说是浪费——选择阶段只需要 description 字段。 ### formatMemoryManifest():清单格式 扫描结果被格式化为清单,提供给 Sonnet 评估: ``` - [feedback] feedback_terse.md (2026-03-28T10:30:00Z): 用户不希望在响应末尾看到总结 - [project] project_freeze.md (2026-03-01T09:00:00Z): 2026-03-05 合并冻结,移动端发布 ``` 格式中的 **ISO 时间戳**至关重要——它让 Sonnet 能判断记忆的新鲜度。一个月前的"合并冻结"记忆很可能已过时,Sonnet 可以据此降低其优先级。 ### selectRelevantMemories():Sonnet 语义评估 ```typescript const SELECT_MEMORIES_SYSTEM_PROMPT = `You are selecting memories that will be useful to Claude Code as it processes a user's query. Return a list of filenames for the memories that will clearly be useful (up to 5). - Be selective and discerning. - If recently-used tools are provided, do not select usage reference docs for those tools. DO still select warnings, gotchas, or known issues about those tools.` const result = await sideQuery({ model: getDefaultSonnetModel(), system: SELECT_MEMORIES_SYSTEM_PROMPT, messages: [{ role: 'user', content: `Query: ${query}\n\nAvailable memories:\n${manifest}${toolsSection}` }], max_tokens: 256, output_format: { type: 'json_schema', schema: { /* selected_memories: string[] */ } }, }) ``` **为什么用 Sonnet 而非关键词匹配?** 语义相关性评估比关键词匹配更准确。例如,用户问"部署流程"时,关键词匹配可能错过标题为"CI/CD 注意事项"的记忆,但 Sonnet 能理解语义关联。 **为什么限制 5 个?** 上下文空间有限。记忆内容作为 user message 注入对话,过多的记忆会挤占工作空间。5 个是召回价值和上下文成本的平衡点。 ### recentTools 参数:精确的噪声过滤 `recentTools` 参数是一个巧妙的设计。当 Claude Code 正在使用某个工具(如 `mcp__X__spawn`)时: - 该工具的**参考文档型记忆**是噪声——对话中已经包含了使用方法 - 但关于该工具的**警告和已知问题**仍然有价值 提示词中明确区分这两种情况:"do not select usage reference docs for those tools. DO still select warnings, gotchas, or known issues about those tools." 这让选择器在工具使用的上下文中做出更精确的判断。 ### alreadySurfaced 预过滤 `findRelevantMemories()` 在调用 Sonnet **之前**就过滤掉已展示的记忆路径。这不是为了避免重复展示(虽然也有这个效果),而是为了**不浪费 5 个召回槽位**——如果不预过滤,Sonnet 可能选中 3 个已展示的记忆,只留下 2 个新记忆的空间。 ### 异步预取:不阻塞主循环 记忆召回通过 `pendingMemoryPrefetch` 实现**异步预取**——在模型开始生成响应的同时,后台通过 `sideQuery()` 查询 Sonnet。当模型实际需要记忆时,结果通常已经就绪。 这个设计确保记忆召回的 ~250ms 延迟不叠加到用户感知的响应时间上。对用户来说,记忆召回是"免费"的。 ## 6.6 记忆新鲜度与漂移防御 记忆记录的是**写入时的事实**,但时间会让记忆过时。记忆系统通过多层防御机制来处理这个问题。 ### 人类可读的时间距离 `memoryAge.ts` 将 mtime 转为人类可读的字符串: ``` 0 天 → "today" 1 天 → "yesterday" 47 天 → "47 days ago" ``` **为什么不用 ISO 时间戳?** 模型不擅长日期算术。给模型 `2026-02-12T10:30:00Z` 并告诉它今天是 `2026-04-01`,它可能算不清楚过了多少天。但 "47 days ago" 直接触发模型的"这可能过时了"推理。 ### 新鲜度警告 对于超过 1 天的记忆,系统注入新鲜度警告文本(`memoryFreshnessText`): > "Memories are point-in-time observations, not live state — claims about code behavior or file:line citations may be outdated." 这个警告的出发点是:用户报告过 Agent 将过时的记忆(如"X 函数在 line 42")作为事实断言,导致错误的代码修改。 ### 记忆访问三规则 源码中的 `WHEN_TO_ACCESS_SECTION` 定义了三条访问规则: 1. **当已知记忆与任务相关时**:主动查阅 2. **当用户明确要求时**:**必须**访问记忆(用 MUST 强调) 3. **当用户说"忽略记忆"时**:视为记忆不存在 第三条规则背后有一个 eval 失败案例:用户说"忽略关于 X 的记忆",但 Claude 回复"不是 Y(如记忆中所述),而是..."——它承认了记忆的存在并试图"修正",违背了用户的意图。 ### 信任召回:验证而非盲信 `TRUSTING_RECALL_SECTION` 是记忆系统中最关键的安全网之一: > "记忆说 X 存在" ≠ "X 现在存在" 规则要求:如果记忆提到一个文件路径,用 Glob/Read 验证它是否存在。如果记忆提到一个函数,用 Grep 确认它是否还在。 这个节的效果在 eval 中得到了验证:**没有这个节,通过率 0/2;加入后,通过率 3/3。** 这说明模型默认会信任记忆中的具体引用,但记忆中的代码位置信息衰减很快——一次重构就可能全部失效。 ## 6.7 后台记忆提取 除了模型主动写入和用户通过 `/remember` 保存外,Claude Code 还有一个**后台记忆提取 Agent**(`src/services/extractMemories/extractMemories.ts`),在每次对话回合结束后自动运行。 ### 整体架构 ```mermaid sequenceDiagram participant User as 用户 participant Main as 主 Agent participant Hooks as Stop Hooks participant Extract as 提取 Agent (Forked) participant Memory as 记忆目录 User->>Main: 提交查询 Main->>User: 生成响应(无工具调用) Main->>Hooks: 触发 handleStopHooks Hooks->>Hooks: hasMemoryWritesSince() 检查 alt 主 Agent 已写记忆 Hooks->>Hooks: 跳过提取,推进游标 else 主 Agent 未写记忆 Hooks->>Extract: runForkedAgent()<br/>共享 prompt cache Extract->>Memory: Turn 1: 并行读取已有记忆 Extract->>Memory: Turn 2: 并行写入新记忆 Extract->>Hooks: 完成 Hooks->>User: 系统消息 "Memory saved: ..." end ``` ### 触发、互斥与重叠防护 提取 Agent 的运行受三层控制,确保既不遗漏也不重复: **1. 触发时机**:提取 Agent 在 `handleStopHooks` 中被触发——即主 Agent 完成响应(没有更多工具调用)时。 **2. 频率控制**:不是每次回合结束都触发提取,有两道过滤: - **互斥检查**:`hasMemoryWritesSince()` 检查主 Agent 是否在最近的消息范围内已经写入了记忆文件。如果主 Agent 已经主动保存了记忆(比如用户说"记住这个",主 Agent 直接调用 Write 写入),提取 Agent 就**跳过**——避免对同一段对话产生重复记忆。 - **回合节流**:`turnsSinceLastExtraction` 计数器控制提取频率。很多回合(如简单的问答)没有值得记忆的信息,不需要每次都提取。 **3. 并发防护**:如果上一次提取还在运行时新的回合结束了,系统不会启动并发提取,而是通过 `pendingContext` 暂存 + trailing run 机制处理: ``` inProgress = true → 将新请求暂存为 pendingContext(后到的覆盖先到的) 当前提取完成 → 检查 pendingContext,如果有则启动 trailing run trailing run → 只处理自游标推进后的新消息 ``` 这个设计确保:(1) 不会有两个提取 Agent 同时写入记忆目录(避免冲突);(2) 不会遗漏任何对话内容——即使提取来不及处理,最新的上下文会被暂存并在当前提取结束后立即处理。 ### 工具权限:严格的写入白名单 提取 Agent 的工具权限由 `createAutoMemCanUseTool()` 定义: | 工具 | 权限 | |------|------| | Read / Grep / Glob | 无限制——需要读取已有记忆和代码 | | Bash | 只读命令(ls, find, grep, cat, stat, wc, head, tail)| | Edit / Write | **仅限记忆目录内**(通过 `isAutoMemPath()` 校验)| | 其他所有工具 | 拒绝 | 这是**最小权限原则**的体现——提取 Agent 只需要读取对话上下文和已有记忆,然后写入新记忆。它不需要执行代码、修改项目文件或调用外部服务。 ### 提取提示词设计 提取 Agent 的提示词(`src/services/extractMemories/prompts.ts`)有几个关键设计: **高效的回合预算**:提示词明确指导 Agent 的执行策略——"Turn 1: 并行发起所有读取;Turn 2: 并行发起所有写入"。这最大化了工具调用的并行度,通常 2 个回合就能完成工作(硬上限是 5 个回合)。 **防止重复**:提示词注入已有记忆的清单(manifest),并指导 Agent "先检查是否已有类似记忆,再决定创建新的"。 **范围限制**:`MUST only use content from last ~${newMessageCount} messages`——只从最新的消息中提取,不重新处理已处理过的历史。 ### 共享 Prompt Cache 提取 Agent 通过 `runForkedAgent()` 创建,这与技能系统的 fork 模式使用相同的底层机制。关键优势是**共享父级的 prompt cache**——系统提示词不需要重新计算和传输,大幅降低提取的 token 消耗。 ## 6.8 记忆提示词构建层级 记忆系统的提示词构建分为三个层级,每层叠加不同的内容: ```mermaid flowchart TD L1["buildMemoryLines()<br/>行为指令层<br/>四类型分类法 + 保存/访问规则<br/>+ 记忆 vs Plan/Task 区分"] L2["buildMemoryPrompt()<br/>内容层<br/>= buildMemoryLines() + MEMORY.md 内容<br/>(经 truncateEntrypointContent 截断)"] L3["loadMemoryPrompt()<br/>分发层<br/>按 feature gate 选择构建方式"] L1 --> L2 L2 --> L3 L3 -->|KAIROS 模式| K["buildAssistantDailyLogPrompt()<br/>追加式日期命名日志"] L3 -->|TEAMMEM 模式| T["buildCombinedMemoryPrompt()<br/>私有 + 团队两个目录"] L3 -->|普通模式| N["buildMemoryLines()<br/>单目录"] L3 -->|禁用| Null["返回 null"] ``` ### buildMemoryLines():行为指令的八个子节 `buildMemoryLines()` 构建的指令包含八个子节: 1. **持久化记忆介绍**:告知模型记忆目录路径,`DIR_EXISTS_GUIDANCE` 说明目录已存在 2. **显式保存/遗忘**:用户说"记住"→ 立即保存,说"忘记"→ 查找并删除 3. **四类型分类法**:user / feedback / project / reference 的完整定义、示例、保存时机 4. **什么不该保存**:代码模式、git 历史、CLAUDE.md 已有内容等排除列表 5. **如何保存**:两步流程(写文件 + 更新 MEMORY.md)或单步(skipIndex 模式) 6. **何时访问**:三条规则 + "用户说忽略则忽略" 7. **信任召回**:验证记忆中的引用,不盲信 8. **记忆 vs 其他持久化**:Plan 用于对齐实施方案,Task 用于追踪当前会话进度,记忆用于跨会话信息 第 8 点的区分特别重要——模型容易混淆何时用记忆、何时用 Plan、何时用 Task。记忆系统的提示词明确划定了边界: > - Plan:非平凡实现任务的方案对齐,变更应更新 Plan 而非保存记忆 > - Task:当前会话中的步骤分解和进度追踪 > - 记忆:跨会话有价值的信息 ### KAIROS 模式 KAIROS 是一个实验性的"助手模式",为长期运行的会话设计。与普通模式维护 MEMORY.md 实时索引不同,KAIROS 模式将信息追加到**日期命名的日志文件**中: ``` ~/.claude/projects/{hash}/logs/ └── 2026/ └── 04/ └── 2026-04-01.md ← 今天的日志 ``` 每天的日志是追加式的,避免了频繁更新 MEMORY.md 索引的开销。定期通过 `/dream` 技能将日志**蒸馏**为结构化的主题记忆文件。这种"先追加、后整理"的模式适合高频交互场景。 ## 6.9 团队记忆 当启用团队记忆(`TEAMMEM` feature gate)时,系统管理两个记忆目录: ``` ~/.claude/projects/{hash}/memory/ ← 私有记忆(仅自己可见) ~/.claude/projects/{hash}/memory/team/ ← 团队记忆(项目成员共享) ``` ### 作用域指导 在团队模式下,类型分类法增加了 `<scope>` 标签来指导记忆的存储位置: | 类型 | 默认作用域 | 原因 | |------|-----------|------| | **user** | 始终私有 | 个人偏好不应强加给团队 | | **feedback** | 偏向私有,项目约定可团队共享 | "不要总结"是个人偏好;"测试必须用真实数据库"是团队约定 | | **project** | 偏向团队 | 里程碑、决策对所有成员有价值 | | **reference** | 偏向团队 | 外部系统位置是共享知识 | **敏感数据防护**:团队记忆的提示词中明确要求"MUST NOT save sensitive data (API keys, credentials) in team memories"。私有记忆也不建议存储敏感信息,但团队记忆中这是强制要求——因为团队记忆会被其他成员的 Agent 读取。 **架构细节**:`isTeamMemoryEnabled()` 要求先启用自动记忆。团队目录是自动记忆目录的子目录——`mkdir(teamDir)` 会通过递归创建自动创建父目录。两个目录各有独立的 MEMORY.md 索引,都加载到系统提示词中。 ## 6.10 Agent 记忆 除了主 Agent 的记忆系统,Claude Code 还为**子 Agent**(通过 Agent 工具创建的)提供了独立的记忆系统(`src/tools/AgentTool/agentMemory.ts`)。 ### 三个作用域 ``` user 作用域: ~/.claude/agent-memory/{agentType}/ project 作用域: .claude/agent-memory/{agentType}/ local 作用域: .claude/agent-memory-local/{agentType}/ ``` - **user**:跨所有项目的 Agent 级知识(如"这种类型的探索 Agent 应该如何工作") - **project**:项目特定的 Agent 知识(如"这个项目的测试 Agent 应该使用哪个测试框架") - **local**:本地机器特定,不会签入版本控制 ### 为什么与主记忆分离? 子 Agent 的知识类型与主 Agent 不同。一个 "explorer" Agent 学到的代码导航技巧、一个 "test-runner" Agent 学到的测试模式——这些是 Agent 类型特有的操作知识,与用户偏好和项目决策没有关系。分离存储避免了主记忆被 Agent 操作细节污染。 `agentType` 在路径中的作用是隔离不同类型 Agent 的知识空间。路径中的冒号被替换为破折号(`sanitizeAgentTypeForPath()`)以兼容文件系统。 ### 记忆注入方式 Agent 记忆通过与主记忆相同的 `buildMemoryPrompt()` 函数构建,但带有 Agent 特有的行为指导。注入方式也相同——MEMORY.md 索引进系统提示词,具体记忆按需通过语义召回加载。 ## 6.11 记忆注入对话的方式 记忆通过两条路径到达模型的上下文窗口——MEMORY.md 走系统提示词(每次会话必加载),召回的记忆走用户消息(按需注入)。理解这两条路径对于理解记忆系统的上下文开销至关重要。 ### MEMORY.md:用户上下文注入 MEMORY.md 的内容通过 `getMemoryFiles()` → `getClaudeMds()` 流程加载,与 CLAUDE.md 走同一条路径,最终作为用户上下文(`getUserContext()`)的一部分注入。系统提示词中还有一段独立的记忆行为指令,通过 `systemPromptSection('memory', () => loadMemoryPrompt())` 注入,包含四类型分类法、保存规则等。 这意味着: - 每次会话自动加载,无需模型主动请求 - MEMORY.md 内容经过 `truncateEntrypointContent()` 截断(200 行 / 25KB) - 行为指令位于系统提示词中,MEMORY.md 内容位于用户上下文中 实际注入到上下文中的 MEMORY.md 内容大致如下: ``` Contents of ~/.claude/projects/a1b2c3d4/memory/MEMORY.md (user's auto-memory, persists across conversations): - [用户角色](user_role.md) — 数据科学家,专注可观测性 - [简洁回复偏好](feedback_terse.md) — 不要尾部总结 - [合并冻结](project_freeze.md) — 2026-03-05 移动端发布冻结 - [Bug 追踪](reference_linear.md) — 管道 Bug 在 Linear INGEST 项目 ``` 这段文本由 `getClaudeMds()` 拼接生成(`src/utils/claudemd.ts`),格式为 `Contents of {path}{description}:\n\n{content}`。`description` 部分根据文件类型不同而变化——MEMORY.md 对应的是 `(user's auto-memory, persists across conversations)`。 ### 召回的记忆:用户消息注入 通过 Sonnet 选中的记忆作为 **user message**(带 `isMeta: true`)注入对话: ```typescript 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 }) }) ) } ``` `memoryHeader()` 根据记忆的新鲜度生成不同的头部(`src/utils/attachments.ts`): - **新鲜记忆**(今天/昨天):`Memory (saved today): ~/.claude/projects/.../feedback_terse.md:` - **过时记忆**(>1 天):先输出新鲜度警告,再输出路径。例如: ``` This memory is 47 days old. Memories are point-in-time observations, not live state — claims about code behavior or file:line citations may be outdated. Verify against current code before asserting as fact. Memory: ~/.claude/projects/.../project_freeze.md: --- name: 合并冻结 description: 2026-03-05 合并冻结,移动端发布 type: project --- 2026-03-05 后合并冻结,移动端 v3.2 发布。 **Why:** 产品团队要求冻结期间不合并非紧急 PR。 **How to apply:** 03-05 之后的 PR 推迟到下周合并。 ``` 记忆被包裹在 `<system-reminder>` 标签中(通过 `wrapMessagesInSystemReminder`),与其他上下文信息(如 Read/Grep 结果)归为同一组。 `isMeta: true` 标记确保这些消息在 UI 中不作为用户消息显示,但模型能看到它们。这意味着用户不会被大量的记忆注入打扰,但模型的每个回合都能参考这些信息。 ## 6.12 设计洞察 1. **只记忆不可推导的信息**:代码模式从代码读,git 历史从 git 查,记忆只存"元信息"——这个约束是整个系统的根基,防止记忆成为过时的代码映射 2. **语义召回优于关键词匹配**:用 Sonnet 评估相关性,能理解"部署"和"CI/CD"的语义关联。代价是 ~250ms 额外延迟,但通过异步预取完全隐藏 3. **两层截断防御长索引**:行截断捕捉正常增长,字节截断捕捉异常长行(实际观察到 197KB 在 200 行内)——面向实际数据设计,而非理论场景 4. **后台提取 Agent 模式**:将"从对话中提取记忆"封装为独立的 forked agent,共享 prompt cache 降低成本,互斥机制避免重复,最小权限限制写入范围。这个模式可推广到任何"后台智能"场景 5. **eval 驱动的提示词工程**:TRUSTING_RECALL_SECTION 的加入直接由 eval 数据驱动(0/2 → 3/3)。记忆系统的每个提示词节都经过测评验证,不是凭直觉添加的 6. **用系统设计消除模型低效行为**:预创建目录 + `DIR_EXISTS_GUIDANCE` 比"教模型不要检查目录"更可靠。这是一个通用原则:如果模型反复犯某个错误,优先考虑改变环境而非改变提示词 7. **frontmatter 作为统一接口**:记忆和技能使用相同的 Markdown + YAML frontmatter 格式,降低了模型的认知负担——只需学习一种文件格式就能操作两个系统 --- > **动手实践**:在 [claude-code-from-scratch](https://github.com/Windy3f3f3f3f/claude-code-from-scratch) 的 `src/session.ts` 中,可以看到一个最小的会话持久化实现。尝试在此基础上增加记忆系统——将用户偏好写入 `~/.mini-claude/memory/` 目录,并在系统提示词中注入。 上一章:[技能系统](./09-skills-system.md) | 下一章:[Hooks 与可扩展性](./06-hooks-extensibility.md)

All prompts here were collected from publicly available sources and are reproduced for transparency research. Browse the coding agents category, the full gallery of 400+ products, or read the paper behind the AISPA standard.