一个模块今天一行源码都没有修改。

但是它依赖的库升级了,编译器换了版本,一个会影响构建结果的环境变量也发生了变化。

持续集成还能直接复用昨天的缓存吗?

如果只看源码版本,答案很容易是“可以”。但我们最终交付的不是源码目录,而是源码在一组具体依赖、工具链、配置和环境中形成的制品。只要其中真正参与结果的条件发生变化,原来的缓存就未必还对应现在的软件。

这类问题并不只出现在构建缓存里。

同一次变更还会继续带来更多问题:哪些下游需要重新构建?哪些测试结论已经失效?容器镜像里应该包含什么?流水线到底需要执行哪些任务?一个 Coding Agent 应该先阅读哪些代码?某个开源组件出现漏洞以后,哪些产品真正包含了它?两个版本为什么会产生不同制品?

前两篇文章分别从构建工程和整个软件工程出发,讨论了实现成本下降以后,为什么工程重心会逐渐转向变更处置能力(Change Handling)

但只知道工程体系应该识别影响、建立证据、控制边界,并对变更作出接受、调整、拆分、推迟、拒绝或者重新设计等明确处置,还没有回答一个更实际的问题:

这些能力怎样真正进入日常工程?怎样落地?

本文主要讨论变更处置所依赖的技术事实和可验证执行怎样建立。至于一项影响最终是否可以接受、由谁承担剩余风险,仍然属于前两篇文章讨论的决策边界,这里不再展开。

如果落地方式仍然是人工维护更多依赖清单、流水线、制品描述、供应链清单和 Agent 提示词,那么我们只是把原来的隐式知识重新复制到了更多地方。

真正需要改变的,是工程体系用什么对象描述软件,又让这些对象之间的关系怎样参与实际执行。

软件应该由什么样的工程事实描述

先建立工程单元

软件工程里最容易被当成基本对象的是源码文件、目录、仓库和模块。

它们很重要,却不是天然的工程边界。

一个编译器没有业务源码,却会直接改变大量制品。一个系统类库可能来自操作系统,却是软件运行时的一部分。一个环境变量看起来只是一个字符串,如果它参与编译选项或者特性选择,它同样可能改变最终结果。

所以,工程单元不能只是“软件模块”的另一个名字。

我更愿意把工程单元理解成:

工程体系需要对它建立独立身份、输入输出和变化边界,并且需要知道它与其他对象之间关系的东西。

它可以很大。

一个产品系列可以是一个工程单元,一个具体产品、子系统或者平台也可以。

它也可以逐渐变细,成为一个组件、模块、工具链、系统类库或者文件。在必要的时候,一个能够独立改变结果的配置项,甚至一个环境变量,也可以成为单独的工程单元。

工程单元也未必一定要编译出二进制。补丁、文档、测试报告、验证结果或者一组环境设置,都可以成为工程单元的产出。重要的是,每个工程单元都要有一个或者多个语义明确的输出,使直接下游知道自己实际消费的是什么。

粒度不是由代码量决定的。

真正决定粒度的是:这个对象是否需要被独立识别、复用、验证、替换和追踪变化,以及它是否存在值得单独表达的工程边界。

这里的“工程单元”也不是为了替代配置项、构建目标、制品或者测试任务等已有术语。它只是本文为了讨论这些不同层次的对象怎样在同一次变更中被识别、组合和追踪而使用的上位称呼。进入具体领域以后,仍然应该使用更准确的专业名称。

这实际上是一种很朴素的分而治之。它体现的是一种典型的工程思维:先把复杂系统拆成边界清楚、可以独立理解和验证的单元,再通过明确的关系把它们重新组织起来。

一个产品级工程单元不应该直接关心几千个源码文件。它只需要知道自己直接依赖哪些产品配置、平台或者子系统,以及这些直接上游向它提供了什么确定的工程结果。

子系统再关心自己的直接上游,模块继续管理更细粒度的依赖。

整个产品最终仍然形成一张完整的工程关系图,但每个工程单元只需要理解与自己直接连接的部分。产品级别的判断不用一直穿透到文件,文件级别的变化也不需要把所有内部细节暴露给产品。

细节可以封装,真实关系不能因此消失。

这和代码模块化的思路没有本质区别。不同的只是,我们把这种分而治之从源码接口继续扩展到了构建、工具链、环境、制品、产品和运行条件。

工程关系表达的是产出怎样形成

有了工程单元以后,接下来才是真正建立工程关系。

这里的关系不能只理解成源码里的 import、函数调用或者仓库依赖。

假设一个二进制由源码 A 编译得到。编译过程使用工具链 B,链接系统类库 C,通过配置 D 选择功能,又由环境输入 E 决定一个构建选项。

那么 A、B、C、D 和 E 都参与了这个制品的形成。

只画源码调用图,会漏掉真正能够改变结果的关系。

工程关系的主干,应该表达工程单元之间形成产出的因果和派生关系:一个单元依赖哪些直接上游,这些上游向它提供什么,它又在什么条件下形成自己的结果。

