魔鬼都在细节里,而 Vibe Coding 让它们藏得更深了

15 阅读10分钟

魔鬼都在细节里,而 Vibe Coding 让它们藏得更深了

cover.png

最近,我用 AI 开发了一个可以探索 Kafka、DB、Redis 实验岛的 3D 技术博客。整个过程认真采用了 SDD:梳理需求、编写规格、评审方案,再实现和验证。

但第一次交付的落差依然很大。Kafka 有场景和入口,核心演示却有留白;DB 有查询实验,通往实验的桥却没有接通;Redis 有运行模型,在特定组合状态下却会一直停在准备中。再往后,还有机制已经实现、表达却不准确,实验能够运行、观看者却很难理解的问题。

00-build-journey.png

图 1 · 建设过程概念示意,非项目历史截图;各阶段在多轮反馈中反复迭代。

这些缺陷后来陆续得到处理。真正促使我复盘的,是一个更难解释的现象:为什么代码、文档和测试越来越齐全,我仍然需要不断把成品拉回最初想解决的问题?

我开始觉得,“魔鬼都在细节里”在 Vibe Coding 时代有了更深一层含义:偏差可以藏在一套看起来已经完成、甚至能够相互印证的产物之中。

最能说明这一点的,是 Kafka 岛的一条历史回归测试。

当消费者读取消息后,画面会隐藏轨道上的源箱子。测试同时确认两件事:模型里的日志仍然存在,轨道上的箱子已经消失。两条断言都通过了。

从程序内部看,日志没有被错误删除;从观看者的角度看,箱子被消费后消失,却很容易传达“消费会删除消息”。而 Kafka 的消费与日志保留是分开的机制。Kafka 官方介绍明确说明,事件不会因为被消费而删除。

当时的规格已经要求保留源消息,并用视觉复制表现读取。实现与测试仍然接受了隐藏源箱子的行为。误导性的表达就这样进入了验收标准,继续运行同一组测试,只会再次确认它符合测试自己设定的预期。

这比漏掉一个函数更难处理。函数缺失通常会留下直接的失败信号;一个错误的理解,却可能顺利长成接口、状态、动画和断言。每一层都能解释自己为什么这样实现,最初的偏差反而需要从整条链路中重新辨认。

还有一些偏差,发生得更早。AI 可以把一个尚未被充分讨论的选择,迅速写成一个已经实现的事实。

例如,“支持查询演示”并没有自动回答:每个访问节点需要停留多久?“支持事务演示”也没有自动回答:事务操作应该发生在现有记录上,还是另建一套场景?这些选择总要由某个环节作出。如果实现先于充分校验,文档里的留白就会变成代码里的默认答案。留白消失了,问题却未必已经解决。

这也改变了我对“细节”这个词的理解。很多决定成败的细节,连接着几个各自看起来正确的部分。

DB 实验曾经准确地完成扫描,却只给每条记录约 38 毫秒的展示时间。运行时经历了这些状态,观看者很难据此看清访问顺序。后来的调整保持业务结果不变,延长停留时间,并让镜头与单条请求的过程对齐。

这里的时间参数决定了计算过程能否成为教学过程。类似地,同一个任务能否在模型、机器人、电梯和结果面板中保持身份,决定了观众看到的是一条因果链,还是几组同时发生的动作。

组件可以逐个验收,目的却依赖它们之间的关系。一个项目越容易批量生成完整的组件,就越需要警惕:组件的存在,被当成了关系已经成立的证据。

我们把这些关系称为“细节”,常常只是因为它们在概括功能时被省略了。但被描述省略,不等于能被实现省略。它们恰恰决定了那个概括是否成立。

SDD 确实能帮助我们把这些关系写清楚,但规格本身也来自对问题的理解。

写下“用户可以看懂事务过程”很容易,判断什么样的过程足以让用户理解,已经涉及教学、交互和注意力分配。写下“复用现有能力”也很容易,判断旧系统里究竟什么值得复用,则需要理解它的设计。

规格让判断获得了明确的形式。它的可信程度,仍然取决于判断的依据。

如果规格、实现和测试都继承了同一个错误前提,三者一致就只能证明这个前提被贯彻了。三份产物未必构成三份独立证据。这并不意味着测试没有价值,而是提醒我们追问:测试中的“应该如此”,究竟从哪里来?

01-shared-assumption.png

图 2 · 概念示意:三份产物若源于同一前提,彼此一致仍需要外部事实验证。

项目里甚至有一项名为“步行过桥”的测试,实际直接传送角色,再检查坐标。它没有验证标题所承诺的行为。这属于明确的验证缺口,也意味着我不能把“认真采用了 SDD”等同于“整个流程已经严格闭环”。

