软件工程的来龙去脉

前两讲把软件看作意图、规约和实现的组合体,并用版本管理保存它们的演化。这一讲回到更早的问题:从一个空项目、一句模糊的需求出发,我们怎样得到一个真正能用的软件系统?

今天,一个很自然的答案是:先让 AI 做出来看看。课堂展示的 Office Assistant 就是由 GLM-5.3 直接生成的原型。以前需要花很多时间补齐的细节,现在模型可以迅速替我们完成。但软件工程曾经并不是这样开始的。理解前人为什么发明需求分析、开发流程、契约和建模语言,能帮助我们判断:当写代码的成本骤降,哪些困难随之消失,哪些困难只是换了一个位置。

不断移动的能力边界

摧毁性的事情一直都在发生。我们刚开始习惯让大语言模型逐字生成答案,就会看到另一种交互方式。课堂讨论的 Jev 演示 让人联想到人类的 System I:不需要先在脑海中写完一篇分析,就能对眼前发生的事情作出快速反应。TypeSafe AI 的技术介绍强调,在一次查询中并行输出结构化结果。课堂上“是不是一个 prefill only 的 Transformer”的说法,是对实现方式的猜测;真正值得追问的是,实时决策是否一定要经过我们已经习惯的逐字生成过程。

课堂还讨论了 Noam Brown 关于工作和 taste 的访谈。Taste 可以理解为“对接下来该做什么的直觉”:面对许多可能的方向,哪一个问题值得问,哪一个实验值得做。如果 AI 能完成一个人工作中的 90%,这个人的注意力自然会转向剩下的 10%。但剩下的部分并不因此成为永久属于人的领地。他预测,再过一两代模型,模型在这种判断上也会超过自己。这个预测提醒我们,不要把今天模型的短板当作人类的永恒优势。

一个相关的问题是:某种能力能不能获得可靠的反馈?写程序至少可以运行测试,数学证明可以检查推导;选择研究方向、澄清需求、管理长期记忆,看起来更难评价。但“难以评价”也未必意味着永远无法训练。研究方向可以通过后续实验检验,需求澄清可以通过最终交付是否符合用户意图来评价。反馈可能更晚、更贵、更有噪声,这些都是工程问题。

Richard Sutton 的 The Bitter Lesson 为这类讨论提供了一个背景:能够持续吸收更多计算资源的通用方法,往往会超过依赖人手编码领域知识的方法。沿着这个思路,课堂提出“数据分布工程”的设想。训练的关键不只是收集多少数据,还包括在什么阶段,以什么比例、难度、顺序和训练方式接触哪些经验,再根据学习进展、跨领域迁移和泛化表现不断调整。

例如,空间变换、图形规律和几何构造可以由生成器出题,再用约束检查答案;并发协议和故障诊断可以通过实际运行、模型检查和故障注入获得反馈;主动实验、记忆管理和需求澄清,则可以联系后续预测与任务结果。于是,“增加可训练的领域”本身也可能成为扩展能力的方向。

更强的模型又能参与寻找新领域、构建训练环境和验证器、调整数据分布。这就形成了一个可能的循环:发现领域,建立反馈,获得新能力,再用新能力改进下一轮训练。递归自我改进,也就是 RSI,关心的正是能力生产过程本身能否被持续改进。这个循环能不能转起来,要看每一轮是否带来经过独立验证的净收益。模型给自己打了更高的分,并不等于能力真的提高了。

从空项目出发,AI 补全了什么

回到软件开发。Intent 是人的意图,Spec 是明确写下的要求,Impl 是满足要求的实现。我们把这些内容存成文件系统的快照,不断推出新版本,直到软件符合需求。多人从同一版本出发,自然会出现分叉;不同修改重新汇合,自然会遇到冲突。SVN、Git 和 Jujutsu 的许多设计,都可以从这些实际需要中理解。

版本管理回答了怎样保存、组织和比较变化,却还没有回答第一份软件从哪里来。面对一句“我想要一个办公助手”,今天可以先生成一个最小可行产品,也就是 MVP,让人实际使用,再继续修正。原型把抽象的想象变成了可以点击、可以批评的对象。

这个过程之所以快,是因为模型替我们补齐了大量没有说出口的决定:界面如何组织,数据如何保存,操作失败时如何反馈。第一次演示很顺利,只能说明这些决定在演示场景下能够组成一个可运行的系统。它们是否符合用户真正的需要,还要继续检验。

