Vibe Coding 实践记录

背景

最近一段时间,比较重度的使用 AI 开发了2个软件,都还是蛮有挑战性的:

molap storage

说是 MOLAP(Multi-dimension Online Analysis Process) storage,这个名字起得大了一些,其实本项目只是针对 MOLAP 计算中的计算度量的预计算 的存储。一般的 MOLAP 引擎紧处理原子度量的预计算的存储,因为原子度量自身具备再聚合的能力,其存储的性价比相对较高,而且其本身对计算度量的计算 也是具有加速效果的。而大部份的计算度量,不具有再计算性。

本质而言,这个存储引擎就是一个简单的 Key-Value 存储引擎,其基本的 API 是:

trait DB {
    fn put(&self, cube_id: u16, dimensions: &[&str], value: Value) -> Result<()>;
    fn get(&self, cube_id: u16, dimension: &[&str]) -> Result<Value>;
}

不过,简单的 API 背后,蕴含了不少复杂的设计挑战:

  • 高效的存储格式设计,作为预计算的存储引擎,而不仅仅是存放到内存中。
  • 高效的读写性能,尤其是读性能,在我最早的设计目标中,希望能够达到 10M QPS 的读性能。(单个请求耗时 ~100ns),这里需要尽可能的匹配现代 CPU 的特性, 包括缓存有好、SIMD 指令集的使用等。
  • 高效的内存使用。这个 API 将在 JVM 中使用,如果需要占用过多的 JVM Heap 内存,将会影响到整体的 JVM 性能。
  • 零启动开销。即使对一次性的脚本任务,也希望能够快速启动,避免因为存储引擎的初始化而带来过多的延迟。设计目标是小于 1ms 的启动时间(读), 一个重大的挑战就是可直接 mmap 的数据结构,避免任何的数据序列化操作和数据复制开销。

the 1brc program 项目中的极致性能挑战,为 molap storage 的设计提供了很好的经验积累, 在这项目的开发过程中,我基本上没有花费太大的代价,只经历了几轮快速调优后,就达到了性能上的极致目标。

上述的设计目标,一开始就注定这个项目是极具挑战性的,也能充分发挥 Rust 的顶层编程和高性能的挑战。

reactive-system

这个项目是一个响应式系统引擎,是我规划中的 Analysis Report(一个类似于 Observable Notebook 的数据分析工具)的核心引擎。这个引擎的设计目标是:

  • 支持异步的响应式计算。
  • 为交互式 notebook 提供高效的响应式计算引擎。
  • 支持复杂的响应式计算图,支持动态的计算图结构。
  • 保守保证计算的因果一致性,绝不出现“时间倒流”、“数据不一致”的现象。
  • 激进的调度优化:严格的拓扑排序下的调度,避免重复计算,并且在因为变化而导致的计算过期时,能够及时取消过期计算,节省计算资源。

24年,我设计过 reactive-system 的第一个版本,并且在公司的交互式仪表盘中应用,对解决交互式数据分析中的数据依赖、数据联动等问题,发挥了重要的 作用(这一块也是产品历史BUG的重灾区)。在 V1 的设计基础上,我为响应式系统进行引入了新的目标:

  • 支持 notebook 中 code cell 这样的 多输入,多输出的计算单元(V1 是多输入,单输出)。
  • 定义了更保守的因果一致性模型,确保在异步计算环境下,数据的一致性和正确性。
  • 引入了更激进的调度优化策略,对任何过时的计算任务,能够及时取消,节省计算资源。
  • 更明确的异常传播机制。将异常作为计算结果的一部分进行传播,确保下游计算能够正确处理上游的异常情况。

这个系统的核心 API 很简单:

