课程页面:《软件工程的来龙去脉》 · 视频

生成式 AI 已经可以从一个空项目和一句模糊需求出发,迅速补齐界面、数据结构、异常处理和工具调用,生成一个能够演示的系统。实现成本下降以后,软件工程似乎应该失去存在的理由;但真正发生的事情恰恰相反:实现变快了,隐藏的决定也变多了,错误假设会更快地扩散到代码、测试和部署中。
软件工程一直在处理同一个问题:怎样把人的意图变成一个可以工作、可以验证、可以维护的系统。AI 改变了其中一段翻译过程,却没有自动替人决定需求、约束、失败边界和验收证据。理解软件工程的来龙去脉,正是为了看清哪些工作可以交给 Agent,哪些决定必须被显式保存。
一、实现成本下降以后,问题转移到了哪里
模型的能力边界正在快速移动。实时决策模型可以在极短延迟内根据结构化状态输出动作,不必像传统大语言模型那样逐字生成一段面向人的解释;更强的模型也开始处理空间关系、工具操作和跨模态任务。对软件生产来说,这意味着许多过去需要手工补齐的细节,现在可以先让模型生成,再通过运行结果继续修正。
但“能做出来”不等于“值得这样做”。模型容易追逐立即可见的反馈,用最快的方式完成当前任务,却未必能判断什么问题最值得解决、哪些取舍会在长期维护中付出代价。品位、需求澄清和系统边界看起来不像排序或编译那样容易验证,只是说明反馈更晚、更贵、更有噪声,并不说明它们永远不能被工程化。关键在于把这些能力放进可观察的环境,用实验、任务结果和独立检查形成反馈。
训练本身也可以沿着这条路扩展:为新的领域构造生成器、模拟器和验证器,让模型在更丰富的数据分布中获得可比较的反馈。递归自我改进能否形成有效循环,取决于每轮是否带来经过独立验证的净收益;模型给自己更高的分数,不等于能力真的提高。
软件开发中的对应答案是先做一个最小可行产品。MVP 的价值不是把最终系统缩小成一个玩具,而是用尽可能少的实现,尽快把最重要的意图变成可以点击、运行和批评的对象。原型一旦出现,原本藏在脑中的假设就会暴露出来:界面是否符合工作方式,数据是否应该这样保存,失败时用户需要看到什么,系统边界应该放在哪里。
一个物理设备的自助服务原型可以说明这种变化。扫描仪、标签打印机和其他实验室设备各自带着难用的软件和驱动,Agent 可以通过反复尝试把它们抽象成更干净的 scan、print 等接口,再由上层界面组合成统一流程。这里真正可迁移的经验不是某个设备的驱动技巧,而是把具体实现藏在稳定接口之后,让意图和实现解开;AI 降低了做出第一版的成本,却没有替代接口设计和可验收结果。
二、软件危机来自三个空间之间的差距
软件工程的出发点可以用三个空间表示:Intent 是人真正想解决的问题,Specification 是对可接受行为的明确描述,Implementation 则是代码、数据、配置和运行环境。软件的困难并不只在于把代码写出来,而在于这三个空间很容易互相漂移。
早期软件开发中,程序员和计算机都很昂贵,编程工具又不成熟。客户往往知道自己想改善什么,却未必知道计算机能怎样实现;程序员可能擅长算法和机器,却未必理解客户的业务、制度和责任分配。软件企业的职责,就是把两边的意图翻译成可交付的系统。
软件需求还具有高度定制性。同样是选课系统,不同学校可能采用先到先得、抽签或按专业预留名额;先修课不满足时能否特批、退课后的名额如何处理,也都是业务规则。自然语言中的“做一个选课功能”并没有把需求说完。教育、支付、审批和权限系统都包含大量由社会制度和人的偏好决定的例外,不能像桥梁的材料强度那样直接从自然规律中推导出来。
当需求越想越多,进度、成本和质量就可能一起失控,这就是软件危机的核心背景。更快的硬件没有自动消除问题,反而让人们尝试更大、更复杂、更难预测的系统。软件是物理世界和社会需求在信息世界中的投影;投影规则本身可以改变,需求也会在使用中继续变化,因此交付软件必须同时处理技术约束、人的意图和后续维护。
Apollo 软件提供了“能工作”的更严格含义。登月系统面对有限资源、严格时限和不可靠硬件,正确路径只是最低要求;异常发生时,系统仍要保住关键任务。下降过程中出现程序警报时,优先级调度和恢复机制让关键控制继续运行,前提是这些机制已经被设计和测试过。防御性设计不是把所有错误都想象成不可能,而是把“不应该发生的事也可能发生”纳入系统边界。
因此,软件工程研究的是:在成本、时间、团队和技术约束下,把人的意图变成可以运行、交付和维护的软件。需求沟通、设计、代码、测试、团队协作和细小的编码约定,都属于这项工作,因为它们共同决定了系统能否继续工作。
三、瀑布图真正提醒我们的不是按顺序写文档
软件工程教材经常展示一张从需求分析、设计、编码、测试到交付的瀑布图。它通常被理解为严格的单向流程,但经典论文的重点其实是指出:如果把大型软件简单地按这个方向推进,风险会非常大。
测试是整个链条上第一个真正发生的物理事件。在测试之前,需求文档、设计文档和代码都只是对未来行为的描述或实现;只有系统运行起来,时间、存储、输入输出、并发和失败恢复等约束才会被真正经历。分析阶段可以提出一个算法,测试阶段却可能发现它根本无法在规定周期内完成。此时删掉几条指令并不能解决问题,设计、任务划分乃至需求承诺都可能需要重新调整。
缺陷越晚暴露,返工成本越高。一个在线评测题如果在写完代码后才发现算法复杂度错误,就可能需要推翻整个实现;如果先用样例、性质或小规模实验检验核心假设,错误会停留在较小的范围内。大量文档并不能自动产生保证,文档和代码也可能在后续修改中逐渐分离。
Royce 提出的实用建议可以概括为“先做两遍”:在正式交付之前,先构造一个不完备但能运行的试验性系统,让最危险的技术和运行假设尽早接受反馈。它与今天的 MVP 有共同之处,都是用有限成本获得证据;区别在于,MVP 常用于验证用户需求和产品假设,而 pilot 更强调大型系统的技术风险和运行风险。敏捷开发把这种学习拆成许多短周期,测试驱动开发则把期望行为提前写成可执行的检查。
经典经验并没有因为代码由 Agent 生成而失效。无论写代码的是程序员还是模型,只要系统仍然需要从空项目走向可交付状态,就需要尽早运行、尽早比较结果,并限制未经验证的假设继续向下游扩散。
四、把开发流程看成数据依赖图
需求、设计、实现、测试以及交付后的使用事实,可以看成相互依赖的产物。设计需要消费需求信息,实现依赖设计和规约,测试需要实际实现,而“结果是否正确”又需要回到需求或规约。上线后的用户反馈和维护记录会产生新的事实,推动下一轮变化。
这种关系比一张阶段流程图更能解释返工。假设测试发现支付超时处理错误,继续向上追溯后发现设计阶段没有决定重试语义,甚至需求阶段没有说明超时是否可能已经扣款,那么问题就不在某一行代码。所有依赖旧假设的实现、测试和文档都需要重新检查,修改成本来自依赖链的长度和宽度。
MVP 的方法因此可以理解为控制数据依赖的规模:不要先完成一大片互相依赖、却没有运行证据的工作,而是优先走通一条价值最高的端到端路径,再逐步扩展更多功能。它不是单纯的功能删减,而是把工作从一次性铺满所有层次,改成小范围闭环、持续获得反馈。敏捷迭代、短周期发布和 TDD,都在做同一类事情:让关键假设更早遇到证据。
这种视角也解释了为什么“可追踪性”重要。需求、设计、代码和测试之间如果没有链接,项目就只剩下一堆文件;当一条业务规则改变时,人无法可靠地知道哪些界面、权限检查、状态迁移和测试需要一起修改。需求追踪矩阵、交叉引用和自动检查,都是把原本隐含的数据依赖显式化的办法。
五、方法和工具把含糊的决定变成可检查对象
软件工程为不同阶段创造了不同的语言和工具。需求文档、用例和原型帮助人们讨论“究竟想要什么”;决策表、状态机、模块和接口帮助人们讨论“系统怎样组织”;编程语言把这些决定落实为可执行行为。形式化程度逐步提高,不是为了让所有工作都变成数学,而是为了让高风险的决定不再只依赖记忆和猜测。
决策表的价值在于逼出组合情况。例如“学生有优惠,消费满额也有优惠”还没有决定两种优惠能否叠加、门槛按原价还是折后价计算、退款时如何回滚。表格把遗漏的分支摆到面前,讨论才有对象。检查清单也有类似作用:确认是否和客户沟通过、是否完成关键评审、是否保留验收依据,看起来琐碎,却能减少某类错误被完全漏掉的概率。
基于性质的测试把这种思路推进到程序执行中。对排序函数,仅要求输出有序是不够的,因为永远返回空列表也满足这个条件;还需要要求输出保留输入中的元素及其出现次数。工具可以根据性质生成大量输入,主动寻找反例。测试通过只能说明在检查范围内没有观察到违例,不能自动等同于数学证明,但每一项独立质量保障都会增加对实现的信心。
这些方法的共同目标,是让意图拥有可保存、可复查、可自动检查的载体。AI 可以帮助生成文档、测试和模型,但如果原来的要求没有说清楚,生成速度只会让不同的误解更快地变成不同的代码。
六、契约把组件之间的责任写进接口
Design by Contract 从一个简单问题出发:调用者和实现者各自承诺什么?前置条件规定调用者在调用前必须保证的事情,后置条件规定实现者在这些条件成立后必须保证的结果,类不变量则描述对象处于稳定可用状态时始终成立的性质。
以不允许透支的账户为例,取款操作可以要求金额为正且不超过当前余额;完成后,余额应等于旧余额减去取款金额,并继续满足余额非负的不变量。实现内部怎样存储余额并不属于调用者需要知道的内容,调用者真正依赖的是这些行为承诺。
契约还必须处理责任边界。若余额不足被视为非法调用,可以把它排除在前置条件之外;若产品要求把余额不足作为正常业务结果返回,就应设计一个明确的失败状态,并规定余额保持不变。不能一边接受任意请求,一边在异常场景发生时用“调用者不该这样调用”回避产品要求。
契约的意义在 AI 时代更加明显。一个 MVP 可以暂时不处理所有边界,但随着系统扩大,原先被忽略的情况会进入真实使用。把前置条件、后置条件和不变量写下来,才能让测试、运行时检查和形式化验证围绕同一组承诺展开。实现可以快速改写,接口的语义不能只留在某次聊天记录里。
七、UML 讨论的不是图形,而是语义如何保持
UML 试图提供一套介于自然语言和代码之间的共同模型语言,让需求和设计在还没有变成实现之前,也能用相对稳定的元素、关系和约束进行交流。它的价值不在于记住多少图形,而在于追问:哪些意图必须固定,哪些细节可以暂时留白。
有几个容易被图形掩盖的区分值得保留:
| 需要区分的对象 | 软件工程含义 |
|---|---|
| 模型与图 | 图只是模型的一个视图;图上没有画出的约束,不代表模型中不存在或已经被取消 |
| 一次行为与行为承诺 | 一次调用成功,不代表所有合法输入、重试和并发情况都满足要求 |
| 合法、非法与未规定 | 未规定的行为不是默认获得业务认可,而是尚未作出决定 |
| 访问、所有权与生命周期 | 能从一个对象找到另一个对象,不等于两者共享所有权或一起销毁 |
| 顺序与并发 | 图上的上下位置不自动意味着全局串行执行,严格顺序需要显式约束 |
支付系统很适合说明这些边界:用户确认后扣款并返回成功可以是合法轨迹;未经确认就扣款可以是非法轨迹;确认后请求超时是否自动重试,则可能仍未规定。如果实现者自行选择自动重试,就必须继续回答扣款是否已经完成、怎样避免重复扣款,以及并发回调如何处理。
模型本身也需要检查。OCL、元模型和其他约束机制可以检查类型、多重性、输入输出兼容性以及模型的良构性;但模型符合建模规则,不等于它已经满足全部业务目标。工具能稳定执行写下来的规则,却不能替人决定规则是否写对、是否覆盖真正重要的风险。
今天,AI 降低了从自然语言直接生成程序的成本,部分削弱了人们过去需要 UML 来搬运语义的理由。但这不等于“UML 的图形已经过时”就意味着其中的第一性原理失效:把能确定的语义确定下来,把不同含义拆开表达,把未决定的空间显式保留,并让模型、实现和证据彼此可追踪,仍然是有效的工程原则。
八、AI 时代要保存三类信息
当 Agent 可以在很短时间内生成大量实现时,最重要的工程接口不再只是“请写出代码”,而是三类信息的边界:
- 已经决定的行为:用户确认、状态迁移、数据约束和接口承诺;
- 明确禁止的行为:未经授权的扣款、越权访问、重复处理和不可接受的失败方式;
- 尚未决定的行为:可以暂时留白,但必须被标记为待决,而不是让实现者悄悄替人做决定。
只要满足前两类承诺,内部数据结构、算法和代码组织就可以保留实现自由。版本管理保存这些决定的历史,需求追踪把决定连接到实现和测试,契约、性质、状态机和验证器则让其中一部分可以由机器持续检查。
软件工程的价值从来不是让每个程序员手工写出每一行代码,而是让系统在复杂约束下继续工作。AI 让实现更便宜,也让错误实现更容易批量出现;越快生成代码,越需要显式保存约束、证据和留白。软件工程的下一阶段,不是和 Agent 比谁写得更快,而是把意图、规约、实现和反馈组织成一个不会轻易失控的闭环。