在大语言模型出现以前,补齐这些细节需要昂贵的人力。接到任务就立即安排所有程序员写代码,往往会让不同的人沿着不同的理解前进。等代码拼在一起,大家才发现“同一个需求”在每个人脑海里根本不是同一件事。这正是软件工程要面对的问题。

软件危机从哪里来

回到 1960 年代,软件还是新生事物。编程工具不成熟,写程序需要大量专门知识,程序员很贵;当然,计算机硬件往往更贵。一个人擅长编程,并不自动意味着他理解客户的业务、商业目标,或者能设身处地地理解使用者。客户同样未必知道计算机能做什么,更难直接给出一份没有歧义的技术规格。

软件企业承担的任务,就是把客户的意图翻译成可以工作的系统。Intent、Spec 和 Impl 之间的差距,从一开始就存在。

软件需求还有很强的定制性。同样叫“选课系统”,有的学校先到先得,有的抽签,有的给本专业学生预留名额。先修课不满足时是否允许例外,谁来批准?有人退课后,空出的名额给候补学生,还是重新开放抢选?这些差别对应的是学校的制度和责任分配。“做一个选课功能”还远远没有把需求说完。

当时的人已经对计算机抱有很大的期待。1968 年 9 月的《Boys’ Life》描绘了“明日学校”:计算机参与评分、发现学生的薄弱环节,让学生按自己的节奏学习。这些想象今天读来依然熟悉。可是,从“因材施教”到系统实现,中间还有许多决定:怎样判断一个人掌握了知识?答对一次算不算?做题快是不是理解得好?教育愿望不会自动变成清晰的程序规格。

人们想做的事情越来越多,开发进度、成本、质量和维护却越来越难控制,这些问题被称为“软件危机”。更快的机器并没有自动消除危机,因为它也让人们开始尝试规模更大、要求更高的系统。

Dijkstra 在 1972 年的图灵奖演讲 The Humble Programmer 中回忆,1957 年结婚登记时,“程序员”还不能被接受为一种职业,他最后填写了“理论物理学家”。十几年之后,软件已经成为一个必须认真面对的大问题。他在 Go To Statement Considered Harmful 中关于程序员水平与 goto 数量关系的尖锐评论,也不只是语言风格之争:人的推理能力有限,程序的结构应该帮助我们理解执行过程。

1968 年的 NATO 软件工程会议报告 则把工程的理论基础和实践纪律带进了对软件生产的讨论。“软件工程”这个名字本身就带着挑战意味:能否像对待其他复杂工程一样,有组织、有依据地开发和维护软件,而不是只依靠少数高手的个人能力?

在约束下,让系统继续工作

Margaret Hamilton 及其参与的阿波罗软件工作,是理解软件工程的一个具体入口。登月软件面对的要求不只是“算法算得对”,还包括有限的存储空间、严格的时间要求,以及意外发生时系统还能做什么。NASA 的 Hamilton 介绍记录了她在这一领域的工作。

阿波罗 11 号登月过程中出现了 1201、1202 程序警报。按照参与者在 Apollo 11 Program Alarms 中的回忆,系统在资源不足后重新初始化,并恢复关键任务,使发动机控制等重要工作得以继续。这套恢复机制事先经过了大量测试,地面人员也因此能够判断可以继续任务。

这件事把“正确”的含义向前推进了一步。只考虑正常路径,程序可能很漂亮;把资源限制、异常情况、恢复能力和人的操作一起考虑,才是在构造一个能够交付的系统。

因此,软件工程研究的是:在成本、时间等约束下,怎样把人的意图变成可以工作、可以运行和维护的软件系统。 它覆盖团队组织、需求沟通、设计、测试,也覆盖代码风格等看似细小的约定。IEEE 定义中强调的系统化、纪律性和可量化,正是希望把这些工作变成能够组织、检查和改进的过程。

Royce 真正在担心什么

Winston Royce 在 1970 年发表了 Managing the Development of Large Software Systems。他讨论的背景包括航天器任务规划、指令和任务后的数据分析,核心问题很直接:怎样按时、不超预算地交付大型软件?

这篇文章画出了后来与“瀑布模型”联系在一起的阶段图:先分析需求,再设计、编码、测试,最后投入运行。但只记住这张图,很容易读反文章的意思。Royce 随后就指出,照这样简单地推进风险很大,会招致失败。

这种流程隐藏了一个乐观假设:即使需要返工,也主要发生在相邻阶段之间。编码时发现局部设计不好,就修改一下设计;测试时发现代码错误,就回去修代码。只要回退范围不大,进度似乎仍然可控。

