从 Vibe 到 Spec:让 AI 编程在客户端工程里可控、可验收、可合入

如果你已经能用 Vibe Coding 快速做出 Demo,这篇文章解决的是下一步:怎么把 AI 编程变成“工程交付能力”——可验收、可 Review、可合入、可回滚。

TL;DR

  • Vibe Coding 擅长探索:原型、交互试水、小脚本;不擅长交付:多人协作、复杂状态、埋点口径、可回归需求。
  • Spec Coding 的核心不是“更长 Prompt”,而是在动手写代码前加一个 Check Point:把“正确”写成结构化规格,让 AI 既是执行者,也是受限者。
  • 客户端落地最有效的形态是:四阶段产物 + 阶段闸门(STOP/审批),每一阶段都能在 3 分钟内被人类快速判断“能不能过”。
  • Skills(可复用技能包)能把 Spec 的“流程与格式”固化下来:减少重复 Prompt、降低上下文成本、让交付更稳定。

1. 先把 Vibe 用对:它不是交付工具

你大概率已经很熟 Vibe Coding:给 AI 一句模糊诉求,反复对话、边跑边改,最后把东西做出来。它在这类场景非常强:

  • 原型验证:先看方向对不对
  • 交互试水:先把 UI 跑起来
  • 一次性脚本:省体力活

但在成熟客户端工程里(多人协作、组件库/规范/架构约束、要合主干、要可回滚),Vibe 的“自由发挥”会把返工放大:

  • 需求边界靠猜:空态/错误态/权限/弱网/多端差异经常遗漏
  • 架构风格漂移:同一个功能每个人/每次生成都不一样
  • 一次吐几百行:你很难在过程中施加约束,只能靠“报错再贴回去”

一个实用的团队约定:

  • Vibe 产物默认不直上主干,只作为“找方向”的 Demo
  • 一旦进入交付,强制切 Spec:先写规格,再让 AI 写代码

2. Spec Coding 是什么:给 AI 加一个 Check Point

Spec Coding 可以理解为“规范依赖编程”:在 AI Coding 之前先把任务写成结构化规格,包含:

  • 做什么:目标、范围、约束
  • 怎么做:架构选择、组件复用、数据流/状态/交互
  • 如何验证:验收标准、测试点、回滚策略

当规格足够清晰时,你会得到一个非常强的确定性:

  • 你能想清楚多少,就能稳定地让 AI 实现多少
  • 不确定性从“写代码阶段”前移到“写规格阶段”
  • Review 点从“几百行 diff”变成“几页规格 + 小步提交”

3. 客户端最小落地流程:四阶段 + 闸门(STOP 等审批)

把一个需求拆成四个阶段,每个阶段产出一个可 Review 的中间物;阶段完成后 STOP,等待审批通过再进入下一阶段。

需求输入
  |
  v
[1] requirement.md  -> STOP (验收口径能否复现?)
  |
  v
[2] hierarchy.md    -> STOP (组件复用与映射是否靠谱?)
  |
  v
[3] design.md       -> STOP (状态/事件/异常/埋点是否齐全?)
  |
  v
[4] task.md         -> STOP (拆分是否可独立合入与回滚?)
  |
  v
Coding (按 task 小步实现)

四个阶段建议固定为:

  • requirement(需求与验收口径):把“什么算完成”写清楚,越清楚越不返工
  • hierarchy(视觉稿解析与组件层级):把设计稿翻译成“组件树 + 组件映射”,强制复用现有组件
  • design(数据流与交互逻辑):把“点击后发生什么”写成可检查的状态机/事件/副作用
  • task(任务拆分与小 MR 策略):拆成 2–4 个可独立合入的小步,每步都能编译、能自测、能回滚

4. 用一个最小例子走完四阶段:照片预览(KMP + Compose Multiplatform)

下面用一个“照片预览”需求演示 Spec 的长相。你可以把它当模板替换成任何客户端功能(列表页、表单页、搜索页等)。

4.1 requirement.md(最小可验收版)

# Requirement - Photo Preview

## 目标
- 在“相册列表”中点击任意缩略图,进入预览页展示大图