interface ReativeModule {
    defineSource(source: { id: string, initialValue?: any}): void;
    defineComputation(computation: {
        id: string;
        inputIds: string[];
        outputIds: string[];
        body: (scope: Scope, signal: AbortSignal) => Promise<Record<string, any>>;
    }): void;
    updateSource(id: string, newValue: any): void;
    getValue(id: string): Promise<any>;
    observe(id: string, observer: (result: Result<any>) => void): Unsubscribe;
}
interface Scope {
    [variableId: string]: Promise<any>;
    __getResult: (variableId: string) => Promise<Result<any>>;
}
type Unsubscribe = () => void;

当然,24年我仅是作为设计者,而开发是由一位前端同事完成的,而且,彼时并没有借助 AI 的力量,开发过程相对较长,而且也是在开发的过程中不算确认设计 细节。而现在,则是由我来亲自设计、编码,并且是借助 Claude Code /Gemini 等 AI 助力来完成开发任务,这个过程也想比传统的开发方式有了很大的不同, 在差不多2周的时间里,我其实是完成了2版大型的设计改造(从 V2 的 MVCC 设计,到 V3 的基于 cause_at 的时间因果设计),在第3版中,又进行多次的 实现细节的重大调整,对调度、取消机制、状态管理等都做了好几次的重构,几乎是每天一次大型的代码重构),最终完成了 V3 的设计和实现,达到了我心目中 (暂时)比较完美的感觉。


简单来说,这两个系统都比较类似于 《A Philosophy of Software Design》一书中的原则:

  • 更窄的接口:接口提供了高度简单、抽象的语义,隐藏了复杂的实现细节。
  • 更深的实现:在简单的接口之后,隐藏了复杂的实现细节,这些细节都是偏重于性能、一致性的机制优化,但并不暴露使用的复杂性。

workflow: feature-phase-task workflow

在这两个项目的开发过程中,我逐步形成了我自己的一套 workflow,非常适合于 vibe coding 的开发模式,相关的文档可以参考:

使用这个 workflow 优点:

  • 贴合 Spec First 的模式:我们主要的精力在于需求规格和设计文档的准备上(当然这个过程 AI 可以给你很多的助力,但主要的职责是你)
  • 在 Spec 准备好之后,我们可以先和 AI 一起评估一个开发的计划:分解成为多个 phase,每个 phase 包含多个 task。
  • 对于不确定比较高的系统,我们需要启动 phase 0 阶段:通过定义end-to-end 的使用场景,基于这些使用场景,编写测试用例,确定整体的 API 设计, 在确定了 API 设计之后,再进行后续的 phase 拆解和 task 的拆解。
  • feature 对应于 Feature Driven Development 的 feature,或者 git flow 中的一个 feature 分支,一个 Pull Request。是一个完整的功能特性。 多个 feature 可以并行进行开发,最后 merge 到主分支。
  • phase 对应于一个小的迭代目标,一个 phase 可以用于验证某个原型,为下一个 phase 做准备,可以是丢弃型的原型开发,也可以是增量式的开发。
  • task 对应于一个具体的开发任务,可以是一个小的功能点,或者一个模块的实现。task 是最小的开发单元。
  • 完成规划后,就可以以 AI 为主的方式去进行推进,在开发的过程中,适当的进行干预即可,而无需高频率的进行讨论和编码沟通。这也使得我们可以并行进行多个 feature 的开发。

AI 的 长项 与 短板

这两个2项目,一个是 Rust,一个是 TypeScript,两个语言我都是有一定了解,但都不是非常熟练。整个开发过程中,我体验了 claude code 和 gemini 两个 agent, 他们彼此的习性也有所不同。在实战过程中,有的任务,AI 能够非常好的完成,而有的任务,则还是传统的 IDE 更为适合。当然,在能力上,AI 也有长项和短板。

