用Rust实现约1k行小型LLM代理:透明转发与SSE流式透传 在 AI 应用开发中LLM 代理是客户端与上游模型服务之间最常见的中间层负责路由请求、注入密钥、统一超时、透传流式响应和记录调用日志。多业务方共用密钥、切换模型服务商、审计调用记录、限制单用户用量这些事情如果逐个写在业务代码里会非常难维护。用 Rust 写一个约 1k 行的小型 LLM 代理既能跑通这套核心逻辑又能把依赖数量控制在很低的水平。这篇文章围绕“eek! it’s a tiny rust LLM proxy in ~1k loc”这类微型项目风格从职责拆分开始逐步实现一个可运行、可验证、可继续改造成生产服务的代理程序。1. 先搞清楚 LLM 代理到底做了什么1.1 LLM 代理不是 API 网关也不是 SDK很多人会把 LLM 代理和 API 网关混为一谈。API 网关处理的是通用 REST 请求的路由、鉴权、限流、熔断它不一定理解模型上下文。LLM 代理则更贴近模型语义它会关心/v1/chat/completions和/v1/responses这类专用端点会关心流式 SSE 响应是否被正确分块会关心“思考模式”下的额外字段是否在转发过程中被意外删掉。同样LLM 代理也不是 SDK。SDK 是打包给开发者使用的客户端库代理则是独立部署的服务进程。客户端只需要把请求发到代理地址代理负责把请求发往真正的模型服务商。这样做的好处是业务侧不需要知道上游地址和密钥模型服务商切换时也不需要改业务代码。一个最简单的 LLM 代理本质上做的事情只有四件接收客户端的 HTTP 请求。改写目标地址和必要的 Header。把请求体发送给上游模型服务。把上游响应原样返回给客户端。其余能力比如模型路由、日志、限流、缓存、多租户隔离都是在这四个动作上叠加的。1.2 代理的六个核心职责在实际项目中LLM 代理的职责可以拆成六块。第一统一入口。所有模型调用都经过同一个地址便于配置和审计。第二密钥管理。客户端不直接持有上游 API Key代理在转发时统一注入 Authorization Header。第三路由转发。根据路径或请求体里的 model 字段把请求转发到不同上游例如 OpenAI、DeepSeek、本地 vLLM 等。第四流式响应透传。模型接口的 stream 模式使用 SSE代理必须支持边接收上游数据边发给客户端不能等全部接收完再返回。第五错误与状态码透传。上游返回 400、401、403、429、502 时客户端需要看到真实错误原因不能被代理吞掉。第六监控与审计。记录每次调用的模型、耗时、状态码、Token 用量用于成本核算和问题排查。这六块职责并不都需要在第一版实现。第一版只做前三项和第四项错误透传属于第五项监控可以在后面用中间件补上。1.3 透明代理原则不解析才是最快的转发设计 LLM 代理时最容易犯的一个错误是“过度处理请求体”。很多开发者拿到请求体后习惯性地用 serde_json 解析成 Value修改几个字段再序列化回去。这一步看似无害却会带来两类问题。第一丢失未声明字段。上游模型接口经常增加新字段例如 reasoning_content、tool_calls、citations。如果代理只保留自己认识的字段这些新字段会被静默删除上游可能直接返回 400。第二破坏流式响应的时序。JSON 解析和重新序列化会引入额外内存拷贝和 CPU 消耗对 SSE 流式转发尤其不利。所以第一版代理应该坚持透明转发原则请求体是什么就原样转发什么响应体是什么就原样返回什么。只修改必须修改的部分比如 Host、Authorization、Content-Length。这条原则会在后面的代码里反复体现。2. 环境准备Rust 工具链与项目骨架2.1 安装 Rust 工具链并配置国内镜像本项目的核心依赖是 Rust 工具链使用 rustup 安装即可。curl --proto https --tlsv1.2 -sSf https://sh.rustup.rs | sh安装完成后执行如下命令确认版本。rustc --version cargo --version国内网络环境下rustup 和 crates.io 下载可能不稳定。rustup 可以通过环境变量指定下载镜像。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拉取依赖会明显变快。注意镜像源本身会变化配置文件里的地址要以你所在网络环境可用为准。公共镜像只用于加速依赖下载不影响代码逻辑。2.2 创建项目并选择依赖使用 cargo 创建项目。cargo new tiny-llm-proxy cd tiny-llm-proxy这样一个约 1k 行的小型代理不需要引入重量级框架。核心依赖四类axum处理 HTTP 路由、并发和生命周期。reqwest作为 HTTP 客户端负责向上游发起请求。tokio异步运行时。tracing tracing-subscriber结构化日志。Cargo.toml 内容如下[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 关闭默认特性并启用 rustls-tls是为了避免在 Linux 服务器上额外依赖 OpenSSL。后续如果要用Client直接上传 Multipart 表单或 JSON可以在 features 里追加json。2.3 项目目录与配置加载项目结构保持简单三个源文件加一个环境变量示例文件。tiny-llm-proxy/ ├── Cargo.toml ├── .env.example └── src/ ├── main.rs ├── config.rs └── proxy.rs配置不写在代码里通过环境变量读取。新建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. 实现最小转发循环接收请求、注入密钥、转发、返回响应3.1 用 axum 暴露 OpenAI 兼容路由程序入口src/main.rs负责初始化日志、加载配置、构建共享 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。如果你只需要其中一个路由可以继续精简。3.2 请求头过滤与上游地址拼接转发逻辑全部放在src/proxy.rs。核心函数不直接处理具体端点而是接收一个 path 参数这样两个端点复用同一套逻辑。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) } } }几个关键点过滤host是因为上游地址已经由upstream_url决定不能继续使用客户端的 Host。过滤content-length是因为请求体通过 stream 转换后长度可能变化交给 reqwest 自己计算。过滤accept-encoding是为了避免上游返回压缩流后转发层还要处理解压逻辑。第一版最好让响应体保持纯文本流便于排查。注入authorization时使用配置里的api_key客户端传过来的原始 Authorization 会被覆盖。3.3 用 reqwest 转发并透传响应体两个具体端点分别调用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 参数这样可以同时拿到 HeaderMap 和 Request Body也避免 axum 对多个 consuming extractor 的限制。转发层最终把上游响应体转成Body::from_stream(resp.bytes_stream())。这一步非常关键它让响应以流式方式返回给客户端而不是等上游全部发送完再一次性返回。LLM 接口开启stream: true后用户会看到 token 逐字出现而不是长时间等待。启动服务cargo run看到日志输出LLM proxy listening on 127.0.0.1:8787后说明最小转发循环已经跑通。4. 流式响应SSE 转发与连接生命周期4.1 SSE 的传输格式和转发要点OpenAI 兼容接口的流式响应使用 Server-Sent Events响应内容大致如下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]时结束。代理层在转发 SSE 时不需要解析这些内容只需要保证响应头包含content-type: text/event-stream。上游返回的数据按字节流原样传递。不要合并多个事件也不要缓冲到完整响应再返回。上一节的代码里resp.bytes_stream()天然满足这三点。上游返回一个 chunk代理就转发一个 chunk延迟接近直连上游。4.2 客户端断开时如何取消上游请求LLM 流式响应可能持续几十秒甚至几分钟。用户可能中途刷新页面、关闭网页或点击停止生成。此时客户端 TCP 连接已经断开代理如果继续从上游读取数据会产生两个问题。第一浪费上游 Token 和费用。第二上游连接长期得不到释放并发量大时会把代理的端口和内存占满。Rust 的流式转发天然具备取消能力。resp.bytes_stream()是一个异步 Stream它被放在 axum 的 Response Body 里。客户端断开时axum 会 drop 这个 Body底层 Stream 也会被 dropreqwest 连接随之关闭。这里要注意不要在转发层写collect().await或bytes().await这类代码。一旦把整个响应读进内存再返回给客户端客户端断开时上游请求不会自动取消资源占用会直线上升。4.3 流式代理最容易出现的三种异常第一种是响应头里保留了content-length但 Body 实际是分块传输的。客户端看到的长度和实际长度不一致会出现连接重置或挂起。所以在响应头转发时必须把content-length丢弃。第二种是代理层自己对 SSE 做了“优化”比如只转发choices[0].delta.content把reasoning_content、tool_calls等字段丢掉。这会让客户端拿到的数据不完整某些模型在下一轮请求时还会报错。第三种是超时时间设置不合理。流式响应中模型生成单个 token 可能间隔几秒。如果代理把 reqwest 的 timeout 设置得太短上游稍慢就会被误判为超时。第一版可以设置总超时 300 秒后续再根据业务需要拆分成连接超时和读超时。5. 错误处理与状态码透传400 不只是 4005.1 错误应该透传还是重新包装代理层收到的上游响应分为两类。第一类是 HTTP 连接成功但上游在响应体里返回了业务错误例如 400 参数错误、401 密钥错误、403 无权限、429 限流。此时代理应该把状态码和错误体原样返回给客户端。客户端需要看到真实的错误信息才能修正请求。第二类是 HTTP 连接本身失败例如 DNS 解析失败、TCP 连接超时、TLS 校验失败。此时代理无法得到上游的业务错误体只能构造一个 502 或 504 返回给客户端。上面 proxy.rs 的match result已经体现了这个区分。Ok(resp)分支无论状态码是什么都原样透传