问题是,实际测试可能暴露的根本不是一个局部编码错误。Royce 最锋利的观察是:运行时间、存储占用和输入输出等约束,常常到了测试阶段才第一次真正被“经历”,此前只是被“分析”。对复杂系统而言,分析并不总能准确预测运行时会发生什么。

例如,一个控制算法在数学上完全正确,却无法在规定的控制周期内完成。此时删掉几条指令可能毫无帮助,需要更换算法、重新划分任务,甚至重新协商系统原本承诺的功能。问题就从测试一路回到了设计和需求。此前围绕旧假设完成的大量工作,都可能需要重做。

Royce 的建议之一是“do it twice”:在正式交付之前,先用一个 pilot model,也就是试验性系统,尽早接触那些最危险的不确定性。第一次实现的价值,在于让实际运行给设计提供反馈。

用今天的语言看,这与 MVP 有共同之处:先花有限的代价获得证据,再决定后续投入。但两者的关注点并不完全相同。MVP 常用来检验用户需求和产品假设,Royce 的 pilot 尤其关心大型系统的技术和运行风险。敏捷开发则把短周期的实现、反馈和调整反复进行,让这种学习不只发生在一个单独的预演阶段。

把开发流程看成数据依赖

课堂给出了另一种理解阶段图的方法:把它看成一张数据依赖图。

设计需要消费需求中的信息,否则设计会沿着自己的想象漂移;实现依赖设计和规约,否则不同模块可能各说各话;运行测试需要实际实现,判断结果是否正确又需要需求或规约提供依据;交付后的使用和维护,则不断产生新的事实,推动下一轮变化。

这些依赖真实存在,但它们并没有规定所有需求必须一次写完,所有设计必须一次完成,也没有规定每个阶段只能经过一次。我们完全可以只选一小部分需求,完成对应的设计、实现和验证,再根据反馈扩展。

测试驱动开发,也就是 TDD,进一步提醒我们分清两种工作:写出期望行为,与执行程序检查行为。前者可以发生在实现之前,后者需要实现。先写一个测试用例,往往就是在澄清需求:空输入该返回什么,余额不足时该发生什么,同一个请求重复提交算几次?

于是,比较开发方法时,一个有用的问题是:关键反馈会在什么时候到来,在它到来之前,有多少工作已经建立在未经检验的假设上? 原型、MVP 和短周期迭代,都可以从这个角度理解。它们帮助我们调整工作的顺序,控制错误假设影响的范围。

这也解释了为什么值得重新阅读经典。过去,查找原始论文、恢复历史背景、沿着引用继续查证,成本很高,教材承担了知识的“有损压缩”。压缩当然有用,但如果只留下了 Royce 的阶段图,丢掉紧随其后的批评,得到的知识就可能与原文相反。

AI 降低了查找、翻译和串联一手材料的成本,让我们更容易建立自己的知识网络,再根据问题按需压缩。课堂阅读 Royce 的尝试就在做这件事。经典即使有一部分已经过时,也常常保留着发明者第一次面对问题时的观察。把背景恢复出来,我们才能区分:哪些做法依赖当年的工具条件,哪些洞见至今仍然成立。

方法和工具怎样减少返工

软件工程为不同阶段创造了不同的表达方法和工具。需求文档、用例和界面原型帮助人们讨论“到底想要什么”;实体关系、状态迁移、模块和接口帮助人们讨论“系统怎样组织起来”;编程语言则把这些决定落实为可执行的行为。计算机专业的大部分课程集中在实现层,相当于从底向上认识这个过程。

这些方法通常会迫使我们回答一些容易遗漏的问题。例如,商店说“学生有优惠,满一定金额也有优惠”,把条件列成决策表,就必须决定学生折扣能否与满减叠加,门槛按原价还是折后价计算。表格本身并不复杂,它的价值在于把含糊的组合情况摆到了人面前。

QuickCheck 所代表的基于性质的测试,则让我们先表达一类输入都应该满足的要求,再生成输入寻找反例。以排序为例,“输出有序”还不够,因为一个永远返回空列表的函数也满足它。我们还需要要求输出保留输入中各个元素及其出现次数。写性质的过程,也是在发现自己究竟想让程序做什么。

需求可追踪性关心的是另一类遗漏:需求、设计、实现和测试之间是否还保持联系。假设退课规则改成“仅开学第一周允许”,我们需要知道哪些界面、权限检查、状态迁移和测试受到影响。把这些关系维护起来,才能从一项变化找到受影响的工作。NASA 的需求管理说明讨论了这类追踪关系。

