乐于分享
好东西不私藏

Claude Code 源码深度剖析(二):上下文工程与设计启发

Claude Code 源码深度剖析(二):上下文工程与设计启发

上篇拆了核心循环、工具、权限。这篇看看核心循环之外的部分:系统提示词怎么组装?配置怎么合并?对话太长了怎么压缩?最后从 2 万行源码里挑了一些我觉得值得记住的设计经验。

一、回顾

上篇拆解了 Claude Code 的核心三件套:88 行的 Agent Loop、18 个工具的分发系统、5 级权限的安全管控。它们构成了智能体的骨架。

但光有骨架不行。一个能用起来的智能体,还得回答这些问题:

  • • LLM 怎么知道自己是谁、能做什么?→ 系统提示词
  • • 用户的偏好和项目配置怎么加载?→ 配置系统
  • • 与 LLM 的网络通信怎么处理?→ API 客户端
  • • 对话太长了怎么办?→ 会话管理与上下文压缩
  • • 怎么集成外部工具?→ MCP 协议

这些东西合在一起,就是 Claude Code 的”上下文工程”,让 Agent Loop 在正确的环境里运行。


二、系统提示词怎么组装

系统提示词决定了 LLM “认为自己是谁”以及”应该怎么干活”。Claude Code 的提示词构建不是简单的字符串拼接,而是一套分层的组装机制

2.1 Builder 模式

系统提示词由 8-10 个段落动态组装:

SystemPromptBuilder()    .with_intro("你是一个交互式 AI 编程助手...")        # 身份定义    .with_system_rules("工具使用规范、标签说明...")       # 系统规则    .with_task_principles("分析问题、规划步骤...")       # 行为准则    .with_action_guidelines("修改前确认、危险操作谨慎...") # 操作原则    .with_environment(os, model, cwd, date)             # 运行环境    .with_git_context(git_status, git_diff)             # Git 状态    .with_instructions(claude_md_files)                 # 用户指令    .with_config(loaded_config)                         # 配置信息    .build()  # → 完整的系统提示词

2.2 静态 vs 动态:Prompt Caching 的关键

这些段落不是平等的。Claude Code 用一个叫 DYNAMIC_BOUNDARY 的分界线把提示词分成两半:

┌─ 静态区(可缓存)──────────────────────┐│ Intro    — 身份定义(写死在代码中)      ││ System   — 系统规则                     ││ Tasks    — 行为准则                     ││ Actions  — 操作原则                     │├─────── DYNAMIC_BOUNDARY ───────────────┤│ Environment — 模型名、工作目录、日期     │  ← 每次可能不同│ Git Context — 当前分支、文件修改状态     │  ← 随时变化│ Instructions — CLAUDE.md 文件内容       │  ← 用户可编辑│ Config      — 加载的配置信息            │└────────────────────────────────────────┘

为什么要分?因为 Anthropic API 支持 Prompt Caching:分界线以上的静态内容,首次请求写入缓存,后续请求命中缓存时费用降低约 90%,延迟降低约 80%

一个典型的 Claude Code 会话可能有几十轮对话,静态提示词每轮都要发送。没有缓存的话,这些 token 每次按全价计费;有了缓存,从第二轮开始就只付十分之一。在高频对话场景下,这个优化效果非常明显。

2.3 CLAUDE.md 发现机制

用户影响 LLM 行为的方式是写 CLAUDE.md 文件。Claude Code 会从根目录到项目目录逐层搜索:

每个目录搜索 4 种文件:  CLAUDE.md / CLAUDE.local.md / .claude/CLAUDE.md / .claude/instructions.md搜索路径示例(项目在 /home/user/project/sub):  /CLAUDE.md                        ← 全局级  /home/CLAUDE.md  /home/user/CLAUDE.md  /home/user/project/CLAUDE.md      ← 项目级  /home/user/project/sub/CLAUDE.md  ← 子目录级

越靠近项目的文件优先级越高,层层叠加。思路类似 CSS 的层叠规则:全局写通用规范(”使用中文交流”),项目写具体约束(”不修改 src/ 内的文件”),子目录写局部规则。

为了防止提示词占用太多 token,有三重保护:

保护机制
阈值
作用
内容去重
hash 比较
相同内容的文件只包含一次
单文件截断
4,000 字符
防止某个文件过大
总量截断
12,000 字符
所有指令文件合计上限

2.4 Git 上下文注入

系统提示词中还会注入当前的 Git 状态:

git status --short --branch  # → "## main  M src/main.rs"git diff                     # → 当前修改的具体内容