一个模块自己的源码没有变化,并不能说明它没有发生工程变化。如果它依赖的工具链、系统类库或者其他上游工程单元发生变化,那么“这份源码在什么条件下形成什么制品”已经和过去不同。

反过来,也不能简单地把机器上所有东西都算作输入。

一个环境变量如果根本不会被读取,就不应该因为它碰巧不同而让整个下游图失效。工程关系要表达的是实际因果关系,而不是把环境里能够观察到的一切都机械加入模型。

这也是工程模型需要追求的精确性:

该传播的变化不能漏掉,不参与结果的变化也不能被无意义地放大。

如果把软件暂时类比成一座乐高城堡,源码、工具链、系统类库和配置都可以看作参与搭建的积木。

知道用了哪些积木,还不能说明最终会得到什么城堡。

没有拼装手册,同样一批积木交给不同的人,在不同时间、不同地点组装,最后可能得到不同的结果。真正决定这座城堡是什么的,不只是积木清单,还有它们怎样组合。

工程关系图就像一份能够被机器理解的拼装手册。

它不只记录“有什么”,还记录“这些东西怎样共同形成最终结果”。

对于要求可复现的构建类工程单元,如果拼装手册和输入相同,执行过程又受到控制,就应该得到相同的结果。确实包含时间、随机性、签名或者其他副作用的产出,则需要显式表达它的非确定性,不能继续使用普通可复现结果的身份和缓存规则。

这里所说的完整工程关系,也不是声称工程模型已经知道真实系统中的一切。

它指的是,对于一个确定产出,当前模型中所有被允许影响它的因素都需要进入对应的因果边界。模型之外仍然存在的真实关系,需要在后续执行、失败和运行反馈中继续被发现。

从系统工程的视角看,这类围绕系统技术身份、组成关系及其变更的工作,可以放在技术状态管理(Configuration Management)的谱系中理解。

只是到了现代软件里,实际需要维护的技术状态不能只停留在源码版本和配置文件。依赖、工具链、环境、制品,以及这些对象之间的派生关系,都需要进入可以追溯和持续修正的工程模型。

单一代码仓可以为源码提供共同的事实来源,却不能单独回答最终制品为什么是现在这个样子。

工程状态描述的是一个单元当前到底是什么

今天的软件工程大量依赖人类可读的名字。

某个模块叫 foo,某个库版本是 3.2.1,某个镜像叫 latest,某次构建对应一个 Git commit。

这些名字适合沟通,却不足以决定一个工程结果是否真的相同。

这里至少要区分三个层次。

工程单元回答的是“这是哪个对象”。同一个编译器、模块或者产品,可以在不同时间形成多个工程状态。

工程状态回答的是“这个对象在什么源码、依赖、工具链、配置和环境条件下形成”。

产出内容则回答“这次实际得到了什么”。

同一个工程单元可以形成不同工程状态。两个不同工程状态也可能在某次执行中碰巧产生字节完全相同的制品,但它们的形成原因和来源事实仍然不同,已经建立的验证证据也不能因此自动互换。

所以,真正用于构建和来源判断的身份,需要包含所有会影响当前工程状态的输入。

可变的源码引用,在形成一次确定工程状态时应该收敛到确定内容;实际选中的工具链和系统类库需要具有确定身份;真正影响结果的配置和环境输入也需要进入这次工程状态。

这些事实不应该依靠工程师在另一张表里手工同步。

工程体系应该在实际解析和执行过程中把能够确定的内容自动记录下来。

秘密和敏感信息当然不需要因此保存成明文。重要的是留下足以判断身份和变化的受控表示,而不是用完整记录换掉原来的安全边界。

只要完整工程状态发生了变化,原来的状态就不能直接代表现在。即使新旧状态最终产生了相同内容,它们的因果来源仍然不同。

只有当下游明确只依赖一个经过定义和验证的输出边界时,才可以按输出内容复用,而不继续继承工程单元内部的全部推导差异。

封装降低的是下游需要理解和继承的细节,不是删除产出的来源事实。

一项变更怎样落到同一个工程对象上

如果工程关系只保存“现在是什么”,我们仍然很难回答长期研发中最常见的一个问题:

为什么两个版本不一样?

一个正式产出在形成时,应该同时冻结一份与它对应的工程关系快照。

这份快照不只包含源码版本。

它还包括真正参与该产出形成的直接和传递关系:依赖状态、工具链、相关系统类库、构建配置、目标平台,以及会影响结果的环境输入。

这样,一个制品就不再只是一个孤立的文件,而是和“它为什么会成为这个样子”绑定在一起。

当新的变更到来时,我们首先可以比较新旧两个工程状态。

某个模块自身源码没有变化,但是编译器变了,这会直接出现在差异里;某个系统类库升级了,可以沿关系继续看到哪些下游需要重新判断;一个会影响制品的环境输入发生变化,也不会继续藏在某台 CI 机器上。

所以,在真正执行构建以前,我们就已经能够知道:

哪些工程单元的推导条件发生了变化,旧结果为什么已经不能直接代表现在,以及变化会沿哪些已知关系继续传播。