相关新闻

最新新闻

无头服务器、游戏串流怎么配:用 Parsec VDD 三步开出虚拟显示器

无头服务器、游戏串流怎么配:用 Parsec VDD 三步开出虚拟显示器

无头服务器、游戏串流怎么配:用 Parsec VDD 三步开出虚拟显示器 【免费下载链接】parsec-vdd ✨ Perfect virtual display for game streaming 项目地址: https://gitcode.com/gh_mirrors/pa/parsec-vdd 机房里那台 Windows 主机,游戏客户端开着、…

2026/8/27 2:12:32
微软认证空间音频方案落场,游戏耳机听声辨位门槛再抬高

微软认证空间音频方案落场,游戏耳机听声辨位门槛再抬高

你桌上那副主打"7.1虚拟环绕"的PC游戏耳机,实际定位效果到底行不行,很多时候不是看喇叭单元好不好,而是看驱动里那套空间音频算法是谁写的。Ceva这家在音频DSP领域摸爬滚打二十多年的公司,最近把自己的空间音频软件送进…

2026/8/27 2:12:32
深海搜救中的不确定性建模与动态搜索策略

深海搜救中的不确定性建模与动态搜索策略

1. 这不是一道“找潜水器”的数学题,而是一场对真实海洋搜救逻辑的极限推演2024年美赛MCM问题B——“Searching for Submersibles”,表面看是个带点科幻色彩的工程优化题,但实际拆开后你会发现,它根本不是在考你能不能编个漂亮算法…

