缓存时间:
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")
```