1. 为什么你的AI项目需要一个模拟策略

你刚刚把一个前沿的大语言模型集成到你的应用里。原型跑起来效果惊人,充满了魔力。但当你试图运行测试套件时,却撞上了一堵墙:延迟、速率限制,以及来自AI供应商API的不可预测的成本。你的开发速度骤然停滞,在CI/CD中进行测试变成了一场财务和后勤的噩梦。这就是现代AI开发的现实,也是为什么一个健壮的模拟策略不是奢侈品,而是生产级系统的必需品。

虽然市面上有像AIMock这样优秀的工具可以作为一个绝佳的起点,但这份指南会走得更深。我们将从零开始,构建一个可编程的、多用途的模拟层。这种方法能让你在单元测试、集成测试和本地开发中获得细粒度的控制,确保你的AI功能像代码库中其他任何部分一样可靠且可测试。核心问题在于,AI API不是传统的、确定性的数据库查询或计算服务。它们的响应具有非确定性、有延迟、会产生费用,并且可能随时变更。如果你的测试和开发流程直接依赖真实API,就等于将项目的稳定性、速度和成本控制权交给了外部服务,这在追求快速迭代和高质量交付的工程团队中是难以接受的。

2. 超越简单桩程序:剖析一个AI模拟层

一个简单的、返回固定JSON响应的HTTP桩程序对于AI API来说是远远不够的。我们需要模拟它们独特的行为模式,这些模式是测试AI集成功能正确性的关键。

2.1 结构化输出模拟

现代LLM API通常支持JSON模式或函数调用,要求返回严格结构化的数据。你的模拟器必须能够生成符合特定Schema的响应,而不仅仅是随机文本。例如,一个模拟的天气查询函数调用响应,必须包含 location temperature unit 等字段,且类型正确。这对于测试你应用的数据解析和业务逻辑至关重要。

2.2 流式响应模拟

许多AI应用为了用户体验,采用服务器发送事件进行流式传输,实现字词逐个出现的打字机效果。模拟SSE流允许你在没有网络延迟和API成本的情况下,测试前端UI的加载状态、分块渲染逻辑以及中途取消请求等交互。一个不能模拟流式的Mock,就无法验证这些核心用户体验相关的代码路径。

2.3 非确定性行为注入

AI的本质是非确定性的。为了测试健壮性,你的模拟层需要能够可控地引入“随机性”。例如,你可以配置一个模拟处理器,有5%的概率返回一个完全无关的答案,或者偶尔在响应中插入一个乱码字符,以此来验证你的错误处理和用户提示逻辑是否牢固。

2.4 错误场景复现

这是模拟层价值最高的部分之一。真实的API会抛出各种错误:429(速率限制)、500(内部服务器错误)、400(上下文长度超限)、503(服务过载)等。你的测试套件必须能可靠地触发并验证应用对这些错误的处理方式——是否正确地展示了降级UI、是否进行了优雅的重试、是否记录了正确的监控指标。一个只能返回成功响应的Mock,掩盖了一半的潜在故障点。

3. 核心架构:模拟路由器的设计与实现

我们需要创建一个中心路由器,用于拦截发往AI供应商端点(如 api.openai.com/v1/chat/completions )的请求,并根据请求路径和配置的模式,将其委托给相应的处理函数。下面以Node.js和Express框架为例进行架构,但这种模式适用于任何技术栈。

3.1 基础路由器搭建

首先,我们建立一个基础的Express服务器,它根据环境变量 AI_MOCK_MODE 来决定当前的行为模式。这种模式化设计是灵活性的关键。

// mockAIServer.js
const express = require('express');
const app = express();
app.use(express.json());

// 核心配置:模拟模式。可通过环境变量动态切换。
// 'static': 静态响应,用于单元测试。
// 'dynamic': 动态响应,基于输入生成,用于集成测试。
// 'error': 错误注入模式,用于故障测试。
// 'stream': 流式响应模式。
const MOCK_MODE = process.env.AI_MOCK_MODE || 'dynamic';

