准备删除一层看起来多余的接口转换时,我们通常会先查它是什么时候引入的。

Git 能找到对应的提交,看到当时修改了哪些文件,也可能找到关联的需求和测试。可是,代码看起来仍然很奇怪:内部已经有一份数据结构,为什么还要维护另一套差不多的表示?

注释写着“兼容旧版本”。

哪个旧版本?它现在还在使用吗?当时是为了兼容已经发布的客户端,还是某个工具暂时不支持新的格式?有没有考虑过其他方案?如果直接删除这一层,究竟会破坏什么?

这些问题的答案会直接改变今天的判断。

假设当年的设计记录已经说明,这层转换是为了保护 A、B、C 三类旧客户端。现在能够确认 A 和 B 已经退出,却找不到足够证据说明 C 是否还在使用。

我们已经知道了这段历史,却仍然不能直接删除代码。

历史在这里提供的不是答案。它只是把原来模糊的问题缩小成了一个今天真正需要确认的事实:

C 现在还需要这项兼容吗?

在前面关于一项实现怎样取得进入系统的资格的讨论中,我们已经区分了实现能够工作和变更可以被接受。工程体系需要理解影响、取得相应证据,也需要在新的结果推翻原有判断时,让决定重新进入评估。

沿着这个问题继续往下走,还会遇到另一层困难。

即使软件的状态、变更和验证结果都保存得很好,我们也未必知道:当时为什么选择这样改变系统,这条路是怎样找到的,以及过去发生过的事情能不能帮助我们作出下一次判断。

这并不是 AI 出现以后才有的问题。

不同的是,当越来越多研发工作开始由 Coding Agent 参与完成,过去大量散落在人的记忆、日志和不同工具里的探索过程,也开始有机会更连续地留下来。

于是问题进一步变成:

什么样的历史,才真的能够成为以后可以使用的工程经验?

版本记录没有保存全部历史

一个版本接着一个版本,很容易让我们把软件历史理解成变化沿时间的累积。

这种累积当然存在。

对研发来说,最容易理解的是当前这个时间点。

一个需求实现以后,我们首先会问:代码写对了吗?测试通过了吗?需求是不是已经完成?这个 commit 能不能合并?

这些问题都很重要。它们关心的是软件在当前这个点上是否成立。

但是软件不会停在这个点上。

今天合进去的代码,明天还会继续修改;一个依赖会继续升级,一个接口会出现新的调用者,一组测试也要继续帮助后来的人理解原来的行为。无数次实现、修复、迁移和重构沿着时间不断发生,最后形成今天看到的软件系统。

所以,当我们把视线从一个点拉长到一段时间以后,问题也会发生变化。

我们开始关心:这个依赖有没有被恰当地隔离?测试是在验证行为,还是已经和当前实现绑得太紧?这个设计以后容易修改吗?今天增加的关系,会不会让几个月以后一次普通变更需要同时修改很多地方?

一个小的决定在当下可能几乎没有成本,经过后面的变化却可能逐渐变成系统不能忽略的约束。

从这个意义上说,软件工程今天面对的系统,就是过去无数次编程、设计、决策、迁移、妥协和变化留下来的结果。今天接受的变化会沉淀成历史,这些历史又成为明天继续变化时必须面对的条件。

但如果只看到这种沿时间的累积,仍然没有看到完整的历史。

假设一个内部模块准备向外部系统提供接口。

直接暴露内部数据结构,通常是最容易实现的方案。另一个方案是在内部结构和外部接口之间增加一层转换,让两边使用不同的表示。还可以暂时不开放接口,先解决更小范围内的问题。

在当时,这些方案可能各有理由。


                 ┌─ 直接暴露内部结构
当前状态 ─────────┼─ 建立独立的外部接口 ──→ 新状态
                 └─ 暂时不开放接口

最终真正采用的只是其中一条路径。

知道这条路径本身当然有价值,但还不够。以后重新面对同一个接口、依赖或者架构边界时,我们真正需要知道的是:当时为什么认为这条路更合适,那些条件今天还成立吗?

知道过去为什么这样选择,不是为了替过去补一份解释,而是为了让今天能够更准确地判断哪些理由仍然有效、哪些已经消失,以及这次新的选择又会给未来留下什么条件。

如果只看最终合入的实现和版本差异,我们通常能够知道系统最后走到了哪里,却很难重新看到当时还存在过哪些选择,以及为什么有些路没有继续走。

更重要的是,这次选择改变的不只是当时的代码。

如果内部字段直接进入公共接口,外部客户端就可能开始依赖这些字段的含义。以后内部模型需要变化时,团队必须同时考虑客户端兼容。

增加转换层会提高当前实现和维护成本,却可能让内部结构继续独立演化。

两种方案今天都能工作,也可能通过同一组功能测试,但它们给下一次变化留下的条件不同。

所以,软件历史不能只理解为变化数量的累积。

每一次选择都发生在具体条件下,也会改变后续选择的条件。

如果用“点、线、面”来帮助理解,可以把它们都放在状态变化里来看。

点是一个具体状态。

在研发中,它首先对应我们正在处理的这个版本、这次修改和当前结果:实现是否正确,测试是否通过,当前状态是否可以被接受。

线是这些状态沿时间实际走过的演化路径。

S0 → S1 → S2 → S3

一次次实现和变化不断沉淀下来,软件就是这样一步步成为现在这个样子。把视线放到这条线上,我们关心的已经不只是每个点能不能工作,还包括系统经过这些变化以后是不是仍然容易理解、修改和继续演进。

但每一个状态周围,其实还存在其他可能的选择。

             S2a
            /
S0 → S1 ── S2b
            \
             S2c

最后真正走过的只是其中一条。

这里可以把这些可能的方向理解成一个“面”。它不是一套需要精确定义的状态空间模型,只是在提醒我们:只知道过去做了什么、最后得到了什么还不够。

我们还需要知道,当时面对什么条件,为什么认为这个方案更合适,哪些路被放弃了,又有哪些选择改变了以后继续演化的空间。

单纯的时间轴可以告诉我们变化什么时候发生、前后顺序是什么,却不足以解释这些事情。

今天的选择,怎样成为明天的条件

在讨论候选变更及其长期后果时,前文区分了变更负荷和承诺成本。

变更负荷关心这次修改需要处理哪些已有关系。承诺成本关心变化被接受以后,会留下哪些需要继续维护、兼容、迁移或者结束的关系。

从历史的角度看,这两个概念把过去、现在和未来连接了起来。

过去的选择
    │
    ▼
形成今天的状态、关系和承诺
    │
    ▼
成为今天变化需要面对的条件
    │
    ▼
今天作出新的选择
    │
    ▼
改变未来可以怎样继续变化

过去形成的数据、依赖和承诺,会成为今天变更负荷的一部分。

今天接受的变化,又会改变以后继续变化的条件。

但这并不是简单的成本累积。

一个稳定接口可能限制内部语义随意改变,同时又让更多模块能够独立演进。一次迁移会产生当前成本,也可能结束一项长期承诺。兼容层增加一层实现,却可能在一段时间里保留新旧系统同时存在的选择。

选择可能关闭一些路,也可能打开新的路。

工程要判断的,不是怎样保留最多选择,而是为了得到什么能力,值得接受什么约束。

这也给技术债和重构提供了另一个观察角度。

允许模块直接读取另一个模块的内部状态,眼前可能只少写几行代码。后来越来越多调用者沿用这种做法,替换底层模块就需要同时修改多个上层系统。

问题不仅是维护成本一点点增加。

原来可以独立完成的变化,现在需要很多部分一起行动。

反过来,一次重构可能没有增加功能,也没有提高性能,甚至暂时增加了代码量,但它重新建立了边界,让几个部分恢复独立变化的能力。

它的价值,要到后续变化发生时才能真正看见。

历史也不等于技术债。

一个被客户长期使用的接口、一种保存了重要数据的格式,都可能是合理选择产生的正常承诺。软件如果持续提供价值,就很难不建立这些关系。

即使后来发现维护两套接口表示确实产生了明显成本,也不能因此直接说当年的决定错了。

团队可能原本就知道需要维护两套表示,并明确接受这项代价,用来换取内部实现和外部协议之间的隔离。

后来的事实真正能够告诉我们的,是实际成本究竟有多大,是否符合当时预期,以及原来想保护的性质是不是真的得到了保护。

这也意味着,如果以后还需要重新理解这项选择,只有最后一句“当时选择了这个方案”通常是不够的。

我们还需要知道,当时接受了什么代价,希望保护什么,以及后来应该用什么事实重新看待这个决定。

软件在变化,我们对软件的认识也在变化

到这里,我们讨论的主要还是软件本身怎样形成历史。

但是,如果过去的选择只在当时那些条件下成立,而软件又会继续变化,那么几年以后重新面对同一个问题时,只知道系统发生过什么仍然不够。

我们还需要知道:

当时为什么认为这些条件重要?

以及:

后来的事实有没有改变这种认识?

因此,软件研发里其实存在两种相互影响的历史。

一种是软件和工程状态本身怎样变化。

S0 → S1 → S2 → S3

另一种则是我们对这些状态和关系的认识怎样变化。

M0 → M1 → M2 → M3

两者并不总是同步。

软件本身可能没有变化,但一次排查已经改变了我们对它的理解。

反过来,认识发生变化以后,我们又可能修改声明、工程边界或者实现,进一步改变软件状态。

这也是为什么只保存最终版本,很难完整保存工程经验。

最终版本告诉我们系统最后变成了什么,却很少完整记录:我们怎样逐渐知道它应该成为这样。

最后成功的路径还不是全部

设计文档、ADR、评审和决策记录可以保存一部分理由。

但还有一些认识,在得到最终方案以前就已经随着探索过程消失了。

在前一篇记录的 GCC 三阶段自举实践中,我们让 AI 从空白目录开始,寻找源码、依赖、工具和系统条件,逐步建立工程单元及其关系。

整个过程持续了一天多。

它并没有先得到一张完整地图,再沿着地图执行。

