☰
拆解Claude Code源码:从主循环到工具系统,掌握LLM Agent核心架构
2026/10/27 7:04:18 网站建设 项目流程

简介:《Claude Code 源码架构深度解析-第二版》是一份系统性拆解 Claude Code 源码架构的学习资料包,面向想理解大模型 Agent 内部设计的 Python 开发者、技术爱好者,也适合非技术背景读者入门。压缩包共 31 个文件,以 19 个 Markdown 文档为核心,另有 5 个 Python 脚本、3 个 PDF 报告及少量配置与依赖文件,整体仅 1.35MB,轻量易携带。Markdown 内容覆盖产品总览、系统提示与编排、工具权限、技能与插件 MCP、Agent 运行循环、上下文与记忆管理、任务模型、失败恢复、MVP 范围等主题,同时提供教学用最小 Python Agent 项目,核心代码位于 src/agt/agent.py,CLI 入口在 src/agt/cli.py,可直接运行调试,将抽象架构落到真实代码。作者亲自审校并刻意降低技术门槛,配合 PDF 报告与逐模块笔记,帮助读者从零到一建立对 Agent 系统设计的整体认知,目前已有 167 人学习。

1. Claude Code 源码架构:为什么拆源码比跑 20 个 Demo 更能教会你 Agent

Claude Code 源码架构,是我拆过的 Python Agent 项目里投入产出比最高的一份。它不是一个教你调 API 的教学 Demo,而是一个把生产级 Agent 完整骨架摊开给你看的源码工程:CLI 入口怎么做依赖注入、Agent 主循环怎么控制轮次、工具注册表怎么把 Python 函数暴露给模型、MCP 客户端怎么把外部工具服务器拉进会话、权限系统怎么决定哪些命令自动执行、上下文压缩怎么保证长会话不崩。每一条链路都能在源码里找到对应实现,而不是停留在概念图上。如果你已经写过两三个 LLM 调用 Demo,但对 Agent 的「主循环怎么不跑飞」「工具结果怎么喂回模型」还是一头雾水,这份第二版资源比 10 个教学 Demo 都值得先读。下面的内容全部基于我实际拆这套源码时的路线,照着走,你能少踩一半的坑。

2. 先看骨架:从 CLI 入口到 Agent 主循环,摸清核心链路

读源码最忌讳一上来就钻细节。我的习惯是拿工程当洋葱剥:先找最外层入口,再逐层往里。Claude Code 本质是一个命令行程序,你在终端敲下claude的那一刻,最先执行的是一段标准 CLI 解析逻辑,然后把结果交给后面的装配代码。如果你用 VSCode 接入 Claude Code,入口会多一层 IDE 适配,但核心链路是一样的:命令行参数 → 配置合并 → 依赖装配 → 创建 Agent 实例 → 进入会话循环。这一章就沿着这条链路走一遍。

2.1 入口文件与依赖注入:终端命令是怎么变成 Agent 实例的

工程里main.py的常见实现是下面这个骨架,我按最常见的依赖注入方式简化过:

# main.py:第一层骨架 import asyncio from cli import parse_args # 命令行参数解析 from config import load_config # 配置加载与合并 from agent import Agent def main(): args = parse_args() # 比如 --model、--print、--output-format cfg = load_config(args) # 合并命令行 > 环境变量 > 工程配置 registry = build_tool_registry(cfg) # 工具注册表,见第 3 章 mcp = build_mcp_manager(cfg) # MCP 服务器连接池 agent = Agent(cfg=cfg, tools=registry, mcp=mcp) asyncio.run(agent.run(args.prompt)) if __name__ == "__main__": main()

逻辑说明:这段代码里main()没有任何业务逻辑,它只做三件事:收参数、组装依赖、启动 Agent。build_tool_registry把内置工具(bash、文件读写、搜索等)注册成一张表,build_mcp_manager负责连接外部 MCP 服务器,之后这两个对象被当作依赖注入到 Agent 构造器里。这样设计的直接好处是测试和二次开发时,可以只替换其中一个组件而不动 Agent 本体。比如你在测试工具逻辑,完全可以注入一个 fake registry,让 Agent 主循环照常跑,但所有工具调用都落进 mock 对象。

