← 随机比特 / 所有内容

我又开始手写代码了:234 个 commit、7 个月 vibe coding 后的复盘

2026-05-12 · 随机比特

最近一篇博客在 HN 上被顶到了首页:作者用 Claude 全程 vibe-coding(不读代码、只发 prompt、凭手感推动项目前进)造了一个 GPU 感知的 Kubernetes TUI 工具 k10s,7 个月、30 个周末、234 个 commit,最后把整个项目归档,决定从零用 Rust 重写。

他不是反 AI,恰恰相反——他是认真用过 AI 全程编码的人,在他写下"I’m going back to writing code by hand"的时候,给出了一份非常具体的 postmortem:AI 写得动的是 feature,写不动的是 architecture。

整篇 HN 讨论的核心冲突也在这里:大家都在晒"AI 一晚 ship 多少 feature"的当下,这位作者站出来说——feature 没问题,是骨架烂了。今天我们把他这篇博客原原本本地拆开来讲一讲。

先用三句话给你一个心智锚点,方便你边读边对照:

  1. 有个人花 7 个月,几乎全靠让 AI 写代码,做出一个工具。
  2. 工具不是被某个 bug 弄垮的,是整个"骨架"烂掉了。
  3. 他归纳出 5 条规矩,告诉你 AI 在哪几个地方一定会写出烂骨架,以及该怎么提前防住。

一、什么是 k10s,它是怎么烂掉的

先交代下背景。k10s 是作者做的一个 TUI(terminal UI,跑在终端里、用键盘操作的全屏文字界面)工具。

如果你完全不熟 Kubernetes,可以这样想:Kubernetes 是当下最主流的"集群操作系统",公司里管成千上万台机器的人都在用。k10s 是给这些工程师用的一个仪表盘,黑底白字、键盘操作,类似一个 top + 数据库可视化的混合体。同类工具里最有名的叫 k9s,作者这个新工具的差异点是:专门关注 GPU。因为现在很多公司用集群跑 AI 训练,一台带 GPU 的机器一小时要 32 美元,闲一秒都是钱。所以他想做给跑 NVIDIA 集群、关心 GPU 利用率、关心 DCGM 指标(NVIDIA 官方的 GPU 监控数据采集器)的人用——可以理解为 “k9s 的 GPU 版”。

技术栈是 Go + Bubble Tea。Bubble Tea 是一个基于 Elm 架构的 TUI 框架——一句话讲不直观,给你一个比喻:

把它想象成一台只有一个总开关的机器。所有按钮、所有传感器、所有定时器都不直接动机器,而是写一张小纸条扔进一个箱子里;机器旁边坐着一个值班的人,他严格按顺序拿纸条、按纸条上的内容改机器的状态。Bubble Tea 里那个值班的人就叫 Update(),"纸条"就叫 message。整个程序只有一份全局 state,谁也不能绕过值班的人直接伸手去拧机器——这套架构本来非常清爽。

记住这个值班大叔的画面,等下你会看到作者是怎么把这套优雅设计写成相反的样子的。

k10s 最核心的卖点是一个叫 fleet view 的视图——一张表格罗列每个节点的 GPU 分配、利用率、温度、功耗、显存,闲置黄色、繁忙绿色、爆满红色,一目了然。

<figure><img src=“images/gpuview.png” alt=“k10s 的 fleet view 截图,表格展示了多台 g4dn.xlarge 节点的 GPU 使用情况,包含 Instance、Compute、Usage 等列,Usage 列用紫色/橙色/黄色横条表示分配比例”></figure>

如图所示,每一行是集群里的一个节点,Compute 列写着 gpu/nvidia 1×(带一张 NVIDIA GPU),Usage 列那一条彩色横杠就是当前 GPU 的分配占用情况——这个视图本身确实很对 GPU 集群运维的胃口。

前几个星期作者形容是"魔法"。“加个 pods 视图,带实时刷新” — 一句话,搞定。资源列表、命名空间过滤、日志流、describe 面板、键盘导航,一个接一个落地。3 个周末就把 k9s 的核心功能 clone 了个差不多。他说自己写代码的速度大概是平时的 10 倍。

然后他想加上真正的卖点:GPU fleet view。Claude 也是一击即中——一次生成了 FleetView 结构体、tab 过滤(GPU/CPU/All)、自定义渲染、分配条,界面漂亮。