实际过程更像在迷宫里探索:发现缺少一个条件,补上后继续;新的执行结果又说明原来的路线或者边界不成立,于是退回去,修改关系,再试另一条路径。

初始理解
    │
    ├─ 尝试一条路径 → 失败 → 检查结果
    │                          │
    │                          └─ 修正原来的理解
    │
    ├─ 尝试另一条路径 → 发现新的条件 → 继续调整
    │
    └─ 形成可执行的关系 → 在其他环境重新检验

最后,同样的工程关系能够在其他环境中重新完成三阶段自举。

最终正确的构建关系当然重要。以后再执行,不应该继续依赖最初那个 Agent 的记忆。

但是,最后的构建关系没有自动保存找到它的过程。

如果只保留最后成功的声明,我们能够知道以后怎样构建,却不一定知道最初为什么认为另一条路也应该成立,哪次失败暴露了遗漏,又是什么事实迫使原来的边界发生改变。

失败本身当然不是经验。

一次失败可能来自方案本身,也可能来自版本、缺失条件、错误调用或者临时故障。

真正有价值的是:

这次失败为什么改变了原来的判断。

同样,经验也不只来自失败。

如果一个重要关系在不同时间、不同环境里反复得到独立验证,判断本身虽然没有改变,我们却有了更多理由继续相信它。

GCC 能够在其他环境重新完成同样的三阶段自举,就属于这种情况。

探索过程既可能推翻原来的理解,也可能让原来的理解变得更可靠。

Agent 让探索过程更容易被连起来

过去的软件工程师同样经历这些探索。

终端、IDE、CI、Issue、代码评审和构建日志,也早就保存了大量数字痕迹。

问题是,这些信息通常散落在不同地方。

一次复杂排查为什么从方案 A 转向方案 B,可能需要事后重新从 Shell 历史、日志、提交、评审讨论和工程师自己的记忆里拼出来。

Coding Agent 带来的变化,不是软件研发第一次变得可以记录。

更实际的变化是,一次任务里的很多步骤越来越容易作为同一个过程被连续记录和关联起来:

读取上下文
    ↓
搜索和检查
    ↓
修改
    ↓
调用工具
    ↓
观察结果
    ↓
回退或调整
    ↓
再次验证

这些原本分散的痕迹,也就更容易在发生时被留下,并和这次任务、当时的工程状态关联起来。

但这仍然不意味着把 Agent 的完整对话保存下来,就已经得到工程经验。

首先要区分 Agent “怎样思考”和我们真正能够观察到的工程轨迹。

模型内部究竟怎样形成判断,并不是一个能够被完整、可靠记录的工程事实。Agent 明确给出的理由也可能错误,事后生成的一段流畅解释更不能替代当时真正执行过的操作和得到的结果。

工程上更可靠的是:

它收到什么,实际读取了什么,明确提出了什么假设,进行了什么操作,观察到什么结果,以及这些结果以后发生了哪些调整。

工具确实执行过一条命令,是可以核对的事实。

Agent 判断“这次失败由工具链导致”,仍然只是一个需要继续验证的解释。

两者不能因为出现在同一段记录里,就被当成同一种事实。

同样,被检索出来的旧日志、Issue 或 Agent 记录也仍然只是当前判断的材料。它们不会因为进入新的 Agent 上下文,就自动获得新的指令权或者执行权限。

真正需要保存的是支撑一个判断的依据

单独保存一份很长的 Agent 对话,几年以后仍然可能没有多少用。

记录里写着:

这个版本不支持。

哪个版本?

这里不能改。

受什么约束?

方案 A 已经失败过。

它在什么环境、什么依赖和什么任务目标下失败?

所以,真正值得和工程状态关联的,不只是一个结论,而是当时什么条件和证据支撑了这个判断。

当时的工程状态
    │
    └─ 本次任务和目标
         │
         ├─ 候选方案
         ├─ 已知约束
         ├─ 实际观察
         ├─ 支撑判断的证据
         └─ 最终选择

这里讨论的是能够明确表达和回头核对的工程依据。

我们不需要假装能够完整恢复一个人或者模型内部真正怎样形成了决定。

这和前三篇讨论工程状态时有一个很自然的对应关系。

一个工程结果由源码、工具链、配置和环境等条件共同形成。这些条件发生真正影响结果的变化以后,原来的结果可能需要重新建立。

工程判断也一样。

如果支撑原判断的重要条件发生变化,原有证据被新的事实削弱,或者出现以前不知道的重要证据,原来的判断就需要重新评估。

工程结果
    │
    └─ 依赖一组工程输入
              │
              └─ 关键输入变化 → 结果需要重新判断


工程判断
    │
    └─ 有一组成立条件和证据
              │
              └─ 关键依据变化 → 判断需要重新评估

两者当然不能使用完全相同的算法。

构建系统在受控关系里可以机械判断一个输入变化以后哪些结果失效。

一个客户今天是否还值得兼容、一项维护成本现在是否还能接受,往往还需要新的业务事实和人的判断。

但至少有一件事是一样的:

不要让结论脱离它赖以成立的条件。

回到开头那个兼容层,我们至少已经能够知道:当年的决定保护了谁,也知道今天还有什么事实没有确认。

但这仍然只是过去留下来的依据。

还有一个问题没有回答:

后来发生的事情,会不会让我们对当年的判断有了新的认识?

后来的结果要回到当时的判断

记录下上下文、探索和决定以后,经验仍然没有自动形成。

因为一个判断当时看起来合理,并不代表以后不会出现新的事实。

真正让历史开始形成经验的,是后来发生的事情还能重新回到原来的判断上。

一个环境设置,为什么后来多了一层含义

一个构建环境明确要求使用英文 locale。

当时的理由很直接:一些构建脚本会识别工具输出。如果工具根据机器语言环境输出不同文本,脚本里的字符串判断就可能失效。

所以团队已经有一条明确经验:

locale 会影响工具消息,构建环境不能依赖机器默认设置。

后来一次构建完全正常。

工具输出是英文,脚本判断正确,制品也顺利生成。

几天以后,测试团队却发现一个字符串排序结果和原先不同。

这个问题随后跨了几个团队和部门,花了很长时间排查。源码、构建过程和直接相关逻辑看起来都没有明显问题,最后才定位到同一个看起来已经被处理过的环境条件:两边使用的 locale 分别是 POSIX C 和 en_US。

问题在于,locale 并不是一个简单的“使用什么语言”的开关。

在 POSIX locale 中,工具消息和字符串排序本来就是不同的作用,通常分别由 LC_MESSAGES 和 LC_COLLATE 控制。两个环境都能输出英文消息,并不意味着它们使用相同的排序规则。

这次问题没有推翻原来的经验。

构建脚本确实需要稳定的工具输出。

真正改变的是我们对这个环境条件的理解。

原来的认识:

locale
  │
  └─ 影响工具消息
         │
         └─ 影响脚本判断


后来发现:

locale
  ├─ 影响工具消息
  │      └─ 影响脚本判断
  │
  └─ 还会影响排序规则
         └─ 影响程序或测试结果

原来的规则没有错。

只是我们以为已经理解了这个输入,实际只理解了它的一种作用。

这个案例和前面的兼容层还有一个共同点。

后来出现的新事实,并不一定证明过去的判断错了。

有时候,原来的判断确实建立在错误认识上。

有时候,原来的判断在当时完全合理,只是赖以成立的条件后来消失了。

还有时候,就像这个 locale 问题一样,原来的判断仍然成立,但我们后来发现了一个当时根本不知道的重要作用。

因此,后来的结果不应该被用来简单地审判过去。

真正需要比较的是:

当时认为哪些条件重要,接受了什么代价,希望得到什么;后来实际发生的事情,与这些判断有什么关系?

后来知道得更多以后,我们可以修改今天怎样理解过去,却不应该把历史重新写成:

我们当时就知道真正原因。

当时观察到了什么、相信什么、尝试了什么,仍然应该保留。

新的证据怎样改变了这些认识,也应该继续关联回来。

我们真正需要留下的,是从一个不完整的判断逐渐走到更准确判断的过程。

经验最先改善的,可能不是答案

这也让“从历史中学习”有了一个更实际的含义。

这次 locale 排查以后,团队得到的并不只是一条:

上次排序问题就是 locale。

更有价值的是:

当相同字符串在不同环境中出现排序差异时,locale 和排序规则应该成为较早检查的条件。

它未必直接告诉下一次问题的答案,却能改变下一次从哪里开始查。

过去的经验也可能同时给出几个方向。

类似问题也许曾经来自 Unicode 库版本,也可能来自基础镜像或者其他环境差异。

真正有价值的不是机械照着上一次的顺序再走一遍,而是结合今天看到的现象,先做一个能够区分这些可能原因的检查。

有经验的工程师未必更早知道答案。

他往往只是更早知道:

先检查什么,可以更快排除一大类错误解释。

所以,经验最先改善的可能不是答案。

它更常见的价值是:

让下一次工作更早取得真正有区分力的证据。

如果过去的记录已经和对应的工程对象、状态建立了足够可靠的关联,新的任务再次涉及它们时,人或者 Agent 可以先找到相关的旧决定和探索,再比较当时和现在有什么不同。

如果过去某条构建路径只因为一个已经淘汰的工具版本失败,那么今天就不应该把“以前失败过”继续当成永久禁令。

经验真正改变的,是下一次判断从哪里开始,而不是替下一次工作直接给出结论。

经验不应该永远停留在历史里

但当一种认识已经经过反复验证,稳定到不需要每次重新判断时,它还应该一直作为一段历史被重新阅读吗?

通常没有必要。

能够明确执行的知识,可以进入依赖声明、环境约束、测试、诊断工具或者其他自动检查。

探索历史则继续解释:

为什么有这条规则,它保护什么,以及什么条件发生变化以后值得重新评估。

即使经验已经变成一条自动执行的规则,最好也不要把这段联系完全切断。

否则几年以后,我们可能又得到另一种熟悉的问题:

CI 一直要求这么设置,但已经没人知道为什么。

