Rust AI CLI 集成测试用 mock 模型服务替代真实 API 调用的方案一、为什么集成测试 AI 应用是个麻烦事做 AI 开发的兄弟们都知道只要你的代码里有对 OpenAI、Claude 或本地 Ollama 服务的调用测试就会变得很棘手。真实 API 调用的成本暂且不说光是网络延迟和模型响应时间的不确定性就能让你的测试套件从 3 秒跑到 3 分钟。刚开始写 AI CLI 工具的时候我的做法特别粗暴 —— 每次改完代码就手动跑一遍看看输出对不对。这样做的问题很明显一是效率极低二是我根本没法保证代码重构后行为的一致性。后来我才慢慢理解自学出身的人最应该补的课不是什么算法数据结构而是工程习惯。构建 AI CLI 工具的测试挑战我总结起来就是三条成本问题GPT-4 一次调用几美分跑 100 次测试就是几美元速度问题网络往返 模型推理单次测试可能耗时 5-10 秒一致性问题模型输出不确定同样的 prompt 可能返回截然不同的结果测试断言没法写这三个问题里最让我头疼的其实是第三个。毕竟钱和时间还能忍但不确定性让自动化测试几乎不可能。graph TD A[开发修改代码] -- B{运行测试套件} B --|真实 API 调用| C[等待 3-10 秒/次] B --|Mock 模型服务| D[毫秒级响应] C -- E[费用累积 结果不稳定] D -- F[零成本 结果可预测] E -- G[测试信心下降] F -- H[可以放心重构] G -.-|恶性循环| A H -.-|良性循环| A style A fill:#333,stroke:#666,color:#fff style B fill:#555,stroke:#888,color:#fff style C fill:#933,stroke:#c44,color:#fff style D fill:#393,stroke:#4a4,color:#fff style E fill:#933,stroke:#c44,color:#fff style F fill:#393,stroke:#4a4,color:#fff style G fill:#933,stroke:#c44,color:#fff style H fill:#393,stroke:#4a4,color:#fff上图总结了两种测试策略的差异。当我还在手动测试的泥潭里挣扎时mock 模型服务这个方案直接把我拉了出来。二、设计一个可替换的模型服务层解决问题的第一步不是写 mock而是先让自己的代码结构支持替换。这是我在一个月里反复改代码才悟出来的道理。我之前写的 AI CLI 工具核心逻辑直接把 OpenAI SDK 的调用写在业务代码里。要测试的话要么真连 API要么就改源码去掉网络请求。这两种方式都不是正经的工程做法。抽象掉调用层的关键是定义一个 trait。Rust 的 trait 系统在这里特别好用它让你可以定义接口契约然后在不同场景下提供不同的实现。use async_trait::async_trait; use anyhow::Result; /// 定义模型服务的统一接口 /// 所有模型调用真实 API 或 Mock都必须实现这个 trait #[async_trait] pub trait ModelService: Send Sync { /// 发送聊天请求并获取响应 /// messages: 符合 OpenAI 格式的消息列表 /// 返回模型输出的文本内容 async fn chat(self, messages: VecChatMessage) - ResultString; /// 返回模型名称用于日志和调试 fn model_name(self) - str; } /// 聊天消息结构体兼容 OpenAI 格式 #[derive(Debug, Clone)] pub struct ChatMessage { pub role: String, // system | user | assistant pub content: String, // 消息正文 }有了这个 trait下一步就是实现两个版本一个真实调用 OpenAI 的生产版本一个用于测试的 mock 版本。需要特别注意的是ModelServicetrait 标记了Send Sync这是为了让它在tokio的多线程调度器中被安全地共享引用。use reqwest::Client; use serde_json::Value; /// 生产环境的真实模型服务实现 pub struct OpenAIService { client: Client, // HTTP 客户端复用连接池 api_key: String, // OpenAI API 密钥 base_url: String, // API 端点地址 model: String, // 模型名如 gpt-4o } #[async_trait] impl ModelService for OpenAIService { async fn chat(self, messages: VecChatMessage) - ResultString { // 将内部消息格式转换为 OpenAI JSON 请求体 let body serde_json::json!({ model: self.model, messages: messages.iter().map(|m| { serde_json::json!({ role: m.role, content: m.content }) }).collect::Vec_(), }); // 发送 HTTP 请求到 OpenAI let resp self.client .post(self.base_url) .header(Authorization, format!(Bearer {}, self.api_key)) .json(body) .send() .await?; // 解析响应提取 content 字段 let json: Value resp.json().await?; let content json[choices][0][message][content] .as_str() .unwrap_or() .to_string(); Ok(content) } fn model_name(self) - str { self.model } }三、实现 Mock 模型服务Mock 的核心思路是不发出网络请求直接返回预设的响应内容。但简单的 mock永远返回同一段文本不足以覆盖真实场景。我们需要让 mock 支持多种行为 —— 按关键词匹配返回不同响应、模拟错误情况、甚至记录调用历史供断言使用。use std::collections::HashMap; use std::sync::Mutex; /// Mock 模型服务 —— 用于集成测试 pub struct MockModelService { /// 存储 (关键词 - 预定义响应) 的映射 /// 如果用户消息中包含某个关键词就返回对应的响应 responses: HashMapString, String, /// 默认响应当所有关键词都没匹配时使用 default_response: String, /// 调用历史记录用于验证测试上下文 call_history: MutexVecChatMessage, /// 模型名称 name: String, } impl MockModelService { /// 创建一个新的 mock 服务实例 pub fn new(default_response: str) - Self { Self { responses: HashMap::new(), default_response: default_response.to_string(), call_history: Mutex::new(Vec::new()), name: mock-model-v1.to_string(), } } /// 注册一条关键词匹配规则 /// keyword: 当用户消息包含此关键词时触发 /// response: 匹配后返回的文本 pub fn with_response(mut self, keyword: str, response: str) - Self { self.responses.insert(keyword.to_lowercase(), response.to_string()); self } /// 获取调用历史用于测试断言 pub fn call_history(self) - VecChatMessage { self.call_history.lock().unwrap().clone() } } #[async_trait] impl ModelService for MockModelService { async fn chat(self, messages: VecChatMessage) - ResultString { // 记录调用历史 if let Ok(mut history) self.call_history.lock() { history.extend(messages.clone()); } // 提取用户最后一条消息的内容 let user_content messages .iter() .filter(|m| m.role user) .last() .map(|m| m.content.to_lowercase()) .unwrap_or_default(); // 按关键词匹配返回对应响应 for (keyword, response) in self.responses { if user_content.contains(keyword) { return Ok(response.clone()); } } // 没匹配到任何关键词返回默认响应 Ok(self.default_response.clone()) } fn model_name(self) - str { self.name } }这里的call_history用Mutex包裹是因为ModelService的chat方法接受self不可变引用但我们需要记录历史内部可变。Rust 的Mutex在内部分配了不可变引用下的可变访问能力也就是内部可变性模式。一开始我对这个概念很懵直到看了《Rust 程序设计语言》第 16 章才慢慢理解。四、编写集成测试用例有了 mock 模型服务写测试就变得非常直观了。以下是一个典型的 CLI 工具集成测试它验证当用户询问今天的天气时系统能正确路由到 mock 返回的对应响应。#[cfg(test)] mod tests { use super::*; /// 测试用户提问天气mock 应该返回预定义的天气响应 #[tokio::test] async fn test_cli_with_mock_service() { // 1. 构建 mock 服务设置多组关键词响应 let mock_service MockModelService::new(抱歉我不太理解你的问题) .with_response(天气, 今天北京晴气温 25°C适合出门) .with_response(代码, 你提供的代码中有一个逻辑错误在第 12 行); // 2. 用 mock 服务创建 CLI 应用实例 let app MyAICli::new(Box::new(mock_service)); // 3. 模拟用户输入今天的天气怎么样 let result app.run(今天的天气怎么样).await.unwrap(); // 4. 断言输出符合预期 assert_eq!(result, 今天北京晴气温 25°C适合出门); } /// 测试未匹配关键词时返回默认响应 #[tokio::test] async fn test_fallback_response() { let mock_service MockModelService::new(默认回复请重新描述你的问题); let app MyAICli::new(Box::new(mock_service)); let result app.run(一些无关的话题).await.unwrap(); assert_eq!(result, 默认回复请重新描述你的问题); } /// 测试验证调用历史记录功能 #[tokio::test] async fn test_call_history_recording() { let mock_service MockModelService::new(ok); let app MyAICli::new(Box::new(mock_service)); app.run(hello world).await.unwrap(); // 验证历史中确实记录了这次调用 let history mock_service.call_history(); assert!(!history.is_empty(), 调用历史不应为空); assert_eq!(history[0].role, user); } } /// 简化的 CLI 应用结构体实际项目中会更复杂 struct MyAICli { model_service: Boxdyn ModelService, } impl MyAICli { fn new(service: Boxdyn ModelService) - Self { Self { model_service: service } } async fn run(self, user_input: str) - ResultString { let messages vec![ ChatMessage { role: user.to_string(), content: user_input.to_string(), }, ]; self.model_service.chat(messages).await } }让我特别有感触的是中间那段关于 fallback 的测试。很多初学者包括三个月前的我写测试只测正常路径但真正的 bug 往往出现在意外情况 —— 用户输入了不匹配任何关键词的内容或者网络断了但没处理错误。这些边界情况不测上线就是定时炸弹。五、总结这篇文章介绍了在 Rust 中构建 AI CLI 工具时如何用 trait 抽象和 mock 模式来替代真实 API 调用做集成测试。核心思想很简单定义ModelServicetrait → 提供真实和 mock 两种实现 → 在测试中注入 mock验证业务逻辑的正确性。从自学的角度看这个方案最核心的价值不是省那几美元的 API 费用而是建立了一种可测试的架构意识。以前我写代码总是急着实现功能从不考虑测试。现在我会先想这段逻辑方便测试吗如果不方便是不是设计有问题如果你也在做 AI 应用的开发不妨试试这个思路。哪怕一开始只写两个测试对代码质量信心的提升也是实实在在的。