这时候得到的是变化原因和当前模型能够计算出的影响范围。

真正执行以后,工程体系又会得到另一类事实:

实际产生了什么制品,完成了哪些测试和验证,又有哪些关系或者假设在执行中被证明不成立。

测试也需要进入同一套工程模型。

构建和测试可以位于同一个工程单元,也可以拆成不同的工程单元。怎样拆分,取决于它们是否需要独立执行、独立复用和独立形成证据。测试报告和验证结论本身也可以成为具有明确语义的产出。

如果一张产品工程关系图完整描述了软件怎样被制作出来,却没有任何工程单元产生测试或验证结果,那么它只建立了构建的因果边界,还没有建立软件是否符合预期的证据边界。

新旧工程状态之间的差异,因此可以形成一份比 Git Diff 更完整的工程变更。

代码变化只是其中一部分。

工具链变化、系统类库变化、依赖状态变化、环境输入变化、当前可计算的影响范围、新产生的制品、已经完成的验证,以及仍然没有解决的模型缺口,都可以属于同一项工程变更。

这也是前两篇文章里的两个问题真正落到同一个工程对象上的地方:

执行以前,工程状态告诉我们为什么旧结果失效、可能影响什么;
执行以后,同一个工程状态继续绑定实际结果和验证证据。

工程体系不再先靠代码差异猜影响,然后再去另一套流水线和报告里寻找证据。

变更的原因、传播和结果开始属于同一组工程事实。

工程模型是在执行中形成和修正的

建立这样一套模型,最现实的困难是:很多项目从来没有完整表达过这些关系。

构建脚本里有一部分,CI 里有一部分,容器定义里有一部分,开发者的机器和经验里还有一部分。

AI 在这里很有价值。

它可以阅读项目结构、构建脚本、流水线、依赖声明和现有文档,帮助识别可能的工程单元,提出候选关系,也可以在构建失败以后帮助分析当前模型可能漏掉了什么。

对于大型遗留系统,这会显著降低从脚本、文档和人员经验中恢复初始工程模型的成本。

但这里有一条不能越过的边界:

AI 可以帮助建立和补充工程关系,工程关系本身不能靠 AI 说“我认为是这样”成为工程事实。

人工也一样。

一个资深工程师说“这个模块应该只依赖这些东西”,可以是很好的建模起点,却不能代替客观验证。

AI 推断、人工声明和执行中观察到的关系,可以共同进入建模过程,但在经过实际执行或者其他独立验证以前,它们不应该具有相同的可信程度。

一个模型只有真正参与实际决定,才会持续承受现实压力。

构建顺序从工程关系推导,缓存复用根据工程状态判断,需要重新验证的范围从关系传播得到,真正使用的源码提交、工具链、依赖和环境条件也在这个过程中自动进入工程状态。

这时,工程模型不再是一份需要工程师定期同步的资产台账,而是软件正常构建、验证和发布过程中自然形成的事实。

真正的验证也必须客观。

对于构建类工程单元,如果声明的输入已经完整,那么在相同工程状态下应该能够独立得到相同结果;如果实际执行仍然读取了模型之外的工具、文件、环境或者网络资源,这种不一致应该由执行过程本身暴露出来。

人和 AI 负责提出对世界的描述,实际执行负责检验这份描述是否足以解释结果。

但一次执行成功不能证明模型已经包含所有真实关系。

模型如果漏掉了一条下游关系,也可能因为没有调度那个下游而暂时看不到失败。

所以,让工程模型参与实际执行,只是让它变得更容易被现实反驳,不是让它自动获得完备性。

对于日常开发,可以让构建、测试和持续使用不断暴露缺失关系。对于需要更强保证的场景,还可以定期进行完整构建,或者在不同时间、不同隔离级别和差异较大的环境中交叉验证,避免工程模型只看到自己选择执行的那部分事实。

隔离以后构建突然失败,某个看起来无关的修改影响了下游,Agent 总是在请求声明之外的工具,或者运行中出现了模型无法解释的结果——这些都不只是执行失败。

它们也是工程模型需要修正的证据。

因此,目标不是建立一张宣称永远完整的全局工程图。

真正需要的是一个可修正的工程模型:

人和 AI 提出候选关系;
实际执行把能够确定的内容变成工程事实;
封闭执行和交叉复现不断检验这些事实;
现实一旦证明模型解释不了结果,新关系重新进入模型,并改变下一次判断。

技术状态管理只能管理已经进入工程模型的技术事实。

工程体系还必须具备修正模型本身的能力。

一次三阶段自举怎样检验工程模型

我们第一次让 AI 在这个工程模型下做的一个复杂任务,是让 AI 从一个空白目录开始,完整建立 GCC 的源码自举构建。

这不是在一个已经准备好的项目里让 Agent 改几行代码。

它需要自己去找到 GCC 源码,识别源码怎样获得,继续寻找构建 GCC 所需要的依赖、工具和系统条件,再逐步把这些东西建立成工程单元和候选关系。

整个过程持续了一天多。