自动化可以减少重复判断,却不应该重新制造一种失去理由的传统。

另一方面,能够记录,也不意味着应该把所有东西永久保存。

一次 Agent 任务可能产生大量搜索、临时尝试、重复输出和很快失去价值的中间信息。

如果把这些东西全部长期放进后续上下文,新的工程师和 Agent 反而需要花更多时间判断什么仍然有意义。

秘密、敏感数据和权限边界,也不会因为这些记录以后可能有用就自动消失。

长期记录应该优先围绕那些真正影响重要判断的节点:

  • 哪项重要假设被证据推翻;
  • 哪次重要验证让一个关键判断变得更可信;
  • 哪个失败改变了排查方向;
  • 哪条工程边界因此被修改;
  • 哪项代价被明确接受;
  • 哪个条件决定一条旧路径今天是否仍然适用。

已经稳定的知识可以进入可执行机制。

已经失去意义的记录也应该能够归档或者结束。

保存历史本身不能成为另一项无限增长的维护工作。

目标始终不是保存得更多。

真正需要的是,让以后面对类似变化时,可以更准确地知道过去为什么这样做、哪些条件仍然存在,以及下一步最值得检查什么。

软件工程怎样真正从自己的历史中学习

过去,大量探索经过人的理解和压缩,形成了个人经验。

一个维护产品很多年的工程师,会逐渐知道哪些地方不能只看代码,什么问题更可能来自环境,哪个看起来方便的接口以后会很难退出,以及什么时候继续修补已经不如重新划定边界。

这些判断很有价值,但其中相当一部分很难完整继承。

文档、评审和制度一直在努力解决这个问题,却通常只能保存经过选择和整理以后的一部分结果。

AI 时代带来的新机会,并不是它能够替我们判断哪些经验永远正确。

真正发生变化的是,越来越多过去分散在代码、日志、工具和个人理解里的探索过程,可以在一次任务中自然地关联起来。

状态、上下文、工具操作、失败、回退、判断变化和实际结果,有机会在事情发生的时候就建立关系,而不必等到任务结束以后再依赖执行者回忆。

但记录只解决了第一步。

历史真正形成经验,还需要后来的结果能够回来挑战或者支持原来的判断;需要知道一个结论在什么条件下成立;需要在这些条件发生重要变化以后重新判断,而不是机械继承;也需要把已经足够稳定的认识继续变成可以实际执行的工程机制。

这样,过去才不会只是保存下来。

它会改变下一次探索从哪里开始。

几年以后,当我们再次准备删除开头那层接口转换时,理想的起点就不再只有一个提交和一句“兼容旧版本”。

我们应该能够知道,当时保护的是谁,为什么选择了这条路径,明确接受过什么代价,后来又发生过什么,以及今天还有哪些条件没有确认。

过去不会替我们决定现在应该怎么做。

一段历史真正形成工程经验,是因为它让下一次判断知道原来的结论依据什么,什么变化值得重新判断,以及面对新的不确定性时,应该先去检查什么。

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

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

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

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

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

同一次变更还会继续带来更多问题:哪些下游需要重新构建?哪些测试结论已经失效?容器镜像里应该包含什么?流水线到底需要执行哪些任务?一个 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 和人的记忆重复描述。

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

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

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

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

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

现在,一个应用从需求描述到完成测试、打包和部署,可能只需要几分钟。

开发者不必逐页查找文档,也不必亲手写完每一段脚本。AI 可以识别项目结构、安装依赖、生成流水线,根据执行结果修复错误,最后把应用部署到一个真正能够提供服务的环境里。

站在完成任务的人的位置上,许多过去熟悉的软件工程工作确实像是消失了。原来要花几天完成的事情,现在可能只剩下一句目标和几轮确认。

这种变化是真实的。大量人工实现和操作会减少,以这些操作为主要价值的岗位也可能随之收缩。平台和 AI 甚至可能真正降低软件生产的总成本,而不只是把同样的工作转移给另一批人。

但这里要讨论的,不是未来还需要多少软件工程师,也不是为了证明软件工程成本一定会上升。真正的问题是:

当一份实现需要长期运行、保存状态、被其他系统依赖,并开始对现实产生后果时,哪些问题会因为实现和部署变简单而消失,哪些仍然需要平台、组织或者自动化机制继续处理?

前一篇文章选择构建工程作为切口,是因为这里很容易看见一个转折:一份可以随时删掉重写的代码,怎样开始变成需要测试、发布、维护并承担后果的软件。

让我们继续往下追:一份可以随时重新生成的实现,怎样开始碰到状态、依赖、权限和责任关系,并因此越来越难被直接替换。

一次成功只说明一条路径走通了

假设 AI 为一个应用生成了部署脚本,拉取运行依赖,完成测试和打包,最后一次部署成功。

这次成功至少说明,在当时的代码、依赖版本、网络条件、权限配置和平台状态下,存在一条能够完成任务的路径。

对于一个短期使用、风险很低的内部工具,这可能已经足够。软件工程并不要求每个临时程序都建立完整的供应链证明、跨环境验证和长期维护体系。应该做到什么程度,本来就取决于软件的用途、寿命和失败后果。

但是,一次成功没有自动回答接下来的问题。

代码修改以后,还能不能得到符合要求的结果?依赖升级会不会改变程序行为?外部服务不可用时,应用会怎样退化?这次成功是否依赖环境中某个没有声明的软件包?部署脚本实际获得了哪些权限?数据能否迁移和恢复?新版本还能不能读取已有状态?

这些问题不一定都需要开发者亲自处理,也不必在第一次部署以前全部解决。但只要软件开始被持续使用,它们就不会因为第一次部署很顺利而消失。

这里容易混淆两件事。

第一件事,是实现并执行一条已知路径的成本正在快速下降。

第二件事,是让软件在持续变化、故障和长期运行中仍然保持可理解、可控制和可恢复的成本,也在同步下降。

前一个变化已经非常明显。后一个变化也可能通过平台化、标准化和进一步自动化发生,但不能只根据一次部署成功得出结论。

平台确实会让一部分工程消失

AI 只靠一句话就能完成部署,并不只是因为模型变聪明了。

它还依赖一个已经被高度工程化的环境。

云平台把计算资源、网络、证书、域名、容器运行、日志、扩缩容和权限控制整理成稳定接口。包管理系统规定依赖怎样描述、解析和下载。应用框架约定项目结构、启动方式和配置位置。CI 系统提供可以反复调用的执行环境。平台 API 又把这些能力组合成可以被程序控制的操作。

AI 面对的并不是一个完全开放、没有结构的现实世界,而是一组经过长期设计、能够接收明确输入并给出明确反馈的工程接口。

平台也不只是把应用团队原来的工作搬到另一层。

一个托管平台统一处理证书签发和续期以后,不需要再为每个应用维护一套相同的人工流程。平台只允许少数经过验证的部署方式时,大量自定义脚本、环境组合和相应故障也会直接消失。

平台最有效的地方,往往不是把一百种部署方式全部自动处理,而是让其中大部分方式不再成为可选项。

应用团队不再管理物理服务器,也不意味着平台团队逐台接管了同样数量的手工操作。标准化、共享实现和多租户平台可以同时降低人工成本和故障率。

但平台没有标准化的关系仍然存在。

数据状态、业务权限、外部依赖、兼容要求和特殊运行条件,不会因为部署接口变简单就自动得到处理。平台本身还可能引入新的供应商依赖、控制面复杂性和不可见的内部状态。

所以,更准确的问题不是“复杂性被搬到了哪里”,而是:

哪些问题已经通过标准化被消除,哪些被共享和摊薄,哪些只是换了处理位置,平台又引入了哪些新的关系?

当同样的 AI 进入旧系统、专有网络、特殊硬件、历史数据和内部安全规则大量存在的环境时,效果往往会明显下降。

这不一定是 AI 突然变笨了。更常见的原因是,这些环境里仍然存在许多没有被标准化的关系:真实依赖没有完整记录,事实来源并不可靠,各个系统之间又有足够大的差异,不值得或者无法被统一成一套接口。

平台能够消除的,是已经被标准化的选择、路径和重复工作。剩下的问题,主要来自某项具体变更与既有状态、依赖、权限和责任之间的关系。

从系统工程的视角看,这类围绕系统技术身份、组成关系及其变更的工作,可以放在技术状态管理(Configuration Management)的谱系中理解。源码仓可以为源码状态提供单一事实来源,但要知道一个制品究竟由什么形成、一次变更又会影响什么,还需要把依赖、工具链、配置、环境以及制品之间的派生关系纳入可追溯、可修正的工程模型。

实现一旦开始碰到这些关系,就不再只是一个可以随时删掉重来的方案。

一份实现怎样变成系统中的一次变更

前一篇文章用“实现可以重写,系统关系不能一键重来”概括了这种不对称。

AI 刚生成的一段代码,通常很容易替换。发现方向不对,可以删掉重做;换一种实现,代价也可能不大。

这里可以把这种还没有被系统接受、仍然能够自由替换的方案称为“候选实现”。

真正困难的部分,出现在候选实现准备进入一个已经运行的系统时。因为这个系统并不是空白的,它已经形成了两类关系。

第一类是已经存在的系统事实。

数据已经按照某种格式写入,某个行为已经部署并被用户使用,组件之间已经建立依赖,接口也已经被其他系统实际调用。这些事实会直接影响后续的兼容、迁移、恢复和验证。

第二类是系统对外形成的承诺。

公共接口、客户合同、交付计划、用户预期,以及监管、认证和合规义务,都可能限制系统以后可以怎样变化。有些承诺甚至会在第一行代码出现以前就已经成立。

系统事实和外部承诺形成的时间并不同步。

一段代码可能已经进入生产制品,但仍然被功能开关隔离。它没有写入数据,也没有被外部用户使用。此时虽然发生了部署,新增的系统事实和外部承诺仍然有限。

反过来,一个功能可能尚未开始实现,但公共接口、交付合同和合规要求已经确定。代码仍然可以自由选择,系统未来必须满足的条件却已经增加。

