3个SDD工具实测:结果是什么
我使用SDD在三个工具中构建了一个真实功能。智能体违反了我的规格两次。以下是全部内容。
AI模型价格对比 | AI工具导航 | ONNX模型库 | Vibe Coding教程 | PLC在线仿真器 | Tripo 3D | Meshy AI | ElevenLabs | KlingAI | ArtSpace | Phot.AI | InVideo
六周前我发表了一篇文章,认为vibe coding正在失败,规格应该是至高无上的。我没有预料到它的反响。
随后的评论和消息在一个问题上是一致的:给我看你怎么实际做的。不是理论。不是框架。一个真实的功能、一个真实的规格、一个真实的智能体犯真实的错误,以及你如何在它们到达生产环境之前捕获它们。
这篇文章就是为那些好奇的人准备的。
在我们进入功能之前,以下是本文逐步讲解的完整SDD工作流——六个步骤、一个反馈循环、每个阶段一条规则:

Plan和Implement之间的审批门是这个工作流中杠杆率最高的补充。Verify回到规格的反馈循环是让规格在功能的整个生命周期中保持活力的关键。
我选择了一个每个工程师都在某个时候构建过的功能:Express.js API的速率限制中间件。足够具体以贴近现实。足够简单,你无需了解我的代码库就能理解逻辑。足够复杂,如果你不仔细写规格,智能体至少会犯一个有趣的错误。
我在三个不同的SDD实现中运行了它:Claude Code原生、GitHub Spec Kit和BMAD-METHOD。每个都产生了可工作的代码。每个都违反了规格中的至少一个约束。而最重要的发现——没有其他SDD指南诚实地涉及的是——它们都没有自动验证实现是否真正符合规格。
以下是每一步、每个模板、每个错误,以及我构建的验证层——用于在CI管道捕获违规之前捕获它们。
1、功能
Express API的速率限制中间件。
开始时的需求,用通俗英语描述(规格之前):
- 滑动窗口算法(不是固定窗口——重要区别)
- 基于Redis(开发环境有内存回退)
- 按用户和按端点配置
- 返回429并带有Retry-After头
- 不计算健康检查端点
- 如果Redis不可用则优雅降级
六个需求。过去四年中我构建过三次。每次,团队中不同的工程师对"滑动窗口"有不同的解释,实现偏离了我们口头达成的协议。先写规格不是实验——它是对一个反复出现的问题的解决方案。
2、规格
在接触三个工具中的任何一个之前,我写了规格。花了47分钟。比写代码要长。这就是重点。
# 功能规格:速率限制中间件
# 版本:1.0 · 最后更新:2026-06-01
# 状态:激活 — 本文档管辖实现
## 意图
通过限制每个用户和每个端点的请求频率来防止API滥用。
超过限制的用户会收到一个清晰的错误,指导何时重试。
当Redis不可用时系统优雅降级——永远不会因为基础设施故障
而阻止合法请求。
## 必须始终做到的事
- [ ] 强制执行滑动窗口算法,而不是固定窗口
(固定窗口允许在窗口边界突发——这是我们预防的故障模式)
> **为什么是滑动窗口,不是固定窗口?**
> 固定窗口在时钟边界重置。设每分钟100个请求的限制:用户可以在11:59:58发送100个,在12:00:01再发送100个——3秒内200个请求,零次阻止。滑动窗口没有边界。它始终从*现在*看过去60秒,所以任何突发都会被捕获,无论何时发生。规格明确命名了故障模式,因为只看到规则的智能体("使用滑动窗口")可能实现一个仍然有边缘情况的正确算法——看到*原因*的智能体可以自己捕获这些边缘情况。
- [ ] 返回HTTP 429并带有以秒为单位的Retry-After头
- [ ] 支持按用户限制(以认证用户ID为键)
- [ ] 支持按端点覆盖(对/api/auth/*更严格,对/api/public/*更宽松)
- [ ] 记录每次速率限制违规,包含:user_id、endpoint、timestamp、limit、current_count
- [ ] 如果Redis不可用则回退到内存跟踪,带有控制台警告
## 绝对不能做的事
- [ ] 对健康检查端点(/health、/ping、/ready)应用速率限制
- [ ] 当Redis不可用时返回500错误——改为静默降级
- [ ] 阻止通过速率限制的请求,即使Redis返回过期读取
- [ ] 在日志输出中存储完整用户ID(仅保留最后8个字符以保护隐私)
## 成功标准(可衡量)
- 同一用户60秒内100个请求 → 第101个请求返回429
- 请求1-100返回200(或适当响应)
- Retry-After头值精确到±2秒以内
- Redis连接故障 → 请求继续,console.warn每30秒触发一次
- 健康检查端点始终返回200,无论速率限制状态如何
## 需要显式处理的故障模式
- Redis超时:回退到内存,不抛出异常
- Redis对某个键返回null:视为第一次请求(count = 1)
- 请求中缺少用户ID:应用基于IP的回退限制(更严格:20/分钟)
- 并发请求命中同一窗口:使用原子Redis操作(INCR + EXPIRE)
> **什么是原子操作——为什么对并发请求很重要?**
>
> 想象两个请求在同一毫秒从同一用户到达。没有原子操作,两个请求都读取计数器(假设是4),都看到"4 < 5,允许",都将其增加到5。现在计数器是5但两个请求同时通过了——你实际上允许了6个请求而计数器没有反映。
>
> 原子操作意味着Redis在一个不可中断的步骤中完成整个读取-修改-写入。没有其他命令可以在读取和写入之间运行。对于有序集合,将ZADD + ZREMRANGEBYSCORE + ZCARD包装在Redis事务(MULTI/EXEC)中或使用Lua脚本使整个序列原子化——要么三个命令都完成,要么都不完成。
## 实现约束
- 必须使用ioredis(已在项目依赖中)——不要引入redis或ioredis-mock
- 滑动窗口实现:使用Redis有序集合(ZADD + ZREMRANGEBYSCORE + ZCARD)
> **这三个Redis命令是什么——为什么是有序集合?**
>
> 有序集合存储一组唯一成员,每个成员都有一个数字分数。集合始终按分数排序。对于速率限制,每个成员是一个请求时间戳,分数也是该时间戳。
>
> - `ZADD user:123 1717516800 1717516800` — 添加一个成员,分数=时间戳(两者是相同的值——时间戳标识请求并为其排序打分)
> - `ZREMRANGEBYSCORE user:123 0 1717516740` — 移除分数在0到截止值之间的所有成员(即所有超过60秒的请求)
> - `ZCARD user:123` — 计算剩余成员数(即当前窗口内的请求数)
>
> 为什么不用`INCR`的简单计数器?计数器告诉你发生了多少请求。它不能告诉你它们*何时*发生。没有时间戳,你不能移除过期的——你只能在边界重置整个计数器,这就是固定窗口问题。有序集合记住每个时间戳,所以窗口可以连续滑动。
- 内存回退:带有TTL清理的简单Map,不是第三方包
- 测试覆盖:滑动窗口逻辑的单元测试,429响应的集成测试
- 生产代码中不使用console.log — 使用项目日志记录器(import { logger } from '../utils/logger')
这就是一页。47分钟写完。注意它包含了大多数规格省略的内容:故障模式部分、"绝对不能做"的边界,以及——关键的——每个约束后面的原因。这四个部分防止了五个智能体错误中的三个,否则我可能已经发布了。
以下是按部分分解的相同规格——每个部分的作用以及为什么存在:


每个部分服务于不同的目的。Intent在规则没有覆盖情况时给智能体一个指南针。Must Never Do部分使用绝对语言("本模块中任何地方")来防止相对语言("在生产代码中")所允许的分类错误。
3、工具1:Claude Code原生
Claude Code是我每天使用的工具。我了解它的模式。我从这里开始。
设置:
我此项目的CLAUDE.md:
# 项目:API网关服务
## 技术栈
- Node.js 20 + TypeScript 5.3
- Express 4.18
- ioredis 5.3(Redis客户端 — 使用这个,不是'redis'包)
- Jest 29(测试框架)
- 项目日志记录器:import { logger } from '../utils/logger'
- logger.info(), logger.warn(), logger.error() — 生产代码中不使用console.log
## 架构规则
- 中间件放在 /src/middleware/
- 测试并置:middleware.ts → middleware.test.ts
- 所有异步中间件必须使用async/await,不使用回调
- 错误处理:永远不要从中间件throw — 改为调用next(error)
## 必须始终为真的约束
- 零TypeScript错误(严格模式)
- 所有新代码至少有一个单元测试
- Redis操作使用原子模式(MULTI/EXEC或单个原子命令)
我给Claude Code的提示:
阅读 /specs/rate-limiting.md 中的速率限制规格和CLAUDE.md约束。
实现速率限制中间件。在开始编码之前,先告诉我:
1. 你将创建或修改的文件
2. 你计划使用的Redis数据结构
3. 你将如何处理Redis回退
4. 你将编写什么测试
等待我的批准后再实现。
为什么"等待我的批准"——这实际上做了什么?
没有这个指令,智能体读取规格并立即开始生成代码。如果它误解了什么——将"滑动窗口"理解为令牌桶,或计划使用错误的Redis模式——你要在读了200行代码并意识到它是错误的时候才发现。
有了审批门,你在任何代码存在之前的10行计划文本中就能发现。错误计划的成本是几秒。错误代码的成本是阅读、理解和重新运行智能体的时间。这个指令将"在输出中捕获错误"转换为"在输出之前捕获错误。"
那个"不要开始编码直到"的指令是我的SDD工作流中最重要的补充。我从本文第一版犯的一个错误中学到了这一点——智能体在我确认它对"滑动窗口"的解释之前就开始生成代码了。按照标准定义它是正确的。对我的用例来说是错误的。
Claude Code编码前说了什么?
文件:/src/middleware/rateLimiter.ts、/src/middleware/rateLimiter.test.ts Redis结构:使用ZADD和ZREMRANGEBYSCORE的有序集合 回退:带有定期清理的Map 测试:窗口边界测试、并发请求测试、Redis故障测试
我批准了。Claude Code在四分钟内生成了实现。
违规1——我预期的那个:
内存回退使用setInterval进行清理,而不是惰性TTL检查。这在无服务器环境中造成了内存管理问题,因为interval永远不会触发。规格说"带有TTL清理的简单Map"——我的措辞有歧义。"TTL清理"没有指定清理何时运行。
智能体选择了基于interval的,因为这是最常见的模式。它没有错——它在我造成的歧义中做出了合理的选择。

规格修复:
## 实现约束(更新后)
- 内存回退:使用惰性TTL — 每次读取时检查条目是否已过期,
如果是则删除。不要使用setInterval。原因:无服务器环境可能不会在
调用之间维护后台定时器。
> **惰性TTL vs setInterval — 有什么区别?**
>
> `setInterval`每N秒运行一个清理函数:每60秒,扫描内存Map并删除过期条目。这是"急切"清理——它按计划运行。
>
> 惰性TTL意味着:当你*读取*一个条目时,检查它是否已过期。如果是,当时删除并将该请求视为第一个。没有后台定时器。没有计划清理。
>
> 为什么无服务器会破坏`setInterval`?在无服务器环境(AWS Lambda、Vercel Edge Functions、Cloudflare Workers)中,函数实例可能在请求之间被暂停。在一次调用中设置的定时器如果实例在下一个tick之前被冻结可能永远不会触发。惰性TTL没有定时器——它在访问时检查,所以无论实例是持续运行还是刚从冷启动唤醒,它都能正确工作。
一句话。"惰性"这个词和原因。智能体在后续12个不同项目的运行中再也没有犯这个错误。
违规2——让我惊讶的那个:

Claude Code在Redis回退警告中使用了console.warn而不是logger.warn。CLAUDE.md明确说"生产代码中不使用console.log — 使用项目日志记录器"。智能体在实现中的所有日志记录都遵循了这一点——但Redis回退在错误处理程序中,在那里智能体回退到console.warn,因为它将错误处理程序归类为"基础设施代码"而不是"生产代码"。
这是一个分类错误,不是理解错误。智能体理解了规则。它错误地分类了上下文。
规格修复:
## 绝对不能做的事(更新后)
- [ ] 在本模块的任何地方使用console.log、console.warn或console.error。
所有输出通过项目日志记录器,包括错误处理程序和回退路径。
不是"生产代码中不使用console"——这要求智能体分类什么是生产代码。"本模块中任何地方不使用console"是不含糊的。
4、工具2:GitHub Spec Kit
GitHub Spec Kit显著改变了工作流。不是由我写规格然后交给智能体,Spec Kit的/speckit.specify命令通过对话帮助你写规格。你用自然语言描述功能。智能体问澄清问题。规格从对话中产生。
我用这个提示运行了/speckit.specify:
为Express API构建速率限制中间件。滑动窗口算法,
基于Redis带有内存回退,按用户和按端点限制,
429带Retry-After头,排除健康检查端点。
Spec Kit问了我什么?
- "滑动窗口应该使用令牌桶还是漏桶语义,还是严格基于窗口的计数?"(基于窗口的计数——否则我会在规格澄清中遗漏的)
- "当Redis不可用时,回退限制应该与Redis限制相同还是单独的降级限制?"(相同限制——好问题)
- "速率限制状态是否应该在内存回退中跨服务器重启持久化?"(不——这是内存回退,状态是每个实例的)
- "如果请求中缺少用户ID应该怎么办?"(我在规格中指定了基于IP的回退——Spec Kit发现我的自然语言描述中遗漏了这一点)
第四个问题很重要。我的自然语言描述省略了缺少用户ID的情况。Spec Kit的澄清对话在实现运行之前就捕获了它。
对话之后,/speckit.plan生成了结构化的实现计划,/speckit.tasks将其分解为原子任务。/speckit.implement按顺序针对每个任务运行Claude Code,并在任务之间对照规格进行验证。
结果: GitHub Spec Kit产生了比Claude Code原生更干净的输出,没有违规——因为它生成的规格包含了四个澄清问题(我手写规格中回答的那些),加上我指定的Redis ZADD/ZREMRANGEBYSCORE模式。
权衡: Spec Kit对话花了12分钟。从头写我的规格花了47分钟。如果我已知道我想要的细节,手写规格更快。如果我在规格化一个我没有完全想清楚的功能,Spec Kit的澄清对话产生更完整的规格。
5、工具3:BMAD-METHOD
BMAD(AI开发业务方法论)采取最结构化的方法。在写一行规格之前,你与一个"业务分析师"智能体互动,它询问关于业务背景和用户影响的问题,然后是一个"产品经理"智能体,将这些转化为功能需求,然后是一个"技术负责人"智能体,添加架构约束。
对于速率限制中间件,这感觉有点杀鸡用牛刀。BA智能体问我关于用户画像和业务影响。我没有基础设施中间件的用户画像,我有一个工程师和一个API。我发现自己编造答案以到达实现。
BMAD的闪光之处在于具有重大产品背景的功能——理解功能为什么存在会改变如何实现的功能。对于工程师编写的基础设施中间件,BA/PM层增加了摩擦而没有增加价值。
BMAD违规: 技术负责人智能体生成的规格使用redis作为Redis客户端,而不是ioredis。两者都是有效的。我的项目使用ioredis。
为什么使用哪个Redis客户端包很重要?
redis和ioredis是两个不同的npm包,都能连接到Redis——把它们想成两种不同品牌的汽车,都在同样的道路上行驶。它们有不同的API(不同的方法名、不同的promise模式、不同的错误处理),所以为一个写的代码不能与另一个一起工作。
如果你的项目全程使用ioredis而智能体生成使用redis的代码,应用程序会编译但在运行时失败——或者需要第二轮来协调两个不同的客户端接口。规格约束("使用ioredis,不是redis — 原因:已在项目依赖中")通过在智能体开始之前告诉它该在什么路上行驶来防止这种情况。这是上下文无关的规格生成在没有读取你的依赖文件时无法知道的经典第三方工具约束。
教训:
BMAD(像所有Spec Kit变体一样)需要在生成实现约束之前读取你的package.json。工具在这方面正在改进,但目前你必须明确告诉它们你现有的依赖。
6、没人构建的验证层
这里有一个没有其他SDD教程涉及到的发现:我测试的每个工具都产生规格和实现。它们都不会自动验证实现是否满足规格。
这是缺失的层。在跨十多个功能运行SDD之后,我现在为每个项目都构建它。
验证层做什么?
它在智能体完成后、CI之前运行。它检查四件事:
// spec-verifier.ts — 在每个智能体生成的实现之后运行
import { readFileSync } from 'fs';
import * as ts from 'typescript';
interface SpecCheck {
description: string;
check: () => boolean;
failureMessage: string;
}
const spec = readFileSync('./specs/rate-limiting.md', 'utf8');
const checks: SpecCheck[] = [
{
description: '实现中没有console.log/warn/error',
check: () => {
const impl = readFileSync('./src/middleware/rateLimiter.ts', 'utf8');
return !impl.includes('console.log')
&& !impl.includes('console.warn')
&& !impl.includes('console.error');
},
failureMessage: '违规:发现console输出。使用项目日志记录器。',
},
{
description: '使用了ioredis(不是redis包)',
check: () => {
const impl = readFileSync('./src/middleware/rateLimiter.ts', 'utf8');
return impl.includes('ioredis') && !impl.includes("from 'redis'");
},
failureMessage: '违规:错误的Redis客户端。使用ioredis。',
},
{
description: '存在ZADD + ZREMRANGEBYSCORE模式(滑动窗口)',
check: () => {
const impl = readFileSync('./src/middleware/rateLimiter.ts', 'utf8');
return impl.includes('ZADD') || impl.includes('zadd');
},
failureMessage: '违规:滑动窗口未使用有序集合实现。',
},
{
description: '健康检查路径被排除',
check: () => {
const impl = readFileSync('./src/middleware/rateLimiter.ts', 'utf8');
return impl.includes('/health') || impl.includes('healthCheck')
|| impl.includes('skip');
},
failureMessage: '违规:未找到健康检查排除。',
},
{
description: '回退中没有setInterval(需要惰性TTL)',
check: () => {
const impl = readFileSync('./src/middleware/rateLimiter.ts', 'utf8');
return !impl.includes('setInterval');
},
failureMessage: '违规:发现setInterval。使用惰性TTL清理。',
},
];
let passed = 0;
let failed = 0;
checks.forEach(({ description, check, failureMessage }) => {
try {
if (check()) {
console.log(`✓ ${description}`);
passed++;
} else {
console.error(`✗ ${description}`);
console.error(` → ${failureMessage}`);
failed++;
}
} catch (err) {
console.error(`✗ ${description} (error: ${err})`);
failed++;
}
});
console.log(`\n${passed} passed · ${failed} failed`);
if (failed > 0) process.exit(1);
这在200ms内运行。它捕获了我在三个工具中遇到的每次违规。它生成一个清晰的失败消息,可以反馈给智能体进行自我纠正。
CI管道中的补充:
CI(持续集成)是每次代码推送到仓库时运行的自动化过程。通常:运行测试 → 构建项目 → 部署(或拒绝)。"在构建之前运行"意味着规格验证器在代码编译或打包之前触发。如果验证器以错误退出(代码1),整个构建停止——在修复规格违规之前代码不能部署。
这很重要,因为它使规格在结构上可执行。写在markdown文件中的规则可以被忽略。阻止部署管道的规则不能。规格验证器是将"我们的规格说不使用console.log"从指导原则变成关卡的东西。
// package.json scripts
{
"scripts": {
"verify:spec": "ts-node spec-verifier.ts",
"prebuild": "npm run verify:spec"
}
}
智能体不能在不被CI管道阻止的情况下发布违反规格的代码。这是每个当前SDD工具都有的规格到实现验证差距——而这就是你如何在工具赶上之前自己关闭它。
7、完整模板库
复制这些。使用它们。为你的项目更新它们。
模板1:CLAUDE.md(项目级)
# [项目名称] — 智能体上下文
## 技术栈
- [语言 + 版本]
- [框架 + 版本]
- [关键依赖及显式版本]
- 日志记录器:[导入路径和使用模式]
## 架构规则
- [中间件放在哪里]
- [测试并置模式]
- [异步模式]
- [错误处理模式]
## 不可协商的约束
- [ ] 零[语言]错误(严格模式)
- [ ] 所有新代码至少有一个测试
- [ ] [原子操作的具体模式]
- [ ] 任何地方不使用[被禁止的输出方法] — 使用[批准的方法]
## 开始编码之前,始终:
1. 说明你将创建或修改的文件
2. 说明你将使用的算法或数据结构
3. 说明你将如何处理主要故障模式
4. 等待批准
模板2:功能规格
# 功能规格:[功能名称]
# 版本:1.0 · 最后更新:[日期]
# 状态:激活
## 意图
[一段话。这解决了什么问题?为什么解决方案这样工作?]
## 必须始终做到的事
- [ ] [行为1 — 具体的、可测试的、有原因的]
- [ ] [行为2 — 具体的、可测试的、有原因的]
## 绝对不能做的事
- [ ] [边界1 — 有原因说明为什么这个边界很重要]
- [ ] [边界2 — 有原因说明为什么这个边界很重要]
## 成功标准(可衡量)
- [输入X] → [输出Y,可衡量的]
- [故障条件] → [具体的降级行为]
## 故障模式(显式处理)
- [基础设施故障]:[具体响应]
- [缺少输入]:[具体的回退行为]
- [并发访问]:[具体机制]
## 实现约束
- 使用[特定库/模式] — 不使用[常见替代]
原因:[为什么这个约束存在]
- [测试覆盖要求]
- [日志记录要求 — 不模糊地说明使用什么]
模板3:规格验证器(为每个"绝对不能做"的约束添加一个检查)
// spec-verifier.ts
// 为规格中每个"绝对不能做"添加一个检查。
// 在构建之前运行。将失败反馈给智能体。
const checks: SpecCheck[] = [
{
description: '[规格约束描述]',
check: () => {
const impl = readFileSync('./src/[实现路径]', 'utf8');
return /* 检查约束是否满足 */;
},
failureMessage: '违规:[什么被违反了]。修复:[用什么代替]。',
},
// 为每个"绝对不能做"约束添加一个检查
];
模板4:Claude Code的提示
阅读 [规格文件路径] 和 [CLAUDE.md路径]。
在生成任何代码之前,告诉我:
1. 你将创建或修改的文件
2. 主要算法或数据结构
3. 你将如何处理[规格中最关键的故障模式]
4. 你将编写的测试
在我确认你的计划之前不要开始编码。

