最近 AI 编程的热度大家有目共睹,vibe coding 这个词已经泛滥到什么程度了?连我那个做平面的朋友都在跟我聊让 AI 直接干活。我试过几轮 vibe coding 之后,最大的感受是:爽是真的爽,改起来是真的想骂人。AI 能在十分钟内给你生成一个看起来能跑的排版工具,但真到边界情况、细节风格、错误处理的时候,它就开始自由发挥了,而这部分恰恰是最让人头疼的。
后来我认真试了 SDD(Specification-Driven Development,规范驱动开发),把整个过程拉回到“先写规范、再写代码”的正轨上。为了验证这套打法,我用 SDD + AI Agent 完整做了一个排版 npm 包,从需求收集到发布到 npm registry,全程记录。这篇文章就是把我的操作步骤、提示词、踩坑记录整理出来,特别是那些只有真正动手才会踩到的细节。如果你也想让 AI 从“偶尔写出漂亮代码”变成“稳定输出可用代码”,这篇应该能给你不少参考。
1. 先搞清楚 SDD 到底在解决什么问题
1.1 vibe coding 的爽与坑
vibe coding 说白了就是给 AI 一个模糊想法,让它自由发挥,什么“帮我写个 markdown 排版工具”“做一个批量格式化脚本”,然后看着 AI 刷刷刷生成几百行代码。说实话,前几次体验确实很上头,人模狗样的代码结构、合理的命名、看似完整的注释,感觉再也不用加班了。
但只要需求稍微复杂一点,问题马上就来。AI 对“排版工具”的理解可能和你完全不一样:你觉得排版是调整标题层级和段落间距,它理解的是给每个块级元素套上彩色卡片;你想要命令行工具,它默认给你做了一个 Web 页面;你说“处理一下边界情况”,它只是加了几个 if 判断,真正遇到空输入和畸形 Markdown 照样崩。
更麻烦的是后续维护。AI 生成的代码没有背后那份“为什么这么做”的设计文档,我根本不知道它为什么用 Map 不用 plain object,也不知道那个 mysterious 的正则表达式到底覆盖了哪些场景。改一行代码,可能要花一下午去读它之前生成的一千行代码,这种感觉就像请了个外包干完活就失联,留下一个没法接手的烂尾楼。
1.2 SDD 的核心:先写规范,再写代码
SDD 的思路刚好反过来:先把“要什么”“边界在哪里”“怎么算完成”写成文字,再让 AI 照着规范实现。这套方法在 Thoughtworks 的工程师 Birgitta Böckeler 提出后,被很多人拿来和 AI 协作开发组合使用,核心就是把 AI 当“高级执行者”,而不是“需求分析师”。
我自己的理解是,vibe coding 和 SDD 的区别就像让设计师“随便弄一个好看的海报”和“按品牌规范出一版主视觉”。前者看着快,但来回改的次数能把时间全吃回去;后者前期多花半小时定规范,后面几乎不用返工。
Birgitta 提出的三级分类框架我强烈建议先收藏,它把规范拆成了三层,每一层解决不同粒度的问题:
| 规范层级 | 内容定位 | 典型产出 | 主要读者 |
|---|---|---|---|
| Level 1 Overall Spec | 产品目标、用户、功能范围、成功标准 | 需求说明、技术选型、整体架构 | 人和 AI 一起对齐方向 |
| Level 2 Component Spec | 模块职责、接口签名、数据模型、错误处理 | 接口文档、数据结构、伪代码 | AI 的主要实现依据 |
| Level 3 Task Spec | 具体文件、函数级指令、验收条件 | 任务卡片、输入输出示例 | AI 直接照着写 |
很多人以为 SDD 就是“写文档”,其实不对。SDD 的重点不是文档本身,而是通过分层规范把不确定性逐层消除,让 AI 每一步都有据可查。Level 1 消除“方向不准”,Level 2 消除“模块边界模糊”,Level 3 消除“函数实现走样”,三层叠加之后,AI 的自由发挥空间就被压缩到了安全线以内。
1.3 为什么拿“排版 npm 包”当试验田
第一次跑通 SDD,我特意选了一个边界清晰、验证成本低的项目:一个把 Markdown 转成干净 HTML 的排版 npm 包。为什么选这个?
首先,npm 包天然有清晰交付物。既要写好 TypeScript 逻辑,又要处理 package.json 的导出配置,还要覆盖 CLI 场景,能完整检验 SDD + AI 的协作质量。其次,排版工具的功能边界足够明确,好写规范。“解析文本、渲染 HTML、生成主题样式”这三个步骤都不存在含糊的想象空间,特别适合测试 AI 的代码还原度。
最重要的是,npm 包可以用最真实的方式验收:本地跑测试、输出 HTML 对比、甚至直接发布到 registry 让别人安装。一个人说“我做得差不多了”不算数,测试用例通过、npm install 能跑、产出物符合预期,这才是硬标准。
2. 项目设计与规范撰写:把“想做什么”变成 AI 能执行的东西
2.1 需求收敛与整体设计
动笔写规范之前,我先把项目收敛成一句话:一个输入 Markdown 文本、输出经过排版处理的 HTML 字符串的轻量级工具库,同时提供 CLI 入口。
功能清单控制在最小可用范围:
- 解析 Markdown 的常用块级元素:标题、段落、引用、无序/有序列表、代码块、表格。
- 内联样式支持:加粗、斜体、行内代码、链接。
- 渲染时支持主题配置,默认提供一套简洁排版主题。
- CLI 命令接收
input.md,输出output.html。 - 首版明确不处理的内容:图片资源拷贝、数学公式、脚注、嵌套表格、原始 HTML 透传。
技术选型上我没有引入任何 Markdown 解析依赖,直接用 TypeScript 手写一个极简解析器。理由有两个:一是 AI 生成代码时依赖越少越不容易失控,二是这个包的定位是“排版输出”,解析逻辑应当保持透明可控。
目录结构也写进规范里,让 AI 知道它工作在什么框架下:
src/ tokenizer.ts # Markdown 文本 -> Token 数组 renderer.ts # Token 数组 -> HTML 字符串 theme.ts # 主题类型定义与默认主题 cli.ts # 命令行入口 test/ tokenizer.test.ts renderer.test.ts2.2 三级规范怎么落到纸面
Level 1 规范我写得很短,但每一句都决定方向:
目标用户:需要将 Markdown 快速转为带基础样式 HTML 的开发者。成功标准:提供的 API 与 CLI 均能在 5 分钟内跑通;输出 HTML 不包含任何未转义的原始 HTML;全部测试用例通过。
这里没有写“做成一个功能强大的编辑器”,也没有写“支持任意 Markdown 方言”。边界越死,AI 越不会跑偏。
Level 2 规范我按模块拆,每个模块写清楚接口签名和异常行为。比如 tokenizer 的规范:
type Token = | { type: 'heading'; level: 1 | 2 | 3 | 4 | 5 | 6; content: string; line: number } | { type: 'paragraph'; content: string; line: number } | { type: 'codeBlock'; lang?: string; content: string; line: number } // ... 其他块级类型 function tokenize(md: string): Token[];规范里额外写了三条约束:不引入第三方依赖;EOF 时遇到未闭合代码块要抛错;每个 Token 要带原始行号。行号这个细节在排查渲染问题时帮了大忙,这是 AI 自己不会主动想到的,必须在规范里点出来。
Level 3 任务规范就是在 Level 2 基础上拆成具体函数级指令,我会在下面的 AI 协作实战中给你看一个可以直接抄的模板。
2.3 规范里要写清哪些“反例”
写规范最容易漏的是“不要做什么”。我踩过好几次坑之后发现,与其等 AI 发挥想象力,不如一开始就把反例列清楚,比写正向功能还管用。
我在项目规范里专门开了一节“明确不处理”:
- 不处理 HTML 标签语义,只负责转义和输出。
- 不支持 Markdown 的嵌套列表超过两层的情况。
- 不自动下载远程图片,也不解析图片宽度高度。
- 表格列数不一致时,以第一行为准,多余单元格直接忽略。
- 不通过 CSS 类名污染全局样式,所有样式只挂在容器类名下。
还有安全边界:默认所有 HTML 都做转义,除非调用方显式传入{ rawHtml: true }才放行。这一点在 AI 生成的代码里最容易丢,我会在 review 时重点检查。把这些反例写进规范之后,AI 的“创造力”基本就锁死了,它不会再自己发明什么奇奇怪怪的行为。
3. AI 协作实战:六步走完从规范到发布
3.1 事前准备:环境与工具链
开始之前先准备好环境。我本机是 Windows + Node.js LTS,包管理器用 npm。编辑器里装了 AI 编程插件,同时安装了 Claude Code 和 OpenAI Codex 作为 Agent 工具,安装命令是:
npm i -g @anthropic-ai/claude-code@latest npm install -g @openai/codex为什么用 Agent 而不是普通聊天窗口?因为我需要它直接操作文件系统、跑测试命令,而不是每次都把代码复制到对话里来回粘贴。Agent 最核心的价值是“能自己读项目上下文”,它会根据已有代码风格去生成新代码,这比从零对话高效得多。
这里说句题外话,Windows 上如果遇到npm.ps1无法加载、提示“禁止运行脚本”,基本是 PowerShell 执行策略问题,后面我会专门列一个 npm 环境常见坑的排查表。
3.2 SDD 六步实践指南
整个开发过程我总结成六步,每一步都有明确的输入输出:
第一步:写 Level 1 整体规范。不用长,但必须说清楚这个包给谁用、解决什么问题、边界在哪。这一步大约花 20 分钟,输出一份 docs/specs/01-overall.md。
第二步:拆 Level 2 组件规范。把 tokenizer、renderer、theme、cli 四个模块的接口签名全部定下来。这一步最花时间,也最值钱。我花了约 40 分钟,输出 docs/specs/02-components.md。
第三步:给 AI 下发任务,一次一个文件。把 Level 3 任务规范发给 Agent,让它打开项目目录,先读已有的 Level 1 和 Level 2 规范,再实现指定文件。这个顺序很关键,AI 只有先读顶层规范,才能理解为什么 API 长这样。
第四步:人工验证。每生成完一个文件,我立刻运行测试或手动检查输出。查三样东西:功能是否匹配规范、有没有引入规范之外的依赖、有没有偷偷改掉其他文件。
第五步:回到 Spec 修问题。AI 写的代码大概率第一次不会全对,我会把问题描述原样反馈给它,但它改完如果还在同一个地方犯同样的错,说明不是代码问题,而是规范本身有歧义。这时我会更新规范里的对应条款,让 AI 重来。规范变成活文档,而不是一次性草稿。
第六步:发布与复盘。全部实现通过后,执行构建、测试、打包、发布。发布完成后把每一步遇到的问题补记到规范文件的末尾,给下次开发留参考。
3.3 我在关键节点给 AI 的“提示词”长什么样
很多人以为 SDD 不需要写提示词,直接把规范丢给 AI 就行。实际上,规范是“知识层”,提示词是“指令层”,AI 需要一条清晰的路径去读规范和产出文件。
我用得最多的 Level 3 任务模板长这样:
请先阅读项目目录中的 docs/specs/01-overall.md 和 docs/specs/02-components.md。
你的任务是实现 src/tokenizer.ts。函数签名必须严格符合组件规范中的定义:
export function tokenize(md: string): Token[];额外要求:
- 不引入任何第三方依赖,包括不新增 package.json 依赖项。
- 遇到 EOF 时未闭合的代码块,抛出 TokenizeError,错误信息中包含行号。
- 每个 Token 都要带 line 字段,值为对应 Markdown 文本的 1-based 行号。
- 空行不产生 Token。
- 不要改动项目中的任何其他文件。
完成后运行
npx tsc --noEmit确认类型检查通过,再列出你生成的功能清单。
注意几个细节:要求先读规范文件;把函数签名直接写进提示词,避免 AI 自己“优化”为别的命名;明确“不要改动其他文件”,防止 Agent 顺手改掉 renderer;要求它自己跑类型检查,把部分验证工作前置到 AI 侧。
我把类似的提示词按 tokenizer、renderer、theme、cli 各写了一份,存在 prompts/ 目录下。后面如果改需求,先改 prompt 里的验收条件,再让 AI 重新执行,整个过程是可复现的。
4. 核心实现拆解:AI 生成的代码里藏着哪些“坑”
4.1 解析器实现与边界处理
tokenizer 是 AI 生成的最核心代码,逻辑上把 Markdown 的块级元素逐行扫描,产出 Token 数组。简化后的核心逻辑长这样:
export function tokenize(md: string): Token[] { const lines = md.split(/\r?\n/); const tokens: Token[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const lineNo = i + 1; const headingMatch = /^(#{1,6})\s+(.*)$/.exec(line); if (headingMatch) { tokens.push({ type: 'heading', level: headingMatch[1].length as 1 | 2 | 3 | 4 | 5 | 6, content: headingMatch[2].trim(), line: lineNo, }); continue; } if (line.trim() === '') { continue; } tweets.push({ type: 'paragraph', content: line.trim(), line: lineNo }); } return tokens; }这段代码看着简单,但里面有两个隐藏坑。
第一个是\r\n换行处理。规范里我写了“输入可能来自 Windows、Linux、macOS,换行符必须兼容”,所以 AI 用了split(/\r?\n/)而不是split('\n'),这个细节如果没写进规范,AI 大概率会用\n一刀切,导致 Windows 文件解析出差错。
第二个是行号对齐。array.split之后遍历的索引从 0 开始,但用户看到的文本从第 1 行开始,所以代码里const lineNo = i + 1这行是规范里明确要求的结果。
AI 生成这个文件时第一次也翻了车,它把 list 和 blockquote 的判断放在 paragraph 之前,导致以-开头但后续行全部空白的文本被误判为列表。我没有直接告诉它怎么改,而是回到组件规范里加了一句话:“列表至少要有两项,单个- 文本且后续无内容时视为段落”。这比在代码层面打补丁干净得多,也方便后续维护者理解“为什么这里会这样判断”。
4.2 渲染器与主题系统
renderer 负责把 Token 数组变成 HTML。这个模块最容易出问题的不是功能,而是安全。
让 AI 输出内联样式之前,我在规范里写了硬性要求:渲染阶段对所有来自 Markdown 的原始内容做 HTML 转义。这意味着# 你好 <World>输出的 heading 内容必须是你好 <World>,而不是一段可以被浏览器执行的 HTML。
AI 第一版完全没做转义,直接content原样插进字符串,我写了一个带<img onerror="alert(1)">的测试用例,它当场挂了。我把测试结果反馈给 AI,让它实现一个escapeHtml函数,并在渲染所有动态内容前调用,这一轮才通过。
theme 部分我定义了一个接口:
export interface Theme { containerClass: string; headingFont: string; bodyFont: string; codeBackground: string; linkColor: string; maxWidth: string; }默认主题是一套偏阅读风格的参数。渲染器输出 HTML 时,会把样式内联到容器包裹的<style>块里。这里有个小技巧:容器类名默认取md-tidy,所有 CSS 选择器都写成.md-tidy h2 { ... }这种带前缀的形式,避免用户接进现有站点时全局样式被覆盖。这是做工具库的基本素养,AI 不会主动想到,必须由人在规范里提出来。
4.3 CLI 与发布准备
CLI 部分 AI 做得最省心。规范里我指定用 Node.js 内置的util.parseArgs,不给它引入 commander 这类依赖的机会。入口文件是 cli.ts,核心逻辑是:读文件、调用 tokenize、调用 render、写文件。
CLI 的参数设计写在规范里:
md-tidy input.md -o output.html --theme minimal -o 不传时默认输出到 stdout --theme 支持 minimal 和 default 两个内置主题package.json 里的配置我让 AI 按规范里的字段写,但最后我会人工核对以下内容:
{ "name": "md-tidy", "version": "0.1.0", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { "md-tidy": "./dist/cli.js" }, "files": ["dist", "README.md"], "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } } }files字段很关键,它决定了发布到 npm 时哪些文件被打包进 registry。写dist和README.md,就不会把源码、测试文件、规范文档泄漏给使用者。这一步 AI 容易漏,发布前必须人工确认。
5. npm 发布全流程与那些年踩过的发布坑
5.1 从本地构建到 npm publish
项目全部实现完,开始走发布流程。我的发布操作顺序是固定的,顺序不要乱:
npm run build # tsc 编译到 dist npm test # 跑所有单测 npm pack # 本地打包,看看包里到底有哪些文件 npm publish --access publicnpm pack这步很多人跳过,但我强烈建议每次都跑。它会生成一个 tgz 压缩包,你可以直接解压看里面都有什么。我有一次漏配files字段,结果源码、测试用例、甚至 node_modules 都被打进包,用户安装下来体积暴涨。发布前做一次npm pack,比发布后发现问题再撤版本省心一万倍。
版本号策略我用的是 semantic versioning,初始版本0.1.0,等 API 稳定后再上 1.0.0。发布到 npm 时如果包名是公开的,记得加--access public,不然 npm 默认认为你是私有包,会报 402 错误。
5.2 npm 环境常见的几类报错与排查
这部分内容来自我长期在 Windows 和 macOS 之间切换开发的实际经历,全踩过,不煽情,直接给结论:
| 报错信息 | 原因 | 解决办法 |
|---|---|---|
npm.ps1无法加载,禁止运行脚本 | PowerShell 执行策略限制脚本运行 | 用管理员权限运行Set-ExecutionPolicy RemoteSigned,然后重新打开终端 |
npm ERR! code CERT_HAS_EXPIRED | 配置的第三方镜像源 HTTPS 证书过期 | npm config get registry查看当前源,把地址改回官方源或更换仍在维护的源 |
npm ERR! code EUNSUPPORTEDPROTOCOL | registry 地址被人为填成了奇怪的协议头 | 打开.npmrc检查registry=配置,改成合法的https://开头地址 |
npm WARN deprecated node-domexception@1.0.0 | 某个依赖引用了已废弃的包 | 属于警告不阻塞安装,建议升级相关依赖或检查依赖树 |
| 无法将 npm 识别为 cmdlet 或程序 | Node.js 安装后 PATH 环境变量未配置 | 确认 Node.js 安装路径,将其加入系统 PATH,重启终端 |
npm ERR! code EACCES权限不足 | Linux/macOS 下全局安装目录没有写权限 | 优先用 nvm 管理 node,不要直接用 sudo 绕过去 |
还有一个很多人容易忽略的点:如果你换过镜像源,后来镜像本身停更或证书过期,npm 会一直报错。排查时我一般先跑npm config get registry,确认当前用的是哪个源,再决定下一步。建议除非团队内部有稳定维护的私有源,否则个人项目直接用官方源最省事,少一个变量就少一类问题。
发布完成后我在一个新目录里执行npm install md-tidy,用干净环境验证包能正常安装、CLI 能跑。这一步相当于最终验收,确认用户拿到的是可用的包,而不是“在我电脑上好好的”。
6. 复盘:SDD + AI 协作到底值不值
6.1 数据与感受
整个项目从开始写 Level 1 规范到发布成功,大概用了一个周末。真正的写代码时间很少,大头花在解析器边界问题的迭代上,但那部分也是因为我在 AI 第一版之后发现规范不够细,补了两次 Level 3 任务描述。
如果按传统方式手写这个包,我估算大概需要同样的时间,甚至更长,因为中间还要查文档、调试解析细节、处理 CLI 参数等琐碎工作。但 SDD + AI 带给我的真正收益不在“省了几小时”,而在于可控性:
- 代码风格统一,因为规范里直接给了类型定义和目标结构。
- 测试覆盖明确,每个模块至少有三条边界用例。
- 文档同步到位,规范文件本身就是很完整的项目文档。
- 后续改需求不用重新读懂全部代码,改规范然后让 AI 重跑就行。
最直观的体感是:以前我收到 AI 代码的第一反应是“这是啥”,现在收到 AI 代码的第一反应是“对照规范检查这回调有没有写歪”。心态完全不一样。
6.2 这套流程还能扩展到哪些项目
SDD + AI 的适用场景比我想象中宽,我总结了几类比较好落地的方向:
- 后端 API 服务:Controller 的请求参数、响应结构、错误码本身就是天然的 Level 2 规范,AI 照着实现几乎零跑偏。
- 前端组件库:props 的详细说明和视觉规范就是 Component Spec,AI 生成的组件代码可以直接接入 Storybook 验证。
- CLI 工具:参数定义、输出格式、退出码全是强约束,AI 的发挥空间本来就不大,特别适合 SDD。
- CI/CD 脚本:每一步的输入输出非常明确,写规范比写代码还简单,AI 生成后人工 review 也快。
不太适合走 SDD 的场景是:一次性原型、个人小脚本、探索性验证代码。这些场景连需求自己都没想清楚,硬写规范反而是浪费时间。灵活判断比盲目套方法论更重要。
6.3 容易翻车的场景
我在多轮实践中发现几个典型翻车点,写出来给你提个醒:
第一,规范忽略“隐含规则”。比如我没写“不得引入第三方依赖”,AI 第一次就直接在 tokenizer 里 import 了marked。代码能跑,但完全违背了我做这个轻量工具的初衷。所以规范里宁可多写几条看似废话的约束,也不要给 AI 留自由裁量空间。
第二,Level 3 任务拆分太粗。如果你让 AI“实现 renderer”,它会把所有渲染逻辑塞进一个函数。正确做法是把 renderer 再拆成 renderHeading、renderList、renderCodeBlock 等函数级任务,这样每个函数都能独立测试,出了问题也容易定位。
第三,review 环节只看“能不能跑”。能跑不代表规范达标。我后来给自己定了一个 checklist:检查反向约束(不该有的功能有没有出现)、检查异常输入是否按规范处理、检查是否有额外的依赖项改动。宁可慢一点,也别被 AI 的“表面繁荣”欺骗。
如果让我给想试 SDD + AI 的人一句话建议:小改动别上 SDD,新项目或者核心模块请务必写 spec。写 spec 的成本会在后续 review、测试、返工中几十倍赚回来。我个人最大的收益是终于不用在 AI 生成的代码里大海捞针找 bug 了,因为规范把“什么不该做”写得很死,AI 自由发挥的空间被压缩到安全线以内。最后再分享一个小习惯:我在仓库里固定放一个 docs/specs.md,每次让 AI 改代码之前,先让它更新这个文件再动代码,等于是先对齐再施工。这套组合拳,我后面写工具库、写 CLI、甚至写内部服务都会继续用下去。