我见过太多人兴致勃勃用AI写Java代码,结果对着满屏报错和逻辑漏洞,最后愤愤然丢下一句“AI编程就这?”然后继续回到手写代码的老路。作为一个从传统Java开发一路折腾到大模型应用的老兵,我想说句公道话:问题八成不在大模型,而在你根本没给它一个明白的Spec(规格说明)。
Spec这个概念,在传统软件工程里叫“软件需求规格说明书”,在大模型应用时代,它变成了人和AI之间最重要的“翻译契约”。尤其在Java这种强类型、重业务规则的领域,Spec写得好不好,直接决定了AI是从“智能助手”变成“高级复制粘贴器”,还是从“玩具”变成“生产力”。这篇文章我想把Spec在AI编程中的底层价值、Java实战中的编写方法、以及喂给大模型时的提示词配方,一次性讲透。
如果你正在用Cursor、GitHub Copilot这类AI编程工具,却总觉得生成代码“差点意思”;或者你刚接触大模型应用开发,想找到一套能稳定复现的高效协作方式,这篇文章值得你花十分钟读下去。
1. 为什么AI编程卡在“说不清需求”这一步:Spec的真正价值
1.1 大模型写代码的本质,是一个“翻译任务”
很多人第一次用AI编程时的体验是:打开Cursor,输入“帮我写一个用户注册接口”,AI噼里啪啦一通输出,一个像模像样的Controller加上Service就出来了。看着挺爽,一跑起来全是坑——没校验手机号格式、没处理用户名重复、密码还是明文存库的。
为什么会这样?因为当你说“写一个用户注册接口”时,对AI来说这不是一个任务,而是一道无限制的开放题。它需要自行猜测:用什么框架?Spring Boot还是Javalin?要不要校验邮箱?密码加密采用BCrypt还是MD5?重名了返回什么错误码?这些信息在你这句话里全都是空白,它只能从训练数据里随机抽取一个“最常见”的模板,然后套上去。抽中的概率,和你掷骰子差不多。
我在大模型应用开发中反复验证过一件事:AI编程本质上是一个“自然语言到机器语言”的翻译任务,而翻译质量的上限,取决于你给的源语言信息量。你给它的约束越具体、越精确,它翻译出来的代码就越贴合你的业务。你只给一句话,它就只能交给你一个“平均的、没有灵魂的”代码骨架。
1.2 Spec不是需求文档,而是“机器可读的约束集”
传统软件工程里,需求文档动辄几十页,描述的是“系统应该做什么”,而Spec(Specification,规格说明)的核心不是描述,而是约束。它精确到输入参数的类型范围、输出结构、异常场景、业务规则,甚至性能指标。
在AI编程语境下,Spec的价值会被进一步放大。因为大模型有一个显著特点:它对“模糊的描述”非常不敏感,但对“明确的规则”非常敏感。举个直观的例子:
- 模糊版:“订单金额要合理计算。”
- 可执行版:“订单金额 = 商品单价 × 数量 × 折扣系数,折扣系数由用户等级决定,VIP为0.85,普通用户为1.0,金额保留两位小数,四舍五入。”
这两句话喂给同一个大模型,产出的代码质量天差地别。前者它可能直接给你写个totalPrice = price * quantity就完事,折扣逻辑和精度处理全看运气;后者它会老老实实把枚举判断、精度计算、四舍五入全部实现。
所以我对Spec的定义很简单:**一份能让AI在无需追问的情况下,独立完成正确实现的规则清单。**它不需要像需求文档那样铺垫背景、描述用户故事,只需要像一份合同条款一样,白纸黑字写清楚边界。
1.3 为什么Java程序员尤其需要Spec?
- Java是强类型语言,一个方法签名、一个DTO的字段类型写错,编译直接失败。
- Java后端业务规则重,一个订单状态机、一个计算逻辑,涉及大量分支和异常场景。
- Java生态框架约束多,Spring的注解、MyBatis的Mapper映射、校验框架的注解,少了一个就可能运行时报错。
这些特征导致AI在Java领域“自由发挥”的空间越大,出错概率就越高。反过来,如果你用Spec把这些约束写清楚,AI的出错概率会指数级下降。这也是为什么同样一套AI编程工具,有人能效率翻倍,有人却觉得不如手写。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Spec在Java项目中的真实定位:从“人机对话”到“可验证的契约”
2.1 把开放式问题变成封闭式选择
在日常开发中,我们给AI的很多指令都有一个致命问题:它是一个开放式的“话题”,而不是一个封闭式的“选择”。比如:
“优化一下这段代码” —— 优化什么?性能还是可读性?要保留原有行为验证吗?
“给这个接口加个缓存” —— 缓存key怎么设计?过期时间多少?分布式环境用Redis还是本地Caffeine?
当问题过于开放时,AI只能根据概率选择一个最“平庸”的答案,而恰好业务场景往往不允许平庸。Spec的作用,就是把开放问题转化为封闭问题。你不需要让AI知道“为什么加缓存”,你只需要告诉它“当userId不为空时,key为user:info:{userId},缓存过期时间300秒,采用@Cacheable注解实现”。
封闭式选择有几个好处:AI不需要猜测,生成的代码不需要大改;也方便你检查——Spec写清楚了,代码有没有偏离,一眼就能看出来;出问题时还能快速定位,是规则理解错了,还是实现逻辑有bug。
2.2 无Spec与有Spec的Java接口生成对比
我带团队时经常做一个小实验:让AI用两种方式生成同一个接口。第一种直接说需求,第二种先给Spec,效果差异巨大。
-
需求口语化,AI自主发挥空间大。
提示词:“写一个查询订单的接口,支持分页和条件筛选。” -
AI大概率生成一个Controller,方法名、参数结构甚至返回值都是“猜”的。
可能长这样:
@GetMapping("/orders")
public List<Order> queryOrders(String keyword, Integer page, Integer size)
问题:没有统一的返回包装、keyword字段和数据库不匹配、分页类型和项目里其他接口不一致,甚至可能直接用List<Order>返回数据库实体,把敏感字段也暴露了。 -
给足Spec,AI生成的结果几乎可内嵌运行。
java复制/** * 接口路径:GET /api/v1/orders * 入参:<QueryDTO> pageNum(默认1), pageSize(默认10), status(可空,枚举: PENDING/PAID/SHIPPED/COMPLETED) * 返回:Result<PageResult<OrderVO>> * OrderVO 包含 orderId, userId, totalAmount(保留两位小数), status, createTime * 异常:当status为非法枚举值时,返回错误码 40001 */有了这段话,AI生成的方法签名、DTO字段、枚举校验、统一返回结构,就会和你的项目规范严丝合缝。
2.3 Spec与Java领域已有模式的对应关系
Spec并不是一个凭空创造的新概念,在Java生态里它早就有了对应物:
- 函数式接口:
Predicate<T>本质上就是一种“可测试的条件规格”,用于组合业务规则。 - Specification设计模式:将业务规则封装成独立的Specification对象,通过
and()、or()、not()组合,这是领域驱动设计里常见的做法,核心思想也是“把规则显性化、可组合化”。 - Bean Validation注解:
@NotNull、@Size、@Pattern这些注解,本质上就是一种声明式的字段级Spec。
所以当你在AI编程中强调“写Spec”时,其实是在延续Java社区一贯推崇的“显式优于隐式”哲学。你用接口签名、校验注解、枚举定义这些精确的机器可读信息,去约束大模型的输出——这在理念上是完全同构的。
3. Java实战:从模糊需求到可执行的Spec编写全流程
3.1 一个经典的业务场景:员工月度薪资计算
为了把话说透,我选一个业务规则相对密集的Java实战场景:员工月度薪资计算。
需求一句话版本:写一个根据员工基本信息计算月度实发工资的方法。如果你把这个需求直接丢给AI,结果必然是灾难——它不知道绩效系数范围、不知道个税怎么算、不知道社保扣款口径、更不知道最低工资保障。所以我们必须先把它转化为Spec。
3.2 第一步:澄清使用场景与输入边界
写Spec的第一步不是列规则,而是先明确谁在什么条件下调用这个方法。我会问自己几个问题:
- 调用方是HR后台系统,还是员工自助查询App?(决定了是否需要权限校验)
- 是月度批量跑批,还是单个员工即时查询?(决定了是否需要批量接口)
- 输入数据从哪来?数据库已有员工档案表、考勤表、绩效表?(决定了方法入参的粒度)
这些边界如果不写清楚,AI可能会在Service里自作主张去查数据库,但你可能只是想让它写一个纯计算函数。针对这个问题,我决定把场景定为:HR月度结算,针对单个员工调用一次,传入该员工的薪资档案数据和当月考勤绩效数据,方法内部不查库,只负责“纯计算”。这一步是整个Spec的地基,边界定了,后面才不会跑偏。
3.3 第二步:定义方法签名和DTO结构
紧接着,把输入输出结构用Java类型固定住。
java复制public class SalarySpec {
/**
* 入参:薪资计算请求
*/
public static class SalaryCalculateRequest {
private BigDecimal baseSalary; // 底薪,单位元,必填,不能小于当地最低工资
private BigDecimal performanceScore; // 绩效系数,范围 0.5 ~ 2.0,保留两位小数
private Integer absentDays; // 缺勤天数,范围 0 ~ 当月工作总天数
private BigDecimal socialInsurance; // 社保个人缴纳部分,不能为负数
private BigDecimal specialDeduction;// 专项附加扣除,不能为负数,可为0
}
/**
* 出参:薪资计算结果
*/
public static class SalaryCalculateResult {
private BigDecimal grossSalary; // 应发工资 = 底薪 × 绩效系数 - 缺勤扣款
private BigDecimal taxableAmount; // 应纳税所得额
private BigDecimal taxAmount; // 个税金额
private BigDecimal netSalary; // 实发工资
}
/**
* 核心方法:根据入参计算月度实发工资
*/
SalaryCalculateResult calculate(SalaryCalculateRequest request);
}
这一步非常关键。当我用Java的DTO和接口签名来“承载”Spec时,大模型能精准理解它需要生成哪些类、哪些字段、哪些方法,而不是从零开始猜接口设计。
3.4 第三步:用规则清单约束业务逻辑
现在开始列业务规则,这是Spec最核心的部分。规则要写成覆盖完整计算链路的命题:
- 底薪校验:
baseSalary小于3000元时,抛出BizException(10001)。 - 绩效系数校验:
performanceScore小于0.5或大于2.0时,抛出BizException(10002)。 - 缺勤扣款:按天扣款,
deduction = baseSalary / 21.75 * absentDays,结果保留两位小数。 - 应发工资:
grossSalary = baseSalary * performanceScore - deduction,结果保留两位小数。 - 社保扣款:
socialInsurance直接从应发工资中扣除。 - 应纳税所得额:
taxableAmount = grossSalary - socialInsurance - specialDeduction - 5000(5000为个税起征点),若为负数则取0。 - 个税计算公式:采用简化版超额累进税率(税率表可参考3%~20%的速算扣除数)。
- 实发工资:
netSalary = grossSalary - socialInsurance - taxAmount,保留两位小数。
每一句都是确定性的,不存在“合理”或“酌情”这种模糊空间。为了让AI更容易解析,还可以在规则前面加上数字编号[Rule 1]、[Rule 2]。当后续代码有问题时,你可以直接说“Rule 3算错了”,而不需要重新解释整个计算逻辑。
3.5 第四步:定义异常规格
很多程序员写Spec时最容易漏掉的就是异常。可在AI编程里,你不定义异常,AI就会替你定义,而且它定义的错误码往往和你们的规范不一致。所以在Spec里明确:
10001:底薪低于最低工资标准10002:绩效系数超出允许范围10003:缺勤天数为负数或超过当月工作天数10004:社保金额或专项附加扣除为负数
再配合结果返回结构 Result<T>(code、message、data),AI连全局异常处理器的写法都能自动对齐。
3.6 第五步:把Spec沉淀成文本文件
最后一步,是把上面的内容整理成一个独立文本,保存到项目的/spec目录下,比如salary-calculate-spec.md。为什么用独立文件?有三个原因:
- 方便复用:下次改需求或者新员工接手,直接看Spec即可。
- 方便在AI工具中引用:在Cursor里写提示词时,直接用
@引用这个文件,不用反复粘贴。 - 方便大模型理解上下文:文件命名清晰、结构完整,AI读取后的理解效果远好于你在对话框里补一句“等等,我还没说清楚”。
4. 把Spec喂给大模型:提示词配方与“AI自测”闭环
4.1 一个可复制的提示词模板
有了Spec文件,下一步就是把Spec喂给大模型。我试过很多种写法,最稳定的是三段式:
code复制角色:你是一名具有10年经验的高级Java工程师,擅长编写高质量、符合Spec的业务代码。
任务:请严格根据规格文件 /spec/salary-calculate-spec.md 实现Java后端薪资计算模块。
硬性要求:
1. 不允许修改Spec中定义的字段名、方法名和业务规则。
2. 使用JDK 17 + Spring Boot 3.x 的编码风格,Lombok注解用于DTO。
3. 输出完整代码:DTO、Service接口、ServiceImpl、单元测试。
4. 代码中需要包含必要的参数校验逻辑,校验失败时抛BizException并携带Spec中定义的错误码。
关于角色设定,不要用夸张的“你是世界级专家”——模型的输出质量并不和头衔挂钩,反而简洁明确的角色描述更能约束输出风格。任务部分的关键是引用文件而不是粘贴内容,这能节省token、减少上下文混乱。硬性要求部分,重点声明“不允许修改Spec”和“必须包含校验逻辑”,这是为了防止AI“自由发挥”。
4.2 拆细任务,一次只让AI做一件事
实战中我发现,一个巨大的Spec如果一次性丢给AI,很容易出两个问题:
- 上下文长度告急,模型为了“赶进度”而丢三落四;
- 模块耦合度高,一个地方生成的代码有问题,排查起来很困难。
所以我的推荐做法是拆成小批次交付。比如薪资计算模块,我通常会按照这样的顺序来让AI逐步实现:
- 先生成DTO和常量类;
- 再生成Service接口;
- 再生成Service实现类;
- 最后生成单元测试类。
每一步都和AI确认后再继续。这样做的好处是每一步上下文都很短,AI不需要在“记API设计”和“写计算逻辑”之间反复横跳;并且如果某一步生成得不符合预期,也能快速定位到具体模块,不需要重新生成整份代码。
另外提醒一个容易踩的坑:分步生成时,每个步骤结束都要稍作总结。比如“已生成DTO,字段与Spec一致,接下来生成Service接口”,或者直接让AI把当前步骤的关键决策记在回答末尾。这样可以帮助模型在后续对话中维持上下文一致性。
4.3 让AI先写测试,再写实现代码
这一步是我个人认为整个流程中回报最高的一招:在让AI写实现代码之前,先让它按照Spec写一份单元测试。
为什么?因为测试本身就是可执行、可验证的Spec。当你要求AI写测试时,它会重新阅读Spec并用自己的语言翻译一遍“输入什么、输出什么、异常抛什么”,这套流程比直接写实现更能暴露Spec中的歧义。
举个例子,在薪资计算的Spec中,有一条:taxableAmount = grossSalary - socialInsurance - specialDeduction - 5000。当你让AI先写测试用例时,它会自动去构造“底薪10000、绩效1.0、社保1000、专项扣除0”的输入,并断言应纳税所得额是4000。如果实际业务中起征点是5000,但AI把它理解成500,测试用例就会给出错误的预期值——这反而成为一个提示,方便你发现Spec是否写清楚。
AI自测的流程我总结为四步:
- AI根据Spec生成测试用例;
- AI根据Spec和测试用例生成实现代码;
- AI运行测试,把失败信息抛回给它;
- AI根据失败信息迭代修复,直至测试全部通过。
这四步形成闭环后,AI生成的Java代码可靠性会大幅提升。我在项目中实测下来,一套规则的实现,通常两三轮迭代就能跑通。
4.4 在Cursor等AI编程工具中的实操心得
如果你用的是Cursor,有几个细节能让Spec的威力更好发挥:
- 在项目中创建
/spec目录并放置Markdown文件,Prompt中要用@引用对应文件,例如@salary-calculate-spec.md。 - 建议优先使用Composer或Chat模式,而不是Tab行内自动补全模式,因为Spec交互是多轮迭代的过程。
- 让AI为
ServiceImpl里的关键计算方法生成短注释,并在注释中标注采用的是哪条规则,方便review时对照。
如果你用的是GitHub Copilot或其他AI插件,思路也一样:把Spec文本放进Prompt开头,再让AI“严格按此实现”。不同工具只是交互方式不同,Spec的逻辑是通用的。
5. 常见Spec反模式:我踩过的坑和排查经验
5.1 反模式一:把Spec写得像需求文档
有一种非常常见的“伪Spec”,写出来的东西全是“系统应支持用户登录”“系统需要处理订单异常”。这种话对AI来说约等于什么都没说。因为“支持登录”背后有一大堆未定义的细节:账号密码还是手机验证码?失败几次锁定账号?锁定多久?JWT还是Session?你不写清楚,AI就只能猜。
修正方法:所有规则必须是“可测试的”。写完每条规则后,问自己“有没有办法写一条单元测试来验证这句话?”如果能,就是一条好Spec;如果不能,那就继续拆解,直到能测为止。判断标准是,这条规则能否被一条@Test里的断言覆盖。如果写不出来,AI大概率也搞不定。
5.2 反模式二:Spec和生成的代码不一致
这个问题困扰过我很久。AI生成完代码后,我发现它私自加了一些“友好功能”,比如在SalaryCalculateRequest里多了一个email字段用于“预留通知功能”。加字段是小问题,怕的是它悄悄修改计算规则,比如把缺勤扣款的分母从21.75改成了30,只因为它觉得这是“更常见的规则”。
这里的核心治理手段有三个:首先,在提示词中明确“不允许修改Spec中未提到的规则”;其次,让AI生成代码后附带一个“与Spec差异说明”,列出它自己的增补行为;最后也是最重要的一点,Spec中要写明“版本号”和“变更禁止事项”。迭代时如果需求真的有变化,修改Spec后提醒AI“这是新版本,覆盖旧版本”,避免模型在新旧规则之间混淆。
5.3 反模式三:在一个Prompt里塞入过多Spec
我见过有人把一个月的工作量——登录、订单、支付、物流、售后——全部写成一个巨大的Spec,丢给AI让“按此实现整个系统”。结果AI生成到一半就开始自相矛盾,前一个接口的返回类型和后面的调用对不上,而且context一长,连最早的规则都忘了,最终生成的代码几乎不可用。
AI编程是一个“分治”的过程。一个Prompt里只关心一个模块、一个服务、一个方法,是成功率最高的。如果你需要一个多模块系统,正确做法是先让AI生成一个总体设计,然后逐个模块生成;每个模块独立Spec、独立生成、独立测试。这和敏捷开发的粒度控制是一个道理。
5.4 反模式四:只有正常流程,没有异常Spec
有一次我需要一个“根据订单ID查询订单详情”的接口,我写的Spec里只有正常返回的结构,没有写明“订单不存在时怎么办”。结果AI生成的代码在订单不存在时返回了一个null,前端收到之后直接空指针崩溃。后来我把Spec补上了:
- 当订单ID为空或小于等于0时,抛出
BizException(40001); - 当订单不存在时,抛出
BizException(40004); - 当订单属于其他用户时,抛出
BizException(40301)。
AI下一次生成的代码,每个分支都处理得非常标准。从那以后,我在Spec里会专门留出一节“异常规格”,要求每个接口必须描述“输入非法时怎么处理、数据不存在时怎么处理、权限不足时怎么处理”,从根本上防止AI自己编错误码。
5.5 排查实例:当AI生成的代码违反业务规则
分享一个具体的排查经历。在一次订单状态流转功能的开发中,我写的Spec里有这样一条规则:“当订单状态为PAID且locked=false时,允许执行ship()操作,状态更新为SHIPPED。”但AI生成的代码里,locked条件被我写成了!locked,导致所有locked订单都能发货。我看了半天也没看出AI犯了错,直到让AI自己读了一遍Spec,对照逻辑后发现它把locked=false翻译成“非锁定”时犯了双重否定错误。
这个案例让我明白:AI在理解否定词、边界条件、以及“且/或”逻辑时,仍会出现低级错误。所以代码生成后要仔细检查条件分支,而不是因为AI生成了代码就放松review。尤其是涉及状态机、权限校验、金额计算这类高风险逻辑,必须通过单元测试把规则钉死。让AI生成测试用例,再根据测试结果逐条验证Spec中的规则,是很有效的兜底策略。
5.6 Spec自查清单
现在每写完一份Spec,我都会对照以下清单检查一遍再交付给AI:
- 每条规则都涵盖正常流程和异常流程。
- 每个字段都有明确的类型、单位、边界值。
- 每个表达式都有明确的计算顺序和精度处理说明。
- 错误码统一并在Spec中集中列出。
- 整份Spec能被拆分为多个可执行的命题,且每个命题都对应一个单元测试用例。
如果这份Spec能被另一个程序员在完全不需要追问的情况下写出一模一样的代码,那这份Spec才是合格的。AI只是把这个“另一个程序员”变成了“大模型”。
我一直觉得,AI编程最核心的杠杆点不是提示词技巧,而是用确定性对抗不确定性。Spec,就是把你的业务确定性显性化的工具。在Java这种本身就重视类型的语言里,这份确定性带来的收益会加倍。把写Spec当成一项正式工作来投入,表面看起来多花了时间,但如果你算算“省掉AI反复改代码的时间”和“减少代码评审的沟通成本”,这笔投入的回报率高得惊人。
如果看完这篇,你准备动手试试,我最后的建议是:不要一上来就做大系统,挑一个你最熟悉的小业务模块,花半小时写一份像样的Spec,再让AI按Spec生成。跑通一次之后,你会立刻感受到AI编程从“抽卡”变成“工程”的差别。
