Read in English

局部性原则:为防止遗漏而设计

局部性不是把相似的代码放在一起,而是让修改闭包从每个合理的修改入口都能触达——对人和智能体都是如此。

Lianghui Zhang (Leric Zhang)·v0.1·更新于

本文为中文版,根据英文原版改写,而非逐句直译。

智能体做完了显而易见的工作

一个 AI 编程智能体接到任务:新增 Apple Pay 作为一种支付方式。

任务看起来不大。代码库里已经有 PaymentMethod 类型,结账页已经能渲染可用支付方式的列表,支付处理器也已经能分派到不同的提供方。智能体找到了这些显而易见的入口:把 "apple_pay" 加进联合类型,更新结账表单,把新方式接入处理器,再补一个正常路径的测试。测试全部通过。补丁看起来干净、局部、合理。

它甚至做了一个谨慎的智能体该做的事:收尾之前,它问评审者还有没有别的地方需要处理 Apple Pay。

评审者想了想,记起了后台的订单详情页。智能体在那里也加上了显示标签。现在补丁看起来更完整了:能编译,测试全绿,还经过了人工审阅,似乎可以合并了。

但这次修改仍然是不完整的。

几天后,遗漏开始陆续暴露。Apple Pay 订单的退款走了错误的兜底路径,因为退款策略没有把 Apple Pay 归为钱包支付;结算报表把它归到了 other,因为报表映射里没有这个新方式;反欺诈评分没有套用钱包支付的风险规则;i18n 表里没有它的显示名;测试工厂从不生成 Apple Pay,所以有些路径从来没被跑到;埋点事件的 schema 仍然把这个新值当作未知值拒绝。

这些故障都不涉及别的服务、别的仓库或复杂的组织边界,完全可以发生在同一个代码库里。每个遗漏的地方都和 PaymentMethod 有关,但这种关系不一定被某个显式结构记录下来。这些构件共享同一个设计决策:系统支持一种新的支付方式。

智能体做的每一处局部修改可能都是对的。它知道怎样更新联合类型、调整 UI、补测试、接入处理器。失败之处在于:从 PaymentMethod 这个自然入口出发,它没能可靠地获取完整的修改闭包(modification closure)。

问人也没能解决问题。评审者并不是闭包的”神谕”,他只能补充自己想得到的东西:最近动过的模块、显眼的名字、熟悉的负责领域。那些遗漏的映射、策略、工厂、报表和 schema 依然隐藏着,因为系统里没有任何路径能把它们暴露出来。

“我想不到别的了”并不能证明闭包已经完整。

这正在成为智能体编程的一种典型失败模式:智能体拥有的上下文足以写出局部正确的补丁,却不足以让整个修改正确。模型越强,这种失败反而越重要。生成代码越来越便宜,而漏掉本该随代码一起修改的上下文,代价越来越高。

局部性原则针对的就是这种失败。它关心的主要不是物理上挨得近不近、类名是否优雅、东西是否都在一个文件里,而是:当一次真实的修改从任何一个合理入口开始时,系统能否让人或智能体找到所有必须一并考虑的构件。

从广度到局部性

在前一篇关于广度的文章里,我们给一次修改中必须一并考虑的构件集合起了个名字:修改闭包。它可能包括代码、测试、UI、配置、文档、报表、部署步骤,或者任何为了让任务正确而必须修改、检查或保持一致的东西。

广度是获取这个集合的成本。它问的不是某个文件是否难懂,而是:为了让这次修改安全,哪些构件必须一起进入上下文?当闭包中某个必要成员始终没有进入上下文时,修改就会以一种特定的方式失败:被修改的代码也许没错,但某个必要的地方从来没人去过。

这种失败模式就是遗漏(omission)。

遗漏之所以危险,是因为它在工作进行时往往不像是失败。智能体修改它能找到的东西,测试通过,评审看起来也合理。人类开发者的体验也一样:可见路径上没有特别复杂的东西,修改甚至会显得很顺。真正的问题在于,系统从未暴露同属一个闭包的其他构件。

有经验的工程师会把这识别为一种设计坏味道。他们在修改时注意到几个地方总是一起变动,并意识到代码库并没有记录它们为什么属于一起。让他们警觉的不是”这段代码已经坏了”,而是”下一个修改者可能从其中某处进入,却永远找不到其余的部分”。

局部性正是回应这种警觉的设计属性。

局部性是一种设计属性:它让完整的修改闭包,能够从一次合理修改自然开始的地方被触达。