接下来他敲了 :rs pods 想切回 pods 视图(k10s 仿照 k9s 的命令面板语法,:rs 是切换资源类型的命令)。

什么都没渲染出来。表格空的。实时刷新停了。切到 nodes,看到的是 fleet view 残留下来的过滤数据。切回 fleet,tab 计数又错了。

用作者的原话:“the god object had consumed itself.” 上帝对象把自己吃了。

所以这就是事情的起点:不是某一行代码写错了,是整个程序的"骨头"已经长歪了,按哪里都不对。

二、God Object:AI 编码的标志性"气味"

这是作者整篇博客的核心隐喻,他用了一个很妙的类比:

就像 em-dash(破折号 —)是 AI 写作的气味,god-object(上帝对象)就是 AI 编码的气味。

什么叫 god object?字面意思就是"上帝对象"——一个全能、全知、什么都管的超级对象。在面向对象设计里这是公认的反模式,因为它把本来应该分散在多个职责单元里的状态全捏在一处,导致任何一处改动都可能引发不可预测的后果。看一眼他贴出的 Model 结构体就明白了:

type Model struct {
    // 第三方 UI 组件
    table        table.Model
    paginator    paginator.Model
    commandInput textinput.Model
    help         help.Model

    // 集群信息和状态
    k8sClient         *k8s.Client
    currentGVR        schema.GroupVersionResource
    resourceWatcher   watch.Interface
    resources         []k8s.OrderedResourceFields
    listOptions       metav1.ListOptions
    clusterInfo       *k8s.ClusterInfo
    logLines          []k8s.LogLine
    describeContent   string
    currentNamespace  string
    navigationHistory *NavigationHistory
    logView           *LogViewState
    describeView      *DescribeViewState
    viewMode          ViewMode
    viewWidth         int
    viewHeight        int
    err               error
    pluginRegistry    *plugins.Registry
    helpModal         *HelpModal
    describeViewport  *DescribeViewport
    logViewport       *LogViewport
    logStreamCancel   func()
    // ...还有更多
}

UI 组件、K8s 客户端、每个视图各自的状态、导航历史、缓存、鼠标处理——全都塞在一个 struct 里。而所谓的 Update() 方法是一个 500 行的函数,里面是一个 110 个 case 的 switch 在分发消息。

model.go 整个文件 1690 行。换句话说,所有视图共享同一个 state,所有按键、所有消息、所有后台事件都涌进同一个 500 行的 Update() 里被分发——任何一个 case 写错,理论上都可能影响别的视图。

作者在博客里有句话特别戳人,几乎可以当作 vibe-coding 这个词的活体定义:“7 个月里我一直在 prompt、ship,从来没坐下来真正读过 Claude 写的代码。我看 diff、确认编译通过、跑通 happy path,然后下一个。”——这就是 vibe coding 最危险的姿势。直到某个东西彻底坏掉、prompt 已经救不回来,他第一次坐下来认真读那 1690 行代码,“我被吓到了”。

三、五条戒律:从废墟里挖出来的

作者把他踩过的坑提炼成五条戒律,每一条都配一个具体的反例代码片段,以及他建议你写进 CLAUDE.md / AGENTS.md 的 directive(指令)。

先说明 CLAUDE.md 是个什么东西:它就是一份放在项目根目录的 markdown 文档。Claude(或别的 AI 编程助手)每次开工前会先读它,你可以把它理解成 “AI 的工作守则”。作者这五条戒律的最终形态全都是"该往这份守则里加哪几句话"——因为他发现,AI 不会主动给自己定规矩,但你写下来它就会照做。

下面一条条看。

戒律 1:AI 只写 feature,不会写 architecture

每次他让 Claude 加一个 feature,Claude 都漂亮地交付。fleet view 一次就通,日志流也通,鼠标支持也通。问题是:每个 feature 都是在"先把这个搞通"的语境下被实现的,对另外 49 个共用同一份 state 的 feature 一无所知

最典型的就是 resourcesLoadedMsg 这个 handler——每次切换视图都会跑(顺带提一下:下面代码里反复出现的 m.currentGVR.Resource,GVR 是 Kubernetes API 里 Group/Version/Resource 的缩写,意思就是"这是哪种资源对象",比如 pods、nodes、deployments):