// 拦截聊天补全API
app.post('/v1/chat/completions', async (req, res) => {
  const { model, messages, stream } = req.body;
  
  // 根据请求体和模式进行高级路由
  if (stream === true) {
    // 如果请求要求流式输出,则进入流式处理器
    return handleStreamingCompletion(req, res);
  }
  
  // 非流式请求,根据MOCK_MODE路由
  switch (MOCK_MODE) {
    case 'static':
      return handleStaticCompletion(req, res);
    case 'dynamic':
      return handleDynamicCompletion(req, res);
    case 'error':
      return handleErrorInjection(req, res);
    default:
      return handleDynamicCompletion(req, res);
  }
});

// 可以继续添加其他端点,如 /v1/completions, /v1/embeddings
app.post('/v1/embeddings', (req, res) => {
  // 处理嵌入向量请求的模拟
  res.json({
    data: [{ embedding: new Array(1536).fill(0).map(() => Math.random() - 0.5) }]
  });
});

const PORT = process.env.AI_MOCK_PORT || 3001;
app.listen(PORT, () => console.log(`AI Mock Server running on port ${PORT}`));

这个基础框架建立了一个清晰的入口点。环境变量 AI_MOCK_MODE 控制了全局的模拟行为,使得在运行测试时,你可以通过一行命令(如 AI_MOCK_MODE=static npm test )来切换整个测试环境的AI行为。

3.2 静态响应处理器

这是最简单但极其重要的处理器,专为单元测试设计。它每次都返回完全相同的响应,保证了测试的绝对确定性。

function handleStaticCompletion(req, res) {
  // 这是一个理想的单元测试响应:完全可预测。
  const staticResponse = {
    id: 'chatcmpl-mock-static-123',
    object: 'chat.completion',
    created: Math.floor(Date.now() / 1000),
    model: req.body.model || 'gpt-3.5-turbo-mock',
    choices: [{
      index: 0,
      message: {
        role: 'assistant',
        content: '这是一个预定义的静态模拟响应。所有基于此响应的断言都将始终通过。'
      },
      finish_reason: 'stop'
    }],
    usage: {
      prompt_tokens: 27, // 可以基于req.body.messages长度简单计算
      completion_tokens: 12,
      total_tokens: 39
    }
  };
  // 模拟一个短暂的网络延迟,更贴近真实场景
  setTimeout(() => {
    res.json(staticResponse);
  }, 30);
}

在实际操作中,我建议将多个静态响应体(对应不同的测试用例)存储在独立的JSON文件或一个Map对象中,然后根据请求中的某个特征(如第一个用户消息的哈希值)来返回对应的固定响应。这样可以为不同的单元测试场景提供不同的、但各自确定的“正确答案”。

3.3 动态响应处理器

对于集成测试,我们需要响应能根据输入内容有所变化,以验证应用逻辑链的正确性,但又不能引入真实API的不确定性。

function handleDynamicCompletion(req, res) {
  const messages = req.body.messages || [];
  const lastUserMessage = messages.filter(m => m.role === 'user').pop()?.content || '';
  
  // 基于输入生成动态但确定性的内容
  // 例如:提取关键词,或进行简单的规则匹配
  let responseContent;
  if (lastUserMessage.toLowerCase().includes('你好')) {
    responseContent = '你好!我是模拟AI助手。';
  } else if (lastUserMessage.toLowerCase().includes('天气')) {
    responseContent = '根据模拟数据,今天天气晴朗,气温22度。';
  } else {
    // 一个通用的动态响应,包含输入摘要
    const truncatedInput = lastUserMessage.substring(0, 100);
    responseContent = `我已收到您的请求:“${truncatedInput}...”。这是一个动态生成的模拟回复。`;
  }
  
  const dynamicResponse = {
    id: `chatcmpl-mock-dyn-${Date.now()}`,
    choices: [{
      message: {
        role: 'assistant',
        content: responseContent
      },
      finish_reason: 'stop'
    }],
    usage: {
      prompt_tokens: estimateTokens(messages),
      completion_tokens: estimateTokens([{content: responseContent}]),
      total_tokens: 0 // 下面计算
    }
  };
  dynamicResponse.usage.total_tokens = dynamicResponse.usage.prompt_tokens + dynamicResponse.usage.completion_tokens;
  
  setTimeout(() => {
    res.json(dynamicResponse);
  }, 50 + Math.random() * 100); // 添加一个可控的随机延迟,模拟网络波动
}

