0%

Claude Code 学习笔记

现在来学习一下 Claude Code 的架构与设计思想。

一下子也看不完,准备持续记录笔记,对哪里感兴趣就先看哪;互联网上,各种 CC 源码报告充斥着无数自相矛盾的 LLM 幻觉,真是越看越红温,不如用自己的 CC 专门读几个方向的代码。

首先下载源码 anthropic-ai-claude-code-2.1.88cli.js 是已经打包后的成品代码,是打包且混淆过的代码;cli.js.map 是成品代码与源代码对照表,里面有 sourcesContent。对 cli.js.map 遍历每个 sourcesContent[i] 就能获得源码内容。

由于我只写过 python 和 c/cpp,补充下最小的 JS/TS 项目知识:

  1. Node 是 JavaScript 运行时,运行 Node 项目就是输入命令 node cli.js;npm 是包管理器,在 node_modules 管理依赖。

  2. 然而,Claude Code 的运行时不是 Node 而是 Bun。Node 项目可以打包为 exe。事实上,where.exe claude 得到 bin\claude.execlaude.exe 是用 Bun 打包的独立可执行文件,这就是 Bun 的单文件打包(Single-file Executable)。

  3. 对于通过 irm https://claude.ai/install.ps1 | iex 下载到的 claude.exe,其本质是 Bun 运行时 + 打包好的 JS 代码。

  4. 我在终端输入 claude,发生:Windows 执行 claude.exe,exe 内部的 Bun 运行时启动,Bun 引擎执行内嵌的 Claude Code JS 代码。

现在来看 src/


Memory

在了解最小的 JS/TS 项目知识时,Claude Code 误导了我,认为自己是 Node.js 运行时。后来 CC 自己更正了记忆。

image-20260403215053731

记忆文件大致如下:

image-20260403215151792

对于记忆的读取,在 src\memdir\memdir.tsloadMemoryPrompt()。启动时,Claude Code 把记忆目录的内容注入到系统提示里,记忆目录在 ~/.claude/projects/<sanitized-project-path>/memory/

更正记忆时,使用的就是普通的 Write / Edit 工具写文件。

src/services/extractMemories/extractMemories.ts 是一个后台 agent,在关闭会话后异步运行。它会扫描整个对话记录,自动提取出值得记忆的内容并写入文件。然而我在 ~\.claude.json 里根本没找到 tengu_passport_quail,这可能是没有开启的功能。

总之,没有数据库,没有 RAG,就是文件。

共有 4 种记忆类型,在 src/memdir/memoryTypes.ts 中定义,如图:

image-20260403230409628

src/memdir/memoryTypes.ts 中各记忆类型的 when_to_save 提示 LLM 什么时候要更新记忆。

一般来说,MEMORY.md 只放一行一行的指针链接,不放内容。指向的具体话题文件会有 frontmatter,其中的 description 会被 memoryScan.ts 扫到。

工具

工具调用入口在 src/services/tools/toolExecution.tscheckPermissionsAndCallTool() 函数。完整调用链是:

image-20260404013217612

每个工具是一个实现了 Tool 接口的对象,

1
2
3
4
5
6
7
8
9
10
Tool = {
name: string // "Write"
inputSchema: ZodSchema // 声明参数类型,API 用来生成 JSON Schema
prompt(): string // 工具说明,注入到系统提示里
validateInput() // 业务合法性检查
checkPermissions() // 权限检查逻辑
call() // 实际执行逻辑
renderToolUseMessage() // 在终端 UI 里怎么显示
mapToolResultToToolResultBlockParam() // 结果序列化回 API 格式
}

然后通过 buildTool() 工厂函数填入安全默认值创建。

我之前读过 opencode 对于工具使用的源码,和这里的逻辑很相似,都是先注册对象,再填入安全默认值、自定义配置。这样的设计可以让自定义覆盖默认,默认值可以写更偏向于保守的安全措施。

在下文会提到的 Agent Loop 中,一次 while 循环里可以用多个工具,且如果可以的话会并发执行。具体来说,模型可以在一条 assistant 消息里返回多个 tool_use block。runTools() 收到整个数组后,调用 partitionToolCalls() 把它们分成 Batch。分 batch 的规则在 toolOrchestration.ts 里,isConcurrencySafe=true 的工具合并成一批并发运行,isConcurrencySafe=false 的工具单独一批串行运行。批次之间顺序严格。默认并发上限是 10,不过环境变量 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 能覆盖。

isConcurrencySafe=true 的工具例子:Read、Grep、Glob 等只读工具。isConcurrencySafe = false 的工具例子:Write、Edit 等写文件工具。

TOOL_DEFAULTS 里默认是 isConcurrencySafe = false

源码里还读到个最激进的。当 streamingToolExecutor 启用时,工具在 API 还在流式输出时就已经开始执行,不用等整条 assistant 消息结束,让工具执行和模型输出重叠。

image-20260404014128568

如果想用它,要用 anthropic 的官方账号获得 feature flag 缓存,我也不知道这个是实验性的还是说有官方账号就能用。

Agent Loop

src/query.ts 里的 queryLoop() 函数就是主循环。结构是 while(true) + 状态机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
while(true) 第 N 轮

├─ [压缩] microcompact / snip / autocompact

├─ [API调用] POST /v1/messages ← 包含所有历史 + 工具定义
│ │
│ └─ 流式返回
│ ├─ text_delta → yield → 终端逐字显示
│ ├─ 如果有 tool_use 块就设置 needsFollowUp = true
│ └─ stop → 流结束

├─ if !needsFollowUp → return "completed" ← 对话结束

├─ [工具执行] runTools(toolUseBlocks)
│ ├─ 校验 → 权限检查 → tool.call()
│ └─ yield 结果 → 显示在终端

