AI 没有写错代码,但它仍然给错了方案

0 阅读17分钟

从一次 Promise.all 与 Promise.allSettled 的选择,谈谈 AI 编程中的场景判断、业务边界和开发者责任。

前言

现在,Cursor、GPT、DeepSeek、豆包等 AI 工具,已经逐渐成为程序员日常开发的一部分。

写 CRUD、补类型、生成表单、封装请求、解释陌生代码,AI 确实能够显著减少重复劳动。

但在真实项目中,我逐渐发现了一个比“AI 会不会写错语法”更值得警惕的问题:

AI 给出的代码可能语法正确、能够运行,甚至符合通用最佳实践,但放进具体业务后,仍然可能是错误的方案。

这种错误往往比语法错误更隐蔽。

语法错误通常会直接报错,而场景不匹配的代码,可能在正常情况下运行得很好,直到某个接口失败、某个数据为空、某个用户进行特殊操作时,问题才暴露出来。

近期,我在处理一个多字典并发请求的业务时,就遇到了这样的问题。


一、四款 AI 都推荐了 Promise.all

当时的业务场景是:

一个后台表单初始化时,需要同时请求多个下拉字典接口,例如证件类型、医保类型、人员类别、结算类型等。

这些字典之间没有严格依赖关系。

业务要求也很明确:

  • 多个字典接口需要并发请求;
  • 某一个字典接口失败,不能阻断其他字典加载;
  • 已成功返回的字典仍然需要正常展示;
  • 失败的字典可以单独记录、提示或重新请求;
  • 页面可以在部分数据可用的情况下继续运行。

我先后使用了 GPT、豆包、DeepSeek 和 Cursor 辅助分析,得到的初始方案高度一致:

const results = await Promise.all([
  getDictA(),
  getDictB(),
  getDictC(),
  getDictD(),
]);

从代码本身来看,这段实现没有语法问题,Promise.all 也确实是非常常见的并发请求方案。

但它不符合当前业务的失败策略。

只要其中一个 Promise 进入 rejected 状态,Promise.all 返回的 Promise 就会立即进入 reject 分支:

try {
  const results = await Promise.all([
    getDictA(),
    getDictB(),
    getDictC(),
  ]);
} catch (error) {
  console.error('字典加载失败', error);
}

需要特别说明的是:

Promise.all 进入 reject,并不代表其他已经发出的网络请求会自动终止。

其他请求通常仍然会继续执行,只是调用方无法再通过这次 Promise.all 正常获得一个包含全部成功结果的数组。

除非额外使用 AbortController 等机制,否则已经发出的请求不会因为 Promise.all 失败而自动取消。

这也意味着,我的问题不是“请求有没有继续执行”,而是:

当部分请求失败时,系统是否还需要保留并处理其他成功结果?

在当前业务中,答案是需要。

因此,我最后选择了 Promise.allSettled:

const results = await Promise.allSettled([
  getDictA(),
  getDictB(),
  getDictC(),
  getDictD(),
]);

results.forEach((result, index) => {
  if (result.status === 'fulfilled') {
    console.log(`第 ${index + 1} 个字典加载成功`, result.value);
  } else {
    console.error(`第 ${index + 1} 个字典加载失败`, result.reason);
  }
});

Promise.allSettled 会等待所有 Promise 都完成,并分别保留每一项的成功或失败状态。

这样,一个字典失败不会阻断其他字典的处理,更符合当前业务“允许部分成功”的要求。


二、真正的问题不是选哪个 API

如果只停留在表面,这件事很容易被总结成:

Promise.allSettled 比 Promise.all 更好。

但这显然是不准确的。

Promise.all 并没有错,Promise.allSettled 也不是任何场景下都更高级。

真正需要判断的是:

当前系统采用的是整体成功策略,还是允许部分成功策略?

适合 Promise.all 的场景

当多个结果共同组成一个不可拆分的整体时,任何一个请求失败,都意味着后续流程无法继续。