所以,一次发布并不是所有约束同时生效的分界线。

当一份实现必须连同已有数据、依赖关系、权限边界和外部承诺一起接受影响判断时,它就从候选实现变成了面向既有系统的“候选变更”。

候选变更首先要面对已经存在的事实和承诺。它如果被系统接受,还会继续写入新的数据、建立新的依赖、形成新的行为预期,并留下新的长期责任。

我们可以把一项变更当前需要处理的这些影响称为“变更负荷”。

变更负荷不是代码改了多少行,而是这次修改碰到了哪些已有关系。这些关系是否包含重要状态,能不能快速撤销,影响是否跨越多个系统,又需要多少验证和协调,都会改变实际负荷。

把变更接受以后留下的长期义务称为“承诺成本”。

它可能来自新的数据格式、公共 API、消息协议、权限模型、用户行为预期或者合同义务。以后要继续维护、修改或者退出这些关系,就需要承担兼容、迁移、验证、协调和责任成本。

变更负荷和承诺成本通常有关,但并不是同一件事。

一次内部重构可能需要修改很多底层代码,当前变更负荷很高,但如果它没有改变数据、接口和外部行为,就未必增加多少长期承诺。

一个公共 API 第一次实现起来可能很简单,但一旦被外部用户依赖,未来修改和退出它的成本就可能很高。

今天被接受的变更,会成为明天系统的一部分。它写入的数据、建立的依赖和形成的承诺,会变成下一次变更必须面对的既有条件。

长期软件正是在一次次接受和再次修改中形成历史的。已有关系可以被迁移、退役或者结束,但通常不能无成本清空。

这并不只发生在有固定硬件的系统中。

硬件会带来资源、时序、产品变体和认证限制。纯软件系统虽然没有同样的物理约束,却会逐渐积累数据格式、公共接口、消息协议、权限模型、合同和制度要求。

这些约束形成的原因不同,但都会减少系统以后可以自由选择的空间。

工程的作用因此不只是证明一个实现能够工作。它还要识别已有承诺,避免不必要的关系过早固定下来,把高风险变更拆成可以观察和撤销的步骤,并在必要时保留兼容窗口、迁移路径和退出方式。

这些过渡结构本身也有成本。兼容层、双轨运行和分阶段迁移会增加临时状态、额外观测和旧路径清理工作。只有在它们保留下来的选择空间足够有价值时,这些复杂性才值得承担。

当然,这些机制无法证明需求本身是正确的。把错误的目标稳定、可靠地做进系统,仍然是在做错事情。

“应该做什么”与“怎样把它可靠地带进一个长期运行的系统”,是两个不同的问题。本文主要讨论后一个。

实现变便宜以后,为什么变更处置更容易成为主要成本

AI 最直接的变化,是让候选实现的生成成本下降。

它同样可以帮助分析、测试、调试和运维,但这些工作更多受到已有状态、外部依赖和证据要求的限制。AI 可以快速写出一个数据库迁移脚本,却不能因此消除已有数据。它可以生成一套兼容方案,却仍然需要知道哪些系统实际依赖了旧行为。

前文把工程体系处理这些问题的能力称为“变更处置能力”(Change Handling)。

它指的是:工程体系能否看清一项变更会碰到什么,为关键判断建立足够证据,并在该继续、该调整和该停止时作出明确处置。

这里处理的不是一组完全相同、可以按提交数量计算的修改。

一百个彼此隔离、能够自动验证并快速回退的小修改,可能比一次涉及历史数据和公共接口的迁移更容易处置。

反过来,一个发布频率很低的系统,也可能因为一次协议或者数据库格式变更,承担巨大的验证、协调和迁移成本。

多个小变更还可能相互影响。它们单独看都很安全,组合以后却可能扩大依赖版本范围、引入配置冲突,或者同时影响同一批下游系统。

而且,变更负荷通常只能在开始以前粗略估计。真正的构建、验证和运行结果,可能暴露原来没有发现的依赖和失效方式,迫使工程体系重新判断影响范围。

即使工程体系还没有超载,实现成本下降得更快以后,影响分析、验证和风险处置也可能先成为一项变更的主要成本。

过去主要花在写代码上的时间,现在可能更多地花在几个问题上:这次修改究竟影响什么,需要什么证据,失败以后能否恢复,以及它是否应该继续进入系统。

只有当持续到来的变更负荷接近或者超过某类处理能力时,问题才会进一步表现为排队、降低准入标准和长期复杂性积累。

这里还要注意,工程能力并不是一个统一的总容量。

数据库迁移、安全评估、硬件验证和跨团队协调依赖的是不同能力。整个研发体系看起来仍有余量,不代表每一类变更都能被及时处理。

完整地说,变更处置能力包括三件事:

理解候选变更会触动哪些已有关系;为这些影响建立与风险相称的证据;判断变更被接受以后会形成哪些新的事实和承诺,并据此决定接受、拆分、推迟、缩小范围、拒绝或者重新设计。

工程体系不会独自决定所有产品、合同和组织承诺,但它需要把一项变更会怎样兑现、扩大或者违背这些承诺说明清楚,也要阻止未经授权的后果悄然进入系统。

因此,变更处置能力不能用流水线每小时处理多少任务来衡量,也不能简单看放行率或者拒绝率。

如果工程流程只会帮助每项变更寻找通过方式,却没有真实的背压和否决能力,发布数量可能提高,系统承担的长期复杂性也会继续增加。

反过来,如果标准路径只会拒绝真实存在的必要差异,复杂性也不会消失。团队会转向私有脚本、人工操作和平台看不见的例外路径,最终让整个组织更难理解系统实际是怎样运行的。

好的工程体系要让可以接受的变更及时前进,让不合适的变更尽早改变形状或者停止,同时不稀释原有的权限边界和证据要求。

平台和架构会同时影响变更负荷、变更处置能力和未来的承诺成本。

标准化接口、限制可选状态和建立稳定模块边界,可以减少一项修改需要触动的关系。自动测试、执行隔离、影响分析和恢复机制,可以提高工程体系建立证据和处理失败的能力。

更好的设计还会直接改变变更本身的形状,让它更局部、更容易观察,也更容易停止和撤销。这样既能降低当前负荷,也能避免系统形成不必要的新关系。

工程体系需要管理的三类边界

要处置一项变更,工程体系至少需要回答三个问题:

什么可以影响结果?

现有证据究竟证明了什么?

谁有权让这件事继续发生?

这三个问题分别对应因果边界、证据边界和决策边界。

什么可以影响结果:因果边界

因果边界关心的是,哪些输入、状态和外部条件被允许影响制品或者运行结果。

源码、依赖、工具链、配置、网络资源、模型、数据和可调用工具,都可能进入实际的因果链。

把这些输入写进声明,只是第一步。真正重要的是实际执行是否仍然遵守这条边界。

如果构建脚本可以读取未声明的宿主文件,可以直接调用环境中碰巧存在的工具,或者在受控入口之外访问网络,那么声明中的输入就不等于真正影响结果的输入。

Agent 系统也是如此。系统可能声明 Agent 只能使用几种工具,但如果它还能绕过这些工具直接访问其他资源,实际因果边界就已经超出了声明。

因果边界必须能够被执行、观察和验证,不能只存在于配置文件或者设计文档里。

现有证据究竟证明了什么:证据边界

一项测试、评测或者运行记录,并不笼统地证明“这个软件没有问题”。

它只能支持一个更具体的结论:哪个对象,在什么环境和时间下,满足了哪些实际被检查的条件。

首先,证据要绑定到身份明确的对象。

前文强调的“确定的制品”,就是为了这一点:测试、安全检查和发布记录需要说明自己针对的是哪一个制品,以及这个制品从哪里来。让同一个正式制品依次进入测试和发布阶段,是降低对象识别成本的一种常见做法,但这并不意味着制品在物理上只能构建一次。

其次,证据不能在不同对象之间随意转移。

可复现构建解决的是:另一方获得相同源码、相同构建指令,以及项目声明为相关的环境条件以后,能否重新得到逐字节一致的制品。

如果两个阶段产生的是不同制品,就需要说明它们在哪些性质上可以被视为等价。

两个制品功能行为相同,不代表它们的性能、安全属性和来源也相同。分别通过同一组测试,也只能说明它们满足了这些测试实际检查的条件。

证据能够从一个对象转移到另一个对象的范围,不能超过原有证据检查的内容,也不能超过等价证明覆盖的性质。

最后,证据还需要必要的独立性。

同一个 Agent 根据同一份需求理解,连续生成实现、测试和说明,整个过程即使可以追溯,也不等于已经形成了相互独立的证据。

问题不只是是否换了另一个模型。真正需要检查的是,关键证据会不会因为同一个错误假设而一起失效。

对于风险较高的变更,不能让所有证据都依赖同一份需求解释、环境假设、数据来源或者测试判定方式。

对于影响有限、容易发现并且容易恢复的问题,同一个 Agent 的自我检查仍然有价值。证据要求应该与后果相称,而不是机械地追求形式上的独立。

谁有权让它继续发生:决策边界

决策边界关心的是,谁有权接受哪些剩余风险,又有权授权哪些外部副作用。

构建系统、策略引擎和其他自动化机制可以执行已经确定的规则。例如,测试失败就停止发布,权限不足就拒绝操作,金额超过限制就要求额外授权。

但自动化系统不能因为自己找到了一条能够通过的路径,就替真正承担后果的人或组织决定某项风险值得接受。

有权决定项目进度,不代表有权豁免合同、安全或者监管义务。拥有生产环境操作权限,也不代表有权改变所有业务数据。

如果风险最终由谁承担、谁有权决定本身就不清楚,这种不确定性也应该被暴露出来,而不能由流水线或者 Agent 默默替组织作出决定。

这三类边界并不会因为写进文档就自动变得正确。

在遗留系统里,真正危险的往往是没人知道的依赖、无法完整枚举的产品变体,以及只存在于少数人经验中的历史假设。

此时第一步不是假装系统边界已经完整,而是围绕后果较大的变更,通过隔离、观测、影子运行、失败反馈和逐步建模,发现原来没有表达出来的关系。