## 不做什么
- 不做图片编辑
- 不做离线下载

## 验收(≤10条)
- [ ] 点击缩略图进入预览页,展示对应图片
- [ ] 加载态:大图加载时显示骨架屏
- [ ] 错误态:加载失败时显示错误文案与“重试”按钮
- [ ] 空态:图片 URL 缺失时显示空态提示
- [ ] 手势:支持左右切换上一张/下一张(键盘方向键也可切)
- [ ] 关闭:点击关闭按钮返回列表,并保持列表滚动位置不变
- [ ] 埋点:photo_preview_open / photo_preview_swipe / photo_preview_retry
- [ ] 性能:预览切换不触发列表页重新请求数据

这个文件只回答一件事:“什么算完成”。它不追求细节多,而追求可复现。

4.2 hierarchy.md(组件树 + 组件映射)

# Hierarchy - Photo Preview

## 视图层级树
```text
AlbumListPage
├── PhotoGrid
│   ├── PhotoThumbCard (xN)
│   └── ...
└── PaginationFooter

PhotoPreviewPage
├── TopBar
│   ├── CloseButton
│   └── Title
├── PreviewStage
│   ├── ImageViewport
│   ├── Skeleton
│   └── ErrorPanel
└── PagerHint
```

## 组件映射(复用优先)
| 设计组件 | 映射目标组件 | 匹配程度 | 备注 |
| --- | --- | --- | --- |
| TopBar | AppHeader | 高 | 复用统一导航样式 |
| CloseButton | IconButton | 高 | 统一点击面积与无障碍 |
| Skeleton | Skeleton | 中 | 需适配图片比例 |
| ErrorPanel | InlineErrorState | 中 | 需加“重试”回调 |

这一步的“关键收益”是:强制复用已有组件,避免 AI 临时造轮子导致风格/交互/无障碍不一致。

4.3 design.md(状态、事件、副作用、错误映射、埋点口径)

这里给一个最小可检查的 Kotlin 结构(适用于 KMP + Compose Multiplatform)。

# Design - Photo Preview(KMP + Compose)

## UIState
```kotlin
sealed interface PhotoPreviewUiState {
    val photoId: String

    data class Loading(override val photoId: String) : PhotoPreviewUiState
    data class Empty(override val photoId: String) : PhotoPreviewUiState
    data class Error(override val photoId: String, val reason: FetchError) : PhotoPreviewUiState
    data class Ready(
        override val photoId: String,
        val url: String,
        val width: Int,
        val height: Int
    ) : PhotoPreviewUiState
}

sealed interface FetchError {
    data object Network : FetchError
    data object NotFound : FetchError
    data object Unknown : FetchError
}
```

## Event
```kotlin
sealed interface PhotoPreviewEvent {
    data class Enter(val photoId: String) : PhotoPreviewEvent
    data class LoadSucceeded(val meta: PhotoMeta) : PhotoPreviewEvent
    data class LoadFailed(val photoId: String, val reason: FetchError) : PhotoPreviewEvent
    data object RetryClicked : PhotoPreviewEvent
    data object SwipeNext : PhotoPreviewEvent
    data object SwipePrev : PhotoPreviewEvent
    data object CloseClicked : PhotoPreviewEvent
}
```

## Effect
```kotlin
sealed interface PhotoPreviewEffect {
    data class FetchPhotoMeta(val photoId: String) : PhotoPreviewEffect
    data class EmitAnalytics(val name: String, val fields: Map<String, Any?>) : PhotoPreviewEffect
    data object NavigateBack : PhotoPreviewEffect
}
```

