flowchart TB
subgraph R["Riff:两个平台都有"]
R0["Modules/Riff/"] --> RA["Android/"]
R0 --> RI["iOS/"]
RI --> RB["角色分桶"]
end
subgraph Q["QuipKey:一个平台,两个进程"]
Q0["Modules/QuipKey/"] --> QA["App/"]
Q0 --> QK["Keyboard/"]
Q0 --> QS["Shared/"]
QA --> QB["角色分桶"]
end
subgraph I["InkBoard:单平台单进程"]
I0["Modules/InkBoard/"] --> IB["Launch / Libraries /<br/>Services / UI / Localization"]
end
style RA fill:#e8f4e8
style QK fill:#e8f4e8
style IB fill:#fdf3e3
6 Codebase:把边界写进目录
Agent 动手之前要回答两个问题:这段新代码该放在哪里,它能依赖谁。
这两个问题答不上来的时候,再多的检查也只是在反复报错 —— 检查会告诉它 “放错了”,却不会告诉它该放哪。于是它换一个位置再试一次, 而你付的是一整个回路的延迟。
6.1 先说清楚一个区别
大部分团队处理这件事的方式是写一份《代码组织规范》。
这个做法的问题不在于文档写得好不好,在于生效时刻。一条写在文档里的规则, 要等人(或 Agent)想起来去查才生效 —— 而”想起来”是一个概率事件, 这个概率随文档变长而下降,随任务紧急程度而下降,随写代码的人是不是新来的 而下降。
一条写进目录结构的规则,在 Agent 落笔的那一刻就已经在场了。它不需要被 记住 —— Agent 打开这个目录,看到里面有什么、没有什么,就已经接收到了 这条规则。
这一章后面的每一条,都可以用这个标准去检验:它是一条需要被记住的条文, 还是一个一读代码就接收到的事实?
6.2 路径模板与「被挣得」原则
客户端产品的路径模板长这样:
Modules/{Product}/[{Platform}/][{Deployable}/]<角色分桶>
↑ 可选 ↑ 可选
关键在中间那两个可选层:它们必须被一个真实存在的第二个实例「挣得」, 既不为了对称而加,也不为了预期中的将来而加。 这条规则听起来很小,但它是这个仓库两年没长歪的主要原因。
6.2.1 三个真实判例
规则的价值不在措辞,在它能不能被机械地应用。看仓库里的三个实际形态:
Riff 有平台层,因为第二个平台真的存在。QuipKey 有部署件层,因为它真的有 两个独立进程 —— 一个主 App,一个输入法扩展,而 Shared/ 里只放 被两个兄弟部署件共同消费的代码,它自己不能有组合根。InkBoard 两层都没有, 角色分桶直接展开在产品目录下。
这三个例子放在一起,规则就不再是一句话,而是一个可以对照的模式。一个 Agent (或一个新来的人)看到 Modules/InkBoard/ 下面直接是角色分桶,就知道 这个产品目前只有一个平台一个进程 —— 这个信息不需要任何人告诉它。
6.2.2 为什么”预期中的将来”是禁止的
最容易被违反的是后半句:不为了预期中的将来而加。理由不是”你可能猜错”—— 就算你猜对了,提前加也是错的。
第一个原因是目录层级一旦加上,就再也没人敢删了。删一个空目录看起来 是零风险的操作,但做这个操作的人需要确认”真的没有人计划往里放东西”, 而这个确认成本比留着它高。于是它会一直在那儿。
第二个原因更要紧:每一层空目录都在稀释结构本身的信息量。 上面判例三之所以能传递信息,恰恰是因为那两层不在。如果每个产品都有 iOS/ 层(不管它有没有第二个平台),那么这一层就不再携带任何信息 —— 它从”这个产品有多个平台”的证据,退化成了纯粹的仪式。
结构能传递信息,靠的是它在不需要的时候不出现。这条原则后面 还会以不同形态再出现三次:讲规则时(章节 15)、 讲载体时(章节 8)、讲风险等级时(小节 14.10)—— 任何为了”以后可能用到”而提前建立的东西,都在削弱现有东西的信息量。
6.3 角色分桶:按怎么获取,不按关于什么
角色分桶是固定的五个:Launch 是组合根,唯一知道自己 owner 全部服务与 界面的地方;Libraries 是直接 import、生命周期归调用方的包;Services 是 经微内核注册、通过协议解析的包;UI 分成组件和页面;Localization 放文案。
这里有一条容易被误解的判据:角色由”怎么获取”决定,不由”关于什么”决定。 也就是说,判断一个包该放 Libraries 还是 Services,问的不是”它是干什么的”, 而是”使用它的人怎么拿到它” —— 通过微内核注册取得就是 Services, 直接 import 且生命周期归调用方就是 Libraries。
这条区分能消掉大量”这个该放哪”的争论,因为它把一个品味问题变成了 一个事实问题,而事实问题有唯一答案。判断一条分类判据好不好, 就看它能不能做到这一点。
配套的还有一条命名约束:共享层里 Services/ 下的每个包都必须以 Service 结尾,而一个不能诚实地取这个后缀的包,说明它属于 Libraries/ 或 Pages/, 或者必须按生命周期边界拆开。这条约束的巧妙之处在于它把判定交给了 命名的直觉 —— 你在给一个包起名的时候,如果 XxxService 这个名字念着别扭, 那通常不是名字的问题,是位置的问题。
6.4 共享层:按能力组织,不按角色
共享层的顶层结构和产品层不一样,值得对比。产品层的顶层是产品, 第二层才是角色;而共享层的顶层是能力(支付、身份、媒体、导航、 网络、通知……),角色在第二层。
flowchart LR
subgraph BAD["如果反过来:角色在外"]
S1["Services/"] --> X1["…几百个服务平铺…"]
X1 -.->|"「跟支付相关的有哪些」<br/>只能靠搜索或记性"| Q1(("?"))
end
subgraph GOOD["实际:能力在外"]
C1["Commerce/"] --> L1["Libraries/"]
C1 --> V1["Services/"]
V1 --> N1["StoreService<br/>PaywallOperationService<br/>CommerceOperationService"]
end
style Q1 fill:#f8d7da
style N1 fill:#e8f4e8
这个顺序是刻意的。如果反过来 —— 共享层下面平铺几百个服务 —— 那么 “跟支付相关的东西有哪些”这个问题就没法通过看目录回答,只能靠搜索或者靠记性。
Agent 最需要的恰恰是”跟这件事相关的东西都在哪”。 章节 8 会讲到, Agent 的第一个动作应该是”先看看共享层里有没有现成的”, 而这个动作能不能便宜地完成,完全取决于共享层是不是按能力组织的。
6.4.1 把命名变成一次类型检查
共享层的角色分桶上还挂着一条规则,它的形态值得单独学: 共享 Services/ 下的每一个叶子包,名字都必须以 Service 结尾; 一个不能诚实地接受这个后缀的包,说明它属于 Libraries/ 或 Pages/, 或者它需要沿生命周期边界被拆开。
这条规则表面上是命名约定,实际上它是一次归类的自检。 “这个包能不能诚实地叫 XxxService”这个问题, 和”它的生命周期归不归微内核管”是同一个问题的两种问法 —— 而前者任何人都能当场回答,后者需要读代码。
它的机制值得说清楚。目录位置是一个人放进去的, 而名字是写这个包的人起的,两者由不同的时刻、 不同的判断产生—— 当它们对不上时, 说明这两次判断里至少有一次错了。这是一次极其廉价的解析冗余 (小节 18.2.3):不需要第二套检查, 只需要让同一个事实在两个地方各表达一次,然后比对。
最后半句”或者它需要沿生命周期边界被拆开”是这条规则最有价值的部分。 它承认了第三种可能:不是放错了,是这个包本身混了两件事。 一条只给”改名”和”挪位置”两个出口的规则,会把这类包硬塞进某一边; 而给出第三个出口,它才可能真正被修好。
6.5 四件套的第四格
一个服务的内部结构是固定的四格:Protocol/、Service/、Tests/、Testing/。 前三个好理解,最容易被忽略的是第四个。
Tests/ 是这个服务自己的测试,而 Testing/ 是给别人用的测试替身 —— 一个 testonly 的公开目标,挨着它实现的那个协议。整个仓库里有 178 个 这样的目录。
6.5.1 这一格解决什么
没有它会发生什么,是可以准确预测的。
某个模块的测试需要一个支付服务的假实现,于是它在自己的测试目录里写一个。 下个月另一个模块也需要,于是它也写一个。半年后,同一个协议有五个略有不同的 假实现,散在五个模块的测试目录里。
它们的差异是渐进产生的:A 模块的 fake 在某次改动里加了一个状态, B 模块的没加。这时候两个模块的测试在断言不同的行为,而没有任何东西 会告诉你这件事。
下一次 flake 就来自这里。而且它会表现成”某个模块的测试偶尔失败”, 排查方向会指向那个模块 —— 而根因在半年前另一个模块的一次改动里。
这是形状 B(同一份状态两个写者)在测试替身上的形态, 而 Testing/ 这一格做的事情就是:给替身一个 owner。
6.6 演进纪律:机制什么时候上移
判断标准很直白:同一个问题出现第二次,就该把机制升到共享层。 配套的是每一份可变状态收敛到单一 writer,出了问题在 owner 那里修, 而不是在调用点打补丁。
最后这条尤其重要。当你发现自己在不同地方做着相似的局部修复, 那本身就是”这个机制该上移了”的信号,而不是”再修一次就好了”。
6.6.1 我自己违反过这条
这条规则说起来容易,做起来很难。小节 4.4 讲过一个完整的反例: 同一类”磁盘满”的问题在几个月内出现了五次,前四次的处理都是清盘或调参, 真正的机制上移到第五次才被提出来。
放在这一章的语境里,那次的意思是:“第二次就上移”这条纪律, 我在代码这一侧执行得极其彻底,在运维那一侧执行了五次才想起来。 两侧用的是同一条纪律、同一个人、同一个星期。
这里真正的信息是:知道原则和执行原则之间隔着注意力 (小节 4.4 展开讲过这次的完整形状)。 这一章讲的每一条都是”第二次就上移”在代码这一侧的产物 —— 而同一条纪律在基础设施那一侧没有被执行。 区别在哪一侧有判定覆盖。 代码有三层检查盯着,每一次违反都会立刻变成 一个红灯;基础设施没有,于是”下次注意”就成了唯一的机制,而它不工作。
章节 19 会讲这个缺口该怎么补。
6.7 模块化:按变更原因拆,不按行数
文件尽量控制在两百行以内,但拆分的依据是”变更原因”而不是行数 —— 两段代码如果总是因为同一个理由一起改,那它们就该待在一起, 哪怕加起来超过两百行。实测下来,Swift 文件里超过两百行的占 10.9%。
6.7.1 检查器对自己更严,而且严得不完全对
检查器自己那部分设了硬顶:697 个 Rust 文件,无一超过两百行。 我当时的理由是:它是检查别人的那一方,尤其不能自己先松掉。
这个态度是对的,但这里有一处值得指出的不一致 —— 那 697 个文件里, 最大的几个分别是 200、200、200、199、199 行。
一个按”变更原因”拆分的代码库,文件长度的分布应该是连续的,在两百附近 没有特别的堆积。紧贴上限的堆积说明这些文件是按行数拆的,不是按变更原因拆的。
这不是什么严重问题 —— 两百行的上限本身是个合理的启发式,而检查器的代码 高度同质,按行数拆的代价很低。但它说明了一件更普遍的事:
一条”按 A 判断而不是按 B 判断”的规则, 如果只有 B 是可以自动检查的,那么实践会向 B 漂移。
这是全书会反复回到的一个张力。章节 15 会讲,它不是靠 “更严格地要求自己”能解决的 —— 得靠让 A 也变得可检查, 或者接受这个漂移并明确它的代价。
6.8 Code is the SSOT
用命名、类型、代码结构和 schema 承载意图与数据模型,只保留解释”为什么”的 注释(约束、取舍、引用),删掉复述”做了什么”的注释。
这条有一个可核验的数字:在 310 万行 Swift、Rust、Go 代码里, TODO 有 23 个,FIXME 有 0 个。约每十三万行一个 TODO, 而一般代码库的密度是每两百到五百行一个。
这个数字只能靠强制得到,不可能靠自觉。它的意义不在于”很干净”, 在于:当 TODO 稀少到这个程度时,它重新变成一个信号。 在一个有五千个 TODO 的代码库里,TODO 是噪声 —— 没有人会去读它们, grep 出来的结果没法行动;而在这里,二十三个 TODO 是一份可以在一小时内 读完并处理掉的清单。
6.9 worktree 隔离与一条反直觉的约束
分支模型是主干开发:只有一条长期分支,另外一百多个远端分支全部是短生命周期的, 一律经合并请求汇入。这个选择在 Agent 并行度上来之后变得格外重要 —— 长期分支意味着长期的分歧,而分歧的合并成本是人在付。
实测:同一时间有 260 个工作区并存,单日最多 48 个分支同时推进, 峰值一天合入 52 次,每个工作区独占自己的构建输出根和验证 owner。
6.9.1 会咬人的那条约束
这里有一条反直觉但很要紧的事实:这些工作区就住在仓库树内部。
所以任何对仓库根目录的递归命令都是危险的。一次 grep -r, 或者一次看起来无害的批量替换,会一路走进几十个不相干的检出, 改到你从没打开过的分支。
这条约束的处理方式值得学。它没有被写成”请小心使用递归命令”, 而是被写成了一条带着具体验证动作的规则:
把每一次递归读取和每一次批量编辑限定到任务拥有的具体目录, 并在动作之前确认匹配列表里没有工作区路径。
章节 8 会讲这个写法为什么重要:一条规则如果只说”要小心”, 它对 Agent 等于不存在,因为”小心”不是一个可以执行的动作。 只有当它给出一个可执行的验证步骤时,它才是一条规则。
6.10 Designs:设计也是 Agent 能读的规格
最后一块容易被忽略。设计资产的链路是:产品需求 → 信息架构 → 设计系统 → 高保真原型 → 视觉资产。
这条链上最关键的一步是原型的格式:那些原型是 HTML 文件,不是设计稿截图。
这个选择的意义在于,Agent 可以直接读懂它的结构和交互 —— 按钮在哪、 点了之后去哪、状态怎么变,都在代码里写着,不需要人先看一遍图 再转译成文字描述给它。
设计因此从”给人看的东西”变成了”可被消费的输入”。这是这一章那条主线的 又一个实例:把信息放进一个 Agent 能直接读的载体里,而不是放进一个 需要人转译的载体里 —— 而转译这个动作本身,就是规模化时第一个断掉的环节。
6.11 一次真实的目录重构
版本历史里有一次提交,标题是”把某产品未被挣得的 App 层摘掉, 按获取方式拆进组装根与库”。这次改动值得拆开看,因为它同时演示了 这一章的三条原则。
它执行的是”被挣得”原则 —— 那个产品只有一个部署件,所以 App/ 这一层 从来没有被第二个实例挣得过。它拆分的依据是”怎么获取” —— 标题里那句 “按获取方式拆进组装根与库”,不是按功能拆,不是按大小拆。它是一次 纯结构改动,没有任何业务变化。
6.11.1 纯结构改动值不值
一次不改变任何行为的改动,怎么论证它的价值?三条,按可验证程度排。
第一,它让结构重新开始传递信息。 在改之前,看到 App/ 的人会以为 这个产品有多个部署件;改完之后,结构说的是真话。
第二,它让规则重新可用。 一条”兄弟部署件不许直连”的规则,在只有一个 部署件时是空转的。更麻烦的是:这条规则的存在会让人以为这个产品的 部署件边界被守着 —— 而实际上没有东西可守。
第三,它降低了未来的成本。 真的要加第二个部署件时,现在的形状是对的 (因为它反映了真实的一个部署件),而之前那个形状是猜的。
三条里只有第一条是立刻可见的,这就是纯结构改动难以被排优先级的原因, 也是为什么它需要一条规则来强制 —— 否则它永远排在”有可见收益”的事情后面。
6.11.2 这次改动的代价
这类改动有一个隐藏代价:所有以路径通配符定义范围的规则, 它们的匹配范围都变了。
没有任何机制会告诉你”这次重构让某条规则的覆盖面掉了一半”。 所以一次目录重构之后,应该主动检查各条规则的扫描数 —— 那个数就在每次运行的输出里,只是没有人把它存成时间序列。
6.12 目录之外:还有哪些结构能承载规则
这一章讲的是目录,但”把规则写进结构”这条原则不止目录这一种载体。 把它们列全很有必要,因为选错载体的成本很高。
最强的是类型,因为它的违反是编译期的 —— 而编译期的反馈是所有反馈里 最快的,快到它不像反馈,像前馈。三态的判定枚举、非零的哨兵整数都属于这一类。 其次是可见性修饰,它能表达”谁能构造、谁能调用” —— 已校验的配置类型 在模块外无法构造,就是靠这个。再次是构建目标,它能表达”什么能依赖什么”, 比如协议与实现被拆成两个目标。然后是目录层级,它表达归属、 可见性和依赖方向。
最弱的是命名约定,因为它需要一条额外的检查来执行。但它有一个别的结构 都没有的优点:它在阅读时零成本地传递信息 —— 一个叫 XxxService 的包, 读者不需要打开它、不需要查目录、不需要读文档,就知道它的获取方式。
6.12.1 选择顺序
面对一条要写进结构的规则,按这个顺序试:
flowchart TB
A{"能用类型表达吗?"} -->|能| T["类型(编译期,最强)"]
A -->|不能| B{"能用可见性表达吗?"}
B -->|能| V["可见性(编译期)"]
B -->|不能| C{"能用构建目标表达吗?"}
C -->|能| G["构建目标(构建期)"]
C -->|不能| D{"能用目录表达吗?"}
D -->|能| P["目录(一读就知道)"]
D -->|不能| E{"能用命名表达吗?"}
E -->|能| N["命名 + 一条检查"]
E -->|不能| W["写进常驻文件"]
style T fill:#e8f4e8
style W fill:#fdf3e3
大部分团队直接跳到最后一步。 而这个顺序有一个副作用值得注意: 越往上,规则越不需要被”知道” —— 一条用类型表达的规则, 新来的人不需要读任何文档,他写错了编译器会告诉他。
6.12.2 命名作为最轻的一种结构
命名被列为”最弱”,但它的零成本传递在 Agent 场景下被放大了: Agent 每次读代码都是从零开始,所以任何”不需要额外查询就能获得的信息”, 价值都比对人高。
一条好的命名约定有三个特征。它编码的是一个稳定的事实,不是一个分类 —— “以 Service 结尾表示它经微内核注册”编码的是获取方式,那是结构性事实; 而”以 Manager 结尾表示它管理某个东西”编码的是一个模糊的角色。 它有一个明确的否定形式 —— “一个不能诚实地取 Service 后缀的包, 说明它属于别处”,这让命名约定同时变成了一个诊断工具:起名困难时, 问题通常在位置上,不在名字上。它可以被机器检查 —— 一条无法被检查的命名约定,会在半年内漂移到 60% 的遵守率, 而那时候它已经不传递信息了。
反过来,命名约定也会过度。判据是:一个新来的人需要记住几条命名规则 才能读懂路径?超过三到四条,它就从”零成本传递信息”变成了”需要先学一套编码”。 我那套系统实际上只有两三条,其余的信息都由目录结构承载 —— 因为目录结构是可见的,而命名规则需要被记住。
6.13 目录结构作为一种压缩
把这一章的内容抽象一层,会看到一个统一的视角: 目录结构是一种对”这个系统是什么形状”的压缩编码。
所有关于压缩的直觉在这里都适用。冗余降低信息密度 —— 每一层为了对称 而加的空目录,都是一个不携带信息的符号,读者仍然要花注意力去处理它, 却得不到任何东西。编码方案必须是双向的 —— 你能从目录推出结构, 也应该能从结构推出目录,而”角色由怎么获取决定”之所以重要, 正是因为它保证了这个映射是函数,给定一个新包,它该放哪只有一个答案。 压缩率的上限由规律性决定 —— 一个到处是特例的仓库,目录结构无法承载 太多信息,因为读者不能从它推出任何东西。
这个视角能解释一件事:为什么”要不要加一层目录”这种看起来鸡毛蒜皮的问题, 值得写进常驻文件、值得设一条规则来守。因为它不是关于目录的, 是关于 Agent 每次读代码时能免费获得多少信息。
6.14 目录结构决定了哪些检查能被写出来
这一章讲的东西看起来很”软”,但它和判定层有一条硬连接:
一条依赖规则能不能被自动检查, 取决于依赖关系能不能从目录结构推出来。
具体地说,“产品之间不能互相依赖”这条规则的可执行版本是”查询构建图, 找出所有 Modules/A → Modules/B 的边,报违规”。这条查询之所以 能写出来,是因为”哪些是产品”可以从路径推出来 —— Modules/ 下面 每个目录是一个产品。如果目录结构不规律,这条规则就无法被表达。
同样的连接还有好几处:服务包以 Service 结尾,让”服务必须经注册取得” 可以被检查;协议与实现是两个目录,让”契约与实现必须分目标”可以被检查; 组合根固定叫 Launch,让”生产目标不得依赖组合根”可以被检查。 四条结构约定,四条可执行的规则。
反过来说,一个结构混乱的仓库,它的架构规则大多只能停在文档层 (小节 5.7 的第一层),因为没有一个可靠的方式 把”这是什么”从路径推出来。
这是”环境先于检查”最具体的一个含义:不是环境更重要, 而是环境决定了检查能不能被写出来。
有了这条连接,可以反过来做一个诊断:看看你有多少条架构规则是自动检查的, 多少条只写在文档里。如果后者远多于前者,通常不是因为”没时间写检查”, 而是因为结构不足以支撑那些检查 —— 你没法可靠地判断一个文件属于哪一层、 哪个模块、哪个角色。那时候正确的第一步不是写检查,是先把结构理顺。
6.14.1 结构诊断:十个文件,四个问题
上一节那条连接给出了一个诊断方法,值得写成可以照着做的形式, 因为它比”读一遍目录觉得还行”可靠得多。
第一步:随便挑十个文件,对每一个问四个问题—— 它属于哪一层?它属于哪个模块?它是什么角色(实现、契约、测试、组装)? 它能依赖谁?四个问题里,有几个能只看路径回答出来?
这个测试的严格之处在”只看路径”。允许打开文件看内容的话, 任何结构都能得分;而 Agent 在决定”这个新文件放哪”的时候, 它看的正是路径 —— 它不会把整个仓库读一遍再决定。
第二步:数一数四个问题的平均得分。 四个全能答出来,说明你的结构足以支撑自动检查; 答出两三个,说明部分规则能写、部分只能停在文档; 只能答出一个(通常是”哪个模块”), 说明你现在写不出任何一条可靠的架构规则—— 而这时候去写检查是在浪费时间,正确的动作是先理结构。
第三步:对答不出来的那些问题,找出是什么信息缺失了。 通常是三种之一:没有命名约定(服务、协议、测试的文件名看不出区别)、 层级不一致(同一类东西在不同产品下的深度不一样)、 角色和主题混在一起(Login/ 下面既有界面又有服务又有测试)。 三种各自的修法不同,但它们的共同点是修完之后规则才写得出来。
这个诊断的价值在于它把一个模糊的问题(“我们的结构好不好”) 换成了一个可以数的量(“十个文件、四个问题,答对了几个”)。 这正是 小节 10.17 那六条判据的做法 —— 把品味问题变成事实问题,因为事实问题在不同的人之间 能得到一致的答案。
需要提醒的是这个诊断有一个盲区:它测的是结构的规律性, 不是结构的正确性。一个把所有东西都放在 src/<产品>/<角色>/<文件> 下的仓库能拿满分, 即使那个分层本身是错的(比如按功能分而不是按获取方式分)。 所以它是一个必要条件的检查,不是充分条件 —— 它能告诉你”这里写不出检查”,不能告诉你”这里的结构是对的”。
6.16 一个反常识:不要追求”完美的目录结构”
最后一句提醒。目录结构的价值是传递信息,不是”正确”。
一个”更正确”但没人能一眼读懂的结构,比一个稍有妥协但一目了然的结构差。 “一眼读懂”的判据很具体:一个新来的人(或 Agent)看到一个路径, 能不能推出它是什么、能依赖谁?
按这个判据,小节 6.3 那五个固定的角色分桶是好的 —— 五个,可穷举,且角色由获取方式决定;而一个有十五个分类、每个分类 都有充分理由的结构是坏的,因为没有人能记住十五个。
结构的可读性上限,由人的工作记忆决定,而不是由领域的复杂度决定。
6.17 不显然的那个收益
还有一个收益通常不被提起,而它和 Agent 的工作方式直接相关: 一个规律的目录结构,让”我该读哪些文件”变成一个可计算的问题。
一个 Agent 接到”给某产品加一个页面”的任务时,如果目录结构是规律的, 它可以直接推出需要看的位置:那个产品的页面目录、它的路由库、 它的本地化表。如果结构不规律,它只能靠搜索 —— 而搜索的结果是一个 按相关度排序的列表,列表里没有”完整性”这个保证。
这个差别在实践中的表现是:结构规律的仓库里 Agent 更少遗漏, 结构混乱的仓库里它会漏掉那些名字不像但实际相关的东西。 遗漏是静默的 —— 它不会导致编译失败,它会导致一个功能只做了一半。
6.18 ⚙️ 小规模怎么做
这一章里没有任何一条需要重基建。三条今天就能用的:
一、把”被挣得”原则用在你现有的目录上。 遍历一遍,找出那些只有一个子项的中间层。每一个都问: 它是被第二个实例挣得的,还是为了对称/将来加的?后者删掉。
小项目更容易犯这个错,因为小项目更容易为了”以后会用到”而提前分层 —— 而且代价更大,因为在一个只有三十个文件的项目里, 一层空目录稀释掉的信息量占比更高。
二、给你的测试替身找一个 owner。 数一下同一个接口在你的测试目录里有几个假实现。 如果超过一个,把它们合并到一个地方 —— 不需要什么框架, 一个共享的测试工具目录就够。
三、数一下你的 TODO。 如果超过一百个,那么它们已经是噪声了。 不需要一次清完,但要停止增长 —— 加一条 CI 检查, 不允许新增(这就是 小节 13.3 讲的单调收敛,最小形态)。
6.19 目录结构改动的成本什么时候会突然变高
小节 6.15 说移动的成本随规模超线性增长, 但那条曲线不是平滑的 —— 它有一个台阶,而知道台阶在哪很有用。
台阶出现在第一次有外部的东西引用了你的路径的时候。 在那之前,一次目录重构的成本是”改所有的 import”, 而那是一个机械的、可以被工具完成的工作。 之后就不是了 —— 一条 CI 配置里写死的路径、 一份文档里的链接、一个别的仓库里的子模块引用、 一条以路径通配符定义范围的规则(小节 6.11.2), 这些各自住在不同的地方,而没有任何一个工具能同时找到它们。
具体的信号有三个,出现任意一个就说明你已经过了那个台阶: 有人在一次重构之后说”CI 挂了但代码没问题”; 有一份文档里的链接指向了一个不存在的路径; 或者一条规则的扫描数在一次重构后掉了一大截而没人注意到。
过了台阶之后正确的做法不是”从此不再重构”, 是把那些外部引用收敛到可以被一次性更新的地方: CI 里的路径改成引用一个变量、文档里的链接改成引用锚点、 规则的 scope 改成引用一个命名的目标集合而不是通配符。 这些改动本身也是一次”把规则写进结构”—— 它们把”重构时要记得改的地方”从一份心智清单 变成了一个可以被枚举的列表。
小节 6.2 那条原则在早期特别值钱,道理就在这里: 它省下的不是当下的一层目录, 是将来那次跨过台阶之后的重构。
6.20 压成一句话
目录结构是你写给 Agent 的第一份文档, 而且是唯一一份它必然会读的。
它不会读你的 README(除非被指向), 不会读你的架构文档(除非被指向), 但它一定会看到路径 —— 因为它必须知道文件在哪。
这意味着:每一个能被目录结构承载的信息, 都是零成本传递的;每一个不能的,都需要另一个载体 (章节 8),而另一个载体都有它自己的到达概率。
所以”能写进目录的就不要写进文档”这条建议, 不是为了整洁,是为了到达率。
6.21 目录结构和上下文窗口的关系
有一个很实际的角度前面没提:目录结构决定了 Agent 要读多少东西才能开始动手。
一次任务开始时,Agent 需要建立一个足够的局部理解 —— 这段代码属于哪一层、能依赖谁、相关的东西在哪。 在一个规律的结构里,这个理解大部分来自路径本身, 读文件只是为了确认;在一个不规律的结构里, 它必须靠读文件来建立,而读多少才够是一个没有答案的问题—— 所以它要么读太少(漏掉了相关的东西), 要么读太多(把上下文塞满了无关的内容)。
第二种失败更隐蔽,因为它看起来很勤奋。 一个把二十个文件读进上下文的 Agent, 它对每一个文件的注意力都被稀释了 —— 这和 小节 8.3 讲的常驻文件变长是同一个机制, 只不过对象从规则换成了代码。
这给出一个判断目录结构好坏的操作性判据, 它比 小节 6.14.1 那个更直接: 看你的 Agent 在动手之前读了几个文件。 如果一个”给某产品加一个页面”的任务要先读十几个文件, 那不是 Agent 谨慎,是结构没有把答案放在路径里。
小节 6.20 那句”目录结构是唯一一份 它必然会读的文档”值得被当真:它是零上下文成本的—— 路径本身不占上下文,而它承载的每一条信息, 都是一条不需要用上下文额度去换的信息。
6.22 压成三句话
一、每一层结构必须被一个真实的第二个实例挣得。
这条的价值不在整洁,在于 结构能传递信息,靠的是它在不需要的时候不出现 (小节 6.2.2)。
二、角色由”怎么获取”决定,不由”关于什么”决定。
这条把一个品味问题变成了事实问题, 而事实问题在不同的人(和不同的 Agent)之间 能得到一致的答案(小节 6.3)。
三、目录结构决定了哪些检查能被写出来。
这是”环境先于检查”最具体的含义 (小节 6.14)—— 不是环境更重要,是结构不足以支撑的检查根本写不出来。
6.23 留给读者的练习
十分钟,三步:
一、随便挑五个文件,看它们的路径。 问:只看路径,你能说出它是什么、能依赖谁吗?
答不出来的每一个,都是一次信息传递的失败。
二、找出所有只有一个子项的中间目录。 每一个都问:它被第二个实例挣得了吗?
三、grep 你的测试目录,看同一个接口有几个假实现。 超过一个 → 它们的差异会在某天变成一次难以定位的失败 (小节 6.5.1)。
三步做完,你会有一份具体的清单 —— 而这份清单比读完这一章更有用, 因为它是关于你自己的仓库的。