这里的 estimateTokens 是一个简化的令牌估算函数。对于精确测试,你可以引入类似 tiktoken 的库来进行相对准确的计数,这对于测试涉及令牌限制的功能(如上下文窗口管理)非常重要。

4. 高级模拟:流式响应与场景注册表

4.1 模拟流式响应

流式响应模拟是提升集成测试真实度的关键一步。它不仅能测试UI,还能测试你后端处理数据流的能力。

function handleStreamingCompletion(req, res) {
  // 设置SSE所需的响应头
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders(); // 立即发送头部,建立流连接
  
  const requestId = `chatcmpl-mock-stream-${Date.now()}`;
  const messageContent = req.body.messages?.slice(-1)[0]?.content || '';
  
  // 根据输入动态生成模拟的令牌流
  const mockTokens = generateTokenStream(messageContent);
  
  let tokenIndex = 0;
  
  const sendInterval = setInterval(() => {
    if (tokenIndex < mockTokens.length) {
      const chunk = {
        id: requestId,
        object: 'chat.completion.chunk',
        created: Math.floor(Date.now() / 1000),
        model: req.body.model || 'gpt-3.5-turbo-mock',
        choices: [{
          index: 0,
          delta: {
            content: mockTokens[tokenIndex]
          },
          finish_reason: null
        }]
      };
      // SSE格式:每段数据以 "data: " 开头,以两个换行符结尾
      res.write(`data: ${JSON.stringify(chunk)}\n\n`);
      tokenIndex++;
    } else {
      // 发送结束块
      const doneChunk = {
        id: requestId,
        object: 'chat.completion.chunk',
        created: Math.floor(Date.now() / 1000),
        model: req.body.model || 'gpt-3.5-turbo-mock',
        choices: [{
          index: 0,
          delta: {},
          finish_reason: 'stop'
        }]
      };
      res.write(`data: ${JSON.stringify(doneChunk)}\n\n`);
      clearInterval(sendInterval);
      res.end(); // 结束流
    }
  }, 80); // 模拟每秒约12.5个令牌的速度,这是一个合理的模拟值
}

// 一个辅助函数,将输入句子拆分成模拟的“令牌”
function generateTokenStream(input) {
  // 简单按空格和标点分割,真实场景可以更复杂
  const words = input.split(/(?<=[,。!?\s])/).filter(w => w.trim());
  if (words.length > 0) {
    return words;
  }
  // 如果输入为空,返回一个默认的问候语流
  return ['模拟', 'AI', '正在', '思考', '...', '\n', '这是', '一个', '流式', '响应', '。'];
}

在实测中,模拟流式响应时需要注意缓冲区的问题。确保 res.write 调用是异步且非阻塞的,并且在流结束或客户端断开连接时,一定要清理定时器( clearInterval )并正确关闭连接,防止内存泄漏。一个实用的技巧是在响应对象上监听 close 事件,以便在客户端提前断开时立即清理资源。

4.2 实现“场景注册表”进行复杂测试

对于涉及多轮交互或特定业务逻辑的集成测试,你需要编排一系列特定的AI行为。一个场景注册表允许你根据预定义的场景ID来编程响应。

// AIMockScenarioRegistry.js
class AIMockScenarioRegistry {
  constructor() {
    this.scenarios = new Map();
    this.defaultHandler = this._defaultHandler.bind(this);
  }
  