## Reducer(纯函数,可单测)
```kotlin
data class ReduceResult(
    val state: PhotoPreviewUiState,
    val effects: List<PhotoPreviewEffect> = emptyList()
)

data class PhotoMeta(
    val photoId: String,
    val url: String,
    val width: Int,
    val height: Int
)

fun reduce(state: PhotoPreviewUiState, event: PhotoPreviewEvent): ReduceResult {
    return when (event) {
        is PhotoPreviewEvent.Enter -> {
            ReduceResult(
                state = PhotoPreviewUiState.Loading(event.photoId),
                effects = listOf(
                    PhotoPreviewEffect.FetchPhotoMeta(event.photoId),
                    PhotoPreviewEffect.EmitAnalytics(
                        name = "photo_preview_open",
                        fields = mapOf("photoId" to event.photoId)
                    )
                )
            )
        }

        is PhotoPreviewEvent.LoadSucceeded -> {
            ReduceResult(
                state = PhotoPreviewUiState.Ready(
                    photoId = event.meta.photoId,
                    url = event.meta.url,
                    width = event.meta.width,
                    height = event.meta.height
                )
            )
        }

        is PhotoPreviewEvent.LoadFailed -> {
            ReduceResult(
                state = PhotoPreviewUiState.Error(photoId = event.photoId, reason = event.reason)
            )
        }

        PhotoPreviewEvent.RetryClicked -> {
            if (state is PhotoPreviewUiState.Loading) {
                ReduceResult(state = state)
            } else {
                ReduceResult(
                    state = PhotoPreviewUiState.Loading(state.photoId),
                    effects = listOf(
                        PhotoPreviewEffect.FetchPhotoMeta(state.photoId),
                        PhotoPreviewEffect.EmitAnalytics(
                            name = "photo_preview_retry",
                            fields = mapOf("photoId" to state.photoId)
                        )
                    )
                )
            }
        }

        PhotoPreviewEvent.SwipeNext -> {
            ReduceResult(
                state = state,
                effects = listOf(
                    PhotoPreviewEffect.EmitAnalytics(
                        name = "photo_preview_swipe",
                        fields = mapOf("direction" to "next", "photoId" to state.photoId)
                    )
                )
            )
        }

        PhotoPreviewEvent.SwipePrev -> {
            ReduceResult(
                state = state,
                effects = listOf(
                    PhotoPreviewEffect.EmitAnalytics(
                        name = "photo_preview_swipe",
                        fields = mapOf("direction" to "prev", "photoId" to state.photoId)
                    )
                )
            )
        }

        PhotoPreviewEvent.CloseClicked -> {
            ReduceResult(
                state = state,
                effects = listOf(PhotoPreviewEffect.NavigateBack)
            )
        }
    }
}
```

## Effect runner(建议放到 commonMain,可一码三端复用)
```kotlin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

interface PhotoRepository {
    suspend fun getPhotoMeta(photoId: String): Result<PhotoMeta?>
}

interface Analytics {
    fun track(name: String, fields: Map<String, Any?>)
}

class PhotoPreviewPresenter(
    private val repo: PhotoRepository,
    private val analytics: Analytics,
    private val scope: CoroutineScope,
    private val onClose: () -> Unit
) {
    private val _uiState = MutableStateFlow<PhotoPreviewUiState>(PhotoPreviewUiState.Empty(photoId = ""))
    val uiState: StateFlow<PhotoPreviewUiState> = _uiState

    fun dispatch(event: PhotoPreviewEvent) {
        val result = reduce(_uiState.value, event)
        _uiState.value = result.state
        result.effects.forEach { effect -> scope.launch { runEffect(effect) } }
    }

    private suspend fun runEffect(effect: PhotoPreviewEffect) {
        when (effect) {
            is PhotoPreviewEffect.FetchPhotoMeta -> {
                val res = repo.getPhotoMeta(effect.photoId)
                val meta = res.getOrNull()
                when {
                    meta != null -> dispatch(PhotoPreviewEvent.LoadSucceeded(meta))
                    res.isFailure -> dispatch(PhotoPreviewEvent.LoadFailed(effect.photoId, FetchError.Network))
                    else -> dispatch(PhotoPreviewEvent.LoadFailed(effect.photoId, FetchError.NotFound))
                }
            }

            is PhotoPreviewEffect.EmitAnalytics -> analytics.track(effect.name, effect.fields)
            PhotoPreviewEffect.NavigateBack -> onClose()
        }
    }
}
```

## Error mapping
- FetchError.NotFound -> 展示“图片不存在/已删除”
- FetchError.Network -> 展示“网络异常”+ 重试
- FetchError.Unknown -> 展示“加载失败”+ 重试

