我决定回归手写代码

Hacker News Top 新闻

摘要

作者在重构一个 Kubernetes 仪表盘工具时反思道,虽然借助 AI 进行“氛围编程”(vibe-coding)能加速功能开发,但在缺乏人工监督的情况下,往往会导致架构臃肿和技术债务。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/05/11 03:47

# 我要回归手写代码 来源: https://blog.k10s.dev/im-going-back-to-writing-code-by-hand/ *2026年5月9日* 这篇开发日志在 HN 上引起了大量关注(吓人!):[HN 讨论串](https://news.ycombinator.com/item?id=48090029)。 致那些从 HN 过来的人:这一切始于一次调查,或者说是一个问题:“如果让自己完全置身事外,我在构建软件方面能走多远?” 这篇开发日志的 tl;dr(太长不看版)是:要想做出有意义的东西,我仍然需要身处其中。 **核心要点:** - 就像 "em-dash" 之于 AI 写作,"上帝对象(god-object)" 之于 AI 编码 - “氛围编码(vibe-coding)”让一切显得廉价,你可能会失去焦点,最终构建出臃肿的代码 - 让人类(你自己)来编写架构,不要只是不断要求添加功能 - 还有一些 `AGENTS.md`/`CLAUDE.md` 指令,我觉得有助于我稍微摆脱这种“置身事外”的状态 截至 2026 年 5 月 10 日,仍然需要人类干预。你现在完全可以回归手动编码了! --- ## I 这是 k10s:[https://github.com/shvbsle/k10s/tree/archive/go-v0.4.0](https://github.com/shvbsle/k10s/tree/archive/go-v0.4.0) 234 次提交。约 30 个周末。完全基于与 Claude 的“氛围编码”会话构建,只要我的 Token 足够长以发布某些东西。 我正在归档我的 TUI 工具并从头重写。k10s 始于一个感知 GPU 的 Kubernetes 仪表板(也是我第一次尝试用 AI 构建严肃的东西)。想想 k9s,但是为运行 NVIDIA 集群的人们构建的,那些真正关心 GPU 利用率、DCGM 指标以及哪些节点空闲浪费每小时 $32 的人。 我用 Bubble Tea [1] 在 Go 中构建了它,它运行良好。有一段时间…… :\( 在这 7 个月里学到的东西,比我丢弃的 `model.go` 中的 1690 行代码更有价值。我认为任何进行严肃“氛围编码”的人都能从中受益,因为这部分内容很少被提及(我觉得它被淹没在演示片段和速度胜利之下)。 tl;dr:AI 编写功能,而不是架构。你让它无约束地主导的时间越长,残骸就越糟。速度会让你认为你正在赢,直到一切同时崩溃的那一刻。 ## II ### 氛围编码的高光时刻 我在 2025 年 9 月底开始了 k10s。最初的几周是神奇的。我向 Claude 提示“添加一个带有实时更新的应用程序视图”,砰,它工作了。资源列表视图、命名空间过滤、日志流、描述面板、键盘导航。每个功能都落地干净,因为项目足够小,AI 可以将整个内容保持在上下文中。 基本的 k9s 克隆可能花了 3 个周末。Pod、节点、部署、服务的资源视图。一个命令面板。基于 Watch 的实时更新。Vim 键绑定。所有功能都正常工作,所有功能都在单次会话中进行氛围编码。 我的构建速度可能是正常速度的 10 倍,感觉棒极了。 然后我想要主要的卖点。k10s 存在的整个原因是 GPU 机队视图。一个专用的屏幕,显示每个节点的 GPU 分配、来自 DCGM 的利用率、温度、功耗、内存。不是埋在 `kubectl describe node` 输出中,而是直接在一个专为此设计的表中,带有颜色编码的状态。空闲节点为黄色。忙碌为绿色。饱和为红色。 [GPU 模拟节点上的机队视图] 然后 Claude 一次性完成了。我提示机队视图,它生成了 `FleetView` 结构体、标签过滤(GPU/CPU/全部)、带有分配条的自定义渲染。看起来很美。我正处于高潮中。 然后我输入 `:rs pods` 切换回 pods 视图。没有渲染。表是空的。实时更新已停止。我切换到节点,它显示来自机队视图过滤器的过时数据。我回到机队,标签计数错误。 **上帝对象吞噬了自己。** 这是博客帖子的标题。这是我第一次干预。在 7 个月里,我一直在提示和发布,却从未坐下来实际阅读 Claude 编写的代码。我会查看差异,验证它是否编译,测试幸福路径,然后继续。但现在有些东西从根本上坏了,我不能只是通过提示来解决它。 所以我坐下来阅读了 `model.go`。所有 1690 行。我震惊了。它看起来是这样的。 一个统治一切的结构体: ```go 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 // fleet's unfiltered set allCreationTimes []time.Time // fleet's timestamps rawObjects []unstructured.Unstructured ageColumnIndex int // ... } ``` UI 组件。K8s 客户端。日志、描述、机队的每视图状态。导航历史。缓存。鼠标处理。所有都在一个结构体中。而 `Update()` 方法是一个 500 行的函数,根据 `msg.(type)` 进行调度,有 110 个 switch/case 分支。 这是我停止氛围编码并开始思考的时刻。 好吧,我想我会让你用鼠标复制日志。能出什么错呢? ## III ### 从残骸中总结的五条信条 以下是我从 7 个月观看 AI 生成一个逐渐自我吞噬的代码库中提取的内容。每一条都是我做错的事情、为什么它在 AI 辅助编码中发生,以及你实际应该放入 `CLAUDE.md` 或 `agents.md` 中以防止它的内容。 --- **信条 1:AI 构建功能,而不是架构。** 每次我向 Claude 提示一个功能,它都能交付。完美。机队视图一次就工作了。日志流工作了。鼠标支持工作了。问题在于,每个功能都是在“让它现在就工作”的上下文中实现的,没有任何关于共享相同状态的另外 49 个功能的意识。 以下是 `resourcesLoadedMsg` 处理器的样子。这是每次你切换视图时运行的代码: ```go 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` 条件了吗?那是机队视图在通用资源加载路径中被特殊处理。每个需要自定义行为的新视图都会在这里获得另一个分支。而每个分支都需要手动清除正确的字段组合,否则前一个视图的数据会泄漏。 这个文件中有多少行 `= nil` 清理代码?我数了一下: ```go m.logLines = nil // Clear log lines when loading resources m.allResources = nil // Clear fleet data when not on nodes m.resources = nil // Clear resources when loading logs m.resources = nil // Clear resources when loading describe view m.logLines = nil // Clear log lines when loading describe view m.resources = nil // Clear resources when loading yaml view m.logLines = nil // Clear log lines when loading yaml view m.logLines = nil // ... two more in other handlers m.logLines = nil ``` 九行手动 nil 赋值散布在一个 1690 行的文件中。错过一个,你就会从前一个视图获得幽灵数据。 这就是没有视图隔离时发生的事情。AI 无法看到这种模式随着时间的推移而衰减,因为每个提示只触及一条代码路径。 **应该怎么做:** 在任何代码之前自己编写架构。不是模糊的设计文档。而是一组具体的接口、消息类型和所有权规则。然后将这些规则放入你的 `CLAUDE.md` 中,以便 AI 在每个提示中看到它们: ```markdown # 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 会遵循这些。但它不会为你发明它们。 --- **信条 2:上帝对象是默认的 AI 产物。** AI 倾向于单一结构体持有所有内容,因为它以最少的仪式满足了即时提示。但情况变得更糟。因为没有视图隔离,键处理变成了噩梦。以下是 `s` 键的实际键分发: ```go 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 ``` 一个键绑定。根据你的视图不同,有三种完全不同的行为。在日志中,`s` 键表示“自动滚动”,在 Pod 中表示“Shell”,在容器中表示“Shell 到容器”。这都在一个平坦的 `switch` 中,因为没有每视图键映射。 AI 生成了这个,因为我说了“为 Pod 添加 Shell 支持”,它找到了最近的键处理器并将其塞入。看看 `Enter` 是如何工作的。这是深入处理程序: ```go 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 ... ``` 每个视图都是扁平分发中的一个条件。在这个单个文件中有 20+ 次 `m.currentGVR.Resource ==` 用作类型鉴别器。不是类型。字符串比较。每个新视图意味着触及每个处理器。 **应该怎么做:** 把这个放入你的 `CLAUDE.md`: ```markdown # 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 view's keymap, not a global one. - Adding a view means adding a file. If your change requires modifying existing views, stop and ask. ``` AI 总是会选择最短的路径(“添加另一个 if 分支”)。你的工作是通过在它每次调用时读取的文件中设置护栏,使最短的路径也成为正确的路径。 --- **信条 3:速度幻觉扩大了你的范围。** 这一条是心理上的,而不是技术上的,我认为它是最危险的。当我开始 k10s 时,我想要一个专注于 GPU 的工具。为运行训练集群的人。一个我所属的小众受众。但氛围编码让一切显得廉价。“哦,我可以在一次会话中添加 Pod 视图?让我再添加部署。和服务。和完整的命令面板。和鼠标支持。和上下文。和命名空间。” 天哪,我在里面加了所有东西…… 突然间,我在构建 k9s。一个通用的 Kubernetes TUI。为每个人。因为 AI 让每个功能感觉都是免费的。它并不免费。每个功能都是上帝对象中的另一个分支。 以下是键绑定结构体: ```go 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 (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 } ``` 一个适用于所有视图的平坦键映射。括号中的注释显示每个绑定适用的视图。`Autoscroll` 和 `Shell` 都是 `s`。这“有效”,因为分发在操作前检查 `m.currentGVR.Resource`。但这意味着你无法在本地推理键绑定。你必须追踪整个 500 行的 Update 函数才能知道某个键的作用。 复杂性在速度指标显示“你正在发布!”的同时不可见地积累。 **应该怎么做:** 写一份愿景文档,明确说明你**不**为谁构建,并将范围边界放入你的 `CLAUDE.md`: ```markdown # Scope (do NOT expand beyond this) k10s is for GPU cluster operators. Not all Kubernetes users. Supported views: fleet, node-detail, gpu-detail, workload. That's it. Do NOT add generic resource views (pods, deployments, services). Do NOT add features that duplicate k9s functionality. If a feature request doesn't serve someone running GPU training jobs, reject it. ``` 氛围编码让你感觉你有无限的实现预算。你没有。你有无限的**行**预算(AI 会生成你想要的任意多代码)。但你的复杂度预算始终有限。无论你的编写速度多快,架构只能支持这么多功能,然后就会屈服。`CLAUDE.md` 的范围部分是你提前说不,在速度高潮说服你说之前。 --- **信条 4:位置数据是定时炸弹。** k10s 中的每个资源都是从 Kubernetes API 获取并立即展平的: ```go type OrderedResourceFields []string ``` 列身份纯粹是位置性的。以下是机队视图的排序函数。看看索引访问: ```go 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") ```

相似文章

氛围编码与智能工程正变得比我预想中更接近

Simon Willison's Blog

# 氛围编码与智能工程正变得比我预想中更接近 来源:[https://simonwillison.net/2026/May/6/vibe-coding-and-agentic-engineering/](https://simonwillison.net/2026/May/6/vibe-coding-and-agentic-engineering/) 2026年5月6日 我最近与 Joseph Ruscio 在 Heavybit 的 High Leverage 播客中讨论了 AI 编程工具: [Ep. #9, 与 Simon Willison 探讨 AI 编程范式转变](https://www.heavybit.com/library/podcasts/high-leverage/ep-9-the-ai-coding-paradigm-shift-with-simon