  // 注册一个场景
  register(scenarioId, handlerFunction) {
    if (typeof handlerFunction !== 'function') {
      throw new Error('Scenario handler must be a function');
    }
    this.scenarios.set(scenarioId, handlerFunction);
    console.log(`[Mock Registry] Scenario registered: ${scenarioId}`);
  }
  
  // 处理请求,优先使用场景处理器
  async handleRequest(scenarioId, request, requestBody) {
    const handler = this.scenarios.get(scenarioId);
    
    if (handler) {
      try {
        const response = await handler(requestBody, request);
        if (response !== null && response !== undefined) {
          // 场景处理器返回了自定义响应
          return this._formatResponse(response, requestBody);
        }
        // 如果处理器返回null/undefined,则降级到默认行为
      } catch (error) {
        console.error(`[Mock Registry] Error in scenario "${scenarioId}":`, error);
        // 出错时也降级到默认行为
      }
    }
    // 默认或降级行为
    return this.defaultHandler(requestBody);
  }
  
  // 默认的处理器逻辑
  _defaultHandler(requestBody) {
    const lastMessage = requestBody.messages?.slice(-1)[0]?.content || '';
    return {
      choices: [{
        message: {
          role: 'assistant',
          content: `这是默认模拟响应。您说:“${lastMessage.substring(0, 30)}...”`
        }
      }]
    };
  }
  
  // 确保响应格式符合API规范
  _formatResponse(customResponse, originalRequest) {
    const baseResponse = {
      id: `chatcmpl-scenario-${Date.now()}`,
      object: 'chat.completion',
      created: Math.floor(Date.now() / 1000),
      model: originalRequest.model || 'gpt-3.5-turbo-mock',
      usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
    };
    return { ...baseResponse, ...customResponse };
  }
}

// 在你的测试设置文件中使用
const registry = new AIMockScenarioRegistry();

// 示例1:模拟一个安全过滤器触发的场景
registry.register('safety_filter_triggered', (reqBody) => {
  const lastMessage = reqBody.messages?.slice(-1)[0]?.content || '';
  const lowerMessage = lastMessage.toLowerCase();
  
  // 检查是否包含预设的敏感词
  const blockedTerms = ['暴力', '仇恨言论', '非法内容'];
  if (blockedTerms.some(term => lowerMessage.includes(term))) {
    return {
      choices: [{
        message: {
          role: 'assistant',
          content: '抱歉,我无法回应这个请求。它可能违反了内容政策。'
        },
        finish_reason: 'content_filter' // 模拟特定的完成原因
      }]
    };
  }
  return null; // 返回null,触发默认处理器
});

// 示例2:模拟一个多轮对话中的特定状态
registry.register('user_confirms_order', (reqBody) => {
  // 假设之前的场景已经模拟了用户询问产品信息
  // 这个场景模拟用户确认购买
  return {
    choices: [{
      message: {
        role: 'assistant',
        content: '好的,已确认您的订单。订单号是 #MOCK-2024-001。我们将尽快处理发货。'
      }
    }]
  };
});

// 在测试中,你可以通过设置一个特殊的请求头或消息内容来触发场景
// 例如,在请求体中添加一个 `_mock_scenario` 字段(真实调用API时需删除)
app.post('/v1/chat/completions', async (req, res) => {
  const scenarioId = req.body._mock_scenario;
  
  if (scenarioId && registry.scenarios.has(scenarioId)) {
    const response = await registry.handleRequest(scenarioId, req, req.body);
    return res.json(response);
  }
  
  // ... 原有的路由逻辑
});

场景注册表模式将测试逻辑与模拟服务器本身解耦。你可以为每个重要的集成测试用例编写一个场景处理器,从而在测试中精确复现复杂的AI交互序列。这比在测试代码中写一堆 if-else 判断请求内容要清晰和可维护得多。

5. 将模拟层集成到你的开发工作流

模拟层的真正威力在于,能够在开发、测试和生产环境之间无缝切换。关键在于通过配置,而非代码修改,来控制行为。