参数说明:parse_args()解析用户可见参数,常见的有--model指定模型、--print进入非交互输出模式、--output-format控制结果格式。load_config()的合并顺序值得记一下:命令行参数优先级最高,其次是环境变量,最后才是工程配置文件。我见过不少人改了settings.json发现不生效,原因就是环境变量里残留了一个旧值把配置覆盖了。排查这种问题,先看load_config的实现,确认当前版本到底先读谁。

读到这里你就找到了第一个锚点:Agent构造器接收的是配置和依赖对象,之后的run()方法才是核心,前面的代码只是脚手架。VSCode 扩展那一条入口的代码结构也类似,只是把parse_args换成了 IDE 传过来的会话参数,依赖装配的部分几乎可以复用。

2.2 Agent 主循环:一个 while 循环如何撑起整个会话过程

很多人以为 Agent 是个复杂的黑匣子,其实核心就是一个循环:把消息发给模型 → 模型返回文本或工具调用 → 执行工具 → 把结果追加回消息列表 → 再发给模型。循环停下来只有两种可能:模型认为任务完成,或者轮次用完了。第二版源码里的主循环逻辑简化后是下面这样:

# agent.py:Agent 主循环骨架 async def run(self, user_input: str): self.history.append({"role": "user", "content": user_input}) for turn in range(self.max_turns): resp = await self.client.messages.create( model=self.model, max_tokens=self.max_tokens, tools=self.tool_registry.schemas(), # 工具定义转成 API 需要的格式 messages=self.history, ) self.history.append(assistant_msg(resp)) # 模型回复必须进历史 if resp.stop_reason != "tool_use": break for block in resp.content: if block.type == "tool_use": result = await self.execute_tool(block.name, block.input) self.history.append(tool_result(block.id, result)) # 带上工具结果后进入下一轮 return self.history[-1]

逻辑说明:这个循环最关键的不变量是「每一条模型输出都必须完整写回history」,尤其是工具调用结果tool_result。如果你在二次开发时漏了这一步,模型会在下一轮反复调用同一个工具,因为它在消息里根本看不到执行结果,这是 Agent 开发里最常见的翻车现场之一。另外一个细节是循环体内每执行一个工具就追加一条tool_result,但是如果模型一次返回了多个tool_use块,工程里的做法通常是先收集所有工具结果再一起追加,避免历史顺序错乱。

参数说明:max_turns是轮次上限,Claude Code 默认值不低,但生产环境一般会调小一点,防止工具调用陷入死循环。max_tokens是单轮生成的输出上限,注意它和上下文总预算不是一回事。stop_reason是接口返回的停止原因,当它等于"tool_use"时说明模型想要调用工具;等于"end_turn"或其他值时说明模型认为可以收尾。调试 Agent 时我永远先看这个字段,比看完整消息快得多。

2.3 配置加载与环境探测:模型、权限、hooks 在哪里读

第三个锚点是配置加载。读源码时你会频繁看到三个来源:CLI 参数、环境变量、工程级配置文件(比如.claude/settings.json)。这三者的合并逻辑通常集中在config.py里。下面这张表对应第二版源码里最容易被检索到的配置项,也是我建议你拿到源码后最先对照着看的部分。

配置项常见位置作用
modelsettings.json / 环境变量指定模型,调试时换成不同模型来对比
permissionssettings.json控制工具自动执行、询问还是拒绝
hookssettings.json在 PreToolUse、PostToolUse 等事件触发回调
mcpServers.mcp.json / settings声明外部 MCP 服务器连接信息
maxTokenssettings / CLI单轮生成上限,也是压缩策略的预算基准

环境探测也值得细读:Agent 会把当前目录、Git 分支、操作系统、是否在 CI 里等信息拼进系统提示词。源码里这段逻辑往往不显眼,但它决定了模型「知道自己在哪、能做什么」。比如在 Git 仓库里执行命令和在任意目录里执行命令,权限策略都可能不一样。有一次我调试一个「模型为什么不用 git 命令」的问题,最后发现是环境探测把当前目录识别成了非 Git 工程,工具描述里 git 相关前置条件没满足,模型自然不选。这种问题不看源码根本定位不到。