明确边界不等于已经完全掌握真实系统。它至少要求工程体系把已知的未知、信任假设和验证缺口记录下来,而不是默认它们不存在。

因果边界决定当前能够表达、观察和限制哪些影响。

证据边界决定这些影响被理解和验证到了什么程度。

决策边界决定在仍有不确定性时,谁有资格让变更继续。

三者共同构成一项候选变更被接受、调整、推迟或者拒绝的基础。新的运行结果一旦暴露出未知关系,原有判断也需要重新评估。

AI 系统怎样改变这些边界的表达和范围

AI 没有创造一套完全不同的软件工程问题,但它改变了这些边界的表达方式,也扩大了需要管理的因果范围和现实后果。

交互方式会改变,精确表达不会消失

随着模型能力提高,人通过编程语言逐句描述实现的比例很可能继续下降。

在标准化程度较高的领域,自然语言、图形化描述、示例和已有系统状态,可能成为人与软件生产系统交互的主要入口。代码更多地由 AI 生成,再交给工具执行和检查。

但因果、证据和决策边界不能只存在于一次临时对话中。

一句“把这个应用部署到内网”,对于人来说可能已经表达了主要目标。对于执行系统来说,仍然需要确定部署到哪个环境、使用什么身份、允许访问哪些网络和数据,以及失败以后怎样处理。

AI 可以推断这些条件,但推断本身就是一次实际选择。不同推断可能产生完全不同的权限范围和现实后果。

这也不意味着系统的所有行为都必须固定。

推荐、调度和交互式 Agent 本来就可能允许动态甚至概率性的结果。真正需要稳定表达的,是会影响系统风险、外部副作用、兼容承诺和责任判断的条件。

允许变化的部分,也需要说明变化范围和不可突破的限制。

编程语言可以逐渐退到交互界面的后台,但精确、稳定、可检查的表达能力不能消失。否则,每次执行都相当于重新解释一次意图。

Agent 运行时扩大了因果和证据范围

传统程序的大部分行为可能已经包含在编译后的制品里。

以 Agent 为执行节点的系统,最终行为还可能取决于模型、系统提示、检索数据、可调用工具、外部服务状态、当前时间、会话状态和运行权限。

即使代码和制品完全没有变化,其中任何一个运行时条件改变,都可能得到不同结果。

构建过程和运行时过程都需要管理来源、授权和追溯,但它们不能使用完全相同的保证方式。

封闭构建可以限制输入、隔离环境,并追求确定的制品或者可复现构建。

开放、概率性的运行系统会持续接收外部输入,与不断变化的数据和服务交互,还可能产生无法撤销的外部副作用。即使记录了模型、提示和检索内容,也未必能够重新得到完全相同的执行轨迹。

因此,这类系统需要管理的不是全面确定性。

有些条件必须成为不能突破的硬约束,例如权限范围、金额上限和禁止访问的数据。

有些性质只能通过统计方式判断,例如错误率、危险动作发生率和结果分布。

工程体系还需要提前定义:什么情况下停止高风险动作,什么时候降级到已经验证的路径,何时隔离某项能力,以及哪些变化会触发重新评估。

这不等于任何异常都要停止整个系统。不同后果需要不同的停止和降级条件。

运行时证据也有明确的对象、环境和时间范围。模型升级、外部 API 改变、数据分布变化、权限扩大或者新工具接入以后,过去的测试和评测可能已经不足以支持原来的结论。

证据记录本身还受到隐私、敏感数据和保留成本的限制。目标应该是为具体工程判断留下足够证据,而不是无限记录所有可能影响结果的内容。

Agent 把决策边界直接连接到现实动作

当 Agent 能够搜索资料、修改配置、调用服务和执行多步操作时,模型输出就不再只是文本,也可能成为现实动作的起点。

模型可以提出动作,但不能由它自己决定这个动作是否有资格发生。

系统需要预先规定,哪些来源和通道具有指令权。即使不可信内容影响了模型提出的动作,也不能因此获得新的执行资格。

具体工具调用及其参数,仍然应该经过模型之外的权限和策略判断。这套判断不能被同一个 Agent 任意修改或者绕过。

策略执行机制不必先证明一段自然语言在语义上“不是指令”。它需要保证,无论模型怎样理解这段内容,都不能因此突破已经确定的能力、参数和副作用边界。

当然,外部策略本身也不会天然正确。它同样需要版本化、测试、观测和审计。

它的价值在于把动作生成与动作授权分开,避免同一次模型误判同时控制目标解释和最终授权。

权限正确也不等于动作正确。

一个 Agent 可以在合法权限内误解目标,也可能因为重试而重复执行一个不能安全重复的操作。风险取决于它的自治程度、权限范围、行动频率、影响范围和可撤销性,而不只是模型看起来有多聪明。

外部机制不能证明业务意图本身正确,但可以限制错误动作的范围,为高后果操作增加独立判断点,并阻止一次模型误解直接变成不可控制的现实后果。

AI 降低了提出和执行动作的成本,也让工程体系需要管理的因果、证据和决策边界,从发布前的变更控制延伸到了持续运行过程。

哪些工作会消失,什么时候不需要更多工程

讨论软件工程还在解决什么问题,没有必要否认大量工作会被自动化。

按照文档组合已有组件,编写常见部署脚本,修复普通依赖问题,根据日志反复调整环境,这些工作已经越来越适合交给 AI。成熟平台提供足够明确的接口以后,人甚至不需要理解其中的大部分实现细节。

如果一个岗位的主要价值长期停留在这些操作上,它确实可能被压缩,甚至不再需要。

软件仍然需要可靠性、权限控制和恢复能力,也不能因此推出每个应用团队都要保留原有数量和分工的岗位。

这些能力可以集中到平台层,也可以通过共享机制和统一控制降低每个应用的边际成本。很多过去由各个团队分别承担的工作,会被平台一次性解决。

同样,并不是每个软件都值得建立完整的控制机制。

一个寿命很短、没有重要状态、影响范围有限、失败以后可以直接删除重来,也不需要兼容旧版本的工具,采用很轻的工程控制可能就是合理选择。

工程不是把每个系统都做得尽可能复杂,而是根据寿命、状态、依赖、权限和失败后果,决定哪些关系值得被明确管理。

临时工具真正危险的地方,通常不是它一开始少做了工程,而是它在被长期使用、开始保存重要状态或者获得更高权限以后,仍然继续按照临时工具对待。

随着软件承担的关系和现实后果增加,原来可以忽略的条件会逐渐变得需要表达、验证和控制。

结语

AI 和平台可以真实降低,甚至消除大量实现、测试和部署成本。软件工程不需要把一句目标重新展开成更多人工步骤,来证明自己仍然存在。

当实现和部署不再稀缺,软件工程更需要处理的是:一份可以随时重写的实现,怎样成为面向既有系统的一次变更;它会触动哪些已有事实和外部承诺;被接受以后,又会留下哪些新的长期关系。

工程体系需要理解这项变更当前带来的负荷,判断它会形成什么新的事实和承诺,并通过因果、证据和决策边界,决定应该接受、调整、推迟还是拒绝它。

实现可以越来越容易重来,但一次接受所形成的关系,会成为下一次变更必须继续面对的历史。

软件工程要管理的,正是这段历史怎样形成、怎样改变,以及它的后果最终由谁承担。

在云原生大行其道的今天,如果一名架构师说:“我不想用 Kubernetes,我想直接用 Linux Systemd 部署”,很多人可能会觉得他在开倒车。

我们沉迷于 Kubernetes 提供的服务发现、负载均衡和自愈能力;我们习惯于将域名解析、CDN 甚至安全策略全盘托付给 Cloudflare。这些工具像魔法一样掩盖了底层的复杂性。

但这种便利往往伴随着一种隐秘的焦虑:如果这些魔法失效了怎么办?

这不是杞人忧天。当 Cloudflare 发生全球性宕机,控制台无法访问,甚至无法修改 NS 记录将流量切走时,我们除了看着 503 错误发呆,什么也做不了。当 Kubernetes 集群的 etcd 数据损坏且无法恢复,我们是否还有能力在几台裸金属服务器上,仅凭标准 Linux 指令让业务跑起来?

Kubernetes 应该是我们的一个选项,而不应是唯一的生存土壤。 真正的健壮性,来源于应用对环境的“低依赖”以及对原生能力的“高兼容”。

那些让我们陷入“温水煮青蛙”的强依赖

除了 Kubernetes 和 Cloudflare,现代技术栈中还有太多让我们丧失“原生生存能力”的陷阱。我们需要审视以下几类典型的强依赖:

1. Serverless 与 FaaS 的“代码级锁定”

AWS Lambda 或 Vercel Functions 极大地简化了运维,但它们往往要求代码按照特定的 Handler 签名编写,并依赖特定的触发器(Trigger)机制。

  • 风险: 你的代码不再是一个标准的 HTTP Server,而是一个只能在特定云厂商运行时里跑的“碎片”。一旦云厂商涨价或改变策略,迁移成本极高,因为你无法直接将这些碎片扔进一台普通的 Docker 容器里运行。
  • 原生替代思考: 应用应当首先是一个标准的 Web Server(如 Go 的 net/http 或 Python 的 WSGI/ASGI)。FaaS 应该只是最外层的一层薄薄的适配器(Adapter),而不是代码的骨架。

2. 专有数据库服务(Vendor-Specific DBs)

使用 AWS DynamoDB、Google Firestore 或 Azure Cosmos DB 非常诱人,它们提供了无限的扩展性和极简的 API。

  • 风险: 与 MySQL 或 PostgreSQL 这种基于标准协议的开源数据库不同,专有数据库的 API 是独占的。一旦你的业务逻辑深度耦合了 DynamoDB 的 Single Table Design,要想迁移到自建的 SQL 或 NoSQL 环境,基本等同于重写整个数据层。
  • 原生替代思考: 是否设计了良好的 Repository 模式?如果脱离了云厂商的 IAM 认证和专有 API,你的应用还能连接一个本地运行的标准数据库吗?

