“氛围编程让一切看起来很廉价,我要回归手写编码了!”
如果说这几年 AI 编程令人沉迷的地方是“几乎不费力就能把东西做出来”那么真正让人开始警觉的瞬间往往发生在系统第一次“看起来还在工作但已经开始悄悄失控”的时候。这篇来自 k10s 作者的长文就是从那个临界点开始往回看的。他记录了一次几乎完全依赖 vibe-coding 的开发实验从一个 GPU 感知的 Kubernetes TUI到一个功能不断膨胀、最终被自身复杂度吞噬的系统。过程中有过极高的开发速度也有过 AI 一次性生成完整功能的“爽感时刻”但同样也埋下了架构失控、状态混乱和隐性 bug 的伏笔。更重要的是这不是一篇单纯的“翻车复盘”而是一份带着具体代码细节、设计选择和反思路径的拆解记录。它试图回答的并不是“AI 能不能写代码”而是另一个更现实的问题当 AI 真的参与到软件构建的每一层时人类到底还需要在哪些地方重新夺回控制权。原文https://blog.k10s.dev/im-going-back-to-writing-code-by-hand/作者 | shvbsle 责编 | 苏宓出品 | CSDNIDCSDNnews这件事最开始其实更像一次实验或者说一个问题——“如果我尽可能不亲自参与开发流程只靠 AI 来写软件我到底能走多远”话不多说这篇开发日志最终得出的核心结论是如果真想做出点有意义的东西人类依然必须参与其中。几点感受就像 “em-dash长破折号” 已经快成 AI 写作的标志一样“god-object上帝对象” 也快成 AI 写代码的典型特征了。“氛围编程” 会让一切看起来都很廉价你最后很可能会失去重点做出越来越臃肿的东西。架构一定要由人也就是你来设计而不是不停地让 AI 往里加功能。另外我还整理了一些 AGENTS.md / CLAUDE.md 的指令配置它们确实能让我稍微少亲自下场一点。截至 2026 年 5 月 10 日人类干预依然是必需的。这项实验到底验证了哪些东西以下是完整的过程。234 次提交、30 个周末用氛围编码开始一个项目这是 k10s GitHub 仓库https://github.com/shvbsle/k10s/tree/archive/go-v0.4.0234 次提交差不多花了 30 个周末。整个项目几乎完全是在“氛围编程”状态下完成的——只要 Anthropic 的 Claude token 额度还没耗尽我就继续往前做、继续发功能。现在我决定把这个 TUI 工具归档然后从头开始重写。k10s 最初的定位是一个“支持 GPU 感知”的 Kubernetes Dashboard也是我第一次认真尝试用 AI 去开发一个真正复杂的软件。你可以把它理解成一个面向 NVIDIA GPU 集群的 k9s服务的是那些真正关心 GPU 利用率、DCGM 指标以及哪些节点正在空转烧钱每小时 32 美元的人。我用 Go 和 Bubble Tea 写了它而且它一开始确实能跑。至少……有一段时间是这样。过去 7 个月里我学到的东西比我现在准备删掉的那 1690 行 model.go 更有价值。我觉得任何认真尝试“vibe-coding”的人可能都会从这些经验里得到点东西因为这部分内容其实很少有人讨论——它通常都会被那些炫酷 Demo 和“开发速度暴涨”的故事掩盖掉。一句话总结AI 会帮你写功能但不会帮你设计架构。如果你长时间不加约束地让它“自己运行”最后只会留下越来越严重的事故现场。开发速度会让你误以为自己一路领先直到某个瞬间整个系统开始同时崩塌。加入 AMD AI 开发者计划免费领 50 小时云算力券进群月月抽显卡、AIPC好运不停“氛围编程”上头的时候我是在 2025 年 9 月底开始做 k10s 的。最开始那几周简直像魔法一样。我对 Claude 说一句“加一个支持实时更新的 Pods 视图”然后“砰”的一下功能就出来了。资源列表视图、命名空间筛选、日志流、描述面板、键盘导航……一个个功能都顺利落地。原因也很简单那时候项目还足够小AI 还能把整个工程都“装进上下文”里。最基础的那个 “k9s 克隆版”我大概只花了 3 个周末。它包含了Pods、Nodes、Deployments、Services 的资源视图命令面板基于 Watch 的实时更新Vim 风格快捷键……全部都能跑而且几乎全是在单次 session 里“vibe-coded”出来的。我当时的开发速度可能是平时的 10 倍。那种感觉真的很爽。然后我开始做这个项目真正的核心卖点。k10s 存在的真正原因其实是 GPU 集群视图GPU Fleet View。我想做一个专门的界面让你能一眼看到每个节点的 GPU 分配情况、DCGM 利用率、温度、功耗、显存占用。不是把信息埋在 kubectl describe node 那种输出里而是做成一个专门设计的表格界面并且带颜色状态提示空闲节点显示黄色、繁忙节点显示绿色、GPU 打满显示红色。结果 Claude 一次就生成成功了。我只写了个 prompt它就直接生成了 FleetView 结构体、GPU / CPU / All 的标签过滤、自定义渲染逻辑、GPU 分配进度条而且界面看起来还特别漂亮。那时候我整个人都沉浸在“AI 开发效率爆炸”的快感里。直到后来我输入了一句:rs pods想切回 Pods 视图。结果什么都没出来。表格是空的。实时更新停了。我切换到 Nodes 视图时它显示的还是 Fleet View 的旧过滤数据。再切回 Fleet View标签统计数字又全错了。那个“上帝对象god object”终于把自己吞噬掉了。这也是这篇博客标题的来源。也是我第一次真正“人工介入”的时刻。整整 7 个月里我一直都在“prompt → 生成 → 发布”却从来没有真正坐下来认真读过 Claude 写的代码。我通常只是看一下 diff、确认能编译、再测一下 happy path、然后继续往前做。但这次不一样了。这已经不是再写个 prompt 就能解决的问题。系统的基础结构已经坏掉了。于是我第一次真正坐下来开始读 model.go。整整 1690 行代码。我当场头皮发麻。代码大概是这样的一个结构体统治一切。type Model struct { // 3rd party UI components table table.Model paginator paginator.Model commandInput textinput.Model help help.Model // cluster info and state 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() logLinesChan -chan k8s.LogLine horizontalOffset int mouse *MouseHandler fleetView *FleetView creationTimes []time.Time allResources []k8s.OrderedResourceFields // fleets unfiltered set allCreationTimes []time.Time // fleets timestamps rawObjects []unstructured.Unstructured ageColumnIndex int // ...}UI 组件、K8s Client、日志状态、Describe 状态、Fleet 状态、导航历史、缓存、鼠标事件处理……全部塞进了同一个 struct。而 Update() 方法更夸张一个 500 行的大函数里面根据 msg.(type) 分发逻辑堆了 110 个 switch/case 分支。也就是从这一刻开始——我不再只是凭氛围进行编码而是开始真正思考软件工程了。从“事故现场”里总结出的五条原则这是我花了 7 个月时间看着 AI 一点点生成、并最终“反噬自身”的代码库后总结出来的东西。下面每一条都是我亲自踩过的坑我哪里做错了为什么 AI 辅助编程特别容易掉进这个坑以及你到底应该在 CLAUDE.md 或 agents.md 里写些什么才能提前避免它原则一AI 会写功能但不会设计架构每次我让 Claude 加一个功能它都能做出来而且完成得相当漂亮。Fleet View 一次成功日志流能跑鼠标支持功能也能运行。问题在于每个功能都是站在“先把当前需求做出来”的角度实现的。它根本不会意识到系统里还有另外 49 个共享同一份状态的功能。举个例子下面这个 resourcesLoadedMsg handler是每次切换视图时都会执行的代码。case resourcesLoadedMsg: m.logLines nil // Clear log lines when loading resources m.horizontalOffset 0 // Reset horizontal scroll on resource change if m.currentGVR ! msg.gvr m.resourceWatcher ! nil { m.resourceWatcher.Stop() m.resourceWatcher nil } m.currentGVR msg.gvr m.currentNamespace msg.namespace m.listOptions msg.listOptions m.rawObjects msg.rawObjects // For nodes: store the full unfiltered set, classify, then filter 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 m.creationTimes msg.creationTimes m.allResources nil m.allCreationTimes nil }你看到这个「if msg.gvr.Resource k8s.ResourceNodes m.fleetView ! nil」条件语句了吗这代表 Fleet View 被硬编码进了通用资源加载流程。之后每新增一个需要“特殊行为”的视图这里就会再多一个 if branch。而且每个 branch 还得手动清理对应字段否则前一个视图的数据就会“泄漏”到下一个视图里。我后来专门数了一下这个文件里到底有多少个 nil 的手动清理逻辑m.logLines nil // Clear log lines when loading resourcesm.allResources nil // Clear fleet data when not on nodesm.resources nil // Clear resources when loading logsm.resources nil // Clear resources when loading describe viewm.logLines nil // Clear log lines when loading describe viewm.resources nil // Clear resources when loading yaml viewm.logLines nil // Clear log lines when loading yaml viewm.logLines nil // ... two more in other handlersm.logLines nil答案是9 个。9 个散落在 1690 行文件里的“手动状态回收”。漏掉任何一个你就会看到前一个视图留下的“幽灵数据”。这就是没有“视图隔离”时必然发生的事情。而 AI 根本看不到这个架构正在慢慢腐烂。因为每一次 prompt它只会关注当前那一条代码路径。正确做法的是在写任何代码之前先自己把架构设计好。不是那种空泛的设计文档。而是要编写一套有明确的接口、消息类型、状态所有权规则。然后把这些规则写进 CLAUDE.md# Architecture Invariants (CLAUDE.md)- Each view implements the View trait. Views do NOT access other views state.- All async data arrives via AppMsg variants. No direct field mutation from background tasks.- Adding a new view MUST NOT require modifying existing views.- The App struct is a thin router. It owns navigation and message dispatch. Nothing else.这样 AI 每次收到 prompt 时都会先看到这些约束。原则二“上帝对象”是 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 } // Shell exec for pods and containers views if m.currentGVR.Resource k8s.ResourcePods { // ... 20 lines to look up selected pod, get name, namespace ... return m, m.commandWithPreflights( m.execIntoPod(selectedName, selectedNamespace), m.requireConnection, ) } if m.currentGVR.Resource k8s.ResourceContainers { // ... container exec logic ... return m, m.commandWithPreflights(m.execIntoContainer(), m.requireConnection) } return m, nil同一个快捷键在不同视图里有三种完全不同的含义在 Logs 里表示“自动滚动”在 Pods 里表示“进入 Shell”在 Containers 里表示“进入容器 Shell”所有逻辑全塞在同一个巨型 switch 里。为什么会变成这样因为我对 AI 说“给 Pods 加 shell 支持。”于是它就找到最近的键盘处理逻辑直接把代码塞进去。再看 Enter 键。整个 drill-down handler 也是同样的问题。case m.config.KeyBind.For(config.ActionSubmit, key): // Special handling for contexts view if m.currentGVR.Resource contexts { // ... 12 lines ... return m, m.executeCtxCommand([]string{contextName}) } // Special handling for namespaces view if m.currentGVR.Resource namespaces { // ... 12 lines ... return m, m.executeNsCommand([]string{namespaceName}) } if m.currentGVR.Resource k8s.ResourceLogs { return m, nil } // ... 25 more lines of generic drill-down ...所有视图都被塞进一个“平铺式 dispatch”。整个文件里有 20 多处m.currentGVR.Resource ...这样的字符串判断。这里说的不是类型系统、不是抽象接口只是字符串比较。这意味着每新增一个视图你都得改所有 handler。正确的做法是你应该在 CLAUDE.md 里明确写下规则# State Ownership Rules- NEVER add fields to the App/Model struct for view-specific state.- Each view is a separate struct implementing the View trait/interface.- Each view declares its own key bindings. The app dispatches keys to the active view.- If you need to add a keybinding, add it to the relevant views keymap, not a global one.- Adding a view means adding a file. If your change requires modifying existing views, stop and ask.AI 永远会走“最短路径”——也就是“再加一个 if 分支”。你的工作是让“最短路径”同时也是“正确路径”。而方法就是把约束提前写进 AI 每次都会读取的规则文件里。原则三开发速度的幻觉会不断扩大你的项目范围这一条不是技术问题是心理问题也是我觉得最危险的一条。我一开始做 k10s 时目标其实很简单就是设计一个面向 GPU 集群的小众工具给那些跑训练集群的人用也就是我自己这种人。但 “vibe-coding” 会让一切看起来都太便宜了。“哦Pods 视图一晚上就做完了”那顺手再加 Deployments、Services、完整命令面板、鼠标支持、Context 管理、Namespace 管理..最后我突然发现我已经在重新造一个 k9s 了。一个面向所有人的通用 Kubernetes TUI因为 AI 让每个功能都显得“像不要钱一样”。但它其实不是免费的。每个新功能都是那个“上帝对象”里新增的一条分支。比如这个 keybinding structtype 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 (also shell in pods!) 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 (CONFLICTS with Autoscroll!) FilterLogs key.Binding // log view FleetTabNext key.Binding // fleet view only FleetTabPrev key.Binding // fleet view only}所有视图共用一个扁平化的 keymap。注释里甚至还得写“这个快捷键属于哪个视图”s 同时代表 Autoscroll、Shell之所以“还能工作”只是因为 dispatch 里会先检查 m.currentGVR.Resource。但代价是你已经无法局部理解任何一个快捷键了。你必须一路追踪整个 500 行的 Update()才能知道一个按键最终会干什么。复杂度正在悄悄累积而 AI 给你的“速度反馈”却一直在告诉你“你开发得真快。”正确的做法是提前写一份 Vision Doc。明确写清楚“哪些用户不是你的目标用户”然后把项目边界写进 CLAUDE.md。# Scope (do NOT expand beyond this)k10s is for GPU cluster operators. Not all Kubernetes users.Supported views: fleet, node-detail, gpu-detail, workload. Thats it.Do NOT add generic resource views (pods, deployments, services).Do NOT add features that duplicate k9s functionality.If a feature request doesnt serve someone running GPU training jobs, reject it.“Vibe-coding” 会让你误以为自己拥有无限开发预算。其实你拥有的只是“无限代码行预算”。AI 可以无限生成代码但你的“复杂度预算”仍然是有限的。无论代码写得多快架构能承受的功能数量始终有限。超过之后它一定会塌。CLAUDE.md 里的 scope section本质上就是在“速度快感”让你什么都想加之前提前替自己说“不”。原则四位置型数据结构就是定时炸弹在 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: // Sort by Alloc column (index 3) ascending allocA, allocB : , if len(ra) 3 { allocA ra[3] } if len(rb) 3 { allocB rb[3] } return allocA allocB case FleetTabCPU: // Sort by Name column (index 0) ascending nameA, nameB : , if len(ra) 0 { nameA ra[0] } if len(rb) 0 { nameB rb[0] } return nameA nameB case FleetTabAll: // GPU nodes first, then CPU nodes. // Within GPU: sort by Alloc (index 3). // Within CPU: sort by Name (index 0). computeA, computeB : , if len(ra) 2 { computeA ra[2] } if len(rb) 2 { computeB rb[2] } aIsGPU : strings.HasPrefix(computeA, gpu) bIsGPU : strings.HasPrefix(computeB, gpu) // ... } })}ra[3] 表示 Alloc。ra[2] 表示 Compute。ra[0] 表示 Name。全是“魔法数字”。index 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] 的地方都会在不知不觉中失效。编译器根本帮不上忙因为这些数据全都是 []string。更糟的是JSON 配置根本没法表达排序行为、条件渲染或自定义 drill-down 跳转目标所以这些逻辑最后只能写进 Go 代码里而代码又硬编码地依赖 JSON 中字段的位置。AI 会生成这种模式因为这是从“获取数据”到“渲染表格”的最短路径。一个 []string 可以立刻塞进任何表格组件里而强类型结构体typed struct前期需要更多样板代码ceremony。于是 AI 总会选择那条最快的路。结果六个月后你开始排查为什么排序后“Name” 列的数据会出现在 “Alloc” 列里。正确做法是什么把这条规则写进你的 CLAUDE.md# Data Representation- NEVER flatten structured data into []string, VecString, or positional arrays.- All data flows as typed structs (FleetNode, PodInfo, etc.) until the render() call.- Column identity comes from struct field names, not array indices.- Sort functions operate on typed fields, never on positional access like row[3].- The ONLY place strings are created for display is inside render()/view() functions.这样一来你定义的强类型结构体typed struct就能让“不可能出现的状态”真正变得不可能出现 [2]struct FleetNode { name: String, instance_type: String, compute_class: ComputeClass, alloc: GpuAlloc,}当列对应的是具名字段时你就不可能排错列。你也不可能误把 Alloc 字段的字符串当成名字去比较。编译器会替你强制保证这些约束。而 AI 永远会倾向于选择 VecString因为它能更快满足提示词要求。你在 CLAUDE.md 里写下的规则本质上就是在把“强类型”这条路变成阻力最小、最容易被 AI 采用的默认路径。原则五AI 不应该掌控状态变更。Bubble Tea 的架构里有一个很漂亮的理念Update() 是唯一允许状态发生变化的地方而且所有状态变更都由消息驱动。但 k10s 违背了这一点。updateTableMsg 处理器启动了一个闭包在 goroutine 内部修改 Model 字段case updateTableMsg: return m, func() tea.Msg { // block on someone sending the update message. -m.updateTableChan // Preserve cursor position across column/row updates so that // background refreshes dont reset the users selection. savedCursor : max(m.table.Cursor(), 0) // run the necessary table view update calls. m.updateColumns(m.viewWidth) m.updateTableData() // Restore cursor, clamped to valid range. rowCount : len(m.table.Rows()) if rowCount 0 { if savedCursor rowCount { savedCursor rowCount - 1 } m.table.SetCursor(savedCursor) } return updateTableMsg{} }这个返回函数tea.Cmd会被 Bubble Tea 放到独立 goroutine 执行。它里面调用了 m.updateColumns(m.viewWidth) 和 m.updateTableData() 函数。这些函数会同时读写 m.resources、m.table、m.viewWidth。与此同时主线程里的 View() 也在读取同一批字段。没有锁。没有 mutex。-m.updateTableChan 虽然会阻塞 goroutine直到收到更新信号但它根本无法阻止 View() 在“状态只更新到一半”的时候进行读取。这是教科书级别的数据竞争data race。99% 的时间它看起来都正常。剩下 1% 的时间它会以一种极其诡异的方式损坏 UI。严重到让我一度怀疑自己精神出了问题。AI 为什么会生成这种代码是因为“直接在 closure 里修改状态”是最快能跑起来的方案。而正确的消息传递架构是 worker 发消息、Update() 收消息、主循环统一修改状态需要更多类型、更多 plumbing。AI 不会为了并发正确性去做这些。它只会为了“当前 prompt 能跑”去优化。正确的做法是所有对“影响渲染的状态”的修改都必须发生在主循环里仅此一条原则没有例外。后台 worker 只负责产生数据然后把数据以 message 的形式发送出去。主循环接收 message再统一应用状态变更。在并发 UI 代码里这是唯一不能被打破的规则。// Background task:tx.send(AppMsg::FleetData(nodes)).await;// Main loop:match msg { AppMsg::FleetData(nodes) { self.fleet_view.update_nodes(nodes); }}不要共享可变状态。不要数据竞争。不要“99% 情况下能跑”。把这些规则写进 CLAUDE.md。# Concurrency Rules- Background tasks (watchers, scrapers, API calls) NEVER mutate UI state directly.- Background tasks send results through a channel as typed messages.- Only the main event loop applies state mutations from received messages.- render()/view() is a PURE function. No side effects. No I/O. No channel operations.- If you need to update state from async work, define a new AppMsg variant.如果 AI 默认不会这样写代码。那规则文件就必须强制它只能这样写。我现在准备怎么做我正在用 Rust 重写 k10s。不是因为 Rust “更强”。而是因为——这是我真正“能掌控”的语言。我已经写了足够多的 Rust以至于很多时候代码哪里不对劲我甚至在还没说清原因之前就已经能本能地察觉到。而这种“直觉”恰恰是 vibe-coding 永远替代不了的东西。AI 会给你一份“看起来很合理”的代码。但你必须自己具备一种嗅觉知道它什么时候其实是一坨垃圾。另一个变化则更简单这一次我会亲手完成设计工作。而且是在写任何代码之前。不是那种空泛的大纲文档。而是拥有明确的接口、消息类型、所有权规则。之前那些总被 AI 做错的架构决策现在我会在第一条 prompt 发出去之前先白纸黑字地写清楚。至于这样做最终能不能避免这次重构再次“被自己的复杂度压垮”……拭目以待推荐阅读人均奖金达610万SK海力士回应微信灰度测试转账「组合支付」黄仁勋应届生们别怕AI当下是开启事业的最佳时机 | 极客头条开源打破“AI黑箱”集结全球大咖GOSIM Paris 2026带你看懂Agent时代大变局“今年还没亲手写过一行代码”Claude Code之父自曝CC诞生源于“偶然”现主要在手机上干活从“拥抱 AI”到“AI 原生”我们正站在生产力变革的奇点。由 CSDN 与奇点智能研究院联合举办的「2026 全球产品经理大会」将于 7 月 17-18 日在北京正式召开。本次大会精心设计了十二个深度专题旨在通过最前沿的实战案例拆解 AI 原生时代的进化密码。目前大会正式开启演讲议题与优质分享嘉宾招募。你的每一次真实分享都在为 AI 原生时代的产品实战手册添砖加瓦。我们在北京期待听见你的声音。议题 嘉宾推荐/自荐方式hemiaocsdn.net