从外面看,AI 的工作过程并不优雅。

它很像在一个迷宫里探索。

走几步,发现缺了一个条件;补上以后继续走,又发现原来的路线不成立;有时候退回来一小段,有时候甚至回到很早的位置重新选择另一条路径。

工程关系也在这个过程中不断被补全。

新的依赖被发现,原来隐藏在环境里的工具被显式表达,一些最初认为正确的边界在真正执行以后又被修改。

最后完成的是 GCC 的三阶段自举构建,而不只是让 GCC “编译成功一次”。

真正重要的也不是 AI 最后告诉我们:

“我已经成功建立了 GCC 的构建关系。”

如果结论停在这里,这份工程模型仍然只是 AI 的一种解释。

我们把同样的工程关系拿到其他环境中,重新执行整个三阶段自举过程。

它能够按照相同关系再次完成。

这虽然不能从逻辑上证明工程模型已经包含所有可能存在的关系,但它形成了一份不依赖最初 Agent 和人工解释、能够被其他环境重复检查的证据。

而且,复现发生在不同时间、不同环境,还可以进一步进入不同隔离级别或者差异更大的硬件环境。环境差异越大,隐藏依赖在所有环境中同时保持不变的可能性就越低,证据强度也越高。

这个过程说明了一条很重要的边界:

AI 很适合帮助我们探索工程模型,但模型最终需要能够在没有原 Agent、也没有人工主观判断的情况下被独立验证。

否则,我们只是把过去依赖人的隐式经验,换成了依赖 AI 的隐式经验。

不要从整个组织开始

说到这里,很容易得到一个看起来合理、实际上很危险的落地方案:

先把公司的所有代码、工具链、平台、环境、制品和服务关系全部建模。

这样的项目通常很难完成。

范围越大,越容易先建出一套脱离实际执行的元数据系统。所有团队开始补字段,真正的缓存判断、CI 流水线和发布系统却继续沿用旧逻辑。

更合理的起点,是选择一个已经反复产生真实成本的问题。

可以先从缓存开始。

选一个经常错误失效或者错误复用的构建单元,把源码、直接上游、工具链、配置和环境真正纳入工程状态。如果模型正确,它应该直接改变缓存判断。

也可以从持续集成开始。

选择一条很长、每次修改都需要跑大量无关任务的流水线,先建立能够计算受影响工程单元的关系,再让下一次变更的流水线直接由这个关系生成。

还可以从大型代码库中的 Agent 定位开始。

先给一个产品或者子系统建立足够可靠的工程边界,让 Agent 从候选工程子图开始,而不是从整个仓库开始搜索。

或者从供应链开始。

选一组长期难以确认影响范围的第三方依赖,让每个正式制品都能够回答自己实际使用了什么,以及一个有风险的组件究竟会沿哪些关系进入哪些产品。

关键不是从哪个案例开始。

关键是第一份工程模型必须控制一个真实决定。

如果模型只是供人查看,它迟早会成为另一张过期架构图。

如果缓存、CI、制品生成、组件定位或者 Agent 工作边界真正依赖它,错误和遗漏就会在使用过程中不断暴露出来。

局部关系稳定以后,再沿直接上游和下游逐渐扩展。

产品内部稳定以后,再扩展到跨产品、跨平台和系统之系统的技术状态关系。

这是一条从真实工程事实逐步扩大的路径,而不是先设计一个覆盖整个组织的理想世界。

同一组工程事实怎样进入实际研发

到这里,真正需要建立的基础已经比较清楚了:

工程单元表达对象,工程关系表达产出因果,工程状态固定一次具体条件,状态快照保存历史事实;执行以前,新旧状态差异给出变化原因和当前可计算的影响,执行以后,实际结果和验证证据继续绑定到新的状态。

接下来的问题不再是继续扩展这套模型,而是已有的工程系统怎样使用同一组事实。

持续集成、制品和供应链、Coding Agent 关心的问题并不相同,所以它们会形成不同的视图,也不能只凭图上的可达性使用同一种判断规则。

但它们不应该再分别维护第二份“这个软件是什么、由什么形成”的事实。

构建和持续集成

今天很多 CI 系统的基本对象是一份长期存在的流水线。

工程师维护一个 Jenkinsfile、GitHub Actions Workflow 或其他任务定义,然后不断往里面增加条件:

某个目录变化时运行 A,另一个平台运行 B,只有修改公共组件时才执行 C。

随着产品、平台和测试越来越多,这些条件最终又形成一套独立的依赖系统。

它需要长期维护,也很容易与真正的软件结构发生漂移。

如果工程关系本身已经能够回答:

这次变更使哪些工程单元的工程状态失效了?

那么流水线没有必要继续承担依赖建模职责。

对于每一项候选变更,工程体系可以根据新旧工程状态之间的差异,自动得到本次必须重新建立结果的构建和测试单元,再生成一份只属于这次变更的执行计划。

执行计划中的任务与工程单元对应,任务之间的依赖关系也直接来自工程单元之间的真实关系。

