約1k行小型LLM代理:透明轉(zhuǎn)發(fā)與SSE流式透傳)
在 AI 應(yīng)用開發(fā)中LLM 代理是客戶端與上游模型服務(wù)之間最常見的中間層負責路由請求、注入密鑰、統(tǒng)一超時、透傳流式響應(yīng)和記錄調(diào)用日志。多業(yè)務(wù)方共用密鑰、切換模型服務(wù)商、審計調(diào)用記錄、限制單用戶用量這些事情如果逐個寫在業(yè)務(wù)代碼里會非常難維護。用 Rust 寫一個約 1k 行的小型 LLM 代理既能跑通這套核心邏輯又能把依賴數(shù)量控制在很低的水平。這篇文章圍繞“eek! it’s a tiny rust LLM proxy in ~1k loc”這類微型項目風格從職責拆分開始逐步實現(xiàn)一個可運行、可驗證、可繼續(xù)改造成生產(chǎn)服務(wù)的代理程序。1. 先搞清楚 LLM 代理到底做了什么1.1 LLM 代理不是 API 網(wǎng)關(guān)也不是 SDK很多人會把 LLM 代理和 API 網(wǎng)關(guān)混為一談。API 網(wǎng)關(guān)處理的是通用 REST 請求的路由、鑒權(quán)、限流、熔斷它不一定理解模型上下文。LLM 代理則更貼近模型語義它會關(guān)心/v1/chat/completions和/v1/responses這類專用端點會關(guān)心流式 SSE 響應(yīng)是否被正確分塊會關(guān)心“思考模式”下的額外字段是否在轉(zhuǎn)發(fā)過程中被意外刪掉。同樣LLM 代理也不是 SDK。SDK 是打包給開發(fā)者使用的客戶端庫代理則是獨立部署的服務(wù)進程。客戶端只需要把請求發(fā)到代理地址代理負責把請求發(fā)往真正的模型服務(wù)商。這樣做的好處是業(yè)務(wù)側(cè)不需要知道上游地址和密鑰模型服務(wù)商切換時也不需要改業(yè)務(wù)代碼。一個最簡單的 LLM 代理本質(zhì)上做的事情只有四件接收客戶端的 HTTP 請求。改寫目標地址和必要的 Header。把請求體發(fā)送給上游模型服務(wù)。把上游響應(yīng)原樣返回給客戶端。其余能力比如模型路由、日志、限流、緩存、多租戶隔離都是在這四個動作上疊加的。1.2 代理的六個核心職責在實際項目中LLM 代理的職責可以拆成六塊。第一統(tǒng)一入口。所有模型調(diào)用都經(jīng)過同一個地址便于配置和審計。第二密鑰管理??蛻舳瞬恢苯映钟猩嫌?API Key代理在轉(zhuǎn)發(fā)時統(tǒng)一注入 Authorization Header。第三路由轉(zhuǎn)發(fā)。根據(jù)路徑或請求體里的 model 字段把請求轉(zhuǎn)發(fā)到不同上游例如 OpenAI、DeepSeek、本地 vLLM 等。第四流式響應(yīng)透傳。模型接口的 stream 模式使用 SSE代理必須支持邊接收上游數(shù)據(jù)邊發(fā)給客戶端不能等全部接收完再返回。第五錯誤與狀態(tài)碼透傳。上游返回 400、401、403、429、502 時客戶端需要看到真實錯誤原因不能被代理吞掉。第六監(jiān)控與審計。記錄每次調(diào)用的模型、耗時、狀態(tài)碼、Token 用量用于成本核算和問題排查。這六塊職責并不都需要在第一版實現(xiàn)。第一版只做前三項和第四項錯誤透傳屬于第五項監(jiān)控可以在后面用中間件補上。1.3 透明代理原則不解析才是最快的轉(zhuǎn)發(fā)設(shè)計 LLM 代理時最容易犯的一個錯誤是“過度處理請求體”。很多開發(fā)者拿到請求體后習慣性地用 serde_json 解析成 Value修改幾個字段再序列化回去。這一步看似無害卻會帶來兩類問題。第一丟失未聲明字段。上游模型接口經(jīng)常增加新字段例如 reasoning_content、tool_calls、citations。如果代理只保留自己認識的字段這些新字段會被靜默刪除上游可能直接返回 400。第二破壞流式響應(yīng)的時序。JSON 解析和重新序列化會引入額外內(nèi)存拷貝和 CPU 消耗對 SSE 流式轉(zhuǎn)發(fā)尤其不利。所以第一版代理應(yīng)該堅持透明轉(zhuǎn)發(fā)原則請求體是什么就原樣轉(zhuǎn)發(fā)什么響應(yīng)體是什么就原樣返回什么。只修改必須修改的部分比如 Host、Authorization、Content-Length。這條原則會在后面的代碼里反復(fù)體現(xiàn)。2. 環(huán)境準備Rust 工具鏈與項目骨架2.1 安裝 Rust 工具鏈并配置國內(nèi)鏡像本項目的核心依賴是 Rust 工具鏈使用 rustup 安裝即可。curl --proto https --tlsv1.2 -sSf https://sh.rustup.rs | sh安裝完成后執(zhí)行如下命令確認版本。rustc --version cargo --version國內(nèi)網(wǎng)絡(luò)環(huán)境下rustup 和 crates.io 下載可能不穩(wěn)定。rustup 可以通過環(huán)境變量指定下載鏡像。export RUSTUP_DIST_SERVERhttps://mirrors.tuna.tsinghua.edu.cn/rustup export RUSTUP_UPDATE_ROOThttps://mirrors.tuna.tsinghua.edu.cn/rustup/rustupcrates.io 依賴下載慢時可以在~/.cargo/config.toml里配置鏡像源。[source.crates-io] replace-with rsproxy-sparse [source.rsproxy-sparse] registry sparsehttps://rsproxy.cn/index/配置完成后cargo build拉取依賴會明顯變快。注意鏡像源本身會變化配置文件里的地址要以你所在網(wǎng)絡(luò)環(huán)境可用為準。公共鏡像只用于加速依賴下載不影響代碼邏輯。2.2 創(chuàng)建項目并選擇依賴使用 cargo 創(chuàng)建項目。cargo new tiny-llm-proxy cd tiny-llm-proxy這樣一個約 1k 行的小型代理不需要引入重量級框架。核心依賴四類axum處理 HTTP 路由、并發(fā)和生命周期。reqwest作為 HTTP 客戶端負責向上游發(fā)起請求。tokio異步運行時。tracing tracing-subscriber結(jié)構(gòu)化日志。Cargo.toml 內(nèi)容如下[package] name tiny-llm-proxy version 0.1.0 edition 2021 [dependencies] axum 0.8 tokio { version 1, features [full] } reqwest { version 0.12, default-features false, features [rustls-tls, stream] } serde { version 1, features [derive] } serde_json 1 tracing 0.1 tracing-subscriber { version 0.3, features [env-filter] } dotenvy 0.15reqwest 關(guān)閉默認特性并啟用 rustls-tls是為了避免在 Linux 服務(wù)器上額外依賴 OpenSSL。后續(xù)如果要用Client直接上傳 Multipart 表單或 JSON可以在 features 里追加json。2.3 項目目錄與配置加載項目結(jié)構(gòu)保持簡單三個源文件加一個環(huán)境變量示例文件。tiny-llm-proxy/ ├── Cargo.toml ├── .env.example └── src/ ├── main.rs ├── config.rs └── proxy.rs配置不寫在代碼里通過環(huán)境變量讀取。新建src/config.rs。use std::env; #[derive(Clone, Debug)] pub struct Config { pub listen_addr: String, pub upstream_base: String, pub api_key: String, pub timeout_secs: u64, } impl Config { pub fn from_env() - ResultSelf, String { Ok(Config { listen_addr: env::var(LISTEN_ADDR) .unwrap_or_else(|_| 127.0.0.1:8787.to_string()), upstream_base: env::var(UPSTREAM_BASE) .map_err(|_| UPSTREAM_BASE is required.to_string())?, api_key: env::var(UPSTREAM_API_KEY).unwrap_or_default(), timeout_secs: env::var(TIMEOUT_SECS) .ok() .and_then(|v| v.parse().ok()) .unwrap_or(300), }) } }.env.example里放一份配置模板。LISTEN_ADDR127.0.0.1:8787 UPSTREAM_BASEhttps://api.openai.com/v1 UPSTREAM_API_KEYsk-xxxx TIMEOUT_SECS300 RUST_LOGinfo把密鑰文件加入.gitignore不要提交到倉庫。3. 實現(xiàn)最小轉(zhuǎn)發(fā)循環(huán)接收請求、注入密鑰、轉(zhuǎn)發(fā)、返回響應(yīng)3.1 用 axum 暴露 OpenAI 兼容路由程序入口src/main.rs負責初始化日志、加載配置、構(gòu)建共享 HTTP 客戶端、注冊路由。mod config; mod proxy; use std::time::Duration; use axum::{routing::post, Router}; use reqwest::Client; use tracing_subscriber::EnvFilter; use config::Config; #[derive(Clone)] pub struct AppState { pub cfg: Config, pub client: Client, } #[tokio::main] async fn main() { dotenvy::dotenv().ok(); tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env()) .init(); let cfg Config::from_env().expect(failed to load config); let client Client::builder() .timeout(Duration::from_secs(cfg.timeout_secs)) .build() .expect(failed to build reqwest client); let state AppState { cfg, client }; let app Router::new() .route(/v1/chat/completions, post(proxy::chat_completions)) .route(/v1/responses, post(proxy::responses)) .with_state(state); let listener tokio::net::TcpListener::bind(state.cfg.listen_addr) .await .expect(failed to bind listener); tracing::info!(LLM proxy listening on {}, state.cfg.listen_addr); axum::serve(listener, app).await.expect(server error); }這里暴露了兩個 OpenAI 兼容端點/v1/chat/completions和/v1/responses。如果你只需要其中一個路由可以繼續(xù)精簡。3.2 請求頭過濾與上游地址拼接轉(zhuǎn)發(fā)邏輯全部放在src/proxy.rs。核心函數(shù)不直接處理具體端點而是接收一個 path 參數(shù)這樣兩個端點復(fù)用同一套邏輯。use axum::{ body::Body, extract::State, http::{HeaderMap, Request, StatusCode}, response::Response, }; use reqwest::Body as ReqwestBody; use crate::AppState; async fn forward( state: AppState, headers: HeaderMap, body: Body, path: str, ) - Response { let upstream_url format!( {}{}, state.cfg.upstream_base.trim_end_matches(/), path ); let mut upstream_headers HeaderMap::new(); for (name, value) in headers.iter() { let lower name.as_str().to_ascii_lowercase(); if lower host || lower content-length || lower connection || lower accept-encoding { continue; } upstream_headers.insert(name.clone(), value.clone()); } upstream_headers.insert( authorization, format!(Bearer {}, state.cfg.api_key) .parse() .expect(invalid bearer token), ); let result state .client .post(upstream_url) .headers(upstream_headers) .body(ReqwestBody::wrap_stream(body.into_data_stream())) .send() .await; match result { Ok(resp) { let status resp.status(); let mut builder Response::builder().status(status); for (name, value) in resp.headers() { let lower name.as_str().to_ascii_lowercase(); if lower transfer-encoding || lower content-encoding || lower content-length { continue; } builder builder.header(name, value); } builder .body(Body::from_stream(resp.bytes_stream())) .expect(failed to build response) } Err(err) { tracing::error!(error %err, upstream request failed); let status if err.is_timeout() { StatusCode::GATEWAY_TIMEOUT } else { StatusCode::BAD_GATEWAY }; Response::builder() .status(status) .body(Body::from(format!(upstream request failed: {err}))) .expect(failed to build error response) } } }幾個關(guān)鍵點過濾host是因為上游地址已經(jīng)由upstream_url決定不能繼續(xù)使用客戶端的 Host。過濾content-length是因為請求體通過 stream 轉(zhuǎn)換后長度可能變化交給 reqwest 自己計算。過濾accept-encoding是為了避免上游返回壓縮流后轉(zhuǎn)發(fā)層還要處理解壓邏輯。第一版最好讓響應(yīng)體保持純文本流便于排查。注入authorization時使用配置里的api_key客戶端傳過來的原始 Authorization 會被覆蓋。3.3 用 reqwest 轉(zhuǎn)發(fā)并透傳響應(yīng)體兩個具體端點分別調(diào)用forward。pub async fn chat_completions( State(state): StateAppState, req: RequestBody, ) - Response { let (parts, body) req.into_parts(); forward(state, parts.headers, body, /v1/chat/completions).await } pub async fn responses( State(state): StateAppState, req: RequestBody, ) - Response { let (parts, body) req.into_parts(); forward(state, parts.headers, body, /v1/responses).await }這里用RequestBody作為 handler 參數(shù)這樣可以同時拿到 HeaderMap 和 Request Body也避免 axum 對多個 consuming extractor 的限制。轉(zhuǎn)發(fā)層最終把上游響應(yīng)體轉(zhuǎn)成Body::from_stream(resp.bytes_stream())。這一步非常關(guān)鍵它讓響應(yīng)以流式方式返回給客戶端而不是等上游全部發(fā)送完再一次性返回。LLM 接口開啟stream: true后用戶會看到 token 逐字出現(xiàn)而不是長時間等待。啟動服務(wù)cargo run看到日志輸出LLM proxy listening on 127.0.0.1:8787后說明最小轉(zhuǎn)發(fā)循環(huán)已經(jīng)跑通。4. 流式響應(yīng)SSE 轉(zhuǎn)發(fā)與連接生命周期4.1 SSE 的傳輸格式和轉(zhuǎn)發(fā)要點OpenAI 兼容接口的流式響應(yīng)使用 Server-Sent Events響應(yīng)內(nèi)容大致如下data: {id:chatcmpl-xxx,object:chat.completion.chunk,choices:[{delta:{content:你},index:0}]} data: {id:chatcmpl-xxx,object:chat.completion.chunk,choices:[{delta:{content:好},index:0}]} data: [DONE]客戶端需要逐行讀取data:開頭的 JSON并在收到[DONE]時結(jié)束。代理層在轉(zhuǎn)發(fā) SSE 時不需要解析這些內(nèi)容只需要保證響應(yīng)頭包含content-type: text/event-stream。上游返回的數(shù)據(jù)按字節(jié)流原樣傳遞。不要合并多個事件也不要緩沖到完整響應(yīng)再返回。上一節(jié)的代碼里resp.bytes_stream()天然滿足這三點。上游返回一個 chunk代理就轉(zhuǎn)發(fā)一個 chunk延遲接近直連上游。4.2 客戶端斷開時如何取消上游請求LLM 流式響應(yīng)可能持續(xù)幾十秒甚至幾分鐘。用戶可能中途刷新頁面、關(guān)閉網(wǎng)頁或點擊停止生成。此時客戶端 TCP 連接已經(jīng)斷開代理如果繼續(xù)從上游讀取數(shù)據(jù)會產(chǎn)生兩個問題。第一浪費上游 Token 和費用。第二上游連接長期得不到釋放并發(fā)量大時會把代理的端口和內(nèi)存占滿。Rust 的流式轉(zhuǎn)發(fā)天然具備取消能力。resp.bytes_stream()是一個異步 Stream它被放在 axum 的 Response Body 里??蛻舳藬嚅_時axum 會 drop 這個 Body底層 Stream 也會被 dropreqwest 連接隨之關(guān)閉。這里要注意不要在轉(zhuǎn)發(fā)層寫collect().await或bytes().await這類代碼。一旦把整個響應(yīng)讀進內(nèi)存再返回給客戶端客戶端斷開時上游請求不會自動取消資源占用會直線上升。4.3 流式代理最容易出現(xiàn)的三種異常第一種是響應(yīng)頭里保留了content-length但 Body 實際是分塊傳輸?shù)摹?蛻舳丝吹降拈L度和實際長度不一致會出現(xiàn)連接重置或掛起。所以在響應(yīng)頭轉(zhuǎn)發(fā)時必須把content-length丟棄。第二種是代理層自己對 SSE 做了“優(yōu)化”比如只轉(zhuǎn)發(fā)choices[0].delta.content把reasoning_content、tool_calls等字段丟掉。這會讓客戶端拿到的數(shù)據(jù)不完整某些模型在下一輪請求時還會報錯。第三種是超時時間設(shè)置不合理。流式響應(yīng)中模型生成單個 token 可能間隔幾秒。如果代理把 reqwest 的 timeout 設(shè)置得太短上游稍慢就會被誤判為超時。第一版可以設(shè)置總超時 300 秒后續(xù)再根據(jù)業(yè)務(wù)需要拆分成連接超時和讀超時。5. 錯誤處理與狀態(tài)碼透傳400 不只是 4005.1 錯誤應(yīng)該透傳還是重新包裝代理層收到的上游響應(yīng)分為兩類。第一類是 HTTP 連接成功但上游在響應(yīng)體里返回了業(yè)務(wù)錯誤例如 400 參數(shù)錯誤、401 密鑰錯誤、403 無權(quán)限、429 限流。此時代理應(yīng)該把狀態(tài)碼和錯誤體原樣返回給客戶端??蛻舳诵枰吹秸鎸嵉腻e誤信息才能修正請求。第二類是 HTTP 連接本身失敗例如 DNS 解析失敗、TCP 連接超時、TLS 校驗失敗。此時代理無法得到上游的業(yè)務(wù)錯誤體只能構(gòu)造一個 502 或 504 返回給客戶端。上面 proxy.rs 的match result已經(jīng)體現(xiàn)了這個區(qū)分。Ok(resp)分支無論狀態(tài)碼是什么都原樣透傳