这让 LLM 知道项目当前有哪些文件改了。实际效果是 LLM 能做出更贴合上下文的判断,比如”这个文件刚被修改过,我来看看改了什么”。

2.5 设计启发

这部分最值得注意的是静态与动态的分离。大多数 AI 应用把系统提示词当成一个整体字符串,每次全量发送。但当你意识到其中有一部分是永远不变的(身份定义、行为准则),就可以利用 Prompt Caching 省下不少钱。Claude Code 一次会话可能几十上百轮,这个优化累积下来效果很可观。


三、配置系统

3.1 五级配置优先级

优先级从低到高(后者覆盖前者):1. ~/.claude.json              ← 用户级(老格式)2. ~/.claude/settings.json     ← 用户级(新格式)3. 项目/.claude.json           ← 项目级(老格式)4. 项目/.claude/settings.json  ← 项目级(新格式)5. 项目/.claude/settings.local.json ← 本地级(gitignore,不提交)

合并规则:

  • • 对象:递归合并(两边的 key 都保留)
  • • 标量/数组:后者直接覆盖前者
// 文件 1{ "model": "opus",  "mcp": { "servers": { "a": {} } } }// 文件 2{ "model": "sonnet", "mcp": { "servers": { "b": {} } } }// 合并结果{ "model": "sonnet", "mcp": { "servers": { "a": {}, "b": {} } } }

3.2 双存架构

配置加载后,同时保存两份:

