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,有三重保护:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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 次失败 → 放弃,返回错误
不是所有错误都值得重试:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
区分可重试和不可重试错误,算是 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 个维度的信息:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
todo/next/pending 关键词 |
|
|
|
/ 且扩展名是代码类 |
|
|
|
|
|
这里面我觉得「自动保留待办事项」做得挺好。就是通过关键词搜索 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 种预定义角色:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
所有角色都不包含 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 智能体与工程实践。如果你也在研究这个方向,欢迎加我一起交流。

夜雨聆风