应该并行的任务可以并行,必须等待直接上游结果的任务才进入等待。流水线不再依靠人工经验把若干模块捆进一个任务,或者用大量条件近似描述真实依赖。

在日常增量流水线中,没有受到这次变更影响、已有结果仍然有效,也没有被独立验证要求重新执行的工程单元,不需要进入本次执行计划。

一个公共工具链发生变化时,它可能接近完整流水线;一个边界良好的局部修改,则可能只需要运行完整工程图中的很小一部分。

进入这条流水线的每个构建任务都有明确原因:

它对应的工程状态因为本次变更而失效,需要重新建立结果。

不再需要把大量任务先塞进固定流水线,然后再通过 if、路径判断和环境猜测决定“今天到底要不要跑”。

针对这次变更生成的流水线实例执行完以后甚至可以删除。

长期需要保存的是产生它的工程关系、执行计划生成规则、对应工程状态和实际执行记录,而不是一张需要人工持续维护的固定任务拓扑。

CI 从一份长期人工维护的任务图,变成了一次具体变更在当前工程关系上的执行视图。

这会同时改变几个成本。

首先,流水线本身的维护成本下降。

其次,每次变更只需要调度真正受影响的工程单元,执行规模下降。

更重要的是,真实并行度会提高。

流水线配置了多少并发,不等于真正获得了多少并发。人工编排的任务通常混入了团队划分、历史兼容和过去的性能优化。一些本来没有依赖关系的模块被放进同一个大任务,只能串行执行;另一些存在实际先后关系的工作,又可能为了缩短表面耗时被安排成并行,从而产生偶发且不稳定的构建失败。

当任务边界和依赖顺序直接来自工程单元的实际关系以后,流水线可以更接近工程上能够安全达到的并行上限。

构建环境也会随之变化。

传统流水线经常把产品和环境绑定在一起。产品 A 使用一套构建镜像,产品 B 维护另一套镜像;某个平台依赖特殊编译器,就长期保留一组专用执行节点。

如果每个工程单元已经明确知道自己真正需要哪些工具链、系统类库、配置和平台条件,执行基础设施就不必再长期“扮演某个产品的机器”。

相同的通用构建基础设施可以承载更多原本环境不同的工程单元,每个单元在执行时获得自己明确声明的条件。

容器镜像也不必继续作为大量人工维护的环境模板存在。构建需要的环境和运行制品需要包含的依赖,都可以根据对应工程关系生成。

这也意味着,本地构建和 CI 不应该具有不同的软件语义。

CI 可以运行在不同机器上,本地也可以有完全不同的宿主环境。只要真正参与结果的输入相同,并且执行没有越过声明边界,构建类工程单元的工程状态和结果就应该相同。

CI 中由工程输入导致的问题应该能够在本地复现。

如果相同工程状态仍然反复出现不稳定失败,就应该继续寻找尚未声明的关系,或者把问题归到硬件、存储和调度等更底层的基础设施,而不是笼统地归因于“CI 环境不一样”。

反过来,本地已经完成的构建结果,在工程状态一致、结果经过客观验证并满足当前信任策略时,也可以被 CI 安全复用。

某些高信任场景仍然可以要求在独立受控环境中重新构建,但那是在增加独立证据,而不是因为本地和 CI 天生属于两套不同的软件。

一个 900 多模块的产品

这些变化在一个拥有 900 多个模块的大型产品中表现得很直接。

这个产品原来长期维护一条完全由人工编排的持续集成流水线,大约有 200 多个任务节点,任务之间又有复杂的依赖关系。

有的构建任务只负责一个模块,有的会一次构建多个模块。

为什么这样划分,并不是从产品真实工程结构直接推导出来的,而是在多年的团队划分和性能优化中逐渐形成的。

流水线维护者会根据经验,把若干模块合并进一个任务,也会调整任务之间的并行关系。

但这套编排并不等于产品真正的工程关系。

有些本来可以并行的模块被放进同一个大任务,只能依次构建;有些存在实际先后关系的任务,又因为一个耗时长、另一个耗时短,被人为安排成并行,偶尔会产生难以稳定复现的构建失败。

更麻烦的是,全局结构和局部细节很难同时集中在一个人身上。

团队里只有极少数工程师对整个流水线有比较完整的认识,但他们不可能同时理解每个任务内部具体怎样构建。各个研发团队又往往有人非常熟悉自己负责的局部任务,却缺少整个产品的全局关系。

因此,每一次较大的流水线优化都要重新协调这两类知识。特性分支里如果修改了流水线,最后除了代码要合并,另一套人工维护的流水线逻辑也要继续合并。

我们后来按照独立工程单元重新建立了整个产品的关系。

第一次直接从工程关系生成完整流水线时,得到的是 900 多个任务。

(这个复杂流水线的一个局部)

这次完整流水线甚至比原来 200 多个任务的人工流水线还慢。

原因很直接:工程单元的粒度细了,调度节点从 200 多个增加到 900 多个,调度器、执行资源和任务启动本身都产生了额外负荷。

这件事反而说明,仅仅把模块切得更细,并不会自动得到更高性能。

