【AI Native开发-01】AI Coding 最危险的地方:它会把没决定的事情直接做成代码
这是“AI Native开发”系列的第 1 篇。上一篇提出:可靠交付的最小单元不再只是代码。本篇通过一个订单取消案例,说明 AI Coding 最难防的错误,往往不是语法错误,而是未经授权的产品决策。
摘要
本文分析 AI Coding 中最难被发现的一类错误:AI 在需求空白处替业务做出了未经授权的决策,并把它写成了结构完整、测试通过的生产代码。
主要内容包括:一个订单取消案例的完整失效分析、未授权决策的六类失效模式分类、Spec 的质量评分标尺、可复用的歧义探测提示词模板,以及错误在不同阶段被发现时的成本差异。
核心论点:AI 不会因为一个问题没有答案而停下来,它会补全一个答案并继续执行。 因此 SDD 的第一价值不是规范格式,而是在 AI 开始执行前把“未决事项”从隐式变成显式。
最危险的错误,不是明显报错
AI 生成一段明显错误的代码并不可怕:构建失败、类型报错、测试挂掉,问题会立刻暴露。
真正危险的是另一种情况:AI 写出了一套结构完整、命名合理、测试也能通过,但并不符合你真实业务的系统。
因为它不是“不会做”,而是在需求空白处替你做了决定。
案例:增加订单取消功能
产品需求只有一句话:
增加订单取消功能。
AI 很快给出一个实现计划:
1. 增加取消按钮
2. 新增 POST /orders/:id/cancel 接口
3. 将订单状态改为 cancelled
4. 恢复库存
5. 已支付订单调用退款接口
6. 增加取消成功测试这份计划看起来非常专业,但其中至少有四个未经确认的决定:
- 哪些状态可以取消?
- 已支付订单是否允许取消?
- 库存恢复和退款哪个先发生?
- 退款接口异步时,订单最终状态是什么?
问题在于,AI 不会因为这些问题没有答案就停下来。它会根据常见电商模式补全一个答案,然后继续写代码。
一套“合理但错误”的实现
假设 AI 生成了下面的伪代码:
async function cancelOrder(orderId: string) {
const order = await orders.findById(orderId)
if (!order) {
throw new NotFoundError()
}
await inventory.restore(order.items)
if (order.paid) {
await payment.refund(order.paymentId)
}
return orders.update(orderId, { status: "cancelled" })
}它有查找订单、恢复库存、退款和更新状态,看起来甚至比很多人工初版更完整。但上线后可能出现:
- 用户连续点击两次,库存被恢复两次;
- 库存恢复成功、退款失败,订单仍然停留在待处理状态;
- 退款接口超时,重试后产生重复退款;
- 已发货订单也被取消;
- 普通用户修改订单 ID 后取消了别人的订单。
这些问题都不是“少写了一行 if”那么简单,它们本质上属于产品和系统设计决策。
逐条拆解:每个缺陷对应哪个未做的决定
把上面五个故障和代码位置对应起来,可以看清它们的共同结构:
| 线上现象 | 代码位置 | 缺失的决定 | 严重性 |
|---|---|---|---|
| 库存恢复两次 | inventory.restore 无幂等保护 | 取消请求是否必须幂等 | 数据错误,需人工对账 |
| 退款失败但库存已恢复 | 两次外部调用无补偿机制 | 部分失败时的最终状态 | 资金与库存不一致 |
| 重试导致重复退款 | payment.refund 无幂等键 | 退款的重试语义 | 直接资金损失 |
| 已发货订单被取消 | 缺少状态白名单校验 | 哪些状态允许取消 | 履约流程冲突 |
| 越权取消他人订单 | 未校验 order.userId | 谁有权取消 | 安全事故 |
注意最后一行的性质变化:前四项是业务规则缺失,最后一项是安全缺陷。 而它们在代码里的表现完全相同 —— 都只是“少了一个判断”。这解释了为什么这类问题极难在 Code Review 中被发现:审查者看到的是一段做了它声称要做的事的代码,看不到它没被要求做的事。
六类未授权决策的失效模式
把这类错误归纳成可检索的分类,比逐案例讨论更有用。以下六类覆盖了实践中的绝大多数情况:
F1 状态假设。 AI 假设了一套状态机,而真实业务的状态集合不同。案例中 AI 假设“订单只有已付款和未付款”,忽略了已发货、已完成、退款中。识别方式:Spec 里有没有穷举状态集合,以及每个状态的允许操作。
F2 权限默认放行。 AI 实现了功能路径,但把权限当作前端问题。典型表现是后端接口不校验归属关系,只依赖前端隐藏按钮。识别方式:能否用其他用户的 token 直接调接口成功。
F3 一致性缺口。 涉及多个外部系统时,AI 按顺序调用而不处理部分失败。案例中库存与退款没有补偿或事务边界。识别方式:把任意一步改成抛异常,系统会停在什么状态。
F4 幂等性缺失。 AI 默认请求只发生一次。重复点击、网络重试、消息重投都会破坏这个假设。识别方式:同一请求连发两次,业务结果是否相同。
F5 验收标准坍缩。 AI 生成的测试断言了最弱的可观察信号(HTTP 200、页面未崩溃),而非业务结果。识别方式:把实现改成只返回 200 而不做任何事,测试是否仍然通过。
F6 非目标越界。 AI 顺手实现了没被要求的能力(自动加了批量取消、加了回收站),扩大了攻击面与维护成本。识别方式:变更文件是否超出 Plan 批准范围。
这六类的价值在于可以直接转成 Spec 的检查项。写完 Spec 后逐条自问“F1 到 F6 是否都已明确”,比泛泛地问“Spec 写全了吗”有效得多。
一个特别值得注意的现象:F5 会掩盖其他所有失效
F5(验收标准坍缩)的危险性高于其他五类,因为它会让前四类错误全部隐身。当测试只断言响应码,F1 到 F4 的缺陷都不会被测出来,团队反而获得了“有测试覆盖”的错误信心。
有个简单的自检手法,可以称为空实现测试:
把被测函数替换成只返回成功、不做任何业务动作的空实现,
然后重新运行测试。
如果测试仍然全部通过 → 这些测试没有验证任何业务行为
如果测试大量失败 → 测试确实在验证行为这个手法能在几分钟内暴露一整套无效测试,成本极低,建议在引入 AI 生成测试后作为常规动作执行。
为什么 AI 会这样做
1. 它会优化“完成任务”,而不是确认“任务定义”
当你说“增加取消功能”,AI 的默认目标是产出一个看起来完整的取消流程。它会填补空白,而不是把空白列出来。
2. 代码库会放大局部线索
如果项目里已有“删除用户”的接口,AI 可能照着它设计取消接口;如果测试只验证 HTTP 200,它可能把返回 200 当成成功标准。
3. 通过测试不代表通过需求
如果测试只写成:
调用取消接口 → 断言响应码为 200那么下面这些关键行为都没有被证明:
- 库存是否只恢复一次;
- 退款失败时订单处于什么状态;
- 无权限用户是否被拒绝;
- 不允许取消的状态是否被拦截。
SDD 如何阻止“合理的错误”
SDD 的第一步不是让 AI 立刻写代码,而是把未决事项显式化。
第一步:把需求写成 Spec
功能:取消订单
允许取消:
- pending_payment:允许取消
- paid:允许申请取消,但不能直接标记完成
- shipped:不允许取消
- completed:不允许取消
权限:
- 用户只能取消自己的订单
- 管理员可以处理取消申请
- 服务端必须校验权限,不能只隐藏前端按钮
一致性:
- 取消请求必须幂等
- 库存恢复和退款结果必须分别记录
- 退款失败时订单不能伪装成已完成取消
- 重复请求返回相同业务结果,不重复恢复库存
验收:
- pending_payment 取消后状态为 cancelled
- paid 取消后状态为 cancellation_pending
- shipped 取消返回 409
- 他人订单返回 403
- 相同幂等键重复请求不会产生第二次库存变更
本次不做:
- 售后退货
- 部分商品取消
- 自动仲裁此时 AI 才有资格开始规划。它仍然可以提出方案,但不能继续替你决定规则。
用状态机消除 F1
上面的 Spec 用列表描述状态,已经比自然语言好,但仍有空白:paid 取消后进入 cancellation_pending,然后呢?退款成功和失败各走向哪里?把它画成状态机,缺口会自己浮现:
pending_payment ──cancel──> cancelled
paid ──cancel──> cancellation_pending
│
├─refund_ok──> refunding ──done──> cancelled
│
└─refund_fail──> refund_failed ──manual──> cancelled
│
└─retry──> refunding
shipped ──cancel──> [拒绝 409]
completed ──cancel──> [拒绝 409]有了这张图,就能追问出真正关键的问题:
refund_failed状态下用户看到什么?能不能再次点击取消?refunding停留超过多久需要告警?谁处理?- 从
refund_failed到cancelled的人工操作,谁有权限执行? - 库存是在哪一步恢复的?如果在
cancellation_pending就恢复,退款失败后要不要回滚库存?
最后一个问题尤其重要,它决定了整个实现结构,而原始需求“增加订单取消功能”里完全看不到它的影子。状态机的作用就是把这种隐藏在动词背后的复杂度强制展开。
补上这些决定后,Spec 增加一段:
状态流转:
- pending_payment 取消 → cancelled(同步完成)
- paid 取消 → cancellation_pending → refunding → cancelled
- 退款失败 → refund_failed,需人工介入,允许重试
- refund_failed 对用户展示“退款处理中,客服将联系您”
库存时机:
- 库存在进入 cancellation_pending 时不恢复
- 库存在 cancelled 落定时恢复
- 理由:避免退款失败后需要回滚库存,减少补偿路径
超时与告警:
- refunding 超过 30 分钟未落定,触发告警
- refund_failed 计入人工工单队列注意 库存时机 那条附带了理由。这一点常被省略,但半年后有人想“优化”成提前恢复库存时,理由是唯一能阻止他的东西。
用幂等键消除 F4
幂等不是加一句“必须幂等”就能实现的,Spec 需要写到可实现的粒度:
幂等实现要求:
- 客户端为每次取消操作生成幂等键(UUID),随请求提交
- 服务端以 (order_id, idempotency_key) 建唯一索引
- 命中已存在记录时,直接返回首次的业务结果,不重复执行
- 幂等键保留期不少于 7 天
- 对外部支付接口调用时,透传同一幂等键
并发要求:
- 同一订单的取消操作需取行级锁或使用状态条件更新
- 状态更新使用条件写:UPDATE ... WHERE status = 'paid'
- 条件不满足时返回 409,而不是覆盖状态最后一条是实践中最容易漏的:无条件的 UPDATE orders SET status = 'cancelled' 会让并发请求互相覆盖状态。 加上 WHERE status = 'paid' 之后,第二个请求自然失败,不需要额外的锁逻辑。这类实现级约束写进 Spec 是值得的,因为它直接对应一条可测的验收项。
对应的验收补充:
- 同一幂等键并发 10 次请求,库存变更恰好一次
- 同一幂等键重复请求,返回与首次相同的业务结果
- 不同幂等键对已取消订单请求,返回 409第二步:让 AI 把歧义变成问题
好的 AI Coding 提示词不是:
请把订单取消做出来。
而是:
请阅读当前订单、库存和支付模块。
暂时不要修改代码。
对照 order-cancel.spec.md:
1. 列出 Spec 未覆盖的业务分支;
2. 列出当前代码中可能影响幂等性的实现;
3. 标记所有需要产品或技术负责人决策的问题;
4. 只有在没有阻塞性歧义后,再输出 Plan。这一步把 AI 从“自动补全器”变成“歧义探测器”。
值得注意的是,这个提示词里最关键的一句是 “暂时不要修改代码”。缺了它,AI 通常会一边列问题一边把代码改完,此时列出的问题已经失去意义 —— 决策已经被实现固化了。
三个可复用的提示词模板
模板一:Spec 缺口探测(Spec 写完后、Plan 之前)
阅读 specs/<feature>/spec.md 与相关代码,不要修改任何文件。
按以下六类逐项检查 Spec 是否存在缺口,每类给出结论与依据:
F1 状态假设:状态集合是否穷举,每个状态的允许操作是否明确
F2 权限:服务端校验规则是否明确,是否含数据归属校验
F3 一致性:多系统调用的部分失败路径是否定义
F4 幂等:重复与并发请求的语义是否定义
F5 验收:每条规则能否转成可断言的用户可观察行为
F6 边界:非目标是否明确
输出格式:
- 已明确的项,引用 Spec 原文行
- 存在缺口的项,写出具体待决问题和可选方案
- 区分「阻塞性」与「可后续补充」
不要给出建议实现,不要开始写 Plan。模板二:实现前的假设声明(Plan 批准后、写码之前)
在开始实现之前,列出你将依赖的所有假设,包括:
- 数据模型字段名与类型(需从代码验证,不得推测)
- 现有工具函数与鉴权入口
- 测试执行方式与环境依赖
- 你打算修改的文件清单
对每条假设标注:已从代码验证 / 推测未验证。
所有「推测未验证」项必须先验证或提问,不得直接实现。第二个模板专治上一篇提到的那类事故 —— AI 假设存在 status 字段而实际是 hide: true。强制区分“已验证”和“推测”,是成本最低的一道防线。
模板三:测试有效性审查(测试生成后)
审查刚生成的测试文件,不要修改实现代码。
1. 对每个测试用例,说明它断言的是用户可观察行为还是内部实现
2. 假设把被测逻辑替换为「只返回成功的空实现」,
指出哪些测试仍会通过 —— 这些是无效测试
3. 对照 Spec 的 REQ 编号,列出没有任何测试覆盖的规则
4. 指出依赖执行顺序、共享状态或固定时间的脆弱测试
输出一张表:REQ → 测试 → 有效性判定 → 问题。Spec 质量标尺
“Spec 写好了没有”需要一个可判定的标准,而不是感觉。建议用五个维度各自打 0 到 2 分:
| 维度 | 0 分 | 1 分 | 2 分 |
|---|---|---|---|
| 可测性 | 描述性语言(“体验流畅”) | 部分规则可测 | 每条规则都可转成断言 |
| 完备性 | 只有正常流程 | 含主要异常 | 正常 + 异常 + 边界 + 并发 |
| 明确性 | 存在多解释空间 | 少量歧义 | 两人独立验收结论一致 |
| 可追溯 | 无编号 | 部分编号 | 全部 REQ 编号且被测试引用 |
| 边界 | 未声明非目标 | 粗略声明 | 非目标明确且含理由 |
判定建议:总分低于 7 分不应进入 Plan 阶段;可测性或明确性单项为 0 分,无论总分多少都应退回。 这两项为零意味着后续的一切工作都建立在不确定的前提上,越往后走返工越贵。
错误在不同阶段被发现的成本差异
上面所有流程的正当性,最终归结为一个成本梯度。以案例中的“越权取消他人订单”为例:
| 发现阶段 | 修复动作 | 相对成本 | 附带损失 |
|---|---|---|---|
| Spec 评审 | 补一行权限规则 | 1 | 无 |
| Plan 评审 | 调整方案与测试计划 | 3 | 无 |
| 实现中 | 改代码,补测试 | 8 | 少量返工 |
| 测试阶段 | 定位、修复、回归 | 20 | 阻塞交付 |
| 生产环境 | 紧急修复 + 数据核查 | 100+ | 安全事故、信任损失、可能的合规问题 |
这张表的量级不必精确,重要的是它的形状:成本随发现阶段推后呈指数增长,而 Spec 阶段的修复成本几乎为零 —— 因为那时“错误”还只是一行没写的文字。
反过来说,这也界定了 SDD 值得投入的范围。如果一类变更即使出错也只需要付出成本 1 到 3(改个文案、调个间距),那么为它建立完整闸门是净亏损。流程强度应当与成本梯度的陡峭程度匹配。
SDD 的价值是保存决策,不是保存格式
很多人以为 SDD 的价值是 Markdown 模板。其实模板只是载体,真正重要的是让决策可追踪:
REQ-004:取消请求必须幂等
→ PLAN-003:使用幂等键和取消操作记录
→ TEST-007:重复请求只产生一次库存变更
→ VERIFY-007:并发测试通过当线上出现重复扣库存时,团队可以反过来检查:
- Spec 是否根本没写幂等?
- Plan 是否看到了规则却没有实现?
- 测试是否只测了顺序请求,没有测并发?
- 实现是否绕过了已有事务机制?
没有这条链路,团队只能说“AI 这次写错了”;有了这条链路,才能知道错误发生在哪一层。
什么时候不必使用完整 SDD
不是所有任务都值得写完整订单级 Spec:改一处文案、调整一个间距、修复明显的类型错误,可以采用轻量流程。
但只要变更涉及以下内容,就不应该把需求空白交给 AI 自由填充:
- 状态流转
- 权限和身份
- 删除和恢复
- 支付、库存和资金
- 数据迁移
- 对外兼容性
- 并发和幂等
结语
AI Coding 最需要防的不是“AI 不会写”,而是“AI 写得太顺”。
凡是没有被业务明确决定的地方,都不应该直接变成生产代码。
附录:Spec 评审检查表
可直接作为评审会议的逐条清单使用。任一「阻塞」项未通过,Spec 不予放行。
【阻塞项】
角色与权限
[ ] 每个操作的授权角色已明确
[ ] 数据归属校验已明确(能否操作他人数据)
[ ] 明确声明服务端必须校验,不依赖前端隐藏
状态与流转
[ ] 状态集合已穷举
[ ] 每个状态的允许操作已列出
[ ] 不允许的操作返回什么已定义
[ ] 中间状态的停留与超时已定义
失败与一致性
[ ] 每个外部依赖失败时的系统状态已定义
[ ] 部分成功场景已定义(A 成功 B 失败)
[ ] 补偿或人工介入路径已定义
幂等与并发
[ ] 重复请求语义已定义
[ ] 并发请求语义已定义
[ ] 幂等实现粒度已写到可实现程度
验收
[ ] 每条规则可转成断言
[ ] 断言的是用户可观察行为
[ ] 存在负向用例(应被拒绝的场景)
边界
[ ] 非目标已明确
[ ] 非目标附带理由
【非阻塞项】
[ ] 关键决策附带理由说明
[ ] 已识别需要 Spike 验证的技术不确定项
[ ] 已标注预期的风险等级与所需自主级别
[ ] 已知限制与后续迭代项已记录一条使用经验:评审时先跑「阻塞项」的权限与状态两组。 实践中这两组的缺口占比最高,而且一旦缺失,后面所有条目的答案都可能要重写。
下一篇,我们把这套原则放进一个完整的教学案例:同一个“删除草稿文章”需求,如何经过 Spec、Plan、Implement、Verify 四道闸门,并在每道闸门拦住不同类型的错误。
