为了交付一个AI辅助开发的项目,我们搭了一套质量保障体系

13 阅读1分钟

为了交付一个AI辅助开发的项目,我们搭了一套质量保障体系

上周五代码评审会上,一个同事展示了他用 Cursor 二十分钟生成的完整 CRUD 模块,在场所有人都觉得挺酷。周一上线后,这个模块在并发场景下把数据库连接池打满了——AI 生成的代码在每次请求里都新建了一个数据库连接,没有复用。这件事让我想起刚结束的那个项目重构。

这篇文章不是要唱衰 Vibe Coding,恰恰相反,我们团队现在每个人都在用 AI 编程工具。但用了半年之后,我越来越确信一件事:AI 能帮你写出 80 分的代码,但从 80 分到 95 分的那段路,比从 0 到 80 分更难走。

我们踩过的三个典型坑

坑一:类型体操看着对,运行时炸了

我们项目用的是 TypeScript 4.9 + Vue 3.3 的组合。在重写权限模块时,一个同事让 AI 生成了一套基于角色的权限判断逻辑。AI 给出的代码类型定义写得很漂亮:

type Permission = 'read' | 'write' | 'admin';
type RolePermissionMap = Record<string, Permission[]>;

function hasPermission(
  userRoles: string[],
  requiredPermission: Permission,
  roleMap: RolePermissionMap
): boolean {
  return userRoles.some(role => 
    roleMap[role]?.includes(requiredPermission)
  );
}

TypeScript 编译没有任何报错,逻辑看起来也清晰。问题出在哪?出在我们的实际业务场景里,权限不是简单的字符串匹配。我们有"数据权限"的概念——同一个角色在不同部门下的权限不同。AI 生成的代码完全没有考虑这个维度,因为它不知道我们的业务上下文。这个 bug 直到 QA 用跨部门账号测试时才发现,整个权限模块的核心函数需要重写。

这就是 Vibe Coding 的第一个边界:AI 能写出语法正确、类型安全的代码,但它对你的业务上下文几乎一无所知。 你给它的 prompt 越简短,它脑补的业务逻辑就越多,而这些脑补往往是错的。

坑二:看似优雅的抽象,实则过度设计

重构营销活动模块时,我让 AI 帮忙设计一个活动配置的数据流方案。

// AI 生成的"优雅"方案(简化版)
interface ActivityStrategy {
  validate(config: ActivityConfig): ValidationResult;
  calculate(config: ActivityConfig): PriceResult;
  render(config: ActivityConfig): RenderSchema;
}

class ActivityStrategyFactory {
  private strategies = new Map<ActivityType, ActivityStrategy>();
  
  register(type: ActivityType, strategy: ActivityStrategy) {
    this.strategies.set(type, strategy);
  }
  
  getStrategy(type: ActivityType): ActivityStrategy {
    const strategy = this.strategies.get(type);
    if (!strategy) throw new Error(`Unknown activity type: ${type}`);
    return strategy;
  }
}

代码质量高吗?从设计模式的角度看,挑不出毛病。但我们的营销活动一共就三种类型:满减、折扣、赠品。三种。用 Map + 策略注册 + 工厂这一套,是拿大炮打蚊子。

我们最后用了一个简单的 switch-case,加上三个纯函数,总共不到 80 行代码,可读性远好于 AI 给的方案。

// 实际采用的方案
function calculatePrice(type: ActivityType, config: ActivityConfig, originalPrice: number) {
  switch (type) {
    case 'full_reduction':
      return originalPrice >= config.threshold 
        ? originalPrice - config.reduction 
        : originalPrice;
    case 'discount':
      return originalPrice * config.discountRate;
    case 'gift':
      return originalPrice; // 赠品不影响价格
    default:
      throw new Error(`Unsupported activity type: ${type}`);
  }
}

这背后的规律是:AI 训练数据里充斥着开源项目和技术博客的代码,这些代码天然倾向于展示"最佳实践"和"设计模式"。但在实际项目里,最佳实践要匹配问题规模。三种活动类型用策略模式,就像一个人住的房子装了中央空调——技术上没错,但没必要。

坑三:异步竞态,AI 的盲区

