Claude Code源码架构拆解:从分层设计到权限模型 简介Claude Code 源码架构深度解析·第二版是一份面向 Agent 教学的源码教程作者亲自审校并刻意降低技术门槛适合开发者、教学项目参考者也适合非技术背景读者建立真实系统认知。压缩包共31个文件以19份Markdown教学文档为知识主线辅以5个Python源码文件核心Agent模块、命令行入口、3份PDF报告以及yml/toml工程配置整体仅1.35MB结构紧凑、目录清晰便于按需翻阅。目前已有167人学习浏览是入门Agent源码架构的高性价比资料。内容不仅剖析Claude Code源码组织还附带最小Python Agent教学项目src/agt/agent.py演示核心结构src/agt/cli.py展示命令行入口docs/下覆盖系统提示词、工具权限、记忆会话、上下文管理、运行时循环、失败恢复等细分主题配合PDF报告可从整体架构到具体实现逐层拆解。配合演示项目读者可边读文档边运行验证快速打通从源码到设计的认知链路。1. Claude Code 源码架构是什么一个会自己动手执行命令的终端 AgentClaude Code 这类 agent 不是只回你一段建议它会在你的终端里直接执行命令、读文件、改代码把结果带回模型继续决策整个循环像一名真实工程师坐在键盘前。源码架构要解决的核心问题有三个模型怎么把自然语言意图变成结构化动作这些动作怎么被安全地放进真实环境执行执行结果又怎么回到上下文形成下一轮决策。这篇笔记从源码拆解讲这条循环的主干分层设计、核心模块、命令执行链路、权限模型和模型适配。适合两类人想在本地改行为、加自定义工具的工程师以及参考这种架构做自己 AI 编程助手的开发者。第二版重点补进程内权限模型和外部模型接入这两处是二次开发最容易翻车的地方。2. 源码怎么拆Claude Code 的三层架构与六个核心模块很多人在第一次接触 Claude Code 时以为它是一个“AI 插件”装到 VS Code 里就能用。但源码架构层面CLI 壳才是真正的主角VS Code 配置和桌面版入口都只是拉起这个进程的外壳。我读源码的习惯是先把目录按职责切成三层然后顺着一次完整对话走一遍比逐文件读效率高得多。Claude Code 的源码架构按我的理解可以分成三层CLI 壳、Agent 运行时、模型适配层。CLI 壳负责跟人打交道Agent 运行时负责跟工具打交道模型适配层负责跟大模型打交道。三层之间通过消息和控制流解耦这也是后文所有模块分析的底图。2.1 三层架构CLI 壳、Agent 运行时、模型适配层CLI 壳这一层要解决的问题是“用户怎么把配置和意图交给程序”。它负责参数解析、配置文件加载、终端交互界面和升级检查。以--permission-mode为例这个参数决定整个会话的权限基调它必须在一开始就被解析并固化而不是在对话中途由某个模块临时改变。配置文件里一般会有 provider 相关的配置块包括 API base URL、模型名、环境变量名。第三方配置切换工具能在 DeepSeek、Qwen、GLM 等模型之间切换本质上就是改这些配置字段再重启会话。源码如果把这些字段写死在代码里切换工具就无从下手所以架构上要求配置数据必须来自文件而不是编译期常量。Agent 运行时是源码的重心它实现的是一个循环组装上下文、请求模型、解析输出、分发工具、收集结果、再组装。这个循环不会无限跑源码里必须有一个最大迭代数否则模型连续输出工具调用时程序会失控。运行时下面挂了四个子模块上下文管理器负责拼装和压缩历史会话管理器维护当前工作目录、会话 ID 和消息列表工具注册表维护模型可用的动作集合权限策略在动作执行前做拦截判断。这四件事在同一进程内协同不需要引入消息队列所以它和“微服务架构”“分布式架构”不是一回事。agent 架构天然是单进程多模块形态部署简单调试也简单。模型适配层是第三层职责是把内部统一的消息结构翻译成不同模型 API 的请求格式。Anthropic 原生接口用tool_useblock 表示动作OpenAI 兼容接口用function_call二者在参数结构、流式事件类型、结束标记上都不一样。适配层会定义一个内部中间表示把两种格式都转成中间表示再由运行时统一消费。这样你的源码才可能支持“今天用 Claude明天用 Qwen”而不改业务逻辑。一个典型的运行时循环用伪代码写出来大概长这样# src/cc_agent/__init__.py # 一个极简的 Agent 运行时骨架用来说明 Claude Code 类工具的分层 class AgentRuntime: def __init__(self, model_adapter, tool_registry, permission_policy): self.model model_adapter # 模型适配层统一收口 LLM 调用 self.tools tool_registry # 工具注册表name - ToolSpec self.policy permission_policy # 权限策略执行前拦截 def run(self, user_input: str) - None: context ContextBuilder().build(user_input) # 拼系统提示词历史工具定义 for _ in range(MAX_ITERATIONS): # 防止死循环的硬上限 reply self.model.chat(context.to_messages()) for block in reply.content: if block.type text: print(block.text) elif block.type tool_use: self._dispatch_tool(block.name, block.input) if not reply.has_tool_use(): break这段骨架不是任何开源项目的原样实现只是把核心循环压缩出来了。两个参数值得细看MAX_ITERATIONS是循环的硬上限漏掉它模型生成一串连续工具调用时程序会卡死policy在dispatch之前做判断是安全边界的所在位置不是事后审计。实际工程里tool_useblock 还会带一个id后续工具结果要靠它回填这个 id 会在第三章讲。2.2 核心模块逐个过入口、工具注册表、上下文管理器、安全策略先看入口。常见做法是 main 函数只做组装解析参数读配置初始化 model adapter、tool registry、permission policy然后进入一个 REPL。REPL 读到用户输入后调runtime.run。源码阅读的一个实用技巧是别去找 main 函数本身去找“谁在调model.chat”那个调用点才是运行时的心脏。从那里往两侧展开上游是上下文组装下游是工具分发。再看工具注册表。每个工具是一个 ToolSpec包含name、description、input_schema、needs_approval、handler五个字段。name 是模型侧看到的工具名必须和 API schema 完全一致大小写都不能错description 是模型决定“什么时候调用”的依据写得太简单模型就不会用input_schema 是 JSON Schema 格式的参数定义needs_approval 表示该工具是否需要在执行前得到用户确认handler 是真正的执行函数。工具注册表的好处是新增能力不用改循环注册一个 spec 就好。很多 AI agent 主流架构的二次开发入口就在这里Claude Code 里的execute_command、read_file、write_file都是这种模式定义的。上下文管理器决定模型每次请求能看到什么。它要做两件事静态地裁剪工具定义动态地压缩历史消息。工具定义本身很占 token一个工具几十到几百 token几十个工具就上千了。上下文管理器会把不常用的工具定义临时摘掉或者把 description 换成精简版。历史压缩则是当消息接近窗口上限时把最早的一部分消息交给模型生成摘要再用摘要替换原文。自动压缩一旦发生原始细节就丢了所以压缩阈值要可配置这是源码里一个隐性但是关键的参数。安全策略在架构里不应该散落在各个工具 handler 中而应该集中在 policy 模块。policy 模块解析会话参数把 allowlist、deny patterns 加载成内部结构然后在 dispatch 时统一判断。这里有一个容易忽略的设计点权限判断的输入是“工具名加参数”不是原始文本。也就是说系统可以在工具层声明“此命令需要确认”也可以在命令层做字符串策略。两者配合才能同时防住误点和高危操作。一个简化版工具注册表实现如下# src/cc_agent/tools/registry.py # 工具注册表统一 name/handler/权限声明二次开发加工具只改这里 from dataclasses import dataclass dataclass class ToolSpec: name: str # 模型侧看到的工具名必须和 API schema 一致 description: str # 越具体越好模型靠它决定何时调用 input_schema: dict # JSON Schema参数名/类型/必填 needs_approval: bool # 是否需要用户在终端点确认 handler: callable # 实际执行逻辑 class ToolRegistry: def __init__(self): self._specs {} def register(self, spec: ToolSpec): self._specs[spec.name] spec def to_openai_schema(self): # 转成 OpenAI 兼容接口需要的 tools 参数格式 return [{type: function, function: { name: s.name, description: s.description, parameters: s.input_schema}} for s in self._specs.values()] def dispatch(self, name: str, arguments: dict, context): spec self._specs[name] if spec.needs_approval and not context.permission_granted(name, arguments): raise PermissionDenied(name, arguments) return spec.handler(**arguments)参数说明needs_approval这个字段是权限体系的开关read_file这类只读工具设 Falseexecute_command设 True。PermissionDenied不能只是安静地拒绝还要把拒绝原因带回给模型让它下一轮换一条路走否则模型会反复尝试同一个被拒的操作。to_openai_schema说明适配层的存在价值注册表本身不关心后端是 Anthropic 还是 OpenAI 兼容接口由适配层负责转换。2.3 为什么这么拆给二次开发留的口子第一次改 Claude Code 类源码的人最容易问的问题是新模型怎么接、新工具怎么加。三层架构恰好给了三个明确的口子。换模型只动适配层。你在配置文件里加一个 provider 块填上 base_url、api_key 环境变量名、模型名、适配器类型适配层根据类型分发。这也是“使用 cc switch 接入 DeepSeek、Qwen、GLM 等模型”这类工具能存在的前提源码体系里 provider 是数据驱动而不是代码硬编码。注意适配层不只是改请求地址消息格式转换和工具调用格式转换才是大头。我之前见过有人把 provider 分支写进业务循环里换第四个模型时整个模块都动了一遍教训深刻。加工具只动注册表。写一个 handler注册一个 ToolSpec然后在适配层的 schema 列表里同步加一条。工具返回结构统一为{exit_code, output}或{ok, data}这样上下文管理器可以统一截断和回灌不用为每个工具写特殊逻辑。这种约定比让每个工具自由返回任意结构更重要因为自由结构意味着上下文管理器要做很多防御性判断。改权限只动 policy 层。把高频安全命令加进 allowlist把破坏性命令加进 deny patterns。这块数据可以外置成配置文件让使用者不碰代码就能调权限。架构上最忌讳的是把权限判断直接写进某个 handler 里那会导致新加工具默认没有权限保护。读这套源码时我建议你顺着“用户输入 → CLI 壳 → 运行时 → 工具注册表 → 权限策略 → 适配器”这条线做一份笔记把每次调用涉及的关键参数记下来。读源码配笔记比收藏一堆源码解析文章有用得多因为你真正动手改的时候笔记里的调用链才是你的地图。3. 命令执行链路与权限模型Claude Code 怎么在你的终端里“动手”“claude code 如何直接执行终端命令”这个问题使用教程只能告诉你“它支持跑命令”但源码层面真正关键的是命令由谁、在什么条件下、以什么方式被启动。这一章把执行链路拆成六个步骤再展开权限模型的三个状态最后落到上下文窗口控制。理解了这条链路你就能解释为什么有些命令要弹确认为什么有些输出会把模型“搞迷糊”以及二次开发时 execute_command 该往哪个方向改。3.1 一次终端命令执行的生命周期从模型返回结果到 Shell 执行完毕完整链路是六步。第一步模型返回的 message 夹带tool_useblock里面有工具名和参数。第二步运行时循环解析这个 block先在注册表里按 name 查工具查不到就跳过并记录错误接着按input_schema校验参数参数不合法也跳过。第三步调policy.grant(tool_name, input)放行则继续ask 则把待执行命令打印到终端等用户按 ydeny 则记录拒绝原因。第四步执行 handlerexecute_command的 handler 用 subprocess 起子进程命令通过[bash, -lc, command]传入cwd 为会话工作目录。第五步stdout 和 stderr 合并捕获输出按max_output_chars截断和 exit_code 一起封装成tool_result。第六步tool_result带对应的tool_use_id塞回上下文继续下一轮循环直到模型输出不再包含tool_use。一个简化版的 execute_command 实现如下它反映的是一次命令执行需要处理的全部边界# src/cc_agent/tools/execute_command.py # 命令执行工具子进程、超时、输出截断、权限检查 import subprocess def execute_command(command: str, cwd: str, timeout: int 120, max_output_chars: int 30000): # 关键点用列表传参而不是 shellTrue 拼接避免注入和引号地狱 proc subprocess.Popen( [bash, -lc, command], cwdcwd, stdoutsubprocess.PIPE, stderrsubprocess.STDOUT, # 合并两路模型只看一路 textTrue, start_new_sessionTrue, # 独立进程组超时才能杀干净 ) try: out, _ proc.communicate(timeouttimeout) except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL) # 杀进程组而不是单进程 return {exit_code: -1, output: Command timed out after %ds % timeout} if len(out) max_output_chars: out out[:max_output_chars] \n... [truncated] return {exit_code: proc.returncode, output: out}参数说明timeout默认 120 秒交互式命令不适合直接丢给这个工具max_output_chars取 30000 是常见值再大容易把上下文窗口吃穿。start_new_sessionTrue是血泪经验不加这一行超时 kill 只杀掉 bash 进程bash 启动的子进程会继续留在系统里跑几次你就看到一堆残留的 find 或者 sleep。bash -lc和 Python 侧shellTrue是两码事前者是把整段命令交给 bash 解释后者是在 Python 里再包一层 shell 转义后者更容易出安全问题。命令里如果有外部传入的可变部分你要自己用shlex.quote包一层不能直接拼字符串。3.2 三态权限allowlist、ask、deny 的工程取舍权限不是一道二元闸门而是三态决策。工程上常见用一张表来定义策略策略判断方式典型配置allowlist命令前缀命中则放行allow: [npm test, git status, python -m pytest]ask默认模式每次执行前弹 y/n不配置即默认deny正则命中直接拒绝不询问deny: [rm -rf, curl .* \| sh]工程取舍的核心是默认 ask 是稳妥起点allowlist 用来放行高频安全命令deny 用来拦破坏性命令。allowlist 不能做“包含”匹配否则npm test -- --danger也会命中deny 的正则又要克制写太宽容易误伤正常命令。还有一个架构层面的要点权限判断必须发生在子进程启动之前一旦进程启动再拦截已经来不及。这就是为什么execute_command的 handler 里没有再写权限逻辑权限统一由 policy 模块在 dispatch 前处理。实际使用中你会发现一个绕不开的事实allowlist 只匹配命令前缀防不住“先 cd 到 /tmp 再执行脚本”的组合动作。模型完全可以先调用write_file写一个脚本再调用execute_command执行它。所以真正可靠的体系是两层工具级权限加命令级策略。read_file这种只读工具永远不需要弹窗execute_command默认弹窗高危模式再加 deny。如果某个工具能间接执行任意代码它的权限等级应该跟execute_command一样高这是源码设计里容易被忽略的漏洞点。3.3 会话状态与上下文窗口控制上下文窗口是整个 agent 架构的内存而工具输出是吃掉内存的大户。一个 30000 字符的日志输出换算成 token 可能超过 8000如果窗口只有 60000几轮交互就满了。所以上下文管理器会做动态压缩当历史消息超过窗口的某个阈值常见是 80%就触发 autoCompact把最早的消息交给模型生成摘要再用摘要替换原文。这个阈值要可配置否则用户换一个更大窗口的模型时压缩策略还按旧窗口走白白浪费容量。源码里跟上下文控制相关的参数有三个值得关注max_output_chars决定单次工具输出上限autoCompactThreshold决定何时触发压缩max_turns决定循环上限。我一般建议二次开发时把这三个参数做成配置文件可见不要埋在常量区。工具返回给模型的内容要做到“短而关键”只给失败摘要、错误行号和退出码不要把完整日志回灌。字节数不等于 token 数中文日志和 base64 输出尤其费 token截断时要按 token 估算单纯按字符截断会出现“字符不长但 token 爆了”的情况。4. 避坑Claude Code 源码阅读与二次开发的五条踩坑记录这一章是给踩过坑的人写的。改这类 agent 源码你遇到的大部分问题不是语法错误而是消息格式、权限边界和上下文管理这些“看不见的墙”。每条都是“现象、原因、解决”三段式你可以直接对应自己的场景。4.1 流式输出解析不完整工具调用被截断现象用 OpenAI 兼容接口接 DeepSeek 或 Qwen 时模型偶尔不触发工具调用或者触发了但参数是坏的程序报 JSON 解析错误。原因流式响应里function_call的name和arguments是分片到达的。简单写法是每收到一个 delta 就拼到字符串上但循环可能在arguments还没收完时就因为看到空的 delta 而 break拼出来的 JSON 不完整。这是适配层最典型的坑因为它只在流式模式下出现非流式测试全过。解决适配层解析流式响应时不要边收边解析要等 SSE 流的finish_reason到达后再做json.loads。arguments用增量拼接不是覆盖要按delta.function_call.arguments一段一段追加。另外解析失败时不要把原始错误直接回灌给模型那会让模型陷入“反复生成同一段错误 JSON”的死循环。正确做法是重试一次仍然失败就把这条工具调用标记为 invalid并告诉模型换个写法。4.2 权限模型被“嵌套工具”绕过现象配置了 deny 规则拦截rm -rf但模型先调用write_file写一个脚本再调用execute_command执行sh /tmp/clean.sh绕过了拦截。原因allowlist 和 deny 都是对命令字符串做前缀或正则匹配而bash -lc接受的是整段 shell 脚本前缀匹配看不到脚本里的内容。更隐蔽的是工具链组合write_file本身没有危险危险的是后续用execute_command把写出来的东西跑起来。解决不要把权限判断停留在字符串匹配。二次开发时对execute_command的命令做命令解析抽取出实际的可执行文件路径比如sh、bash、python然后对路径后面的脚本参数做二次扫描。如果脚本是刚由write_file生成的源码里可以把这一对操作关联起来对“写文件后立刻执行”的组合默认走 ask 模式。另外blocklist 里要覆盖间接执行模式比如sh .*、bash .*、eval而不仅是常见的rm -rf。4.3 工具结果把上下文窗口爆掉现象模型调用了一个git log --all或者find / -name *.log然后下一轮就开始答非所问甚至忘掉最初的用户请求。原因单次工具输出过大把上下文窗口里属于对话历史和系统指令的部分挤掉了。更麻烦的是源码里如果只按字符数截断而日志里的堆栈行重复率高token 数会远高于字符数截断后仍然爆窗口。解决给工具输出做三层收口。第一层按行数截断只保留前 N 行和后 N 行第二层做 token 估算截断用 tiktoken 或类似库拿不准时宁可少留第三层是结构化摘要对明显是日志的内容只提取异常行、行号和退出码把中间重复的堆栈省略号代替。还有一个好用的兜底工具返回结构里增加一个truncated布尔字段上下文管理器看到它时自动触发一次历史压缩防止本轮输出的余威影响下一轮。4.4 换模型后工具调用格式不兼容现象用 Claude 模型一切正常通过 cc-switch 这类配置切换到 Qwen 后模型只输出文本从不调用工具偶尔调用也是参数结构对不上。原因适配层只做了消息格式的转换没有做工具调用格式的双向转换。Anthropic 的tool_useblock 和 OpenAI 兼容接口的function_call结构不同前者是{type: tool_use, name: ..., input: {...}}后者是{function: {name: ..., arguments: JSON字符串}}。如果内部循环只认一种格式另一家的工具调用就会被静默丢弃。解决内部统一成中间表示适配层负责双向转换。具体做法是在适配器里定义两个方法parse_tool_calls(response)把各家格式解析成统一的ToolCall(name, arguments)列表render_tools(tool_specs)把注册表里的工具定义转换成目标 API 要求的 schema。这样换模型时只新增一个 adapter不碰运行时的循环逻辑。配置层面也要保证base_url、adapter_type、model_name三个字段解耦这是配置切换类工具能工作的前提。4.5 日志缺失二次开发排错像黑匣子现象改完源码后模型不调用工具程序也不报错什么都看不到只能一遍遍加 print 重新跑。原因dispatch 前后没有结构化日志权限被拒时异常被上层吞掉模型请求的原始响应也没有落盘。这类 agent 的异步回流很容易让错误被淹没在循环里。解决在dispatch调用前后加结构化日志至少记录四样东西工具名、参数摘要、权限决策、耗时。PermissionDenied不能只记日志还要把拒绝原因写进给模型看的tool_result否则模型不知道为什么被拦会反复尝试同一个操作。模型请求的原始响应建议在调试级别落盘包括流式事件的原始片段。有了这份日志你才能分辨“模型没打算调用工具”和“模型调了但被适配层丢了”之间的差别这两个问题的修法完全不同。5. 调试与进阶验证给 Claude Code 加自定义工具并跑通回归最值得先做的二次开发实验是加一个限定范围的测试工具。它把“只能对指定目录跑 pytest”写进工具层而不是靠提示词约束模型。这样既验证了工具注册表、适配器、权限策略三个模块的配合也避免模型拿任意命令胡跑。注册代码大概是这个形态# examples/add_tool.py # 加一个限定目录的测试执行工具避免模型拿任意命令胡跑 def run_pytest_for_path(path: str, extra_args: str ) - dict: cmd fcd {shlex.quote(path)} python -m pytest -x -q {extra_args} return execute_command(cmd, cwdpath, timeout60, max_output_chars20000) registry.register(ToolSpec( namerun_pytest_for_path, description在指定目录运行 pytest返回失败摘要。仅用于 python 项目。, input_schema{ type: object, properties: { path: {type: string, description: 项目绝对路径}, extra_args: {type: string, default: }, }, required: [path]}, needs_approvalTrue, handlerrun_pytest_for_path, ))这里三个参数值得注意cwd从 path 推导而不是让模型传避免模型给出一个不存在的目录timeout设 60 秒太短 pytest 没跑完太长会卡住整个循环max_output_chars设 20000足够装下失败摘要但不撑爆窗口。needs_approvalTrue保证第一次触发时用户能看到并确认而不是被静默执行。跑通后写一个最小回归脚本用一个 mock 模型返回固定的tool_use块断言 dispatch 回调了 handler、权限回调被询问、返回的tool_result被截断。这个回归脚本是改权限和适配器时的“后悔药”先跑它再跑真人会话能省下大量调提示词的时间。我现在的习惯是任何自定义工具默认needs_approvalTrue跑一周看日志确认它足够人畜无害再考虑放进 allowlist工具返回值永远先截断再回灌模型宁可让模型说“信息不足”也不要让它被日志淹没。这两个习惯都是从翻车里磨出来的。希望帮到你。本文还有配套的精品资源点击获取