例如:

  • 订单信息、订单明细和支付信息必须完整返回;
  • 多项配置之间存在强依赖关系;
  • 缺少任意一项都会导致页面展示错误;
  • 业务要求数据必须全部准备完成后才能提交。

这时,使用 Promise.all 更符合语义:

const [order, details, payment] = await Promise.all([
  getOrder(),
  getOrderDetails(),
  getPaymentInfo(),
]);

适合 Promise.allSettled 的场景

当任务之间相互独立,允许部分成功时,Promise.allSettled 更合适。

例如:

  • 表单中的多个独立字典;
  • 批量查询多个非关键数据源;
  • 批量上传文件,需要分别展示成功与失败结果;
  • 首页多个独立统计卡片;
  • 多个第三方服务的聚合查询。

因此,这次技术选择的本质不是 API 记忆,而是:

接口之间是强依赖还是弱依赖?
系统应该整体失败,还是允许降级运行?

当问题被这样重新定义后,答案才真正清晰。


三、这算不算 AI 幻觉?

严格来说,这个案例不完全属于典型的“AI 幻觉”。

通常所说的技术幻觉,更接近以下情况:

  • 编造一个不存在的 API;
  • 虚构某个框架参数;
  • 引用不存在的官方文档;
  • 混淆不同版本的配置项;
  • 对库的实际行为作出错误描述;
  • 在被纠正后仍坚持错误事实。

而这一次,AI 推荐的 Promise.all 是真实存在的,也确实可以正常运行。

它的问题在于:

方案本身成立,但与具体业务场景不匹配。

更准确地说,这是一次:

  • 场景误判;
  • 通用模板与业务约束错配;
  • 失败策略选择不当;
  • 上下文不足导致的默认方案偏差。

这个区分非常重要。

如果把所有不合适的答案都称为幻觉,我们很容易忽略另一个事实:

AI 输出质量不仅与模型能力有关,也与开发者有没有把业务目标、约束和失败策略描述清楚有关。


四、为什么不同 AI 容易给出相同方案?

多个 AI 给出相同答案,并不意味着它一定适合当前业务。

它更多说明:

这个答案是一种常见、概率较高的默认实现。

1. Promise.all 是更常见的并发模板

在大量教程、博客、面试题和示例代码中,Promise.all 经常被用来演示并发请求。

因此,当问题只描述为“批量请求多个接口”时,模型很容易优先生成这种高频模式。

但“批量并发”只是表面需求,模型还需要知道:

  • 请求之间是否相互依赖;
  • 部分失败是否可以接受;
  • 失败后是否需要保留成功结果;
  • 页面是否允许降级;
  • 是否需要重试失败项。

缺少这些信息时,AI 很可能只能给出通用答案。

2. AI 只能基于获得的上下文进行判断

AI 可以分析业务,但前提是业务信息已经被提供。

如果提示词只是:

帮我并发请求多个字典接口。

那么 Promise.all 并不是一个离谱答案。

但如果明确说明:

多个字典相互独立,任意一个失败都不能影响其他结果,需要保留成功数据并记录失败项。

那么更合理的方案就会发生变化。

所以,AI 并不是在独立理解整个项目,它只能根据:

  • 当前对话;
  • 提供的代码;
  • 项目文件;
  • 文档内容;
  • 工具检索结果;

来推断真实需求。

它无法自动知道那些没有被表达出来的业务规则。

3. 多个模型可能共享相似的高频代码模式

即使不同产品使用不同模型,它们也可能学习过大量相似的公开代码、教程和文档。

所以多个模型得出相同结论,只能说明这个结论常见,不能替代业务验证。

模型共识不等于业务正确。


五、AI 编程中更值得警惕的四类问题

这次经历让我意识到,真正危险的并不只是不存在的 API,而是那些“看起来非常正确”的方案。

1. 场景与失败策略不匹配