这个坑最隐蔽,也是排查时间最长的。我们的活动列表页有一个搜索功能,用户输入关键词后实时请求接口。AI 生成的搜索组件用了 watch + debounce 的经典组合:

<script setup lang="ts">
const keyword = ref('');
const list = ref<Activity[]>([]);

const debouncedSearch = useDebounceFn(async (val: string) => {
  const res = await fetchActivities({ keyword: val, page: 1 });
  list.value = res.data;
}, 300);

watch(keyword, (val) => {
  debouncedSearch(val);
});
</script>

看起来没问题对吧?debounce 300 毫秒,避免频繁请求,教科书式的写法。

但实际使用中出现了一个诡异的 bug:用户快速输入"春节"两个字,列表偶尔会显示搜索"春"的结果,而不是"春节"的结果。原因是第一个请求(搜"春")因为网络波动返回比第二个请求(搜"春节")慢,后发先至,把正确的结果覆盖了。

这就是经典的异步竞态问题。

// 修复后的方案:用 AbortController 取消过期请求
const abortControllerRef = ref<AbortController | null>(null);

const debouncedSearch = useDebounceFn(async (val: string) => {
  // 取消上一次未完成的请求
  abortControllerRef.value?.abort();
  const controller = new AbortController();
  abortControllerRef.value = controller;
  
  try {
    const res = await fetchActivities(
      { keyword: val, page: 1 },
      { signal: controller.signal }
    );
    list.value = res.data;
  } catch (e) {
    if (e instanceof DOMException && e.name === 'AbortError') return;
    throw e;
  }
}, 300);

这类问题 AI 几乎不会主动帮你处理,因为在大多数 demo 级别的代码里,竞态条件不会暴露。只有在真实网络环境、真实用户操作下,这些幽灵般的 bug 才会出现。我们在整个重构过程中统计了一下:AI 生成的代码中,和异步时序相关的 bug 占了所有线上问题的 35%。 这个比例远超我们的预期。

Vibe Coding 的四条边界线

踩完上面这些坑,我们团队逐渐摸清了 AI 编程的能力边界。

边界一:单文件能力强,跨文件协调弱

AI 在单个文件内生成代码的质量相当不错,函数逻辑、类型定义、组件结构都能处理。但一旦涉及跨文件的协调——比如修改一个公共类型定义后,所有引用处需要同步更新——AI 的表现就大幅下降。打个比方:AI 像一个手艺很好的泥瓦匠,你让他砌一面墙,砌得又快又整齐。但让他协调整栋楼的结构承重,他就力不从心了。他看不到隔壁那面墙的受力情况。

在我们项目中,公共模块(utilshookstypes)的修改,AI 的参与度不到 20%,基本靠人工维护。业务组件内部的逻辑,AI 的参与度能到 70% 以上。

边界三:生成快,审查难

生成一段 50 行的代码可能只要 10 秒,但认真审查这 50 行代码至少需要 5 分钟。如果 AI 一天帮你生成了 2000 行代码,你需要多少时间来审查?我们团队真实的数据是:在重构高峰期,每天新增代码量大约是以前的 2.5 倍,但代码评审的时间不但没有减少,反而增加了约 40%。因为 AI 生成的代码风格不统一、命名习惯各异、有时还会引入团队不熟悉的 API 用法,审查者需要额外的时间去理解和验证。

边界四:写得出来,解释不了

当 AI 生成的代码出了 bug,你让它解释为什么这样写,它的回答往往是重新组织一遍代码的逻辑描述,而不是真正的设计决策说明。它不知道自己为什么选了方案 A 而不是方案 B,因为它根本没有"选择"的过程——它只是根据概率生成了最可能的 token 序列。这在排查问题时尤其痛苦。你面对一段 AI 写的代码,既不知道它的设计意图,也不知道它是否考虑了某个边界条件,唯一的办法就是从头读一遍,自己重建心智模型。

我们搭建的质量保障体系

认清了这些边界之后,我们没有选择减少 AI 的使用,而是围绕 AI 的特点建了一套流程。核心思路是:让 AI 做它擅长的生成工作,让人做 AI 不擅长的审查和决策工作,然后用自动化工具覆盖两者都容易遗漏的部分。

第一层:Prompt 规范化