5.1 基于环境的配置管理

使用环境变量来切换API的基础URL和模拟模式,这是行业标准做法。

# .env.development.local
# 本地开发,指向本地模拟服务器
OPENAI_BASE_URL=http://localhost:3001/v1
AI_MOCK_MODE=dynamic
# 可选:为特定功能启用场景测试
AI_MOCK_SCENARIO=

# .env.test
# 运行自动化测试,使用静态或场景化模拟以确保确定性
OPENAI_BASE_URL=http://localhost:3001/v1
AI_MOCK_MODE=static
# 或者,在启动测试套件时通过脚本设置场景

# .env.production
# 生产环境,指向真实的AI服务提供商
OPENAI_BASE_URL=https://api.openai.com/v1
# AI_MOCK_MODE 不应在生产环境设置

5.2 在应用代码中配置客户端

你的AI客户端(如OpenAI SDK)应该根据环境变量动态配置端点。

// lib/aiClient.js
import { OpenAI } from 'openai';
import { config } from 'dotenv';

config(); // 加载环境变量

// 关键:baseURL 从环境变量读取
const baseURL = process.env.OPENAI_BASE_URL;

if (!baseURL && process.env.NODE_ENV !== 'production') {
  console.warn('OPENAI_BASE_URL is not set. Falling back to default OpenAI endpoint.');
}

const configuration = {
  apiKey: process.env.OPENAI_API_KEY || 'mock-key-in-development', // 开发环境可以用假密钥
  baseURL: baseURL, // 这个变量决定了是连真实API还是Mock服务器
  dangerouslyAllowBrowser: false, // 根据你的前端环境设置
};

// 一个有用的调试技巧:在非生产环境记录使用的端点
if (process.env.NODE_ENV !== 'production') {
  console.log(`AI Client configured with baseURL: ${configuration.baseURL}`);
}

const openaiClient = new OpenAI(configuration);
export default openaiClient;

现在,在你的业务代码中,你只需要像平常一样导入和使用 openaiClient 。在本地开发时,它会自动将请求发送到你的模拟服务器;在CI中,发送到测试用的模拟服务器;在生产环境,则发送到真实的OpenAI API。代码本身无需任何修改。

5.3 在测试套件中集成

在测试启动时,你需要确保模拟服务器已经运行,并且环境变量已正确设置。

// jest.setup.js 或你的测试框架的全局设置文件
const { spawn } = require('child_process');
const path = require('path');

module.exports = async function globalSetup() {
  // 只在测试环境下启动模拟服务器
  if (process.env.NODE_ENV === 'test') {
    global.__MOCK_SERVER__ = spawn('node', [path.resolve(__dirname, './mockAIServer.js')], {
      env: { ...process.env, AI_MOCK_MODE: 'static', AI_MOCK_PORT: 3099 },
      stdio: 'inherit', // 在控制台看到服务器日志
    });
    
    // 等待服务器启动
    await new Promise(resolve => setTimeout(resolve, 1000));
    
    // 强制设置测试环境使用Mock服务器
    process.env.OPENAI_BASE_URL = 'http://localhost:3099/v1';
    
    console.log('AI Mock Server started for tests.');
  }
};

// jest.teardown.js
module.exports = async function globalTeardown() {
  if (global.__MOCK_SERVER__) {
    global.__MOCK_SERVER__.kill();
    console.log('AI Mock Server stopped.');
  }
};

对于单元测试,你甚至可以更进一步,直接模拟SDK客户端本身,而不是启动一个HTTP服务器。使用Jest、Sinon或Vitest的模拟功能,可以让你在函数级别进行更细粒度的控制。

// __tests__/myAIService.unit.test.js
import myAIService from '../services/myAIService';
import openaiClient from '../lib/aiClient';

// 在单元测试中,直接模拟整个openai模块
jest.mock('../lib/aiClient', () => ({
  chat: {
    completions: {
      create: jest.fn().mockResolvedValue({
        choices: [{ message: { content: '固定的单元测试响应' } }]
      })
    }
  }
}));