├─ [附加上下文] memory prefetch 消费、queued commands

└─ state.messages += 助手回复 + 工具结果
→ while(true) 第 N+1 轮

逻辑顺下来比 opencode 的 agentic loop 简明。其是否继续 loop 仅与是否要继续调用工具有关。

对于压缩,有对应的逻辑,稍后分析。

1
2
3
4
messagesForQuery = getMessagesAfterCompactBoundary(messages)
→ snipCompact(剪掉不重要的早期消息)
→ microcompact(把旧 tool_result 替换成摘要)
→ autocompact(上下文快满时,调用另一个 Claude 压缩整段历史)

注意这个 snip 是实验性内容。源码文件 snipCompact.js 在这份源码里找不到,被 DCE 掉了。microcompactautocompact 我在下一节里做笔记。

然后流式调用 API。如果有 tool_use,就 toolUseBlocks.push(...) 且标记 needsFollowUp = true

检查 needsFollowUp 标记,如果没有就结束。如果有就往后走 runTools,伪代码类似:

1
2
3
4
5
6
const toolUpdates = runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)

for await (const update of toolUpdates) {
yield update.message // 在 UI 显示工具执行过程
toolResults.push(...) // 收集工具结果
}

runTools 就是 toolExecution.ts 里的流程,校验 → 权限 → 执行 → 返回结果。

最后拼接新 state,进入下一轮 while 循环,类似:

1
2
3
4
5
6
7
8
state = {
messages: [
...messagesForQuery, // 历史消息
...assistantMessages, // 这轮助手的回复(含 tool_use)
...toolResults, // 工具执行结果(tool_result)
],
turnCount: nextTurnCount,
...

补充,queryLoop()query() 调用。query()async function* 异步生成器,里面可以 await 可以 yield*, 把 queryLoop 产出的每一条消息原封不动地 yield 给外层:

1
const terminal = yield* queryLoop(params, consumedCommandUuids)

补充,QueryEngine.tsQueryEngine 类会处理 /slash 命令、构建 system prompt 等,然后 for await query()。一个会话一个 QueryEngine 实例,One QueryEngine per conversation。

Compact

token 接近上下文限制触发 autoCompactIfNeeded 或输入 /compact 进行压缩。

src\services\compact\compact.ts 中,先做两步清理。stripImagesFromMessages() 把图片替换成 [image] 文字占位符,stripReinjectedAttachments() 去掉 compact 后会自动重新注入的附件。然后 microcompactMessages 清空旧工具结果内容。

注意这个清空旧工具结果内容 microCompact 上文也提到了,每一轮 query 循环里都有,不只在自动压缩里。microcompactMessages() 函数本身是一个二路分支,按优先级依次判断。不过第一个为默认关闭,第二个为 anthropic 内部的,用户用不了:

  1. 时间触发 maybeTimeBasedMicrocompact:距离上一条 assistant 消息的时间戳超过阈值。这是因为 Anthropic API 的 prompt cache 有时间限制,如果没超时当然最好就是不改直接用上,但超时了反正会缓存失效,不如趁此机会压缩 prompt。压缩的方法就是把可压缩的工具结果内容替换成占位字符串 [Old tool result content cleared],默认只保留最近 5 条。
  2. 缓存编辑 cachedMicrocompactPath:向 API 发一个额外的 cache_edits 指令块,让服务端在自己的 prompt cache 里删掉旧工具结果。真没理解这是啥意思,没想象出来如何让服务端修改 prompt cache 的。询问大语言模型也没问出个所以然,网上也查不到

然后以 src\services\compact\prompt.ts 为 prompt,让 LLM 压缩全部对话。大意:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
[禁止使用工具的警告,防止 AI 在摘要时乱调工具]

Your task is to create a detailed summary...

<summary> 要包含的章节:
1. Primary Request and Intent ← 用户想干什么
2. Key Technical Concepts ← 涉及的技术
3. Files and Code Sections ← 读/改了哪些文件,附代码片段
4. Errors and fixes ← 遇到了什么错误,怎么修的
5. Problem Solving ← 解决了什么问题
6. All user messages ← 所有用户消息(逐条列出)
7. Pending Tasks ← 未完成的任务
8. Current Work ← 刚才在做什么
9. Optional Next Step ← 下一步要做什么
</summary>

生成摘要之后,src\services\compact\compact.ts 先标记这里发生过 compact,然后插入生成的摘要,附上最近几条消息,重新注入 CLAUDE.md 等附件。最后还留了能给用户 hook 的事件节点,用户在 settings.json 能配置这个压缩事件结束后执行的 shell 命令。

要是没读到这里,都不知道这里能设 hook。顺便看了一下官方文档,能 hook 扩展的事件点还挺多:Hooks 参考 - Claude Code Docs

源码里看到有个未开放的实验性的压缩功能:src\services\compact\sessionMemoryCompact.ts。浏览了一下发现是让 CC 在对话过程中持续在后台提取关键信息,存到一个本地文件 session_memory 自动做笔记。触发 compact 时,直接把这份现成笔记当摘要用,跳过调 LLM 生成摘要的步骤。

auto Dream

这是 src\services\autoDream\autoDream.ts 里的实验性功能。auto Dream 在对话结束后自动触发后台子 Agent,把最近多个会话的 transcript 读一遍,然后整理、更新、合并 memory 文件。三个门控:

image-20260404025854538

流程如下:

image-20260404030022147

搞笑

src\utils\userPromptKeywords.ts 用了一个非常长的正则表达式判断用户的 prompt 是不是在骂 CC,太难绷了。


未完。TODO:

  • [x] Memory,工具调用总览,Agent Loop(CC 把它称为 query Loop),Compact
  • [ ] 看几个具体的读写 tool
  • [ ] fork subagent
  • [ ] 明天再想