“自然开始”这几个字很重要。一次修改可能始于一个失败的测试、一个类型定义、一个 API 字段、一个 UI 选项、一个配置键、一个事件载荷、一条业务规则,或者一个用户报告的 bug。好的局部性并不要求所有相关构件都紧挨着入口,而是要求从入口到闭包其余部分之间有显式的路径。

对人来说,局部性降低了修改的认知成本。它减少了在整个系统里搜索或依赖记忆的需要,帮助开发者知道该检查什么、可以忽略什么,以及什么时候闭包大概率已经完整。

对智能体来说,局部性还是一种可靠性属性。强大的模型能对已获取的上下文进行很好的推理,却无法对从未进入上下文的构件进行推理。缺乏局部性时,智能体会自信地产出一个局部自洽、整体却不完整的补丁。

因此,智能体的可靠性不只是模型的属性,也是代码库及其周边工程系统的属性。模型能力决定智能体能多好地利用已获取的上下文;局部性则决定智能体能否可靠地获取足够的上下文。

广度命名了获取闭包的成本,局部性则是设计让这种获取变得可靠的方式。

重读 DRY、SRP 与内聚

经典的局部性原则之所以流传下来,是因为它们指向真实的设计压力。DRY 说不要重复知识;SRP 说一个模块应该只有一个变化的理由;内聚说相关的东西应该放在一起。这些说法都有用,但关键词都很滑:什么算同一份知识?什么算一个变化的理由?什么叫相关?

修改闭包为这些直觉提供了一个更具体的对象。

这一点之所以重要,是因为修改闭包并不是又一个含糊的设计名词。它是任务本身在修改者工作记忆中构成的集合。当你实现一个功能——比如新增 Apple Pay——等工作完成时,你已经往 PaymentMethod 联合类型里加了一个值,改了结账表单,在处理器里注册了处理函数,扩展了退款策略映射,在结算报表分组里加了一项,更新了埋点 schema,在 i18n 表里加了显示名,删掉了一个过时的兜底分支,还调整了测试工厂让它能生成新方式。完成任务的过程中,这些文件每一个都被打开、阅读、修改、新增或删除过。到最后,它们作为一个完整的工作单元同时存在于你的工作记忆里,而不是一条条先后到来的零散记忆。对做这项任务的人来说,它们是同一份知识、同一个变化理由,因同属一次修改而彼此相关。这个集合既不是靠抽象分类发明出来的,也不是靠回忆找回来的,而是由任务本身构成的。它们就是为了让这次修改正确而必须一并考虑的构件。

这让修改闭包比”知识""职责""相关性”更具可操作性。它问的是:对于这次具体的修改,如果忘了它,会出什么错?

DRY:一个决策,一处(或带索引的)表示

DRY 常被简化为”不要把相似的代码写两遍”,但相似和”属于同一个决策”是两回事。User 领域实体和 UserRecord 持久化模型可能字段相同,却编码着不同的决策(业务不变量与存储布局);而 PaymentMethod、退款策略、显示名、埋点值和测试工厂长得毫不相像,却编码着同一个决策:这个系统支持哪些支付方式。

从局部性的角度看,DRY 是一条关于同一决策的重复表示的规则:这些表示要么合并到一个有归属的地方,要么能够彼此触达。真正的 DRY 失败,是一个拥有多处表示、却没有索引的决策。

SRP:一个变化理由就是一个闭包族

SRP 说”一个模块应该只有一个变化的理由”,但理由很难数清楚。典型的 SRP 失败是概念上的:职责或一件事被定义得太宽。一整个业务流程——“订单处理""用户注册引导""结账”——在对话中听起来像一件事,实际上却捆绑了定价、库存、支付、履约、通知、埋点等等,每一项都有自己的修改闭包和变化理由。

一旦这些互不相关的闭包挤在同一个修改面上,每次修改都会在它们之间泄漏。调个价格,你得穿过履约代码;改个通知,你得去推理支付状态;修复一个闭包,却顺带往另一个闭包里塞进了意料之外的改动。小修改的范围不断膨胀,因为本该彼此分离的闭包如今纠缠在了一起。

局部性把问题换了一种问法:这个模块内的闭包是否构成一个连贯的族?还是说,互不相关的闭包因为被误称为同一项职责,而被压到了同一个修改面上?

一个模块不应迫使互不相关的修改闭包共享同一个修改面。

内聚:共变可达性

内聚通常被表述为”相关的东西放在一起”,但相关太宽泛了。局部性给了它一个更窄的含义:如果一个模块中的构件服务于那些通常会被一起获取、并且彼此容易触达的闭包,这个模块就是内聚的。内聚就是共变可达性——与其说它是一条独立的规则,不如说它是良好局部性的结果。