3. 身份认证即服务(IDaaS)

Auth0、Okta 或 AWS Cognito 接管了我们的用户系统。

  • 风险: 如果服务商封禁了你的账户,或者发生服务中断,你的所有用户瞬间无法登录。更可怕的是,用户数据并不在你手中,导出和迁移用户哈希数据的难度极大。
  • 原生替代思考: 即使使用 IDaaS,是否保留了同步用户数据到本地数据库的能力?应用是否支持标准的 OIDC/OAuth2 协议,以便在紧急情况下切换到开源的 Keycloak 或自研的简单的 JWT 服务?

“原生回退”:不仅仅是备选方案,更是架构试金石

你可能会问:“维护两套完全不同的部署逻辑(如 K8s 和 Systemd),成本不是翻倍了吗?”

恰恰相反,这种差异性正是检验架构解耦程度的最佳试金石。

如果我们强制要求应用必须具备在“原生操作系统”上运行的能力,这会迫使我们思考很多本质问题,从而优化整体架构:

1. 服务的发现(Service Discovery)

  • K8s 依赖: 代码里直接写死 http://my-service,依赖 K8s 的 CoreDNS 和 iptables 转发。
  • 原生思考: 如果没有 K8s DNS 怎么办?这迫使我们在代码层面支持配置化的服务地址。在裸机模式下,它可能是 localhost:8080 或配置在 /etc/hosts 中。应用不应假设网络拓扑,而应通过配置感知拓扑。

2. 配置与密钥管理(Configuration & Secrets)

  • K8s 依赖: 极度依赖 ConfigMap 和 Secret 挂载,甚至依赖 Sidecar 动态注入。
  • 原生思考: 如果没有 Sidecar 怎么办?这促使我们回归标准:环境变量(Environment Variables)。这是最通用的标准,无论是 K8s、Systemd、Docker 还是 Shell 脚本,都原生支持环境变量。

3. 负载均衡与入口(Ingress)

  • Cloudflare/Ingress 依赖: 依赖边缘节点的路由规则和 SSL 卸载。
  • 原生思考: 如果外层壳子没了,我们是否有一个标准的 Nginx 配置文件作为备用?这要求我们的路由规则不能过于依赖特定厂商的非标准语法(如 Cloudflare Workers 里的特殊逻辑),而应尽量保持在 HTTP 协议的标准范畴内。

构建“逃生舱”策略

为了避免“傻眼干等”,我们需要将“备选方案”从文档变成可执行的代码。

1. 定义“最小生存环境”

不需要在备选方案中复刻 K8s 的所有高级功能。我们需要定义一个“最小生存环境(Minimum Viable Environment)”:

  • 计算: 标准 Linux VM 或裸金属服务器。
  • 进程管理: Systemd 或 Supervisord(操作系统原生能力)。
  • 网络: 标准 DNS 解析,Nginx 反向代理。

2. “双模”部署验证

不要让备选方案只停留在理论上。可以采取以下策略:

  • 开发环境原生化: 强制开发人员在本地(非 K8s 环境)直接运行二进制文件或简单容器。如果开发者本地跑不起来,说明对平台依赖过重。
  • 灾备演练: 定期(如每季度)尝试在一个完全隔离的、没有 K8s、没有 Cloudflare 的纯净 Linux 环境中部署全套系统。

3. 基础设施即代码(IaC)的抽象

使用 Terraform 或 Ansible 时,尝试将“应用逻辑”与“基础设施胶水”分离。

  • Plan A (K8s): Terraform -> AWS EKS -> Helm Charts.
  • Plan B (Native): Terraform -> EC2/DigitalOcean Droplet -> Ansible Playbook (安装 Systemd Service)。

回归朴素的技术价值观

我们并不反对 Kubernetes,也不否认 Cloudflare 的伟大。我们反对的是“无意识的依赖”。

拥有在原生操作系统中部署运行的能力,是一种底气。这种底气意味着:虽然我选择了云,但我拥有随时下云的自由;虽然我使用了复杂的编排工具,但我从未忘记如何用最基础的命令让代码跑起来。

当我们将应用与基础设施解耦,减少对特定运行时环境的黑盒依赖时,我们得到的不仅是一个可用的灾备方案,更是一个结构清晰、边界明确、生命力顽强的软件系统。

柴锋(Odd-e)

在现代软件工程的复杂世界里,“依赖管理”这个词汇对开发者来说再熟悉不过。我们习惯于在 package.json 或 pom.xml 中审视那些明确列出的软件包,认为这就是依赖管理的全部。然而,这仅仅是冰山一角。一个真实的、贯穿始终的故事,将揭示冰山之下那片更广阔、也更危险的水域,它最终将引向一个更深层次的命题:在大规模软件系统中,“信任”究竟从何而来?

一场关于“信任”的三重考验

这个故事,我是亲历者,也是全程的参与者。2018年,一家通信设备公司面临某国政府公开对其产品存在“后门”的尖锐质疑。为了自证清白,公司开启了一场长达两年的马拉松式技术验证,这期间经历了三轮不断升级的严苛考验。这段经历不仅是本文探讨问题的缘起,更是一次宝贵的契机——我与客户方一位核心技术管理者在共同应对挑战时,因对工程哲学的深刻共鸣而开启了此后几年的深度合作研究。故事,就从这里开始。

第一重考验:源码审计

要证明产品安全,最直接的办法就是审查源码。于是,公司将产品的全部源码提交给对方政府,由其联合多家顶级安全与软件公司进行长达半年的逐行审计。最终的结论是:源码是干净的,未发现任何后门。

然而,第一个问题随之而来:源码是安全的,产品就一定安全吗?听起来答案显而易见,但事实远非如此。

第二重考验:构建复现

质疑方很快提出了一个更深入骨髓的问题:“我们承认源码是干净的,但我们部署在网络里的产品,真的是用这份源码构建出来的吗?”

这是一个极为合理的疑问,直指“阴阳源码”的可能性。为了打消顾虑,公司必须在对方完全控制的基础设施中,重现整个构建过程,且最终产物必须与线上部署的产品做到“比特级别一致”,就算有差异,也必须是可以解释的,比如由时间戳、文件路径、排序等引起的差异。这是一个近乎疯狂的任务,因为复现一个规模庞大、技术栈复杂的系统本就困难重重。但经过又一个半年的努力,构建成功复现,证明了产品与源码的一致性。

第三重考验:终极质询

源码可信,产品由源码构建,这下总该放心了。但对方抛出了第三个,也是最致命的问题:“你们怎么证明,在如此复杂的构建过程中,每一个步骤都是安全、可审计的?没有任何机会可以悄无声息地引入风险和后门?”

这个问题直指现代软件工程的核心——生产过程。它不再纠结于可见的源码,而是拷问那些看不见的环节:构建脚本里有没有隐藏任务?构建过程中用到的所有工具,有没有可能故意修改源码或结果?这并非杞人忧天,2024年的 “xz 后门事件” 就是一个血淋淋的教训:攻击者没有修改源码,而是通过修改构建脚本注入了漏洞,成功绕过了源码审查。如果不是被意外发现,全世界大量的网络服务器都将门户大开。

为了回答这个终极问题,公司又投入了约一年的时间,最终证明了其构建流程的每一步都是安全的。故事的结局出人意料,但也在意料之中,尽管公司证明了一切,对方政府依然决定在未来几年内全面替换其产品。

从“信任”到“工程化信任”

看似两年的巨大投入付诸东流,但该公司高层却认为这是一次宝贵的学习机会,是一场深刻的公司内部革命。他们甚至想感谢当初提出这些问题的审查机构负责人,因为正是这些问题,帮助公司照见了自己工程体系中最深层次的挑战和不足。

为此,他们在前后5年里累计投入约20亿美元,专门用于提升软件工程能力。他们得出一个核心结论:在大规模、长周期的复杂系统中,“信任”早已不是一种感觉或承诺。信任,本身必须被设计和实现出来。信任,是一种工程能力。

而构建这种“工程化的信任”,其核心,就是本文真正要讨论的主题:依赖管理。

重新定义问题:看见依赖的整座冰山

一提到依赖管理,我们脑海里浮现的通常是 package.json 里的 dependencies,或是 pom.xml 里的 dependency 标签。我们知道,这些依赖的变更会影响产品的功能和构建,它们的错误或缺失会导致功能异常或无法构建。

但这,就是依赖管理的全部了吗?难道只有这些变化才会影响软件制品及其构建过程吗?

让我们想一想,你用的哪个版本的编译器(这是 工具链依赖 )、一个细微差异的环境变量(这是 环境依赖 )、Makefile 里的编译选项(这是 配置依赖 ),甚至用于代码生成的模板文件(这是 数据依赖 )。这些看不见的“隐性”依赖,和代码变更一样,都能导致产品失败,或者引入致命的漏洞。它们的变化对产品功能和构建产生的影响,与那些软件包是一样的。既然随着研发的进行,这些要素也会变更、升级,那为什么不能也叫它们是依赖呢?

因此,我们必须将视角拉高,站在软件“生产”的全过程来看,并引入一个更为宏大的概念——“广义依赖”。

“广义依赖”是指所有会影响软件生产过程和最终结果的要素。这不仅包括我们熟知的代码依赖(可称之为“狭义依赖”),更涵盖了以上提到的所有“隐性”依赖。

这个“广义依赖”网络,在真实世界里到底有多复杂?在一个真实的、拥有超过8400万行代码、由1500多名开发者维护超过10年的大型通信产品中,其“广义依赖”网络错综复杂,仅仅是其构建视图的15%就足以让人望而生畏。

之所以要从这样一个巨大规模的案例来看,是因为随着规模的增长,很多原本细小琐碎、经常被我们无视的问题,就会被指数级放大。大规模研发场景就像一个放大镜,帮助我们看清问题背后的本质。

