1. 为什么"照着Spec写代码"在AI手里特别容易翻车
我最早听到"AI基于Spec开发是巨坑"这个说法,是从一个做后端重构的朋友那来的。当时他让AI按一份写得很详细的功能规格书去改造订单模块,规格书里连字段含义、边界条件、异常返回都写了,结果AI交付的代码"看起来都对,跑起来全不对"。改了三轮,耗时比手写还长,最后他撂下一句话:Spec驱动开发?骗鬼的。
这个反应我能理解,但一直觉得哪里不对劲。后来自己也踩了几次,才慢慢琢磨明白——"AI基于Spec开发"之所以被骂成巨坑,往往不是因为Spec这个思路有问题,而是因为大多数人对"Spec"的理解还停留在"需求文档"层面。把一份给人看的文档原封不动喂给AI,然后指望它产出可交付的代码,这本质上是把AI当成一个无限耐心的外包程序员,而不是把它当成一套需要精确输入才能稳定输出的工具。
先说一个我自己的典型翻车现场。早期我让AI用Python写一个配置解析器,Spec里写了"支持从JSON和YAML读取配置,JSON优先级高于YAML,当两个文件同时存在时合并配置,字段冲突以JSON为准"。听起来是不是很清楚?AI也确实照做了,但合并逻辑是用一个很朴素的深合并实现的,遇到数组字段直接覆盖,遇到嵌套字典里的None值也直接覆盖。
业务侧的真实需求是:数组字段应该按ID做增量合并,None值应该保留原值,因为后续流程里"显式置空"和"没配置"是两种完全不同的状态。这些细节,AI当然不知道,因为它只知道我Spec里写的那句话。问题不在AI,也不在Spec,在于我把"需求描述"当成了"规格说明"。
这件事给我的启发是:AI非常擅长把"给定的明确规则"翻译成代码,但它并不擅长"脑补你文档里没写的规则"。脑补出来的部分,十次有九次是按最常见的工程惯例走的,而真实业务里,最常见的惯例恰恰不是你想要的那个。
这里要展开说说三个最容易被忽略的隐性成本。第一个是评审成本。AI生成的代码如果你敢不review,那它就是个黑盒;如果你要认真review,那你就得把AI写的每一行都看一遍。很多项目的实际情况是:自己手写一个函数十分钟,review AI写的版本半小时,因为你要在脑子里模拟AI当时是怎么理解这份Spec的,才能判断它哪里理解偏了。这个成本算下来,效率反而是负的。
第二个是规格漂移。Spec在项目推进过程中必然会被修改,但AI并不会自动记住你在上一轮对话里改过什么。你如果只给它看最新版Spec,它会丢失上下文;给它看全部历史,它会在新旧规则之间产生混乱。我在一个内部工具项目里遇到过:AI在第一次迭代时按旧Spec实现了"用户ID用UUID",第二轮Spec改成了"业务编号用雪花ID",AI确实改了,但只在新增接口里用了雪花ID,老接口还留着UUID的逻辑。最后线上出现了两套ID体系并存。
第三个是版本地震。这个在依赖声明里最明显,后面我会单独讲。总之当Spec里出现"选用稳定的版本""使用较新的API"这种模糊约束时,AI往往会向模型偏好倾斜,而不是向项目实际依赖倾斜。它会写出一个当前AI训练数据里最流行、看起来最"标准"的版本,而这个版本跟你的项目其他组件完全没对齐。
所以我想说的第一件事是:不要急着骂"Spec开发是巨坑",先检查一下你给AI的到底是"需求描述"还是"工程规格"。这两个东西的差距,基本就是AI开发项目从翻车到稳定的全部差距。
2. Spec要怎么写,AI才真的"读得懂"
既然问题出在Spec质量,那接下来最值得聊的就是:一份AI真正读得懂的Spec长什么样。
2.1 规格的颗粒度:写到"状态转换"级别
我在实践中最有用的一个转变,是把Spec的颗粒度从"功能层面"下沉到"状态与转换层面"。举个例子,"用户登录时需要校验账号密码"是功能描述;"当用户提交凭据时,系统进入AUTH_PENDING状态,校验通过进入AUTH_SUCCESS并签发令牌,校验失败进入AUTH_FAILED并记录失败次数,连续失败五次锁定账号15分钟"这才是规格描述。
颗粒度下沉的核心作用是:让AI没有发挥的余地。你可能会担心,规格写这么细,那AI的作用不就变成打字机了?对,在关键路径上,我恰恰希望它当打字机。AI真正的发挥空间应该留给那些不重要的部分,比如错误处理文案、日志格式、辅助函数怎么组织。这些地方发挥错了,代价极低;核心流程发挥错了,代价就是事故。
我手头一个比较稳定的项目,Spec的写法已经固定成了一种模式:每个功能点都拆成"输入条件、状态迁移、输出结果、异常分支"四段。AI在这个框架下产出的代码,review起来轻松很多,因为我只需要检查每段状态迁移是否跟Spec一致,而不用从头理解它的实现思路。
2.2 用强制关键词给AI划边界
第二个关键做法是:在Spec里使用带有强制语义的关键词,比如MUST、MUST NOT、SHOULD、MAY。这套东西在RFC 2119里定义得很成熟,很多做协议的人很熟,但在AI编程这个场景里被严重低估了。
AI对自然语言里"可以""应当""最好""尽量"这类词的判断是概率性的,它倾向于取一个温和的理解。你说"应当记录日志",它可能只在主路径上加了log;你说"必须记录每次请求的完整参数、返回码和耗时,不得在debug级别以下过滤",它就老实了。
我会在Spec开头写一段统一约束:本规格中,MUST表示无例外强制要求;MUST NOT表示任何情况不得执行;SHOULD表示除非有明确理由否则应当执行,若偏离需在代码注释中说明理由;MAY表示可选的,AI可自行决策。别小看这段声明,它能让AI在拿不准时做出正确选择的比例明显提升,因为它不再是猜"用户语气强不强",而是在执行一套明确定义的优先级。
2.3 一个反例和一个正例的对比
说得再具体一点。我见过很多团队写的Spec是这样的:
用户注册后需要发送欢迎邮件,如果发送失败应该重试,邮件内容包含用户名和注册时间。
这份Spec让AI做决策,它能写出相对合理的代码,但"合理"不等于"符合业务预期"。重试几次?退避策略是什么?注册时间用哪个时区?用户名需要做HTML转义吗?邮件队列放内存还是Redis?这些都是AI自由发挥的,每个决策都会埋一个潜在雷。
我后来把同样一个需求改写成这样:
用户注册成功后,系统MUST将注册事件写入email_queue表,状态为PENDING。独立消费者进程SHOULD每30秒扫描一次PENDING消息,MUST NOT在同一时刻对同一消息重复发送。消息发送成功后MUST将状态置为SENT;失败时MUST将attempt_count加1,当attempt_count达到3时状态置为FAILED并停止重试。邮件模板MUST包含用户昵称(HTML转义后)和注册时间(UTC+8格式),MUST NOT包含明文密码或验证码。
这看上去很长,但它直接消灭了一整类review时的争议问题。你不需要再问AI"为什么重试了五次""为什么时间和用户预期差八小时""为什么昵称里的特殊字符把邮件模板炸了"。它没有机会犯这些错。
当然这里有性价比的问题。不是每个需求都值得花这个篇幅去写Spec。我的判断标准是:看错误代价。如果这个功能出错会造成资损、数据不一致、安全事故,就值得写细;如果只是个展示性列表页,大颗粒度完全够。把规格投入集中在最关键的那20%需求上,性价比最高。
3. 版本规格与配置声明:AI开发链路里最阴的三个坑
如果说过度概括的Spec是"坑一",那版本规格和配置声明这块,可以说是一套连环坑。这些坑的共同特点是:它们藏在你最不会注意的地方,等你发现时已经浪费了大半天。
3.1 invalidversionspecerror的根源:版本声明的"半吊子"
最近网上有个热搜词是invalidversionspecerror: invalid version spec: =2.7。这个报错看起来离AI很远,但它恰恰特别能说明"规格文字"在工程链路里的分量有多重。这个报错来自Go模块依赖的约束解析,当某个依赖指定了=2.7这种写法时,解析器直接拒绝掉。原因很简单:Go的模块版本规范要求明确的语义化版本格式(v2.7.0之类的三段式),并且精确等于的约束应该写作=v2.7.0,而不是=2.7。
这跟AI开发有什么关系?关系大了。我让AI生成Go项目时,它偶尔会写出类似require example.com/lib =2.7这种没有v前缀、缺段位的声明糊弄过去,Go工具链直接给一个晦涩的invalidversionspecerror。这个问题在堆栈溢出里能搜到几百条,大部分提问者在人肉排查了半天之后才发现,就是版本号写法不规范。
所以从那以后,我在所有涉及依赖管理的Spec里,都会固定加一句:所有依赖版本申明MUST使用完整语义化版本号,包含v前缀和完整的三段位数字,禁止使用范围模糊的写法。这句话看似多余,但能省掉一堆到处查"为什么这个版本约束解析不了"的时间。
还有个相似的情况是Python的requirements.txt。AI经常生成numpy>=1.24这种看起来很合理的约束,但真实项目里为了可复现性,我们往往需要numpy==1.24.3这种完全锁定的。Spec里不写清,AI就会默认给你一个"更宽容"的版本策略。宽容本身不是坏事,但它在交付环境里会变成"昨天的代码今天跑不通"的源头。
3.2 隐式上下文:AI看不见Spec之外的约定
第二个坑是隐式上下文。AI在生成代码时,它能看到你给它的Spec,能猜到你项目的语言和框架,但它看不到你仓库里那些"没有写出来的约定"。
举一个我踩过的例子。当时我们的项目里有个约定:所有对外接口的出参结构必须是{code, message, data}这个包裹层,错误码统一走枚举,不允许直接把异常堆栈抛给前端。这个约定写没写进代码注释里?写了,但分散在好几个基础模块里,没人把这句话放进功能Spec。结果AI在新增接口时,直接按框架默认方式返回了一个裸对象。看起来只是少了层包装,但对前端来说,已有的统一错误处理中间件全部失效。
这类问题靠责怪AI没意义,因为它真的不知道。解决办法有两个:一是把通用的工程约定固化成一份"全局约束Spec",每次对话都丢给AI;二是在功能Spec开头显式引用相关约定,像"本模块所有对外接口必须遵守全局约束Spec中的响应包裹规则"。二选一即可,但我更推荐两种都做,因为AI对话有上下文窗口损耗,前面提过的约定后面就有可能模糊掉。
3.3 配置漂移:一份代码两套真相
第三个坑是配置漂移,这个在AI开发项目里几乎是必然发生的。典型场景:你让AI基于配置中心实现一个动态开关功能,AI为了演示方便,在本地写了一个YAML配置文件,同时在代码里硬编码了一份默认配置。当线上配置中心的值变化时,本地配置没跟着变,运行结果就出现"一部分配置生效,一部分配置是死值"的状态。
我现在的Spec里会直接规定:本项目中所有运行时可变的配置MUST通过配置中心读取,本地配置文件仅保留启动参数和占位符;任何地方出现具体业务值时,MUST以配置中心键名为准。这条规则看起来蠢得没必要,但对AI特别有效,因为它本质上是在告诉AI:你没有权力替运行环境做决定。
这三个坑的共同点,是它们都源于AI对"规范和现实"之间的缝隙做了过度友好的填补。AI太想给你一个"能跑起来"的方案了,以至于它会隐式假设很多它不该假设的东西。Spec的任务就是把这些缝隙全部堵死。
4. 把Spec变成团队的长期资产:规格库的治理思路
前面讲的都是怎么写好一份Spec,但真正让我从"AI每次都要重新教"里解脱出来的,是把Spec从一个一次性文档变成一套可持续维护的规格库。这个转变不能靠某个AI工具完成,得靠工程规范。
4.1 给Spec本身建立schema
规格库的第一步,是给Spec文件本身定一套schema。听起来很玄,做起来很简单。我们团队的Spec目录是这样的:
specs/ 01-global/ conventions.md error-codes.md 02-modules/ order/ >