别再靠“感觉”验收 AI 项目了!我用这 4 层自动化“门禁”,守住了 Agent 的上线底线

5 阅读8分钟

作者:前端转 AI 深度实践者

【💡 省流助手 / 核心观点】 AI 项目最危险的验收方式,不是没有测试,而是“我手动问了几句,看起来能用”。在真实的 AI 工程中,Agent 存在接口契约易变、工具调用分支爆炸、模型质量波动等多重风险,靠肉眼点页面只能覆盖 1% 的“开心路径”。 建议前端开发者将验收拆为 4 层:契约测试 (Zod) -> Mock 交互 (Playwright) -> 关键链路 E2E -> 评测集回归。 先守住工程底线,再谈模型效果。


很多团队第一次做 AI 项目,上线前的验收流程大概是这样:

打开页面。
问一句:帮我查订单 A1001。
页面有回答。

再问一句:帮我总结这份文档。
看起来也能答。

点两下按钮,没报错。
发版!🚀

这套方式在 Demo 阶段没问题。但只要项目进入生产环境,它就会迅速失效。

因为 AI 项目的失败方式,比普通 CRUD 多得多:

  • 接口字段少了一个,前端确认弹窗不出现。
  • 模型没拿到资料,却“自信”地编了一个看似合理的答案(幻觉)。
  • 高风险工具没有二次确认,直接执行了删库操作。
  • 权限判断只写在 Prompt 里,被模型轻松绕过。
  • traceId 丢了,线上反馈“答非所问”,排查时完全查不到链路。
  • Loading 状态能跑,但发生 Error 时页面直接白屏,没人测过。
  • 改了一句系统提示词,旧问题的答案质量整体掉了一截。

AI 工程化的浪潮下,前端开发者不再只是 UI 的搬运工。这篇文章不讲“测试很重要”这种空话,我们只解决一个具体痛点:

前端开发者做 AI 项目,如何把“手动点页面验收”升级成一套能进 CI 的上线门禁?


1. 先说清楚:AI 项目到底要验收什么?

很多人一提 AI 验收,就只盯着模型回答:“答得准不准?语气自然不自然?有没有幻觉?”

这些当然重要,但远远不够。AI Agent 的稳定性,很大程度上取决于前端对异常分支的容错处理。 在真实项目中,前端至少要关心 4 类验收对象:

  1. 接口契约:字段是否稳定,错误结构是否一致?
  2. 前端交互:Loading、确认、取消、失败、重试流转是否正确?
  3. Agent 规则:权限拦截、工具调用、风险确认、降级策略是否生效?
  4. 模型质量:检索命中率、答案相关性、引用依据 (Citations) 是否可靠?

如果只靠手动点页面,你最多能覆盖“今天我刚好想到的几条路径”。但 AI 项目的风险,经常藏在你没点到的分支里。


2. 真实惨案:用肉眼验收一个会变化的系统

先看一个很多新手踩坑的错误做法。前端页面只关心 answerloading

❌ 错误做法:防御性为 0 的“随缘型”渲染

// 很多前端默认 AI 只会成功返回一段文本
const response = await fetch("/api/agent", { /* ... */ });
const data = await response.json();

// 致命伤:没考虑 waiting_confirmation、error 等状态
// 一旦后端加了字段或模型报错,页面直接白屏或逻辑错乱
setAnswer(data.answer);
setLoading(false);

这段代码的问题不在于它不能跑,而是它默认 AI 接口永远是个乖孩子。 但真实的 Agent 可能会返回高风险确认状态:

{
  "status": "waiting_confirmation",
  "message": "取消订单属于高风险操作,请确认。",
  "traceId": "tr_20260509_001",
  "confirmation": { "toolName": "cancel_order", "riskLevel": "high" }
}

或者返回权限错误:

{
  "status": "error",
  "errorType": "permission_denied",
  "message": "当前用户无权执行该操作。",
  "traceId": "tr_20260509_002"
}

如果前端只写 data.answer,这些状态在页面上不是消失,就是变成空白。手动验收时,你刚好只问了成功路径,就会误以为项目完美,然后开开心心上线,直到客诉打爆你的钉钉。


3. 第一层门禁:契约测试,把不确定性挡在门外

AI 项目的第一层验收,不是看模型聪不聪明,而是看接口契约稳不稳定。

✅ 正确做法:用 Zod 强行锁死契约

import { z } from "zod";

// 1. 定义严丝合缝的契约,明确所有可能的分支
export const AgentResponseSchema = z.discriminatedUnion("status", [
  z.object({
    status: z.literal("success"),
    answer: z.string(),
    traceId: z.string().min(1),
    citations: z.array(z.object({ title: z.string(), url: z.string().optional() })),
  }),
  z.object({
    status: z.literal("waiting_confirmation"),
    message: z.string(),
    traceId: z.string().min(1),
    confirmation: z.object({ toolName: z.string(), riskLevel: z.enum(["medium", "high"]) }),
  }),
  z.object({
    status: z.literal("error"),
    errorType: z.enum(["permission_denied", "tool_failed", "retrieval_empty"]),
    message: z.string(),
    traceId: z.string().min(1),
  }),
]);

export type AgentResponse = z.infer<typeof AgentResponseSchema>;

// 2. 在请求层进行强校验
export async function sendAgentMessage(question: string): Promise<AgentResponse> {
  const response = await fetch("/api/agent", { /* ... */ });
  const json = await response.json();
  
  // 只有通过契约的请求,才能进入业务逻辑
  return AgentResponseSchema.parse(json); 
}