真正的变化发生在第一次完整运行以后。

这些工程单元已经形成可以安全复用的结果以后,后续每一次变更就不再需要生成 900 多个任务。

新旧工程状态一比较,可以直接知道哪些工程单元需要重新建立结果,以及变化会沿哪些下游关系继续传播。

在实际研发中,大多数日常变更最终影响的工程单元大约只有 30 个左右。

工具链或者公共基础模块变化时,影响范围仍然可能接近完整流水线。这是正常结果,原来的团队遇到这种变化时同样会主动进行完整构建。

新的体系不是让所有变更都变得局部,而是不再把每一次普通变更都当成全局变化处理。

于是,一条日常变更的流水线从原来的 200 多个人工任务,通常收缩成大约 30 个真正需要重新建立结果的任务。

新的任务通常还比旧任务更小。

旧流水线的一个任务经常同时构建多个模块,新流水线中的任务则直接对应已经声明的工程单元。任务之间的依赖顺序也与工程单元之间的实际依赖一一对应。

可以并行的工作不再被大任务强行串行,不能并行的工作也不会因为人工平衡耗时而被错误地同时执行。

这里所说的“数十倍”,并不是由 200 ÷ 30 推导出来的精确比例。

它描述的是开发人员最直接感受到的结果:代码推送以后,从开始集成到完成构建的等待时间缩短到了原来的很小一部分,在大多数日常变更中表现为数十倍量级的提升;执行过程中占用的构建资源,也下降到了原来的数十分之一量级。

造成差异的不是一个孤立的性能技巧。

需要执行的任务更少,单个任务更小,任务依赖更准确,安全并行度更高,错误并行造成的不稳定失败也随之减少。

同一批构建资源完成一项变更以后,很快可以被下一项变更或者另一个项目继续使用,资源周转率明显提高。

持续集成团队的工作也随之改变。

他们不再把主要精力花在维护一张 200 多节点的人工任务图,以及不断调整“这个任务到底负责哪些模块”。

真正值得维护的是工程单元的边界:哪些输入决定它的工程状态,它依赖哪些直接上游,向哪些直接下游提供结果。

流水线变成了这些关系自动生成的结果。

一次只改 CMake 的对照

这种性能差异并不只来自增量构建和缓存。

在另一个 CMake 构建工程里,我们只重构了 CMake 文件,没有修改源码、工具链或者需要构建的模块。

新旧两套构建都运行在同一台 32 核机器上,都限制为 8 个并发,构建相同模块,其他条件保持一致。

新的构建框架耗时只有旧框架的四分之一。

外部设置的并发数相同,不代表实际得到的并行度相同。

旧框架中存在人工形成的任务边界和先后关系,很多时候没有足够的就绪任务填满 8 个执行位置,或者一个大任务内部仍然串行。

按照实际工程关系重新组织以后,更多真正独立的任务能够同时进入执行,关键路径明显缩短。

这个案例基本隔离掉了增量缓存、变更范围和机器差异,留下的主要就是任务关系本身。

它说明,在复杂工程中,人工编排能够达到的并行上限,通常低于根据真实工程关系能够得到的可达上限。

制品和供应链

工程关系快照在制品侧首先解决的是来源问题。

如果一个正式制品绑定了形成它的工程状态,那么我们不仅知道“这个文件是什么”,还能够知道它来自什么源码、使用什么工具链、经过哪些依赖和配置形成。

所以,对比两个软件版本也不必只比较 Git Diff。

我们可以比较两个制品各自绑定的工程关系快照。

源码没有变化但编译器变了,会直接出现在差异里;系统类库升级了,可以继续沿关系看到它进入了哪些制品;一个会影响结果的环境条件发生变化,也不会继续隐藏在构建机器里。

同样的事实还可以直接成为软件供应链信息的重要来源。

用了哪些第三方组件,具体是什么版本,通过什么关系进入哪个制品和产品,由什么工具链生成,这些内容本来就已经存在于形成制品的工程事实中。

当某个开源组件出现漏洞时,也不需要只根据仓库搜索判断“哪些项目可能使用过它”。

可以从这个工程单元出发,沿下游关系找到哪些制品包含或者依赖它,哪些产品继续使用这些制品,哪些历史版本的工程状态中存在这个组件。

这里首先解决的是:

这个组件究竟在哪里,经过什么关系进入了哪些产出。

至于是否需要立即清除、重新发布或者采用其他临时处置,是后续风险判断。

同一组工程事实还可以继续支持来源证明、第三方许可证和制品组成分析。

前提仍然是这张图足够真实。

如果模型漏掉了一个宿主系统偷偷提供的动态库,那么供应链视图同样会漏掉它。

所以,供应链信息的准确性最终还是回到最初的问题:形成制品的关系是否真正进入了工程模型,并且能够被客观验证。

从源码可信继续走到制品可信

对于运营商、政府、军方、航空航天、汽车、医疗设备等具有较高安全和审查要求的软件,仅仅交付源码供人检查,通常还不够。

