先从一个小事故说起

AI 写代码最让人疲惫的地方,有时并非它写错了。麻烦在于它写得太勤快。

你让它加一个生日字段,它可能先装一个日期选择库,再包一层组件,再补一份样式,再考虑时区、国际化和移动端弹窗。你让它校验邮箱,它可能新建一个 validator 文件,写一个正则,包一个 helper,再在前端和后端各复制一遍。

这些代码看起来都“合理”。每一小段都能解释自己为什么存在。问题在于,它们很多本来不该作为独立事实存在。

生日字段可以先用浏览器原生 input[type="date"]。邮箱校验可能已经在 schema 里定义过。API 类型可以从 OpenAPI 生成。数据库唯一性应该由 unique index 兜底。前端的 hasItems 可以从 items.length 推出来,不需要再存一份状态。

AI 的毛病,不只是“啰嗦”。它经常把派生事实写成源事实。

这件事用最近一篇信息论论文来看,会变得很清楚。那篇论文叫 *Semantic Rate-Distortion Theory: Deductive Compression and Closure Fidelity*。它讨论的是通信,并非软件工程;它还只是 arXiv 论文,不该被当成已经盖章的工程定理。但它给了我们一个很有用的模型:如果接收方会推理,发送方就不必传输那些能被推出的结论。

落到代码里,就是一句话:

代码库不该保存所有事实,只该保存源事实。其余事实应该被推导、生成、验证,或者干脆不存在。

Shannon 扔掉意义,是为了先把地基打稳

1948 年,Shannon 写下那篇通信理论论文时,做了一个很关键的取舍:通信的语义方面和工程问题无关。

这句话经常被后人拿来发挥。公平地说,Shannon 并非看不见意义。他是把问题先收窄。只要先不管一句话“是什么意思”,就可以精确讨论符号、概率、熵、信道容量、噪声和编码。

这个取舍极其成功。现代通信、压缩、纠错、网络系统,都站在这个地基上。

但这个地基也有一个边界:它主要把消息当成符号序列来处理。符号之间可以有统计相关性,可以压缩重复模式,但它不关心“这条事实能不能由另一条事实推出”。

经典例子是三段论:

  • 所有人都会死。
  • 苏格拉底是人。
  • 苏格拉底会死。

如果逐条传输,这就是三条事实。第三条也要占成本。

但一个懂三段论的人不需要你传第三条。前两条到了,他自己就能推出第三条。这里省下的不是字符重复,省下的是逻辑关系带来的冗余。

新的论文做的第一件事,就是把“成功传输”的定义换掉。传统通信看逐符号保真:你发什么,对方收到什么。论文讨论的是闭包保真:对方从收到的事实出发,能不能推出和原知识库一样的结论集合。

如果能推出同一个闭包,即使你没有逐字传完原知识库,也算在语义上无损。

这个定义一换,压缩对象就变了。逐条传完知识库不再是唯一目标,重点变成了找到它的最小生成集。也就是那些不能从其他事实推出、删掉就会改变闭包的核心事实。

苏格拉底例子里,第三条可以删。前两条保留。因为第三条在前两条的逻辑闭包里。

这就是“不可约核”的直觉。

软件工程一直在做这件事,只是名字不同

把这套说法搬到软件工程里,并不陌生。

我们很早就知道“不要重复自己”。但 DRY 这个词容易被误读成“不要出现相似代码”。真正的问题不在相似,而在同一个事实被维护了两次。

一个字段叫 birthDate,数据库 schema 里有一次,后端 DTO 里有一次,前端 form 里有一次,校验规则里有一次,文档里有一次,测试 fixture 里还有一次。只要这些地方都是手写的,它们迟早会漂移。

漂移表面上是代码风格问题,根上是事实治理问题。

比较好的系统会把核心事实集中起来。比如:

  • 数据库 schema 负责描述持久化真相。
  • 类型系统负责描述程序边界。
  • OpenAPI 或 GraphQL schema 负责描述接口契约。
  • Zod、Valibot 这类 schema 负责描述运行时输入约束。
  • design tokens 负责描述设计系统的基本变量。
  • 状态机负责描述合法状态转移。
  • 测试负责描述关键行为闭包。

