
1. 为什么我要把 ClaudeCode 的 Agent 架构拆开看ClaudeCode 是 Anthropic 官方推出的代码智能 Agent它能在终端里读文件、跑命令、改代码背后是一套相当克制的 Agent 架构。很多人第一次接触它会直接去翻源码结果陷进零散的函数调用里看完还是不知道一个请求从输入到工具执行到底经过了哪些层。我试过按「架构先行 → 链路贯通 → 源码落地」的顺序来读效率高很多。这篇内容聚焦三件事把 ClaudeCode 的分层架构讲清楚把一次文件读取的完整链路走通最后给出一套可运行的 MVP 最小骨架包含目录结构、核心模块配置和本地启动验证步骤。适合有 TypeScript 基础、想从零理解 Agent 闭环的开发者。读完你能自己跑通一个「用户输入 → 模型决策 → 工具执行 → 结果汇总」的最小 Agent并知道每一层为什么这样切。需要的前置知识不多TypeScript 的联合类型、async/await、异步生成器Node.js 的 fs/promises 和 readline/promises再加上对 LLM 多轮对话的基本认知就够了。不需要提前熟悉 ClaudeCode 源码也不需要真实 API Key 就能跑通骨架。2. 六层架构与四条硬约束ClaudeCode 的架构核心是分层加单向依赖。整套系统拆成六层每层职责单一只和相邻层通信避免跨层依赖和循环依赖。层级核心文件职责入口层index.ts终端输入输出、事件渲染、维护会话消息队列编排层query.ts全局状态机管控多轮循环、模型调度、工具触发模型层modelClient.ts / fakeModel.ts / config.ts统一模型调用入口支持模拟与真实 API 切换工具层tools.ts工具执行入口权限校验、IO 操作、结果封装消息协议层message.ts定义全量消息类型与工厂函数持久化层compress.ts / session.ts历史压缩、会话保存恢复可选真正支撑稳定性的是四条约束。第一编排层和模型实现完全解耦query.ts 只认通用响应类型不依赖具体模型。第二modelClient.ts 是唯一的模型切换入口换服务商只改这一个文件。第三工具层独立可复用不反向调用编排层。第四消息协议全局唯一message.ts 被所有模块依赖自身不依赖任何业务代码。注意如果你在编排层直接 import 了具体模型实现解耦就破了如果工具层反向调用 query.ts就会形成循环依赖。这两条是最容易踩的坑。3. 一次文件读取的完整链路静态架构看懂后用「读取 package.json」这个场景把模块串起来。整个过程分三个阶段。第一阶段是模型决策工具调用。用户在终端输入read package.json入口层通过消息工厂生成标准用户消息推入消息队列触发编排层。编排层调用模型路由入口模型识别出「读取文件」意图不返回文本而是输出结构化的 tool_use 指令指定工具名和文件路径参数。第二阶段是工具执行。编排层拿到指令后触发 executeToolUse先做权限询问校验通过后用 Node.js 原生 API 读取文件再把结果封装成 tool_result 消息推回队列。第三阶段是结果汇总。模型接收包含工具结果的完整消息队列解析内容生成自然语言回复。编排层收到最终回复后终止循环入口层渲染到终端。这里有个设计亮点值得单独说整个 Agent 没有全局状态变量所有决策和状态流转完全依赖消息队列。模型不输出自由文本而是结构化指令驱动工具执行可控性强。工具执行的成功、权限拒绝、读取失败、未知工具全部走统一的结果封装逻辑没有散乱的异常消息。4. 可复制的 MVP 最小骨架官方 examples/mvp 剔除了持久化、历史压缩、多工具池等扩展能力只保留 6 个核心文件完整复刻原版架构。下面给出目录结构和每个文件的核心配置骨架。claudecode-mvp/ ├── index.ts └── src/ ├── message.ts ├── modelClient.ts ├── fakeModel.ts ├── tools.ts └── query.ts4.1 消息协议层 message.ts这是全局数据地基定义四类消息和工厂函数自身无业务依赖。export type UserMessage { role: user; content: string } export type AssistantMessage { role: assistant; content: string } export type ToolUseMessage { role: tool_use; id: string; toolName: string; input: Recordstring, unknown } export type ToolResultMessage { role: tool_result; toolUseId: string; toolName: string content: string; isError?: boolean } export type Message UserMessage | AssistantMessage | ToolUseMessage | ToolResultMessage let nextToolUseNumber 1 export function createUserMessage(content: string): UserMessage { return { role: user, content } } export function createAssistantMessage(content: string): AssistantMessage { return { role: assistant, content } } export function createToolUseMessage( toolName: string, input: Recordstring, unknown, ): ToolUseMessage { return { role: tool_use, id: toolu_${nextToolUseNumber}, toolName, input } } export function createToolResultMessage( toolUse: ToolUseMessage, content: string, isError false, ): ToolResultMessage { return { role: tool_result, toolUseId: toolUse.id, toolName: toolUse.toolName, content, ...(isError ? { isError } : {}), } } export function lastMessage(messages: Message[]): Message | undefined { return messages.at(-1) }联合类型让 TypeScript 自动收窄自增 ID 让工具调用和结果精准配对isError 按需挂载保持消息精简。4.2 模型路由层 modelClient.ts对外只暴露一个 callModel固定输出契约后续换真实 API 只改这里。import { fakeModel } from ./fakeModel.ts import { Message, ToolResultMessage, UserMessage } from ./message.ts export type ModelResponse | { type: assistant; content: string } | { type: tool_use; toolName: string; input: Recordstring, unknown } export type ModelMessage UserMessage | ToolResultMessage export async function callModel(messages: Message[]): PromiseModelResponse { return await fakeModel(messages) }4.3 模拟模型层 fakeModel.ts不依赖真实 LLM用规则模拟「意图识别 → 工具调用 → 结果汇总」无状态只看最新消息角色。import { lastMessage, Message } from ./message.ts import { ModelResponse } from ./modelClient.ts export async function fakeModel(messages: Message[]): PromiseModelResponse { const latest lastMessage(messages) if (latest?.role user) { const lower latest.content.toLowerCase() if (shouldReadFile(latest.content, lower)) { return { type: tool_use, toolName: Read, input: { filename: extractPath(latest.content) } } } return { type: assistant, content: 普通回答${latest.content} } } if (latest?.role tool_result) { if (latest.isError) { return { type: assistant, content: 工具 ${latest.toolName} 执行失败${latest.content} } } return { type: assistant, content: 读取结果${latest.content} } } return { type: assistant, content: 无法处理${JSON.stringify(latest)} } } function shouldReadFile(text: string, lower: string): boolean { return text.includes(读) || text.includes(打开) || lower.includes(read ) || lower.includes(show file) } function extractPath(text: string): string | undefined { const quoted text.match(/[](.?)[]/)?.[1] if (quoted) return quoted const tokens text.split(/\s/).filter(Boolean) return tokens.find(t /[./\\]|\.json$|\.md$|\.ts$|\.txt$/i.test(t)) }4.4 工具执行层 tools.ts统一执行入口整合路径解析、权限校验、IO 执行、结果封装。import { readFile } from fs/promises import path from node:path import { createToolResultMessage, ToolResultMessage, ToolUseMessage } from ./message.ts import { RuntimeOption } from ./query.ts export async function executeToolUse( toolUse: ToolUseMessage, runtime: RuntimeOption, ): PromiseToolResultMessage { if (toolUse.toolName Read) { const readPath path.resolve(runtime.toolContext.rootDir, toolUse.input.filename as string) const hasAccess await runtime.askUser(申请访问${readPath}) if (!hasAccess) return createToolResultMessage(toolUse, 访问被拒绝, true) try { const content await readFile(readPath, utf-8) return createToolResultMessage(toolUse, content) } catch (err) { return createToolResultMessage(toolUse, 读取失败${(err as Error).message}, true) } } return createToolResultMessage(toolUse, 未知工具调用失败, true) }4.5 编排调度层 query.ts核心状态机用异步生成器实现多轮循环带最大轮次兜底。import { createAssistantMessage, createToolUseMessage, Message, } from ./message.ts import { callModel } from ./modelClient.ts import { executeToolUse } from ./tools.ts export type QueryEvent | { type: assistant; message: Message } | { type: tool_use; message: Message } | { type: tool_result; message: Message } export type RuntimeOption { toolContext: { rootDir: string } askUser: (p: string) Promiseboolean } export async function* query( messages: Message[], runtime: RuntimeOption, ): AsyncIterableQueryEvent, void { const maxToolRounds 5 for (let round 0; round maxToolRounds; round) { const response await callModel(messages) if (response.type assistant) { yield { type: assistant, message: createAssistantMessage(response.content) } return } const toolUse createToolUseMessage(response.toolName, response.input) messages.push(toolUse) yield { type: tool_use, message: toolUse } const toolResult await executeToolUse(toolUse, runtime) messages.push(toolResult) yield { type: tool_result, message: toolResult } } const failed createAssistantMessage(工具循环超过 ${maxToolRounds} 轮已停止。) messages.push(failed) yield { type: assistant, message: failed } }4.6 入口交互层 index.ts维护会话状态消费事件流并渲染与调度逻辑完全解耦。import { cwd, stdin, stdout } from node:process import { createInterface } from node:readline/promises import { createUserMessage, Message } from ./src/message.ts import { query, QueryEvent } from ./src/query.ts const rl createInterface({ input: stdin, output: stdout }) const lineIterator rl[Symbol.asyncIterator]() const messages: Message[] [] async function ask(prompt: string): Promisestring | null { stdout.write(prompt) const next await lineIterator.next() return next.done ? null : next.value } function renderEvent(event: QueryEvent) { const m event.message if (m.role assistant) { console.log(assistant:\n ${m.content}); return } if (m.role tool_use) { console.log(tool_use:\n ${JSON.stringify(m.input)}); return } if (m.role tool_result) { const status m.isError ? error : ok const preview m.content.length 500 ? ${m.content.slice(0, 500)}\n... : m.content console.log(tool_result(${status}): ${m.toolName}\n${preview}) } } while (true) { const answer await ask(user\n) if (answer null) break const input answer.trim() if (input ) continue messages.push(createUserMessage(input)) for await (const event of query(messages, { toolContext: { rootDir: cwd() }, askUser: async (q) { const a await ask(${q} [y/N] ) return a?.trim().toLowerCase() y }, })) { renderEvent(event) } } rl.close()5. 本地启动与验证请求骨架跑起来只需要 Node.js 18 以上版本因为用到了原生 fetch 和 readline/promises。TypeScript 可以直接用 tsx 运行省去编译步骤。# 初始化项目 mkdir claudecode-mvp cd claudecode-mvp npm init -y npm install -D tsx typescript types/node # 按上面的目录结构创建文件后启动 npx tsx index.ts启动后终端会出现user提示符。输入read package.json你会看到类似下面的输出user read package.json tool_use: {filename:package.json} 申请访问/your/path/package.json [y/N] y tool_result(ok): Read { name: claudecode-mvp, version: 1.0.0, ... } assistant: 读取结果{ ... }这条链路验证了三件事模型决策层正确识别了读取意图并输出结构化 tool_use工具层完成了权限询问和真实文件读取编排层把结果回传后模型生成了汇总回复。整个闭环没有全局状态变量全靠消息队列驱动。如果你想接入真实模型只需要改 modelClient.ts把 fakeModel 换成对 Anthropic Messages API 的调用其他文件一行都不用动。这也是分层解耦带来的直接好处。6. 本篇常见错排查报错一Cannot find module ./message.tstsx 默认支持 .ts 后缀导入但如果你用 tsc 编译需要在 tsconfig.json 里开启allowImportingTsExtensions: true并配合noEmit: true。或者把导入路径改成不带后缀的./message。报错二工具执行后循环不结束检查 fakeModel 里对 tool_result 的处理分支。如果最新消息是 tool_result 但你的判断条件写成了latest.role user模型会一直返回 tool_use触发最大轮次兜底。确认分支顺序是先判断 user 再判断 tool_result。报错三权限询问一直返回 falsereadline 的 asyncIterator 在连续调用时如果上一次的 next() 没有 await 完成会拿到 undefined。确保 askUser 里的 ask 调用是 await 的并且不要在 for await 循环外提前消费迭代器。报错四读取文件报 ENOENTpath.resolve 的 rootDir 用的是 cwd()如果你在子目录启动相对路径会解析错。要么在项目根目录启动要么把 rootDir 改成绝对路径。这也是后续加路径白名单沙箱的切入点。报错五TypeScript 报input.filename类型错误toolUse.input 的类型是Recordstring, unknown直接当 string 用会报错。用as string断言或者在 createToolUseMessage 时对 input 做更严格的类型约束。7. 从骨架到可用 Agent 的下一步这套 6 文件骨架已经跑通了标准 Agent 核心链路但离生产可用还有距离。扩展方向按优先级排先接真实 LLM API在 modelClient.ts 新增路由分支再加会话持久化把 messages 数组落盘然后补路径白名单沙箱防止越权读取最后加模型超时和限流容错。如果你在接入真实模型时想快速验证对话效果可以直接用 TaoToken 的模型对话功能做对照测试确认消息格式和工具调用指令是否符合预期。需要生成 API Key 的话在控制台的 API Keys 页面创建即可接入文档里有完整的请求示例。对于长期跑编码任务或 Agent 场景Coding Plan 的额度模型更适合持续调用不用每次手动续。骨架代码建议先跑通再改改的时候守住那四条约束编排层不 import 具体模型、工具层不反向调用编排层、消息类型不拆分、模型切换只动 modelClient.ts。守住这四条你后面加多少工具、换多少模型架构都不会乱。