即使审查方已经检查过源码,没有发现已知漏洞或者恶意代码,仍然存在一个必须继续回答的问题:

真正部署到产品上的软件,确实是由这份经过审查的源码构建出来的吗?

如果这个关系无法被证明,那么源码审查和最终产品之间仍然断着一段。

而且,即使能够说明最终制品来自这份源码,构建过程本身仍然可能引入新的不确定性。

源码使用了哪个编译器?链接了哪些系统类库?经过了哪些代码生成器和补丁?构建过程中读取了什么环境变量?有没有从宿主机器取得没有声明的文件?有没有临时使用另一套工具,或者从网络获得额外输入?

只要这些因素还有一部分依赖“构建机器当时就是这样”,就很难说明最终制品到底是怎样形成的。

工程关系图可以把源码审查继续向最终制品延伸。

源码是其中的工程单元,工具链、系统类库、构建配置和其他会影响结果的条件同样进入关系图。每一步产生的结果又成为下一步的明确输入,直到形成真正交付到产品里的制品。

这样,我们得到的不再只是:

这份源码经过了审查。

还可以继续回答:

这个最终制品由哪一份源码形成,经过了哪些工程单元,每一步使用了什么输入,又产生了什么输出。

构建过程本身因此也能够进入审计范围。

但更重要的一步仍然是独立复现。

如果这份工程关系能够在另一台机器、另一个时间,甚至差异较大的隔离环境中重新执行,并再次得到相同的指定制品,那么第三方就不需要只接受供应商关于“这个二进制就是由那份源码构建出来的”这一声明。

它可以自己重新执行这条关系,并比较得到的结果。

验证强度还可以继续提高。

对一般研发,能够在受控环境稳定重建可能已经足够。对安全等级更高的场景,可以进一步使用不同时间、不同隔离级别甚至异构环境进行交叉验证。每增加一种独立检查,我们都在继续缩小那些只能依赖信任、却无法验证的范围。

这并不意味着建立工程关系图以后,软件就自动变成“绝对可信”。

源码审查仍然可能遗漏问题,工具链可能存在缺陷,测试和验证本身也存在边界。

真正发生变化的是,原来只能通过组织信誉、流程声明和人的经验来连接的几个环节,逐渐变成可以被独立检查的工程事实。

从:

这份源码是可信的,请相信最终软件就是这样构建出来的。

逐渐变成:

可以检查源码,可以追溯它怎样形成最终制品,可以审计中间每一步,还可以在独立环境中重新得到同样的结果。

这时,我们才开始从源码可信、构建过程可信和制品来源可信,一步步逼近端到端的可信。

真正的可信,不是要求别人相信你的结论。

而是让别人能够独立验证你的结论。

Coding Agent

工程关系对于 Coding Agent 的第一个直接价值,是给大型项目提供一个比全局搜索更有结构的起点。

在小仓库里,从需求里的关键词开始搜索,再沿调用关系阅读几个文件,通常可以工作。

到了大型项目,这种方法很容易失效。

Sourcegraph 对 1,281 次 Agent 运行的分析里,反复出现的失败包括在代码库中不断游走、选错文件或符号、只完成局部修改、反复搜索和回退,以及读入大量无关代码以后耗尽有效上下文。

CodeScaleBench也把大型代码库中的代码修改,与文件、符号和依赖关系的发现及理解分别评测。代码生成能力和找到完整工程上下文并不是同一件事。

工程关系能提供的,不是另外一种全文搜索。

它更像一张地图。

如果让 Agent 在一个陌生城市里找一栋楼,传统做法有点像告诉它城市名字,然后让它从整个城市开始逐条街搜索。

完整的工程关系提供的是另一种路径。

任务先落到一个产品或者产品系列,再进入相关子系统,然后根据关系继续缩小到平台、模块、组件、目录、文件和具体符号。

它不是一下子把整个城市的全部细节塞给 Agent,而是像:

城市 → 街区 → 街道 → 小区 → 楼栋

一样,沿已有工程结构逐步收敛搜索范围。

这也是为什么大粒度和小粒度工程单元都需要存在。

如果图里只有文件,Agent 仍然必须从数十万个文件开始理解全局;如果只有产品级节点,又无法继续落到具体实现。

到了足够小的工程范围以后,语义检索、符号引用、调用分析和代码阅读再继续工作。

工程关系负责回答:

先往哪个方向走。

代码智能继续回答:

到了这里以后,具体是哪一个实现。

Agent 最后才判断应该怎样修改。

找到候选位置以后,工程关系还可以进一步呈现不同修改位置已经能够确定的结构差异。

修改公共组件可能代码很少,却影响大量下游;修改适配层可能代码更多,但传播范围更小;增加一个新工程单元又会形成新的长期关系。

这里不需要让系统自动选择“影响最小”的方案。它的作用是让这些结构后果在实现以前就能够进入判断,而不是等代码写完以后才发现传播范围。

工程单元明确以后,同样的边界还可以成为 Agent 的工作边界。

Agent 可以读取这个单元需要的源码和直接上游结果,使用已经声明的工具,在限定范围里持续搜索、修改、构建和验证。

