AI 规格驱动编程入门:Spec 如何成为人和 Agent 的共同契约

0阅读12分钟

一个让我反复遇到的问题

最近几个月我在日常开发中频繁使用 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-firstSpec 写完指导当次实现,完成后不再维护一次性独立功能开发、一次性脚本
Spec-anchoredSpec 持续维护,伴随功能演进长期维护多人协作模块、持续迭代的功能
Spec-as-sourceSpec 是唯一真相源,人只改 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 到交付的完整流程

流程图画布 · 115%
Mermaid 流程图加载中...

对比:不同阶段的 Spec 策略

从 Prompt-only 到 Spec-Driven 的演进

维度Prompt-only简单 Spec完整 Spec
信息载体口头描述或一行指令包含目标和设计目标 + 边界 + 任务 + 验收
Agent 理解度靠猜测补齐缺失上下文知道要做什么知道做什么、不做什么、怎么验证
返工率高(方向偏差频繁)中(细节仍会猜错)低(边界和验收都锁定)
适用场景一次性脚本、UI 微调单文件功能、简单 bug 修复跨模块功能、架构变更、高风险任务
前置成本几乎为零10-15 分钟30-60 分钟
迭代方式每次推倒重来按 Spec 局部修正按任务步骤逐步推进

不同任务风险对应的 Spec 详细程度

任务风险典型场景Spec 需要包含的内容预估书写时间
低风险修文案、调颜色、单文件 bugProblem + Design + Acceptance5 分钟
中风险新 API 端点、新 UI 组件加上 Non-goals + 数据流 + 错误处理15 分钟
高风险跨模块重构、数据库变更加上迁移策略 + 回滚方案 + 权限归属30 分钟
AI/Agent 任务Prompt 变更、Agent 行为调整加上 eval 样例 + 失败分类 + 人工兜底30 分钟

Spec 的成熟度对比

维度Spec-firstSpec-anchoredSpec-as-source
谁写 Spec开发者开发者 + 团队维护开发者定义,Agent 辅助演进
Spec 更新时机实现前写,完成后不再维护功能演进时同步更新Spec 即代码,改 Spec 自动触发代码变更
适合团队个人项目、小团队多人协作、长期维护的项目高度标准化的生成场景
当前工具支持Kiro、Claude Code、Cursor需要团队约定 + CI 检查实验阶段,Tessl 在探索
维护成本中(需要同步更新)高(需要自动化管道)

工具对比

维度KiroGitHub Spec KitOpenSpec
核心工作流Requirements → Design → TasksConstitution → Specify → Plan → TasksExplore → 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 阶段

  1. Problem 是否具体:能说出哪个用户在哪个场景下遇到什么问题,而非「优化体验」。
  2. Goals 是否可验证:每条目标都能对应一个测试命令或可观察行为。
  3. Non-goals 是否明确:列出至少 2 项「不做的事」,防止 Agent 扩大范围。
  4. 边界是否标注:涉及哪些模块、依赖方向是什么、哪些层级不该碰,都要写清楚。
  5. 异常态是否覆盖:除了 happy path,至少列出网络错误、权限不足、数据为空三种场景。

拆任务阶段

  1. 每个任务有独立验收:不是「实现搜索功能」,而是「API 层返回 SearchState 联合类型且 typecheck 通过」。
  2. 任务之间有顺序声明:标注依赖关系——「任务 3 依赖任务 2 的错误映射完成」。
  3. 单任务粒度可控:一个任务对应一次可 review 的 diff,不超过 200 行代码变更。

执行阶段

  1. Agent 每步产出可观察结果:不是「处理完了」,而是「typecheck 通过 + diff 链接 + 截图」。
  2. 发现偏差时能定位到具体任务:如果 Agent 在错误的层级处理了问题,能回溯到 Spec 中对应的边界声明,补充约束后重新执行。

验收阶段

  1. 验收命令可重复执行:不是「人工检查一遍」,而是 pnpm verify 能通过。
  2. 高风险变更有回滚方案:数据库变更、权限模型变更、API 合约变更,都要写明回退步骤。
  3. 剩余风险有记录:Spec 不可能覆盖所有场景,明确标注「已知的未覆盖情况」和后续跟进计划。

适用边界

Spec 驱动不是万能的。对一次性样式微调、单行 bug 修复、快速原型验证,写完整 Spec 的前置成本高于收益。我通常的判断标准是:

  • 改错了成本很低的任务(改个文案、调个颜色),直接 prompt 即可。
  • 改错了需要返工但不影响线上的任务(新 UI 组件、新 API 端点),写个简单 Spec 花 10 分钟。
  • 改错了会影响线上或其他模块的任务(跨模块重构、数据库变更、权限变更),必须写完整 Spec。

团队可以把 Spec 分成轻量版(Problem + Design + Acceptance)和完整版(加上 Non-goals、数据流、迁移策略、验收命令),让流程跟风险匹配,而不是所有需求都套同一份重模板。

参考资料

评论 0

0 / 1000