即使补上这类缺口,还有一个更上游的问题:我们依据什么确认规格描述的就是值得交付的结果?成熟的 SDD 实践也包含研究上下文、持续校正和现实反馈;Spec Kit 的方法说明对此有明确论述。只保留规格到代码的生成过程,就丢掉了检验规格的那一半工作。

DB 岛的重构,让这个问题变得更具体。

重构之前,原货箱已经代表数据库记录,原索引已经表达查询路径。新增事务和锁,本可以沿着这些关系继续扩展。中间版本却另外建立了几组记录表,让新业务场景与旧索引演示分别呈现。后来经过反馈,字段、锁、等待者与事务暂存才重新附着到原来的货箱上。

当时底层查询算法已经复用,旧演示也仍然保留。遗漏的是:这些已有对象为何成立,它们之间的关系为什么适合承接新能力。

如果只检查旧函数有没有继续调用、旧入口有没有继续保留,这次重构可以显得很完整。但对产品而言,新旧演示之间的理解没有接续起来。我需要在实现完成后,再解释一次新机制应该生长在哪里。

软件里有一部分资产,很难从代码复用率中看出来:对象为何这样划分,某个动作代表什么,哪些关系已经得到验证,以及新需求与旧能力之间存在哪种对应。

02-inherited-meaning.png

图 3 · 关系示意:保留查询函数和旧入口,尚未保证新能力沿着原有记录与访问路径继续生长。

Peter Naur 在 1985 年的《Programming as Theory Building》中讨论过一个相似案例:新团队拿到了编译器的完整代码和详细文档,扩展时仍提出另加结构的方案;原团队却能看出,既有结构已经蕴含了所需能力。他据此强调,编程还包含对问题、程序及其对应关系的理解。原文

这个案例让我意识到,我遇到的困难有很长的历史。读取到设计资料,与能够用其中的设计思想处理新问题,是两种不同程度的理解。

Vibe Coding 让这件事更值得关注,因为一个新方案可以在这种理解尚未被检验时,就迅速获得完整实现。过去可能还停留在讨论中的分歧,现在已经成为需要拆除、迁移和重新验收的代码。

便宜的是新增代码的动作。判断新增是否必要,以及新结构会破坏哪些已有关系,仍然需要投入。

还有一笔容易漏算的成本,发生在开发者自己身上。

逐步实现一段功能,也提供了逐步理解它的机会:这个状态为什么存在,两个模块为什么这样交接,异常应该由谁处理。实现中的阻力可能让这些问题提早浮现,开发者也可能绕过它们。关键在于,这份理解是否真的建立了起来。

使用 Agent 后,大量中间决策可以在一次交付里集中出现。如果我只审阅最终摘要和通过的测试,这些决策背后的假设就可能没有经过同等程度的辨认。等到体验偏离预期,我还要反向重建它们:它把什么当成了记录,为什么新建了一套表,又按什么标准认为任务已经完成。

节省下来的编码时间,与后来理解和纠偏所需的时间,属于同一笔交付成本。

这并不能推出“自己写一定更好”。Agent 同样可以帮助阅读旧系统、比较方案、寻找反例。关键在于,我们是否把生成能力也用于检验理解,还是主要用它扩张实现。

如果功能增长的速度长期超过判断被验证的速度,更多代码就会建立在尚未确认的前提上。一次误解的影响范围随之扩大,下一轮修正也可能在旧偏差上叠加新的局部解释。开发依然很忙,系统却未必越来越接近目的。

因此,我现在更在意一次迭代消除了什么不确定性。

演示行锁时,只展示“A、B 都执行完了”提供的信息很有限。让 A 锁住一条记录,观察 B 是否在同一记录等待,再让 C 修改另一条记录,最后释放 A、确认原 B 继续,才能逐步辨认模型和表达是否共同支持了我们想说明的关系。让观看者提前判断下一步,还能暴露开发者已经习以为常、初学者却无法从画面获得的信息。

同样,验证过桥就要经过那座桥;讨论重构就要把新需求放回旧对象与旧路径中;判断动画是否准确,就要把它传达的含义与领域规则对照。这样的验证带来了可能推翻当前方案的证据。

当生成代码、补齐文档、编写测试都变得容易时,我们熟悉的那些完成标志,需要更仔细地核对其实际证明了什么。

“魔鬼藏得更深”,深在我们得开始检查用来判断软件的那些判断。谁决定了这个状态代表消费完成?谁认定了这些旧能力已经得到继承?谁定义了测试通过就足以交付?

Vibe Coding 压缩了从想法到实现的距离,却没有自动回答:我们凭什么相信这个实现是对的?

魔鬼仍在细节里。如今,它也可能藏进了回答这个问题的规格、测试和完成说明里。

欢迎到我的 3D 技术博客逛一逛,亲自体验这些实验岛。