現(xiàn)約1k行小型LLM代理:透明轉(zhuǎn)發(fā)與SSE流式透?jìng)? alt=)
在 AI 應(yīng)用開發(fā)中LLM 代理是客戶端與上游模型服務(wù)之間最常見的中間層負(fù)責(zé)路由請(qǐng)求、注入密鑰、統(tǒng)一超時(shí)、透?jìng)髁魇巾憫?yīng)和記錄調(diào)用日志。多業(yè)務(wù)方共用密鑰、切換模型服務(wù)商、審計(jì)調(diào)用記錄、限制單用戶用量這些事情如果逐個(gè)寫在業(yè)務(wù)代碼里會(huì)非常難維護(hù)。用 Rust 寫一個(gè)約 1k 行的小型 LLM 代理既能跑通這套核心邏輯又能把依賴數(shù)量控制在很低的水平。這篇文章圍繞“eek! it’s a tiny rust LLM proxy in ~1k loc”這類微型項(xiàng)目風(fēng)格從職責(zé)拆分開始逐步實(shí)現(xiàn)一個(gè)可運(yùn)行、可驗(yàn)證、可繼續(xù)改造成生產(chǎn)服務(wù)的代理程序。1. 先搞清楚 LLM 代理到底做了什么1.1 LLM 代理不是 API 網(wǎng)關(guān)也不是 SDK很多人會(huì)把 LLM 代理和 API 網(wǎng)關(guān)混為一談。API 網(wǎng)關(guān)處理的是通用 REST 請(qǐng)求的路由、鑒權(quán)、限流、熔斷它不一定理解模型上下文。LLM 代理則更貼近模型語(yǔ)義它會(huì)關(guān)心/v1/chat/completions和/v1/responses這類專用端點(diǎn)會(huì)關(guān)心流式 SSE 響應(yīng)是否被正確分塊會(huì)關(guān)心“思考模式”下的額外字段是否在轉(zhuǎn)發(fā)過程中被意外刪掉。同樣LLM 代理也不是 SDK。SDK 是打包給開發(fā)者使用的客戶端庫(kù)代理則是獨(dú)立部署的服務(wù)進(jìn)程。客戶端只需要把請(qǐng)求發(fā)到代理地址代理負(fù)責(zé)把請(qǐng)求發(fā)往真正的模型服務(wù)商。這樣做的好處是業(yè)務(wù)側(cè)不需要知道上游地址和密鑰模型服務(wù)商切換時(shí)也不需要改業(yè)務(wù)代碼。一個(gè)最簡(jiǎn)單的 LLM 代理本質(zhì)上做的事情只有四件接收客戶端的 HTTP 請(qǐng)求。改寫目標(biāo)地址和必要的 Header。把請(qǐng)求體發(fā)送給上游模型服務(wù)。把上游響應(yīng)原樣返回給客戶端。其余能力比如模型路由、日志、限流、緩存、多租戶隔離都是在這四個(gè)動(dòng)作上疊加的。1.2 代理的六個(gè)核心職責(zé)在實(shí)際項(xiàng)目中LLM 代理的職責(zé)可以拆成六塊。第一統(tǒng)一入口。所有模型調(diào)用都經(jīng)過同一個(gè)地址便于配置和審計(jì)。第二密鑰管理。客戶端不直接持有上游 API Key代理在轉(zhuǎn)發(fā)時(shí)統(tǒng)一注入 Authorization Header。第三路由轉(zhuǎn)發(fā)。根據(jù)路徑或請(qǐng)求體里的 model 字段把請(qǐng)求轉(zhuǎn)發(fā)到不同上游例如 OpenAI、DeepSeek、本地 vLLM 等。第四流式響應(yīng)透?jìng)?。模型接口?stream 模式使用 SSE代理必須支持邊接收上游數(shù)據(jù)邊發(fā)給客戶端不能等全部接收完再返回。第五錯(cuò)誤與狀態(tài)碼透?jìng)?。上游返?400、401、403、429、502 時(shí)客戶端需要看到真實(shí)錯(cuò)誤原因不能被代理吞掉。第六監(jiān)控與審計(jì)。記錄每次調(diào)用的模型、耗時(shí)、狀態(tài)碼、Token 用量用于成本核算和問題排查。這六塊職責(zé)并不都需要在第一版實(shí)現(xiàn)。第一版只做前三項(xiàng)和第四項(xiàng)錯(cuò)誤透?jìng)鲗儆诘谖屙?xiàng)監(jiān)控可以在后面用中間件補(bǔ)上。1.3 透明代理原則不解析才是最快的轉(zhuǎn)發(fā)設(shè)計(jì) LLM 代理時(shí)最容易犯的一個(gè)錯(cuò)誤是“過度處理請(qǐng)求體”。很多開發(fā)者拿到請(qǐng)求體后習(xí)慣性地用 serde_json 解析成 Value修改幾個(gè)字段再序列化回去。這一步看似無害卻會(huì)帶來兩類問題。第一丟失未聲明字段。上游模型接口經(jīng)常增加新字段例如 reasoning_content、tool_calls、citations。如果代理只保留自己認(rèn)識(shí)的字段這些新字段會(huì)被靜默刪除上游可能直接返回 400。第二破壞流式響應(yīng)的時(shí)序。JSON 解析和重新序列化會(huì)引入額外內(nèi)存拷貝和 CPU 消耗對(duì) SSE 流式轉(zhuǎn)發(fā)尤其不利。所以第一版代理應(yīng)該堅(jiān)持透明轉(zhuǎn)發(fā)原則請(qǐng)求體是什么就原樣轉(zhuǎn)發(fā)什么響應(yīng)體是什么就原樣返回什么。只修改必須修改的部分比如 Host、Authorization、Content-Length。這條原則會(huì)在后面的代碼里反復(fù)體現(xiàn)。2. 環(huán)境準(zhǔn)備Rust 工具鏈與項(xiàng)目骨架2.1 安裝 Rust 工具鏈并配置國(guó)內(nèi)鏡像本項(xiàng)目的核心依賴是 Rust 工具鏈?zhǔn)褂?rustup 安裝即可。curl --proto https --tlsv1.2 -sSf https://sh.rustup.rs | sh安裝完成后執(zhí)行如下命令確認(rèn)版本。rustc --version cargo --version國(guó)內(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 依賴下載慢時(shí)可以在~/.cargo/config.toml里配置鏡像源。[source.crates-io] replace-with rsproxy-sparse [source.rsproxy-sparse] registry sparsehttps://rsproxy.cn/index/配置完成后cargo build拉取依賴會(huì)明顯變快。注意鏡像源本身會(huì)變化配置文件里的地址要以你所在網(wǎng)絡(luò)環(huán)境可用為準(zhǔn)。公共鏡像只用于加速依賴下載不影響代碼邏輯。2.2 創(chuàng)建項(xiàng)目并選擇依賴使用 cargo 創(chuàng)建項(xiàng)目。cargo new tiny-llm-proxy cd tiny-llm-proxy這樣一個(gè)約 1k 行的小型代理不需要引入重量級(jí)框架。核心依賴四類axum處理 HTTP 路由、并發(fā)和生命周期。reqwest作為 HTTP 客戶端負(fù)責(zé)向上游發(fā)起請(qǐng)求。tokio異步運(yùn)行時(shí)。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)閉默認(rèn)特性并啟用 rustls-tls是為了避免在 Linux 服務(wù)器上額外依賴 OpenSSL。后續(xù)如果要用Client直接上傳 Multipart 表單或 JSON可以在 features 里追加json。2.3 項(xiàng)目目錄與配置加載項(xiàng)目結(jié)構(gòu)保持簡(jiǎn)單三個(gè)源文件加一個(gè)環(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不要提交到倉(cāng)庫(kù)。3. 實(shí)現(xiàn)最小轉(zhuǎn)發(fā)循環(huán)接收請(qǐng)求、注入密鑰、轉(zhuǎn)發(fā)、返回響應(yīng)3.1 用 axum 暴露 OpenAI 兼容路由程序入口src/main.rs負(fù)責(zé)初始化日志、加載配置、構(gòu)建共享 HTTP 客戶端、注冊(cè)路由。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); }這里暴露了兩個(gè) OpenAI 兼容端點(diǎn)/v1/chat/completions和/v1/responses。如果你只需要其中一個(gè)路由可以繼續(xù)精簡(jiǎn)。3.2 請(qǐng)求頭過濾與上游地址拼接轉(zhuǎn)發(fā)邏輯全部放在src/proxy.rs。核心函數(shù)不直接處理具體端點(diǎn)而是接收一個(gè) path 參數(shù)這樣兩個(gè)端點(diǎn)復(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) } } }幾個(gè)關(guān)鍵點(diǎn)過濾host是因?yàn)樯嫌蔚刂芬呀?jīng)由upstream_url決定不能繼續(xù)使用客戶端的 Host。過濾content-length是因?yàn)檎?qǐng)求體通過 stream 轉(zhuǎn)換后長(zhǎng)度可能變化交給 reqwest 自己計(jì)算。過濾accept-encoding是為了避免上游返回壓縮流后轉(zhuǎn)發(fā)層還要處理解壓邏輯。第一版最好讓響應(yīng)體保持純文本流便于排查。注入authorization時(shí)使用配置里的api_key客戶端傳過來的原始 Authorization 會(huì)被覆蓋。3.3 用 reqwest 轉(zhuǎn)發(fā)并透?jìng)黜憫?yīng)體兩個(gè)具體端點(diǎn)分別調(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ù)這樣可以同時(shí)拿到 HeaderMap 和 Request Body也避免 axum 對(duì)多個(gè) consuming extractor 的限制。轉(zhuǎn)發(fā)層最終把上游響應(yīng)體轉(zhuǎn)成Body::from_stream(resp.bytes_stream())。這一步非常關(guān)鍵它讓響應(yīng)以流式方式返回給客戶端而不是等上游全部發(fā)送完再一次性返回。LLM 接口開啟stream: true后用戶會(huì)看到 token 逐字出現(xiàn)而不是長(zhǎng)時(shí)間等待。啟動(dòng)服務(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ā)要點(diǎn)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]時(shí)結(jié)束。代理層在轉(zhuǎn)發(fā) SSE 時(shí)不需要解析這些內(nèi)容只需要保證響應(yīng)頭包含content-type: text/event-stream。上游返回的數(shù)據(jù)按字節(jié)流原樣傳遞。不要合并多個(gè)事件也不要緩沖到完整響應(yīng)再返回。上一節(jié)的代碼里resp.bytes_stream()天然滿足這三點(diǎn)。上游返回一個(gè) chunk代理就轉(zhuǎn)發(fā)一個(gè) chunk延遲接近直連上游。4.2 客戶端斷開時(shí)如何取消上游請(qǐng)求LLM 流式響應(yīng)可能持續(xù)幾十秒甚至幾分鐘。用戶可能中途刷新頁(yè)面、關(guān)閉網(wǎng)頁(yè)或點(diǎn)擊停止生成。此時(shí)客戶端 TCP 連接已經(jīng)斷開代理如果繼續(xù)從上游讀取數(shù)據(jù)會(huì)產(chǎn)生兩個(gè)問題。第一浪費(fèi)上游 Token 和費(fèi)用。第二上游連接長(zhǎng)期得不到釋放并發(fā)量大時(shí)會(huì)把代理的端口和內(nèi)存占滿。Rust 的流式轉(zhuǎn)發(fā)天然具備取消能力。resp.bytes_stream()是一個(gè)異步 Stream它被放在 axum 的 Response Body 里??蛻舳藬嚅_時(shí)axum 會(huì) drop 這個(gè) Body底層 Stream 也會(huì)被 dropreqwest 連接隨之關(guān)閉。這里要注意不要在轉(zhuǎn)發(fā)層寫collect().await或bytes().await這類代碼。一旦把整個(gè)響應(yīng)讀進(jìn)內(nèi)存再返回給客戶端客戶端斷開時(shí)上游請(qǐng)求不會(huì)自動(dòng)取消資源占用會(huì)直線上升。4.3 流式代理最容易出現(xiàn)的三種異常第一種是響應(yīng)頭里保留了content-length但 Body 實(shí)際是分塊傳輸?shù)摹?蛻舳丝吹降拈L(zhǎng)度和實(shí)際長(zhǎng)度不一致會(huì)出現(xiàn)連接重置或掛起。所以在響應(yīng)頭轉(zhuǎn)發(fā)時(shí)必須把content-length丟棄。第二種是代理層自己對(duì) SSE 做了“優(yōu)化”比如只轉(zhuǎn)發(fā)choices[0].delta.content把reasoning_content、tool_calls等字段丟掉。這會(huì)讓客戶端拿到的數(shù)據(jù)不完整某些模型在下一輪請(qǐng)求時(shí)還會(huì)報(bào)錯(cuò)。第三種是超時(shí)時(shí)間設(shè)置不合理。流式響應(yīng)中模型生成單個(gè) token 可能間隔幾秒。如果代理把 reqwest 的 timeout 設(shè)置得太短上游稍慢就會(huì)被誤判為超時(shí)。第一版可以設(shè)置總超時(shí) 300 秒后續(xù)再根據(jù)業(yè)務(wù)需要拆分成連接超時(shí)和讀超時(shí)。5. 錯(cuò)誤處理與狀態(tài)碼透?jìng)?00 不只是 4005.1 錯(cuò)誤應(yīng)該透?jìng)鬟€是重新包裝代理層收到的上游響應(yīng)分為兩類。第一類是 HTTP 連接成功但上游在響應(yīng)體里返回了業(yè)務(wù)錯(cuò)誤例如 400 參數(shù)錯(cuò)誤、401 密鑰錯(cuò)誤、403 無權(quán)限、429 限流。此時(shí)代理應(yīng)該把狀態(tài)碼和錯(cuò)誤體原樣返回給客戶端??蛻舳诵枰吹秸鎸?shí)的錯(cuò)誤信息才能修正請(qǐng)求。第二類是 HTTP 連接本身失敗例如 DNS 解析失敗、TCP 連接超時(shí)、TLS 校驗(yàn)失敗。此時(shí)代理無法得到上游的業(yè)務(wù)錯(cuò)誤體只能構(gòu)造一個(gè) 502 或 504 返回給客戶端。上面 proxy.rs 的match result已經(jīng)體現(xiàn)了這個(gè)區(qū)分。Ok(resp)分支無論狀態(tài)碼是什么都原樣透?jìng)?