☰
深入理解 Claude Code 源码:终端 Agent 的工程解剖与实现
2026/10/18 23:09:16 网站建设 项目流程

简介:《深入理解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、parameters(JSON 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 行的简化版,跑通再回去看细节。这样比逐行读快得多,也不容易迷失在工具实现里。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询