LLM 应用的测试替身工程实践:用 Fake/Stub/Mock 让 AI 代码真正跑起来 CI

你的 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,验证单个组件行为和错误路径               │
└─────────────────────────────────────────────────────────────┘

关键原则:

  1. Unit Tests 里永远不调真实 API。这是底线。
  2. Integration Tests 用 Recording。一旦录制文件提交,CI 里不产生任何 API 费用。
  3. 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 里可测,跟让任何有外部依赖的代码可测,是一回事。

相关推荐
adaierya1 小时前
用 AI 解决音频转换编程问题
开发语言·人工智能·python·分类·ai编程
Java小白笔记13 小时前
Java 单元测试怎么写:JUnit 5、Mockito 与 Spring Boot 方法级实战
java·junit·单元测试
GoGeekBaird13 小时前
从「跑完即销毁」到「用完再销毁」:云沙箱的一次进化
后端·github·ai编程
kyriewen14 小时前
AI 改祖传模块后,代码评审总绕不开两个问题
前端·程序员·ai编程
武子康15 小时前
小智的 MQTT 已连接,为什么还不能说话?从音频通道看协议选择
人工智能·llm·agent
颜淡慕潇16 小时前
Loop已死,Graph永生?深度看懂:2026年AI工程的真正范式跃迁
ai编程
两斤牛肉一壶酒17 小时前
第一个纯AI手搓的脚本程序终于出炉了:##批量导入知识库##
ai编程
wechatbot88817 小时前
SpringBoot Vue 企业微信多账号托管|扫码登录 代理 IP 消息回调
大数据·后端·微信·企业微信·ai编程
星核0penstarry17 小时前
B站AI大赛3万份投稿背后的真相:门槛下移后,新的壁垒是什么?
人工智能·个人开发·ai编程·自动写代码