case resourcesLoadedMsg:
    m.logLines = nil       // 加载资源时清空日志行
    m.horizontalOffset = 0 // 切资源时重置横向滚动

    if m.currentGVR != msg.gvr && m.resourceWatcher != nil {
        m.resourceWatcher.Stop()
        m.resourceWatcher = nil
    }
    m.currentGVR = msg.gvr
    m.currentNamespace = msg.namespace
    // ...

    // 节点视图特殊处理:先存全量、分类、再过滤
    if msg.gvr.Resource == k8s.ResourceNodes && m.fleetView != nil {
        m.allResources = msg.resources
        m.allCreationTimes = msg.creationTimes
        if len(msg.rawObjects) > 0 {
            m.fleetView.ClassifyAndCount(m.rawObjectPtrs())
        }
        m.applyFleetFilter()
    } else {
        m.resources = msg.resources
        // ...
    }

那个 if msg.gvr.Resource == k8s.ResourceNodes && m.fleetView != nil 看到了吗?这就是 fleet view 在通用资源加载路径里被特殊处理。每加一个需要点定制行为的新视图,就得在这里多加一个分支,每个分支还得手动清理"上一个视图"残留的对应字段——否则你切过去就会看见前一个视图泄漏过来的鬼影数据。

他数了一下整个文件里 = nil 这种清理语句出现了多少次:

m.logLines = nil      // 加载资源时清空日志
m.allResources = nil  // 不在节点视图时清空 fleet 数据
m.resources = nil     // 加载日志时清空资源
m.resources = nil     // 加载 describe 视图时清空资源
m.logLines = nil      // 加载 describe 视图时清空日志
m.resources = nil     // 加载 yaml 视图时清空资源
m.logLines = nil      // 加载 yaml 视图时清空日志
m.logLines = nil      // ... 另外两处在别的 handler 里
m.logLines = nil

9 处分散在 1690 行里的手动置空。漏一个,你就有鬼影。AI 看不到这个模式的腐化,因为每次 prompt 只动一条代码路径。

他建议你在 CLAUDE.md 里写:

# Architecture Invariants(架构不变式)

- 每个 view 实现 View trait。views 之间不允许互相访问状态。
- 所有异步数据通过 AppMsg 变体送达。后台任务禁止直接改字段。
- 加一个新 view 不应该需要修改任何已有的 view。
- App 结构体只是一个薄路由层。它只负责导航和消息分发,别的什么都不管。

他说,AI 不会主动发明这些规矩,但你写下来,它就会照着做。

所以这一条的关键不是"AI 不行",而是 “AI 没有把全局看在眼里的义务,把全局看清楚是你的活”。

戒律 2:god object 是 AI 的默认产物

为什么 AI 会默认走向"一个 struct 装一切"?因为这条路最短、最快满足当前 prompt、最少仪式。但代价是键位分发会变成噩梦

看一眼 s 这个键的实际分发逻辑:

case m.config.KeyBind.For(config.ActionToggleAutoScroll, key):
    if m.currentGVR.Resource == k8s.ResourceLogs {
        m.logView.Autoscroll = !m.logView.Autoscroll
        if m.logView.Autoscroll {
            m.table.GotoBottom()
        }
        return m, nil
    }
    // pods 和 containers 视图下是 shell exec
    if m.currentGVR.Resource == k8s.ResourcePods {
        // ... 20 行查找选中 pod、拿名字、命名空间 ...
        return m, m.commandWithPreflights(
            m.execIntoPod(selectedName, selectedNamespace),
            m.requireConnection,
        )
    }
    if m.currentGVR.Resource == k8s.ResourceContainers {
        // ... 进容器的 exec 逻辑 ...
        return m, m.commandWithPreflights(m.execIntoContainer(), m.requireConnection)
    }
    return m, nil

一个键,三种行为:在 logs 视图里是"切换 autoscroll",在 pods 视图里是"进 pod shell",在 containers 视图里是"进容器 shell"。全挤在一个扁平的 switch 里,因为根本没有"每个视图自己的键位表"这种东西。