describe('My AI Service Unit Tests', () => {
  it('should process AI response correctly', async () => {
    const result = await myAIService.askQuestion('Hello');
    expect(result).toBe('固定的单元测试响应');
    expect(openaiClient.chat.completions.create).toHaveBeenCalledWith({
      model: 'gpt-3.5-turbo',
      messages: [{ role: 'user', content: 'Hello' }]
    });
  });
});

这种分层策略——单元测试用桩函数,集成测试用本地模拟服务器——提供了最佳的测试速度和保真度平衡。

6. 模拟策略带来的核心收益

投入时间构建这样一个模拟层,回报是立竿见影且影响深远的。

极速测试 :单元测试从依赖网络、耗时数秒,变为纯内存操作、毫秒级完成。这直接改变了开发节奏,你可以频繁运行测试而无需等待,真正实践测试驱动开发。

确定性测试 :再也不会因为API的偶然性波动(如偶尔的慢响应或内容微调)而导致测试时而过、时而不过。你的测试套件变得完全可靠,这为持续集成流水线提供了稳定的基石。

CI/CD中的零成本测试 :在每次拉取请求或合并时运行成百上千次测试,不再产生一分钱的API费用。这对于初创公司或大规模项目来说,长期能节省一笔可观的支出。

离线与无网络开发 :你可以在飞机上、火车上,或任何没有稳定网络连接的地方,继续开发和测试核心的AI功能逻辑。开发环境不再受制于外部服务的可用性。

全面的错误场景测试 :你可以系统性地测试应用如何处理各种API故障:网络超时、速率限制、身份验证失败、内容过滤、上下文过长等。这能极大提升你应用的韧性,而这些场景在依赖真实API的测试中很难稳定复现。

7. 你的实施路线图与常见问题

不要试图在第一天就构建一个完美的模拟系统。采用渐进式的方法。

第一周:建立基础 。为你最常用的一个AI端点(例如 /v1/chat/completions )实现一个基础的静态模拟服务器。修改你的本地开发环境配置( .env.local ),将 OPENAI_BASE_URL 指向 http://localhost:3001 。运行你的应用,确保所有基础的AI功能调用都能被模拟器接管并返回响应。这一步的目标是“走通流程”。

第二周:增加智能与集成 。为你的模拟器添加一个动态处理器,使其能根据输入消息生成一些简单的、基于规则的响应。然后,为你最关键的一个集成测试编写第一个“场景”。例如,测试一个“用户查询产品-AI推荐-用户购买”的完整流程。确保这个测试能在完全离线、确定性的环境下通过。

第一个月:完善与团队共享 。实现流式响应模拟,测试你的前端加载状态。将你的模拟服务器、场景注册表和相关配置打包成一个Docker容器。这样,团队的任何新成员只需一条 docker-compose up 命令,就能获得一个完整的、包含模拟AI服务的本地开发环境,极大降低了 onboarding 成本。

在实施过程中,你可能会遇到一些典型问题。例如,模拟的响应格式与真实API的细微差别可能导致前端解析错误。解决办法是定期用真实API的响应样本(在开发或测试环境抓取)来更新你的模拟响应模板,确保字段一致。另一个常见问题是模拟服务器的性能,当集成测试并发量很大时,简单的Node.js服务器可能成为瓶颈。可以考虑使用更高效的HTTP框架如Fastify,或者将一些处理器逻辑缓存起来。

最后,记住模拟层的核心哲学:它不是为了100%复现AI的智能,而是为了给你的工程流程提供稳定性、速度和可控性。真正的AI能力仍然来自生产环境的真实API。通过投资这个模拟层,你构建的不仅仅是一个测试工具,而是一个让AI功能开发变得可靠、快速且经济高效的基础设施。当上线日来临,一切功能都如测试中那样运行时,你和你的团队都会感谢当初的这项投资。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