我读了Claude Code的51万行源码(下):那些让我皱眉头的工程债

上篇我们聊了Anthropic藏在代码里的那些精妙设计。这篇,我们聊点不那么好看的。

上一篇文章发出去之后,有不少朋友来问:这代码真的有那么厉害吗?Anthropic不是有100多亿美元的融资吗?代码质量应该很高吧?

我只能说------这个问题本身就暴露了一个常见误解:融资多不等于代码写得好

事实上,当 Chaofan Shou 把 Claude Code 的源码扒出来之后,另一位开发者 Rohan 也仔细翻了一遍。他发现的东西,和我的视角完全不同------不是精妙的架构设计,而是一堆让人皱眉头的工程债。

我把他的发现整理了一下,加上自己的理解,跟大家聊聊:一个顶级AI产品的代码里,藏着什么"正常"的混乱

提前说清楚:这不是黑Anthropic。任何一个快速迭代的产品,都会积累这些东西。Claude Code能做到今天这个程度,工程团队已经相当厉害了。但正因为它是"世界上最重要的AI开发工具之一",这些问题才更值得拿出来谈。

第一件事:一个React组件,5005行

打开 screens/REPL.tsx,你看到的是一个5005行的文件。

这是你每天和Claude Code交互的主界面。整个界面,就是一个组件。

光是这一个文件里的React Hook调用数量:

  • useState:68个
  • useEffect:43个
  • useRef:54个
  • useCallback:44个
  • useMemo:18个

合计:227个Hook调用,大部分在同一个组件里。

JSX嵌套最深的地方在第4604行,缩进了22层。整个文件有超过300个条件分支。仅import部分就有244行,引用了235个不同的模块。

我知道你想说什么------"大文件怎么了,能跑就行"。

问题不在于"大",问题在于这个东西已经不可测试了

想象一下:43个useEffect,每一个都可能依赖前面68个useState里的某几个。你要给这个组件写单测,依赖链追到最后,你会发现几乎无从下手。代码里也有这么一行承认现实的注释,在第4114行:

arduino 复制代码
// TODO: fix this
// eslint-disable-next-line react-hooks/exhaustive-deps

团队自己知道这里出了问题。但没修。

这种"神组件"是怎么来的?

没有人一开始就打算写5000行。它是这样长大的:最开始是个简单的终端输入框,然后加了流式输出,然后加了工具执行,然后加了权限弹窗,然后加了context压缩提示,然后加了语音模式,然后加了远程会话,然后......

每次新功能塞进来,加几十行,看起来还好。等你回过神来,已经5005行了。

正确的做法是什么?用状态机(比如XState,或者简单的reducer)来驱动15-20个职责单一的子组件。REPL其实有非常清晰的状态边界:初始化中、等待输入、流式输出、执行工具、等待权限、压缩上下文、展示结果。每个状态对应一个组件,68个useState变成一个带类型的状态对象。这是React处理复杂UI的标准做法,我不明白为什么没这么做。

可能的原因:这个产品迭代太快了,没时间重构。

第二件事:89个Feature Flag,960次引用

Feature flag是做产品的常规操作------你想灰度一个新功能,开个开关,先给10%的用户测试,没问题再全量。

但Claude Code里有89个 Feature Flag,在代码里被引用了960次

我把完整列表搬过来,你感受一下:

objectivec 复制代码
ABLATION_BASELINE, AGENT_MEMORY_SNAPSHOT, AGENT_TRIGGERS, 
AGENT_TRIGGERS_REMOTE, ALLOW_TEST_VERSIONS, ANTI_DISTILLATION_CC,
AUTO_THEME, AWAY_SUMMARY, BASH_CLASSIFIER, BG_SESSIONS,
BREAK_CACHE_COMMAND, BRIDGE_MODE, BUDDY, BUILDING_CLAUDE_APPS,
BUILTIN_EXPLORE_PLAN_AGENTS, BYOC_ENVIRONMENT_RUNNER,
CACHED_MICROCOMPACT, CCR_AUTO_CONNECT, CCR_MIRROR, CCR_REMOTE_SETUP,
CHICAGO_MCP, COMMIT_ATTRIBUTION, COMPACTION_REMINDERS,
...(还有60多个)
KAIROS, KAIROS_BRIEF, KAIROS_CHANNELS, KAIROS_DREAM, 
KAIROS_GITHUB_WEBHOOKS, KAIROS_PUSH_NOTIFICATION,
ULTRAPLAN, ULTRATHINK, VERIFICATION_AGENT, VOICE_MODE,
WEB_BROWSER_TOOL, WORKFLOW_SCRIPTS