当这个“广义依赖”网络变得如此庞大且大部分不可见时,我们日常工作中的那些痛点就来了。这些看似混乱的现象,可以归结为三大核心痛点:

  1. 变更扩散路径不清晰:底层一个库的小改动,到底会影响谁?影响范围有多大?没人能完全说清楚,精确评估影响范围成了一个不可能的任务。至于开发环境、构建环境的变更所带来的影响,则常常被忽视。一次看似平常的工具链升级,就可能让整条流水线停摆,故障不断。
  2. 集成滞后且脆弱:我们的CI/CD充满了不确定性,“本地能跑,CI挂了”或“CI出错了,本地无法复现”的场景屡见不鲜。随着规模增加,我们不得不在“快但不完整”和“完整但很慢”之间痛苦地选择。我们的集成过程,充满了大量的人工干预,以及……祈祷。
  3. 构建效率低下:一次完整的发布构建,需要7到8个小时。这意味着开发者提交代码后,要到第二天才能知道结果。为了提速,我们只好退而求其次,每次提交代码只做局部的集成和测试。但这样会把更大范围的集成问题,推迟到每个月发布版本的时候。每次整体集成时,团队都要花上一周甚至更久的时间来解决这些累积的集成问题。

必然的演进:从“黑盒遍历”到“显性工程化治理”

面对这样的困境,我们必须寻求演进。在小规模时,我们依赖构建工具的“黑盒遍历”模式。比如我们运行 make,它会自己扫描文件,计算和发现应该做什么。这很直接,也很有效。但随着规模增长,哪怕你一行代码都没改,仅仅是“全局扫描”这个动作本身,就成了巨大的浪费,是研发流程的最大瓶颈。

在一个有大约2000名开发者、使用 Bazel 构建的有数百万行代码的项目中,“黑盒遍历”的瓶颈也暴露无遗。这里有两个巨大的时间开销:首先,哪怕你一行代码都没改,仅仅是启动构建后,Bazel 对整个代码仓进行扫描分析,这个“全量扫描”阶段,就需要6到8分钟。除此之外,为了利用缓存,系统还需要下载约40G由大量小文件组成的中间制品,光是下载就需要30多分钟。大家可以想象一下,在CI流水线上,有上千个构建节点同时运行,并发地进行着全量扫描,又同时去下载这40G的缓存,这常常会引发网络风暴,直接拖垮网络,让所有人的构建时间都变得更长,而且极不稳定。

所以,新的模式是必然的选择,那就是“显性工程化治理”。简单来说就是“分而治之”。它的核心思想非常简单:我们不能再被动地依赖工具去“发现”了,我们必须主动地、显式地去定义和管理 我们的工程体系。我们要把过去隐性的东西,全部显性化。

这种“分而治之”,和我们过去为了提速而做的“局部集成”,有着本质区别。过去的局部集成,往往依赖开发者的直觉和手动选择,但这很容易造成影响范围的错误评估,也很容易遗漏掉隐藏的依赖,最终把风险和问题都推迟到最后的大集成阶段。而这里所说的“分而治之”,是基于精确、显性的广义依赖网络 ,进行系统性的、可预测的拆分。它的目标不是简单地“少构建一点”,而是精准地“只构建必要的部分” 。

这样带来的改变是极为明显的:系统能够自动、快速地计算出任何变更的精确影响范围,然后只针对这个范围进行完整的构建和测试,而其他所有未受影响的部分则可以直接复用缓存。并且这种复用是以构建单元为粒度进行打包和下载的,避免了过去下载海量小文件的低效方式,效率也更高。对于开发者来说,体验就从“为了快,我只测一小块,然后祈祷别出问题”变成了“我每次都运行完整的全局构建,但系统能让它在几分钟内就完成”。这就在速度和完整性之间找到了完美的平衡。

显性治理的三大支柱:拆解、封装、验证

如何实现“显性工程化治理”?其答案可以归结为三大核心实践支柱,它们环环相扣,共同构建起一个可靠的软件生产体系。

支柱一:拆解 (Chunking) —— 定义清晰的边界与输入

“拆解”是“分而治之”的前提,其核心是“小而有界” (Make it small and bounded)。它要求我们将庞大的系统,拆解成一系列具有清晰边界、可独立管理的构建单元。然而,这项工作之所以困难,其根源在于我们用来指导拆解的“地图”——那张广义依赖全景图,本身就是不可见和不完整的。

要有效拆解,我们必须首先解决这张“地图”本身的三大缺陷,并采取相应的实践方法:

  1. 挑战:边界不清晰为了方便维护,我们常常会把源码各部分对环境的依赖合并后集中管理。这带来了好处,但恰恰是这种好处,模糊了每个构建单元的真实广义依赖边界。声明的依赖冗余了,就会在无关变更时重复构建,浪费资源;声明的依赖遗漏了,就会错误地命中缓存,造成难以排查的集成问题,最终只能靠“清空缓存再试一次”来解决。
  2. 挑战:存在“隐含依赖”CI服务器上的一个环境变量,基础镜像里的一个系统类库版本,这些真实地影响着构建结果的要素,却从未被正式地声明和管理,导致我们的依赖地图是不完整的。
  3. 挑战:连接被“切断”一个C++库怎么影响到一个Java服务?这种关系往往隐藏在某个CI脚本里,从任何一个语言的依赖工具看,这条连接都是不可见的。还有更糟糕的“幽灵依赖”,比如通过相对路径直接引用文件,这完全绕开了构建系统的感知。

为了绘制一幅精准的地图,我们的实践核心是将所有隐性要素显性化:

  • 精准拆解构建上下文:我们必须明白,相同的源码并不能确保得到相同的制品。因此,我们拆的不仅仅是源码,更是它背后的完整构建上下文。每一个构建单元都必须拥有一个独立的、精准且完备的构建上下文定义,完整地捕获这个单元所有的广义依赖:工具链、库、配置等等。而且,拆解的过程也可能包括合并,比如把几个小单元合并成一个更大的构建单元。在这个过程中,就必须显式地解决它们各自广义依赖之间的冲突。
  • 发现并固化隐式依赖:让所有“不可见”都变得“可见”。这就要把CI环境、基础镜像、外部工具这些过去被认为是“理所应当”的生产要素,全部进行版本化和声明化管理。虽然我们已经实践了基础设施即代码(IaaC),但很多时候,它也仅仅是把配置用代码的形式保存在了代码仓里。如果我们真的认为这些是代码,那就应该更进一步:不仅要声明,还要像代码一样建立起与业务代码的依赖关系。一旦建立了这种关系,被依赖的要素,就应该被自动化地创建和提供出来。
  • 用声明式关系修复连接断裂:单元与单元之间的依赖关系,必须被显式地声明出来,避免通过命令、相对路径或者一段胶水脚本来连接。

支柱二:封装 (Fabrication) —— 打造可靠且确定性的生产过程

在通过“拆解”定义了清晰的输入单元后,“封装”的目标是“完整可交” (Make it whole and shippable)。它要求依据单元所声明的全部广义依赖,通过一个确定性的生产过程,像一个现代化的工厂一样,可靠地将其构建并组装成可交付的产品。其黄金标准是实现可复现构建 (Reproducible Build)。

然而,这一过程面临两大核心挑战:输入的“非确定性”与过程的“不可靠性”。

  1. 挑战:输入的非确定性当一套代码需要支持跨平台、多硬件以及多样的用户定制化需求时,有效的产品配置组合便会呈指数级增长,形成“产品配置组合爆炸”。每一个差异化的产品,都是源码、编译选项、功能开关、定制化配置等一系列输入的特定组合。这些组合中的细微差异,都可能通过复杂的构建逻辑判断引入未被预期的行为。更棘手的是,许多无法复现的问题,其根源还隐藏在那些未被版本化管理的“幽灵依赖”中,例如特定的运行时环境变量,或调试时手工指定的命令行参数。问题的复现之所以变得极为困难,正是因为我们难以精确还原导致问题发生的那一组特定的、非确定性的完整快照。
  2. 挑战:过程的不可靠性构建过程中访问了网络,或者依赖了当前的时间戳,这些都会引入未声明的输入,污染我们的广义依赖集。更常见的是为了应急的手工热修复,绕过了标准流程,引入了无法追踪的变更,比如在构建过程中动态修改文件、打补丁等。随着规模增加,构建和发布过程会演变成一个由大量任务组成的复杂依赖图,这又会把不稳定的影响放大,降低整体的可靠性。

为了应对这些挑战,打造一条可靠的生产线,必须遵循三大实践原则:

  • 管理配置组合爆炸 (Managing Configuration Explosion):要解决输入的非确定性,必须从“命令式”转向“声明式”,将不同的产品配置(如功能开关)本身也作为一种显性的依赖来管理。这能极大简化构建逻辑,让复杂的配置组合变得可追踪、可审计,而不是隐藏在脚本的 if-else 判断里。
  • 建立封闭的构建行为 (Hermetic Builds):为了确保过程的可靠,必须将构建过程隔离在“沙箱”中。这能确保输入源的纯净,杜绝一切未声明的外部干扰,如网络访问、随机文件读写、未定义的环境变量等。
  • 保障过程的幂等性 (Idempotency):这是可靠性的核心保证,即“相同的输入,永远得到相同的结果”。更进一步说,一个幂等的构建任务,在成功执行一次之后,立刻再次执行,结果也必须完全相同,不能对环境或产物造成任何累加的副作用。这不仅是为了消除时间戳、随机数等不确定性,更是为了防止构建过程本身污染工作区和环境。只有保证了幂等性,我们才能确保每一次构建都是一次“干净”的执行,从而放心地依赖缓存,也为最终实现可复现构建打下了坚实的基础。

支柱三:验证 (Verification) —— 建立完整且不可篡改的信任链

通过“拆解”我们看清了输入,通过“封装”我们规范了过程。现在,我们来到了最后,也是最关键的一环:“验证 (Verification)”。其目标是“可证可溯” (Prove it and trace it),即为我们每一个交付的制品,提供一份无可辩驳的“身份证明”,让它的全部广义依赖和整个生产过程,都变得透明、可追溯、可审计。

