如何在不烧钱的情况下测试AI产品
原文:https://dev.to/debs_obrien/how-we-test-an-ai-product-without-burning-credit-4c5p
大多数测试都很廉价。点击按钮,断言某些变化,运行一千次也无人察觉。但测试AI产品则不同,因为有趣的行为来自模型,每次触发都需要付费。
我们在AI Platform上构建课程产品时直接遇到了这个问题。我想带您了解我们最终如何使用Playwright对整个聊天流程进行端到端测试。
首先,平台是什么
Zephyr Cloud的AI Platform (TAP) 是一款桌面应用,团队可以在频道中与AI专家协作。可以理解为Slack,但频道中的某些成员是你可以对话、提及和分配任务的AI代理。
创建频道,提及专家,它就会像其他成员一样在对话中回复。这个聊天界面是产品的核心,因此也是我们最需要信任的部分。
Kent的课程是什么
我们与Kent C. Dodds合作,在平台上构建了一个课程试点项目。您可以在此处阅读公告。
该课程是一个产品工程工作坊,存在于平台内一个名为Course Studio的界面中。学习者不是观看视频,而是通过与AI利益相关者聊天来完成真实练习。有一位名为Kody的向导帮助你构建问题框架,还有像产品VP这样的利益相关者专家,你可以通过采访收集证据。完成后,你撰写一份简短备忘录,一个隐藏的AI评估器会读取整个对话并根据评分标准打分。
所以这是一个真正的AI产品,层层叠加在另一个AI产品之上。使用起来很愉快,测试起来有点吓人。
为什么测试很困难
从测试的角度看一个练习。要完成它,学习者需要:
- 提及向导并获取响应
- 采访一个或多个利益相关者并获取响应
- 提交备忘录
- 触发评估器,读取所有内容并返回分数
每一步都是真实的模型调用。路由消息以决定谁回答是一次调用。每个专家回复是一次调用。评估器是另一次调用,而且是一次大调用,因为它读取整个对话。
现在乘以五个练习,再乘以每次测试套件运行的次数。如果我们以显而易见的方式测试,成本会随着每次运行而攀升,而且随着我们添加更多内容,套件会变得更慢、更不稳定。这不是任何人想要运行的套件。
思路:替换模型,保持其他一切真实
这是我喜欢的地方。我们没有模拟整个应用或存根UI。我们保持所有内容真实,只替换了一个昂贵的部分:模型。
这些测试使用Playwright驱动实际的桌面应用,与学习者运行的应用相同。为了实现可测试性,应用在window上暴露了其聊天提供者。我们的测试工具介入,保留对真实提供者的引用,并包装了原本会与模型通信的三个函数:
- 决定谁应该回答的函数
- 发送专家回复的函数
- 将更新推回UI的事件流
当测试提及专家时,测试工具用脚本化的回复代替调用模型。当评估器运行时,它返回固定的评分结果。其他一切——消息、时间线、头像、评分条——仍然通过真实代码运行,并完全按照学习者看到的方式渲染。

消息仍然通过真实聊天API保存。因此我们正在端到端地测试真实的产品体验。唯一缺少的是账单。
你可能会问为什么我们不直接模拟网络。我们想要真实的routing、真实的消息管道和真实的UI运行,而这些正是网络模拟会跳过的部分。在提供者层面拦截是最小的替换,保留了学习者实际接触的所有内容。
我们实际测试什么
我们运行所有五个练习作为完整完成流程。对于每个练习,测试:
- 打开一个新的尝试,创建真实的聊天室
- 提及向导并断言正确的回复到达
- 采访每个利益相关者并检查他们的响应
- 提交备忘录
- 观察真实的评分条填充到100%
这读起来几乎像是对学习者行为的描述,这正是你希望测试看起来的样子。以下是其结构:
test('学习者完成练习,不消耗积分', async ({ app }) => {
await installDeterministicHarness(app.page, config);
await openExerciseAttempt(app.page, config); // 真实聊天室,真实UI
// 提及专家并断言脚本化回复
await sendMentionedMessage(app.page, 'Kody', '帮我构建问题框架。');
await expectAssistantMessage(app.page, config.kodyResponseAnchor, 'Kody');
// 提交备忘录,观察真实评分条填充
await sendPlainMessage(app.page, config.finalMemo);
await expect(app.page.getByTestId('course-attempt-eval-chip')).toHaveText(
/100%/,
);
// 证明评估器被拦截,未计费
await expect
.poll(async () => (await readHarnessCalls(app.page)).evaluatorCallCount)
.toBeGreaterThan(0);
});
保持诚实的护栏
这种方法的陷阱在于:一个看似免费但悄悄进行真实调用的测试比没有测试更糟糕,因为你信任它,而账单却在悄悄攀升。
因此测试工具是严格的。任何它没有脚本的消息都不会回退到真实调用。它会返回无害的"忽略"代替。非评估器的专家轮次返回阻塞的存根。我们断言评估器确实被拦截,这样测试永远不会默默跳过它本应检查的内容。
我知道这很重要,因为我曾发布过一个标题为以下内容的修复:
test(course-studio): 防止确定性测试使用提供者
一个未匹配的消息曾经悄悄通过到真实提供者。它工作了,测试通过了,但每次运行都在悄悄花钱。修复方法是让测试工具永远拒绝这样做。测试项目甚至以no-credit命名,这样一眼就能看出哪些套件是安全的。
什么有效,什么无效
比我预期效果更好的:保持UI真实。因为我们只替换模型,测试能捕获真实的UI回归。如果时间线停止渲染回复,或评分条停止更新,测试会失败,这是真正的产品bug,而不是模拟不同步。
并非免费获得:
- 保持脚本与产品同步。脚本化回复必须随着练习变化而保持可信。当内容变动时,fixtures也必须随之变动。
- 使失败诚实。大部分工作不是伪造happy path,而是确保测试工具不能撒谎。"忽略未匹配消息"规则和评估器断言都存在,因为天真的版本看起来正常,却在做错误的事情。
- webview边界。这些测试针对真实运行的应用,这对信心很好,但意味着它们不是你在每次按键时运行的快速、隔离的单元测试。它们是独立的层级。
这对你意味着什么
测试过去主要关乎正确性。对于AI产品,它还关乎成本,两者相互矛盾。模拟太多,测试通过而真实产品悄悄崩溃。模拟太少,每次运行都花钱。
对我们来说,解决之道是找到堆栈中最昂贵的单次调用,并在尽可能接近模型的地方拦截它,然后让其他一切真实运行。你保持诚实的端到端覆盖,成本不再随测试数量增长。如果你在模型之上构建,你也会遇到同样的障碍,我认为大多数团队最终会在某个地方画这样一条线。
真正值得厘清的唯一问题是:你能否信任自己划定的界限。那些看似自由却暗藏真实调用的测试最为危险,因为你会因此放松对账单的监控。因此,无论你将界限划在何处,当有事物越界时,务必发出响亮的警报。这才是值得精心构建的部分。