契约测试不用等完整页面,直接测请求解析函数:

import { describe, expect, it } from "vitest";

describe("AgentResponseSchema", () => {
  it("rejects response without traceId", () => {
    expect(() =>
      AgentResponseSchema.parse({
        status: "success",
        answer: "订单已发货",
        // 缺少 traceId,立刻抛错拦截!
      }),
    ).toThrow();
  });
});

这一步非常值钱:后端字段一变,前端立刻报错;traceId 丢了,测试立刻发现。这是防止“接口轻微变更,页面静默坏掉”的最有效手段。


4. 第二层门禁:Mock Agent 交互,点亮所有黑暗分支

手动点页面最大的问题是点不到所有 Agent 分支。正确做法是让前端在测试里接管 /api/agent,把成功、确认、权限失败、空检索等分支都 Mock 出来。

以 Playwright 为例,测试高风险确认拦截:

import { expect, test } from "@playwright/test";

test("拦截高风险操作,必须展示确认卡片", async ({ page }) => {
  // Mock Agent 返回等待确认状态
  await page.route("/api/agent", async (route) => {
    await route.fulfill({
      contentType: "application/json",
      body: JSON.stringify({
        status: "waiting_confirmation",
        message: "取消订单属于高风险操作,请确认。",
        traceId: "tr_confirm_001",
        confirmation: { toolName: "cancel_order", riskLevel: "high" },
      }),
    });
  });

  await page.goto("/agent");
  await page.getByLabel("问题").fill("帮我取消订单 A1001");
  await page.getByRole("button", { name: "发送" }).click();

  // 核心断言:必须出现警告和确认按钮
  await expect(page.getByText("取消订单属于高风险操作")).toBeVisible();
  await expect(page.getByRole("button", { name: "确认执行" })).toBeVisible();
});

把 Agent 的每个重要分支,变成可重复执行的页面自动化测试,你晚上才能睡得着觉。


5. 第三层门禁:关键链路 E2E,只守最值钱的路径

Mock Agent 能保证前端状态机不坏。但上线前还需要少量真实 E2E,确认前后端、Agent、工具和日志能真实串起来。

⚠️ 注意:不要把所有用例都做成真实 E2E。 真实模型慢且贵,极易由于网络抖动导致测试 flaky(脆弱)。

我一般建议只守这 5 条生死线:

  1. 普通问答成功:页面展示答案、引用 (Citations) 和 traceId。
  2. 检索为空降级:页面不展示编造答案,明确提示资料不足。
  3. 权限不足拦截:页面展示明确错误,不出现操作按钮。
  4. 高风险阻断:先出现确认卡片,绝不直接执行工具。
  5. 确认后执行:二次确认请求发出后,页面进入最终状态。

连这 5 条自动化都没有,上线等于“裸奔”。


6. 第四层门禁:评测集回归,别让模型质量悄悄掉

前面三层解决的是工程底线。但 AI 项目还有一个幽灵:模型效果退化。 改了 Prompt、切了模型版本、调了温度参数……页面不报错,但答案变蠢了怎么办?

答案是:建立最小评测集 (Evaluation Set)。

// 一个朴素但管用的评测 Case 定义
export const evalCases = [
  {
    id: "order-status-a1001",
    question: "订单 A1001 现在是什么状态?",
    mustContain: ["A1001", "待发货"], // 必须包含关键信息
    mustNotContain: ["已退款", "已取消"], // 绝对不能乱编的状态
    expectedCitationCount: 1, // 必须有引用来源
  }
];

每次发版前,用脚本跑一遍这些核心 Case。不用追求高大上的多维评测框架,先从“20 条高频问题、10 条失败路径、10 条线上坏 case”开始,这就足以挡掉 80% 的“越改越烂”。


7. 生产环境避坑指南 (💡 重点收藏)

  1. 别在 CI 流程里跑全量大模型测试。
    • 真相:不仅慢,还会烧光你的 Token 预算。PR 阶段用 Mock (第二层) 跑前端逻辑,发版前再跑真实 E2E (第三层) 和评测集 (第四层)。
  2. 别相信模型能自己搞定权限。
    • 真相:Prompt 里的“你没有权限执行 X”是防君子不防小人。权限必须由后端工具网关 (Tool Gateway) 兜底,并返回对应的 errorType 给前端展示。
  3. 漏掉 traceId 等于在线上“盲跑”。
    • 真相:用户反馈“AI 刚才回答不对”时,如果没有 traceId,你根本不知道是检索没搜到、模型抽风了,还是上下文被截断了。traceId 必须作为前端契约的必填项。
  4. 别让评测集只活在某个人电脑里。
    • 真相:评测集应该随代码入库。每新增一个线上 Bad Case,就加一条回归用例。把线上事故变成系统的免疫力。

结语

AI 项目不能靠手动点页面验收。不是因为手动验收没价值(它适合探索体验),而是它撑不起回归保障

构建这套 4 层验收体系,是前端迈向高级 AI 工程师的必经之路:

  1. 先用 契约测试 守字段。
  2. 再用 Mock 交互 守页面状态流转。
  3. 再用 关键链路 E2E 守真实业务闭环。
  4. 最后用 评测集 守模型质量基线。

做到这一步,你的 AI 项目才不再是“今天看起来能用”,而是“下次改完我也敢大胆发版”!

你的团队是怎么验收 AI Agent 的?欢迎在评论区交流踩过的坑!如果觉得有启发,点个赞和收藏防走丢哦~ 👇


Tags: #前端 #人工智能 #AI Agent #自动化测试 #大模型