DRY、SRP 和内聚是同一种可达性判断的三个视角。旧原则并没有错;局部性把它们背后的直觉变成了一个人和智能体都能据以行动的问题:这次修改的闭包是什么?它能否从修改开始的地方被触达?

如何实现局部性

局部性不只是代码的属性。实现它的机制可以按照约束闭包的方式分为三层:

  • 代码:一切以源码、测试或 CI/CD 构件形式表达的东西。它们通过执行提供反馈:缺失的闭包成员会导致构建失败、类型检查失败、测试失败或流水线检查失败。
  • 架构:关于东西放在哪里、系统各部分如何连接的约定,包括目录布局、模块边界、依赖方向、服务边界、API 与事件 schema、契约。约定不会让构建失败,但能大幅缩小搜索空间。
  • 文档:纯文本提示,包括注释、README、ADR、运维手册(runbook)、检查清单。没有强制力,只为阅读者提供可达性。

三层都重要,只要它们能把原本只存在于某人记忆里的东西,变成下一个修改者可以顺着走的路径。

代码层的索引

代码层的索引把闭包关系编码进可以执行或测试的构件里,让缺失的成员在修改上线之前就产生反馈。最强的形式是把闭包变成编译期的义务。Record<PaymentMethod, RefundPolicy> 这样的类型表达的是”一个必须为 PaymentMethod 的每个值都提供 RefundPolicy 的映射”——如果你往联合类型里加了 apple_pay 却没给它策略,代码就无法编译。穷尽式 switch、生成代码以及类似的构造也是同样的道理:新增一个变体却不更新依赖它的映射,就会变成构建错误。

测试把同样的思路延伸到运行时。普通的正常路径测试只能证明智能体改过的那条路径能工作。完备性测试则直接对闭包本身做断言:对于一个可枚举集合中的每个成员(每种支付方式、每种订单状态、每种支持的语言区域),所需的映射、处理函数或策略都必须存在。对支付方式来说,有用的局部性测试可以断言:

  • 每种支付方式都有显示名
  • 每种支付方式都有退款策略
  • 每种支付方式都有埋点值
  • 每种支付方式都有风险类别
  • 测试工厂能生成每一种受支持的方式

每一条都能把遗漏变成可见的失败,远早于它在生产环境中酿成事故。

同样的机制也延伸到应用代码之外。迁移脚本、特性开关配置、基础设施定义、监控面板、告警规则、运维手册和发布检查清单,都以文件形式存放在仓库里,也都能参与同样的约束:缺少迁移条目会让构建失败,没有负责人的告警会让 lint 报错,依赖一个不存在的监控面板的发布步骤会让检查失败。只要闭包关系被写成工具能读懂的形式,工具就能拒绝不完整的修改。

设计模式也属于这一层。它们的价值不在优雅,而在于让一类常见的闭包变得可遍历:

  • 访问者模式与穷尽匹配服务于变体闭包。新增一个 AST 节点、支付状态或订单状态,可能影响解析、求值、序列化、渲染、校验、快照生成和自动补全。穷尽匹配把闭包变成一张必须实现的处理函数清单。
  • 注册表与插件注册表服务于成员闭包。新增一种支付方式、导出格式、通知渠道或集成提供方,不应该靠猜测每一个 if。成员资格在一个构件中声明,并附带一组必填字段。
  • 策略模式服务于策略族闭包。新增一种退款策略、定价规则或风险评分方法时,应该能看到接口、已有实现、共享测试和注册点——策略模式让一族策略变得可枚举。

模式、完备性测试和 CI 即代码,只有在把”我还应该看哪里?“变成工具可以遵循的路径时才有帮助。如果它们只是增加了层次,却没有提升闭包的可达性,那就只是仪式。

架构层的索引

架构层的索引是关于东西放在哪里、系统各部分如何连接的约定:目录布局、模块边界、依赖方向、服务边界、API schema、事件 schema 和契约。它们本身无法让构建失败,却能大幅缩小修改者需要查找的范围。如果支付能力总是放在 payments/capabilities 下,埋点值总是从同一个 schema 生成,外部事件总是在 events/contracts 下声明生产者、消费者和兼容性规则,修改者就不必搜索整个代码库。

跨越系统边界时,局部性与边界成了同一属性的两个视角。一条有效的边界隐藏上下文:边界一侧的修改者不需要获取另一侧的内容,只需要它们之间的契约。当边界失效时——出现了未登记的消费者、缺失的兼容性规则、与现实脱节的契约——被隐藏的上下文就会重新加入闭包,而修改者却没有任何路径能从修改开始的地方触达它。因此,边界失效同时也是局部性失效:闭包里此时包含了下游消费者、版本和行为,而它们对发起修改的一侧是不可见的。