这项工作的核心挑战在于,传统模式下我们缺乏一条统一的、覆盖所有广义依赖的证据链。开篇故事中的三重质询,就完美地揭示了这条证据链是如何层层断裂的:

  1. “源码可信吗?” —— 这仅仅验证了广义依赖中很小的一部分,证据链在此刚刚开始。
  2. “产品是用这份源码构建的吗?” —— 这开始挑战从“源码”到“产品”的转换过程,这是证据链上的第一处关键断裂。
  3. “构建过程本身安全吗?” —— 这实际上是在拷问,构建过程所依赖的其它所有广义依赖,比如基础镜像、工具链,它们都可信吗?这是更深层次、也更隐蔽的断裂。

因此,“验证”的核心实践,就是要为每一个制品,建立一条从所有广义依赖源头,到最终产物,完整、统一、不可篡改的证据链。这条证据链必须是完整的,要超越源码版本,记录所有影响构建的广义依赖——工具链、脚本解释器、环境变量、运行时参数配置、构建中用到的系统类库等等。它也必须是准确的,最终产物的任何一个字节级的差异,都必须能在这条证据链里,找到对应的某个广义依赖输入的变化。

而可复现构建 (Reproducible Build),正是对整条广义依赖证据链最强有力的物理保证。它将“信任”,从一句主观的“相信我”,变成了一项客观的、任何人都可以去验证的事实:“你可以自己去验证”。

结论:构建数据驱动的自验证闭环

“拆解(Chunking)”、“封装(Fabrication)”和“验证(Verification)”这三大支柱,并非孤立存在,而是共同构成了一个数据驱动的、自验证的工程闭环,这正是整套体系的核心精髓所在。

首先,它们形成了一条清晰的价值链:精确的“拆解”,为我们定义了清晰、有边界的单元及其广义依赖,这是所有信任的起点。这些清晰的输入,驱动了可靠的“封装”生产过程。可靠的过程,产出了带有完整广义依赖证据链的制品,从而可以用于“验证”。

更重要的是,“验证”的结果为这个闭环提供了强大的反馈机制。一方面,像一次不可复现的构建失败,会立刻暴露我们“拆解”时的不足,驱动我们去发现并固化新的隐式依赖,从而完善我们的依赖地图。另一方面,也是最关键的一点:既然一条完整的证据链能够解释产物的任何差异,那么它本身就构成了一份经过事实检验的、最权威的广义依赖清单。这意味着,我们可以从最终的证据链中反向推导出构建这个产物所需要的一切输入,从而用最终的结果,来验证我们最初对输入的定义是否精准、完备。

至此,一个自验证的闭环便形成了。我们不再是单向地“定义输入 -> 执行过程 -> 得到输出”,而是让输出的结果反过来证明我们对输入的定义是正确的。

回顾我们日常工作中遇到的构建效率、可靠性、漏洞追溯、架构治理等看似孤立、分散的问题,其实它们拥有一个共同的根源——广义依赖管理的缺失。通过“拆解-封装-验证”这套显性治理框架,我们可以在一个统一的视角下,系统性地解决这些问题。最终目标,是建立一个数据驱动的、可追溯、可自验证的,真正值得信赖的现代化软件生产体系。

(END)

我写了一个 Alfred Workflow,用于新建和加入 Zoom 会议。
现在支持免费和付费帐号,如果没有登录也可以直接进入会议。

使用方法:
1、直接在 Alfred 里面粘贴 Zoom 的会议链接,按下回车就可以自动加入会议。如果 Zoom 还没有运行,也会自动运行起来。
2、 直接在 Alfred 里面粘贴 Zoom 的会议链接后,按下 Command 键会 新建一个会议,而不是加入现有的会议。并且会自动把新建的会议的 url 放入剪贴板,我们只要直接粘贴就可以。
3、有 `zm` 关键字,直接按下回车就是新建一个会议;
4、在 `zm` 关键字后面可以继续添加 meeting id,按下回车会自动加入;
5、在 `zm` 关键字后面无论是否有 meeting id,按下 Command 键都会新建一个会议。

源码:https://github.com/chaifeng/alfred-zoomus

下载:https://github.com/chaifeng/alfred-zoomus/releases

This Alfred Workflow is used to start or join a Zoom meeting.

Support free or paid account, you can join a meeting without logged in

Usage:

  1. Paste a Zoom meeting URL in Alfred directly, press Enter, this will join an existing meeting.
  2. Paste a Zoom meeting URL in Alfred, press Command + Enter, it will start a new meeting. And put the URL of this new meeting into your clipboard automatically.
  3. A new keyword “zm” is used to start a new meeting.
  4. Append an existing Zoom meeting ID after the keyword “zm”, it will join this meeting.
    for examples: zm 123-456-789, zm 123456789
  5. Whether or not there is a meeting ID after keyword zm,press Command + Enter will always start a new meeting.

Source code: https://github.com/chaifeng/alfred-zoomus

Download: https://github.com/chaifeng/alfred-zoomus/releases

Dreamhost 的空间就要到期了,实在无法忍受在中国访问的龟速了。

昨天正式迁移到 Linode 东京机房,速度那叫一个爽,基本上带宽全占满。

前天是女性朋友的节日,大早上的写了一个段子,昨天竟然发现上了新浪微博的热门,Cooooool。

兜兜的照片也好久不放了,很纠结,担心万一被坏人利用把我家兜兜给骗走。

把基本情况先简述一下,首先是周六太原大雨,影响了到场率,不过还是有将近40人,而且还看到一些新面孔。其次是两位讲师的话题都很给力,演讲都超时,而且QA环节大家都很踊跃的提问。郭振的话题用了1个半小时才结束,张龙的话题更是达到了2个小时。最后5点50结束本次活动。

以下文绉绉的总结出自专业编辑李洋之手:

7月21日,QClub太原站如期在山西出版传媒集团一楼会议室举行,这已经是QClub第三次来到太原举行活动。今天的龙城太原下起雨来,这给炎炎夏季带来了丝丝凉意;而这场期盼已久的QClub技术社区活动对于技术相对滞后的太原地区而言,也是如同久旱逢甘霖一般及时与酣畅。此次活动中,增添了很多新的面孔,他们为QClub太原站社区活动添加了新的血液与活力。

大雨并没有浇灭前来参加活动人们的热情,在不小的会议室当中坐得是满满当当。技术开发人员也都希望在这场难得的“Android应用开发”的主题讲座中汲取自己所需要的养分并希望借此来解决在工作中所遇到的实际问题。好了,我们现在直入主题,看看讲师们今天会给我们分享那些先进理念与经验以及会带给我们哪些意想不到的惊喜!

首先是来自盛大创新院的高级研究员、乐众ROM项目组总架构师郭振分享的 Android 备份框架的架构与设计,以及如何将自己的服务集成到 Android 系统中。此次活动有一点明显地改善就是参与者的积极性与主动性较前两次有了显著地提高。在互动环节当中,参与者与讲师之间的交流更加自然,提问也是更加踊跃,奖品更是抢手……

接下来是联想集团全球应用开发部的高级工程师、InfoQ翻译团队编辑张龙分享的Android跨进程通信机制与AIDL,介绍了Activity与Service之间的通信原理。张龙这个名字,大家一定不陌生,这已经是第二次来到太原与大家分享经验,算是QClub太原站的老朋友了。大家对于张龙刚刚翻译出版的《Android Web应用高级编程》这本书产生了浓厚的兴趣,这本书涉及到一些跨平台移动开发技术的内容。这本书也是作为活动中的奖品来发放,在互动过程中大家的热情都很高涨,很想要获得这本精美IT图书,因此互动问答过程就格外积极与踊跃!

这已经是山西书海数字网络传媒科技有限责任公司第三次成功举办QClub太原站活动了,书海传媒对于推动太原地区IT技术交流,应该说是功不可没。最后,我们还是要再次感谢QClub太原站活动的本地赞助商——书海传媒的鼎力支持。

from 2002-06-14

 

08. May 2012 · 2 comments · Categories: Uncategorized · Tags:

先说这次活动的问题,一定要自我检讨,这次竟然忘记安排摄影师了,最后只有区区可数的几张照片。

再说高兴的事情,本次活动签到人数首次达到53人,这也是从去年7月开始第一次组织活动以来的最高记录。可能也和这次活动的主题《前端开发与用户体验》有关系吧,在太原的设计师多数都是美眉。从现场照片上也能看出,女生比以往的几次活动明显多了,然后……咳咳……人就来的多了。

然后说说讲师吧,分别是来自赞助商快乐妈妈(www.happymama.cn)的设计师赵敏,和来自阿里云的交互设计专家安勇。本地讲师与外地讲师相比,明显在演讲经验和内容的组织上比较欠缺,希望通过以后的活动可以提高本地讲师的演讲水平,也能够让太原的讲师去外地做分享。

这次活动准备的礼品还是比较多的,十本书和二十张2012QCon北京大会资料光盘。依然还是贯彻只要你参与或者与讲师互动,就能获得小礼物的宗旨。到活动的结束,就剩下了5张光盘。基本上都是在阿里云交互设计专家安勇的演讲结束后的提问环节,大家的热情都很高,一口气就发出了20份的礼物,让安勇都有点快招架不住了。

最后的 Open space 环节,与上次相比,稍显冷清了一点。可能因为设计师美眉们好多都提前离场的原因,然后……咳咳……人就走的更多了。

讲一个有趣的小插曲,本地赞助商快乐妈妈(www.happymama.cn)的技术负责人叫安磊,他在接到安勇后做自我介绍。结果安勇以为对方把他的名字记错了,还更正说“不是安磊,是安勇”。

总结,本次QClub太原站参与人数超出了预期,终于摆脱了参与人数倒数第一的帽子,以后一定要继续吸引IT美眉的参与。活动中大家的参与度还是比较高的,可能是大家都很关心这个主题,也可能是因为外地讲师是中韩混血帅哥。忘记安排摄影师了,重大失误。

最后一定要再次感谢赞助商快乐妈妈(www.happymama.cn),谢谢,这次的活动真的真的很成功。