扒完 Claude Code 源码,我建议你立刻改掉这 10 个习惯
上周Claude code 源码刚好有Clone 一份,也有时间把 Claude Code的源码关键部分扒了一下。
目的也很简单。Claude Code 用到一定程度之后,会开始有一些直觉上解释不了的现象。同样的提问,有时候贵,有时候便宜。memory 有时候精准命中,有时候好像完全没被读到。/compact 跑完之后,感觉有些东西还是丢了,但又说不清楚丢了什么。
我最开始以为是模型问题,或者玄学。
扒完源码之后,这个念头基本上打消了。大多数现象都有原因,而且代码注释写得非常直白,有些地方甚至带着强调的口气,像是工程师在说「这里你一定要注意」。
以下是 10 条总结,每条都带代码出处片段。篇幅不短,但我觉得值得读完。
1. 想补规则,优先用 --append-system-prompt,别碰 --system-prompt
很多人习惯用 –system-prompt 加自己的约束,觉得这样最干净。这个操作有一个副作用,原来默认 system prompt 里那套 memory 处理、工具说明、交互约束,会被你整个替换掉。
在当前 CLI 和 QueryEngine 的主装配路径里,这两个参数的行为是不一样的,customSystemPrompt 是替换式,appendSystemPrompt 是追加式。
代码出处(节选)
// typescript · /src/QueryEngine.ts#L310// When an SDK caller provides a custom system prompt AND has set// CLAUDE_COWORK_MEMORY_PATH_OVERRIDE, inject the memory-mechanics prompt.const memoryMechanicsPrompt = customPrompt !== undefined && hasAutoMemPathOverride() ? awaitloadMemoryPrompt() : nullconst systemPrompt = asSystemPrompt([ ...(customPrompt !== undefined ? [customPrompt] : defaultSystemPrompt), ...(memoryMechanicsPrompt ? [memoryMechanicsPrompt] : []), ...(appendSystemPrompt ? [appendSystemPrompt] : []),])
补一句边界,在 QueryEngine 某些 SDK 路径里,如果显式启用了 auto memory override,仍可能额外注入 memory mechanics。更准确的说法是「默认前言被替换」,不是「底层机制被关闭」。
所以,追加风格、格式、团队约束,用 –append-system-prompt。只有你真要接管整套 system prompt 时,再用 –system-prompt。
// bashclaude --append-system-prompt "先给结论,再给证据,最后给可执行建议。"
2. 真正让你变贵的,往往不是 prompt 太长,而是你把 cache 亲手打爆了
这条是我扒完之后最有感触的一个。
我一直以为 token 贵是 prompt 写太长了。结果问题经常根本不在长度,在内容有没有变。Claude Code 会把 system prompt 拆成可缓存 section 和会打断缓存的 section,同时用 dynamic boundary 把「固定前言」和「会变的上下文」切开。
源码里对这两种 section 的命名就已经很直白了,一个叫 systemPromptSection,另一个叫 DANGEROUS_uncachedSystemPromptSection。后面那个 DANGEROUS 不是装饰用的。
代码出处(节选)
// typescript · /src/constants/systemPromptSections.ts#L17/** * Create a memoized system prompt section. * Computed once, cached until /clear or /compact. */export functionsystemPromptSection( name: string, compute: ComputeFn,): SystemPromptSection {return { name, compute, cacheBreak: false }}/** * Create a volatile system prompt section that recomputes every turn. * This WILL break the prompt cache when the value changes. * Requires a reason explaining why cache-breaking is necessary. */export functionDANGEROUS_uncachedSystemPromptSection( name: string, compute: ComputeFn, _reason: string,): SystemPromptSection {return { name, compute, cacheBreak: true }}
最常见的错误,是把时间戳、MCP 连接状态、动态列表、会话状态说明写进了 prompt 前缀。每次这些内容一变,cache 就断了,等于每轮都在重新计费。
更稳的做法,固定规则放前面,会变的信息放 boundary 之后,或者直接用 attachment 或 tool result 传。
3. MEMORY.md 不是正文,而且它还会被截断
这条我觉得很多人真的没意识到。
MEMORY.md 不是无限长的便签。超过 200 行或 25KB,就只会加载一部分。这件事源码里说得非常清楚。
代码出处(节选)
// typescript · /src/memdir/memdir.ts#L35export const ENTRYPOINT_NAME = 'MEMORY.md'export const 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).export const MAX_ENTRYPOINT_BYTES = 25_000
保存记忆是两步式,正文先写到 topic file,再把指针写到 MEMORY.md。MEMORY.md 是索引,不是内容本身,每行只写「标题 + 一句 hook」。
推荐命名方式:
// markdownfeedback_testing.mdproject_release_freeze.mdreference_dashboards.md
4. 记忆能不能被捞出来,关键不在标题,而在 description
顺着上面这块再说一个更细的。
相关记忆不是靠全文扫出来的。当前实现会看 frontmatter、做相关性筛选,并受注入数量与会话总字节预算的限制。两个硬限制,每轮最多挑 5 个,累计大约 60KB 就停。
代码出处(节选)
// typescript · /src/utils/attachments.ts#L279const MAX_MEMORY_LINES = 200// Line cap alone doesn't bound size (200 × 500-char lines = 100KB). The// surfacer injects up to 5 files per turn via <system-reminder>, bypassing// the per-message tool-result budget, so a tight per-file byte cap keeps// aggregate injection bounded (5 × 4KB = 20KB/turn).const MAX_MEMORY_BYTES = 4096export const RELEVANT_MEMORIES_CONFIG = {// Per-turn cap (5 × 4KB = 20KB) bounds a single injection, but over a // long session the selector keeps surfacing distinct files — ~26K tokens/ // session observed in prod. Cap the cumulative bytes: once hit, stop // prefetching entirely. MAX_SESSION_BYTES: 60 * 1024,} as const
所以 description 应该写成「未来会被什么问题命中」,而不是 some notes 或者 meeting summary 这种废话。
好的写法:
// markdowndescription: integration tests must hit a real database; mocks once hid a broken prod migration
5. memory 是快照,不是事实源
这条是 Claude Code 自己在注释里反复强调的。memory 会 stale,如果用户要求 ignore memory 就按空处理,如果 memory 里提到了文件、函数、flag,推荐前必须先验证当前状态。
顺带一提,源码里有一行注释我看到之后愣了一下。工程师在 A/B 测试两个不同措辞的 section 标题,「Before recommending」和「Trusting what you recall」,前者 3 次测试全部通过,后者 3 次全部失败。同样的内容,只是换了个标题,效果天差地别。
代码出处(节选)
// typescript · /src/memdir/memoryTypes.ts#L202export const MEMORY_DRIFT_CAVEAT ='- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering the user or building assumptions based solely on information in memory records, verify that the memory is still correct and up-to-date by reading the current state of the files or resources. If a recalled memory conflicts with current information, trust what you observe now — and update or remove the stale memory rather than acting on it.'
// typescript · /src/memdir/memoryTypes.ts#L245export const TRUSTING_RECALL_SECTION: readonlystring[] = [// Header wording matters: "Before recommending" (action cue at the decision // point) tested better than "Trusting what you recall" (abstract). The // appendSystemPrompt variant with this header went 3/3; the abstract header // went 0/3 in-place. Same body text — only the header differed.'## Before recommending from memory','','A memory that names a specific function, file, or flag is a claim that it existed *when the memory was written*. It may have been renamed, removed, or never merged. Before recommending it:','','- If the memory names a file path: check the file exists.','- If the memory names a function or flag: grep for it.','- If the user is about to act on your recommendation (not just asking about history), verify first.',]
实际操作,memory 命中文件路径先检查文件还在不在,命中函数名或 flag 先 grep,用户问「现在怎样了」先看代码和 git,不要直接复述旧记忆。
6. Team memory 不是共享文件夹,在 sync 启用时,本地删除不等于远端删除
这块是最容易想当然的。
源码里有两层事实。第一层,路径安全校验非常硬,专门拦路径穿越、URL 编码绕过、Unicode 归一化和 symlink escape,注释里逐条列出了这些攻击向量。第二层,也是更多人不知道的,在当前 sync service 语义里,文件删除不会向服务端传播,下次 pull 还可能把远端内容恢复回来。
代码出处(节选)
// typescript · /src/services/teamMemorySync/index.ts#L14/** * Sync semantics: * - Pull overwrites local files with server content (server wins per-key). * - Push uploads only keys whose content hash differs from serverChecksums * (delta upload). Server uses upsert: keys not in the PUT are preserved. * - File deletions do NOT propagate: deleting a local file won't remove it * from the server, and the next pull will restore it locally. */
注意,这里说的是当前 sync service 的实现语义,依赖 TEAMMEM、auto memory、OAuth、GitHub remote 等前提,不是任何环境都天然同步。
只有所有协作者都该知道的稳定信息才进 team memory,别把「删本地文件」当成「删共享记忆」,所有 team memory 路径校验都走现成验证器,不要自己手搓 resolve() 级 containment。
7. 长会话先信系统的上下文治理,手动 /compact 不是越勤快越好
/compact 不是一条简单命令,内部有分流。不带自定义指令时,会优先尝试 session-memory compact,这条路更省事。你一旦带了自定义要求,就会绕开这条路径。
代码出处(节选)
// typescript · /src/commands/compact/compact.ts#L55const customInstructions = args.trim()try {// Try session memory compaction first if no custom instructionsif (!customInstructions) {const sessionMemoryResult = awaittrySessionMemoryCompaction( messages, context.agentId, )if (sessionMemoryResult) {return { type: 'compact', compactionResult: sessionMemoryResult, displayText: buildDisplayText(context), } } }}
// typescript · /src/services/compact/autoCompact.ts#L62export const AUTOCOMPACT_BUFFER_TOKENS = 13_000export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000export const MANUAL_COMPACT_BUFFER_TOKENS = 3_000
auto-compact 不是「差不多满了」的模糊信号,是按 budget 和 buffer 精确计算的。
长会话先让 auto-compact 工作,只有你明确想控制「摘要怎么写」时,再手动 /compact 自定义要求。
补一句边界,当前 checkout 里还看不到 contextCollapse 的实现本体,所以这里只讲 /compact 和 auto-compact 这部分能被直接证实的机制。
8. +500k 不是扩 context,只是让这一轮干更久
这条可能是 10 条里最常见的误解。
很多人把 token budget 和 context window 混成一回事了,但这两个在源码里是完全分开的概念。token budget 决定的是「这一轮要不要继续生成」,上下文容量压力要靠 compact 链路处理,两条路,解决的是两个不同的问题。
代码出处(节选)
// typescript · /src/utils/tokenBudget.ts#L1// Shorthand (+500k) anchored to start/end to avoid false positives in natural language.// Verbose (use/spend 2M tokens) matches anywhere.const SHORTHAND_START_RE = /^\s*\+(\d+(?:\.\d+)?)\s*(k|m|b)\b/iconst SHORTHAND_END_RE = /\s\+(\d+(?:\.\d+)?)\s*(k|m|b)\s*[.!?]?\s*$/iconst VERBOSE_RE = /\b(?:use|spend)\s+(\d+(?:\.\d+)?)\s*(k|m|b)\s*tokens?\b/i
想让这轮多做点,才去设 token budget。想解决上下文爆掉,别指望 +500k,去看 compact 链路。
9. deferred tool 的正确姿势,先 ToolSearch,再正式调用
源码甚至把报错信息都写出来了,如果 schema 没先进 prompt,数组、数字、布尔值这类 typed 参数很容易退化成字符串,最后客户端解析失败。
代码出处(节选)
// typescript · /src/services/tools/toolExecution.ts#L573export functionbuildSchemaNotSentHint( tool: Tool, messages: Message[], tools: readonly { name: string }[],): string | null {return (`\n\nThis tool's schema was not sent to the API — it was not in the discovered-tool set derived from message history. ` +`Without the schema in your prompt, typed parameters (arrays, numbers, booleans) get emitted as strings and the client-side parser rejects them. ` +`Load the tool first: call ${TOOL_SEARCH_TOOL_NAME} with query "select:${tool.name}", then retry this call.` )}
正确姿势:
// text先 ToolSearch: query="select:mcp__server__tool"再调用目标工具
这条在 MCP 工具很多、工具描述很长、schema 默认延迟加载的场景里尤其重要。
10. 别高估默认安全边界,hook、subagent、shell、secure storage 都不是你想的那样
这是很多人最容易「脑补过头」的地方,也是我认为这 10 条里最值得单独说的一个。
四个事实。
第一,hook allow 不是最终 allow。源码写得很死,deny 和 ask rule 还会继续生效,safety check 不会因为 hook 放行就消失。
第二,subagent 不是主会话的分身,它有自己的 permission bubble。你没有显式授权的工具,它用不了。
第三,shell auto-allow 很保守。Bash 的危险删除不会被轻易放过,PowerShell 对系统关键删除甚至是直接硬拒,不是 ask,是 deny,用户无法通过审批。
第四,按当前实现,secure storage 不是「永远进系统钥匙串」。darwin 走 keychain 带 plaintext fallback,非 darwin 直接走 plaintext。
代码出处(节选)
// typescript · /src/services/tools/toolHooks.ts#L322/** * Encapsulates the invariant that hook 'allow' does NOT bypass settings.json * deny/ask rules — checkRuleBasedPermissions still applies (inc-4788 analog). */export async functionresolveHookPermissionDecision(...) {if (hookPermissionResult?.behavior === 'allow') {if ((requiresInteraction && !interactionSatisfied) || requireCanUseTool) {logForDebugging(`Hook approved tool use for ${tool.name}, but canUseTool is required`, ) } }}
// typescript · /src/utils/secureStorage/index.ts#L9export functiongetSecureStorage(): SecureStorage {if (process.platform === 'darwin') {returncreateFallbackStorage(macOsKeychainStorage, plainTextStorage) }// TODO: add libsecret support for Linuxreturn plainTextStorage}
所以,别把 hook 当成最终安全层,起 subagent 时显式想清楚它能用什么工具,不要把 shell allowlist 理解成「已经随便删都行」,团队环境里额外管控 Claude config dir 下的 .credentials.json。
如果你今天只改 5 个习惯,就改这 5 个:追加规则优先用 –append-system-prompt,别把会变的信息塞进固定 prompt 前言,把 MEMORY.md 当索引不要当正文,memory 命中文件和函数时先验证,deferred tool 先 ToolSearch。
扒完这份源码,我最大的感受是,Claude Code 在很多关键节点都做了非常明确的工程选择,代码注释写得非常直白,有些地方甚至带着强调的口气。DANGEROUS_uncachedSystemPromptSection,File deletions do NOT propagate,User cannot approve system32 deletion,这些都不是泛泛的说明,是工程师在记录自己做了什么决定,以及为什么这么设计。
这让我想到一件事,好的系统从来不是黑盒,只是大多数人没想到去打开看一眼。
工具是透明的。
只是透明这件事本身,需要你主动去看。
夜雨聆风