SDD 如何落地:从标准文档到执行循环

Spec-Driven Development 不是一个新概念,但它过去一直有个很现实的问题: 太贵。
贵的不是文档本身,而是这几件事叠加起来的成本:
- 需求要整理
- 设计要展开
- 任务要拆分
- 验收要写清楚
- 一旦需求变动,前面所有文档还要一起更新
这也是为什么很多团队最后只剩两种状态: 要么不写 Spec,要么写了但没人维护。
大语言模型真正改变的,不是“替代工程判断”,而是显著降低了把模糊意图转换成结构化文档的成本,也降低了文档之间持续对齐的成本。正因为这件事变便宜了,SDD 才第一次对大多数团队变得现实可行。
这篇文章我想按三个问题来讲清楚:
- 一套可执行的 SDD,到底需要固定哪些关键信息
- 这些信息如何在人和大语言模型协作下形成文档
- 当文档稳定之后,流程如何真正运行起来
为了让讨论保持具体,下面仍然用一个最小例子贯穿全文: Todo Reminder。
第一部分:一套可执行的 SDD,需要先固定哪些信息
一套 SDD 要真正跑起来,至少要先回答四个问题:
- 我们到底要做什么
- 我们准备怎么实现
- 我们准备按什么顺序交付
- 我们怎么判断它真的做对了
如果这四个问题没有被稳定记录下来,后面的实现、测试和修复就没有共同参照物。
也正因为如此,很多看起来不同的工程实践,最后都会自然收敛到四类核心信息:
- 目标和边界
- 结构和设计
- 任务和顺序
- 验收和案例
在工程实践里,这四类信息通常会分别落成下面四份文档:
requirements.mddesign.mdtasks.mdcases.md
这四份文档刚好对应了前面那四个问题:
- 我们要解决什么问题
- 我们准备怎么实现
- 我们准备按什么顺序交付
- 我们怎么判断它真的做对了
它们之间的关系可以压缩成一条非常短的链路:
requirements -> design -> tasks -> cases
1. requirements.md:定义问题,而不是定义代码
requirements.md 的作用,是把“想做什么”说清楚。
它应该回答四类问题:
- Goal: 功能目标是什么
- Requirements: 必须满足哪些行为
- Constraints: 约束条件是什么
- Non-Goals: 明确不做什么
比如在 Todo Reminder 这个例子里,它可以很简单:
# Feature: Todo Reminder
## Goal
Remind users of tasks before their due time.
## Requirements
- System must store tasks
- Each task must support a due datetime
- System must trigger a reminder before due time
- Completed tasks must not trigger reminders
## Constraints
- Use in-memory storage
- Single-process execution
- Timezone = UTC
## Non-Goals
- No UI
- No persistence
- No external notification service
这一层最重要的价值,不是写得长,而是把边界钉死。边界一旦不清楚,后面的设计、任务和测试都会漂移。
2. design.md:把需求翻译成结构
如果说 requirements.md 解决的是“做什么”,那 design.md 解决的是“怎么落到结构上”。
最小设计文档通常只需要包含:
- 核心组件
- 数据模型
- 接口定义
- 关键行为规则
例如:
# Design: Todo Reminder
## Architecture
- TaskStore
- Scheduler
- Notifier
## Data Model
Task:
- id: string
- title: string
- due_at: datetime
- completed: boolean
## Interfaces
TaskStore.add(task)
TaskStore.list()
Scheduler.run(current_time)
Notifier.notify(task)
一个好的最小设计文档,不需要面面俱到,但必须让后续实现不再依赖“脑补”。
3. tasks.md:把设计拆成可执行单元
tasks.md 是从“结构”走向“交付”的桥。
它至少要做两件事:
- 把实现拆成可执行任务
- 给出任务之间的依赖关系
例如:
# Tasks
- [ ] T1: Implement Task model
- [ ] T2: Implement TaskStore
- [ ] T3: Implement Scheduler logic
- [ ] T4: Implement Notifier
- [ ] T5: Wire components together
## Dependency
- T2 depends on T1
- T3 depends on T2
- T5 depends on T3, T4
没有这份文档,开发很容易变成“想到哪写到哪”。而一旦要把执行交给 Agent,tasks.md 更是必要,因为 Agent 需要一个足够清晰的最小工作单元。
4. cases.md:把验收条件写成事实
cases.md 对应的是“怎么验证”。
它的最佳写法通常不是测试框架代码,而是 Given / When / Then 这种行为描述:
# Cases
## Case: Reminder triggers before due
- Given a task with due_at in 30 seconds
- When scheduler runs
- Then reminder is triggered
## Case: Completed task ignored
- Given a completed task
- When scheduler runs
- Then no reminder is triggered
这份文档的意义在于,它把“验收标准”从人的感觉变成了可转译、可执行、可回归的资产。后面无论你要人工写测试,还是让 LLM 自动生成测试,都会以它为基础。
这四份文档为什么刚好够用
很多人做 SDD 时,第一步就把系统做重了。其实在大多数功能开发里,真正必须稳定存在的,往往就是这四份:
requirements.md决定范围design.md决定结构tasks.md决定执行顺序cases.md决定验收真值
只要这四份文档是对齐的,一个最小闭环就已经成立:
Spec -> Tasks -> Code -> Test -> Feedback
第二部分:这四份文档是如何生成的
理解了文档本身之后,真正关键的问题是: 这四份文档到底怎么来?
如果答案是“全部靠人手写”,那么 SDD 在多数团队里还是太贵。如果答案是“全部交给模型自动写”,那最后大概率会得到一套看起来完整、实际上不可信的文档。
更实际的方式是: 由人给出目标、约束和判断,由大语言模型负责展开、结构化、补齐和对齐。
也就是说,这不是一个“人工 or 自动”的二选一问题,而是一个职责分工问题。
Step 0:先由人提供原始输入
在生成四份文档之前,必须先有一层人类输入。通常包括:
- 业务目标
- 用户场景
- 关键约束
- 风险点
- 明确不做的范围
这一层不能省,因为它决定了整个系统的方向。模型可以帮你整理,但不能替你决定产品目标和业务优先级。
Step 1:先生成 requirements.md
这是最适合“人提供原始材料,模型负责整理”的环节。
典型流程是:
- 人先写一个粗糙需求描述
- LLM 把它整理成 Goal / Requirements / Constraints / Non-Goals
- 人再对照业务目标做确认和删减
在这个环节里,职责大致可以这样分:
- 人负责:
- 决定目标是否正确
- 决定约束是否真实
- 决定哪些需求不该做
- LLM 负责:
- 把自然语言整理成结构化文档
- 发现缺失项和歧义项
- 把含糊表达改写得更可执行
换句话说,LLM 在这里不是“拍板的人”,而是“把模糊意图加工成工程输入的人”。
Step 2:基于 requirements.md 生成 design.md
当需求稳定后,下一步是设计。
这里的典型流程是:
- 把
requirements.md作为输入给模型 - 让模型先产出一个候选设计草稿
- 人审查架构、数据边界、接口分层和明显的过度设计
- 定稿为
design.md
这个环节里,人和模型的分工会发生一点变化:
- 人负责:
- 判断架构是否合理
- 判断复杂度是否过高
- 决定哪些设计取舍符合团队现实
- LLM 负责:
- 把需求展开成组件、接口和数据模型
- 提供多个设计备选方案
- 帮助补全遗漏的连接关系
所以 LLM 在这里更像一个“初级架构草稿生成器 + 结构化讨论助手”。
Step 3:基于 design.md 生成 tasks.md
文档走到这一步,本质上是在做任务分解。
这个环节特别适合 LLM,因为任务拆分本身就是一种从结构到步骤的映射:
- 人提供
design.md - LLM 根据组件和依赖关系拆出任务列表
- 人确认粒度是否合适、执行顺序是否合理、是否需要合并或拆细
- 输出
tasks.md
这里的职责大致是:
- 人负责:
- 决定任务粒度是否适合团队节奏
- 决定是否需要串行或并行
- 决定哪些任务必须人工处理
- LLM 负责:
- 做初步任务分解
- 标注依赖关系
- 让任务从“设计描述”变成“可执行清单”
如果没有 LLM,这一步通常很费时间;有了 LLM,这一步会明显加快。
Step 4:基于 requirements.md 和 design.md 生成 cases.md
cases.md 的来源,不应该只是设计,也不应该只是需求,而应该同时参考两者。
因为它既要覆盖业务行为,也要符合实现结构。
这里的流程通常是:
- 人给出核心成功路径和关键风险点
- LLM 根据 requirements 和 design 枚举 Cases
- 人审核哪些 Case 是真正必须保留的
- 定稿为
cases.md
这个环节里:
- 人负责:
- 决定什么才算“真的通过”
- 决定边界案例和高风险场景
- 决定哪些 Case 不值得维护
- LLM 负责:
- 从需求和设计中提取行为路径
- 生成 Given / When / Then 形式的案例
- 补出被人遗漏的常见边界条件
大语言模型在这四份文档里,到底扮演什么角色
如果只说一句话,我会把 LLM 的角色概括成四个词:
整理者 / 展开者 / 对齐者 / 加速器
更具体一点,它主要做的是这些事:
- 把松散需求整理成结构化规范
- 把高层目标展开成设计和任务
- 把文档之间的缺口暴露出来
- 在文档变更时,帮助同步更新相关文档
它不应该做的事也很明确:
- 不替人决定业务目标
- 不替人拍板关键架构取舍
- 不替人定义最终验收真相
也正是因为这个角色划分成立,SDD 才开始真正可行。因为过去最贵的,其实不是“想明白”,而是“把想明白的东西持续写成文档并保持一致”。LLM 把这部分成本打下来了。
为什么说没有 LLM,SDD 很难在大多数团队里落地
因为 SDD 最难的从来不是理念,而是维护成本。
每当需求变化时,你都要连锁更新:
requirements.mddesign.mdtasks.mdcases.md
如果这件事全靠人手工做,大部分团队最终都会退回到口头同步。LLM 的价值就在这里: 它让“重新生成、重新整理、重新对齐”变成一件低成本动作。
所以更准确地说,不是 LLM 发明了 SDD,而是 LLM 让 SDD 从“少数团队能坚持的重流程”变成“大多数团队也能尝试的轻流程”。
第三部分:SDD 如何真正实施和运行起来
如果要把这套 SDD 真正实施起来,最合适的方式是让它围绕 task 进入一个持续验证、持续修复的执行循环。
先看整体流程
先不要引入 Agent。假设这整套流程暂时都由人来完成,它应该长成下面这样:
从 tasks.md 取当前 task
-> 装载相关 requirements / design / cases / code
-> 实现或修复代码
-> 检查这些 cases 是否已经有对应测试
-> 只为缺失或受影响的 cases 生成或更新测试脚本
-> 调用 Test Runner 执行测试
-> 汇总失败结果
-> 对照 requirements / design / cases 判断失败来源
-> 通过: 标记 task 完成,进入下一个 task
-> 实现与 Spec 不一致: 继续修复代码
-> requirements / design / cases 不一致: 回到文档层,由人处理
也就是说,有了 requirements.md、design.md、tasks.md、cases.md 之后,系统就不再停留在写文档,而是围绕一个个 task 进入执行循环。
这个流程里最关键的是,每一轮都只围绕当前 task 运转,并且每轮都要经过测试验证。
顺着上面的流程图往下看,实际只有几件事在发生:
- 先从
tasks.md中取出当前 task - 围绕这个 task 装载相关的 requirements、design、cases 和代码上下文
- 实现代码
- 验证相关 Cases 是否已经被测试覆盖
- 只补充缺失的测试,或更新受影响的测试
- 执行测试并汇总结果
- 对照 requirements、design、cases 判断失败来源
- 通过就进入下一个 task,失败就回到修复,必要时回到 Spec
这里有两个边界需要特别明确:
- 测试不是每一轮都从头生成,而是优先复用已有测试,只补缺失部分
- 测试失败之后,不是立刻重新生成整套流程,而是先做失败汇总,再决定是修代码还是回到文档层
哪些环节适合由 Agent 来完成
当整体流程清楚以后,下一步才是判断:这里面哪些工作适合交给 Agent。
对编码任务来说,更合适的做法通常不是把流程拆成很多 Agent,而是保留一个确定性的执行循环,再配三个核心 Agent:
Loading Agent- 负责为当前 task 装载相关上下文
Coding Agent- 负责实现代码,或者根据失败结果继续修复
Verification Agent- 负责验证当前 task,包括检查测试覆盖、补齐缺失测试、调用 runner、汇总结果,并给出验证结论
而 task 的推进和结果记录这些动作,更适合放在一个确定性的 Task Loop 或 Harness 里完成。
其中 Test Runner 仍然应该保持为确定性的执行组件,比如 pytest。它负责给出稳定结果,可以由 Verification Agent 调用。
这样分工之后,主循环会比较稳定:
Task Loop负责推进流程Loading Agent负责装载上下文Coding Agent负责实现Verification Agent负责验证和归因
如果后面系统规模继续变大,可以再补一个后台的 Doc Maintenance Agent,专门扫描 requirements、design、cases 的陈旧或漂移问题。但它不属于主循环。
每个 Agent 是怎么工作的
把上面的流程映射到角色上,可以把核心角色的工作方式拆得更具体一点。
Task Loop / Harness
Task Loop 本身不是一个 Agent,而是整个流程的确定性骨架。
它负责:
- 从
tasks.md中取当前 task - 调用
Loading Agent - 调用
Coding Agent - 调用
Verification Agent - 根据验证结论决定是进入下一个 task,还是继续修复,还是回到文档层
它的价值在于把流程控制保持为确定性逻辑,而不是把所有步骤都交给模型自由决定。
Loading Agent
Loading Agent 负责把当前 task 真正需要的信息装配成一个可直接使用的上下文包。
它通常按这个顺序工作:
- 读取当前 task
- 根据 task 上的显式关联,找到相关的 requirements、design、cases
- 读取涉及的代码文件、接口定义和必要上下文
- 把这些信息整理成统一的 context bundle
- 把 context bundle 分发给
Coding Agent和Verification Agent
这里最关键的一点是: Loading Agent 不是“猜哪些内容相关”,而是读取事先建立好的关联。
实践里最稳的方式通常是:
- 在
tasks.md里给 task 标出关联的 requirement、design、case - 或者由前置步骤生成一个简单的 task-to-context manifest
只有这样,后面的执行循环才是可重复、可追踪的。
Coding Agent
Coding Agent 负责围绕当前 task 实现代码,或者在失败后继续修复。
它通常按这个顺序工作:
- 接收
Loading Agent提供的 context bundle - 判断这一轮需要改哪些文件、哪些函数
- 在 design 的约束下实现代码
- 测试失败后,读取失败 summary
- 只在当前 task 的范围内继续修复
它最重要的边界也很明确:
- 只处理当前 task
- 不扩散到无关模块
- 不擅自修改 requirements 和 design
- 修复时优先根据测试失败来收缩问题范围
Verification Agent
Verification Agent 负责当前 task 的验证阶段,承担一个完整的验证闭环。
它通常会按下面这个顺序工作:
- 接收
Loading Agent提供的 Cases、代码状态和上下文 - 检查这些 Cases 是否已经有对应测试
- 只为缺失的 Cases 生成测试,或者更新明显失效的测试
- 调用
Test Runner - 把失败结果整理成一个简短、结构化的 summary
- 给出这一轮的验证结论
Verification Agent 的职责可以直接概括成四件事:
- 判断是否需要生成测试
- 生成或更新测试脚本
- 执行测试并整理结果
- 输出当前 task 的验证结论
在转换时,最基本的规则就是:
- Given -> 初始化数据
- When -> 执行行为
- Then -> 写断言
例如这条 Case:
## Case: Completed task ignored
- Given a completed task
- When scheduler runs
- Then no reminder is triggered
会被转成下面这类测试结构:
- 被测对象是
Scheduler - Given: 创建一个
completed=True的Task - When: 调用
scheduler.run(...) - Then: 断言提醒没有被触发
但在真正运行时,Verification Agent 不会每次都重新生成这段测试。更合理的做法是:
- 如果这个 Case 已经有稳定测试,就直接复用
- 如果这个 Case 是新增的,就补一个新测试
- 如果代码结构变化导致旧测试失效,再更新对应测试
这样整个系统才不会在每一轮里重复生成同一批脚本。
而在调用 Test Runner 之后,它还要继续做两件事: 先整理结果,再输出验证结论。比如把冗长日志压缩成:
FAILURE SUMMARY:
- test_completed_task_ignored failed
ERROR:
Expected 0 call, got 1
这样 Coding Agent 收到的就不再是一整段噪音日志,而是一个可以直接用于修复的输入;而 Task Loop / Harness 收到的则是“继续修复”或“回到文档层”的信号。
这一部分的本质是什么
这一部分真正想说明的是:四份文档先把目标、结构、任务和验收条件固定下来,然后执行循环按 task 推进;代码实现之后,验证环节再决定是进入下一个 task,还是回到修复,或者回到文档层。这样,SDD 才真正从“写文档”变成“驱动交付”。
总结
如果把整篇文章压成一句话,我会这样概括:
SDD 的核心,是让四份文档成为一套可以持续驱动实现、测试和修复的控制系统。
这套系统里:
- 人负责目标、约束、判断和取舍
- LLM 负责整理、展开、补齐和对齐
- 测试负责判断结果是否成立
于是,SDD 才从一种理想化流程,变成一种真正能跑起来的工程方法。