单单KAIROS这一个"功能"就有6个独立的Flag:KAIROSKAIROS_BRIEFKAIROS_CHANNELSKAIROS_DREAMKAIROS_GITHUB_WEBHOOKSKAIROS_PUSH_NOTIFICATION

这已经不是Feature Flag了,这是在一个代码仓库里藏了一个平行产品

还有一些Flag,名字本身就暗示着身份尴尬:

  • EXPERIMENTAL_SKILL_SEARCH:还在实验,但实验了多久?
  • NEW_INIT:有新的初始化逻辑,那旧的呢?还在吗?
  • OVERFLOW_TEST_TOOL:这是测试用的工具,为什么在生产代码里?
  • ABLATION_BASELINE:消融测试基线?这是研究代码混进来了?

除了Feature Flag,还有472个环境变量,分散在1425个调用点:

objectivec 复制代码
ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL,
ANTHROPIC_BEDROCK_BASE_URL, ANTHROPIC_BETAS, 
ANTHROPIC_CUSTOM_HEADERS, ANTHROPIC_CUSTOM_MODEL_OPTION,
ANTHROPIC_DEFAULT_HAIKU_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL,
ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_FOUNDRY_API_KEY,
ANTHROPIC_FOUNDRY_BASE_URL, ANTHROPIC_MODEL,
CLAUDE_CODE_COORDINATOR_MODE, ...
// 还有458个

为什么这很重要?

89个Flag说明一件事:这个团队不确定这个产品最终长什么样。Feature Flag是用来做渐进式发布的,不是用来替代产品决策的。当你有89个Flag的时候,你其实是在用代码推迟一个艰难的决定:到底要做哪个功能,不做哪个功能

值得一提的是,这里用的是Bun的编译期feature()函数,所以禁用的Flag对应的代码会在构建时被完全删除,运行时不会有性能损耗。代价是纯粹在开发体验层面的:当960个feature check散落在代码库各处,没有人知道哪些还活着、哪些可以删掉了。

第三件事:61个文件在处理循环依赖

在代码里搜索"break import cycle"、"avoid circular dependency"、"circular dependency",你会在61个不同的文件里找到结果。

而且团队没有隐瞒这件事,注释写得相当坦诚:

arduino 复制代码
// types/permissions.ts
// Pure permission type definitions extracted to break import cycles.
// to avoid circular dependencies.

// schemas/hooks.ts  
// Hook Zod schemas extracted to break import cycles.
// circular dependency between settings/types.ts and plugins/schemas.ts.

// tasks.ts
// Note: Returns array inline to avoid circular dependency issues 
// with top-level const

// utils/bash/ast.ts (line 2218)
// circular import with bashPermissions.ts.

处理循环依赖的方式无非几种:

  1. 把类型定义单独提到一个文件(types/permissions.ts 就是这么来的)
  2. 用懒加载require()代替import
  3. 把本来该import的东西内联进去

这些都是补丁,不是解决方案。

types/permissions.ts这个文件存在的唯一理由,就是打破循环依赖。schemas/hooks.ts同理。几个文件的存在价值不是承载业务逻辑,而是作为架构债务的创可贴。

根本原因在哪?

追踪下来,问题的核心是Tool.ts------一个792行的类型定义文件,它同时引用了权限类型、消息类型、分析模块、MCP类型、Agent类型、进度类型、Hook......当你的核心类型文件引用了一切,那么一切也会反过来引用它,循环依赖就这么产生了。

61个文件,说明模块边界从来没有被设计过,是随着功能增长自然长出来的。每一个懒加载require()都是TypeScript无法在编译期帮你检查的一个漏洞。

第四件事:一个出现1193次的类型名