如果任务需要新的工具、额外网络访问或者模型中没有声明的依赖,它不应该通过临时安装软件、修改 PATH 或直接改变机器状态完成任务。

它应该提出一项新的工程变更。

这使工程边界的价值不再只是“限制 Agent”。

恰恰因为边界足够清楚,边界内部反而可以给予更高自治,把少量真正需要升级的边界变化和大量可以自主完成的工作分开。

在最近一次实际开发中,这种反馈已经出现过。

Agent 完成了一版修改以后,新的工程状态显示,这项修改影响到了原本不应该受到影响的工程单元,传播范围比任务预期明显扩大。

Agent 根据这个变化重新判断了自己的实现,主动撤回之前的提交,换了一条实现路径重新修改。

等我们注意到这个过程时,它已经完成了修正。

这个案例真正有价值的地方,不是“AI 自己发现错误”。

它发现问题依靠的不是重新读一遍自己刚写的代码,然后再次使用同一套理解判断自己对不对。

工程关系给了它一个独立于代码文本的反馈:

你这次修改造成的工程传播,和任务原本应该触动的范围不一致。

代码层面的实现判断和工程层面的影响判断因此不再是同一个信息源。

搜索帮助 AI 找到代码。

工程关系还能在修改以后告诉它:

你实际上改变了什么。

同样的边界也会改变一部分人的协作方式。

在工程状态传递这一层,上游不必把所有内部细节无差别广播给所有参与者,只需要向真正依赖它的下游提供确定的工程状态。

这不能消除跨团队设计、产品决策和非局部质量属性管理,但可以减少大量原本依靠会议、群聊和人工同步完成的状态传播。

这些能力并不是 AI 时代才出现的

走到这里,很容易把这些做法理解成一套“AI 时代的新工程方法”。

其实并不是。

工程单元一直都存在,只是过去可能叫产品、组件、目标、工具链、制品或者环境。

工程关系也一直都存在。

一个构建工程师知道哪个编译器会影响哪些产品,一个发布工程师知道某个系统库升级以后哪些版本需要重新验证,一个平台工程师知道哪条依赖改变以后哪些流水线会失效,一个维护多年的工程师能够迅速判断某个看起来很小的修改实际上会碰到哪些历史关系。

前面那个 900 多模块产品中的旧流水线,其实也是这些关系存在的证明。

只不过它们被压缩到了 200 多个人工任务里,又进一步分散在少数全局流水线维护者和各个团队的局部经验中。

这些知识过去大量存在于脚本、流程、文档和人的经验里。

它们一直参与软件研发,却没有总是被作为一套统一的工程事实表达出来。

有点像软件工程里的“暗物质”。

我们能够从很多现象中知道它存在:缓存为什么会失效,为什么某个修改需要全量测试,为什么这个产品只能在那套环境构建,为什么某个资深工程师一眼就知道不能改这里。

但真正的关系本身经常不可见。

过去实现速度比较慢,人的经验还有时间在这些缝隙之间补足模型。

AI 把实现速度迅速提高以后,这些隐含知识越来越难继续靠人临场填补。Agent 不会天然拥有某个工程师十年积累下来的项目直觉,CI 也不会因为代码生成更快就突然理解整个产品的真实关系。

因此,AI 时代真正暴露出来的,并不是一种过去不存在的新工程需求。

它让我们更难继续忽略那些一直存在、却没有被完整表达的工程事实。

与此同时,AI 也显著降低了从遗留脚本、文档和人员经验中恢复初始工程模型的成本。

不是增加流程,而是改变工程事实怎样产生

这条路径并不要求所有团队采用同一套工具,也不要求把所有状态放进一个系统。

需要统一的是工程身份、关系语义和可验证接口,不是物理存储位置或者工具实现。

真正需要建立的是这样一套结构:

软件由可以独立识别和组合的工程单元构成;单元之间建立能够表达产出因果关系的工程关系;每个单元的工程状态由真正参与结果的输入决定;正式产出绑定对应的工程关系快照;新旧状态之间的差异形成工程变更;实际执行继续产生结果和验证证据,并反过来修正原来的工程模型。

构建、缓存、持续集成、制品管理、供应链和 Agent 不需要因此变成同一个系统。

它们只需要共享同一组已经能够被验证的工程事实,再按照自己的语义使用这些事实。

这并不是建立一个更复杂的元数据世界。

恰恰相反,它是在减少同一件事情被构建脚本、CI、容器镜像、制品库、安全系统、Agent 和人的记忆重复描述。

前两篇文章讨论的是一项变更怎样被理解、验证和处置。

到这里,真正落下来的基础是:

先让工程体系准确保存这个软件为什么成为现在这样,再让每一次变化都沿这些事实被发现、执行、验证和修正。

实现可以重新生成,一次流水线实例可以随时删除,构建环境可以重新建立,容器镜像也可以重新派生。

真正需要长期留下来的,是那些决定结果的工程单元、它们之间可验证的关系,以及现实不断迫使我们修正这些关系的证据。

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.