了解 AI 的长项和短板,有助于我们更好的利用 AI 来完成开发任务,以及如何在一个恰当的成本上,完成开发工作。否则,你就会烧掉大量的 token 却难以达成目标。

  1. AI 非常适合于代码的生成,却不一定适合于代码的修改,尤其是大范围的代码修改。如果某个结构性的设计的调整,导致了大范围的代码修改,大量的代码错误,大量的单元测试失败, 那么这时,你依赖 AI 来进行代码的修改,会是一个相当漫长、且烧钱的过程。

    • 这时,可能人工介入的,基于传统的 IDE 的重构的方式,可能是更为高效的方式。
    • 如果因为设计调整导致大量的单元测试失效,那么与其让其逐个修复单元测试,不如废弃掉这些单元测试,重新编写(生成)新的单元测试,可能会更为高效。
    • 如果某个任务,AI 陷入了无限循环,在不断的修改代码尝试通过的时候,可能就需要及时介入,给予新的指导,或者直接人工介入完成任务。
  2. 结构性的重构,可能导致现有的代码大面积失效,这时,与其让 AI 去修复这些代码,不如大刀阔斧的进行重写

    • 重新和 AI 讨论设计细节,讨论开发计划的规划,与其让其修改现有代码,不如让其基于新的设计,重新编写新的代码。
    • 如果在上次迭代中,AI 生成的代码质量并不高,那么在新的设计中,对这些不清晰的地方,强调设计的细节,包括:
      • 核心的数据结构
      • API 定义,包括输入、输出等签名信息,以及语义的定义,Design by Contract 也是非常适合于 AI 编程。
  3. AI 在写代码的速度上,可能是优秀工程师的10倍+(更可能是普通工程师的几十倍),但是它有一个巨大的问题:就是在设计不够清晰、确定的情况下,它也会 按照一个“快枪手”的思维模式,应付式、快速的达成眼下的目标(如通过单元测试),这一点倒是非常象一个没有追求的,有丰富经验的工程师:它无所不能的可以达成 当下的目标,却完全不管不顾会为后续留下多少技术债务。(很多公司都有这样的“技术高手”,什么问题都能解决,当然,大部份的问题也都是他埋下的坑,一旦问其 他来,理由就是:当时要求的时间那么急,只能这么解决了,至于技术债务,我都知道,但是后面也没有时间去处理了)。

    • 所以,不要太信任 AI 生成的代码,一定要有严格的 code review 机制。如果发现代码质量不高,大部份是我们过于相信 AI,而在没有足够清晰的设计细节下,就让 AI 进行发挥。
    • 这个时候,是重新评估我们的设计细节的时候,需要重新敲定设计,明确数据结构、API 的契约。
    • 评估这个改进是 AI 擅长的,还是不擅长的(一般的,语义确定、契约清晰,要求明确的,AI一般都是比较擅长的),来选择:
      • 是让 AI 来进行重构、修改,
      • 还是废弃当前的修改,让 AI 重新基于新的设计,重新编写代码。(有的时候,这会比修改现有代码来得更为有效)
      • 还是人工介入,基于传统的 IDE 来完成重构任务。
      • 抑或是人工介入,编写框架行的代码,让 AI 来完成剩余的细节工作。
  4. 编写更小的模块、更小的函数,这会显著提升 AI 编写代码的速度、质量,同时,也是减少 token 消耗的有效方式。

    • 在一个 2000 行的代码文件上进行修改,AI 需要理解大量的上下文信息,才能进行有效的修改,这会显著增加 token 的消耗。你可能需要消耗大量的花费。
    • 相反,如果你把代码拆解成多个小的模块、多个小的函数,那么 AI 只需要理解局部的上下文信息,就能完成修改任务,这会显著减少 token 的消耗。
    • 我也尝试过,如果对一个大的函数(实际上,超过50行的代码,人的理解速度就会指数下降),使用 SLAP 原则进行拆解,不仅让我们阅读代码更为容易, 也会显著提升 AI 进行代码修改的效率。
    • 推荐使用 FP 的编程范式,这不仅可以让代码更为简短,也更容易让 AI 理解代码的意图。

大刀阔斧的重构

未完,待续