Back to notes archive

别再只给 AI 写 User Story 了

Spec 才是 agentic coding 真正能执行的上下文

最近看了几篇关于 spec-driven development 的文章,感觉有个点说得很对:现在让 AI 写代码,继续只写 user story,很多时候是不太够的。

不是说 user story 没用。它当然有用,尤其是产品、设计、工程一起讨论需求的时候,一句「作为某某用户,我想要做某件事,这样我可以得到某个结果」,很容易让大家先站到同一个地方。

但是这个东西如果直接丢给 AI,让它开始改代码,就有点悬了。

因为 user story 默认读者是人。人会追问,会补上下文,会知道哪些地方不能乱动,也会知道「这个功能看起来简单,但权限、幂等、兼容性都要小心」。AI 也会补,只是它补出来的东西不一定是真的。

我以前给 AI 派活的时候,也经常偷懒,写得很像 user story。比如:

作为管理员,我想重新同步用户数据,这样我可以修复异常状态。

这句话人当然看得懂。但是 AI 拿到以后,里面其实有一堆洞:

  • 管理员怎么判断?
  • API 路径是什么?
  • 没登录返回什么?
  • 普通用户返回什么?
  • 重复点按钮会不会创建多个任务?
  • 这个操作是同步执行,还是丢到后台任务?
  • 成功以后返回什么?
  • 失败以后怎么处理?
  • 需要补哪些测试?

这些问题你不写,agent 不一定会停下来一个个问。它经常会挑一个看起来合理的答案,然后继续往下写。

有时候它猜得还行。有时候就很烦。

从模糊 user story 过渡到清晰 spec checklist 的抽象工程图

User Story 是起点,不是施工图

user story 更像是讨论的起点,不是执行的施工图。

它的好处是短、好懂、有用户视角。它的问题也是因为太短。对人来说,短不是问题,因为人会把会议里聊过的内容、系统里的历史习惯、代码里的坑一起带进来。

比如一个后端工程师看到「增加用户重新同步入口」,他大概率会自然想到:

  • 这是 admin-only 操作
  • 最好有审计日志
  • 不能重复创建后台任务
  • 返回值要让前端能查状态
  • 权限和重复触发都得有测试

这些东西不一定写在 user story 里,但人会从经验里拿出来。

AI 不一样。它没有那种稳定的项目经验。它只有你这次给它的上下文。你不给,它就猜。你给得太散,它也可能挑错重点。你给一堆旧文档,它还可能把过期约定当成现在的规则。

所以我现在越来越倾向于:user story 可以留给人类讨论,真正给 AI 执行的,应该是一份小 spec。

不用很厚,也不用很正式。关键是把最容易猜错的地方写清楚。

人会补上下文,AI 会补空白

我觉得这里最关键的差别,是人补的是上下文,AI 补的是空白。

人补上下文的时候,多少会带着经验和责任感。他知道哪里是约定,哪里只是猜测,也知道什么时候应该停下来问一句。

AI 补空白的时候,很多时候更像是在补一个最顺的故事。它会把一个模糊需求补成一套看起来完整的实现路径,而且写得很认真。

这也是为什么有时候 AI 写出来的代码看起来特别顺,但一放到真实项目里就很别扭。它不是不会写代码,而是一开始就站偏了。

比如你说:

给订单同步任务加上失败重试。

这句话太像一个人类任务了。人会问:重试几次?间隔多久?哪些错误能重试?重复执行会不会重复扣库存?任务已经成功以后又收到重试怎么办?

AI 可能会直接加一个 retry 配置,然后结束。

代码是有了,坑也有了。

人类补上下文和 AI 猜测空白的差异示意图

所以我现在写给 AI 的需求,会尽量把「不要猜」的部分提前写出来。

不是为了显得专业。就是为了少返工。

我理解的 Spec-driven Development

我理解的 spec-driven development,不是先写一本巨厚的需求文档,然后所有人照着做。

那就太重了,也不现实。

我更愿意把它理解成:在让 AI 动手之前,先把目标、边界、输入输出、验收方式写到足够清楚。清楚到 agent 不需要在关键地方靠猜。

以前我可能会这样说:

给 CLI 增加一个 doctor 命令,检查本地环境有没有问题。

现在我更愿意写成这样:

## Goal
增加一个 `doctor` 命令,用来检查本地开发环境是否满足运行要求。

## Checks
- 配置文件不存在时提示创建路径
- Node 版本低于要求时提示当前版本和最低版本
- 依赖目录缺失时提示运行安装命令