这些例子的共同作用,是让决定更明确,让矛盾更早暴露,让后续工作有可以依赖的依据。接下来看的契约和 UML,则进一步追问:能否为这些依据设计一种更准确、也更容易由机器检查的语言?

契约:调用者与实现者各自承诺什么

Bertrand Meyer 在 Applying “Design by Contract” 中讨论了契约式设计。它的出发点很朴素:一个组件调用另一个组件时,双方应该明确各自的责任。

前置条件规定调用者需要保证什么;在前置条件成立的情况下,后置条件规定实现者需要保证什么。类不变量则描述对象在对外可用的稳定状态下应当满足的条件。它们一起构成了接口的语义,而不只是参数和返回值的类型。Eiffel 的契约介绍 给出了这种方法在语言中的表达。

例如,一个不允许透支的账户,可以把取款操作的前置条件设为“金额为正,且不超过当前余额”,把后置条件设为“新余额等于旧余额减去取款金额”。下面用 Eiffel 风格的代码表示这个过程,省略外围的类定义:

withdraw (amount: INTEGER)
    require
        positive_amount: amount > 0
        sufficient_balance: amount <= balance
    do
        balance := balance - amount
    ensure
        balance_decreased: balance = old balance - amount
    end

这里的 old balance 表示调用开始时的余额。账户还可以有一个类不变量 balance >= 0。具体实现怎样存储余额,调用者不必知道;但只要满足前置条件,就有权依赖后置条件。

这份契约也明确划出了边界:余额不足的请求没有满足它的前置条件。如果产品要求把“余额不足”作为一种正常业务结果返回,就应该另行设计契约,例如规定请求失败时返回失败状态且余额不变。不能一边接受任意取款请求,一边在余额不足时用“调用者不该这样调用”逃避业务要求。契约描述的是双方真正同意的责任。

把条件写进语言后,工具可以在运行时检查某次调用是否违反约定,也可以在适当的语言和逻辑系统中,对实现是否满足规约作静态验证。DafnyVerus 都延续了把规约与实现联系起来的方向。一次断言检查通过,说明这次执行没有触发相应违例;一个验证结论,则取决于写下的规约、假设及工具所支持的推理。它们都需要我们先把真正关心的性质表达出来。

重新阅读 UML

UML 是 Unified Modeling Language,统一建模语言。它试图为面向对象系统提供共同的模型元素、记号和语义,让需求和设计在尚未变成代码时,也能被较准确地交流。

自然语言能直接调动人的经验,却容易让同一句话在不同人的脑海里产生不同解释。数学语言则通过共同定义,让来自不同语言背景的人讨论同一个对象。UML 也在做某种语义对齐:一个箭头表示什么,一个对象处于某种状态意味着什么,一个接口承诺了什么,都需要共同的解释。

很多人学 UML 时的印象,是记住各种图形和箭头,考试后再慢慢忘掉。课堂回顾了从本科接触 UML、到博士阶段阅读相关论文,再到借助 AI 重读规范的经历。重新打开这份很长的文档,可以带着一个贯穿全篇的问题:哪些意图必须明确固定下来,哪些选择可以留白?

如果一个人永远不改主意,对同一句话永远给出相同解释,他也许觉得无需把脑海里的设计写出来。但真正的软件开发要与别人协作,也要与下个月的自己协作。一个有用的模型,会把原来没有意识到的问题逼出来:用户连续点击两次“支付”,究竟算一次授权,还是两次?

我们不必先决定 UML 是否还流行,才能从它的设计里学习。即使某种工具不再常用,创造它时面对的问题和采用的第一性原理,仍然可以指导今天的软件开发。下面沿着 UML 2.5.1 规范 看五个这样的设计问题。

图与模型:意义保存在哪里

第一件事是区分图与模型。模型包含元素、关系和约束;图是对其中一部分内容的展示。同一个模型可以有不同视图,分别突出结构、状态或者交互过程。一张图没有画出某项信息,不足以说明底层模型不存在这项信息。

以选课为例,“学生与课程之间存在选课关系”还不足以回答每人能选多少门课、一门课能容纳多少人。这些数量约束属于模型。某张用于介绍整体结构的图省略了多重性标注,我们就不能仅凭省略的画面替模型作决定。