除了 Promise.all 和 Promise.allSettled,类似问题还包括:

  • 请求失败后是整体回滚还是保留部分结果;
  • 缓存失效后是阻塞页面还是返回旧数据;
  • 表单部分字段加载失败时是否允许继续编辑;
  • 多个服务异常时系统如何降级;
  • 批量任务失败时是立即停止还是继续执行。

这些问题没有脱离业务的标准答案。

AI 可以提供选项,但不能代替项目负责人承担决策责任。

2. 框架版本和项目环境不匹配

AI 可能出现:

  • Vue 2 和 Vue 3 写法混用;
  • React 新旧生命周期混用;
  • 使用当前版本已经废弃的配置;
  • 给出不适用于现有构建工具的插件配置;
  • 忽略浏览器兼容范围;
  • 推荐与项目依赖版本冲突的 API。

现在部分 AI 产品能够联网、读取官方文档或者分析项目代码,因此不能简单地说“AI 永远只能使用训练截止日期之前的知识”。

更准确的说法是:

模型自身记忆可能存在时间滞后;即使产品支持联网和文档检索,如果没有检索官方资料,或者没有结合当前项目版本,仍然可能输出过时方案。

因此,涉及版本问题时,应该优先核对:

  • package.json;
  • 锁文件;
  • 官方文档;
  • Release Notes;
  • Migration Guide;
  • 实际类型定义。

3. 正常流程完整,异常流程缺失

AI 很擅长生成 happy path:

const data = await request();
render(data);

但真实项目还要考虑:

  • 请求失败怎么办;
  • 返回 null 怎么办;
  • 数组为空怎么办;
  • 接口重复提交怎么办;
  • 用户连续点击怎么办;
  • 请求超时怎么办;
  • 组件卸载后请求才返回怎么办;
  • 权限变化怎么办;
  • 部分数据成功怎么办。

代码能运行,只证明正常路径可以通过。

工程质量更多体现在:

异常发生时,系统能否以可控方式失败。

4. 数据结构和业务规则被过度简化

例如:

  • 将重复 ID 直接当作 React 的 key;
  • 每次渲染生成随机数或时间戳作为 key;
  • 树形结构递归时忽略循环关系;
  • 金额计算直接使用浮点数;
  • 日期比较忽略时区;
  • 权限判断只在前端完成;
  • 列表去重忽略业务组合键;
  • 批量数据处理忽略空值和重复数据。

这些代码往往能够通过简单测试,但放到生产数据中就可能出问题。


六、比“让 AI 直接写代码”更可靠的工作流

在与前辈交流时,有一句话让我印象很深:

先把方案归档,再让 AI 实施。

这句话背后的价值,不是要求每个小需求都写一份正式设计文档,而是强调:

不要让 AI 在需求不清、边界不明的情况下,一边猜方案,一边大范围修改代码。

我现在更认可下面这套流程。


第一步:先定义问题,不要立即写代码

至少先回答四个问题:

1. 当前现象是什么?
2. 真正目标是什么?
3. 有哪些不能被破坏的约束?
4. 异常发生时,系统应该怎么表现?

以本文案例为例:

现象:
页面初始化需要加载多个字典。

目标:
提高加载效率,同时保留可用结果。

约束:
字典相互独立,单个失败不能阻断整体。

失败策略:
成功项继续展示,失败项单独记录或重试。

写清楚这些内容后,技术方案通常已经完成了一半。


第二步:先让 AI 分析方案,不要立即修改代码

可以这样提问:

请先不要编写或修改代码。

当前场景:
页面初始化需要并发加载多个相互独立的字典接口。

业务约束:
1. 任意一个接口失败,不能影响其他接口结果;
2. 成功数据需要正常展示;
3. 失败项需要记录,并允许后续单独重试;
4. 页面允许部分可用。

请完成:
1. 分析问题的核心矛盾;
2. 提供至少两种实现方案;
3. 说明每种方案的适用条件、失败行为和风险;
4. 推荐一个方案并说明理由;
5. 构造可能使推荐方案失效的反例。

