Home
avatar

.Sam

【AI Native开发-01】AI 把没决定的事做成代码,代价有多大?

这是“AI Native开发”系列的第 1 篇。上一篇提出:可靠交付的最小单元不再只是代码。本篇通过一个订单取消案例,说明 AI Coding 最难防的错误,往往不是语法错误,而是未经授权的产品决策。

30 秒看懂这篇

一句“增加订单取消功能”,AI 可以很快交出页面、接口和测试。代码没有语法错误,测试也全绿,但它可能已经替你决定了:已付款订单能取消、先恢复库存再退款、重复点击无需特殊处理。

最危险的不是 AI 写错代码,而是它把没人决定的事情做得看起来像已经决定了。

flowchart TD
    A["需求空白:谁能取消?何时退款?重复请求怎么办?"] --> B{"先显式决策,还是直接生成?"}
    B -- "直接生成" --> C["模型用常见模式补齐答案"]
    C --> D["结构完整 · 测试全绿 · 业务语义可能错误"]
    B -- "先写 Spec" --> E["未决问题变成待确认项"]
    E --> F["业务做人决策 · AI 按契约执行"]

本文用订单取消案例拆解六类未授权决策,并给出歧义探测提示词、Spec 质量标尺和验收检查表。核心不是让 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" })
}

它有查找订单、恢复库存、退款和更新状态,看起来甚至比很多人工初版更完整。但上线后可能出现:

  1. 用户连续点击两次,库存被恢复两次;
  2. 库存恢复成功、退款失败,订单仍然停留在待处理状态;
  3. 退款接口超时,重试后产生重复退款;
  4. 已发货订单也被取消;
  5. 普通用户修改订单 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,然后呢?退款成功和失败各走向哪里?把它画成状态机,缺口会自己浮现:

stateDiagram-v2
    [*] --> pending_payment
    pending_payment --> cancelled : cancel
    paid --> cancellation_pending : cancel
    cancellation_pending --> refunding : start_refund
    refunding --> cancelled : refund_ok
    refunding --> refund_failed : refund_fail
    refund_failed --> refunding : retry
    refund_failed --> cancelled : manual_resolve
    shipped --> shipped : cancel_rejected_409
    completed --> completed : cancel_rejected_409
    cancelled --> [*]

有了这张图,就能追问出真正关键的问题:

  • 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 先固定外部可观察语义,不要急着把某个数据库方案写成需求:

幂等与并发契约:
- 每次取消操作携带唯一幂等键
- 同一订单、同一幂等键的重复请求返回首次业务结果,不重复恢复库存或退款
- 同一订单、不同幂等键的重复取消按当前状态返回明确的冲突结果
- 请求处理中和首次请求尚未落定时,并发调用也不能产生第二次业务副作用
- 幂等记录保留期必须覆盖客户端与外部支付接口可能的重试窗口

随后由 Plan 决定如何实现:例如 (order_id, idempotency_key) 唯一约束、事务、状态条件更新、锁或操作记录表。若采用 UPDATE ... WHERE status = 'paid',还要规定条件更新失败后如何区分“同键重放”和“不同键冲突”,不能一律返回 409。

这样分层后,Spec 回答“系统必须保证什么”,Plan 回答“当前架构如何做到”,验收用例再把两者连起来。

对应的验收补充:

- 同一幂等键并发 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+可能涉及安全事故与信任损失

重点不在数字是否精确,而在于:缺陷越晚暴露,通常牵涉的代码、数据、人员和沟通成本越多。 团队若要使用真实 ROI,应记录自己的阶段耗时与缺陷样本,不要直接引用这组示意值。

反过来说,这也界定了 SDD 值得投入的范围。改个文案或调个间距通常不值得套完整闸门;权限、资金和不可逆删除则值得把决策前移。流程强度应与变更风险和潜在损失匹配。

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 四道闸门,并在每道闸门拦住不同类型的错误。

AI Native SDD AI Coding 软件工程