UML 规范本身也把语义落实为机器可读取的数据。它的第 2 节规定,规范正文与随附的规范性 XMI 文件发生冲突时,以后者为准。XMI 是一种用于交换模型信息的格式。这个优先级针对 UML 规范自己的定义文件,并不意味着任何项目都应该无条件相信代码而忽略文档。参见规范第 2 节

这种设计对工具之间的协作很有启发。把一张类图保存成截图,人还能看懂一些意思,但另一个工具很难可靠地知道某条线连接的是哪个模型元素,更难持续追踪这个元素的变化。UML 连图的展示信息也有专门的交换模型 UML DI,用来保留布局等内容。

我们可以把这里的经验推广到自己的项目:凡是需要跨工具、跨时间保留的意图,都应当找到显式的数据表示。图、文字和表格可以各自服务不同的读者,但它们谈论的元素和约束需要能够对应起来。否则需求文档改了,设计图没改,测试还坚持第三种解释,语义就会逐渐分裂。

契约与实现:这次成立,还是每次都应成立

第二件事是区分具体行为与行为必须满足的要求。一个实现成功处理过一次请求,并不能自动说明它在所有约定场景下都会满足要求。

UML 用前置条件、后置条件、不变量、协议状态机和多重性等机制表达这些要求。例如,一个查询支付状态的操作,如果声明为查询,就不应该顺手改变系统状态;“查询是否付款”不能暗中完成一次扣款。协议状态机则可以描述某个操作在什么状态下才允许发生。

继承也带来了类似的问题。如果一个子类声称可以替代父类,使用父类接口的代码能否继续依赖原来的承诺?UML 的 isSubstitutable 可以表达这样的可替换性要求。但写下这个声明,并不等于工具已经证明所有行为都满足它。规范在第 6.3.3 节明确区分高层的稳定要求和详细行为,保证两者一致仍是建模者的责任,工具可以提供帮助。参见规范第 6.3.3 节

自然语言当然也能表达普遍要求。“任何重试都不能重复扣款”就是一个清楚的意图。困难在于,这句话不会自动检查后面的实现是否遵守了它。几个成功样例也覆盖不了所有重试、超时和并发组合。

因此,在 AI 生成代码之前写下约束,在代码生成之后用测试、检查或验证把它们联系起来,才可能让原来的承诺持续生效。否则一次演示中碰巧成立的行为,很容易被当作系统已经具备的性质。

留白也是一种决定

第三件事是承认模型可以有意不把一切说完。设计一个语言,并不意味着要求使用者在第一天就穷尽所有细节。

UML 的交互语义区分合法执行轨迹和非法执行轨迹。轨迹可以理解为系统事件发生的一种序列。这两个集合不一定覆盖所有可能的轨迹,因此还可以存在模型尚未表态的行为。参见规范第 17.1.2 节

仍然以支付为例,一份早期模型可能作出这样的区分:

  • 合法:用户确认支付,系统扣款成功,再返回成功结果。
  • 非法:用户没有确认,系统已经扣款。
  • 尚未规定:用户确认后请求超时,系统是否自动重试。

第三类不能直接当成已经获得业务认可的行为。它说明还有决定没做。如果实现者自行选择自动重试,就必须进一步回答:一次超时是否可能意味着扣款已经完成?怎样保证再次请求不会重复扣款?

规范中的语义变化点,也体现了保留选择空间的设计,不过它与某个用户模型没有写完并不完全相同:前者是语言本身把某些语义选择留给具体使用环境,后者是这份模型还没有约束某种情况。两者都提醒我们关注尚未确定的部分,而不是把它们误认成唯一答案。

形式化有成本,可以把成本花在歧义大、违反代价高、或者需要机器继续推导的地方。早期业务草图只要足以支持评审,就已经有价值;需要执行或验证时,再补足相关语义。明确哪些地方还没有决定,能让这种渐进过程更可控。

不同的含义,需要不同的表达

第四件事是把语义上不同的维度分开。自然语言和随手画的图,经常让一个记号同时承担多种没有说清楚的含义。

例如,两条记录当前具有相同的属性值,并不说明它们是同一个对象;修改其中一个,另一个是否跟着改变,取决于身份和共享关系。把某类对象定义成另一类的特化,与规定某个集合中的成员必须属于另一个集合,也是在表达不同层面的约束。

UML 对关联关系的处理提供了更直接的例子。聚合、可导航性和关联端的所有权各有自己的含义和记号。规范还明确废弃了把“可导航”直接等同于某种所有权的旧约定。参见规范第 11.5.4 节