AI 为什么这么写?因为他当初说"给 pods 加 shell 支持",AI 就找到最近的 key handler 把代码塞进去了——最短路径。

类似的 m.currentGVR.Resource == 在整个文件里被当 type discriminator 用了 20 多次。不是类型判断,是字符串比较。每加一个新视图,每个 handler 都得改。

他给的 directive 是:

# State Ownership Rules(状态归属规则)

- 永远不要在 App/Model struct 里加视图专有的字段。
- 每个 view 都是独立 struct,实现 View trait/interface。
- 每个 view 声明自己的键位表。app 把按键派发给当前激活的 view。
- 要加键位,加到对应 view 的 keymap 里,不要加到全局。
- 加 view = 加一个新文件。如果你的改动需要动已有的 view,停下来问我。

他总结得很到位:AI 永远走最短路径(“再加一个 if 分支”),你要做的就是让最短路径同时也是正确路径——办法就是把护栏写进它每次都会读的那个文件里。

所以第二条戒律可以记成一句话:当你的项目里出现"超级对象 + 巨型 switch"的组合,说明 AI 在用最短路径替你延寿,但这条路通向悬崖

戒律 3:速度幻觉会撑爆你的 scope

这一条是心理层面的,他认为是最危险的一条。

k10s 一开始的定位非常清楚:GPU 集群操作员用的工具。一个小众但精准的用户群。但 vibe coding 让一切都变得"便宜"——“哎,加个 pods 视图只要一个 session?那再加 deployments、services、命令面板、鼠标支持、contexts、namespaces 也都加上吧。”

<figure><img src=“images/resourceview.png” alt=“k10s 的通用 pod 列表视图截图,显示了 130 个 pod,包含 Name、Status、Restarts、Age、Namespace、Pod IP、Node 等列,下方还有一个 rs no 的命令输入”></figure>

不知不觉,他在造 k9s——一个通用 Kubernetes TUI、给所有人用。原因就一个:AI 让每个 feature 看起来都是"白嫖"的。

但其实根本不是白嫖。每个 feature 都是 god object 里又长出来的一根分支。看一下他贴的 keymap 结构体:

type keyMap struct {
    Up, Down, Left, Right    key.Binding
    GotoTop, GotoBottom      key.Binding
    AllNS, DefaultNS         key.Binding
    Enter, Back              key.Binding
    Command, Quit            key.Binding
    Fullscreen               key.Binding  // log view
    Autoscroll               key.Binding  // log view(同时也是 pods 里的 shell!)
    ToggleTime               key.Binding  // log view
    WrapText                 key.Binding  // log + describe view
    CopyLogs                 key.Binding  // log view
    ToggleLineNums           key.Binding  // describe view
    Describe                 key.Binding  // resource views
    YamlView                 key.Binding  // resource views
    Edit                     key.Binding  // resource views
    Shell                    key.Binding  // pods(和 Autoscroll 冲突!)
    FilterLogs               key.Binding  // log view
    FleetTabNext             key.Binding  // 只在 fleet view 有效
    FleetTabPrev             key.Binding  // 只在 fleet view 有效
}

所有视图共用一张扁平 keymap,旁边的注释标注每个键属于哪个视图。AutoscrollShell 都是 s,靠 dispatch 时检查 m.currentGVR.Resource 来区分。这能跑——但你没办法在局部理解任何一个键位,必须沿着 500 行的 Update 函数追一遍才知道这个键到底在干嘛。

值得点明一下:“它能跑"不等于"它是对的”——AI 优化的恰恰是前者,而你需要的是后者。

复杂度在悄悄堆积,而 velocity 仪表盘上还是绿油油的"你在 ship!"

他给的 directive 是写一份明确说明"我们不为谁服务"的 vision doc,并把 scope 边界放进 CLAUDE.md

# Scope(不要超出这个范围)

k10s 是给 GPU 集群操作员用的,不是给所有 Kubernetes 用户用的。
支持的视图:fleet、node-detail、gpu-detail、workload。就这些。
不要加通用资源视图(pods、deployments、services)。
不要加任何重复 k9s 已有功能的特性。
如果一个 feature 请求不能服务到正在跑 GPU 训练任务的人,拒掉。