这些东西不能当普通文件看。它们是代码库的“不可约核”。

一旦核心事实写清楚,很多派生物就不该再手写。API client 可以生成,类型可以推导,表单字段可以从 schema 派生,校验错误可以复用同一个约束,文档可以从接口定义渲染,测试样例可以围绕核心行为生成。

这不是炫技。它解决的是维护成本。

如果一个事实有三个手写副本,你就有三个未来 bug 的入口。AI 加入之后,这个问题会放大。因为 AI 很擅长补副本,也很擅长在不知道源头在哪里时发明一个看起来合理的新副本。

AI 编程最该压缩的是派生代码

很多人谈 AI 编程成本,第一反应是 token。上下文太长,输出太多,模型调用太贵。

这些都对。但更大的成本常常在后面:AI 生成的派生代码会进入仓库,变成长期维护对象。

一个 datepicker wrapper 写出来,只要合并进主干,它就不再是一次输出。它会吃掉未来的测试、样式适配、可访问性、浏览器兼容、bug 修复和迁移成本。

一个重复 validator 写出来,也不只是多了几十行。它以后可能和后端规则不一致,可能和数据库约束不一致,可能和文档不一致。用户遇到的会是“前端说可以,后端说不行”。

所以 AI 编程的第一条工程规则,不该只是“输出短一点”。短代码也可以是错的抽象。

更好的规则是:

在写代码前,先找 source of truth。

具体一点,Agent 应该先问:

  • 这个行为已经由数据库约束表达了吗?
  • 这个字段已经在 schema 或 type 里出现了吗?
  • 这个接口是否已有 OpenAPI、GraphQL 或 tRPC 契约?
  • 这个 UI 是否已有原生控件或设计系统组件?
  • 这个状态是否能从已有状态计算出来?
  • 这个规则是否已经由测试或状态机覆盖?
  • 如果我要新增一段代码,它是源事实,还是派生事实?

如果答案是派生事实,就应该优先复用、生成或计算,不要再写一份。

这和“少写代码”的口号不一样。少写代码只是结果。真正的判断是:这段代码有没有资格成为一个新的维护源头?

几个最常见的派生事实

前端状态是最容易失控的地方。

比如这个写法:

TypeScript tsx
const [items, setItems] = useState<Item[]>([])
const [itemCount, setItemCount] = useState(0)
const [hasItems, setHasItems] = useState(false)

itemCounthasItems 并非独立事实。它们能从 items 推出来。

更稳的写法是:

TypeScript tsx
const [items, setItems] = useState<Item[]>([])
const itemCount = items.length
const hasItems = items.length > 0

这看起来只是少两行,实际少掉的是同步协议。你不再需要保证每次改 items 时都记得改 itemCounthasItems

第二类是重复类型。

如果后端已经有接口契约,前端再手写一份相似类型,就很危险。字段改名、可空变化、枚举扩展,都会让两个世界慢慢分叉。能从契约生成,就让生成器承担同步;不能生成,也要把类型定义集中在一个边界里,不要散在每个页面。

第三类是重复校验。

前端可以做即时提示,但不要把前端校验误当成最终真相。最终真相通常应该在服务端 schema、领域服务和数据库约束里。前端校验最好从同一套规则派生,或者至少明确只是用户体验层。

第四类是重复权限判断。

权限尤其不能散。一个按钮隐藏、一条路由拦截、一个 API 拒绝、一个后台任务限制,如果各写各的,迟早会漏。权限应该有统一 policy 或 middleware,上层只消费结果。

第五类是重复文档。

文档当然要写,问题是不要把文档写成另一套事实源。接口文档能从契约生成的部分就生成;手写文档应该解释为什么这样设计、边界在哪里、常见误用是什么,不要复制字段表。

这些派生事实被 AI 扩写之后,表面上是产能提高,实际是未来债务增加。

“不可约核”在项目里应该长什么样

一个能让人和 Agent 都好好工作的代码库,应该有清晰的核心层。