在选课系统里,从学生对象能找到课程,首先回答的是访问关系;关联端的属性归谁拥有,回答的是模型结构;对象是否随另一个对象一起销毁,又涉及生命周期。不能因为画了一根箭头,就默认这些问题都已经获得了相同答案。删除一个学生的选课信息,显然不应该顺手删除整门课程。

时间顺序也一样。交互图上的上下位置,不能总被解释为全局串行执行。UML 的 seq 表示弱顺序:保留各片段内部的顺序,也保留同一生命线上前后片段的事件顺序;来自不同生命线、不同片段的事件仍可能交错。生命线表示交互中一个参与者随时间发生的行为。参见规范第 17.6.3.11 节

因此,短信通知的行为画在仓库备货行为上方,并不自动要求短信必须全部发送完,仓库才能开始工作。如果业务确实要求两者严格有先后,就需要明确的约束,或者使用相应的严格顺序表达。顺序、并发、失败路径和责任归属,都值得分别说明。

这类细节看起来繁琐,却正是软件接口容易产生分歧的地方。人类程序员会用经验补齐它们,语言模型也会。只要双方的经验不同,或者同一个模型两次补齐的结果不同,模糊之处就会转化成行为差异。

模型本身也需要检查

第五件事是让模型受到约束。既然模型由明确的元素和关系组成,就可以像检查程序的类型一样,检查模型是否满足结构上的规则。

OCL,也就是 Object Constraint Language,对象约束语言,是表达这类条件的一种工具。模型中的类型、多重性,以及活动中输入输出之间的兼容要求,都可能成为检查对象。UML 还用元模型描述自身:元模型就是规定“模型可以由什么组成、它们怎样关联”的模型。MOF(Meta Object Facility,元对象设施)为这类定义提供了基础。

扩展机制也需要边界。UML 的 Profile 可以为特定领域增加标记和约束,但扩展需要尊重原有语义。否则大家都声称使用同一个语言,一个工具理解的“对象”却可能是另一个工具眼中的完全不同的东西。

把惯例逐步写成可检查的规则,会让许多错误在执行之前暴露。不过,通过模型的良构性检查,只说明它符合被检查的建模规则,并不自动证明所有实现都符合业务需要。一个语法完全正确的支付模型,仍然可能遗漏重复扣款问题。

UML 规范也没有把全部语义都化为 OCL。文档中的一些约束明确标注“Cannot be expressed in OCL”。这意味着相关规则仍需通过其他方式表达和理解,不能据此宣称所有语义都已实现机械判定。例如,ObjectFlow 的约束就包含这类情况。

机器检查的价值在于稳定执行明确的规则。规则是否写对、是否覆盖真正关心的问题,以及需求改变后是否仍然适用,仍然需要持续维护。

LLM 时代,差距移到了哪里

UML 试图通过共同的语言,把意图整理成明确的结构和约束。大语言模型让另一条路径变得可行:直接从自然语言生成程序。以前需要人工补齐的中间步骤,现在经常可以一跃而过。

但规约与实现之间的差距仍然存在。规约约束允许出现的行为,具体实现则要选定数据结构、算法、状态更新和异常处理方式。一个并发实现本身也会有多种执行轨迹,所有相关行为都应当落在承诺的边界内。

当用户没有说明“支付超时后怎么办”,模型为了完成程序,往往会选一种处理方式。第一次可能等待,第二次可能自动重试,第三次可能直接返回失败。三份代码都能运行,接口看起来相同,正常路径的测试甚至全部通过,但它们已经包含了不同的业务决定。

这就需要有人持续维护三类信息:已经决定的行为,明确禁止的行为,以及尚未决定的行为。对支付系统,我们可以要求扣款前必须获得相应确认;规定同一订单的累计扣款不能超过应付金额,并明确把重试和并发回调纳入这个要求。至于内部采用什么存储结构、如何组织请求,只要满足这些承诺,就可以保留实现自由。

承诺可以先写在自然语言里,再根据问题的代价和工具能力,落实为接口契约、状态机、断言、测试或验证条件。它们需要能独立于某次聊天和某份实现被保存、检查,并在需求变化时一起更新。版本管理保存这些决定的历史,可追踪关系把决定与实现及证据连接起来。

AI 越能快速生产代码,这个接口就越值得认真设计:哪些事已经决定,哪些行为绝不能发生,哪些细节可以交给实现者选择。给已决定的事一个显式、类型化、机器可检查的表达,我们才更有可能在快速改写实现时,继续保有对软件行为的控制。