1. 为什么我不再"一句话甩给 AI",改回先写规格再写代码
vibe coding 刚火的那阵,我的节奏基本是:在聊天窗口里描述一个需求,AI 直接吐出一坨代码,我粘贴、运行、报错、继续让它改。做两三屏的小脚本还好,真要做一个给别人装的 npm 包时,这个流程崩溃得特别快——不是 AI 不努力,而是我和它之间根本没有一份共同认可的设计稿。项目过到第三天,我自己都忘了最初那版"加空格规则"和后来修修补补的行为是不是一回事。
这次做排版工具,我换了个思路,先把 SDD(Spec-Driven Development,规格驱动开发)完整走了一遍:先写清"这个包应该做什么、明确不做什么、每种输入该出什么结果",再让 AI Agent 按规格实现,最后用规格里的样例反向生成验收测试。整个过程和以往最大的区别是:我当的是需求方和验收方,而不是替 AI 盯着每一行代码的质检员。
SDD 近半年在 AI 辅助编程语境里被反复提起,ThoughtWorks 那位工程师 Birgitta Böckeler 提出的三级分类框架也经常被拿来讨论。按我在实际项目里的理解,它大致把协作模式分成三档:第一档是只有一句任务描述的"纯意图级",本质上还是 vibe coding,AI 的自由度最高;第二档是"决策级",人把技术选型、接口边界、核心输入输出样例写清楚,AI 在限定范围内填代码;第三档是"契约级",每个行为规则都拆到可验收、可测试的粒度,模型实现完必须有测试通过作为证据。我这次做的工具包规模不算大,所以我走了第二档为主、第三档混合的路线——核心规则全部落到可测的验收清单里,但没给整个仓库写几百页文档。
很多人一听 SDD 就抵触,觉得是给 AI 编程加文档负担。我一开始也这么想。但这份排版工具的需求恰好能把问题暴露得很清楚:中文排版规则看起来人人知道,真落到代码边界上全是灰色地带。"中文和英文之间要加空格"这句话,AI 可能默认对 URL、版本号、代码块里的内容也加空格,你不想让它加,就必须写在规格里。没有规格的时候,这些灰色地带全靠试错喂给大模型。有了规格之后,AI 第一轮写出来的代码就非常接近最终目标,剩下的都是微调而不是推翻重来。
2. 给排版包定规格:规则表、反例表、非目标,一个都不能少
2.1 先定范围:它能做什么,更重要的是不做什么
这个 npm 包做的是一件小事:把一段中文文本按常见中文排版习惯整理一遍。它接收字符串,返回处理后的字符串。我给它设计了两个入口模式:plain 模式处理纯文本,markdown 模式额外跳过代码块和行内代码,避免破坏用户贴的代码片段。
规格第一版里我没着急写实现,而是先列了一个"做什么/不做什么"清单。做什么包括:中文与英文之间补半角空格、中文与数字之间补半角空格、中文语境内的省略号整理成规范形式、URL 和邮箱作为整体保留并在前后补空格。不做什么包括:不做繁简转换、不做错别字校对、不做分词、不做 PDF 或 HTML 渲染级的版式。把"不做什么"写清楚这个动作,后来的价值大到我意外——AI 特别容易在实现过程中自作主张地"帮"你加功能,比如顺手把英文标点全转成全角,或者把 Markdown 的标题符号处理坏了。有了一条"非目标"声明,Agent 在规划实现时会自己绕开这些范围。
核心规则我列成了下面这样一张规则表,每条配一个输入输出样例:
| 编号 | 行为规则 | 输入示例 | 预期输出 |
|---|---|---|---|
| R-01 | 中文与英文之间补一个半角空格 | "写TypeScript代码" | "写 TypeScript 代码" |
| R-02 | 中文与数字之间补一个半角空格 | "一共20个版本" | "一共 20 个版本" |
| R-03 | URL/邮箱视为整体,前后补空格,内部不处理 | "访问https://example.com" | "访问 https://example.com" |
| R-04 | 连续多个点号或西文省略号归一为规范省略号 | "恩...好" | "恩……好" |
| R-05 | Markdown 代码块与行内代码内容原样保留 | "写TypeScript" | "写TypeScript" |
这张表看起来简单,但在写规格时已经逼我做了好几个以前没想过的决定:比如"写 Node.js 代码"里 Node.js 前面的空格和后面的空格都要补,但 R-02 的"20个版本"数字后面补空格后,如果下一个字符是中文句号,要不要再补?规则本身不生歧义,才会让 AI 的第一版实现偏离最小。
2.2 规格里的反例:AI 最需要"不要这么做"
给 AI 写规格时,正向规则给再多,它也会在边界上自由发挥。我后来在 spec 文件里加了一整节"反例表",专门记录那些看起来符合规则、但实际不该被处理的情况。这块内容值得讲。
比如 R-03 规定 URL 前后补空格,但"https://example.com"内部的两条斜杠之间绝不能插入空格;"中文里的版本号 v1.2.3"要不要在"."前后加空格?按中文排版习惯,版本号应该整体视为一个 token,不加空格。AI 如果只看了 R-02 和 R-03,很容易把版本号当成英文和数字混合文本,拆得稀碎。规格里我写了反例:"v1.2.3 整体保留,不插入空格;IP 地址 192.168.1.1 同理"。这类例子越具体,后面验收测试出来时和 AI 来回扯皮的次数就越少。
spec 文件我用的是 Markdown,不是某种新语言。结构固定成:背景、范围、非目标、规则表、反例表、验收清单、待办问题。为什么用 Markdown?因为 Claude Code 这类 Agent 对 Markdown 的解析能力已经足够好,而且人读起来也顺畅,不需要为了形式上的"结构化"引入额外工具链。第一次写完 spec 大概花了一个多小时,其中一半时间是在补反例和边界例子,不是写废话。这就是 SDD 的核心体验:人在规格阶段把歧义杀干净,后面 AI 实现才有可能是直线球。
2.3 接口设计在规格里敲定:函数签名比实现先定下来
接口部分我直接在规格里定死了,没让 AI 自由发挥:
export type FormatMode = 'plain' | 'markdown'; export interface FormatOptions { mode?: FormatMode; } export function formatText(input: string, options?: FormatOptions): string;设计时只暴露一个主函数,而不是拆一堆 spaceBetweenChineseAndEnglish、normalizeEllipsis 等细碎 API 再由用户自己组合。原因很简单:排版规则之间有先后顺序和互相影响。比如 URL 识别要先于空格插入,如果用户在 URL 内部先被加了空格,后面的 URL 保护逻辑就识别不出来了。一个黑盒入口,内部按固定管道顺序执行规则,才是最不容易滥用的 API 形态。这个决定也直接写进了规格的接口说明里,AI 按图实现时没有纠结的空间。
3. 把实现交给 AI Agent:AGENTS.md 和阶段化指令是怎么配合的
3.1 初始化项目,让 Agent 一进来先读规格
我这次用的主力是 Claude Code 的终端 Agent 模式。和聊天窗口里贴代码最大的区别是,Agent 能直接读写仓库文件、跑测试、改代码。但这不代表你可以把整个仓库丢给它以后当甩手掌柜。真正关键的工程动作是建立"约束文件"。
我在项目根目录放了一份 AGENTS.md,内容是:
# 项目约定 1. 本项目是规格驱动开发,任何行为变更先改 spec/spec.md,再改 src 下实现。 2. 核心逻辑集中在 src/typography.ts,入口导出来见 src/index.ts。 3. 规则编号 R-xx 的验收用例必须保留在 test/typography.test.ts。 4. 改完代码后必须运行:npm run test && npm run typecheck。 5. 你不确定某个边界行为时,先查 spec,不要猜测。这份文件用大白话告诉 Agent 三件事:项目边界在哪、改动规格和代码的先后顺序是什么、完成后必须用什么命令自证。第一次执行会话里,我发现 Agent 确实会在动手前主动打开 spec 看一眼,而不是直接凭系统提示词里的印象写代码。AGENTS.md 相当于给 Agent 装了个"项目常识",省得每次对话都得重复交代背景。
3.2 阶段化推进:一次只实现一档规则
规格虽然只有几十行,但我没有让 Agent 一把梭全部实现。原因有两个:一是单次上下文窗口内代码多了以后,模型的注意力会下降;二是规则之间有关联,一次性实现全部规则,如果某个中间行为错了,排查时根本分不清是哪一步引入的。
我的拆法是按规则之间的依赖关系分成三个批次。第一批只做 R-01 和 R-02,让基于字符遍历的"中英文之间插空格"逻辑先跑通;第二批做 R-03,加入 URL/邮箱保护,因为它的实现会改变第一批的行为;第三批做 R-04 和 R-05 的 Markdown 模式,最后再做整体联调。每个批次推进时,我给 Agent 的指令大致是这样:
请读取 spec/spec.md,实现 R-03。 要求: - URL、邮箱在内部不处理空格; - 仅在 URL 前后补空格; - 版本号和 IP 按反例表原样保留; - 补全 test/typography.test.ts 里 R-03 对应的用例; - 完成后跑 npm run test,不要改动 R-01、R-02 行为。实际效果比我预想的好。Agent 拿到这份指令后,会先在代码里找它认为的 URL 边界,再对照规格里的 URL 反例逐个核验,实现过程中还主动问了我一个规格没写明的问题:markdown 的链接文本和链接地址要不要同样处理?这个问题说明模型真的在拿规格推演实现细节了,而不是憋着写完全部代码再等我喷。对于这种规格遗漏,我的处理是先补充到 spec 里,再让 Agent 继续,绝不让它自行发挥之后就忘了记录。
3.3 让 Agent 自己写单元测试:规格成了测试脚本
SDD 流程里最容易偷懒的环节是测试。我以前经常遇到"AI 说都测过了,实际上只跑了一遍主流程"的情况。这次我强制要求一条规则至少一组用例,且测试解释文字里要带上规格编号。这个约束放在 AGENTS.md 里,目的是让 Agent 提交的代码自带可验证性。
当 Agent 实现完一个批次,它会顺手跑一次测试。真正跑挂之后,它有两种处理方式:如果代码实现偏了,它会主动修实现;如果它发现规格本身有矛盾,会停下来在对话里指出问题而不是强行"糊"一个输出确保测试通过。这第二条尤其重要——AI 为了满足测试用例而在实现里写死特例的情况很常见,但因为我们每条规则对应的输入样例都是开放式的,它没法靠特例糊弄过去。
4. 验收测试:让规格条目变成 test 用例,AI 没法再糊弄
测试不是给规格"交差",而是整个流程里最硬的验收面。我在项目里用的是 Vitest,因为配置轻量、对 TypeScript 原生友好。每个规则编号在测试文件里都有对应的 describe 分组,测试名直接写规则编号和含义,打开测试文件就像打开一份可执行的规格清单。
describe('R-01 中文与英文之间补半角空格', () => { it('中文后直接跟英文单词', () => { expect(formatText('写TypeScript代码')).toBe('写 TypeScript 代码'); }); it('英文单词后直接跟中文', () => { expect(formatText('本文介绍ofDocker用法')).toBe('本文介绍 ofDocker 用法'); }); }); describe('R-03 URL 前后补空格,内部保持原样', () => { it('中文和 URL 之间自动补空格', () => { expect(formatText('访问https://example.com测试')).toBe('访问 https://example.com 测试'); }); it('URL 内的斜杠不做处理', () => { expect(formatText('链接https://example.com/a/b结尾')).toBe('链接 https://example.com/a/b 结尾'); }); });这些用例不是 AI 拍脑袋生成的,绝大多数直接来自规格里的规则表和反例表,我只是把它们翻译成了断言。翻译过程中我发现一个特别值得提醒的点:Agent 生成测试用例时,有一种天然倾向是把输入写得太"规整",比如只测"中文和 English 之间"这种经典场景,而不去测"中文和 node.js 这种带点号的英文 token"或者"数字、英文、中文三者连在一起"的混乱真实文本。真实文本大多是脏的,所以规格里的反例表在这个阶段成了金矿,每一个反例都能变成一条测试。
规格驱动还有一个反直觉的优点——它把"人改需求"的成本降得很低。比如我原来没考虑"时间格式 3:30 PM"这种输入,后来实测发现规则 R-02 会把冒号后面的空格处理得很难看。按照旧习惯,我可能直接说"AI 修一下这个 bug",然后 Agent 改完代码,测试也过了,但没人知道这条行为是不是后来又会被别的地方破坏。在 SDD 流程里,我的做法是先改规格:在反例表加一行"英文时间格式保留原样 3:30 PM,不拆分冒号前后",然后让 Agent"按规格变更更新实现和测试"。Agent 这次不仅能修好行为,还会主动在测试里补一个"3:30 PM"的回归用例。这才是规格作为单一事实来源的意义。
测试过程中我还撞见过一个很典型的 AI 问题:模型实现 R-04 省略号规则时,倾向于把输入里的三个英文句点"..."直接替换成单个中文省略号 U+2026,而中文排版正确表现其实是两个连着的 U+2026 字符"……"。单从字符看,一个是 U+2026,两个也是 U+2026 的重复,但如果实现里只 replace('...', '…'),最后输出在大多数系统字体下视觉长度不对。这个细节靠肉眼 review 代码很难发现,但规格里的"输出应包含两个连续的 U+2026"这一句,让测试直接给出了明确失败。从那之后我更确信:SDD 里最值钱的不是规则本身,而是规则后面的验收标准精确到了可断言的程度。
5. 走完发布流程:PowerShell 执行策略、registry 证书过期,还有双格式打包
5.1 package.json 的 exports 怎么写才不坑用户
本地实现和测试都过了以后,剩下最后一公里是发布到 npm。这个环节看着不起眼,实际坑比我想象的多。先看 package.json 的核心配置:
{ "name": "cn-typo-fmt", "version": "0.1.0", "type": "module", "files": ["dist"], "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" } } }我明确选择了双格式输出,同时支持 ESM 的 import 和 CommonJS 的 require。现在新项目大多用 ESM,但很多老项目还在用 require,你不给 CJS 出口,人家一装就报ERR_REQUIRE_ESM,这个体验很劝退。源码 TypeScript 写好之后,我用 tsup 一把打包出index.js(ESM)、index.cjs(CJS)和index.d.ts类型声明,比手动配 rollup 省心得多。
发布前的检查动作也很重要。跑一遍npm pack --dry-run,看一下即将打进 tarball 的文件列表。我第一次打包时发现 dist 之外还有 spec 目录和测试文件会被带进去,虽然无损,但会让包很臃肿。加"files": ["dist"]之后才干净。这一步花不了两分钟,但很值得养成习惯。
5.2 我实际遇到的四个发布报错
发布过程中我撞了四个典型的坑,每个都能在网上搜到一堆同款问题,这里直接给出我的处理方案。
第一个也是最经典的,在 Windows PowerShell 里执行 npm 命令直接报错:npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。这是因为 PowerShell 的脚本执行策略默认是 Restricted,npm 的 .ps1 包装脚本跑不起来。修复命令:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUserRemoteSigned表示本地脚本可以运行、从网上下载的脚本需要签名,Scope CurrentUser只影响当前用户,不需要开管理员权限终端。如果你不想动执行策略,也可以直接改用 cmd 里的npm.cmd,但不建议长期这么绕,因为后面还有很多工具链脚本会踩同一个限制。
第二个是环境变量问题:提示npm 不是内部或外部命令。这种情况通常是 Node.js 安装目录没加进 PATH,或者改完 PATH 之后没重启终端。网上很多教程让重新安装 Node,其实没必要。打开系统环境变量,把 Node.js 所在目录(通常是C:\Program Files\nodejs)加到 Path 里,然后重新开一个终端就解决了。
第三个是 registry 证书过期:npm ERR! code CERT_HAS_EXPIRED,请求地址指向https://registry.npm.taobao.org。这是老版淘宝镜像域名证书过期导致的,不是你本机的问题。处理办法是切回官方源或新镜像源:
npm config set registry https://registry.npmjs.org/如果团队必须用国内镜像,也应该用现在维护中的域名而不是已经停用的老域名。证书过期类报错最迷惑的点在于它报的是一堆 TLS 错误,容易让人误判成网络问题或代理问题,实际换源就好。
第四个是 npm 安装时出现的npm warn deprecated node-domexception@1.0.0。这类 deprecation 警告来自你依赖树的某个子依赖,不是你的包本身有问题。我一开始还专门去查要不要锁版本,后来想清楚了:只要不是导致实际报错的 deprecated 警告,就不值得为它打乱依赖版本。真正要留意的是警告里是否带上npm ERR,那才是要处理的。
5.3 发布成功不算完:立刻在空目录里验证一次
npm publish成功那一刻容易让人误以为任务结束了。我的建议是,永远在一个全新的目录里验证一次装包和使用。具体做法:
mkdir /tmp/verify-typo && cd /tmp/verify-typo npm init -y npm install cn-typo-fmt node -e "const { formatText } = require('cn-typo-fmt'); console.log(formatText('用SDD做的排版包测试'));"这一步能同时验证三件事:tarball 里没有漏文件、CJS/ESM 入口都正确、代码在干净环境下能正常运行。我见过太多包"作者本地能跑,别人一装就废",原因大多出在漏了 dist 目录、exports 字段写错或 package 里少了某个文件。空目录验证是成本最低的保险。
6. 一段时间用下来,SDD 最划算的场景和不划算的场景
这套流程跑完一个实际发布的项目之后,我心里对 SDD 的适用边界有了更清楚的答案。如果你只需要一段二三十行的脚本,处理完就扔,那 vibe coding 依然是最快的,让 AI 天马行空没问题。但只要你准备做一个要发布、要维护、要给别人用的 npm 包,哪怕逻辑不算复杂,规格先行都值得。它其实不是给 AI 增加流程,是在保护你的项目不被模型的"合理猜测"带偏。
我现在的判断标准很简单:这个文件或者这个包以后会不会有第三个人看?会不会被测试覆盖?会不会持续迭代超过一周?三个问题里有一个是"是",我就先写规格再写代码。规格不用写长,关键是规则表加反例表加验收清单。真正让我坚持用下去的原因是,SDD 把我和 AI 的对话从"你猜我想要什么"变成了"按这份文档执行,不确定就问我",协作质量稳定了很多。后续如果再做大一点的工具,我会试试把规格拆成多份文件,并按 openspec 的目录习惯维护变更记录,让整个演进过程有迹可查。
最后分享一个小技巧:规格文件写好之后,先别急着让 AI 动代码,自己照着规格里的验收样例在脑子里过一遍,看有没有哪个例子其实有两种解读。每找出一个歧义,就等于后面少一次和 Agent 的无效返工。这种"花一小时写规格,省下三小时改 bug"的交易,做过一次你就回不去了。