3. 深入工具系统与 MCP:Agent 的「手」是怎么被装配起来的

工具系统是 Agent 架构里最值得抄作业的部分。上一章读完了主循环,这一章看主循环依赖的两个对象:工具注册表和 MCP 客户端,以及夹在中间的权限策略。理解了这三层,你就知道给 Agent 加一个「手」到底要动哪几个文件。

3.1 工具注册表:一个装饰器如何把 Python 函数变成模型能调用的工具

Agent 每次请求都会把工具定义发给模型,模型根据描述挑一个工具并返回参数,Agent 再用真实函数执行。这层「函数 → 工具」的适配,通常由一个全局注册表完成。常见做法是装饰器:

# tools/registry.py:工具注册表 _TOOL_REGISTRY = {} def tool(name: str, description: str, schema: dict): def decorator(func): _TOOL_REGISTRY[name] = ToolImpl(name, description, schema, func) return func return decorator @tool( name="bash", description="在沙箱中执行一条 shell 命令,返回 stdout 和 stderr", schema={ "type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"], }, ) def run_bash(command: str) -> str: result = subprocess.run( command, shell=True, capture_output=True, text=True, timeout=30 ) return f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"

逻辑说明:装饰器把run_bash包了一层,注册进全局_TOOL_REGISTRY。主循环调用self.tool_registry.schemas()时,注册表会把每个ToolImpl转成 API 需要的 JSON Schema 列表;模型返回tool_use块时,主循环根据block.name从注册表取出对应函数执行。新增一个工具只需要写一个装饰过的函数,不需要改主循环,这就是解耦的意义。工程里schemas()方法一般还会做缓存,因为工具定义通常不会在会话中变化,每次都重新生成是浪费。

参数说明:description千万不要写「执行命令」这种一句带过的话,模型靠它决定是否调用工具,描述越具体,被误调用的概率越低。schema是 JSON Schema,required决定模型必须提供哪些参数。这里有个常见坑:参数名写得太随意,比如x、y,模型根本猜不出含义;正确做法是command、file_path这种自解释命名。另外工具描述里写上失败时的表现,比如「命令不存在时返回非零退出码」,模型就能从返回结果里自行纠错。

3.2 MCP 客户端:外部工具服务器如何被拉进会话

如果你只看内置工具,理解的还是单体 Agent。MCP(Model Context Protocol)的意义在于把工具做成独立部署的服务:文件系统、数据库、浏览器各自跑在一个服务器进程里,Agent 通过协议调用。吃透这一层,你就能理解为什么有人把 Agent 工具架构称作「分布式工具架构」——不是把 Agent 拆成多个,而是把工具微服务化。

第二版资源里 MCP 客户端的加载逻辑,简化后大致是这样:

# mcp/manager.py:加载 MCP 服务器并合并工具 async def load_mcp_servers(cfg): for name, srv in cfg.mcp_servers.items(): client = await MCPClient.connect( transport=srv.get("transport", "stdio"), # stdio 或 sse command=srv.get("command"), args=srv.get("args", []), env=srv.get("env", {}), ) mcp_tools = await client.list_tools() # 握手后同步工具列表 for t in mcp_tools: tool_registry.register( name=f"{name}_{t.name}", description=t.description, schema=t.input_schema, func=partial(mcp_invoke, client=client, tool=t), )

逻辑说明:MCPClient.connect()负责和外部服务器握手,list_tools()拿到远端工具清单,每个远端工具被包装成本地ToolImpl注册进同一个注册表。这里有个容易忽略的细节:外部工具的工具名要加前缀(name_工具名),避免多个 MCP 服务器之间命名冲突。partial(mcp_invoke, client=client, tool=t)是把「调用哪个客户端、哪个远端工具」提前绑定,真正执行时只传input参数。

参数说明:transport决定通信方式,本地子进程一般用stdio,远程服务用sse或 HTTP。对应的 MCP 配置片段长这样:

{ "mcpServers": { "filesystem": { "transport": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "./data"], "env": { "ALLOW_DIRS": "./data" } } } }

这里的command、args、env会原样传给连接器。调试 MCP 工具不生效时,先手动在终端跑一遍command加args,看服务器能不能正常启动,这一步能过滤掉一半的配置问题。

