前两讲讨论了模型能做什么,以及怎样通过提示词和上下文让它理解我们的目标。这一讲进入真正的软件工程任务:从零开始构建一个项目,并让它在持续修改中保持可理解、可恢复、可协作。
开场谈到 GPT-6 时,老师的使用感受是:幻觉少了,品味提升了,但遇到设计问题,还是需要不断批评和反馈。“我好像还可以再苟活几个月。”这句玩笑延续了前两讲的主题:从意图(Intent),到规格(Spec),再到实现(Impl),存在非常开放的选择空间。你说“做一个好用的界面”,常常要看到第一版,才发现自己真正介意的是什么。实现也在反过来帮助我们确定需求。
老师用“理应当做到的事就是能做到”形容一些生成结果带来的冲击,并把自己对模型发展路线的理解概括为向数学逻辑推理对齐。这是一种从使用经验出发的判断。对这门课更直接的问题是:模型进步这么快,我们还要学什么?答案仍然是,对世界建立正确的概念和理解。你要能看出一个设计好在哪里,知道该追问什么,也知道怎样把一个大目标分解成可以推进的工作。
从最朴素的角度看,软件工程项目就是一个目录,目录里有文件:需求说明表达意图,接口文档和测试描述规格,源代码承担实现,构建脚本把它们组织起来。经过构建或运行,可以得到一个可交付的东西,例如打开 npm run dev 启动的本地网页,或者运行一个编译得到的可执行文件。
早期做课程作业,事情往往到这里就结束了。老师的第一个博士生在大一时,把代码保存在优盘上;优盘坏了,代码也就没了。大约在 2012 年,打包发邮件还是很自然的作业提交方式。今天改成网页上传,虽然交作业方便了,单靠“目录里存着文件”的项目管理方式,却没有发生根本变化。
即使优盘不坏,也还有别的问题。昨天的版本能运行,今天改坏了,怎样回去?一个功能改了一半,突然需要修复另一处错误,怎样暂时放下它?你和同学从同一份代码出发,各自修改了一些文件,怎样把结果汇合起来?
这些需求自然引出了版本管理:除了保存项目现在的样子,还要保存它经过哪些状态,以及这些状态之间的关系。Git 是今天必须掌握的基础工具。课程可以让同学用 git clone 获取作业,用 git pull 接收更新,再通过项目约定的 make submit 入口提交。这里的 make submit 由课程的 Makefile 定义,不是 Git 自带的命令。Git 负责版本,项目规则负责怎样构建、验证和交付。
现在到处都能看到 GitHub 链接,每一个真实项目都可能成为学习素材。问题在于,看到很多项目,不会自动把它们的设计变成自己的知识。你可能打开过几十个仓库,却没有注意过它们如何组织目录,为什么保留某些文件,又为什么忽略另一些文件。
一个很有效的技巧是 virtually implement:先在脑子里做一遍。读论文时,看到作者提出的问题,先停下来想:换成我,会怎样解决?需要哪些数据结构,哪些前提,可能在哪里失败?然后再看作者的方案。如果想法一致,就可以比较实现细节;如果不一致,就追问差异来自哪个约束、哪条自己不知道的经验。
这个方法也适用于读代码、看文档,甚至阅读 AI 生成的一段话。你能不能预测下一段会说什么?为什么作者在这里多检查了一次返回值?为什么一个看上去简单的功能,还配有构建说明和测试?每次多留意一个细节,原有的知识网络就可能向外长出一点。
课堂说“上课耽误学习”,背后是课外的信息量和信息密度已经非常大,而我们未必学过怎样使用这些资源。学习知识的门槛降低了,主动发现自己不知道什么的意识,就更重要了。Git 恰好是一个很好的例子:很多人听说过,甚至每天都在用,却没有真正观察过它做了什么。
老师在保研面试中问过几个问题:什么是 Git?什么是 git rebase?git merge 发生冲突以后,你会观察到什么?第一个问题,多数人还能说上几句;被问到 rebase 的十位同学却没有答出来。合并冲突时,也没有人主动说出:常见文本冲突的双方内容和冲突标记,会直接出现在工作区文件里。
这不只是少背了几个术语。真正使用过一个工具,也需要留意眼前发生的变化。否则,“知道 Git 是版本管理工具”和“能够预测下一条命令的后果”之间,会一直隔着很远。
先从自己已有的经验出发:人和 Agent 都会犯错误,对的代码会被改错,设计会推翻重来,所以我们希望项目具备“回溯时间”的能力。最直接的做法,是复制整个目录,分别叫作“作业-v1”“作业-v2”“作业-最终版”。只要旧目录还在,就能找回旧文件;但版本一多,谁从哪份改来、哪个文件该配哪个配置,就需要靠人记忆。
版本管理工具逐步接管了这些工作。RCS 等工具管理文件历史,CVS、SVN 等系统支持围绕服务器协作,Git 则让开发者通常在本地也拥有项目历史。Git 的诞生与 Linux 内核开发有关:内核曾依靠补丁和打包文件协作,后来使用 BitKeeper;2005 年,原有的免费使用安排结束,内核社区开始开发自己的工具。速度、大规模协作和分支支持,都是实际工作推动出来的要求。Git 简史
最需要建立的概念是:Git 管理项目的快照。 快照描述纳入版本管理的文件在某个状态下的内容和目录位置。为什么要以项目为单位?因为源代码、接口和配置经常要一起变化。只恢复旧代码,却留下新配置,可能拼出一个历史上从未正常运行过的版本。
一次提交(commit)把这样的快照与作者、说明、父提交等信息联系起来。父提交记录它从哪里来,于是一个个状态构成了历史。你删除一个已经提交过的文件,再提交一次,新快照里虽然没有它,仍被保留的旧快照里却还有。编辑器里“存过盘”和 Git 里“提交过”,保存的是不同层次的状态。
Git 也不需要为每个快照机械复制全部文件。它用对象保存内容和目录结构,未变化的部分可以复用;逻辑上是完整快照,存储上可以共享数据。Git 对象
git init 在目录中建立一个仓库(repository,简称 repo),最初还没有提交。git clone 从已有仓库建立本地副本。GitHub、GitLab 可以托管远端仓库,但在本地记录版本并不要求先连接这些平台。
接下来要区分三个东西。工作区是你正在编辑的文件;暂存区(Staging Area,也叫 Index)保存下一次提交准备采用的内容;提交历史保存已经记录下来的快照。git add hello.c 会把此刻 hello.c 的内容放入暂存区,普通的 git commit 再据此创建提交。
因此有一个很值得亲手试的问题:先修改 hello.c,执行 git add hello.c,然后又修改它,最后执行 git commit,提交的是哪个版本?答案是 add 时暂存的版本。后续修改仍然在工作区,要再次 add 才会进入这次提交。可以用 git diff 观察尚未暂存的修改,用 git diff --cached 观察暂存区相对当前提交的变化。暂存区让你能够组织一次提交的内容,而不是被迫把此刻所有编辑一并记录下来。git add 文档
分支则让项目能够从同一个起点沿不同方向推进。可以把分支名理解为指向某个提交的可移动引用;在通常的分支工作方式下,HEAD 表示当前所在的分支。你在这个分支上创建新提交,分支引用就向前移动。另一个分支仍然可以停在原处,或沿自己的路线前进。
假设你和同学从同一版本开始,一个修改界面,一个修复解析器。git merge 会结合共同祖先和双方的变化,尝试得到合并结果。双方已经分叉时,常见结果是创建一个有两个父提交的合并提交;如果一方只是另一方的后续,Git 也可能只把分支引用向前移动,这叫 fast-forward。
当双方对同一段文本做了无法自动协调的修改,Git 通常会在工作区文件里写入 <<<<<<<、=======、>>>>>>> 等标记,展示需要人工决定的内容,同时在 git status 中报告例如 both modified 的状态。解决冲突,就是阅读上下文,把文件编辑成最终应有的内容,移除标记,再用 git add 标记已解决并完成合并。二进制文件、删除与修改冲突等情况不一定呈现同样的文本标记,所以关键是同时检查状态和文件。分支与合并
git rebase 则把一串提交所代表的改动,依次重放到新的起点上。例如你基于旧主线做了两次修改,现在主线已经前进,可以把这两次修改重新应用到新主线之后。重放得到的提交通常有新的标识,过程中也可能冲突。它适合在约定允许的范围内整理工作;如果别人已经依赖原来的提交,你再改写它们,就会增加协作成本。Rebase
理解这些操作后,再回头看命令,就不再只是记住“输入什么”。你可以在操作前预测:工作区会变吗?下一次提交包含什么?哪个引用会移动?历史里会增加什么关系?这才是可以继续外推的知识。
教程把几个命令教完,似乎就可以开工了。但软件和系统里还有大量约定俗成的 best practices,也就是在实践中积累的良好做法。它们经常来自一次次踩坑,或者开发者对旧设计忍无可忍后的改进。
例如,目录里同时有 hello.c 和编译得到的 hello,要不要一起提交?如果两者都保存,下一次只改了源码却忘记重新编译,仓库就会出现两份不一致的状态。别人到底该相信哪份?如果只保存源码,也还要说明怎样构建,让其他人有条件重建结果。“我的目录里有什么”和“项目需要共同保存什么”,是两个问题。
对刚开始做项目的同学,可以在自己的 AGENTS.md 中加入这样一条学习约定:
在做任何 git 操作之前,都逐个文件检查变化,并为我(新手)解释是否符合 best practice,为什么,以及相关的概念。
这条约定的价值在于让具体变化成为学习入口。面对一个文件,先判断它承担什么作用,再决定是否提交。老师反复提醒新生的几类问题,可以放在一起理解:
dist/、编译得到的程序和调试日志;应让源码、依赖和构建方法能够重建所需结果。.DS_Store 等临时文件不提交,它们既不表达项目意图,也不参与项目构建。这些不是按文件名机械执行的禁令。依赖锁文件虽然由工具生成,却常常用于固定依赖版本;有些项目也有意保存生成的头文件或静态发布产物。需要解释的是,这个文件为何构成共同维护的项目状态,以及怎样保证它与其他文件一致。
.gitignore 可以把这些约定落实为忽略规则,但它不会让已经被跟踪的文件自动退出版本管理,更不会抹掉历史。gitignore 文档 这也解释了为什么误提交密钥后,仅仅删除当前文件不够:旧提交可能仍保留它,别人也可能已经取得副本。版本管理帮助我们记住过去;不该公开的东西一旦进入过去,就不能假装它从未发生。
真实项目里出现过一个很小的设计选择:
[TYPO-CHECK] Docs should be typo-free
[typo-check] Docs should be typo-free
这是同一条文档检查规则的两种写法。老师的直觉是“大写似乎更好,但不是极强”,一时又说不清原因。这样的犹豫很有价值:它提示我们,直觉背后可能有可以讨论的设计理由。
这里,方括号里的名字是固定的规则标识,后面则是解释规则的自然语言。大写让两者更容易在视觉上区分;讨论“违反了 TYPO-CHECK”时,它也更像一个明确的引用。这样,读者可以迅速区分“我们正在引用哪条规则”和“这条规则说了什么”。
不过,如果整个项目已经使用小写标识,或处理规则的工具要求小写,保持一致可能比局部的醒目更重要。我们得到的不是“大写永远优于小写”,而是理解了这个选择涉及的角色区分、可引用性和一致性。对一个微小细节多问一句为什么,也是在训练设计能力。
课堂有一句半开玩笑的“计算机科学定律”:你想要的,就一定有人做。遇到难以理解的概念,可以先寻找有没有工具把它直观地展示出来。现在还多了一种可能:把自己的学习需要写清楚,让 Agent 制作一个工具。
这次的例子是 gitv。目标是一个用 Node.js 实现的命令行工具,接收真实 Git 仓库的路径,启动 HTTP 服务,在浏览器中展示仓库的主要概念。这里特意强调“真实”:展示对象前,要能从文件系统里的文件和原始字节出发,解释怎样解析成对象,再展示对象之间的关系。
一个 blob 保存文件内容,tree 把文件名与对象组织成目录结构,commit 关联根目录树和父提交。可视化要帮助学生把这些名字和磁盘中的数据联系起来。内容应能展开阅读,修改应能比较,大小文件都要合理显示。仓库发生变化时,页面持续监听,并用动画显示对象和引用如何变化。明快的浅色背景、可拖动的布局、尽量能自行说明含义的视觉关系,都是为了让课堂上的注意力落在变化本身。
于是,一个适合学习的实验就很自然了:先预测执行 add 后会发生什么,再观察;接着修改文件但不 add,看看哪些内容保持原样;提交以后,再找出新提交、目录树和分支引用。视觉工具降低了观察成本,预测与核对则让观察变成理解。
课堂把直接生成的工具与 Learn Git Branching 等已有学习工具作比较,也拿生成的编程环境与 61A Code 对照。这样的比较应该落在细节上:能否看清关键状态,交互是否顺畅,边界情况是否处理得当,能不能真的帮助使用者。一次课堂生成结果体现的是这次任务的表现,不能直接推成模型之间的普遍排名。
老师对“只是读过介绍,却从未真正打开好东西”的学习方式很不满意。没有使用过优秀工具,就很难知道“把细节做好”到底意味着什么。愿意亲自操作,发现一个细节,继续追问,这种愿意学懂的品质会越来越有价值。遗憾的是,即使学习让人快乐,每个人的学习时间仍然有限;更应该把工具用在值得理解的问题上。
再来看一个日常问题:你会写 commit message 吗?update 当然能成为提交说明,但几个月后翻历史,它几乎不能帮助任何人。假设修复了命令解析器,可以写成:
fix(parser): accept repeated spaces between arguments
这是 Conventional Commits 风格的示例:fix 表示修复,parser 标示范围,后面说明行为变化。固定结构既方便人浏览,也方便工具分类和生成更新记录。具体项目可以采用不同约定,关键在于形成稳定、可理解的表达。
标题之后,还可以解释:旧解析器把连续空格当成空参数,导致复制来的命令失败;现在忽略参数之间的多余空格,但保留引号内部的空格。代码 diff 展示怎样改,提交说明补上为什么改、想保留什么。将来有人想删除这段逻辑,就能找到判断依据,而不必重新猜一遍。
Git Best Practices 收集了许多实践经验。这些经验可以从几个工程问题来理解:怎样留退路,怎样让别人读懂变化,怎样让协作可预期,以及怎样让约定得到执行。
留退路,意味着探索过程中及时保存检查点。不要因为将来的历史想整理得漂亮,就让今天的工作一直只存在于工作区。误操作后先保护现场,再考虑通过记录引用移动的 reflog 等机制寻找原有提交。远端仓库可以保存已推送的历史,但不会自动备份你尚未提交的编辑。
让别人读懂,意味着把探索过程整理成围绕明确意图的提交。修复一个错误和对应的回归测试可以在一起,顺手格式化整个项目通常应该分开。整理后的每一步如果能构建、能验证,审查者就能逐步理解,出问题时也有可靠的定位单位。
让协作可预期,需要约定哪些历史可以整理,哪些已经成为他人的依赖;也要说明谁负责合并、如何发布、怎样把修复送到维护中的旧版本。让约定得到执行,则可以借助 hooks 和持续集成(CI):前者在开发操作附近尽早反馈,后者在集成流程中自动检查构建和测试结果。
这些机制并不替代思考。你自己的修改测试通过了,合入更新后的主线还需要检查组合结果;一段提交说明符合格式,也不等于它解释清楚了行为。规则的价值,最终要落实到恢复、理解和协调的成本上。
这些习惯与软件工程日常任务直接相关。经典 SWE-bench 从真实 GitHub 项目的 issue 和修复构建任务:给定一个已有仓库的状态与问题描述,让模型产出补丁。它要求模型接手已经存在的设计,在上下文中完成修改。
可以把工作过程理解为:读懂现象,复现问题,找到相关调用路径和项目约定,修改实现,再验证修复及已有行为。评测中的 FAIL_TO_PASS 测试应从失败变成通过,PASS_TO_PASS 测试应继续通过。这两个名字说明了修复的双重要求:解决新问题,同时守住已有能力。SWE-bench 评测实现
Astropy 的一个真实问题能把这件事讲得很具体。它的建模工具用一个布尔矩阵表示输出对输入的依赖:某个位置为真,表示对应输出依赖那个输入。两个独立的一维线性模型并排组合时,预期矩阵应只有对角线为真。可是,将这两个模型先组成子模型,再与另一个模型组合后,它们对应的区域却变成了全真,仿佛两个本来独立的计算突然相互依赖。仅仅多包了一层组合,依赖关系就错了。Astropy Issue #12906
根因在拼接依赖矩阵的代码中:右侧已经是子模型的矩阵时,原实现把对应区域填成了 1,丢掉了原矩阵的内部结构。修复只需把填入的值改为 right,保留那份已经计算好的依赖关系。实际 PR 同时增加了不同嵌套方式的测试和更新记录。修复代码与测试 这项修改随后合并进入项目主线。PR #12907
所以,“只改了一行”描述的是最终代码差异的大小。要找到这一行,需要理解用户遇到的行为和项目中的抽象;要让项目接受它,需要留下验证证据;要让用户得到它,还需要完成集成与交付。SWE-bench 主要通过测试验收补丁,真实工程则还包括这些沟通和发布工作。
Git 管理版本,但 issue、审查讨论和 CI 运行状态属于平台协作对象。为此,还有 GitHub 的 gh 和 GitLab 的 glab 等命令行工具。一个完整流程可以从读取 issue 开始,经过本地修改、测试、提交和推送,再创建 Pull Request(PR)或 Merge Request(MR),查看检查与审查反馈,继续修改,直到合入。这样,人和 Agent 都能通过命令行把版本操作与平台上的协作过程连接起来。
回到软件工程,可以把这一讲的核心概括为:项目是由原子变更推进的快照,可以分支,可以合并。 原子变更(atomic change)在这里指围绕一个明确意图组织的完整修改,它应当能够被解释、验证,并在需要时单独撤销。它不一定只改一个文件,也不一定只有几行。
为什么强调这一点?因为人很难处理“一团混沌”。一次改了接口、重写了实现、更新了依赖、调整了格式,最后测试失败,应该从哪里找起?如果这些变化有清楚的边界,就可以分别理解、审查和处理冲突。
历史还可以成为调试工具。git bisect 在已知好版本与坏版本之间,通过不断测试中间提交缩小范围,帮助定位引入问题的变化;git revert 可以记录撤销某次变化的新提交。它们好不好用,与历史中的变化是否完整、是否可验证密切相关。
有些项目要求线性历史(linear history),将集成后的提交组织成较直接的一条主线。这样便于阅读和追踪,也方便用 cherry-pick 把指定提交的变化应用到其他分支,或结合 blame 追查代码行的来历。不过,线性只是组织形式。每一步都意义不明、不能运行,排成一条直线也不会自动变成好历史。
在模型能力还有限时,AI 参与开发同样需要避免“一团混沌”。一次交给它一个有边界的目标,保留可以检验的状态,发现偏差后及时反馈,这些仍然是有效的工程方法。代码生成速度提高了,并没有自动消除理解和集成的难度。
如果项目已经落在 Agent 能力之内,例如课堂提到的 Pico Playground,人的工作可以怎样变化?老师的实践是,架构阶段先“单线程”:与能力强的模型反复讨论,直到接口、约束和总体设计逐渐收敛,再把结果写成文档、规则和示例,包括测试要做到什么程度。
这个阶段仍然需要人的知识。为什么还要学习操作系统、编译原理和数据库?因为这些领域积累了大量好设计:怎样划分职责,怎样表达不变量,怎样隔离变化,怎样协调并发。知道这些,才能在讨论中提出有效问题,判断模型给出的方案是否值得继续。
进入实现阶段后,可以打开少量终端并行工作。老师给出的实践范围是不超过三个终端,通过 AGENTS.md 说明协作规则,例如历史如何整理、任务怎样集成,再由人脑承担一部分并发控制:尽量安排相互不冲突的任务。这个数量是个人工作方式,不是并发 Agent 的普遍上限。
每一轮产出都应尽快检查;有界面的项目,立即打开看看,给出视觉验收反馈。无论你觉得模型这一次的质量高不高,都不要让未确认的设计选择积累太久。架构讨论收敛共同前提,有限并发推进独立工作,快速反馈纠正理解偏差,三者需要配合起来。
最后做一个思想实验:假设 Agent 生产代码的速度提高一千倍,会发生什么?“一千倍”在这里用来放大矛盾,并非一次测量结果。生成一分钟,人工理解和验收一小时,积压的就会是等待判断的变更。Git 本身还能保存它们,但围绕人工提交、审查和协调组织起来的工作方式,可能成为瓶颈。
如果从零开始为 Agent 设计版本控制系统,也许应该把快照、变更以及变更如何组合放到中心。所谓 change algebra,可以理解为描述变更组合规则的一套运算:两项变化能否独立应用?能否交换顺序?是否依赖某个前置变化?冲突以后应当重做哪一部分?
这与数据库中的多版本并发控制(MVCC)有相通之处:不同工作可以基于各自看到的版本推进,系统在它们生效时协调关系。但两者并不是同一个概念。MVCC 关心多个版本下的并发访问;变更的组合规则还要表达修改如何衔接。即使两项修改可以交换应用顺序,结果也未必符合项目要求。
考虑一个简单例子:项目要求至少有一名值班员在岗,初始状态是甲、乙都在岗。Agent A 看到乙在岗,就把甲改为离岗;Agent B 看到甲在岗,就把乙改为离岗。两项修改可以写在不同文件里,文本合并没有任何冲突,结果却是无人值班。这类似数据库中的 write skew:各自依据旧快照作出的决定,组合后破坏了共同约束。快照和局部检查之外,还需要检查组合结果或采用更强的并发控制。事务隔离与并发异常
一种可能的设计是,让 Agent 在独立快照上生成变更,系统把变更与当前主线组合,验证候选结果,再原子地发布这个确切版本。发布时需要确认主线还是验证所依据的版本;如果其他 Agent 已经推进了它,就重新组合并验证。锁可以只保护短暂的版本读取或发布过程,耗时的生成和测试在锁外完成。否则,把整个开发过程锁住,只是把并发工作重新排成了长队。
Jujutsu 的一些设计更接近把“变更”放在中心的思路:它用稳定的 change ID 跟踪经过修改或 rebase 的变更,允许把冲突作为状态保存,也用 operation log 记录仓库操作并协调并发操作。Jujutsu 教程、冲突模型、操作日志 这些机制提供了值得研究的方向;课堂设想的自动组合、验证和准入流程,还需要进一步设计。
再往前想,人看到的历史也许只保留有意义的大步变化,机器则保存更细的操作和验证证据。老师用“反正没有 Agent 能被开除、能坐牢,出什么问题修好就行”挑战以追责为中心的审计习惯:如果生成和修复都足够便宜,工程流程是否可以把更多精力放在正确性保障和恢复能力上?
这仍然取决于错误能否及时发现、后果是否可恢复,以及验收标准能覆盖多少真实要求。测试全绿说明已有检查通过了;如果“至少一人值班”从未写进要求或检查,就不能指望文件合并替我们保证它。人的责任会更多落在定义意图、不变量和完成标准上,系统则需要解释:它凭什么接受这次变化?
未来的项目管理也许会更像持续运行的并发控制与验证系统。要参与设计这样的系统,今天学习快照、分支、原子变更和数据库并发控制,恰恰是在积累能够向外推演的概念。