Rust trait 入门:把 AI 客户端抽象成可替换接口
写 AI 命令行工具的时候,第一版我通常只接一个模型服务------比如 OpenAI。代码里到处直接调用 HTTP 客户端,参数硬编码,返回格式也完全按照那一家服务商的文档来写。写的时候觉得很快,一个下午就能跑起来。但过了两周我想换 Anthropic 试试,才发现几乎每一层代码里都嵌着跟 OpenAI 强绑定逻辑,改起来跟拆积木一样疼。
后来我在 Rust 社区读到 trait 的用法,才意识到一个很重要的设计思路:业务逻辑应该依赖"能做某事的能力",而不是依赖"某个具体的实现"。 在自学的过程中,我以前写代码的习惯是"跑通就行",但 Rust 的 trait 系统让我慢慢学会了,边界先画清楚,代码才不容易越写越绑死。
一、定义统一的接口契约 --- 让命令逻辑不关心背后是谁
我们先看一张图。我在设计 AI 客户端时,希望命令处理逻辑只看到一个 trait,而具体是远程模型、本地模型还是测试用的 Mock,都由外部注入:
命令逻辑只关心一个事实:我给 trait 一个 prompt 字符串,trait 给我返回一个结果。这条边界如果画得干净,后面换模型、写测试、加新能力都会轻松很多。trait 不是为了炫语法,而是为了让"变化"只发生在我们能控制的地方。
二、从最简单的 trait 开始 --- 先定义能力,再考虑异步
初学时不用急着上 async trait。先用同步接口把核心能力定义清楚:
rust
use std::fmt;
/// AI 客户端的能力接口:只承诺能根据 prompt 生成文本
pub trait AiClient {
/// 根据用户提示词返回模型生成文本
fn complete(&self, prompt: &str) -> Result<String, AiError>;
}
/// 在 trait 内部定义自己的错误类型,不要漏出底层库的 Error
#[derive(Debug)]
pub enum AiError {
/// 网络层面的错误
Network(String),
/// 身份认证失败(密钥不对或过期)
Unauthorized,
/// 服务端返回的数据格式与预期不一致
InvalidResponse(String),
/// 请求超过等待时间
Timeout(u64),
}
impl fmt::Display for AiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AiError::Network(msg) => write!(f, "网络错误: {}", msg),
AiError::Unauthorized => write!(f, "认证失败,请检查 API Key 是否正确"),
AiError::InvalidResponse(msg) => write!(f, "响应格式异常: {}", msg),
AiError::Timeout(secs) => write!(f, "请求超时(>{}秒),请检查网络或稍后重试", secs),
}
}
}
这里有一个我觉得很重要的原则:错误类型也要自己定义一层,不要把底层 HTTP 库的 Error 直接暴露给业务层。 我第一次写时偷懒直接用 reqwest::Error,后来想换成别家的 HTTP 库,发现一百多个函数签名都要跟着改------那种教训一次就够了。
三、实现远程客户端 --- 把第三方细节关在 impl 里
下面是一个远程 HTTP 客户端的实现骨架。真实项目里我会在这里封装 reqwest 或 hyper 调用,但关键是 HTTP 细节(构建请求头、解析状态码、处理重定向)全部关在这个 impl 内部:
rust
/// 通过 HTTP 调用的远程 AI 客户端
pub struct HttpAiClient {
/// API 访问密钥(由配置管理模块传入)
pub api_key: String,
/// API 请求的基础地址
pub base_url: String,
/// 默认请求超时秒数
pub timeout_secs: u64,
}
impl AiClient for HttpAiClient {
fn complete(&self, prompt: &str) -> Result<String, AiError> {
// 在这里封装所有 HTTP 细节:
// 1. 构建请求头(Authorization, Content-Type 等)
// 2. 组装请求体(JSON payload)
// 3. 发送 POST 请求并设置超时
// 4. 解析响应状态码和 JSON body
// 5. 把第三方 Error 转换成我们定义的 AiError
// 这里仅展示边界,真实代码会调用 reqwest
if prompt.is_empty() {
return Err(AiError::InvalidResponse("prompt 不能为空".to_string()));
}
Ok(format!("[模型回复] 针对提示: {}", prompt))
}
}
这样做的好处是:如果将来 OpenAI 把接口格式改了,或者我想切到 Anthropic/Mistral,只需要在 HttpAiClient 的 complete 方法里修改------命令逻辑的代码一行都不用动。
四、用 Mock 做测试 --- 不让单元测试依赖网络
有了 trait,单元测试就简单了。我可以写一个永远成功(或故意失败)的 Mock,用来测命令逻辑的各种分支:
rust
/// 测试用的 Mock 客户端,不发送网络请求
pub struct MockAiClient {
/// 预设的返回文本(方便验证不同场景)
pub mock_response: String,
}
impl AiClient for MockAiClient {
fn complete(&self, _prompt: &str) -> Result<String, AiError> {
// 直接返回预设内容,不做任何网络调用
Ok(self.mock_response.clone())
}
}
/// 命令逻辑只依赖 trait,不依赖具体客户端类型
fn run_command(client: &impl AiClient, prompt: &str) -> Result<String, AiError> {
let answer = client.complete(prompt)?;
// 对返回结果做一些通用处理(去首尾空白等)
Ok(answer.trim().to_string())
}
有了 Mock,测试命令逻辑时不需要真的调用模型------不会消耗 API 额度,也不会因为网络波动导致测试随机失败。如果想模拟超时或认证失败,写一个对应的 Mock 就行了,几行代码的事。
有个教训是 Mock 如果写得太"完美",反而测不出问题。我写过一个永远返回成功的 Mock,结果上线后才发现模型返回空字符串时命令逻辑根本没处理。后来给 Mock 加了"返回空""超时""401"几种模式,测试才真的有价值。Mock 和真实环境的差距越小,测试就越可靠。
五、总结
Rust trait 把 AI 客户端抽象成可替换接口的核心思路其实不复杂:先定义能力契约(AiClient),再分别实现(HttpAiClient + MockAiClient),最后让业务代码只依赖 trait 而不依赖具体类型。
作为自学者,我刚开始觉得"多写一层 trait 和多个 impl"很麻烦,不如直接调 HTTP 省事。但写多几个小工具之后就发现:抽象带来的安全感和灵活性,远比多写十几行代码的成本值得。 当远程模型、本地推理和测试桩都能挂在同一个接口下面时,项目结构会安静很多------那种安静,不是没人动代码,而是变化被关在了正确的位置。