3.3 权限与确认机制:哪些工具自动执行,哪些必须问用户

权限系统是生产级 Agent 和玩具 Demo 拉开差距的地方。默认情况下,Agent 不能想执行什么就执行什么,而是按「deny > allow > ask」的优先级决定动作。我在源码里看到的判断逻辑,简化后是下面这样:

# tools/permissions.py:权限判断 def check_permission(tool_name: str, rules: dict) -> str: # deny 最高优先级:命中直接拒绝 for rule in rules.get("deny", []): if match_rule(rule, tool_name): return "deny" # allow 次之:命中则自动放行 for rule in rules.get("allow", []): if match_rule(rule, tool_name): return "allow" # 默认:交给用户确认 return "ask"

逻辑说明:这个函数返回一个三态结果,主循环根据返回值决定直接执行、弹确认框、还是拒绝并告诉模型。把deny放最前面是安全设计:即使allow里写了Bash(*,只要deny里有Bash(rm -rf *),危险操作依然会被拦下。之前有人问「为什么我 allow 了 bash 还是弹确认框」,我没看他的规则前第一反应就是 deny 命中了,最后果真是这样。

参数说明:match_rule里的规则通常支持通配符或前缀匹配,但匹配范围是「工具名 + 参数模式」。比如Bash(git *)只匹配以git开头的命令,Bash匹配全部 bash 调用。源码里最容易被忽略的是「默认 ask」这个设定,很多人以为配置了allow就万事大吉,结果实际工具名和规则里的模式对不上,又落回 ask。排查时先确认工具的真实名字,再确认匹配模式,而不是上来就怀疑权限模块有 bug。

4. 上下文管理与 token 控制:长会话为什么不崩

会话一长,消息列表就会膨胀,不到 20 轮就可能撑爆上下文窗口。这一章看源码怎么解决这个问题。Claude Code 这类生产级 Agent 不会让上下文无限增长,它在每轮请求前都会做预算计算,超了就触发压缩。理解这套机制,你才能解释「为什么模型偶尔像失忆了」以及「为什么同样的对话换个模型表现不一样」。

4.1 历史消息压缩:滑动窗口加摘要的取舍

常用策略是「最近对话保真,早期对话压成摘要」,既保留关键脉络,又不会让 token 预算爆掉。第二版源码里压缩逻辑的骨架如下:

# context/trim.py:历史消息压缩 def trim_history(messages, system_prompt, tool_schemas, max_tokens): base = estimate_tokens(system_prompt) + estimate_tokens(tool_schemas) budget = max_tokens - base if estimate_tokens(messages) <= budget: return messages, None keep = messages[-8:] # 最近 8 轮完整保留 old = messages[:-8] # 更早的消息送去摘要 summary = summarize(old) # 调用模型生成一段压缩摘要 return [{"role": "system", "content": "历史摘要:" + summary}] + keep, summary

逻辑说明:estimate_tokens是估算函数,先算出系统提示词和工具定义占用的 token,剩下的预算留给对话。超出预算后,最近 8 轮完整保留,早期消息被模型总结成一段摘要,摘要在下一轮请求里以 system 消息的形式出现。这个设计最重要的代价是细节损失:摘要只保留事实,不保留工具调用中间的报错输出。所以长会话里模型偶尔忘记细节是正常现象,不是 bug。

参数说明:max_tokens这里是上下文总预算,不是单轮生成上限;keep条数(8 轮)是核心调参项,调大保真但费 token,调小省 token 但容易失忆。工程里还会在摘要提示词里加一句约束,比如「只删过程,不删结论;保留所有用户明确给过的事实」,能显著缓解关键信息丢失。如果你做二次开发,建议把这个摘要提示词单独抽出来维护,它直接影响压缩后的对话质量。

4.2 系统提示词注入:信息按什么顺序拼进请求

系统提示词的组装顺序,决定了模型对信息的「重视程度」。源码里常见的是按优先级从高到低排:最前面是身份与全局约束,然后是项目规范(比如 CLAUDE.md),然后是环境探测结果(当前目录、Git 分支、操作系统),最后是权限策略和工具可用性总结。越靠后模型越容易忽略,所以最重要的指令永远放最前面。

这里有个容易忽略的细节:CLAUDE.md 这类项目级提示词,会在每次请求时重新读取,而不是只在会话开始时读一次。如果你改了项目规范,下一个回合立刻生效。另外,压缩产生的历史摘要通常被插在系统提示词末尾、真实对话之前,这样模型会把摘要当作背景而不是指令,不容易被摘要里的措辞带偏。你在源码里看到一堆system消息时,别惊讶,它们在模型眼里是分层的,不是全部平等的。

4.3 调试与日志:源码里最值得读的观测点

读这套源码时,别急着看模型调用,先搜logger.debug这类日志点。成熟的工程会在一轮请求前后打日志:请求发出时的消息条数、token 估算值、stop_reason、工具执行耗时、压缩前后的 token 对比。这些日志就是你的观测点,照着日志去对应源码,比逐行读快很多。

我看第二版笔记时最受用的做法,是把「压缩前后 token 对比」打印出来对比。一旦发现压缩后 token 反而涨了,说明摘要或保留策略有 bug。这种问题在主循环和工具调用都正常时完全不会暴露,只能靠日志发现。

另一个观测点是 hooks。工程里常见的 hook 事件如下表,你在二次开发时可以在这些点插入自己的回调,比如审计工具调用、统计 token 消耗。

Hook 事件触发时机常见用途
PreToolUse工具执行前审计、拦截敏感命令
PostToolUse工具执行后记录结果、触发后续动作
UserPromptSubmit用户消息进入循环前改写用户输入、注入额外指令
Stop会话结束清理资源、生成会话报告

调试时如果发现工具行为异常,我一般先在 PreToolUse 里打日志看参数,确认进入执行层的数据是对的,再去查执行函数本身。这个顺序能帮你把问题快速定位到「调用前」还是「执行中」。

5. 源码阅读避坑指南:读这套 Agent 源码最容易翻车的五个位置

以下每一条都是实际踩过或看别人踩过的,按「现象 → 原因 → 解决」记录。第五部分单独成章,因为这些问题不看源码时完全不会暴露,看了源码也容易走偏。

5.1 现象:读了半天找不到 Agent 的「大脑」在哪里

很多人拿到源码后从main.py开始读,读完了config.py、cli.py,还是没找到所谓的「Agent 核心」。

原因:这套架构把主循环拆在多个文件里,agent.py里可能只有run()的调度逻辑,真正的模型调用在client.py,工具执行在tools/*.py,事件处理在events.py。你盯着一个文件当然找不到。

解决:先搜stop_reason这个字符串,它只在主循环里出现一次,顺着它就能找到循环边界;再搜tool_use找到工具分支,整条链路就串起来了。这是我每次读新版本源码的第一件事。拿到第二版源码后,我建议你先在 IDE 里全局搜这两个关键词,把主循环的代码块标记出来,再去看别的文件,顺序对了效率会高很多。

5.2 现象:把 SDK 客户端当成 Agent 本体

我见过有人读完源码后得出结论「Agent 不就是调了一下 messages API」,这是被client.py误导了。

原因:模型调用代码确实集中在一个类里,但 Agent 的「人格」和「能力」在系统提示词、工具注册表、权限策略、上下文压缩这些外围组件里,SDK 客户端只是最底层的收发通道。

解决:把 Agent 理解成组合体:主循环负责调度,客户端负责通信,工具负责行动。改任何行为时先判断该改哪一层,改错层的后果是行为完全不受控。比如想让模型少用 bash,应该改权限配置和工具描述,而不是改客户端。

5.3 现象:配置了 allow 但工具仍然不自动执行

配置里写了"allow": ["Bash"],但每次执行命令还是弹确认框。

原因:权限系统匹配的是「工具名 + 参数模式」,不是工具组;有的版本里Bash和Bash(git diff)是两种写法,通配符没对上就落到默认的ask。

解决:先用最小规则测试,比如"allow": ["Bash(git *)"]只放行 git 开头的命令,确认生效后逐步放宽。另外留意 deny 优先这个设定,如果 allow 和 deny 同时命中,deny 会赢,这不是 bug,是安全设计。

5.4 现象:拿第一版笔记对照第二版源码,符号全对不上

第二版改配置路径或重命名模块是常事,比如第一版里settings.json的字段,第二版可能挪进了不同作用域,直接照第一版抄就会翻车。

原因:版本迭代时作者会顺手重构命名,不是文档错了,是你没看升级说明。

解决:拿到第二版先看工程里有没有UPGRADE.md或CHANGELOG.md;没有的话用git log --oneline -20看提交记录,重点关注 rename、move、breaking 这几个关键词。我一般还会在 IDE 里全局搜一次旧符号,看它当前映射到了哪里,宁可多花五分钟也不要带着旧版记忆读新版代码。

5.5 现象:上下文压缩后,模型突然「失忆」

会话超过 20 轮后,模型开始忘记前面给过的事实,甚至重复调用已经执行过的工具。

原因:压缩策略把早期工具调用结果压成了摘要,而摘要里没保留关键输出细节;或者保留条数设得太小,模型参考的「最近」窗口里根本没有那条事实。这不是模型坏了,是上下文管理暴露了问题。

解决:把保留轮数调大,比如从 8 调到 12;或者把关键事实通过系统提示词「钉」在最前面,让压缩逻辑不动它。工程里还有一种做法是给摘要单独建一段 prompt,要求「只删过程,不删结论」,能明显缓解失忆。调试时我会在压缩函数入口打印摘要文本,直接用肉眼判断关键信息有没有被丢掉。

6. 把架构变成自己的原型:30 行最小 Agent 与三条验证方法

读源码的终点不是「看懂了」,而是能动手复刻一个极简原型。下面这个版本把主循环、工具注册和工具执行压到 30 行左右,足够验证你对整套架构的理解。

# mini_agent.py:最小可运行的 Agent 原型 import asyncio, subprocess from anthropic import AsyncAnthropic TOOLS = {} def register(name, desc, schema): def deco(f): TOOLS[name] = (desc, schema, f); return f return deco @register("bash", "执行 shell 命令", {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}) def bash(command): return subprocess.run( command, shell=True, capture_output=True, text=True).stdout async def main(prompt, max_turns=8): client = AsyncAnthropic() messages = [{"role": "user", "content": prompt}] for _ in range(max_turns): resp = await client.messages.create( model="claude-3-5-sonnet-latest", max_tokens=1024, messages=messages, tools=[{"name": n, "description": d, "input_schema": s} for n, (d, s, _) in TOOLS.items()]) messages.append({"role": "assistant", "content": resp.content}) if resp.stop_reason != "tool_use": break for b in resp.content: if b.type == "tool_use": result = TOOLS[b.name][2](**b.input) # 注册表找出实现并执行 messages.append({"role": "user", "content": [ {"type": "tool_result", "tool_use_id": b.id, "content": str(result)}]}) return messages[-1].content if __name__ == "__main__": import sys prompt = sys.argv[1] if len(sys.argv) > 1 else "列出当前目录" print(asyncio.run(main(prompt)))

这个原型帮你验证三件事,我现在每次拿到新版本源码都会强制走一遍。第一,主循环能否正确收敛:观察stop_reason是否出现tool_use以外的值,如果一直是tool_use,说明工具结果没有正确回写。第二,工具执行结果是否真的影响下一轮:故意让 bash 返回一个错误,看模型能否在下一轮做出不同反应。第三,新增一个工具是否只改注册表不动主循环:给TOOLS加一个list_files函数,如果主循环没改一行就能用,说明你确实读懂了注册表的作用。

之前我读第一遍源码时,自以为懂了,结果在权限匹配上翻车——allow规则写对了,却因为deny优先级更高,工具半天不自动执行。从那以后,不管读哪套 Agent 源码,我都强制自己先跑一遍最小复刻,再改工具,最后调权限,出了问题先看日志里的stop_reason,通常一眼就能定位到是主循环、工具还是权限的问题。这套方法也希望对你有用,结合这份第二版源码工程和配套笔记,建议按「先跑最小复刻 → 再读主循环 → 再读工具与权限」的顺序下载后配合源码一起过。希望帮到你。

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

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

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

立即咨询