从 Vibe 到 Spec:让 AI 编程在客户端工程里可控、可验收、可合入
Vibe 用来“找方向”,不要直接交付Spec 用来“交付与合入”,把不确定性前移到文档阶段想让 AI 编程更稳定,关键不是更长的 Prompt,而是更严谨的 Context + 闸门流程 + 可复用的 Skills。
从 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
更多推荐

所有评论(0)