RuntimeConfig {    merged:         原始 JSON       // 通用访问,新字段加了就能读    feature_config: 强类型结构体    // 编译期检查,IDE 自动补全}

为什么双存?原始 JSON 保证灵活(新配置项不用改代码就能读取),强类型保证可靠(字段名拼错编译就报错)。

3.3 设计启发

配置系统有两个细节我觉得做得好。一个是老格式静默兼容.claude.json(老格式)解析失败就跳过,不影响新格式加载。版本升级不会因为配置格式变化而把用户搞崩。另一个是错误信息精确到路径:配置解析失败会告诉你 "/Users/xxx/.claude/settings.json: invalid value for 'permission_mode'",精确到文件和字段名。配置出错是用户最常碰到的问题,好的错误信息能省下大量排查时间。


四、API 客户端

4.1 SSE 事件流

Claude Code 与 Anthropic API 的通信采用 SSE(Server-Sent Events)流式协议。一次 API 调用返回的事件流大概是这样的:

MessageStart         ← 消息开始  ContentBlockStart  ← 内容块开始(文本块或工具块)  ContentBlockDelta  ← 增量内容(文字片段 / 工具参数片段)  ContentBlockDelta  ← (可以有多个 Delta)  ContentBlockStop   ← 内容块结束  (可以有多个 ContentBlock)MessageDelta         ← 消息级增量(停止原因、用量统计)MessageStop          ← 消息结束

流式的好处是用户能实时看到输出,LLM 的回复逐字出现,而不是等全部生成完再一次性显示。CLI 层的实现里,收集事件和渲染到终端在同一次遍历中完成,边收集边渲染

4.2 处理网络分片

SSE 看起来简单,但有一个必须处理的问题:网络分片。

一个 SSE 事件可能被切成多个 TCP 包,一个 TCP 包也可能包含多个事件:

网络包 1: "event: content_block_delta\ndata: {\"text\":\"hel"网络包 2: "lo\"}\n\nevent: content_block_delta\ndata: {\"text\":\"world\"}\n\n"

SSE 解析器的处理方式:用 \n\n(两个连续换行)作为事件分帧标记。不完整的帧留在缓冲区等下一个包,完整的帧立即解析。

4.3 重试机制

第 1 次失败 → 等 200ms → 重试第 2 次失败 → 等 400ms → 重试第 3 次失败 → 放弃,返回错误

不是所有错误都值得重试:

状态码
含义
重试?
429
限流
是,等一下可能就好了
500/502/503/504
服务端临时错误
408
请求超时
401
认证失败
否,重试也没用
403
权限不足
404
不存在

区分可重试和不可重试错误,算是 API 客户端的基本功。重试认证失败只是在浪费时间。


五、会话管理与上下文压缩

5.1 Token 估算

判断会话是否过长需要估算 token 数。Claude Code 的做法很直接:

estimated_tokens = 字符数 / 4

英文平均 1 token 约 4 个字符,中文会有偏差,但对于”是否需要压缩”这种粗粒度判断够用了。如果引入完整的 tokenizer 库,会增加几十 MB 的依赖,精度提升对压缩决策基本没影响。

这种「够用就行,不过度工程化」的思路,在整个 claw-code 代码库里反复出现。

5.2 上下文压缩

对话消息过长时,早期消息会被压缩为结构化摘要:

触发条件:消息数 > 保留数量(4) AND 估算 token >= 上限(10000)原始消息: [M1, M2, M3, M4, M5, M6, M7, M8]                     ↓划分:  旧消息(压缩): [M1, M2, M3, M4, M5, M6]  保留消息:       [M7, M8]  // 最近 4 条原样保留                     ↓压缩后: [System("会话摘要: ..."), M7, M8]

5.3 结构化摘要

压缩的关键在于摘要质量。Claude Code 从旧消息中提取 6 个维度的信息:

维度
提取方式
示例
消息统计
计数各角色消息数
“8 messages (3 user, 3 assistant, 2 tool)”
使用的工具
收集所有工具名
“Tools used: bash, read_file, edit_file”
最近用户请求
最后一条用户消息的前 200 字符
“Last request: 请帮我修复…”
待办事项
搜索 todo/next/pending 关键词
“Pending: TODO fix the parser”
关键文件
包含 / 且扩展名是代码类
“Key files: src/main.rs, lib/utils.py”
时间线
按顺序列出用户消息摘要
“Timeline: 1. 扫描仓库 2. 修复 Bug”

这里面我觉得「自动保留待办事项」做得挺好。就是通过关键词搜索 todo/next/pending,确保压缩后不会把”还有个事没做”给丢了。规则很简单,但解决了上下文压缩中一个很实际的痛点。

5.4 手写序列化

会话持久化没有用框架的自动序列化,而是手写 JSON 序列化/反序列化。原因是:会话文件的格式是一种持久化约定。如果依赖框架默认行为,框架版本升级可能悄悄改变输出格式,导致旧会话文件加载不了。手写保证格式完全可控。


六、扩展层:MCP 协议与子 Agent

6.1 MCP:让任何语言的工具都能接入

MCP(Model Context Protocol)是 Anthropic 推出的外部工具集成标准,通过 JSON-RPC 2.0 协议通信:

Claude Code(主进程)              MCP Server(子进程,任何语言)        │                                │        │── initialize ──────────────→  │  握手        │←── capabilities ──────────── │        │                                │        │── tools/list ──────────────→  │  获取工具列表        │←── [tool1, tool2, ...] ────  │        │                                │        │── tools/call(name, args) ──→  │  调用工具        │←── result ─────────────────  │

工具命名用三段式防冲突:mcp__{服务器名}__{工具名}。比如 GitHub 服务器的搜索工具叫 mcp__github__search

还有个工程细节是懒启动:用户可能配置了 10 个 MCP 服务器,但只用了 2 个。Claude Code 不会提前启动所有服务器进程,而是在第一次用到某个服务器的工具时才启动。

6.2 子 Agent:6 种角色

上篇介绍了子 Agent 的基本机制(复用核心循环 + 工具白名单),这里补充它的 6 种预定义角色:

角色
可用工具
典型场景
Explore
读文件、搜索
探索代码库
Plan
读文件、搜索、待办
制定计划
Verification
bash、读、搜索
验证结果
claude-code-guide
读、搜索、网页
帮助文档
statusline-setup
bash、读写文件
配置修改
general-purpose
几乎全部(除 Agent)
通用任务

所有角色都不包含 Agent 工具,防止子 Agent 递归生成子 Agent。

还有一个容错细节:LLM 输出的角色名不可控(可能大小写混乱、单复数不一),所以做了归一化映射。"explore" / "Explorer" / "EXPLORE" 都映射到同一个角色。对 LLM 的输出,永远要做容错处理,这条经验在很多地方都适用。


七、重写策略

claw-code 是社区开发者基于对 Claude Code 原版架构的理解,用 Rust 进行的 clean-room 重写。项目中有一份 PARITY.md(差距分析文档),列出了哪些做了、哪些没做:

[已完成] 核心完整  Agent Loop / 工具系统 / 权限系统  API 客户端 / SSE 解析 / 认证  会话持久化 / 上下文压缩  配置合并 / 提示词构建 / CLAUDE.md  MCP 协议(Stdio)/ 子 Agent  CLI / REPL / 流式渲染[未完成] 外围缺失  插件系统 / Hooks 执行 / LSP 集成  Task 管理 / Team 功能 / 远程传输  高级 MCP(SSE/HTTP/WS)  高级服务(计费/订阅/企业功能)

核心先做到位,外围逐步补齐。Agent Loop + 工具 + 权限 + API + 会话是 Claude Code 的功能本体,插件、Hooks、Team 这些可以慢慢来。

社区选 Rust 重写的原因大概是:原生二进制(启动快、内存小)、单文件分发(不需要 Node.js 运行时)、编译期类型安全、多线程安全(子 Agent 要用到)、可以作为 library 嵌入其他项目。


八、我从中学到的设计经验

从 2 万行源码里挑了 15 条我觉得值得记住的。不全是 AI 智能体相关的,有些是通用的工程经验。

架构

1. 消息数组即状态。 不需要复杂状态机。一个 append-only 的消息数组就是全部状态,能序列化、能压缩、能回放。

2. 用接口解耦核心循环。 定义 ApiClient 和 ToolExecutor 两个接口,核心循环不绑定具体实现。测试用 mock,生产用真实实现,同一份代码。

3. 子系统复用核心循环。 子 Agent 不是新系统,就是用不同参数创建一个新的 AgentRuntime。

4. 接口在底层定义,在顶层实现。runtime 定义 trait,CLI 层提供真实实现。

安全

5. 提权而非全有全无。 差一级可以问用户,差太多直接拒绝。

6. 拒绝也是结果。 权限拒绝不中断循环,作为错误信息喂回 LLM,让它自己调整策略。

7. 白名单防越权。 子 Agent 通过白名单限制能力,不含 Agent 工具防递归。两个独立约束组合出精确策略。

成本

8. 提示词静态与动态分离。 利用 Prompt Caching,静态部分缓存后成本降低 90%。

9. Token 估算用 字符数/4 够用就行,不引入重依赖。

10. 懒启动外部进程。 配置了 10 个 MCP 服务器,只用 2 个?其他 8 个不占任何资源。

工程

11. JSON Schema 做接口约定。 LLM 和工具之间、内部和外部工具之间,统一用 JSON Schema。

12. 上下文压缩要分维度。 分 6 个维度提取摘要(统计/工具/请求/待办/文件/时间线),不是笼统地”总结一下”。自动保留待办事项防遗忘。

13. 对 LLM 输出永远做容错。 类型名归一化、多种写法映射到同一结果。LLM 的输出格式不可控,代码得防御性地处理。

14. 老格式静默兼容。 旧配置解析失败就跳过。版本升级不能把用户现有环境搞坏。

15. 写差距分析文档。 大的重写项目,诚实列出”做了/没做/做了一半”。让所有人知道当前在哪、接下来往哪走。


九、如果你要做一个智能体

读完这 2 万行 Rust 源码,我最直接的感受是:一个 AI 智能体的本质比想象中简单,但从「能跑」到「好用」之间的距离比想象中远。

最小可用智能体

1. Agent Loop — 用户 → LLM → 工具 → 循环(88 行核心逻辑)

2. 工具系统 — 统一注册 + match 分发(新增工具约 30 行代码)

3. 权限控制 — 至少区分只读和执行(233 行实现完整安全模型)

4. 系统提示词 — 定义身份和行为边界(Builder 模式动态组装)

5. API 客户端 — 与 LLM 流式通信(SSE 解析 + 重试)

有了这 5 个部分,你就有了一个能读写文件、执行命令、搜索代码的 AI 编程助手的基础骨架。

从「能跑」到「好用」

在骨架之上,Claude Code 还做了不少工程工作:

  • • Prompt Caching 把成本降了 90%
  • • 上下文压缩让长对话不丢关键信息
  • • CLAUDE.md 层叠发现让用户可以分层定制行为
  • • MCP 协议让任何语言写的工具都能接入
  • • 子 Agent 实现多任务并行
  • • 配置多层合并平衡团队共享与个人偏好

这些才是 Claude Code 从一个 demo 变成一个产品的过程中真正花力气的地方。


本文基于社区 Rust 重写版 claw-code 的源码分析,约 2 万行代码,6 个模块。如果你想自己读,建议从 conversation.rs(Agent Loop)开始,88 行代码,是理解一切的起点。

相关仓库

  • • Rust 重写版:github.com/instructkr/claw-code(社区项目,与 Anthropic 无关)
  • • Claude Code 官方:github.com/anthropics/claude-code(发布仓库,非可读源码)

我是 Antony,关注 AI 智能体与工程实践。如果你也在研究这个方向,欢迎加我一起交流。

微信二维码