← 返回资讯

Rust 调用大模型 API 完整示例

2026-06-26

Rust 调用大模型 API 最常用的组合是 reqwest(异步 HTTP)+ serde_json(JSON 序列化)。由于大模型 API 基于 HTTP,无需任何特殊协议,只需正确处理 async/await 和 SSE 流式输出即可。

如果你是从 Python 或 Go 转过来写 Rust 的,先说一句大实话:这篇代码你复制粘贴就能跑,但真正让你踩坑的不是”怎么发一个请求”,而是三件事——Client 该不该每次新建、流式响应的字节流会不会把一个汉字切成两半、还有 429/超时之后要不要重试。这几个问题网上大部分示例都不讲,因为写 demo 用不着,但一旦你的服务要跑 7×24 小时,这些坑迟早要踩一遍。下面除了保留原有的完整代码,我会把这几处生产环境才会暴露的问题一并讲透。

环境准备

# Cargo.toml
[dependencies]
reqwest  = { version = "0.12", features = ["json"] }
serde    = { version = "1",    features = ["derive"] }
serde_json = "1"
tokio    = { version = "1",    features = ["full"] }
export OPENAI_API_KEY="sk-xxx"
export OPENAI_BASE_URL="https://api.lidayun.com/v1"

这里有个新手常踩的坑:reqwest 默认走的 TLS 后端是 default-tls,底层链的是系统的 OpenSSL(或者 macOS 的 Secure Transport、Windows 的 SChannel)。在 Linux 服务器上交叉编译,或者用 Docker 多阶段构建时,你大概率会遇到编译报错 failed to run custom build command for openssl-sys,本质是构建镜像里缺 libssl-devpkg-config。两条路都能解:一是在 Cargo.toml 里装上系统依赖(apt install libssl-dev pkg-config),二是把 reqwest 的 feature 换成 rustls-tls(去掉默认的 default-tls),完全用纯 Rust 实现的 TLS,不依赖任何系统库,跨平台编译一次过。如果你的构建环境是 alpine(musl libc),几乎必须用 rustls-tls,否则 OpenSSL 链接会直接失败。改法很简单:

reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

OPENAI_BASE_URL 这行也要留意:力达云和大多数国内中转平台都是 OpenAI 兼容协议,只要把 base_url 换掉、api_key 换成对应平台发的 key,上面这套 Rust 代码不用改一行业务逻辑就能切换供应商——这也是为什么”OpenAI 兼容”这个设计对多语言 SDK 都友好,Rust 也不例外。

基础调用

use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::env;

#[derive(Serialize)]
struct Message {
    role: String,
    content: String,
}

#[derive(Serialize)]
struct ChatRequest<'a> {
    model: &'a str,
    messages: Vec<Message>,
    max_tokens: u32,
}

#[derive(Deserialize)]
struct ChatResponse {
    choices: Vec<Choice>,
}

#[derive(Deserialize)]
struct Choice {
    message: MessageContent,
}

#[derive(Deserialize)]
struct MessageContent {
    content: String,
}

async fn chat(user_msg: &str, model: &str) -> anyhow::Result<String> {
    let api_key  = env::var("OPENAI_API_KEY")?;
    let base_url = env::var("OPENAI_BASE_URL")
        .unwrap_or_else(|_| "https://api.openai.com/v1".into());

    let client = Client::builder()
        .timeout(std::time::Duration::from_secs(60))
        .build()?;

    let body = ChatRequest {
        model,
        messages: vec![Message {
            role: "user".into(),
            content: user_msg.into(),
        }],
        max_tokens: 1024,
    };

    let resp: ChatResponse = client
        .post(format!("{base_url}/chat/completions"))
        .bearer_auth(&api_key)
        .json(&body)
        .send()
        .await?
        .error_for_status()?   // 4xx/5xx 转换为 Err
        .json()
        .await?;

    Ok(resp.choices[0].message.content.clone())
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let reply = chat("用 Rust 实现一个简单的 HTTP 服务器", "gpt-4o-mini").await?;
    println!("{reply}");
    Ok(())
}