文档层的索引

文档层的索引没有强制力,它们是纯粹供人阅读的文本提示:注释、README、ADR、AGENTS.md、skills、运维手册、发布检查清单和弃用策略——前提是它们放在人和智能体都能找到的地方。

最轻量的索引可能只是一段注释:

// When adding a PaymentMethod, also update:
// - refundPolicyByPaymentMethod
// - settlementReportGroups
// - analytics allowed values
// - i18n display names
// - paymentMethodFactory test coverage
type PaymentMethod = "card" | "paypal" | "bank_transfer"

这段注释不强制任何事情,但它改变了可达性。将来的人或智能体从 PaymentMethod 进入时,看到的不再只是一个联合类型,而是一张可能需要获取的闭包地图。对读这个文件的智能体来说,这段注释就是它接下来可以顺着走的索引。

正因如此,在智能体参与的代码库里,注释和文档应该得到与代码同等的重视。文字不再只是代码旁边的附注——智能体把它们当作同一份输入来读,并以同样的方式据此行动。文档不再是比代码更弱的构件,只是另一种构件。

闭包复盘是唯一的机会

局部性问题从结构上就几乎不可见。一个无法从入口触达的闭包,不会在修改开始的地方留下任何痕迹——diff 记录的是改了什么,而不是本该一起改什么,也不是缺失的部分最终是怎么被找到的。

但在一次开发任务结束时,修改闭包是一个可以直接观察的对象。为了完成工作,智能体必须获取它。到任务结束时,智能体的上下文里保存着一份异常完整的记录:读了哪些文件,改了哪些文件,哪些上下文有用,哪些是噪音,哪个必要构件是后来才发现的,哪条路径依赖搜索、猜测或人的提醒。diff 几乎丢弃了所有这些信息。对人来说,这些知识几小时内就会淡去;对智能体来说,上下文窗口一重置,它们就消失了。如果不在这里捕获闭包,别处再也观察不到它。

这使得闭包复盘不是一个锦上添花的附加步骤,而是工作流中最主要的局部性机制。代码层索引、架构和文档只能编码已经有人注意到的闭包;复盘则是发现那些尚未被任何现有索引捕获的闭包的手段。没有它,局部性问题以后仍然可能被找回——通过一次事故、一次细致的重读,或者某个未来的修改者从另一条路径进入——但用这种方式重建闭包要费力得多,而且很少能达到在闭包仍然完整时就捕获它的那种把握。

因此,智能体工作流应当以一次闭包复盘结束。智能体应该问:

  • 这个任务的目标是什么?
  • 这个任务加载了哪些上下文?
  • 哪些被修改的构件服务于同一个功能或设计决策?
  • 这些构件是否应该放在一起?
  • 如果不能,是什么索引把它们连接起来的?
  • 下一个人或智能体能否从任何合理的入口找到同样的闭包?
  • 什么样的索引能让下一次修改更安全?

这不是普通的任务总结。普通总结说的是做了什么;闭包复盘说的是必须获取哪些上下文,以及系统是否正确记录了它们之间的关系。

复盘的产出应该是设计反馈。如果几个文件总是一起变动,也许它们应该放在一起;如果不能放在一起,也许需要一段注释、一个注册表、一个测试、一份契约或一本运维手册。智能体为 Apple Pay 添加 settlementReportGroups 的那一刻,正是在 PaymentMethod 注册表里留一行”报表系统也在使用它”的最低成本时机。

这个反馈闭环的一种实用模式是修改后的设计反思:在智能体刚刚为一项任务付出的上下文消失之前,把它转化为聚焦、可执行的局部性与设计信号。

局部性即可靠性

局部性归根结底关乎可靠性。

它防止人或智能体自信地漏掉那些必须一起修改的东西。随着模型能力的提升,越来越多的失败会从”智能体写不出代码”转向”智能体没有把事情做对”。

所以,智能体的可靠性不只是模型的属性,也是代码库和工程系统的属性。局部性差的系统会不断产出闭包不完整的上下文:足以生成一个看似合理的局部补丁,却不足以保证修改正确。局部性好的系统则会暴露出让充分上下文可以被触达的路径,让人能够有把握地完成修改。

这把 DRY、SRP、内聚、设计模式、契约、测试和注释统一到同一个目标之下:让修改闭包能够从修改的自然入口被触达。

局部性就是可靠性——这一设计属性决定了,下一次修改能否找到所有必须随它一起变动的东西,还是只能找到它碰巧看到的那些。