php 复制代码
logEvent('tengu_startup_telemetry', {
  entrypoint: entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  action: 'hint_converted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  variant: idleHintShownRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})

AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS

53个字符。在整个代码库里出现了1193次 ,其中超过1000次是显式的as类型断言。

这个设计的出发点是好的------Claude Code跑在用户的真实代码库上,你绝对不想把文件路径、源代码内容、或者密钥意外发到你的数据分析管道里去。所以他们搞了这个类型,强制开发者在每次记录事件时手动确认:"我验证了这个字段不是代码也不是文件路径"。

问题是:当你要写这个1193次的时候,它就不再是一个安全检查了。它变成了一个仪式。

第一周你还会认真读它,想一想。第三周你已经是肌肉记忆,手速打完根本没进脑子。

更关键的是:这个类型断言什么都没防住as是TypeScript在说"相信我",不是在做任何运行时验证。你完全可以把一个文件路径as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,编译器不会报任何错误。

正确的做法应该是:

php 复制代码
// 不是这样(现在的做法):
logEvent('name', {
  key: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})

// 应该是这样:
logSafeEvent('name', {
  key: SafeMetadata.from(value)  // 运行时检查:如果value看起来像路径就抛错
})

运行时验证才能真正拦住问题。一个53字符的类型名只是在"礼貌地请求"开发者注意,这请求在1000多次重复之后早就被忽略了。

第五件事:用十六进制编码来拼写"鸭子"

这是整个源码里最让我乐的一段:

javascript 复制代码
// buddy/types.ts
// One species name collides with a model-codename canary in 
// excluded-strings.txt. The check greps build output (not source), 
// so runtime-constructing the value keeps the literal out of the 
// bundle while the check stays armed for the actual codename.
// All species encoded uniformly.

const c = String.fromCharCode

export const duck    = c(0x64,0x75,0x63,0x6b) as 'duck'
export const goose   = c(0x67,0x6f,0x6f,0x73,0x65) as 'goose'
export const blob    = c(0x62,0x6c,0x6f,0x62) as 'blob'
export const cat     = c(0x63,0x61,0x74) as 'cat'
export const dragon  = c(0x64,0x72,0x61,0x67,0x6f,0x6e) as 'dragon'
export const octopus = c(0x6f,0x63,0x74,0x6f,0x70,0x75,0x73) as 'octopus'
// ...还有10多种动物,全部十六进制

没错,Claude Code里藏了一个宠物系统BUDDY Feature Flag,我们上面提过)。有稀有度等级(从common到legendary),有不同的物种,有帽子、眼睛样式、属性分布......这是一个藏在终端编程工具里的电子宠物。

但这段代码想说的不是宠物系统(这个留到下篇讲),而是为什么duck要写成c(0x64,0x75,0x63,0x6b)

原因在注释里:某个物种的名字(大概是axolotl或者capybara这类奇异物种,我猜),和Anthropic内部某个模型的代号撞了。Anthropic的CI流水线会grep构建产物,检查有没有泄露内部模型代号------这是个安全金丝雀机制,很合理。

问题是,撞了名字之后,正确的修法是给CI脚本加一条排除规则,专门忽略buddy模块。但实际的修法是:把所有18个物种的名字全部用十六进制编码,一个不剩。

现在任何一个新来的工程师打开这个文件,看到满屏十六进制,内心独白大概是:???

第六件事:4683行的入口文件

main.tsx是CLI的入口文件,它有4683行,塞进去了:

  • 所有CLI命令定义(claudeinitconfigmcpdoctor等)
  • 全部参数和Flag解析(通过Commander.js)
  • 完整的OAuth登录流程
  • 会话恢复逻辑
  • 远程会话管理
  • 性能基准采样
  • 插件加载
  • MDM(移动设备管理)配置

为什么全塞在一个文件里?注释给出了答案:

less 复制代码
// main.tsx --- lines 1-8
// These side-effects must run before all other imports:
// 1. profileCheckpoint marks entry before heavy module evaluation begins
// 2. startMdmRawRead fires MDM subprocesses in parallel with the 
//    remaining ~135ms of imports below
// 3. startKeychainPrefetch fires both macOS keychain reads in parallel
//    (~65ms on every macOS startup)

翻译:Bun对import是饥渴式求值的,导入越深,启动越慢。把所有东西塞进一个文件,减少import层级,能在启动时省下约135ms。

这不是意外,是有意为之的架构决策:用代码可读性换启动速度。

这个取舍是否合理?看你站哪个角度:

  • 站在用户角度:Claude Code是一个你每天要调用几十上百次的工具。少135ms,乘以100次,每天就是13.5秒。积累下来确实有感知。
  • 站在工程师角度:一个4683行的入口文件,意味着"加一个新的CLI子命令"这件事会在一个本来就很拥挤的地方更拥挤。任何修改都可能引发意外的副作用。

其实有折中方案:懒加载命令模块。有人运行claude init的时候再加载init模块,有人需要OAuth的时候再加载认证模块。这是几乎所有大型CLI工具(oclif、yargs等)的标准做法。Bun支持动态import(),理论上可以实现。

但可能Bun的模块加载有特殊性,这条路在他们的技术栈里走不通。这个我没有足够把握,留个问号。

第七件事:require()混进了TypeScript里

这是上面几个问题叠加之后的连锁反应。

query.tsREPL.tsx里,你会看到这种写法:

javascript 复制代码
// query.ts --- lines 15-22
const reactiveCompact = feature('REACTIVE_COMPACT')
  ? (require('./services/compact/reactiveCompact.js') 
     as typeof import('./services/compact/reactiveCompact.js'))
  : null

const contextCollapse = feature('CONTEXT_COLLAPSE')
  ? (require('./services/contextCollapse/index.js') 
     as typeof import('./services/contextCollapse/index.js'))
  : null

这是TypeScript代码在ES模块里用require(),外面包着编译期Feature Flag检查,然后再用as typeof import(...)把类型找回来。

REPL.tsx里有17处这种写法,query.ts里有6处。

为什么要这么写?因为:

  1. import是声明式的,模块加载时就会被执行,没法条件化
  2. feature()检查需要在编译时阻止整个模块被打包进来
  3. 只有require()能在函数体内条件性地加载模块
  4. require()会让TypeScript丢失类型信息
  5. 所以要用as typeof import(...)把类型"找回来"

这是一种把四个不同层面(编译期、运行时、模块系统、类型系统)的工具硬拼在一起的写法,每一步都是对下一步问题的补救。

最大的风险在哪?

as typeof import(...)是一个类型断言,不是类型验证。如果有人修改了reactiveCompact.js的导出结构,这里的类型会静静地撒谎,TypeScript编译器不会报错。你只会在运行时发现问题。

现代JS有dynamic import()可以做条件性模块加载,而且完全保留类型信息:

javascript 复制代码
const module = await import('./services/compact/reactiveCompact.js')

Bun支持这个语法。但因为import()是异步的,改造需要让调用链变成async,这是一个波及范围比较广的重构。所以他们选择了require()这条"更简单但更危险"的路。

把这些放在一起看

读完这七个问题,你可能会问:这代码到底好不好?

我的回答是:这很正常,也是真实的代价。

这些问题不是Anthropic工程团队水平差。恰恰相反,里面很多决策(比如入口文件的启动速度优化)都是有意识的取舍,是真正做过生产系统的人才会做的权衡。

但这些问题也揭示了一件事:Claude Code在过去一两年里,增长速度比它的架构能承受的速度快

这很常见。几乎所有快速成长的产品都会经历这个阶段:功能塞得比重构快,Flag加得比清理快,依赖加得比梳理快。最后你得到的就是5005行的神组件,89个Feature Flag,61处循环依赖。

问题不在于"这些存在",问题在于:当你的产品是一个直接在用户机器上跑命令的AI工具,这些技术债的风险溢价就比普通的Web应用高得多。

一个循环依赖,在普通的Web应用里可能只是代码丑;在一个有着9层安全审查的工具里,如果恰好影响了权限判断的逻辑,后果就完全不同了。

这是值得认真对待的区别。

你能从这里学到什么

如果你在做AI Agent产品:

Claude Code的这些问题,本质上是"Product Market Fit之后、工程化之前"的典型症状。找到了用户价值,但还没来得及用工程的方式把它固化下来。

这个阶段有一条很难的路要走:在不停止迭代的前提下,逐步偿还技术债。没有捷径,只有优先级选择。

如果你在写任何需要长期维护的代码:

5005行的React组件不是一天长成的。每一次"先这样,以后再说"都在往里加砖。

"以后再说"的问题不是它不对,而是"以后"经常不会来。

定期的重构不是奢侈品,是工程可持续性的最低保障。

如果你觉得Anthropic的代码应该完美无缺:

这篇文章是一个很好的提醒:不存在完美的代码库,只有不同的取舍。真正的工程水平,不是写出没有问题的代码,而是清楚地知道你做了哪些取舍,为什么做,代价是什么

从这个角度看,Claude Code的注释文化其实相当好------61处循环依赖都有注释说明,main.tsx的架构决策都有注释解释,问题被承认,原因被记录。这是一个团队对自己技术债保持清醒认知的表现。

知道自己欠了多少债,不代表没有债。但总比欠了不知道要好得多。

如果你对这份源码有自己的发现,欢迎评论区交流。我只读了其中一部分,这个代码库还有很多我没看到的角落。

相关推荐
攀登的牵牛花3 小时前
Claude Code 泄露事件复盘:前端发布流程哪里最容易翻车
前端·github·claude
小凡同志4 小时前
Claude Code Plugin 到底是什么?别再和 MCP、Hook、Subagent、Skill 混着用了
人工智能·ai编程·claude
诸神缄默不语4 小时前
论文阅读笔记:Claude如何思考
论文阅读·笔记·大模型·llm·大语言模型·claude·大规模预训练语言模型
147API5 小时前
Claude Code 新增「计算机使用」能力:架构解析、自动化场景与安全风险避坑
运维·安全·自动化·claude
IvanCodes5 小时前
ClaudeCode 源码泄露,事情没那么简单
人工智能·ai编程·claude
小和尚同志16 小时前
A社 npm 包事故导致 Claude Code 源码泄漏?
人工智能·aigc·claude
天蓝色的鱼鱼17 小时前
别再只会写 Prompt 了!Claude Code Skills 才是 AI 编程的正确打开方式
ai编程·claude