这并不要求所有项目都上复杂架构。底线更朴素:至少要让别人知道,真相在哪里。

常见的不可约核包括:

  • schema.prisma、SQL migration 或 Drizzle schema:数据真相。
  • openapi.yaml、GraphQL schema、tRPC router:接口真相。
  • domain/*.tstypes.ts、领域模型:业务对象真相。
  • policyauthz、权限中间件:访问控制真相。
  • tokens.css、Tailwind theme、设计系统配置:视觉变量真相。
  • 状态机、工作流定义、队列任务协议:流程真相。
  • AGENTS.mdCONTEXT.md、ADR:工程约束和项目词汇真相。

这些文件的价值不只给人看,也给 AI 看。

一个 Agent 如果先读到了这些源头,它更可能做出小改动。如果它只看到一堆页面组件和 helper,它会继续模仿局部形状,补出更多局部副本。

这解释了为什么有些团队用 AI 写代码越写越乱,有些团队反而越写越稳。差别不一定在模型,而在代码库是否有可读的核心结构。

AI 没有读心术。它只能从上下文里推断项目规则。规则如果散在人的脑子里、聊天记录里、历史 PR 里,它就会猜。猜得越勤快,仓库越胖。

上下文要靠近核心

很多 AI 编程工具都在追求更大的上下文窗口。大上下文有用,但它解决不了源事实混乱的问题。

把整个代码库塞给模型,不等于给了它理解。很多时候,你只是把源事实、派生事实、历史遗留、过期实现、重复 helper、临时 patch 一起倒进上下文。

模型会学到什么?它可能学到最常见的模式,却没学到最正确的源头。

更好的上下文管理,应该像语义压缩:给不可约核,少给噪声副本。

例如让 Agent 修一个用户资料表单,最有用的上下文可能不是所有表单文件,通常是这些源头:

  • 用户对象的 schema。
  • 相关 API 契约。
  • 现有表单组件约定。
  • 校验规则的源头。
  • 权限边界。
  • 失败测试或期望行为。
  • 相邻页面中最接近的一个实现。

这些上下文能让它推导出剩下的细节。

反过来,如果只给它十个相似页面,它可能会复制第十一个页面。复制短期最快,长期最贵。

这也是 Agent memory 和 repo indexing 以后真正要解决的问题。目标不只是“记住更多”,还要识别哪些东西是核心,哪些只是核心的投影。

多 Agent 协作的关键是共享闭包

论文还讨论了多 Agent 情况。直觉很简单:如果两个 Agent 已经共享一部分知识,那部分知识就不用反复传。它们只要确认“我们都知道这个”,后面只传增量。

软件团队也是这样。

两个熟悉同一项目的人沟通很便宜。你说“按现有 billing policy 走”,对方知道 policy 在哪、边界是什么、哪些地方不能碰。一个新人听到这句话,可能要追问半小时。

Agent 之间也一样。多 Agent 协作要便宜,不能靠每次把所有背景重讲一遍,而要有共享语境:

  • 共享项目词汇。
  • 共享架构决策。
  • 共享接口契约。
  • 共享测试命令。
  • 共享权限边界。
  • 共享完成标准。
  • 共享“不该做什么”的负例。

AGENTS.md、ADR、系统设计文档、领域词汇表,看起来像文档,其实是通信压缩层。它们让每个 Agent 不必从零猜项目世界。

但这里有一个很容易被忽略的坑:词汇不对齐时,信道再干净也没用。

一个 Agent 说 workspace,另一个以为是 IDE workspace,项目里实际指租户空间。一个 Agent 说 account,另一个以为是登录账号,业务里实际指客户主体。传输没有噪声,理解照样错。

这就是语义瓶颈。

未来多 Agent 工程里,最重要的基础设施之一可能不是更长上下文。项目词汇对齐会变得很关键:同一个词在同一个代码库里只能有一个默认含义;如果有多个含义,必须在类型、命名和文档里拆开。

这套理论不能拿来偷懒

语义压缩的直觉很好,但它有明显前提。

第一,接收方要有推理能力。你不传第三条“苏格拉底会死”,是因为对方懂三段论。对方不懂,就推不出来。

对应到工程里,生成、推导、类型检查、测试、约束执行,都需要工具链。没有工具链,只说“这个能推出来”,就是把成本从代码转移到人的脑子里。

第二,推理有成本。理论上,从基础物理推出生物学也许可以想象;现实里没人这么工作。软件里也一样。过度抽象会让每次理解都要跑一遍复杂推理。那不是压缩,是折磨。

第三,闭包等价不等于工程等价。两个实现也许行为上等价,但一个更容易调试,一个性能更好,一个更适合排障,一个更能让新人读懂。工程里的“保真”不只有逻辑闭包,还有可读性、延迟、成本、风险和组织协作。

第四,冗余不总是坏事。

缓存是冗余。索引是冗余。日志是冗余。物化视图是冗余。防御性校验有时也是必要冗余。它们存在,是为了性能、排障、可靠性和安全边界。

关键不在于消灭所有冗余,而在于区分两种冗余:

  • 有治理的冗余:能从源头重建,知道何时刷新,有一致性机制。
  • 无治理的冗余:手写副本散在各处,没人知道哪个才是真的。

前者是工程手段。后者是债务。

给 AI 编程的一套落地规则

如果把这篇论文的启发转成 AI 编程流程,可以落成一组很具体的规则。

第一,改代码前先找源事实。

不要一上来写组件、helper 或 service。先找 schema、type、API 契约、数据库约束、已有组件、测试和 ADR。找不到源头,再考虑新增源头。

第二,新增代码前先声明它的身份。

这段代码是新的源事实,还是已有事实的派生物?如果是源事实,它应该放到核心位置,而不是藏在页面里。如果是派生物,它能不能生成、计算或复用?

第三,能计算的状态不存。

前端、后端、任务系统都一样。只要某个值能稳定从已有状态算出来,默认不要长期保存。除非你保存它是为了缓存、审计、性能或跨系统边界,并且有刷新机制。

第四,能由约束兜底的规则不要散写。

唯一性、非空、外键、金额精度、权限边界、状态转移,这些最好进入底层约束或统一 policy。上层可以做体验优化,但不能成为最终真相。

第五,能生成的类型和 client 不手写。

OpenAPI、GraphQL、Prisma、Zod、Protocol Buffers 这类工具,价值就在这里。它们让一个源头产生多个投影,减少人工同步。

第六,文档解释原因,不复制事实。

字段表、接口参数、枚举值,如果能从契约生成,就不要在手写文档里再维护一遍。手写文档应该讲设计理由、边界、例外和操作注意事项。

第七,给 Agent 明确“不写什么”。

可以直接把下面这段放进项目的 AGENTS.md

Markdown md
Before writing code:
1. Find the source of truth for the requested behavior.
2. Prefer platform APIs, existing schema, existing types, existing components, and existing constraints.
3. Do not duplicate validation, permissions, API types, or derived state.
4. If a fact must appear in multiple places, keep one source and generate or derive the rest.
5. Add new code only when it represents a new source fact or the minimal adapter around one.

这比“写优雅代码”有用。因为它告诉 Agent 哪些代码根本不该出现。

好代码库是可推理的代码库

讲到最后,这件事和论文里的“零成本传输”有一个共同底层:推理能力会改变信息成本。

一个学生会推导,老师就不用把每一步都念完。一个 Agent 理解项目核心,它就不用把每个派生文件都读完。一个代码库把源事实放清楚,人和机器就不用在副本之间猜哪个是真的。

这不只是让代码变短的审美偏好。它指向的是一种让系统变便宜的工程结构。

坏代码库像一堆散落的事实。每个页面、每个 helper、每个 service 都藏着一点规则。人要靠记忆同步,AI 要靠猜测补全。

好代码库像一组生成规则。核心事实少而清楚,派生结果能被计算、生成、验证、删除和重建。

AI 编程真正该追求的,未必是让模型一次吐出更多代码。更值得追的是让代码库本身变得可推理。

当仓库可推理,少写代码只是副作用。