AI 规格驱动编程入门:Spec 如何成为人和 Agent 的共同契约
一个让我反复遇到的问题
最近几个月我在日常开发中频繁使用 AI Agent 处理功能实现、bug 修复和重构任务。一个反复出现的模式是:Agent 经常把需求扩展到我没要求的地方,同时也遗漏了我默认它会处理的部分。Agent 的代码生成能力不差,但它不知道项目里哪些模块归属哪一层、哪些边界不能跨、错误应该在哪个层级处理。同一个功能需求,不给 Spec 和给一份结构清晰的 Spec,Agent 的交付方向可能完全不同。
Spec 这个词在软件工程里有漫长的历史,但在 AI 编程场景下它有了新含义:一份版本受控的自然语言文档,用来向 Agent 表达目标、边界、数据流、异常处理和验收标准。它不是归档文档,而是人和 Agent 之间的协作契约。
为什么 Spec 对 AI Agent 格外重要
传统开发中,需求模糊了可以找人确认、开会对齐、看代码猜意图。这些沟通方式对 Agent 都不可用。Agent 不会主动问「这里你说的是行内错误提示还是全局 Toast?」,它会按训练数据中概率最高的模式补齐模糊部分。补齐的结果可能编译通过、功能看起来正常,但和项目实际约定不一致。
GitHub 的工程博客在介绍 Spec Kit 时提到一个判断:AI 编程的瓶颈正在从「怎么实现」转向「要做什么、不做什么」。Vibe Coding(随手写 prompt 让 Agent 生成代码)适合原型验证,但对需要稳定维护的项目,这种方式交付的代码往往需要大量返工。Spec-Driven 的方式要求在写代码之前,先用结构化文档把意图表达清楚。Agent 从「执行一段 prompt」变成「按契约交付」。
Spec 驱动开发的三个成熟度
Martin Fowler 在 2025 年的文章中把 SDD(Spec-Driven Development)分成三个等级,帮助团队判断当前实践处在哪个阶段。
| 等级 | 含义 | Spec 的生命周期 | 典型场景 |
|---|---|---|---|
| Spec-first | Spec 写完指导当次实现,完成后不再维护 | 一次性 | 独立功能开发、一次性脚本 |
| Spec-anchored | Spec 持续维护,伴随功能演进 | 长期维护 | 多人协作模块、持续迭代的功能 |
| Spec-as-source | Spec 是唯一真相源,人只改 Spec,Agent 维护代码 | 永久 | 高度标准化的生成场景 |
Fowler 同时指出,当前工具(Kiro、Spec Kit、Tessl)主要还在 Spec-first 层面运作,Spec-anchored 需要团队配合维护,Spec-as-source 目前受限于 Agent 的非确定性,还难以稳定落地。
这个分级对我最大的帮助是:不需要一步到位。从 Spec-first 开始,在跨模块、高风险任务中逐步升级到 Spec-anchored,是更务实的路径。
三个实际案例
案例一:搜索组件的错误态丢失
我给 Agent 的任务是「给首页 Header 加一个搜索框,对接搜索 API」。Agent 用不到 30 秒完成了:搜索框渲染正常、API 对接正确、loading 态也有。但我在测试时发现,接口返回 500 时页面直接白屏。
Agent 没有处理搜索失败的场景。我追加一句「加个错误处理」,它很快补上了——但用的是 try/catch 包在组件里,错误时渲染了一个 alert()。搜索失败应该展示行内错误提示,不应该弹全局弹窗,也不应该让 loading 态一直转。这些判断我没写,Agent 无从得知。
修复方式:把错误处理策略写进 Spec,明确 API 层和 UI 层各自负责什么。
// ❌ 没有 Spec 约束时,Agent 容易在 UI 层就地处理
// 问题:错误映射和业务组件混在一起,换个页面要重写一遍
async function SearchHeader() {
const [results, setResults] = useState([])
const [error, setError] = useState<string | null>(null)
const handleSearch = async (query: string) => {
try {
// 直接在组件里调用 API、处理错误、映射状态
const res = await fetch(`/api/search?q=${query}`)
if (!res.ok) {
setError('搜索失败,请重试') // 硬编码文案,无法复用
return
}
const data = await res.json()
setResults(data.results)
} catch (e) {
setError('网络异常') // 所有错误一视同仁,无法区分
}
}
}// ✅ 有 Spec 约束后,按 Spec 的层级划分处理
// api/search.ts — API 层统一映射错误对象
// Spec 约束:「API 层负责错误映射,UI 层只消费稳定状态」
export type SearchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; results: SearchResult[] }
| { status: 'error'; message: string }
export async function search(query: string): Promise<SearchState> {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
if (!res.ok) return { status: 'error', message: '搜索暂时不可用' }
const data = await res.json()
return { status: 'success', results: data.results }
} catch {
return { status: 'error', message: '网络连接失败' }
}
}// ui/SearchHeader.tsx — UI 层只消费状态,不做错误判断
// 注释说明差异:组件不再处理 API 细节,只根据 SearchState 渲染对应 UI
export function SearchHeader() {
const [state, setState] = useState<SearchState>({ status: 'idle' })
const handleSearch = async (query: string) => {
setState({ status: 'loading' })
setState(await search(query))
}
if (state.status === 'loading') return <SearchSkeleton />
if (state.status === 'error') return <InlineError message={state.message} />
if (state.status === 'success' && state.results.length === 0) {
return <EmptyHint />
}
if (state.status === 'success') return <SearchResults items={state.results} />
return <SearchInput onSearch={handleSearch} />
}差异说明:没有 Spec 时,Agent 把 API 调用、错误映射、UI 渲染全堆在一个组件里。有 Spec 约束后,API 层负责错误映射,UI 层只消费稳定的状态对象。换页面时复用 search() 函数即可,不需要重写错误处理逻辑。
案例二:权限控制散落三层
我让 Agent 给管理后台加权限控制:「只有管理员能看到删除按钮」。Agent 分别在三个地方做了权限判断:UI 层根据 user.role === 'admin' 控制按钮渲染,API 路由里用中间件检查 req.user.role,数据获取函数里又加了一层 if (!isAdmin) throw new Error()。三个判断逻辑不完全一致,后续改角色模型时要改三处。
Agent 不了解项目的架构约定:权限校验统一在 API 层,UI 层只消费权限状态。这个约束没有写在代码里(靠口头传承和 code review 维持),Agent 自然无从得知。
修复方式:在 Spec 中标注权限归属和消费方式。
// ❌ 没有 Spec 约束时,Agent 在每个文件里各自判断
// 问题:同一个权限逻辑出现三次,角色模型变更时容易漏改
// components/DeleteButton.tsx
function DeleteButton({ user }: { user: User }) {
if (user.role !== 'admin') return null // UI 层自己判断角色
return <Button onClick={handleDelete}>删除</Button>
}
// api/posts/[id].ts
export async function DELETE(req: Request) {
if (req.user.role !== 'admin') { // API 层再判断一次
return new Response('Forbidden', { status: 403 })
}
// ...
}
// lib/getPosts.ts
export async function getPosts(user: User) {
if (user.role !== 'admin') throw new Error('No permission') // 数据层又判断一次
return db.posts.findMany()
}// ✅ 有 Spec 约束后,权限归属清晰
// lib/permissions.ts — 权限判断集中在一处
// Spec 约束:「权限校验在 API 层,UI 层只读权限状态」
// 注释说明差异:所有角色判断逻辑集中管理,角色模型变更时只改这一处
export function canDelete(user: User, resource: Resource): boolean {
return user.role === 'admin' || resource.ownerId === user.id
}
export function canEdit(user: User, resource: Resource): boolean {
return user.role === 'admin' || resource.ownerId === user.id
}
// api/posts/[id].ts — API 层调用统一的权限函数
import { canDelete } from '@/lib/permissions'
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
const resource = await db.posts.findUnique({ where: { id: params.id } })
if (!resource || !canDelete(req.user, resource)) {
return new Response('Forbidden', { status: 403 })
}
await db.posts.delete({ where: { id: params.id } })
return new Response(null, { status: 204 })
}
// components/DeleteButton.tsx — UI 层只消费权限结果
// 不再自己判断角色,而是从 API 获取 canDelete 状态
function DeleteButton({ canDelete }: { canDelete: boolean }) {
if (!canDelete) return null
return <Button onClick={handleDelete}>删除</Button>
}差异说明:没有 Spec 时,权限判断散落在三层,角色模型变更时需要全局搜索替换。有 Spec 约束后,lib/permissions.ts 是权限逻辑的唯一来源,API 层调用它做校验,UI 层消费它返回的状态。改一处,全链路生效。
案例三:数据迁移缺少向后兼容
我让 Agent 给文章模型加一个 status 字段:「把 isPublished 布尔值换成 status 枚举」。Agent 改了类型定义、更新了所有查询、删除了 isPublished 字段。代码编译通过,单元测试也过了。但我一部署就发现:旧文章的 status 字段全是 undefined,因为数据库里没有对应的迁移逻辑。
Agent 把字段替换当成纯代码修改,没有考虑生产数据兼容。这不是 Agent 的错——我没有在任务描述中提到迁移策略。
修复方式:在 Spec 中明确迁移约束——新字段和旧字段共存、兼容层处理旧数据、后续版本再清理。
// ❌ 没有 Spec 约束时,Agent 直接替换字段
// 问题:旧数据没有 status 字段,读取时全部 undefined,线上崩溃
// types/post.ts
interface Post {
id: string
title: string
// isPublished 被直接删除
status: 'draft' | 'published' | 'archived'
}
// lib/getPosts.ts
export async function getPosts(): Promise<Post[]> {
return db.posts.findMany()
// 旧记录:{ id: "1", title: "Hello", isPublished: true }
// 读取 post.status → undefined,下游代码全部异常
}// ✅ 有 Spec 约束后,增加兼容层
// Spec 约束:「字段变更必须向后兼容,新旧字段共存过渡」
// 注释说明差异:新增字段而非替换,旧数据通过兼容映射保持稳定
// types/post.ts
interface Post {
id: string
title: string
isPublished: boolean // 保留旧字段,标记为 @deprecated
status: 'draft' | 'published' | 'archived' // 新增字段
}
// lib/post-compat.ts — 兼容层处理旧数据
// Spec 约束:「迁移期间,读取时自动映射旧字段到新字段」
export function normalizePost(raw: DbRecord): Post {
// 旧数据没有 status 字段时,从 isPublished 推导
const status = raw.status ?? (raw.isPublished ? 'published' : 'draft')
return { ...raw, status }
}
// lib/getPosts.ts
export async function getPosts(): Promise<Post[]> {
const records = await db.posts.findMany()
return records.map(normalizePost)
}
// migrations/add-post-status.sql
// Spec 约束:「数据库变更必须有对应迁移脚本」
ALTER TABLE posts ADD COLUMN status VARCHAR(20) DEFAULT 'draft';
UPDATE posts SET status = 'published' WHERE is_published = true;
UPDATE posts SET status = 'draft' WHERE is_published = false;差异说明:没有 Spec 时,Agent 直接删除旧字段、加新字段,代码逻辑正确但线上旧数据全部丢失状态。有 Spec 约束后,新旧字段共存、兼容层自动映射、迁移脚本补数据。线上零事故,后续版本再清理旧字段。
从 Spec 到交付的完整流程
对比:不同阶段的 Spec 策略
从 Prompt-only 到 Spec-Driven 的演进
| 维度 | Prompt-only | 简单 Spec | 完整 Spec |
|---|---|---|---|
| 信息载体 | 口头描述或一行指令 | 包含目标和设计 | 目标 + 边界 + 任务 + 验收 |
| Agent 理解度 | 靠猜测补齐缺失上下文 | 知道要做什么 | 知道做什么、不做什么、怎么验证 |
| 返工率 | 高(方向偏差频繁) | 中(细节仍会猜错) | 低(边界和验收都锁定) |
| 适用场景 | 一次性脚本、UI 微调 | 单文件功能、简单 bug 修复 | 跨模块功能、架构变更、高风险任务 |
| 前置成本 | 几乎为零 | 10-15 分钟 | 30-60 分钟 |
| 迭代方式 | 每次推倒重来 | 按 Spec 局部修正 | 按任务步骤逐步推进 |
不同任务风险对应的 Spec 详细程度
| 任务风险 | 典型场景 | Spec 需要包含的内容 | 预估书写时间 |
|---|---|---|---|
| 低风险 | 修文案、调颜色、单文件 bug | Problem + Design + Acceptance | 5 分钟 |
| 中风险 | 新 API 端点、新 UI 组件 | 加上 Non-goals + 数据流 + 错误处理 | 15 分钟 |
| 高风险 | 跨模块重构、数据库变更 | 加上迁移策略 + 回滚方案 + 权限归属 | 30 分钟 |
| AI/Agent 任务 | Prompt 变更、Agent 行为调整 | 加上 eval 样例 + 失败分类 + 人工兜底 | 30 分钟 |
Spec 的成熟度对比
| 维度 | Spec-first | Spec-anchored | Spec-as-source |
|---|---|---|---|
| 谁写 Spec | 开发者 | 开发者 + 团队维护 | 开发者定义,Agent 辅助演进 |
| Spec 更新时机 | 实现前写,完成后不再维护 | 功能演进时同步更新 | Spec 即代码,改 Spec 自动触发代码变更 |
| 适合团队 | 个人项目、小团队 | 多人协作、长期维护的项目 | 高度标准化的生成场景 |
| 当前工具支持 | Kiro、Claude Code、Cursor | 需要团队约定 + CI 检查 | 实验阶段,Tessl 在探索 |
| 维护成本 | 低 | 中(需要同步更新) | 高(需要自动化管道) |
工具对比
| 维度 | Kiro | GitHub Spec Kit | OpenSpec |
|---|---|---|---|
| 核心工作流 | Requirements → Design → Tasks | Constitution → Specify → Plan → Tasks | Explore → Spec → Change → Apply |
| Spec 格式 | 三个 Markdown 文件 | 模板驱动,多文件输出 | 按变更组织,支持归档 |
| 定制程度 | 低(轻量开箱即用) | 高(CLI + 模板可配置) | 中(约定优于配置) |
| 适合场景 | 快速验证、个人项目 | 团队标准化流程 | 变更管理、长期规格维护 |
好的 Spec 长什么样
一份适合 AI 执行的 Spec 需要通过以下检查。每个检查项都对应一个常见的翻车模式:
| 检查项 | 缺失时 Agent 的表现 | 修复方式 |
|---|---|---|
| 写了 Non-goals | 顺手重构无关模块 | 显式列出「不做什么」 |
| 标明模块依赖 | 在错误的层级处理问题 | 写明入口、依赖方向、边界归属 |
| 验收可执行 | 交付只剩主观描述 | 附测试命令、预期输出或截图标准 |
| 覆盖异常态 | 只有 happy path | 列出错误场景和对应处理方式 |
| 提到迁移策略 | 旧数据直接丢失 | 高风险变更写明兼容方案 |
| 跨模块边界声明 | 改了不该改的共享包 | 标注受影响的模块和禁止触及的范围 |
| Spec 体积可控 | 上下文溢出、注意力分散 | 单份 Spec 控制在 500 行以内 |
| 人工兜底策略 | AI 任务失败时无退路 | AI/Prompt 任务补充 eval 样例和降级方案 |
| 版本受控 | Spec 丢失、无法追溯 | 纳入 Git 管理,和代码同步 review |
代码写法对比:有 Spec 和没 Spec 的差异
对比一:错误处理
// ❌ 没有 Spec —— Agent 在 UI 层就地处理
// 错误映射和业务组件混在一起,无法复用
const res = await fetch('/api/search')
if (!res.ok) setError('搜索失败') // 硬编码、不可复用// ✅ 有 Spec —— 按层级划分职责
// 注释说明差异:API 层统一映射,UI 层只消费状态对象
const state = await search(query) // 返回 SearchState 联合类型
if (state.status === 'error') return <InlineError message={state.message} />对比二:权限控制
// ❌ 没有 Spec —— 每个文件各自判断
// 注释说明差异:角色判断散落 5 个文件,改角色模型时容易漏改
if (user.role === 'admin') { /* ... */ }// ✅ 有 Spec —— 权限函数集中管理
// 注释说明差异:一个文件定义权限规则,全项目消费同一个函数
if (canDelete(user, resource)) { /* ... */ }对比三:数据迁移
// ❌ 没有 Spec —— 直接替换字段
// 注释说明差异:旧数据没有新字段,读取时 undefined 导致线上异常
post.status // 旧记录:undefined// ✅ 有 Spec —— 兼容层 + 迁移脚本
// 注释说明差异:新增字段 + 旧字段映射,线上零影响
normalizePost(raw).status // 旧记录也能正确推导对比四:Spec 文档质量
<!-- ❌ 低质量 Spec —— 只有目标,没有边界和验收 -->
# 加搜索功能
给首页加一个搜索框,要快。
<!-- 问题:没有 Non-goals、没有错误处理、没有验收标准 -->
<!-- Agent 会把「要快」理解为加 loading 动画,而非优化查询 --><!-- ✅ 高质量 Spec —— 目标、边界、验收齐全 -->
# Change: add-home-search
## Problem
首页缺少搜索入口,用户需要跳转后才能搜索。
## Goals
- Header 增加搜索框,输入后实时查询
- 搜索失败时展示行内错误提示
## Non-goals
- 不做搜索建议/自动补全
- 不修改后端搜索排序逻辑
## Design
- API 层统一映射错误对象(SearchState 联合类型)
- UI 层只消费状态,不做 API 细节处理
## Acceptance
- `pnpm typecheck` 通过
- 模拟 API 500 时页面展示错误提示,不白屏
- 搜索成功时结果正常渲染Spec 质量检查清单
以下清单按阶段分组,覆盖了写 Spec、拆任务、执行和验收四个阶段。适用于中高风险任务;低风险任务可以只检查「写 Spec」阶段的前三项。
写 Spec 阶段
- Problem 是否具体:能说出哪个用户在哪个场景下遇到什么问题,而非「优化体验」。
- Goals 是否可验证:每条目标都能对应一个测试命令或可观察行为。
- Non-goals 是否明确:列出至少 2 项「不做的事」,防止 Agent 扩大范围。
- 边界是否标注:涉及哪些模块、依赖方向是什么、哪些层级不该碰,都要写清楚。
- 异常态是否覆盖:除了 happy path,至少列出网络错误、权限不足、数据为空三种场景。
拆任务阶段
- 每个任务有独立验收:不是「实现搜索功能」,而是「API 层返回 SearchState 联合类型且 typecheck 通过」。
- 任务之间有顺序声明:标注依赖关系——「任务 3 依赖任务 2 的错误映射完成」。
- 单任务粒度可控:一个任务对应一次可 review 的 diff,不超过 200 行代码变更。
执行阶段
- Agent 每步产出可观察结果:不是「处理完了」,而是「typecheck 通过 + diff 链接 + 截图」。
- 发现偏差时能定位到具体任务:如果 Agent 在错误的层级处理了问题,能回溯到 Spec 中对应的边界声明,补充约束后重新执行。
验收阶段
- 验收命令可重复执行:不是「人工检查一遍」,而是
pnpm verify能通过。 - 高风险变更有回滚方案:数据库变更、权限模型变更、API 合约变更,都要写明回退步骤。
- 剩余风险有记录:Spec 不可能覆盖所有场景,明确标注「已知的未覆盖情况」和后续跟进计划。
适用边界
Spec 驱动不是万能的。对一次性样式微调、单行 bug 修复、快速原型验证,写完整 Spec 的前置成本高于收益。我通常的判断标准是:
- 改错了成本很低的任务(改个文案、调个颜色),直接 prompt 即可。
- 改错了需要返工但不影响线上的任务(新 UI 组件、新 API 端点),写个简单 Spec 花 10 分钟。
- 改错了会影响线上或其他模块的任务(跨模块重构、数据库变更、权限变更),必须写完整 Spec。
团队可以把 Spec 分成轻量版(Problem + Design + Acceptance)和完整版(加上 Non-goals、数据流、迁移策略、验收命令),让流程跟风险匹配,而不是所有需求都套同一份重模板。
参考资料
- Understanding Spec-Driven-Development: Kiro, spec-kit, and Tessl — Martin Fowler, 2025
- Spec-driven development with AI: Get started with a new open source toolkit — GitHub Blog, 2025
- How to Write a Good Spec for AI Agents — Addy Osmani, 2026
- Spec-Driven Development: From Code to Contract in the Age of AI — arXiv, 2026
- Diving Into Spec-Driven Development With GitHub Spec Kit — Microsoft Developer Blog, 2025
- How to Use a Spec-Driven Approach for Coding with AI — JetBrains, 2025
- Spec-Driven Development (SDD) — Best Practices — Allegro Tech Blog, 2026