这样,AI 的角色从“直接生成代码”变成了“参与方案评审”。


第三步:主动构造反例

拿到方案后,继续追问:

在什么情况下,这个方案会失效?

对于 Promise.allSettled,也需要继续思考:

  • 某些字典是不是实际上的核心字典?
  • 核心字典失败后是否应该禁用提交?
  • 是否需要区分关键接口和非关键接口?
  • 所有请求都失败时页面如何处理?
  • 是否需要设置超时?
  • 失败接口是否应该自动重试?
  • 字典加载完成前是否允许用户操作?
  • 如何避免组件卸载后继续更新状态?

进一步看,真实方案可能不是简单地全部使用 allSettled,而是分组处理:

const [coreDicts, optionalDicts] = await Promise.all([
  Promise.all([
    getRequiredDictA(),
    getRequiredDictB(),
  ]),
  Promise.allSettled([
    getOptionalDictC(),
    getOptionalDictD(),
  ]),
]);

核心字典要求全部成功,非核心字典允许部分失败。

这比简单争论 all 和 allSettled 更接近真实工程设计。


第四步:让 AI 落地代码

方案确定后,再让 AI:

  • 补充 TypeScript 类型;
  • 封装通用函数;
  • 处理错误日志;
  • 增加重试逻辑;
  • 编写测试用例;
  • 检查影响范围。

例如:

type SettledResult<T> = {
  success: T[];
  failed: unknown[];
};

async function loadIndependentResources<T>(
  tasks: Array<Promise<T>>,
): Promise<SettledResult<T>> {
  const results = await Promise.allSettled(tasks);

  return results.reduce<SettledResult<T>>(
    (acc, result) => {
      if (result.status === 'fulfilled') {
        acc.success.push(result.value);
      } else {
        acc.failed.push(result.reason);
      }

      return acc;
    },
    {
      success: [],
      failed: [],
    },
  );
}

AI 很适合完成这些明确、可验证的实现工作。


第五步:人工终审和异常验证

上线前至少检查三个维度:

场景匹配

  • 当前方案是否符合业务目标?
  • 请求之间究竟是强依赖还是弱依赖?
  • 部分成功是否可能造成错误数据?

边界覆盖

  • 单个请求失败;
  • 多个请求失败;
  • 所有请求失败;
  • 请求超时;
  • 返回空数据;
  • 重复点击;
  • 页面提前卸载。

版本与环境

  • API 是否适用于当前浏览器范围?
  • 框架和依赖版本是否匹配?
  • 是否需要 polyfill?
  • 是否与项目中的请求封装冲突?

AI 可以帮助列出测试项,但最终验收责任仍然在人。


七、基础知识并没有失去意义

在这次交流里,还有一个值得讨论的观点:

有了 AI,以后是不是不需要太关注基础了?

我的答案是:基础知识的作用变了,但并没有消失。

以前学习基础,可能更强调:

  • 记住 API;
  • 手写语法;
  • 背诵概念;
  • 独立查询文档。

现在有了 AI,基础能力更多体现在:

  • 判断 AI 的代码是否符合需求;
  • 看懂不同方案的失败行为;
  • 识别边界条件;
  • 发现版本冲突;
  • 判断性能、安全和维护成本;
  • 在 AI 出错时找到原因。

如果不了解 Promise 的状态变化,就很难发现 Promise.all 不符合业务。

如果不了解 React 的协调机制,就可能接受每次渲染生成随机 key 的建议。

如果不了解前后端权限边界,就可能误以为前端隐藏按钮等于安全控制。

所以,AI 并没有让基础失去价值,而是让基础从:

“我能不能从零写出来”

转变为:

“我能不能判断它为什么这样写,以及这样写会不会出问题”。


八、AI 是工具,不是技术决策的责任主体

有人把 AI 比作“达摩克利斯之剑”。

