我这段时间用 AI Agent 写代码的频率很高,但说实话,翻车次数也不少。一开始以为 AI 编程就是"描述需求 → 复制代码 → 跑起来",结果项目一大,Agent 就开始自由发挥,需求变来变去,代码越改越乱。后来我把工作流切换到SDD(Spec-Driven Development,规格驱动开发)重新做了个小工具——一个排版用的 npm 包,整个过程完全换了个感觉:需求先写成 spec,Agent 照着 spec 干活,每一步都有验收标准,开发效率和个人心态都稳了很多。
这篇文章想跟你聊聊我是怎么用 SDD 完成一个 npm 排版包的,包括我整理的六步实践指南、三层规格框架怎么落地、以及发布 npm 包时踩过的那些坑(PowerShell 报错、证书过期、镜像源、publish 权限这些)。如果你也在用 AI 写代码,或者准备发布自己的第一个 npm 包,这篇应该能帮你省不少时间。
1. 为什么我会用 SDD 来做一个排版 npm 包
1.1 先说结论:SDD 是 AI 协作开发的正解
现在的 AI 编程,很多人还停留在"vibe coding"的状态:想到什么就让 AI 写什么,代码能跑就算赢。这种模式做原型很爽,但做到第两天你就会发现,AI 生成的代码像一盘散沙,每次都重新理解需求,改了一个地方,另一个地方又崩了。
我自己的体验是:AI Agent 的智商取决于你给它的上下文约束,而不是你的想象力。你描述得越模糊,它发挥的空间越大,出 bug 的概率就越高。SDD 正是对症下药:先写一份明确的规格说明(spec),规定清楚输入、输出、边界条件、错误处理、验收标准,再让 AI 按 spec 一步步实现。这不是限制 AI,而是给 AI 画了一条清晰的跑道,让它跑得快还不会跑偏。
这次做排版 npm 包,我最大的感受是:有了 spec,AI 写出来的代码终于不是"能跑"而是"可靠"了。整个项目从初始化到发布,中间几乎没有出现需求回流。
1.2 什么是 SDD:规格驱动的三层分类框架
说到 SDD,Thoughtworks 的工程师 Birgitta Böckeler 提过一个三级分类框架,我觉得特别有启发。她的大概思路是:在 AI 协作开发中,规格不能是一个笼统的东西,要分成三个层级来写。
- 需求规格(What):产品层面,描述用户要什么,解决什么问题,不涉及具体技术。比如"一个能自动修复 Markdown 文档排版问题的 CLI 工具"。
- 设计规格(How):技术层面,描述用什么方案实现,包含接口定义、数据结构、算法选型。比如"采用 Node.js 实现,对外暴露一个
format(content: string): string函数,内部通过正则和 AST 处理文本"。 - 任务规格(Do):执行层面,把设计拆成 AI 可以一次完成的小任务,每个任务有明确的输入、输出和验收标准。比如"任务 1:实现中英文之间加空格的正则逻辑,通过以下 10 个单元测试"。
这三个层级对应到实际开发里,就像建房子的"图纸 → 结构设计 → 施工单":需求规格管方向,设计规格管方案,任务规格管落地。AI Agent 真正执行的是任务规格,但它依赖前两层做上下文,否则就会在细节里迷失方向。
1.3 排版包这个项目,为什么天然适合 SDD
你可能也会问,为什么偏要选一个排版工具来试 SDD?我当时的判断是:排版工具规则明确、边界清晰、可测试性强,是所有项目中最适合验证 SDD 工作流的类型。
- 规则明确:中英文之间加空格、标题层级不能跳级、代码块要有语言标注——这些都是可以量化的规则,写进 spec 不会有歧义。
- 边界清晰:输入是一个字符串,输出也是一个字符串,没有外部依赖,不需要处理数据库和网络,Agent 可以专注实现逻辑。
- 可测试性强:每个规则都能写成单元测试,验收标准非常客观。AI 写得好不好,跑一遍测试就知道了。
相比之下,如果你拿一个"社区论坛后端"来测试 SDD,复杂度会高很多,因为涉及状态管理、权限、数据库事务,spec 写起来本身就是巨大的工程。所以我建议想尝试 SDD 的朋友,也从一个边界清晰的命令行工具开始,先把工作流跑顺了,再挑战更复杂的项目。
2. 项目设计:SDD 六步,把需求变成可执行的 spec
2.1 第一步:把模糊需求固化成产品规格
我在做之前,需求其实很模糊:"我想做个好东西"。后来强制自己用 SDD 的第一层——需求规格——把它写清楚,才意识到"把需求写出来"本身就是一次深度思考。
我最后写的需求规格长这样(节选):
# 需求规格:md-layout-cli ## 目标用户 - 在 GitHub 上维护技术文档的开发者 - 写作包含中英文混排内容的作者 ## 核心痛点 - 中英文之间缺少空格,阅读体验差 - Markdown 标题层级随意跳级,文档结构混乱 - 代码块没有语言标注,高亮失效 - 段与段之间换行不一致,diff 很难看 ## 功能需求 1. 自动在中英文、中文与数字之间插入空格 2. 规范化 Markdown 标题层级(自动纠正跳级) 3. 检查并补齐代码块语言标注 4. 统一换行符为 LF 5. 支持 CLI 单文件处理和 npm API 调用这一步不涉及任何技术实现,纯粹回答"这个工具要解决什么问题"。但你会发现,写到这里,你已经把"排版包"从模糊概念变成了两个明确角色(用户、作者)和四类明确功能。我给 AI 的第一个指令就是"阅读这份需求规格,然后复述你对项目的理解"——它会帮你检查需求有没有漏洞。
2.2 第二步:把产品规格拆成技术决策
需求规格定完后,紧接着写设计规格。这一步要回答"用什么技术方案实现"。我的选择如下:
- 运行时:Node.js >= 18。原因是 CLI 工具生态成熟,发布 npm 包最方便,而且我可以直接用内置的
node:test做单元测试,不用额外装测试框架。 - 入口设计:同时提供
format(content)函数(作为 API)和bin命令(作为 CLI)。 - 核心实现:用 Unicode 正则匹配 CJK(中日韩统一表意文字、假名、谚文)与拉丁字母/数字之间的边界,用行级扫描处理 Markdown 标题层级。
- 零依赖:这是给自己定的硬约束。排版工具的核心逻辑很简单,引入依赖会增加安全风险和下载成本,这一点写进 spec 后,AI 就没有自作主张去装
chalk之类的库。
设计规格里我还会写明"不做什么":不处理图片路径、不重构段落结构、不转成 HTML。这些"边界声明"特别重要,否则 AI 很容易在一个排版工具里给你夹带一个 Markdown 解析器。
2.3 第三到第六步:任务拆分、验收标准、排期与反馈闭环
在三级框架里,任务规格是 AI 真正执行的东西。按照六步实践法,后面的几步落到实操是这样:
- 第三步:任务拆分。把设计规格拆成 4 个任务:
task-1初始化 npm 包结构和 package.json;task-2实现格式化核心函数;task-3实现 CLI 命令行入口;task-4编写单元测试和 README。每个任务都标注了涉及文件、依赖关系和执行顺序。 - 第四步:验收标准。每个任务必须附带验收条件。例如
task-2的验收条件就是"运行node --test后,12 个测试用例全部通过"。我在 spec 里明确规定:验收不通过,Agent 必须自己修复,不允许跳过或修改测试。 - 第五步:开发排期。不是人类排期,而是给 Agent 的"执行顺序约束"。先做任务 1,再做任务 2,绝不能乱。很多 AI 翻车就是因为一次让它做太多事,上下文塞爆,前后逻辑对不上。
- 第六步:反馈闭环。每个任务完成后,我会让 Agent 先自测,再把 diff 发给我评审,我反馈意见后它再进入下一个任务。SDD 不是甩手掌柜,人还是要保留最终审核权。
这套流程跑下来,我发现 AI 写的代码质量有明显提升,原因是它每一次只需要专注一件事,而且有明确的"完成"定义,不会再出现那种写到一半突然重构另一个模块的情况。
2.4 工具选型:Claude Code 与 Codex 的配合方式
既然是 SDD + AI 开发,工欲善其事必先利其器。我用的是两个工具:
- Claude Code:用来做日常的代码生成、重构、解释。它对长上下文的处理比较稳,能记住项目结构和 spec 细节,适合在一个会话里连续完成多个任务。
- Codex CLI:用来做交叉验证和快速实验。我会让它独立实现同一个任务,然后对比两边的实现思路,选出更稳的方案。
安装这两个工具的命令,刚好也踩过不少坑,放到后面常见问题里一起说。这里提一句:如果你也打算用命令行版本的 AI 工具,安装完成后先跑一下ai --version确认环境 OK,再进项目目录启动,否则容易遇到"命令找不到"的困扰。
3. 实操记录:从 spec 到 npm 包发布
3.1 项目初始化:package.json 与目录结构
按任务 1 的要求,我先让 AI 初始化项目,生成结构如下:
md-layout-cli/ ├── package.json ├── bin/ │ └── md-layout.js ├── src/ │ ├── formatter.js │ └── headings.js ├── test/ │ └── formatter.test.js ├── spec/ │ ├── requirements.md │ ├── design.md │ └── tasks.md └── README.md这份package.json是 AI 生成后我调整过的,关键字段在这里:
{ "name": "md-layout-cli", "version": "0.1.0", "description": "A lightweight markdown layout formatter for Chinese-English mixed text", "main": "src/formatter.js", "bin": { "md-layout": "./bin/md-layout.js" }, "files": [ "src", "bin", "README.md" ], "scripts": { "test": "node --test test/", "publish:patch": "npm version patch && npm publish --access public" }, "engines": { "node": ">=18" }, "license": "MIT" }值得强调files字段。不过我没急着在这一步让它跑npm pack,先继续写核心逻辑。
3.2 核心实现:中英文混排格式化逻辑
任务 2 是核心。我让 AI 基于设计规格里定好的正则规则实现src/formatter.js。最终保留下来的核心函数长这样:
// src/formatter.js const CJK_REGEX = /[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/; const LATIN_OR_DIGIT_REGEX = /[A-Za-z0-9]/; // 核心:在中英文/数字之间插入空格 function addSpaceBetweenCJKAndLatin(text) { return text .replace(/([\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af])([A-Za-z0-9])/g, '$1 $2') .replace(/([A-Za-z0-9])([\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af])/g, '$1 $2'); } // 核心:规范化 Markdown 标题,禁止跳级 function normalizeHeadings(text) { const lines = text.split('\n'); let lastLevel = 0; const result = lines.map((line) => { const match = line.match(/^(#{1,6})\s+(.*)$/); if (!match) return line; const hashes = match[1]; let level = hashes.length; if (lastLevel > 0 && level > lastLevel + 1) { level = lastLevel + 1; } lastLevel = level; return `${'#'.repeat(level)} ${match[2]}`; }); return result.join('\n'); } function format(text) { let output = text.replace(/\r\n/g, '\n'); output = addSpaceBetweenCJKAndLatin(output); output = normalizeHeadings(output); return output; } module.exports = { format, addSpaceBetweenCJKAndLatin, normalizeHeadings };这里有两个小心思值得展开:
- CJK 范围:我故意包含了汉字、日文假名、韩文谚文。很多排版工具只处理汉字,但真实文档里可能出现日文注释,所以一处正则,三种文字都覆盖了。
- 标题跳级修复逻辑:默认规则是"
#标题不能从一级直接跳到三级"。实现方式是记录上一个出现的标题级别lastLevel,当下一个标题级别大于lastLevel + 1时,强制定级为lastLevel + 1。这个逻辑简单,但足够解决 90% 的手写 Markdown 跳级问题。
在实现任务 2 的过程中,AI 曾自作主张加了一个"英文首字母大写"的功能,被我一票否决了。因为 spec 里明确写了"不改变语义内容,只处理排版",英文首字母大写是内容层面的修改,不是排版。这也是为什么我反复强调"不做什么"一定要写进规格。
3.3 测试先行:让 Agent 自己给自己"验收"
任务 4 是测试。SDD 的优点在这里体现得最明显:因为 spec 里已经定义了验收标准,测试用例写起来就像抄答案。我让 AI 根据四个功能需求,写出了对应的测试:
// test/formatter.test.js const test = require('node:test'); const assert = require('node:assert/strict'); const { format } = require('../src/formatter'); test('中英文之间插入空格', () => { assert.equal(format('我喜欢JavaScript'), '我喜欢 JavaScript'); assert.equal(format('Node.js真好用'), 'Node.js 真好用'); }); test('中文与数字之间插入空格', () => { assert.equal(format('我已经用了3年'), '我已经用了 3 年'); assert.equal(format('支持React18'), '支持 React 18'); }); test('标题跳级自动修正', () => { const input = '# 一级标题\n### 三级标题'; const output = format(input); assert.equal(output, '# 一级标题\n## 三级标题'); }); test('统一换行符', () => { assert.equal(format('a\r\nb'), 'a\nb'); }); test('不改变已有空格', () => { const input = '我喜欢 JavaScript 和 Vue'; assert.equal(format(input), '我喜欢 JavaScript 和 Vue'); }); test('混合内容集成测试', () => { const input = '# 介绍\n大家好,这是Node.js实战项目,版本为18。\n### 二级标题'; const output = format(input); assert.match(output, /大家 好,这是 Node\.js 实战项目,版本为 18。/); assert.match(output, /## 二级标题/); });这里我犯过一个典型错误:最后一条集成测试,把"大家好"之间的空格也断言了。实际上 "大家" 和 "好" 都是中文,正则不会在它们之间加空格。我第一次跑就红了,AI 一脸无辜,后来我仔细一看,是自己把测试写错了,不是逻辑写错了。这也是一个提醒:AI 生成的测试用例,人一定要看一遍,不是能跑就行,要确认它测的东西真的符合预期。
跑测试是npm test或node --test test/。看到全部绿色,我才会同意 Agent 进入下一个任务。
3.4 打包发布:npm publish 全流程
任务收尾,项目能跑了,接下来是发布 npm 包。整个流程我拆成这几步:
- 登录 npm:
npm login,输入用户名、密码和一次性验证码。这里要注意,npm 现在推荐使用 token 登录,npm 官网 setting 里可以生成。 - 确认版本号:
npm version patch会把版本号从0.1.0升到0.1.1并自动打 git tag。 - 本地打包预览:
npm pack会生成一个.tgz文件,可以用npm install -g ./md-layout-cli-0.1.1.tgz先全局安装测试一遍。这一步绝对不要跳,我见过太多人直接npm publish,结果包结构不对,装下来根本不能用。 - 正式发布:
npm publish --access public。如果你是第一次发公开包,必须加--access public,否则作用域包默认是私有的,会报错。
我专门写了publish:patch这个 script:
"publish:patch": "npm version patch && npm publish --access public"一条命令完成版本升级、git tag 和发布。后来还做了别名publish:minor和publish:major,分别对应小功能和大版本升级。
发布完成之后,可以用npm view md-layout-cli检查包是否存在,也可以到 registry 页面看详情。第一次看到自己的包出现在 npm 上,那种感觉还是很奇妙的——尤其是这个包从需求到实现,有一半是 AI 写的,而整个流程的掌控感却比以前自己手写代码还要强。
4. 踩坑实录:npm 工具链的常见问题与排查
4.1 PowerShell 禁止运行脚本
如果你在 Windows 上开发,几乎必然遇到这个报错:
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。我第一次看到这个直接懵了,明明 Node.js 装好了,为什么npm不能运行?原因是 PowerShell 的执行策略默认是Restricted,禁止运行任何 PowerShell 脚本,而npm.ps1正好是一个脚本文件。
解决办法是打开 PowerShell,以当前用户身份放开脚本执行权限:
Set-ExecutionPolicy -Scope CurrentUser RemoteSignedRemoteSigned的意思是:本地脚本可以运行,从网络下载的脚本必须有数字签名。这样既解决了问题,又不会把系统改成完全无限制模式。改完后重启终端,npm --version就能正常输出了。
如果你见到CommandNotFoundException sqlpackage.exe或者与npm不同的另一个报错,往往是环境变量 PATH 没配置好。确认node安装目录(通常是C:\Program Files\nodejs\)已经加入系统 PATH,并在新开的终端里测试,不要用旧终端。
4.2 证书过期与镜像源配置
在开发过程中,我遇到过一条很经典的安装报错:
npm ERR! code CERT_HAS_EXPIRED npm ERR! request to https://registry.npm.taobao.org/xxx failed, reason: certificate has expired原因是老版本的淘宝镜像域名registry.npm.taobao.org证书已经过期,而很多教程还在让人配这个源。新版的镜像域名改成了registry.npmmirror.com,如果你还在用老地址,自然就会遇到证书错误。
处理方式有两种:
- 使用国内镜像新域名:
npm config set registry https://registry.npmmirror.com - 使用官方源:
npm config set registry https://registry.npmjs.org/
我个人建议:能上官方源就用官方源,体验最稳定,发布包也必须在官方源操作。国内镜像适合加速下载依赖,但如果你要npm publish,记得先把源切回官方。npm config get registry可以随时查看当前源地址。
还有个隐藏问题:即便你改回了官方源,npm 的缓存里可能残留了旧证书的元数据,这时候建议清理缓存再重试:
npm cache clean --force4.3 install 报错与依赖清理
开发过程中,我用npm install -g 某个工具的方式给全局装了 AI 编程相关的命令行工具,好几次遇到 EACCES 权限报错。这是 macOS/Linux 上全局安装的经典问题,原因是全局目录需要 root 权限。网上有些教程让你加sudo,我强烈不建议,因为用 root 运行 npm 脚本会带来安全风险,而且之后每次升级包都要 sudo。
更好的方式是通过 nvm 来管理 Node.js,这样全局安装目录就在你的用户目录下,不需要任何额外权限:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash nvm install --lts nvm alias default <版本号>装完 nvm 之后重新打开终端,npm install -g再也不会报权限错误了。这也顺带解决了"找不到 npm 命令"的问题,因为 nvm 会把 Node.js 路径写进你的 shell profile。
另外还有一个高频问题:npm install出现EINTEGRITY报错。这通常是缓存损坏或锁文件与 package.json 不同步导致的。先删掉node_modules和package-lock.json,再执行npm cache clean --force,最后重新npm install,九成情况都能解决。
4.4 pack 与 publish 阶段的坑
本地开发一切正常,发布才是最容易翻车的地方。我总结了三个高频坑:
- 包名冲突:npm 上的包名是全球唯一的。如果你想起的名字已经被占了,
npm publish会直接报403 Forbidden或You cannot publish over the previously published versions。发布之前先跑一下npm view 你要用的包名,如果返回 404 或者连个ETIMEDOUT都没有,说明这个包名可能可用;如果返回了版本信息,就得改名。 - 文件缺失:很多人
npm publish发布完,装下来发现README没带进去,或者源码路径不对。这就是files字段没配好。我在package.json里配了files后,每次发布前还会跑npm pack --dry-run,它会列出即将被打进 tar 包的所有文件清单,确认一遍再发布,能省不少事。 - force 滥用:有时候发布报错,网上有人会建议
npm publish --force。这个 flag 的官方语义是"忽略某些检查",包括可能覆盖掉已有版本。除非你明确知道自己要做什么,否则永远别用--force发布。我见过有人用--force把一个线上包的旧版本覆盖掉了,导致某个团队依赖的版本突然消失,事故现场非常惨烈。
还有一个提醒:npm version会自动修改package.json版本号并创建 git commit 和 tag。如果你没有配置好 git 用户信息,这条命令会报错。先在项目里确认git config user.name和user.email有值,再跑npm version patch,否则要先:
git config user.name "你的名字" git config user.email "你的邮箱"写在最后:SDD 改变了我的开发习惯
这次用 SDD 做排版 npm 包,给我最大的启发不是 AI 多厉害,而是"写清楚"本身才是核心竞争力。以前我总以为 AI 编程的核心是提示词技巧,后来发现提示词技巧只是表面,真正的底座是你对问题的定义能力。spec 写清楚了,AI 就是你的超级执行者;spec 写得稀烂,AI 就是一台 Corrupted 的自动生成 bug 机器。
我现在连平时的非 AI 项目,也会先写一版简短的 requirements 再动手。这样做的额外好处是:写完 spec 之后我常常会发现,有些需求根本不值得做,或者有更好的实现方式,避免了一言不合就写代码的冲动。
最后再分享一个小技巧:写 spec 时,一定要有一个单独的段落叫"边界与不做项"。允许 AI 做什么写了之后,再把"绝对不做什么"写清楚,比如不引入额外依赖、不改动语义、不重构无关代码。这条约束帮我挡掉了无数次 AI 的自由发挥,你也可以试试。