8、改变我SDD工作流的两件事
第一:审批门。 "不要开始编码直到你告诉我你的计划并且我确认它。"这个单一指令防止了最昂贵的一类SDD错误——智能体误解规格中的歧义并自信地向错误方向构建20分钟。计划审查需要两分钟。它节省了二十分钟。
第二:规格验证器。 规格中每个"绝对不能做"成为验证器中的一个检查。验证器在构建之前运行。智能体不能发布违反规格的代码。这关闭了每个当前SDD工具都留开的差距——拥有规格和知道实现是否满足规格之间的差距。
规格不是治理,除非它被强制执行。测试强制执行行为。验证器强制执行规格。两者都需要。
9、我在原版SDD文章中做错的地方
原版SDD文章将写规格视为困难的部分,将实现视为智能体处理的部分。这基本正确。但我低估了两件事。
规格需要原因,不只是规则。"不使用console.log"是规则。"本模块中任何地方不使用console — 使用项目日志记录器,因为生产日志聚合不捕获console输出"是带原因的规则。智能体在第一个版本不覆盖的上下文中应用第二个版本,因为它可以使用规则背后的目的来推理新情况。
实现需要验证。没有验证器的规格是没有强制执行的合同。智能体会漂移。不是因为它不理解——因为规格有歧义,上下文有缺口,智能体犯分类错误。验证器是使SDD达到生产级而非演示级的层。
这两个补充——规格中的原因、实现后的验证器——是本文对原版的添加。
10、可复用包
本文中的所有内容都作为起点可用:
- CLAUDE.md模板 — 复制,填入你的技术栈,立即使用。
- 功能规格模板 — 复制,填入你的功能,与任何SDD工具一起使用。
- spec-verifier.ts — 复制,为每个"绝对不能做"约束添加一个检查,添加到你的CI管道。
- 审批门提示 — 复制,在每个智能体任务之前粘贴,每个功能节省20分钟。
规格是合同。验证器是执行。审批门是工作开始前的质量检查。它们共同构成了从教程中有效的SDD到生产中有效的SDD所需的一切。
原文链接:I Built a Real Feature Using SDD Across Three Tools.
汇智网翻译整理,转载请标明出处