## Output
- 每一项检查输出 `OK``WARN``ERROR`
-`ERROR` 时退出码为 1
- 只有 `OK``WARN` 时退出码为 0

## Acceptance Criteria
- 覆盖配置缺失、版本过低、依赖缺失、全部通过四种测试
- 输出里不要包含机器相关的绝对路径,除非这是用户需要修复的路径

这东西不复杂,但已经把几个最容易乱猜的地方收住了。

AI 看到这份 spec,就不需要自己发明退出码,不需要自己猜输出格式,也不需要自己决定哪些情况算失败。

这就是差别。

一个更适合 AI 的 Spec

还是用前面那个 admin-only 的例子。

user story 是这样的:

作为管理员,我想重新同步用户数据,这样我可以修复异常状态。

这个写法适合开会讨论,但不适合直接施工。

如果我要让 AI 开始做,我会写成这样:

## Goal
增加一个 admin-only 的用户数据重新同步入口。

## API
POST /admin/users/:id/resync

## Rules
- 未登录用户返回 401
- 非管理员返回 403
- 管理员可以触发同步任务
- 同一个用户 5 分钟内重复触发只创建一个任务

## Response
- 成功时返回 `job_id`
- 重复触发时返回已有 `job_id`
- 用户不存在时返回 404

## Acceptance Criteria
- 覆盖 401、403、404、成功、重复触发五种测试
- 不在请求线程里执行实际同步逻辑
- 不改变已有用户查询 API 的响应结构

这份 spec 也不长。

但是它已经回答了 AI 最容易乱猜的几个问题:权限、幂等、返回值、测试、边界,以及哪些东西不该碰。

我现在越来越觉得,spec 的价值不在于写得多完整,而在于把关键决策从 AI 的猜测里拿回来。

AI 可以写实现,可以补测试,可以根据报错继续改。但那些真正影响系统行为的选择,最好不要让它自己随便决定。

Spec 写到什么程度

这也是很容易走偏的地方。

spec 写太少,AI 会猜。spec 写太多,人又会烦。最后大家很可能又回到一句「你自己看着办」。

我的感觉是:不用把所有细节都写死,但要覆盖那些一旦猜错就会返工的地方。

比如给 REST API 增加分页参数,我会写清楚:

  • 默认 pageper_page 是多少
  • 最大 per_page 是多少
  • 不传分页参数时是否保持旧行为
  • 返回结构是否兼容旧客户端
  • 排序是否稳定
  • 测试覆盖哪些边界

但是我不一定会规定函数怎么拆、变量叫什么、内部 helper 放在哪里。那些可以让 AI 根据项目风格去做。

再比如给后台任务增加重试,我会写清楚:

  • 哪些错误可重试
  • 最大重试次数
  • 退避策略
  • 幂等 key 怎么算
  • 重试期间不能重复发送消息

但是我不会一上来规定所有内部实现。

spec 的重点不是把 AI 变成打字员。它的重点是把重要边界先钉住。

spec 到实现、测试和反馈的开发闭环示意图

一点取舍

当然,spec-driven development 也不是银弹。

如果任务很小,比如改一个文案、调整一个变量名、补一个很明确的测试,硬写一份完整 spec 就有点做作了。

但是只要任务开始涉及行为变化、权限、数据一致性、兼容性、后台任务、跨模块影响,我就会倾向于先写 spec。

尤其是给 AI 做的时候。

因为人类工程师可以在实现过程中不断做判断,但 AI 的判断经常取决于你给它的上下文。如果上下文里没有边界,它就会在边界上发挥。

有时候发挥得很好。有时候就会把你带到一个很难 review 的地方。

我不想每次都在代码写完以后才发现:原来它把权限模型理解错了,原来它把重试写成了重复执行,原来它为了分页改了旧客户端依赖的响应结构。

这些问题不是模型不会写代码,而是输入一开始就太像愿望了。

愿望适合讨论,spec 适合执行。

小结

我现在对 AI coding 的理解越来越简单:不要让 AI 在关键行为上猜。

user story 适合告诉大家「为什么要做」。但如果要让 AI 动手,它还需要知道「具体怎么才算做对」。

所以我以后会更倾向于把任务写成一个小 spec:

  • goal 说明目标
  • rules 说明边界
  • inputs / outputs 说明接口
  • acceptance criteria 说明验收
  • non-goals 说明不要做什么

不用很长,也不用很正式。关键是让 AI 少猜一点。

代码生成会越来越快,但需求里的空白不会自动消失。空白如果不由人来补,就会由 AI 来补。

而 AI 补出来的东西,未必是你想要的东西。