我们发现团队成员给 AI 写的 prompt 质量差异很大。有人写"帮我写一个用户列表",有人写"用 Vue 3 Composition API 写一个用户列表组件,使用 Element Plus 的 el-table,需要支持分页和按姓名搜索,接口是 GET /api/users,返回格式是 { list: User[], total: number }"。后者生成的代码质量远高于前者,这不难理解。但问题是,写好的 prompt 本身需要时间和经验。

我们的做法是建了一个 prompt 模板库,放在项目的 .ai/prompts/ 目录下,按场景分类。每个模板包含:项目技术栈、代码规范约束、必须处理的边界情况。使用时只需要填入业务相关的部分。

<!-- .ai/prompts/table-component.md -->
## 上下文
- 框架:Vue 3.3 + TypeScript 4.9 + Element Plus 2.4
- 状态管理:Pinia
- 请求库:封装的 axios,统一用 useRequest hook
- 代码规范:Composition API,禁止 Options API

## 必须处理的场景
- 空数据状态(显示 el-empty)
- 请求失败的错误提示(用 ElMessage.error)
- 分页参数变化时取消上一次未完成的请求
- 搜索输入做 300ms debounce

## 你的任务
生成一个 [具体业务] 的列表组件...

这个模板库的效果非常明显。使用模板之后,AI 生成代码的一次通过率(不需要人工修改就能通过代码评审)从大约 25% 提升到了 55% 左右。

第二层:AI 专项 Code Review Checklist

传统的 Code Review 主要关注逻辑正确性、性能、安全性。针对 AI 生成的代码,我们额外增加了一份检查清单:

依赖检查:AI 是否引入了项目中没有的第三方库?是否使用了已废弃的 API?我们遇到过 AI 在代码里用 moment.js 的情况——我们项目统一用的是 dayjs。还有一次 AI 用了 Array.prototype.at(),而我们需要兼容的最低浏览器版本不支持这个方法。

幻觉检查:AI 是否编造了不存在的 API?这在调用后端接口时尤其常见。AI 有时候会自信地写出一个看起来很合理但实际不存在的接口路径或参数名。我们要求所有 AI 生成的接口调用代码必须和后端 API 文档逐字段比对。

一致性检查:命名风格是否和项目现有代码一致?错误处理方式是否遵循团队约定?import 路径别名是否正确?我们项目用 @/ 指向 src/,但 AI 有时会生成 ../../ 的相对路径,混用之后代码很难维护。

冗余检查:AI 是否生成了不必要的代码?比如对已经在全局 axios 拦截器中处理过的 HTTP 错误,又在组件里写了一遍 try-catch。这类冗余不影响功能,但会误导后续维护者,以为业务上需要特殊的错误处理。

第三层:自动化防护网

人工检查能覆盖的面有限,我们用自动化工具兜底。

ESLint 自定义规则是第一道防线。我们针对 AI 常见问题写了几条自定义规则,比如禁止在组件内直接 new AbortController() 而不清理(必须在 onUnmounted 中 abort),比如禁止在 watch 回调里直接写 async 而不处理竞态。

// eslint-plugin-team/rules/no-unclean-abort-controller.js
module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description: 'AbortController must be cleaned up in onUnmounted',
    },
  },
  create(context) {
    let hasAbortController = false;
    let hasCleanup = false;

    return {
      NewExpression(node) {
        if (node.callee.name === 'AbortController') {
          hasAbortController = true;
        }
      },
      CallExpression(node) {
        if (node.callee.name === 'onUnmounted') {
          hasCleanup = true;
        }
      },
      'Program:exit'() {
        if (hasAbortController && !hasCleanup) {
          context.report({
            message: 'AbortController created but no onUnmounted cleanup found',
            loc: { line: 1, column: 0 },
          });
        }
      },
    };
  },
};

这条规则看起来简单,但在我们项目里拦截了不下 10 次 AI 生成的遗漏清理的代码。

类型覆盖率检测是第二道防线。AI 有时候会用 any 来绕过复杂的类型推导,我们在 CI 流程中加了 typescript-coverage-report,要求类型覆盖率不低于 92%。重构前这个数字是 78%,重构完是 94%。每次 AI 偷偷塞的 any 都会在 CI 中被拦住。