逐行拆一下这段代码里容易被忽略的细节:

  • ChatRequest<'a> 里的生命周期标注 'a 是 Rust 特有的坑。model: &'a str 意味着这个结构体借用了外部传入的字符串切片,编译器要求你保证 ChatRequest 存活期间那个字符串没被释放。如果你嫌麻烦想偷懒,把 &'a str 换成 String(拥有所有权),可以省掉生命周期标注,但每次调用会多一次堆分配——量不大的话完全无所谓,先追求编译能过,不要在这种地方过早优化。
  • resp.choices[0] 这行是一颗定时炸弹。正常情况下 API 会返回至少一个 choice,但如果上游触发内容审核拦截、或者你传的 messages 是空数组导致 400 却又没被 error_for_status() 拦下来(极少数网关会返回 200 但 choices 为空数组),这里直接 panic,整个服务崩掉。生产代码请换成 resp.choices.first().ok_or_else(|| anyhow::anyhow!("choices 为空,可能被内容安全拦截"))?,把崩溃变成可控的 Result 错误,上层能捕获、能重试、能打日志。
  • Client::builder().timeout(...) 这里设的是整个请求的总超时(从发起连接到读完响应体),不是连接超时。如果你的模型经常需要思考较久(比如推理型模型、长文本生成),60 秒可能不够用,调用方会先收到 reqwest::Error { kind: TimedOut },而不是模型返回的真实内容。区分「连接超时」和「读取超时」需要额外用 connect_timeout() 单独设置连接阶段的超时(建议 5-10 秒,网络问题应该快速失败),把总 timeout 留给模型生成,两者不是一回事,很多人只设了一个就以为够了。
  • 更关键的一点:不要每次调用 chat()Client::builder().build() 一次reqwest::Client 内部维护了连接池(keep-alive 长连接),新建一个 Client 意味着丢弃旧的连接池、重新走 TCP 三次握手 + TLS 握手,高并发场景下这会直接把延迟拖高一个数量级。正确做法是用 once_cell::sync::Lazy 或者 std::sync::OnceLockClient 做成进程级单例:
use std::sync::OnceLock;

static CLIENT: OnceLock<Client> = OnceLock::new();

fn client() -> &'static Client {
    CLIENT.get_or_init(|| {
        Client::builder()
            .timeout(std::time::Duration::from_secs(60))
            .connect_timeout(std::time::Duration::from_secs(8))
            .build()
            .expect("build reqwest client failed")
    })
}

之后所有请求都用 client().post(...),连接池能被复用,TLS 握手只做一次,这是 Rust 后端项目里最容易被漏掉、但影响最直接的一个性能点。

多轮对话

let mut history: Vec<Message> = vec![
    Message { role: "system".into(), content: "你是一个 Rust 编程助手。".into() },
];

// 第一轮
history.push(Message { role: "user".into(), content: "什么是所有权?".into() });
let reply1 = chat_with_history(&history, "gpt-4o-mini").await?;
history.push(Message { role: "assistant".into(), content: reply1 });

// 第二轮(携带上下文)
history.push(Message { role: "user".into(), content: "能举个代码例子吗?".into() });
let reply2 = chat_with_history(&history, "gpt-4o-mini").await?;
println!("{reply2}");

其中 chat_with_history 与上面的 chat 函数结构相同,只是 messages 直接传入 history

多轮对话最实际的问题不是怎么拼 history,而是 history 迟早会撑爆 context 窗口。比如你用一个 8K context 的模型,聊了 30 多轮之后突然收到 400,error_for_status() 直接把它转成一个只有状态码、没有具体信息的 reqwest::Error——这时候你根本不知道是 key 失效了还是超限了。想看清真实原因,得放弃 error_for_status() 这种”一步到位”的写法,改成先拿状态码、再读错误体:

let resp = client()
    .post(format!("{base_url}/chat/completions"))
    .bearer_auth(&api_key)
    .json(&body)
    .send()
    .await?;

