简介《深入理解Claude Code源码》面向希望深入终端AI Agent内部实现的技术人员系统剖析Anthropic命令行编程助手的架构设计、工程实践与编程哲学。全书25章加2个附录覆盖查询引擎、工具系统、Agent编排、六层权限模型、MCP协议、状态管理与性能优化等核心子系统并配有101个Mermaid图表、467个TypeScript代码块、55条术语与90余个关键源码索引基于v2.1.x版本展开分析。资源为单个PDF文件压缩包约14.75MB适合按Part 1-2顺序建立全局认知后按兴趣跳读时间有限者可优先阅读全景概览、工具架构、Agent模型与工程哲学章节。目前已有362人学习读者可借此理解40余种内置工具的动态注册机制、Fork/Resume编排、并行预取与编译时DCE等设计细节获得俯瞰大型AI系统源码的完整视角。1. 深入理解 Claude Code 源码一个终端 Agent 的工程解剖很多人第一次用 Claude Code会觉得它“就是套了个壳的聊天框”。但真把它的源码摊开看你会发现它和普通 CLI 工具完全不是一个物种它把模型调用、工具调度、权限确认、上下文压缩、会话持久化这几件事揉进了一个终端进程里而且每一块都有明确的工程取舍。我最初也是抱着“看看它怎么调 API”的心态去读结果被它的工具循环和上下文管理设计卡了整整两天。这篇笔记面向的是想自己动手复现一个终端 Agent 的开发者——不管你是想改造它、还是想从零写一个类似的编码助手这里面的调度逻辑、参数设计和踩坑点都能直接抄。读完之后你至少能判断哪些设计是必须的哪些是可以砍掉的以及自己动手时最容易在哪翻车。2. 从入口到主循环Claude Code 源码的骨架长什么样读任何 Agent 类项目第一步永远是找主循环。Claude Code 的源码结构不算复杂但它的调用链藏得比较深因为中间夹了一层工具注册和权限校验。我一般会按“入口 → 会话初始化 → 消息循环 → 工具执行 → 结果回填”这条线去拆基本不会迷路。2.1 入口文件与 CLI 参数解析终端工具的入口通常很薄真正的东西在后面。Claude Code 的入口负责解析命令行参数、加载配置、决定是进入交互模式还是单次执行模式。常见做法是用一个轻量的参数解析库把--model、--cwd、--resume这类参数映射到内部配置对象。// 入口伪代码解析参数并启动会话 import { parseArgs } from ./cli/args.js; import { createSession } from ./core/session.js; import { runInteractive } from ./ui/repl.js; async function main() { // 解析命令行参数得到配置对象 const config parseArgs(process.argv.slice(2)); // 根据配置创建会话会话内部持有消息历史和工具注册表 const session await createSession({ model: config.model, // 模型标识决定后续调用哪个后端 cwd: config.cwd, // 工作目录工具执行时的相对路径基准 resumeId: config.resume, // 恢复历史会话的 ID为空则新建 }); // 交互模式进入 REPL 循环单次模式执行一次后退出 if (config.interactive) { await runInteractive(session); } else { await session.runOnce(config.prompt); } } main().catch((err) { console.error(启动失败:, err.message); process.exit(1); });这段代码的关键在于createSession返回的对象。它不是简单的“消息数组 模型客户端”而是一个持有工具注册表、权限策略、上下文窗口管理器的复合对象。参数说明上model决定后端路由cwd影响所有文件类工具的路径解析resumeId则关联到磁盘上的会话文件。很多人读源码时跳过入口直接看主循环结果后面看到session.tools不知道从哪来的——其实就是在这里注入的。2.2 消息循环与工具调用的调度逻辑主循环是整个 Agent 的心脏。它的核心逻辑是把当前消息历史发给模型模型返回要么是纯文本回复要么是工具调用请求如果是工具调用就执行工具、把结果追加到历史然后再次调用模型直到模型返回纯文本为止。// 主循环伪代码模型调用与工具执行的交替 async function runLoop(session) { while (true) { // 1. 把当前消息历史发给模型 const response await callModel({ messages: session.history, tools: session.tools.schema(), // 工具描述告诉模型有哪些工具可用 maxTokens: session.config.maxTokens, }); // 2. 把模型回复追加到历史 session.history.push(response.message); // 3. 如果没有工具调用说明本轮结束 if (!response.toolCalls || response.toolCalls.length 0) { return response.message.content; } // 4. 依次执行工具调用收集结果 for (const call of response.toolCalls) { const tool session.tools.get(call.name); if (!tool) { session.history.push(toolError(call.id, 未知工具)); continue; } // 权限校验危险操作需要用户确认 const allowed await session.permissions.check(tool, call.args); if (!allowed) { session.history.push(toolError(call.id, 用户拒绝执行)); continue; } // 执行工具并把结果回填到历史 const result await tool.execute(call.args, { cwd: session.config.cwd }); session.history.push(toolResult(call.id, result)); } } }这里有几个参数值得注意。tools.schema()返回的是工具的描述列表格式必须符合模型后端的要求否则模型根本不会发起调用。maxTokens控制单次回复长度设太小会导致工具调用参数被截断设太大则浪费上下文窗口。权限校验放在执行之前而不是之后这是安全底线——我见过有人把校验写在工具内部结果模型可以通过构造参数绕过。工具执行结果统一用toolResult包装带上call.id这样模型才能把结果和请求对应起来。2.3 会话状态与上下文窗口的维护方式Agent 跑久了消息历史会越来越长最终撑爆上下文窗口。Claude Code 的处理方式不是简单截断而是有一套压缩策略保留系统提示和最近的若干轮对话把中间的工具调用结果做摘要或丢弃。// 上下文压缩在发送给模型前裁剪历史 function compactHistory(history, maxTokens) { // 估算当前历史的 token 数 let total estimateTokens(history); // 如果没超限直接返回 if (total maxTokens) return history; // 保留第一条系统提示 const system history[0]; const rest history.slice(1); // 从最旧的消息开始丢弃直到降到阈值以下 while (total maxTokens rest.length 0) { const removed rest.shift(); total - estimateTokens([removed]); } return [system, ...rest]; }这个策略很朴素但有效。estimateTokens通常用字符数除以一个经验系数来近似不需要精确。真正需要注意的是丢弃消息时不能把工具调用和对应的结果拆散否则模型会看到孤立的toolResult而困惑。我一般会在丢弃前检查边界确保成对出现。另外系统提示永远保留因为它定义了 Agent 的行为边界。3. 工具系统Agent 能力的真正来源模型本身只会生成文本Agent 之所以能读写文件、执行命令全靠工具系统。Claude Code 的工具设计有几个原则每个工具职责单一、参数 schema 明确、执行结果结构化。这一章拆开讲工具怎么注册、怎么执行、权限怎么管。3.1 工具注册表与 schema 定义工具注册表是一个映射结构键是工具名值是工具对象。每个工具对象必须提供name、description、parametersJSON Schema 格式和execute方法。// 工具注册表示例文件读取工具 const readFileTool { name: read_file, description: 读取指定路径的文件内容返回文本, parameters: { type: object, properties: { path: { type: string, description: 相对于工作目录的文件路径 }, maxLines: { type: number, description: 最多读取的行数默认 200 }, }, required: [path], }, async execute(args, ctx) { const fullPath path.resolve(ctx.cwd, args.path); const content await fs.readFile(fullPath, utf-8); const lines content.split(\n).slice(0, args.maxLines ?? 200); return { content: lines.join(\n), truncated: lines.length (args.maxLines ?? 200) }; }, }; // 注册到会话 session.tools.register(readFileTool);description不是给人看的是给模型看的。写得越清楚模型调用越准确。parameters用 JSON Schema 描述模型后端会据此校验生成的参数。execute接收两个参数模型生成的args和运行时上下文ctx。ctx.cwd是路径解析的基准绝对不能忽略——否则模型传相对路径时你会读到错误的位置。返回结果建议结构化带上truncated这类元信息方便模型判断是否需要继续读取。3.2 工具执行沙箱与权限确认流程工具执行是风险最高的环节尤其是执行 shell 命令和写文件。Claude Code 的权限系统分三层白名单自动放行、灰名单需要确认、黑名单直接拒绝。// 权限校验根据工具类型和参数决定是否放行 async function checkPermission(tool, args, policy) { // 只读工具默认放行 if (tool.readOnly) return true; // 检查黑名单危险命令直接拒绝 if (tool.name run_command) { const cmd args.command ?? ; if (policy.denyPatterns.some((p) p.test(cmd))) { return false; } // 灰名单需要用户确认 if (policy.confirmPatterns.some((p) p.test(cmd))) { return await promptUser(是否执行: ${cmd}); } } // 写文件类工具默认需要确认 if (tool.name write_file) { return await promptUser(是否写入: ${args.path}); } return true; }denyPatterns和confirmPatterns是正则数组配置在策略对象里。黑名单要覆盖删除、覆盖系统文件这类操作灰名单覆盖安装依赖、修改配置这类。用户确认用交互式提示拒绝后返回错误结果给模型模型通常会换一种方式重试。这里有个坑确认提示不能阻塞太久否则整个循环卡死一般会设超时。3.3 内置工具清单与各自职责边界Claude Code 的内置工具不多但每个都有明确边界。下面这张表是我整理的核心工具及其参数要点。工具名职责关键参数注意事项read_file读取文件内容path, maxLines大文件必须限制行数write_file写入或覆盖文件path, content需要权限确认list_dir列出目录内容path, recursive递归深度要限制run_command执行 shell 命令command, timeout黑名单 超时双重保护search_code按关键词搜索代码pattern, path底层用 ripgrep 更快工具边界的设计原则是一个工具只做一件事。不要做一个“万能文件操作”工具那样模型很难正确调用。run_command的timeout参数尤其重要默认给 30 秒长任务让模型自己决定是否加长。search_code底层如果调 ripgrep记得处理二进制文件和.gitignore否则搜索结果会很难看。4. 上下文管理与提示词工程让 Agent 不跑偏Agent 跑着跑着就“忘了自己在干什么”这是最常见的翻车现场。根因通常不在模型而在上下文管理和提示词设计。这一章讲系统提示怎么组织、历史怎么压缩、长会话怎么保持一致性。4.1 系统提示的分层结构与动态注入系统提示不是一大坨静态文本而是分层的基础行为约束、工具使用规范、当前环境信息、项目特定规则。前两层固定后两层动态注入。// 系统提示组装分层拼接 function buildSystemPrompt(session) { const layers [ // 第一层基础行为约束固定不变 你是一个编码助手帮助用户完成代码相关任务。, 不要编造文件内容不确定时先读取。, // 第二层工具使用规范 调用工具前先说明意图。, 读取文件时优先使用 read_file不要用 run_command 执行 cat。, // 第三层环境信息动态生成 当前工作目录: ${session.config.cwd}, 可用工具: ${session.tools.names().join(, )}, // 第四层项目规则从配置文件读取 session.projectRules ? 项目规则:\n${session.projectRules} : , ]; return layers.filter(Boolean).join(\n); }分层的好处是每层可以独立维护和测试。第一层改动会影响所有行为要谨慎第三层每次会话都不同必须动态生成。projectRules从项目根目录的配置文件读取让用户能自定义 Agent 行为。注意过滤空字符串否则提示里会出现多余空行浪费 token。4.2 历史消息压缩与摘要生成策略前面提过简单截断但长会话里更好的做法是摘要。把中间的工具调用结果压缩成一段描述保留关键信息。// 摘要压缩把旧消息交给模型生成摘要 async function summarizeHistory(history, keepRecent 6) { // 保留最近 N 轮其余做摘要 const recent history.slice(-keepRecent); const old history.slice(0, -keepRecent); if (old.length 0) return history; // 让模型把旧消息压缩成一段摘要 const summary await callModel({ messages: [ { role: system, content: 把以下对话压缩成简短摘要保留关键决策和文件路径。 }, { role: user, content: JSON.stringify(old) }, ], maxTokens: 500, }); // 用摘要替换旧消息 return [ { role: system, content: 历史摘要: ${summary.content} }, ...recent, ]; }keepRecent控制保留多少轮原始消息一般 4 到 8 轮。摘要本身也消耗一次模型调用所以不要每轮都做可以设一个阈值比如历史超过 50 条才触发。摘要提示里明确要求保留文件路径和关键决策否则摘要会变得很空。这个策略的代价是丢失细节所以重要操作的结果最好在生成时就写入磁盘而不是只留在历史里。4.3 长会话中的状态一致性保持长会话最容易出的问题是状态漂移Agent 以为文件还是之前的样子其实已经被改过了。解决办法是在关键操作前重新读取状态而不是依赖历史记忆。// 写文件前先读取当前内容避免覆盖他人修改 async function safeWrite(session, path, newContent) { const current await readFileTool.execute({ path }, { cwd: session.config.cwd }); // 如果文件存在且内容不同提示模型确认 if (current.content current.content ! newContent) { const confirmed await promptUser(文件 ${path} 已被修改是否覆盖); if (!confirmed) return { skipped: true }; } return await writeFileTool.execute({ path, content: newContent }, { cwd: session.config.cwd }); }这个safeWrite模式我强烈建议加上。它把“读-确认-写”做成原子操作避免覆盖别人的修改。另外会话恢复时不要直接信任磁盘上的历史文件最好重新扫描工作目录把当前文件状态注入系统提示。状态一致性没有银弹核心思路就是关键操作前重新验证不依赖记忆。5. 避坑与排查读源码和自建 Agent 时最容易翻车的地方这一章是我自己踩过的坑按“现象 → 原因 → 解决”整理。每一条都对应源码里一个具体的设计点理解了就能少走弯路。5.1 工具调用参数被截断导致执行失败现象模型发起的工具调用参数不完整比如 JSON 少了个右括号执行时报解析错误。原因maxTokens设得太小模型生成到一半被截断。解决把maxTokens调到至少 2048并在解析失败时把错误信息回填给模型让它重新生成。源码里对工具调用结果有专门的错误包装就是为了处理这种情况。5.2 上下文压缩把工具调用和结果拆散现象模型看到孤立的toolResult回复“我没有调用过这个工具”。原因压缩历史时按条数丢弃没检查消息配对。解决丢弃前扫描边界确保toolCall和对应的toolResult成对保留或成对丢弃。源码里的压缩函数虽然简单但调用前有一层配对检查读的时候容易忽略。5.3 权限校验被工具内部逻辑绕过现象明明配了黑名单模型还是执行了危险命令。原因权限校验写在工具execute内部而模型可以通过构造参数走另一条分支。解决校验必须在工具执行之前由调度层统一做工具内部不再重复判断。源码里checkPermission是在主循环里调用的不在工具内部这个顺序不能改。5.4 会话恢复后工作目录不一致现象恢复历史会话后文件路径全部解析错误。原因会话文件里存的是相对路径恢复时cwd变了。解决会话持久化时存绝对路径或者恢复时强制要求指定cwd并校验。源码里resumeId关联的会话文件会记录创建时的cwd恢复时如果不匹配会警告。5.5 工具描述太模糊导致模型乱调用现象模型该用read_file的时候用了run_command执行cat。原因工具description写得太笼统模型分不清边界。解决每个工具的description明确写“什么时候用、什么时候不用”并在系统提示里加一条“优先使用专用工具”。源码里read_file的描述就明确写了“不要用 run_command 读取文件”。6. 进阶自己动手复现一个最小可用终端 Agent读源码的最终目的是能自己写一个。这一章给一个最小可用的实现路径不追求功能完整但求跑通核心循环。我一般会从三个文件开始main.js入口、loop.js主循环、tools.js工具集。先跑通“读文件 回复”再加“写文件 命令执行”最后加权限和压缩。// 最小 Agent主循环 两个工具 import readline from readline; const tools { read_file: { description: 读取文件, parameters: { type: object, properties: { path: { type: string } }, required: [path] }, async execute({ path }) { return await fs.readFile(path, utf-8); }, }, write_file: { description: 写入文件, parameters: { type: object, properties: { path: { type: string }, content: { type: string } }, required: [path, content] }, async execute({ path, content }) { await fs.writeFile(path, content); return 写入成功; }, }, }; async function loop(history) { while (true) { const res await callModel({ messages: history, tools: Object.keys(tools) }); history.push(res.message); if (!res.toolCalls?.length) { console.log(res.message.content); return history; } for (const call of res.toolCalls) { const tool tools[call.name]; const result tool ? await tool.execute(call.args) : 未知工具; history.push({ role: tool, toolCallId: call.id, content: String(result) }); } } }这个骨架跑通后按优先级加东西第一加权限确认第二加历史压缩第三加会话持久化。每加一个都单独测试不要一次全上。验证方法是构造几个典型场景读一个不存在的文件、写一个只读文件、连续调用多个工具。能稳定处理这些基本就可用。我自己的习惯是每读一个 Agent 源码就照着它的主循环写一个 50 行的简化版跑通再回去看细节。这样比逐行读快得多也不容易迷失在工具实现里。希望帮到你。本文还有配套的精品资源点击获取