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

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 Cen_US

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

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

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

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

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

原来的认识:

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


后来发现:

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

原来的规则没有错。

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

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

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

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

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

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

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

真正需要比较的是:

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

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

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

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

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

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

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

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

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

上次排序问题就是 locale。

更有价值的是:

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

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

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

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

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

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

他往往只是更早知道:

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

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

它更常见的价值是:

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

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

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

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

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

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

通常没有必要。

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

探索历史则继续解释:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

但记录只解决了第一步。

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

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

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

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

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

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

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