我认为这个比喻有一定道理。

AI 会放大开发者的能力,也会放大开发者的错误:

  • 有判断力的人,可以用 AI 快速验证方案、补充知识、减少重复劳动;
  • 缺少判断的人,可能会把不合适的代码更快地扩散到整个项目。

因此,与其把 AI 定位为“绝对正确的专家”,不如把它看成:

执行效率很高、知识面很广,但需要明确上下文、持续校验和最终审核的协作者。

一种更合理的协作关系是:

人负责:
定义目标、识别约束、选择方案、评估风险、承担结果。

AI 负责:
搜索资料、解释代码、生成初稿、补充实现、构造测试、提高效率。

人最终负责:
审核、验证、上线和复盘。

AI 可以给出候选答案,但它不能替开发者承担生产事故,也无法自动拥有项目中所有未被表达的业务信息。


九、关于终端、TUI、Vim 和 GUI

交流中还提到了一个观点:

应该尽量减少 GUI,多使用终端、TUI 和 Vim。

我认同学习终端工具的价值,但不认为需要把 GUI 和终端做成二选一。

终端、TUI 和 Vim 能帮助我们:

  • 熟悉命令和工具链;
  • 提升批量处理效率;
  • 进行远程服务器操作;
  • 编写自动化脚本;
  • 更清楚地理解 Git、构建和部署流程。

但 GUI 同样适合:

  • 浏览器调试;
  • 大型项目代码导航;
  • 查看复杂 Git 提交关系;
  • 数据库表结构分析;
  • 可视化性能分析;
  • 处理复杂合并冲突。

真正值得培养的不是“只用哪一种工具”,而是:

理解工具解决了什么问题,并根据场景选择成本最低的方式。

工具偏好不是问题本质,能否完成目标、控制风险、提高质量才是。


十、总结:代码正确,不代表方案正确

这次经历真正让我意识到的,并不是 Promise.allSettled 比 Promise.all 更好,而是:

开发者不能只检查代码是否能够运行,还要判断方案是否符合业务目标、依赖关系和失败策略。

可以将这次经验总结为五点:

  1. AI 给出的代码可能语法正确,但仍然可能与业务场景不匹配;
  2. 多个模型给出相同答案,只能证明方案常见,不能证明方案适合当前项目;
  3. 技术选择不能脱离目标、约束、依赖关系和失败策略;
  4. AI 更适合参与分析和执行,而不应该在上下文不足时独立主导方案;
  5. 程序员真正需要提升的,是定义问题、识别边界、评估方案和验证结果的能力。

以前,我更关注的是:

这段代码怎么写?

现在我会继续追问:

为什么这样写?
它解决的到底是什么问题?
在什么情况下会失效?
失败之后系统会怎样?
是否存在更符合当前业务的方案?

AI 降低了获取知识和编写代码的门槛,但它没有替开发者承担判断责任。

未来真正拉开差距的,可能不再是谁记住了更多 API,而是谁能够更准确地定义问题,并在多个看似正确的答案中,选择真正适合当前业务的方案。


附录:一次技术交流带来的反思

本文源于一次关于 AI 编程的真实交流。

对话中提到了 AI 的双刃剑属性、模型知识边界、开发者基础能力、终端工具,以及“先归档方案,再实施”等观点。

我并不完全认同其中所有结论,例如:

  • 有 AI 以后基础就不重要;
  • GUI 一定会拖累开发者;
  • 多个 AI 给出错误方案一定是模型幻觉。

但这场交流仍然给了我一个很重要的启发:

技术成长不仅是解决眼前的 bug,更重要的是从一次问题中提炼出能够反复使用的判断方法。

相比于记住这次应该使用 Promise.allSettled,更有价值的是记住:

先明确目标
→ 再识别约束
→ 分析底层机制
→ 比较方案边界
→ 构造失败场景
→ 最后实施和验证

这可能才是从“会写代码”走向“能解决问题”的真正起点。