let status = resp.status();
if !status.is_success() {
    let err_body = resp.text().await.unwrap_or_default();
    // 典型错误体:{"error":{"message":"This model's maximum context length is 8192 tokens...","type":"invalid_request_error","code":"context_length_exceeded"}}
    anyhow::bail!("请求失败 status={status} body={err_body}");
}
let data: ChatResponse = resp.json().await?;

这样打印出来的 err_body 里会明确带 "code":"context_length_exceeded",你可以据此写一个”超限就砍掉最早几轮历史再重试”的逻辑,而不是让整个对话直接失败。同理,401 会带 "code":"invalid_api_key",429 会带 "code":"rate_limit_exceeded"——不读错误体,你永远只能靠”猜”来排查线上问题。

流式输出(SSE)

use futures_util::StreamExt;

async fn chat_stream(user_msg: &str) -> anyhow::Result<()> {
    let api_key  = env::var("OPENAI_API_KEY")?;
    let base_url = env::var("OPENAI_BASE_URL")
        .unwrap_or_else(|_| "https://api.openai.com/v1".into());

    let body = serde_json::json!({
        "model": "gpt-4o-mini",
        "messages": [{"role": "user", "content": user_msg}],
        "stream": true,
        "max_tokens": 1024
    });

    let mut stream = Client::new()
        .post(format!("{base_url}/chat/completions"))
        .bearer_auth(&api_key)
        .json(&body)
        .send()
        .await?
        .error_for_status()?
        .bytes_stream();

    while let Some(chunk) = stream.next().await {
        let chunk = chunk?;
        let text  = String::from_utf8_lossy(&chunk);

        for line in text.lines() {
            if let Some(data) = line.strip_prefix("data: ") {
                if data == "[DONE]" { break; }
                if let Ok(v) = serde_json::from_str::<serde_json::Value>(data) {
                    if let Some(c) = v["choices"][0]["delta"]["content"].as_str() {
                        print!("{c}");
                    }
                }
            }
        }
    }
    println!();
    Ok(())
}

上面这段流式代码能跑,但藏着一个不容易发现的 bug:String::from_utf8_lossy(&chunk) 是按每一个网络包单独解码的。TCP 传输不保证每次 stream.next() 拿到的 chunk 都在字符边界上截断——如果一个中文字符(UTF-8 里占 3 个字节)恰好被切成两半分布在两个 chunk 里,from_utf8_lossy 会把解码不出来的那部分替换成 (U+FFFD 替换字符),你会看到输出里偶尔蹦出乱码方块,复现率跟网络状况、chunk 大小强相关,很难稳定复现,排查起来非常烦人。

正确的做法是维护一个跨 chunk 的字节缓冲区,只有解码失败时才把不完整的尾巴留到下一次拼接,而不是每个 chunk 各自为政:

async fn chat_stream_safe(user_msg: &str) -> anyhow::Result<()> {
    let api_key  = env::var("OPENAI_API_KEY")?;
    let base_url = env::var("OPENAI_BASE_URL")
        .unwrap_or_else(|_| "https://api.openai.com/v1".into());

    let body = serde_json::json!({
        "model": "gpt-4o-mini",
        "messages": [{"role": "user", "content": user_msg}],
        "stream": true
    });

    let mut stream = client()
        .post(format!("{base_url}/chat/completions"))
        .bearer_auth(&api_key)
        .json(&body)
        .send()
        .await?
        .bytes_stream();

    let mut byte_buf: Vec<u8> = Vec::new();   // 跨 chunk 的字节缓冲
    let mut line_buf = String::new();          // 跨 chunk 的行缓冲(SSE 可能一行被截断)

    while let Some(chunk) = stream.next().await {
        byte_buf.extend_from_slice(&chunk?);

        // 只解码到最后一个合法的 UTF-8 边界,剩余不完整字节留到下一轮
        let valid_len = match std::str::from_utf8(&byte_buf) {
            Ok(_) => byte_buf.len(),
            Err(e) => e.valid_up_to(),
        };
        let text = String::from_utf8_lossy(&byte_buf[..valid_len]).into_owned();
        byte_buf.drain(..valid_len);

        line_buf.push_str(&text);
        while let Some(pos) = line_buf.find('\n') {
            let line = line_buf[..pos].trim_end_matches('\r').to_string();
            line_buf.drain(..=pos);

            if let Some(data) = line.strip_prefix("data: ") {
                if data == "[DONE]" { break; }
                if let Ok(v) = serde_json::from_str::<serde_json::Value>(data) {
                    if let Some(c) = v["choices"][0]["delta"]["content"].as_str() {
                        print!("{c}");
                    }
                }
            }
        }
    }
    println!();
    Ok(())
}

