简介: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 源码架构是什么:一个会自己动手执行命令的终端 Agent
Claude 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这类只读工具设 False,execute_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 则把待执行命令打印到终端等用户按 y,deny 则记录拒绝原因。第四步,执行 handler,execute_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): # 关键点:用列表传参而不是 shell=True 拼接,避免注入和引号地狱 proc = subprocess.Popen( ["bash", "-lc", command], cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, # 合并两路,模型只看一路 text=True, start_new_session=True, # 独立进程组,超时才能杀干净 ) try: out, _ = proc.communicate(timeout=timeout) 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_session=True是血泪经验:不加这一行,超时 kill 只杀掉 bash 进程,bash 启动的子进程会继续留在系统里,跑几次你就看到一堆残留的 find 或者 sleep。bash -lc和 Python 侧shell=True是两码事,前者是把整段命令交给 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 = f"cd {shlex.quote(path)} && python -m pytest -x -q {extra_args}" return execute_command(cmd, cwd=path, timeout=60, max_output_chars=20000) registry.register(ToolSpec( name="run_pytest_for_path", description="在指定目录运行 pytest,返回失败摘要。仅用于 python 项目。", input_schema={ "type": "object", "properties": { "path": {"type": "string", "description": "项目绝对路径"}, "extra_args": {"type": "string", "default": ""}, }, "required": ["path"]}, needs_approval=True, handler=run_pytest_for_path, ))这里三个参数值得注意:cwd从 path 推导而不是让模型传,避免模型给出一个不存在的目录;timeout设 60 秒,太短 pytest 没跑完,太长会卡住整个循环;max_output_chars设 20000,足够装下失败摘要但不撑爆窗口。needs_approval=True保证第一次触发时用户能看到并确认,而不是被静默执行。跑通后写一个最小回归脚本:用一个 mock 模型返回固定的tool_use块,断言 dispatch 回调了 handler、权限回调被询问、返回的tool_result被截断。这个回归脚本是改权限和适配器时的“后悔药”,先跑它再跑真人会话,能省下大量调提示词的时间。
我现在的习惯是:任何自定义工具默认needs_approval=True,跑一周看日志,确认它足够人畜无害,再考虑放进 allowlist;工具返回值永远先截断再回灌模型,宁可让模型说“信息不足”,也不要让它被日志淹没。这两个习惯都是从翻车里磨出来的。希望帮到你。
本文还有配套的精品资源,点击获取