乐于分享
好东西不私藏

深入 Claude Code 源码:CLAUDE.md 的 10 个真相

深入 Claude Code 源码:CLAUDE.md 的 10 个真相

大多数人写 CLAUDE.md 的方式是:随便写几条规则,感觉效果不明显,然后放弃。

你不知道这些文字在 Claude 眼里长什么样、被放在哪里、和其他指令的优先级关系是什么。

今天我带各位从源码层面,彻底拆解 CLAUDE.md 的底层机制。所有结论都有源码出处,不是猜的。


1. CLAUDE.md 不在 System Prompt 里

这是最大的认知误区。

大多数人以为 CLAUDE.md 的内容被拼接到 system prompt 中。不是的。

源码中,CLAUDE.md 通过 prependUserContext() 函数注入(src/utils/api.ts:449-474):

exportfunctionprependUserContext(
  messages: Message[],
  context: { [k: string]: string },
): Message[] 
{
if (Object.entries(context).length === 0) {
return messages
  }

return [
    createUserMessage({
      content: `<system-reminder>
As you answer the user's questions, you can use the following context:
${Object.entries(context)
  .map(([key, value]) => `# ${key}\n${value}`)
  .join('\n')}


      IMPORTANT: this context may or may not be relevant to your tasks.
      You should not respond to this context unless it is highly relevant
      to your task.
</system-reminder>`
,
      isMeta: true,
    }),
    ...messages,
  ]
}

你的 CLAUDE.md 被包装在 <system-reminder> 标签里,作为第一条 user message 插入到对话开头。不是 system prompt,是 user message。

这样会导致几个直接后果:

  • CLAUDE.md 的优先级低于 system prompt。 System prompt 是模型的”宪法”,user message 是”法律”。当两者冲突时,system prompt 通常胜出。
  • 模型被告知这些内容”可能不相关” —— 注意那句 IMPORTANT: this context may or may not be relevant to your tasks。这是一个显式的降权信号

但是 —— CLAUDE.md 的开头有一句强指令(src/utils/claudemd.ts:89-90):

const MEMORY_INSTRUCTION_PROMPT =
'Codebase and user instructions are shown below. Be sure to adhere to these '
  + 'instructions. IMPORTANT: These instructions OVERRIDE any default behavior '
  + 'and you MUST follow them exactly as written.'

“OVERRIDE any default behavior” + “MUST follow them exactly” —— 这是在用强语气对抗降权信号。效果取决于指令的具体性:越具体的指令越容易被遵守,越模糊的越容易被忽略。

所以别在 CLAUDE.md 里写”尽量简洁”这种模糊指令。写”回答不超过 3 句话”或”不要添加注释到未修改的代码”这种可验证的具体指令


2. 从当前目录向上遍历到根目录

CLAUDE.md 不只是项目根目录的一个文件。Claude Code 从当前目录开始,逐级向上遍历到文件系统根目录,在每一级检查以下文件(src/utils/claudemd.ts:790-934):

每一级目录检查:

├── CLAUDE.md              → Project 类型(可版本控制)
├── .claude/CLAUDE.md      → Project 类型(可版本控制)
├── .claude/rules/*.md     → Project 类型(可版本控制,递归扫描子目录)
└── CLAUDE.local.md        → Local 类型(不应提交到版本控制)

加上全局位置:

~/.claude/CLAUDE.md        → User 类型(个人全局指令)
~/.claude/rules/*.md       → User 类型(个人全局规则)
/etc/claude-code/CLAUDE.md → Managed 类型(企业管理员策略)
/etc/claude-code/.claude/rules/*.md → Managed 类型

核心代码逻辑:

exportconst getMemoryFiles = memoize(
async (forceIncludeExternal: boolean = false): Promise<MemoryFileInfo[]> => {
const result: MemoryFileInfo[] = []
const processedPaths = new Set<string>()

// 1. 先加载 Managed(企业策略)
const managedClaudeMd = getMemoryPath('Managed')
    result.push(...(await processMemoryFile(managedClaudeMd, 'Managed', ...)))

// 2. 加载 User(个人全局)
if (isSettingSourceEnabled('userSettings')) {
const userClaudeMd = getMemoryPath('User')
      result.push(...(await processMemoryFile(userClaudeMd, 'User', ...)))
    }

// 3. 从 CWD 向上遍历,每级加载 Project 和 Local
let currentDir = originalCwd
while (currentDir !== parse(currentDir).root) {
      dirs.push(currentDir)
      currentDir = dirname(currentDir)
    }

// 反转后从根目录向下处理
for (const dir of dirs.reverse()) {
// 加载 CLAUDE.md, .claude/CLAUDE.md, .claude/rules/*.md, CLAUDE.local.md
    }
  },
)

加载顺序决定优先级。源码注释写得很清楚:

Files are loaded in reverse order of priority, i.e. the latest files are highest priority with the model paying more attention to them.

“最后加载的优先级最高” —— 因为模型对靠近输入末尾的内容关注度更高(recency bias)。

完整的加载顺序(从先到后,优先级从低到高):

 1. /etc/claude-code/CLAUDE.md             (Managed - 企业策略)
 2. /etc/claude-code/.claude/rules/*.md    (Managed)
 3. ~/.claude/CLAUDE.md                    (User - 个人全局)
 4. ~/.claude/rules/*.md                   (User)
 5. /repo-root/CLAUDE.md                   (Project - 仓库根)
 6. /repo-root/.claude/CLAUDE.md           (Project)
 7. /repo-root/.claude/rules/*.md          (Project)
 8. /repo-root/src/CLAUDE.md              (Project - 更近的目录)
 9. /repo-root/src/.claude/rules/*.md     (Project)
10. /repo-root/src/feature/CLAUDE.md      (Project - 当前目录)
11. /repo-root/src/feature/CLAUDE.local.md (Local - 最高优先级)

实战建议:

  • 规则放在仓库根目录的 .claude/rules/ 下
  • 个人偏好(语言、风格)放在 ~/.claude/CLAUDE.md
  • 子目录特定规则(比如 frontend/CLAUDE.md 说”用 React 不用 Vue”)放在对应目录
  • 离当前工作目录越近的文件,优先级越高

3. @include 指令引入外部文件

CLAUDE.md 支持 @ 语法引入其他文件(src/utils/claudemd.ts:451-535):

# 我的项目规则

@./coding-standards.md
@~/global-rules.md
@/etc/team-rules/backend.md

解析逻辑的核心正则:

functionextractIncludePathsFromTokens(tokens, basePath): string[] {
const absolutePaths = new Set<string>()

functionextractPathsFromText(textContent: string{
const includeRegex = /(?:^|\s)@((?:[^\s\\]|\\ )+)/g
let match
while ((match = includeRegex.exec(textContent)) !== null) {
let path = match[1]

// 去掉 fragment 标识符
const hashIndex = path.indexOf('#')
if (hashIndex !== -1) path = path.substring(0, hashIndex)

// 反转义空格
      path = path.replace(/\\ /g' ')

// 支持 @./path, @~/path, @/path, @path
const isValidPath =
        path.startsWith('./') ||
        path.startsWith('~/') ||
        (path.startsWith('/') && path !== '/') ||
        (!path.startsWith('@') &&
          !path.match(/^[#%^&*()]+/) &&
          path.match(/^[a-zA-Z0-9._-]/))

if (isValidPath) {
const resolvedPath = expandPath(path, dirname(basePath))
        absolutePaths.add(resolvedPath)
      }
    }
  }
// ...
}

路径规则:

  • @./path 或 @path — 相对于当前 CLAUDE.md 文件的路径
  • @~/path — 相对于 home 目录
  • @/path — 绝对路径
  • 支持转义空格:@./my\ file.md
  • 支持 #fragment 后缀(会被忽略):@./rules.md#section-1

安全限制:

代码块免疫 —— @ 指令只在 Markdown 的文本节点中生效。代码块和行内代码中的 @ 不会被解析:

if (element.type === 'code' || element.type === 'codespan') {
continue// 跳过代码块
}

最大递归深度 —— MAX_INCLUDE_DEPTH = 5src/utils/claudemd.ts:537),防止无限嵌套。

扩展名白名单 —— 只能引入文本文件,支持 .md.txt.json.yaml.ts.py.go 等约 80 种。二进制文件被静默忽略。

不存在的文件静默忽略 —— 不会报错,不会中断加载。

可以用 @include 把 CLAUDE.md 拆成模块:

# 项目根目录 CLAUDE.md

@.claude/rules/code-style.md
@.claude/rules/git-conventions.md
@.claude/rules/testing.md

4. Memory 系统的硬编码限制

Memory 系统和 CLAUDE.md 是两套独立的机制,但它们在提示词中紧挨着。

Memory 通过 loadMemoryPrompt() 加载,注入到 system prompt 的动态部分。注意:Memory 在 system prompt 中,CLAUDE.md 在 user message 中,两者的权重天然不同。

MEMORY.md 有硬编码的截断限制(src/memdir/memdir.ts:34-38):

exportconst ENTRYPOINT_NAME = 'MEMORY.md'
exportconst MAX_ENTRYPOINT_LINES = 200
// ~125 chars/line at 200 lines. At p97 today; catches long-line indexes that
// slip past the line cap (p100 observed: 197KB under 200 lines).
exportconst MAX_ENTRYPOINT_BYTES = 25_000

截断逻辑(truncateEntrypointContent):

  1. 先按行截断 —— 超过 200 行就砍到 200 行
  2. 再按字节截断 —— 超过 25KB 就在最后一个换行符处切断
  3. 截断后追加警告:
> WARNING: MEMORY.md is 350 lines (limit: 200). Only part of it was loaded.
> Keep index entries to one line under ~200 chars; move detail into topic files.

所以 MEMORY.md 应该当索引用,不是写内容的地方。每条记忆写一行,控制在 200 字符以内,详细内容放单独的 topic 文件。

错误写法:

## 用户偏好
用户是一名高级 Go 开发者,有 10 年经验,主要在 Linux 环境下工作。
偏好简洁代码,不喜欢过度抽象。使用 Neovim 编辑器...(大量详细描述)

正确写法:

[User Profile](user_profile.md) — 高级 Go 开发者,10年经验,偏好简洁

5. System Prompt 的分层架构

Claude Code 的 system prompt 由多个模块拼接(src/constants/prompts.ts:444-577),分三层缓存策略:

┌─── 静态层(全局可缓存,跨用户复用)───────────────────────────┐
│  1. 身份介绍:"You are Claude Code..."                     │
│  2. 系统规则(prompt injection 警告等)                     │
│  3. 编码任务指南                                           │
│  4. 操作谨慎性指南                                         │
│  5. 工具使用指南                                           │
│  6. 语气风格                                              │
│  7. 输出效率                                              │
├─── SYSTEM_PROMPT_DYNAMIC_BOUNDARY ──────────────────────┤
│                                                          │
│  ── 动态层(会话级缓存,/clear 或 /compact 后重算)──       │
│  8.  会话指导                                             │
│  9.  ★ Memory 内容(loadMemoryPrompt)                    │
│  10. 环境信息(平台、shell、模型名、Git 状态)              │
│  11. 语言偏好                                             │
│  12. 输出样式                                             │
│                                                          │
│  ── 易变层(每轮重算,不缓存)──                           │
│  13. MCP 服务器指令                                        │
│  14. 暂存板指令                                            │
│  15. 函数结果清除提醒                                      │
│  16. 工具结果摘要                                          │
└──────────────────────────────────────────────────────────┘

┌─── User Message 层 ─────────────────────────────────────┐
│  ★ CLAUDE.md 内容(包装在 <system-reminder> 中)         │
│  作为对话的第一条 user message                           │
├──────────────────────────────────────────────────────────┤
│  用户的第一条实际消息                                    │
│  ...后续对话...                                          │
└──────────────────────────────────────────────────────────┘

源码中,静态层和动态层之间有一个明确的边界标记:

return [
// --- Static content (cacheable) ---
  getSimpleIntroSection(outputStyleConfig),
  getSimpleSystemSection(),
  getSimpleDoingTasksSection(),
  getActionsSection(),
  getUsingYourToolsSection(enabledTools),
  getSimpleToneAndStyleSection(),
  getOutputEfficiencySection(),
// === BOUNDARY MARKER ===
  ...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
// --- Dynamic content ---
  ...resolvedDynamicSections,
]

注意两个 ★ 标记的位置:

  • Memory 在 system prompt 的动态层(第 9 位)
  • CLAUDE.md 在 user message 层(对话的第一条消息)

Memory 是 system prompt 的一部分,模型视为”系统级指令”。CLAUDE.md 是 user message,模型视为”用户上下文”。但 CLAUDE.md 有那句 “OVERRIDE any default behavior” 来提升权重。


6. 缓存经济学

三层结构直接影响 API 成本。

静态层 使用 cacheScope: 'global' —— 跨所有用户和组织复用:

// src/utils/api.ts:392-394
const staticJoined = staticBlocks.join('\n\n')
if (staticJoined)
  result.push({ text: staticJoined, cacheScope: 'global' })

动态层 使用 cacheScope: 'org' —— 在你的组织/账户内复用。

易变层 标记为 DANGEROUS_uncached —— 每轮重新计算,且源码要求必须注明破坏原因:

DANGEROUS_uncachedSystemPromptSection(
'mcp_instructions',
() => getMcpInstructionsSection(mcpClients),
'MCP servers connect/disconnect between turns',  // 必须说明原因
)

CLAUDE.md 在 user message 中,不在 system prompt 中,所以它不影响 system prompt 的缓存。 你修改 CLAUDE.md 不会导致 system prompt 缓存失效。但 CLAUDE.md 本身作为 user message 的一部分,会被 prompt caching 机制缓存 —— 只要内容不变,后续轮次不需要重新处理这些 token。

几个注意点:

  • 不要频繁修改 CLAUDE.md —— 每次修改都会使 user message 缓存失效
  • MCP 服务器配置变化是”免费的” —— 它在易变层,本来就每轮重算
  • /compact 和 /clear 会清除所有动态层缓存 —— Memory、环境信息等都会重新加载

7. .claude/rules/ 的递归发现

.claude/rules/ 目录支持子目录递归(src/utils/claudemd.ts:697-780):

.claude/
├── CLAUDE.md
└── rules/
    ├── general.md
    ├── frontend/
    │   ├── react.md
    │   └── testing.md
    └── backend/
        ├── api-design.md
        └── database.md

所有 .md 文件都会被递归发现和加载。非 .md 文件被忽略。

核心代码:

exportasyncfunctionprocessMdRules({
  rulesDir,
type,
  processedPaths,
  visitedDirs = new Set(),
}: { ... }
): Promise<MemoryFileInfo[]> 
{
if (visitedDirs.has(rulesDir)) {
return []  // 循环检测
  }

const { resolvedPath: resolvedRulesDir, isSymlink } =
    safeResolvePath(fs, rulesDir)

  visitedDirs.add(rulesDir)
if (isSymlink) {
    visitedDirs.add(resolvedRulesDir)  // 同时记录 symlink 真实路径
  }

for (const entry of entries) {
if (isDirectory) {
      result.push(...(await processMdRules({  // 递归子目录
        rulesDir: resolvedEntryPath,
        visitedDirs,
      })))
    } elseif (isFile && entry.name.endsWith('.md')) {
// 处理 .md 文件
    }
  }
}

rules 目录有一个特殊的 symlink 安全 处理:代码会解析 symlink 的真实路径,并在 visitedDirs 中同时记录原始路径和解析后的路径,防止 symlink 循环。


8. Git Worktree 去重

如果你在 Git worktree 中工作,CLAUDE.md 的发现有一个巧妙的去重处理(src/utils/claudemd.ts:859-884):

// When running from a git worktree nested inside its main repo,
// the upward walk passes through both the worktree root and the
// main repo root. Both contain checked-in files like CLAUDE.md,
// so the same content gets loaded twice.

const gitRoot = findGitRoot(originalCwd)
const canonicalRoot = findCanonicalGitRoot(originalCwd)
const isNestedWorktree =
  gitRoot !== null &&
  canonicalRoot !== null &&
  normalizePathForComparison(gitRoot) !==
    normalizePathForComparison(canonicalRoot) &&
  pathInWorkingPath(gitRoot, canonicalRoot)

for (const dir of dirs.reverse()) {
const skipProject =
    isNestedWorktree &&
    pathInWorkingPath(dir, canonicalRoot) &&
    !pathInWorkingPath(dir, gitRoot)

if (isSettingSourceEnabled('projectSettings') && !skipProject) {
// 加载 CLAUDE.md ...
  }
}

当你在嵌套 worktree 中时(worktree 目录在主仓库目录内部),向上遍历会经过 worktree 根目录和主仓库根目录。两者都有 CLAUDE.md,会导致同一份内容被加载两次。

Claude Code 会检测这种情况,跳过主仓库中的 Project 类型文件 —— 因为 worktree 已经有自己的 checkout。

但 CLAUDE.local.md 不受影响 —— 它是 gitignore 的,只存在于主仓库,不会被 worktree 复制。


9. 几个隐藏功能

–bare 模式跳过 CLAUDE.md

claude --bare 或设置 CLAUDE_CODE_DISABLE_CLAUDE_MDS=1,所有 CLAUDE.md 都不会加载。--bare 的语义是”跳过我没明确要求的东西”。

settings.json 可以关闭特定来源

if (isSettingSourceEnabled('projectSettings')) {
// 才会加载 Project 类型的 CLAUDE.md
}
if (isSettingSourceEnabled('localSettings')) {
// 才会加载 Local 类型的 CLAUDE.local.md
}

企业管理员可以通过 policy settings 禁用 Project 或 Local 类型的 CLAUDE.md。如果你的指令不生效,检查 settings.json 的 settingSources 配置。

远程 Feature Flag 可以跳过 Project 和 Local

const skipProjectLevel = getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_paper_halyard',
false,
)
// 如果开启,过滤掉 Project 和 Local 类型

Anthropic 内部有一个 feature flag tengu_paper_halyardsrc/utils/claudemd.ts:1159src/utils/attachments.ts:1824),开启后会跳过所有 Project 和 Local 类型的 CLAUDE.md。这个 flag 目前默认 false,但说明 Anthropic 保留了从服务端禁用项目级指令的能力


10. 我的最佳实践

模块化你的 CLAUDE.md

# 项目根目录 CLAUDE.md

@.claude/rules/code-style.md
@.claude/rules/git-workflow.md
@.claude/rules/testing.md

每个规则文件一个主题,一个文件不超过 50 行。

写具体的、可验证的指令

不要写:

代码要简洁

要写:

- 函数不超过 30 行
- 不要添加注释到你没有修改的代码行
- commit message 用中文,不超过 50 字

利用优先级层级

全局偏好 放 ~/.claude/CLAUDE.md

- 用中文回答
- 不要在回答末尾总结你做了什么

项目规则 放 项目根目录/.claude/rules/

# .claude/rules/backend.md
使用 Go 1.22+ 的新特性
错误处理用 errors.Join 而不是 fmt.Errorf
测试用 testify

子目录规则 放对应目录的 CLAUDE.md:

# frontend/CLAUDE.md
组件用函数组件,不用 class 组件
状态管理用 zustand

MEMORY.md 当索引用

每条记忆一行,不超过 200 字符。详细内容放单独文件。控制总行数不超过 200 行。


总结

CLAUDE.md 远不只是一个配置文件。它有完整的加载优先级体系、模块化 include 机制、缓存策略,以及和 Memory 系统的协作关系。

理解这些底层机制后,你可以:

  1. 根据优先级层级合理组织指令 —— 近的覆盖远的
  2. 用 @include 拆分规则模块 —— 保持可维护性
  3. 写具体可验证的指令 —— 对抗降权信号
  4. 把 MEMORY.md 当索引 —— 避免截断丢失内容
  5. 理解缓存影响 —— 避免不必要的成本

掌握了这些,你的 CLAUDE.md 就不再是”感觉有效”,而是确定有效