2026/8/27 2:12:32
Unity毕设实战:消息机制框架复刻口袋精灵2全解析

Unity毕设实战:消息机制框架复刻口袋精灵2全解析

简介:在Unity游戏开发中,模块间通信与解耦一直是架构设计的核心问题。消息机制通过发布-订阅模式,让发送方与接收方彻底隔离,从而降低系统复杂度,提升代码可维护性。其技术价值在大型客户端中尤为突出,常用…

2026/8/27 2:12:32
逆向分析链路的拆解

逆向分析链路的拆解

逆向分析链路的拆解 把性能问题拆到具体环节 韩朔处理安全分析里的“逆向分析链路的拆解”时,通常不会先讨论工具多不多,而是先把任务压到一个具体场景:谁在什么条件下发起操作,系统需要留下什么结果,哪一步出错必须停…

2026/8/27 2:12:32
产品蜂群大脑:统一查询层如何让产品团队用自然语言问数

产品蜂群大脑:统一查询层如何让产品团队用自然语言问数

“The hive mind for your product”——这个说法最近在产品团队里出现频率不低,翻译成大白话,就是给产品团队装一个能把用户反馈、支持工单、应用商店评论、埋点事件、产品文档、销售和客服对话记录全部汇聚起来的群体大脑。它要解决的核心问题&#xf…

2026/8/27 2:07:32