你的 LLM 应用有测试吗?大概率有。能在 CI 里跑吗?大概率不能。
这不是玩笑。我见过的大多数 AI 应用测试,要么跑一次要花几十秒、调几次真实 API,要么直接跳过所有含 LLM 调用的路径,只测"纯逻辑"部分。结果就是:主干代码的核心分支------所有跟模型打交道的部分------长期没有 CI 覆盖。
这篇文章想解决一个具体问题:如何在不调用真实 LLM API 的前提下,让你的 AI 代码在 CI 里正常跑通。
问题根源:LLM 调用是一个糟糕的测试依赖
先把问题说清楚。LLM API 调用有几个让测试变得很难的属性:
不确定性:同一个 prompt,两次调用可能返回不同内容。测试无法断言具体输出。
慢:一次调用少则 1 秒,多则 30 秒。一百个测试用例,哪怕每个只调一次,也要跑十几分钟。
贵:Token 是钱。CI 一天跑几十次,一个月的 API 账单可以很难看。
有外部状态:依赖网络、依赖服务商在线、依赖账号余额。任何一个断了,测试整体失败。
难以注入边界场景:你想测"模型输出了格式错误的 JSON 怎么办",但没办法让真实模型稳定地给出错误输出。
传统软件早就解决了这类问题,手段就是测试替身(Test Doubles)。把外部依赖替换成一个可控的假实现,测试就可以又快又稳又便宜。
LLM 应用同样适用这套思路,但有几个地方需要特别处理。
测试替身的四种类型,以及 LLM 语境下分别对应什么
先快速过一遍经典分类,然后对应到 LLM 场景:
| 类型 | 作用 | LLM 场景对应 |
|---|---|---|
| Stub | 返回预设值,不验证调用 | 返回固定 JSON / 固定文本,测主流程 |
| Fake | 有真实业务逻辑的简化实现 | 按 prompt 关键词路由返回不同响应 |
| Mock | 验证调用参数和次数 | 验证 prompt 是否包含必要字段 |
| Spy | 记录调用,不干预行为 | 记录每次请求,用于 Eval 数据收集 |
实际工程里,Stub 和 Fake 是用得最多的。Mock 用于需要验证调用契约的场景,Spy 在 AI 应用里更多用于 Tracing。
第一步:把 LLM Client 隔离出来,变成可注入的依赖
这是所有替身技术的前提。如果你的代码直接 import 某个 SDK 然后在业务逻辑里直接初始化,就没有注入替身的空间。
错误做法:依赖硬编码在业务逻辑里
typescript
// ❌ 这样写,测试时没法换掉 LLM
import DeepSeek from 'deepseek-sdk';
export async function summarize(text: string): Promise<string> {
const client = new DeepSeek({ apiKey: process.env.DEEPSEEK_API_KEY });
const res = await client.chat.completions.create({
model: 'deepseek-chat',
messages: [
{ role: 'system', content: '你是一个摘要助手。' },
{ role: 'user', content: text },
],
});
return res.choices[0].message.content ?? '';
}
正确做法:通过接口注入
typescript
// llm-client.interface.ts
export interface LLMClient {
chat(params: {
model: string;
messages: { role: 'system' | 'user' | 'assistant'; content: string }[];
temperature?: number;
max_tokens?: number;
}): Promise<{ content: string; usage: { prompt_tokens: number; completion_tokens: number } }>;
}
// summarizer.ts
import { LLMClient } from './llm-client.interface';
export class Summarizer {
constructor(private llm: LLMClient) {}
async summarize(text: string): Promise<string> {
const res = await this.llm.chat({
model: 'deepseek-chat',
messages: [
{ role: 'system', content: '你是一个摘要助手,输出不超过 100 字。' },
{ role: 'user', content: text },
],
});
return res.content;
}
}
现在 Summarizer 不关心 LLM 是谁,只关心接口。测试时可以传入任意实现了 LLMClient 接口的对象。
第二步:写一个最简单的 Stub
Stub 的目标是"够用就行"------返回一个固定的合法响应,让主流程能跑通。
typescript
// llm-stub.ts
import { LLMClient } from './llm-client.interface';
export class LLMStub implements LLMClient {
constructor(private fixedResponse: string = '这是测试摘要。') {}
async chat(_params: Parameters<LLMClient['chat']>[0]) {
return {
content: this.fixedResponse,
usage: { prompt_tokens: 10, completion_tokens: 5 },
};
}
}
typescript
// summarizer.test.ts
import { Summarizer } from './summarizer';
import { LLMStub } from './llm-stub';
describe('Summarizer', () => {
it('应该返回 LLM 的响应内容', async () => {
const stub = new LLMStub('这是一个测试摘要。');
const summarizer = new Summarizer(stub);
const result = await summarizer.summarize('一段很长的文章内容...');
expect(result).toBe('这是一个测试摘要。');
});
it('LLM 返回空字符串时应该处理', async () => {
const stub = new LLMStub('');
const summarizer = new Summarizer(stub);
const result = await summarizer.summarize('内容');
expect(result).toBe(''); // 或者你的业务逻辑会做 fallback
});
});
这个测试运行时间:几毫秒。无网络调用,无费用。
第三步:写一个 Fake,处理有分支逻辑的测试
Stub 只能返回固定值,测不了"根据不同输入走不同分支"的场景。Fake 有真实(但简化)的内部逻辑。
场景: 你的 AI 助手会判断用户意图,然后路由到不同的处理函数。测试时需要验证路由逻辑正确。
typescript
// llm-fake.ts
import { LLMClient } from './llm-client.interface';
/**
* FakeLLM:根据 user 消息中的关键词,返回不同的预设响应。
* 用于测试依赖 LLM 输出做分支的业务逻辑。
*/
export class FakeLLM implements LLMClient {
private routes: Array<{ trigger: RegExp | string; response: string }> = [];
private defaultResponse: string = '{"intent": "unknown"}';
addRoute(trigger: RegExp | string, response: string): this {
this.routes.push({ trigger, response });
return this;
}
setDefault(response: string): this {
this.defaultResponse = response;
return this;
}
async chat(params: Parameters<LLMClient['chat']>[0]) {
const userMessage = params.messages
.filter(m => m.role === 'user')
.map(m => m.content)
.join(' ');
for (const route of this.routes) {
const matches =
typeof route.trigger === 'string'
? userMessage.includes(route.trigger)
: route.trigger.test(userMessage);
if (matches) {
return { content: route.response, usage: { prompt_tokens: 20, completion_tokens: 10 } };
}
}
return { content: this.defaultResponse, usage: { prompt_tokens: 20, completion_tokens: 5 } };
}
}
typescript
// intent-router.test.ts
import { IntentRouter } from './intent-router';
import { FakeLLM } from './llm-fake';
describe('IntentRouter', () => {
let fake: FakeLLM;
let router: IntentRouter;
beforeEach(() => {
fake = new FakeLLM()
.addRoute('预约', '{"intent": "booking", "confidence": 0.95}')
.addRoute('取消', '{"intent": "cancel", "confidence": 0.90}')
.addRoute('查询', '{"intent": "query", "confidence": 0.85}')
.setDefault('{"intent": "unknown", "confidence": 0.30}');
router = new IntentRouter(fake);
});
it('包含"预约"关键词时应该路由到 booking handler', async () => {
const result = await router.route('我想预约明天的服务');
expect(result.handler).toBe('BookingHandler');
});
it('低置信度 intent 应该转人工', async () => {
const result = await router.route('blahblah 无法识别');
expect(result.handler).toBe('HumanHandoffHandler');
});
});
关键点:这里测的不是"LLM 是否聪明",而是"你的路由逻辑是否正确"。FakeLLM 扮演一个可控的"LLM",让你能稳定地构造各种输出场景。
第四步:测试错误路径------LLM 的 4 类失败场景
这是测试替身最重要的使用场景之一:测真实 LLM 很难复现的错误情况。
typescript
// llm-failure-stubs.ts
import { LLMClient } from './llm-client.interface';
/** 模拟 rate limit / 503 这类暂时性错误 */
export class RateLimitedLLM implements LLMClient {
private callCount = 0;
private failFirst: number;
constructor(failFirst = 3) {
this.failFirst = failFirst;
}
async chat(_params: Parameters<LLMClient['chat']>[0]) {
this.callCount++;
if (this.callCount <= this.failFirst) {
const err = new Error('Rate limit exceeded');
(err as any).status = 429;
throw err;
}
return { content: '正常响应', usage: { prompt_tokens: 10, completion_tokens: 5 } };
}
}
/** 模拟输出格式错误(无法解析为 JSON) */
export class MalformedOutputLLM implements LLMClient {
async chat(_params: Parameters<LLMClient['chat']>[0]) {
return {
content: '这不是一个合法的 JSON 字符串 { 缺少引号: true',
usage: { prompt_tokens: 10, completion_tokens: 20 },
};
}
}
/** 模拟超长响应(超出后续系统字段限制) */
export class OverflowOutputLLM implements LLMClient {
async chat(_params: Parameters<LLMClient['chat']>[0]) {
return {
content: 'x'.repeat(100_000),
usage: { prompt_tokens: 10, completion_tokens: 50000 },
};
}
}
/** 模拟幂等调用但每次返回不同结果(验证调用方是否依赖唯一性) */
export class NonDeterministicLLM implements LLMClient {
private responses: string[];
private index = 0;
constructor(responses: string[]) {
this.responses = responses;
}
async chat(_params: Parameters<LLMClient['chat']>[0]) {
const content = this.responses[this.index % this.responses.length];
this.index++;
return { content, usage: { prompt_tokens: 10, completion_tokens: 10 } };
}
}
typescript
// retry-handler.test.ts
import { RetryHandler } from './retry-handler';
import { RateLimitedLLM } from './llm-failure-stubs';
describe('RetryHandler', () => {
it('遇到 429 应该重试,最终成功', async () => {
// 前 2 次失败,第 3 次成功
const llm = new RateLimitedLLM(2);
const handler = new RetryHandler(llm, { maxRetries: 3, backoffMs: 0 });
const result = await handler.chat({
model: 'deepseek-chat',
messages: [{ role: 'user', content: 'hello' }],
});
expect(result.content).toBe('正常响应');
});
it('超过最大重试次数应该抛出', async () => {
const llm = new RateLimitedLLM(10); // 前 10 次都失败
const handler = new RetryHandler(llm, { maxRetries: 3, backoffMs: 0 });
await expect(
handler.chat({ model: 'deepseek-chat', messages: [{ role: 'user', content: 'hello' }] })
).rejects.toThrow('Rate limit exceeded');
});
});
实测数据: 在我们的 Agent 项目里,加入这类失败场景测试后,发现了 3 个之前在 CI 里从未暴露的 bug:
- Retry handler 遇到 JSON parse error 时没有正确重试(只对 429/503 做了重试)
- 超长输出被截断后,下游解析器没有 graceful fallback,直接抛异常
- 重试时没有重置
callCount,导致第 3 次成功后第 4 次调用又开始失败
第五步:用 Mock 验证调用契约
当你需要验证"调用 LLM 时传入了正确的参数",就需要 Mock。
典型场景:验证 prompt 里是否注入了必要的上下文字段。
typescript
// verifying-mock.ts
import { LLMClient } from './llm-client.interface';
export class VerifyingMock implements LLMClient {
calls: Parameters<LLMClient['chat']>[] = [];
private response: string;
constructor(response: string = '{"result": "ok"}') {
this.response = response;
}
async chat(params: Parameters<LLMClient['chat']>[0]) {
this.calls.push([params]);
return { content: this.response, usage: { prompt_tokens: 20, completion_tokens: 10 } };
}
// 断言工具方法
assertCalledOnce() {
expect(this.calls).toHaveLength(1);
}
assertSystemPromptContains(text: string) {
const systemMessages = this.calls.flatMap(([p]) =>
p.messages.filter(m => m.role === 'system').map(m => m.content)
);
const found = systemMessages.some(m => m.includes(text));
expect(found).toBe(true);
}
assertUserMessageContains(text: string) {
const userMessages = this.calls.flatMap(([p]) =>
p.messages.filter(m => m.role === 'user').map(m => m.content)
);
const found = userMessages.some(m => m.includes(text));
expect(found).toBe(true);
}
}
typescript
// rag-pipeline.test.ts
import { RAGPipeline } from './rag-pipeline';
import { VerifyingMock } from './verifying-mock';
describe('RAGPipeline prompt 注入', () => {
it('检索结果应该被注入到 user message 中', async () => {
const mock = new VerifyingMock('{ "answer": "42" }');
const pipeline = new RAGPipeline(mock, fakeRetriever);
await pipeline.query('什么是宇宙的终极答案?');
mock.assertCalledOnce();
mock.assertUserMessageContains('检索到的相关文档');
mock.assertSystemPromptContains('你是一个知识库问答助手');
});
});
这类测试保护的是 prompt 契约:一旦有人修改了 prompt 模板,漏掉了某个必要字段,测试立即失败,而不是等到生产里出问题才发现。
第六步:Recording Stub------录制真实响应,用于回放测试
以上的 Stub/Fake 都需要手写预设响应,有一定维护成本。更高级的做法是"录制回放"------第一次跑真实 API,把响应录下来;之后 CI 用录制的响应回放。
这跟 HTTP 测试里的 VCR(Video Cassette Recorder)模式一样。
typescript
// recording-llm.ts
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { LLMClient } from './llm-client.interface';
interface RecordedResponse {
content: string;
usage: { prompt_tokens: number; completion_tokens: number };
recordedAt: string;
}
export class RecordingLLM implements LLMClient {
private mode: 'record' | 'replay';
private cassettePath: string;
private cassette: Map<string, RecordedResponse> = new Map();
private realClient?: LLMClient;
constructor(options: {
mode: 'record' | 'replay';
cassettePath: string;
realClient?: LLMClient;
}) {
this.mode = options.mode;
this.cassettePath = options.cassettePath;
this.realClient = options.realClient;
if (this.mode === 'replay' && fs.existsSync(this.cassettePath)) {
const data = JSON.parse(fs.readFileSync(this.cassettePath, 'utf-8'));
this.cassette = new Map(Object.entries(data));
}
}
private hashParams(params: Parameters<LLMClient['chat']>[0]): string {
const key = JSON.stringify({
model: params.model,
messages: params.messages,
});
return crypto.createHash('sha256').update(key).digest('hex').slice(0, 16);
}
async chat(params: Parameters<LLMClient['chat']>[0]) {
const key = this.hashParams(params);
if (this.mode === 'replay') {
const recorded = this.cassette.get(key);
if (!recorded) {
throw new Error(
`RecordingLLM: 没有找到对应的录制响应。key=${key}\n` +
`请先用 mode=record 录制,或者检查测试用例是否改变了请求参数。`
);
}
return { content: recorded.content, usage: recorded.usage };
}
// record mode
if (!this.realClient) {
throw new Error('RecordingLLM record mode 需要 realClient');
}
const response = await this.realClient.chat(params);
// 写入 cassette
const cassette: Record<string, RecordedResponse> = {};
this.cassette.forEach((v, k) => (cassette[k] = v));
cassette[key] = { ...response, recordedAt: new Date().toISOString() };
fs.mkdirSync(path.dirname(this.cassettePath), { recursive: true });
fs.writeFileSync(this.cassettePath, JSON.stringify(cassette, null, 2));
this.cassette.set(key, cassette[key]);
return response;
}
}
使用方式:
typescript
// 录制模式(只在需要更新录制时跑一次,不进 CI)
const llm = new RecordingLLM({
mode: 'record',
cassettePath: '__cassettes__/summarizer.json',
realClient: new DeepSeekAdapter({ apiKey: process.env.DEEPSEEK_API_KEY }),
});
// 回放模式(CI 里用)
const llm = new RecordingLLM({
mode: 'replay',
cassettePath: '__cassettes__/summarizer.json',
});
// 测试代码完全一样,切换只在环境变量里
const llm = new RecordingLLM({
mode: process.env.LLM_TEST_MODE === 'record' ? 'record' : 'replay',
cassettePath: '__cassettes__/summarizer.json',
realClient: process.env.LLM_TEST_MODE === 'record'
? new DeepSeekAdapter({ apiKey: process.env.DEEPSEEK_API_KEY })
: undefined,
});
__cassettes__/ 文件夹提交到 git,CI 里 LLM_TEST_MODE 不设置(默认 replay),本地开发者偶尔跑 LLM_TEST_MODE=record 更新录制。
第七步:测试分层策略------什么场景用什么替身
这是最容易被忽视的部分。不同的测试层级应该用不同的替身策略:
kotlin
┌─────────────────────────────────────────────────────────────┐
│ E2E / Contract Tests(少量,每天跑一次) │
│ → 真实 LLM API,真实环境,验证集成链路 │
├─────────────────────────────────────────────────────────────┤
│ Integration Tests(中等,每次 PR 跑) │
│ → RecordingLLM(replay 模式),验证组件间协作 │
├─────────────────────────────────────────────────────────────┤
│ Unit Tests(大量,每次 commit 跑) │
│ → Stub / Fake / Mock,验证单个组件行为和错误路径 │
└─────────────────────────────────────────────────────────────┘
关键原则:
- Unit Tests 里永远不调真实 API。这是底线。
- Integration Tests 用 Recording。一旦录制文件提交,CI 里不产生任何 API 费用。
- E2E / Contract Tests 有配额控制。每天最多跑 N 次,有独立预算,不和 CI 账单混在一起。
第八步:避免两个常见陷阱
陷阱一:Stub 过于复杂,变成了"测 Stub 本身"
一旦你的 Stub 有超过 100 行逻辑、有自己的 bug,就失去了测试替身的意义。Stub 应该尽量简单,如果需要复杂路由,优先用 FakeLLM 加 addRoute 这种声明式配置,而不是写大量 if/else。
陷阱二:断言 LLM 的具体输出内容
typescript
// ❌ 错误:这个测试在测 Stub 的响应,不在测业务逻辑
expect(result).toBe('这是测试摘要。'); // 这只是 Stub 里写死的字符串
// ✅ 正确:测业务逻辑的行为(响应不为空、长度在范围内、格式正确)
expect(result.length).toBeGreaterThan(0);
expect(result.length).toBeLessThanOrEqual(200); // 你的摘要长度限制
expect(() => JSON.parse(result)).not.toThrow(); // 如果要求是 JSON
测试替身替代的是 LLM,你测的应该是你的代码如何处理 LLM 的响应,而不是 LLM 本身的输出质量。
一个真实案例:Agent 流水线的测试覆盖
把上面所有东西组合起来,一个典型 Agent 流水线的测试套件大概长这样:
typescript
// agent-pipeline.test.ts
import { AgentPipeline } from './agent-pipeline';
import { FakeLLM, MalformedOutputLLM, RateLimitedLLM } from './test-doubles';
describe('AgentPipeline', () => {
describe('正常流程', () => {
it('应该正确执行工具调用并聚合结果', async () => {
const fake = new FakeLLM()
.addRoute('step1', JSON.stringify({ action: 'search', query: '最新 AI 进展' }))
.addRoute('step2', JSON.stringify({ action: 'summarize', content: '{{search_result}}' }))
.addRoute('step3', JSON.stringify({ action: 'done', output: '摘要完成' }));
const pipeline = new AgentPipeline(fake, testTools);
const result = await pipeline.run('请帮我搜索并总结最新 AI 进展');
expect(result.status).toBe('completed');
expect(result.steps).toHaveLength(3);
});
});
describe('错误处理', () => {
it('工具调用返回格式错误时应该重试解析', async () => {
const llm = new MalformedOutputLLM();
const pipeline = new AgentPipeline(llm, testTools);
await expect(pipeline.run('任意任务')).rejects.toMatchObject({
code: 'PARSE_ERROR',
retries: 3, // 应该重试了 3 次才放弃
});
});
it('429 错误应该触发 backoff 重试', async () => {
const llm = new RateLimitedLLM(2); // 前 2 次 429,第 3 次成功
const pipeline = new AgentPipeline(llm, testTools, { retryBackoffMs: 0 });
const result = await pipeline.run('任意任务');
expect(result.status).toBe('completed');
});
});
});
这个测试套件:
- 跑完耗时 < 500ms(全部是内存操作,无 I/O)
- 零 API 费用
- 覆盖了主流程 + 3 种错误路径
- 在任何有 Node.js 的环境里都能跑
总结
| 场景 | 推荐替身 |
|---|---|
| 测主流程走通 | Stub(返回固定值) |
| 测分支路由逻辑 | Fake(按 prompt 关键词路由) |
| 测错误处理和重试 | 专用失败 Stub(RateLimited/Malformed/Overflow) |
| 测 prompt 契约 | Mock(记录并断言调用参数) |
| 测组件集成(Integration) | RecordingLLM(replay 模式) |
| 验证生产集成链路 | 真实 API(有配额控制) |
关键前提只有一个:把 LLM Client 抽象成接口,通过构造函数注入。其他的测试替身都在这个前提上搭建。
LLM 应用不比普通 Web 服务神秘。让它在 CI 里可测,跟让任何有外部依赖的代码可测,是一回事。