## 埋点口径
- photo_preview_open: { photoId }
- photo_preview_swipe: { direction, photoId }
- photo_preview_retry: { photoId }

这里的重点是:把 UI 复杂度收敛成“可审查的结构”。你不需要在这个阶段写任何 UI 代码,但你已经把:

  • 需要哪些状态,缺了哪个状态会漏掉哪种体验
  • 哪些用户动作会触发哪些副作用
  • 哪些错误需要差异化处理

提前暴露出来了。

4.4 task.md(把交付拆成可合入的小步)

# Task - Photo Preview

- MR1:路由与页面骨架(空数据 + 基础导航 + 占位态)
- MR2:状态机与数据获取(reduce + effects 执行器 + 单测)
- MR3:UI 与交互(骨架/错误态/重试/左右切换 + 埋点)

拆分标准只有一句话:每一步都可独立编译、自测、回滚


5. 上下文注入:让 AI 先学项目“宪章”,再写代码

Spec 解决“做什么/怎么验收”,上下文注入解决“按你们项目怎么做”。

在大型客户端项目里,常见做法是维护一份项目宪章(例如 AGENTS.md 或 IDE rules),把最关键的约束写清楚:

  • 项目结构与关键入口
  • 架构范式(MVVM/MVI/Redux 等)
  • 组件库使用规范(必须先复用、再新增)
  • 核心命令(lint/typecheck/test/build)

上下文注入还可以更工程化:把“组件映射表”做成可检索的索引,让 Agent 先检索再实现,避免读完整组件库导致上下文爆炸。


6. Skills:把 Spec 的“流程与格式”固化成可复用能力

当你真的在团队里推 Spec Coding,会遇到一个现实问题:大家会不停重复写同一套要求(输出格式、禁忌、审批口径、字段表),Prompt 越写越长越不稳定。

Skills 的价值是把这部分“可复用的流程知识”打包成可按需加载的技能包:

  • Skill 是一个文件夹:用 SKILL.md 写清“何时触发/怎么做/输出格式/禁止什么”
  • 低频但重要的制度拆到 reference/,需要时才读,节省上下文
  • 确定性动作放到 scripts/,让脚本做校验/转换/上传,减少模型臆测

渐进式披露(Progressive Disclosure)的直觉是:

总是加载:name/description(像目录)
命中才加载:SKILL.md(规则正文)
按需再加载:reference(长资料)/ scripts(确定性动作)

把它放回 Spec Coding 的四阶段,你可以很自然地把每个阶段封装成 Skill:

  • requirement-skill:输出 requirement.md 模板 + 覆盖边界的验收清单
  • hierarchy-skill:输出组件树与组件映射表,强制“复用优先”
  • design-skill:输出 UIState/Event/Effect/Error mapping/埋点口径
  • task-skill:输出小步 MR 计划,保证可合入与回滚

这会让 Spec 不再依赖“写得很长的 Prompt”,而依赖“可版本化、可 Review、可复用”的工程资产。


7. 3 分钟 Gate:每个阶段怎么快速过

  • requirement:验收是否可复现,是否覆盖加载/空态/错误态/权限/弱网/埋点/多端差异
  • hierarchy:是否复用优先,组件映射是否有依据,是否明确新增组件的必要性
  • design:状态/事件/副作用/异常/埋点是否齐全且口径明确,是否存在不可实现的隐含前提
  • task:拆分是否可独立合入,是否有回滚路径,是否有最小自测方案

8. 常见坑:看起来写了 Spec,其实还是 Vibe

  • 规格写成口号:只有“要优雅/要性能好/要体验丝滑”,没有可验证标准
  • 阶段边界混乱:还没写清验收就让 AI 开始堆 UI 代码
  • 上下文不受控:把整个项目扔给模型,结果注意力稀释、幻觉变多
  • 组件复用不强制:AI 造轮子比人类还快,但带来的长期成本更大

9. 一句话总结

  • Vibe 用来“找方向”,不要直接交付
  • Spec 用来“交付与合入”,把不确定性前移到文档阶段
  • 想让 AI 编程更稳定,关键不是更长的 Prompt,而是更严谨的 Context + 闸门流程 + 可复用的 Skills
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