关键就两行:std::str::from_utf8(&byte_buf) 校验时如果失败,e.valid_up_to() 会告诉你前面多少字节是合法的,只解码这部分,剩下的字节留在 byte_buf 里等下一个 chunk 补齐。这个模式不只是大模型场景专用,任何”边收网络流边解码文本”的 Rust 代码都该这么写,属于通用的健壮性写法。如果你的服务只是内部脚本、偶尔乱码也无所谓,用最开始那版简单实现完全没问题;但凡是要给用户展示的生产接口,这里必须补上。

并发请求与重试退避

真实业务里经常要同时问多个模型、或者批量处理一批 prompt,串行 await 一个个来太慢。Rust 里最直接的写法是 tokio::spawn 起多个任务,再用 futures::future::join_all 收集结果:

use futures::future::join_all;

async fn batch_chat(prompts: Vec<String>) -> Vec<anyhow::Result<String>> {
    let tasks: Vec<_> = prompts.into_iter().map(|p| {
        tokio::spawn(async move { chat(&p, "gpt-4o-mini").await })
    }).collect();

    join_all(tasks).await.into_iter()
        .map(|r| r.unwrap_or_else(|e| Err(anyhow::anyhow!("task panicked: {e}"))))
        .collect()
}

注意 tokio::spawn 返回的是 JoinHandle.await 拿到的是 Result<T, JoinError>——外层这层 Result 代表”任务本身有没有 panic”,内层才是你业务逻辑的 Result,两层要分开处理,不然一个任务里的 panic 会被悄悄吞掉,你只会觉得”这条结果为啥是空的”。另外要提醒一句:并发数不是越高越好,几乎所有模型服务商都有并发限速(RPM/TPM 限制),无脑开几百个并发大概率直接被 429 打回来,实际项目里建议用 tokio::sync::Semaphore 限制同时在飞的请求数,比如控制在 5-10 个,比无限并发更稳定,总耗时反而更短(因为不会触发限速重试)。

429(限速)和 5xx(服务端瞬时错误)这两类错误的正确应对是指数退避重试,而不是立刻原样重发或者直接放弃:

async fn chat_with_retry(user_msg: &str, model: &str) -> anyhow::Result<String> {
    let mut attempt = 0;
    loop {
        match chat(user_msg, model).await {
            Ok(reply) => return Ok(reply),
            Err(e) if attempt < 3 => {
                attempt += 1;
                let backoff = std::time::Duration::from_millis(500 * 2u64.pow(attempt));
                eprintln!("第 {attempt} 次失败:{e},{backoff:?} 后重试");
                tokio::time::sleep(backoff).await;
            }
            Err(e) => return Err(e),
        }
    }
}

这里的退避时间是 1s → 2s → 4s,每次翻倍,同时建议加一点随机抖动(jitter),避免大量并发请求在同一时刻集体重试造成”重试风暴”再次把服务打崩。要注意的是:只有 429 和 5xx 值得重试,400(参数错误)、401(鉴权失败)这类错误重试多少次结果都一样,白白浪费时间和 token 预算,实际项目里应该先判断 reqwest::Error 对应的状态码,只对可重试的错误走这条退避逻辑。

手写 reqwest vs 用 async-openai crate,怎么选

