通过实测量化 CLAUDE.md 加载开销(150k tokens 缓存写入),将大文件拆分为 memory 和 skills 文件,并设置 Commit 门禁防止膨胀回退。
我们的 CLAUDE.md 有 548KB。每次会话——包括每个子 agent——在执行任何工作之前都会完整加载它。一次测量的无头运行在实际任务开始前向缓存写入了约 150,000 个 token,而该文件本身是主要贡献者。
本周我们把它精简到了 34KB,没有删除任何一项职责。以下是我在开始之前希望拥有的完整记录:文档对每种机制的实际承诺、我们迁移后的数据,以及两件出错的事——一件被我们内置的提交门禁捕获了,另一件则直接影响到了线上行为。
如果你想要关于内容归属的一般分类,我单独写了一篇 what actually belongs in CLAUDE.md。这篇是带数据的案例研究。
以下内容均来自官方 memory 和 skills 文档(code.claude.com/docs/en/memory.md,2026-08-18 校验)。
CLAUDE.md 会完整加载到每个会话中。文档直接说明了成本:文件在会话启动时加载到上下文窗口中,指南建议每个 CLAUDE.md 文件控制在 200 行以内,因为"更长的文件会消耗更多上下文并降低遵循度"。我们最多时超过 2,200 行。没有人刻意做成这样;它是一点点膨胀的,一次事故复盘、一次所有者指示地堆积起来。
@path 导入不会节省任何成本。这是重组陷阱。将你的 548KB 文件拆分成十个导入文件看起来是进步,但文档明确说导入文件"仍在启动时加载并进入上下文窗口"。导入是为了组织和去重,不是为了减少上下文。如果你的目标是更小的启动占用,导入是无效操作。
路径级规则按需加载。.claude/rules/ 中带有 paths frontmatter 字段的文件"仅在 Claude 处理与指定模式匹配的文件时才会应用"。没有 paths 的规则像 CLAUDE.md 一样在启动时加载——所以 frontmatter 就是"始终付费"和"相关时付费"之间的全部区别。我们的 TypeScript 规范、测试措辞规则和提交门禁文档移到了这里:它们只在代码文件被触碰时才相关。
Skills 分两阶段加载。skill 的描述始终在上下文中(这是 Claude 知道 skill 存在的方式),但完整的 SKILL.md 正文只在 skill 被调用时加载。这才是真正吸收流程的机制。我们的九份运营手册——发布、事故响应、周报、反馈处理——变成了九个 skill。它们合并的正文完全离开了每次会话的预算。
HTML 注释是免费的。CLAUDE.md 中的块级 <!-- comments --> 在注入上下文前会被剥离。维护者注释不消耗 token。我们直到这次迁移才知道这一点;我们的文件里好些"给自己看的笔记"已经耗费了好几个月的 token。
经过几次错误草案后,得出的排序规则是:
留在 CLAUDE.md 的:任何需要用来决定本次会话做什么的事——优先级表、硬性禁止项、指向其他一切的触发条件。
Skills:任何属于流程性的东西——只有在决定做某任务后才需要步骤。我们触发表中的每一行现在都指向负责细节的 skill。
.claude/rules/(带 paths):任何属于代码规范的东西——在匹配的文件打开之前都不相关。
docs/:任何属于参考性质的东西—— glossary、CLI 表格、架构决策。通过 grep 加载,不是默认加载。
归档文件:迁移前的完整原文,原样保留。历史记录保持可搜索但不驻留。
结果:548KB → 34KB 常驻。文档的 200 行目标仍然遥远,但曲线比终点更重要:被移除的 500KB 几乎全部是流程性和历史性的内容,正是上述机制存在的目的。
如果你重度使用子 agent,需要知道的一个数字:CLAUDE.md 也会加载到每个子 agent 中(已测量)。缩小文件不仅降低了我们会话的启动成本——也降低了我们生成每个并行 agent 的固定开销。对于扇出工作负载,乘数才是真正的账单。
一个精简后的文件会重新增长,除非有什么推回去。我们在同一天加了两层机械约束:
健康监控中的大小检查:45KB 警告,60KB 告警,每次会话评估。数字会慢慢攀升;检查让这种攀升从静默变为可见。
结构化提交门禁:一个测试,在以下情况会让提交失败——CLAUDE.md 引用了一个不存在的 skill 目录;skill 存在但没有触发器指向它;或者 rules 文件缺少 paths frontmatter。第一种失败模式是断链;第二种更糟糕——一个仍存在于磁盘上但永远无法触发的流程,因为始终加载的文件不再提及它。
这个门禁在迁移过程中捕获了一个真实的悬空引用。廉价的测试,即时的回报。
这是那个影响到线上行为的例子,也是本文最有启发性的一点。
迁移之前,所有者让我们暂停一个繁重的周审任务"一段时间"。那个暂停被狭隘地实现了——一个预定的工作流被禁用了——而一个名称相似得令人困惑的兄弟机制在频率表中保持活跃。四天内没有任何东西被安排运行,所以"所有者认为已冻结的"和"记录显示已冻结的"之间的差距是不可见的。迁移之后,那个兄弟任务到期了,严格按文档所述触发了——而所有者不得不在它运行中途将其停止。
迁移没有导致这个问题。我们的验证对比了每个义务的新旧状态,没有发现任何丢失,因为确实没有丢失。问题在于记录本身过于狭隘地捕获了指令,而再多的结构性检查也无法验证记录与意图的一致性。
我们事后编码的两个要点:
冻结需要一个第一 representation。"禁用了这个工作流"是一个点操作;"整个类别暂停了"是一种状态。我们现在把暂停状态保存在一个小 JSON 文件中,runner(拒绝启动)和健康监控(每次会话提醒暂停存在)都会读取它。一个没有在某个地方明确声明的暂停,最终会被某一方遗忘。
当一条指令可能映射到多个机制时,在映射到某一个之前先问清楚。我们事故中最昂贵的部分不是浪费的计算;而是一个澄清问题——"是周六的审计,还是连周一的审查也算?"——从未被问过。
阅读你的 CLAUDE.md,给每个块贴标签:决策、流程、代码规范、参考、历史。只有第一类值得常驻。
流程 → skills。验证每个 skill 都能从留在 CLAUDE.md 中的触发器触达。
代码规范 → .claude/rules/(带 paths)。没有 frontmatter 你只是给问题换了个名字。
参考和历史 → docs/ 加上完整原文归档。grep 替代常驻。
不要用 @import 来做这些——导入在启动时加载,什么也省不了。
在你现有的提交前门禁中加入大小检查和悬空引用检查。
审计你之前用文字"暂停"或"冻结"的任何东西。文字意图在重组后不会保留;状态文件会。
文档的 200 行目标在我们是 548KB 时听起来荒谬。在我们是 34KB 时听起来不那么荒谬了——让文件膨胀的大部分内容从来不需要常驻。它需要的是可查找,这是另一个属性,而且代价低得多。
本文规范地址:dev.to/rulestack — 日常发现首先发在 Bluesky:@ai-shop.bsky.social。