集成测试覆盖关键路径是第三道防线。我们没有要求 AI 生成的每个函数都有单元测试,那样成本太高。但对核心业务路径——活动创建、价格计算、权限判断——我们写了端到端的集成测试,用 Playwright 模拟真实用户操作。这些测试不关心代码实现细节,只验证业务结果是否正确,AI 怎么写都行,只要测试过了就行。

// e2e/activity-create.spec.ts
test('创建满减活动后价格计算正确', async ({ page }) => {
  await page.goto('/activity/create');
  
  // 选择活动类型
  await page.getByLabel('活动类型').click();
  await page.getByText('满减').click();
  
  // 填写满减规则
  await page.getByLabel('满减门槛').fill('200');
  await page.getByLabel('减免金额').fill('30');
  
  await page.getByRole('button', { name: '保存' }).click();
  await expect(page.getByText('创建成功')).toBeVisible();
  
  // 验证价格计算
  await page.goto('/order/preview?amount=250');
  await expect(page.getByTestId('final-price')).toHaveText('220');
});

第四层:架构决策人工把关

这一层没有工具,纯靠人。我们在项目中明确了一个规则:所有涉及架构的决策,AI 只能提供参考方案,最终由架构师决定。

什么算"架构决策"?我们列了一个清单:

  • 新增或修改公共模块的接口定义
  • 引入新的第三方依赖
  • 改变数据流向(比如从 props 传递改为 Pinia 全局状态)
  • 定义新的文件组织规则或目录结构
  • 数据库表结构变更

之所以这样做,是因为架构决策的影响面大、修改成本高。AI 在这类问题上给出的方案往往技术上可行,但不一定适合你的团队和项目阶段。它不知道你的团队有三个前端一个后端、不知道你们下个季度要做微前端拆分、不知道你们的运维只支持 Docker 部署不支持 Serverless。

对比:同一个需求,纯 Vibe 模式 vs 质量保障模式

最后用一个具体案例收尾。需求是:做一个实时搜索的下拉选择器,支持远程数据源、防抖请求、键盘导航、支持清空。

纯 Vibe 模式下,把需求直接丢给 AI,平均 30 秒拿到一个组件。我们测了五次,五次生成的代码都有以下问题中的至少两个:

  1. 没有处理请求竞态(5/5 都没有)
  2. 键盘导航时 aria 属性缺失(4/5)
  3. 清空时没有重置搜索状态(3/5)
  4. onClickOutside 没有在卸载时移除(3/5)
  5. 下拉框定位没有考虑屏幕边缘翻转(5/5)

质量保障模式下的做法:先用模板写 prompt(包含竞态处理、无障碍、清理等要求),AI 生成初版代码,对照 checklist 做 review,让 AI 修复发现的问题,跑已有的组件集成测试,最后人工确认。整个过程大约 25 分钟。

25 分钟 vs 30 秒,差距看起来很大。但 30 秒生成的版本,实际投入使用前还需要人工修复上面那些问题,保守估计也要 30-40 分钟。而且因为是在 AI 已有代码上修修补补,修出来的代码往往不如一开始就约束好来得干净。

对比维度纯 Vibe 模式质量保障模式
初版生成时间30s8min(含写 prompt)
达到可上线的总时间35-45min25min
竞态处理
无障碍支持缺失完整
组件卸载清理遗漏完整
后续维护时心智负担高(补丁式修复)低(结构清晰)

这个对比不是要否定 Vibe Coding 本身,而是说明:在生产环境中,Vibe Coding 的"Vibe"不能停留在"看感觉差不多",而是要让这个"感觉"建立在可验证的规则之上。

我们团队现在的共识是:AI 是一个输出极快但质量波动大的初级工程师。你不会让一个刚入职的初级工程师独立负责核心模块,但你会让他在明确的规范和 review 机制下承担大量执行工作。对 AI 的定位也是一样的。

以上就是我们在项目重构中关于 AI 编程质量保障的完整实践。模板库、checklist、ESLint 规则这些东西都不复杂,团队花一两周就能搭起来。但如果你跳过这些直接 Vibe,等到线上出了问题再补,成本会高得多——我们最开始那两个月就是活生生的例子。