维度手写 reqwest + serdeasync-openai crate
上手速度需要自己定义所有请求/响应结构体类型已经封装好,Client::new() 即用
灵活度完全掌控字段,方便对接非标准/魔改接口跟随官方 API 更新,非标准字段需要自己扩展
兼容中转平台base_url 即可,各种平台都能对接支持 with_api_base 覆写,同样能对接兼容平台
二进制体积/编译时间依赖少,编译快多引入一层封装,编译稍慢
适合场景只调用少数几个接口、需要精细控制的服务端组件生产项目要调用完整能力集(函数调用、embedding、audio 等)

简单说:如果你只是要在某个 Rust 服务里加一个”调一下大模型”的功能,本文这种手写方式更轻量、依赖更少;如果你要做的是一个功能完整、长期维护的 AI 应用后端,async-openai 帮你把 function calling、embeddings、image 等一整套类型都定义好了,没必要自己重复造轮子。两者不冲突,很多团队是核心链路用 async-openai,个别需要魔改的边缘接口自己用 reqwest 单独写。

顺手统计一下成本

大模型 API 的响应体里其实带了计费用的 usage 字段(prompt_tokens / completion_tokens / total_tokens),原文的 ChatResponse 结构体没解析这部分,实际项目里建议顺手加上,方便统计和预警:

#[derive(Deserialize)]
struct ChatResponse {
    choices: Vec<Choice>,
    usage: Usage,
}

#[derive(Deserialize)]
struct Usage {
    prompt_tokens: u32,
    completion_tokens: u32,
    total_tokens: u32,
}

拿到 usage.total_tokens 之后,乘以你所在平台该模型的单价(具体单价以平台官网当前公示为准,各家会调整),就能在日志里打出每次调用的实际花费,长期跑下来对成本预警非常有用——尤其是批量任务,没有这层统计,很容易某天账单突然爆炸都不知道是哪个接口在疯狂调用。

常见问题

编译报 feature not enabled 错误怎么办? 检查 Cargo.tomlreqwest 的 features,确保包含 json;流式场景还需加 stream

error_for_status() 和手动判断状态码哪个好? error_for_status() 更简洁,会将 4xx/5xx 转成 reqwest::Error;需要读取错误体内容时,先 .text().await? 再判断状态码。

Rust 有没有类似 go-openai 的完整 SDK? 社区有 async-openai crate,封装了完整的 OpenAI API 类型,支持流式,适合生产项目;配合 BaseURL 覆写同样可兼容其他平台。

为什么本地调试没问题,容器里跑就报 error trying to connect: dns error 多数是容器网络策略或者 /etc/resolv.conf 没配好导致的 DNS 解析失败,跟 Rust 代码本身无关;先在容器里 curl 一下同样的 base_url 确认网络通不通,能排除掉一大半”代码是不是写错了”的猜疑,把问题范围收窄到网络层。

收到 401 Unauthorized 但 key 确认没输错,是什么原因? 排查顺序建议是:① bearer_auth 传的变量有没有多余的空格或换行(用 .trim() 兜底一下);② 中转平台的 key 是否需要额外的请求头(部分平台除了 Authorization 还要求 X-Api-Key 或者自定义 header,具体以对接平台文档为准);③ key 是否过期或者额度已耗尽——这种情况错误体里通常会写明原因,参考前面”读错误体”那段的做法,把 resp.text() 打出来看,比反复猜测快得多。

serde_json::Value 取字段用 v["choices"][0]["delta"]["content"] 这种链式写法,取不到会怎样? 不会 panic,serde_json::Value 的索引操作找不到字段时返回 Value::Null,链式取到最后 .as_str() 拿到的是 None,代码里用 if let Some(c) = ... 兜底了,这也是为什么示例里都用这种写法而不是强行 unwrap()——SSE 的增量事件里不是每个 chunk 都带 content(比如带 finish_reason 的最后一个事件就没有),这种写法天然兼容这种”字段有时不存在”的情况。


更多接入方案见大模型 API 接入完全指南接入教程专题。Go 语言版本参考Go 调用大模型 API。需要统一多模型入口?申请力达云聚合 API 内测