他特别强调:vibe coding 让你以为你有无限的实现预算。你没有。你有无限的"行数预算"(AI 会无限地产代码),但你的复杂度预算和以前一样有限。架构能支撑的 feature 数量是固定的,跟你写得多快没关系。CLAUDE.md 里的 scope 段,就是你提前替自己说"不"——免得在那个 velocity 嗨翻天的当下你一时冲动答应了。

所以第三条戒律是心理战:不是怕 AI 写错,是怕 AI 让"加 feature"这件事感觉太便宜

戒律 4:positional data 是一颗定时炸弹

k10s 里每个 Kubernetes API 拉回来的资源,立刻就被压平成:

type OrderedResourceFields []string

列的身份完全靠位置确定。看一下 fleet view 的排序函数:

func sortFilteredResources(rows []k8s.OrderedResourceFields, times []time.Time, tab FleetTab) {
    sort.SliceStable(indices, func(a, b int) bool {
        ra := rows[indices[a]]
        rb := rows[indices[b]]

        switch tab {
        case FleetTabGPU:
            // 按 Alloc 列升序排序(下标 3)
            allocA, allocB := "", ""
            if len(ra) > 3 {
                allocA = ra[3]
            }
            if len(rb) > 3 {
                allocB = rb[3]
            }
            return allocA < allocB
        // ... 其他 case ...
        }
    })
}

ra[3] 是 Alloc,ra[2] 是 Compute,ra[0] 是 Name。这些是 magic number(魔法数字)。唯一把"下标 3"和"Alloc"绑在一起的东西,是一行注释和 resource.views.json 里定义的列顺序:

{
  "nodes": {
    "fields": [
      { "name": "Name",     "weight": 0.28 },
      { "name": "Instance", "weight": 0.15 },
      { "name": "Compute",  "weight": 0.12 },
      { "name": "Alloc",    "weight": 0.12 },
      ...
    ]
  }
}

在 Instance 和 Compute 之间插一个新列试试看?所有 ra[2]ra[3] 的代码全部静默错位。具体症状是:你点击"按 Alloc 排序",结果表格实际上是按 Compute 列排的——但 UI 上没有任何报错,你根本发现不了;或者更糟,节点名字之间互相比较 GPU 占用率谁高谁低,结果在 UI 上看起来"也排了",只是排得离谱。编译器帮不了你,因为全都是 []string。JSON 又没法表达排序行为和条件渲染,所以这些逻辑只能写在 Go 代码里,并且硬编码了 JSON 里的位置假设

AI 为什么这么写?因为"拉数据→渲染表格"最短路径就是 []string,能立刻满足任何 table widget。typed struct 要多写好多脚手架代码。AI 走快路,你六个月后调试"为什么 Name 列的值出现在 Alloc 列"。

他给的 directive:

# Data Representation(数据表示)

- 永远不要把结构化数据压平成 []string、Vec 或位置数组。
- 所有数据以 typed struct(FleetNode、PodInfo 等)的形式流动,
  直到 render() 调用为止。
- 列的身份来自 struct 字段名,不是数组下标。
- 排序函数操作 typed 字段,禁止 row[3] 这种位置访问。
- 生成 display 字符串的唯一位置是 render()/view() 函数内部。

然后让 typed struct 让非法状态无法被构造(“make impossible states impossible”,Elm/Rust 社区的经典口号):

struct FleetNode {
    name: String,
    instance_type: String,
    compute_class: ComputeClass,
    alloc: GpuAlloc,
}

字段名了,你就不可能用错列排序不可能把 Alloc 字符串当 Name 比——编译器帮你强制保证。核心思路是:用类型系统在编译期把"出错的可能性"消灭掉,而不是在运行期祈祷检查到位。AI 默认会选 Vec<String>,因为更快满足 prompt;你的 CLAUDE.md 让 typed 路径变成"阻力最小的路径"。

所以第四条戒律可以这样记:[]string 加魔法下标 = 把代码的正确性押在你六个月后的记忆力上

戒律 5:AI 不会主动管 state transition

Bubble Tea 框架有一个优雅的设计:Update() 是唯一能改 state 的地方,由消息驱动。但 k10s 把这条违反了。updateTableMsg 这个 handler 里返回了一个闭包,这个闭包在 goroutine 里直接改 Model 的字段:

case updateTableMsg:
    return m, func() tea.Msg {
        // 阻塞等待有人发来 update 消息
        // ... 然后在这个 goroutine 里直接读写 m.resources、m.table、m.viewWidth ...
        if savedCursor >= rowCount {
            savedCursor = rowCount - 1
        }
        m.table.SetCursor(savedCursor)
        return updateTableMsg{}
    }

这个返回的函数(tea.Cmd)被 Bubble Tea 在另一个 goroutine 里跑。它读写 m.resourcesm.tablem.viewWidth。同时,View() 在主 goroutine 里也在读这些字段。没有锁。没有 mutex

形象点说:主线程正在画一张表格,后台线程正在改这张表格的数据。绝大多数时候后台改得早一点或晚一点,相安无事;可一旦两个人凑巧在同一瞬间动同一行——画的那个看到的就是"半新半旧"的乱码。这就是 data race。出 bug 的概率可能只有 1%,但它一旦出现你完全没法稳定复现——这就是经典的"99% 的时候是对的"。

他给的 directive:

# Concurrency Rules(并发规则)

- 后台任务(watchers、scrapers、API 调用)禁止直接改 UI state。
- 后台任务通过 channel 把结果作为 typed message 发出来。
- 只有主事件循环能根据收到的消息施加 state 修改。
- render()/view() 是纯函数。没有副作用、没有 I/O、没有 channel 操作。
- 如果你需要从异步代码里更新 state,定义一个新的 AppMsg 变体。

正确的形态是:

// 后台任务:
tx.send(AppMsg::FleetData(nodes)).await;

// 主循环:
match msg {
    AppMsg::FleetData(nodes) => {
        self.fleet_view.update_nodes(nodes);
    }
}

没有共享可变状态、没有 data race、没有"99% 能跑"

所以第五条戒律记住一句:还记得开头那个"值班大叔"吗?后台任务只许塞纸条,不许伸手拧机器。

<figure><img src=“images/logswork.png” alt=“k10s 的日志视图截图,左侧是大量 JSON 格式的日志行(包含 controllers 的 reconciler 输出),底部是状态栏显示 wrap、time、line numbers 等控制项”></figure>

顺带一提,作者在贴出"鼠标也能复制日志吧,能出什么问题?"这张图(如图,k10s 日志视图里密密麻麻的 JSON 日志行)的时候,配文的意思是反讽的——这正是他后来踩坑的源头之一:鼠标支持本身就是个充满异步状态副作用的地雷区。

四、他现在在做什么

作者决定用 Rust 重写。但他强调了一句很重要的话:选 Rust 不是因为 Rust 更好,而是因为"那是我能驾驭的语言。我写过足够多 Rust,能在某段代码不对劲时——还说不清为什么——就先感觉到。这种本能是 vibe-coding 替代不了的。AI 递给你的代码看起来都很合理,你得有一种’这是垃圾’的嗅觉。"

这句话否定了一个常见的错误推论:"换个更严格的语言(Rust、TypeScript)就能解决 AI vibe-coding 的问题。"作者说不是——是你得是能 review 这门语言的人。

另一个变化更简单:所有架构设计先用手写出来,再写一行代码。不是模糊的设计文档,是具体的接口、消息类型、所有权规则。那些 AI 一直做错的架构决定,现在变成"在第一个 prompt 之前先用文字定下来"。

至于这套做法够不够防止重写出来的版本再次崩塌——他自己直接说:“Whether that’s enough to keep the rewrite from collapsing under its own weight… I’ll find out.”(我也不知道够不够,等着瞧吧。)这种诚实是这篇文章可信度的来源之一——它不是一份兜售方法论的鸡汤,是一份还没尘埃落定的复盘。

五、给所有还在 vibe coding 的人的总结

最后说一句这位作者的立场:他不是 AI 反对者,他是真的拿 234 个 commit、30 个周末、7 个月跑通完整循环的人,下结论之前把 1690 行的 model.go 字字读完。把他整篇 postmortem 浓缩成一段:

最重要的一点:这些规则 AI 不会替你发明,但你写在 CLAUDE.md / AGENTS.md 里它就会照着做。所以与其在事后骂 AI 写得烂,不如把架构护栏写在它每次都会读到的那个文件里。

让 AI 写 feature,让人写 architecture。

参考资料