如果你最近在本地跑 Codex、Cline 这类 AI 编程助手大概率见过一串让人头皮发麻的报错400 Bad Request、401 Unauthorized、404 Not Found、502 Bad Gateway甚至在报错里直接甩给你一句“the reasoning_content in the thinking mode must be passed back to the api”。问题通常不是模型不行而是你绕过了官方 API直接接了个第三方模型地址。这些工具的协议是按某一家模型厂商写的换一家模型就要重新对齐路径、字段、鉴权方式。于是很多开发者会在中间加一层薄薄的“LLM Proxy”把本地工具和上游模型解耦。这层代理看上去是网络转发实际核心是协议适配。更关键是它不需要多复杂。标题eek! its a tiny rust LLM proxy in ~1k loc想表达的就是一个能日常用的 LLM 代理用 Rust 写大约 1000 行就够。没有 Node 运行时没有 Python 环境没有一大堆依赖编译出来就是一个二进制文件内存占用低启动速度也快。本文会从真实痛点出发先讲清楚 LLM Proxy 到底在解决什么问题再带你把一个约 200 行的最小可用版本写出来跑通最后给出生产化建议和真实报错的排查思路。这篇文章不会让你“看懂原理”就结束而是保证你能照着把服务拉起来。1. 为什么你需要一个 LLM Proxy先还原一个场景。你装了某个本地 AI 编程工具它默认读取OPENAI_BASE_URL和OPENAI_API_KEY两个环境变量。你把它指向某个国产模型的上游地址填了自己的密钥结果请求发出去之后返回 404或者返回 400告诉你某个字段不对。为什么会这样因为工具的代码里写死了请求路径比如/v1/chat/completions或者/responses。不同模型厂商的 API 路径、请求字段、流式格式并不完全一致。有的要求额外传reasoning_content有的要求把思考内容在下一轮回传有的模型名映射还需要转换。直接用客户端硬连上游这些问题全部会暴露在用户面前。LLM Proxy 就是在这中间加一个适配层。它做三件事把本地工具的请求原样收下根据配置做模型映射和字段处理再转发给真正的上游。响应回来时再原样送回给本地工具。需要区分两个概念。Proxy 和 Gateway 在某些语境下被混用但侧重点不同。Gateway 强调更重的治理能力多租户、限流、计费、审计、统一监控。Proxy 更强调轻量适配路径转换、鉴权注入、字段透传、流式转发。本地开发工具接入第三方模型时你需要的通常是后者而不是一上来就上全套网关。这个角色的价值是在架构层面把“工具”和“模型供应商”解耦。今天你接 DeepSeek明天想换通义千问或者本地 Ollama只需要改代理配置不需要重新配置工具也不需要改工具代码。对于经常切换模型做对比测试的人这一步省下的时间非常可观。从材料里的报错也能看出很多人是在本地工具里直接把模型地址改成第三方后翻车的。代理层存在的意义就是把这种“每次接入新模型都要踩一遍协议坑”的体验收敛成“改配置重启服务”的操作。2. LLM Proxy 的核心概念协议适配与流式转发想写明白一个 LLM Proxy先要理解几个绕不开的概念。第一个是协议适配。当前主流模型服务接口大致分三类。OpenAI 兼容接口是最常见的路径通常是/v1/chat/completions或/v1/responses。Anthropic 接口走/v1/messages请求体会带system、messages、max_tokens这些字段。本地模型服务如 Ollama 也有自己的一套兼容格式。代理的价值之一就是让你不需要关心这些差异统一暴露一个近似 OpenAI 兼容的入口。第二个是字段透传。很多模型服务在思考模式下会返回额外的字段比如 DeepSeek 的reasoning_content。工具需要把思考内容回传给 API才能维持对话上下文。如果你在代理层做了字段裁剪比如只保留content、丢掉reasoning_content就会触发材料里那种 400 报错。所以代理最重要的原则之一是不要聪明地裁剪字段要原样透传。你做不了模型厂商和工具之间所有字段语义的判断能做到的只有不破坏它们。第三个是 SSE 流式转发。大模型输出是边生成边推送的格式是text/event-stream。代理收到流式响应后不能等全部收完再返回。那样的话用户体验会退化成一整段空白然后一次性蹦出来也容易超时。正确做法是拿到上游响应流后逐块转发给本地工具。这就是流式转发的核心逻辑两头都是流中间不缓存、不重排、不修改。用表格看三种协议差异会更直观。维度OpenAI 兼容AnthropicOllama典型路径/v1/chat/completions/v1/messages/v1/chat/completions鉴权方式Authorization: Bearerx-api-key通常无需鉴权流式响应SSESSESSE常见痛点字段兼容、模型名映射路径和字段完全不同部署和模型管理第四个概念是模型路由。本地工具请求里带的模型名和上游真正能跑的模型名不一定一致。代理需要维护一张映射表比如把claude-sonnet-4-20250514映射到deepseek-chat再转发给上游。这个映射看起来简单但在日常切换模型时非常实用。理解了这四个概念你再看 LLM Proxy 的代码就不会发怵。它本质上就是监听端口、解析请求、查表替换、转发上游、原样回传。3. 为什么用 Rust 写以及环境准备选 Rust 写这种中间层不是情怀是几个很实际的原因。首先是部署形态。Rust 编出来的静态二进制可以在目标服务器上直接运行不需要安装 Node 或 Python。对于“本地工具旁边要常驻一个服务”的场景少一个运行时依赖意味着少一类版本冲突问题。其次是资源占用。LLM 代理本身没有重计算但它是个常驻服务。常驻服务的启动速度和内存占用会直接影响使用体验。Rust 在这方面的优势是结构性的不是靠优化堆出来的。第三是代码可控性。1000 行左右的规模整个项目的代码量和依赖关系完全在一个人可以掌握的范围内。出了问题可以直接读源码定位不需要在依赖树里翻来翻去。这对一个小工具来说非常重要。工程上是这样学习路径上也不复杂。如果从零开始你只需要安装 Rust 工具链。以主流 Rust 安装方式为例通过rustup安装即可curl --proto https --tlsv1.2 -sSf https://sh.rustup.rs | sh安装完成后让cargo命令进入当前 shellsource $HOME/.cargo/env rustc --version cargo --version国内网络环境下建议配置国内镜像源能够显著提升依赖下载速度。在$HOME/.cargo/config.toml里写入[source.crates-io] replace-with rsproxy [source.rsproxy] registry https://rsproxy.cn/crates.io-index如果你用的是 Windows也可以直接配置环境变量指向国内镜像或者选用无需 MSVC 工具链的 GNU 工具链。这里有一个需要注意的点Rust 在 Windows 上默认可能需要 MSVC 构建工具。如果不想装整套 Visual Studio可以选择rustup安装时的x86_64-pc-windows-gnu工具链。具体版本细节以实际安装为准思路是先保证cargo能正常下载依赖再继续后面的项目。新增一个项目很简单cargo new tiny-llm-proxy cd tiny-llm-proxy本文后面所有代码都以这个项目为基础。4. 核心流程拆解一个请求是怎么被转发出去的写代码之前先把一次完整请求的流转路线过一遍。这张“地图”清楚了代码只是翻译。整体架构是本地 AI 工具发请求到代理的本地地址代理完成适配后转发到上游模型服务。具体拆成六步。第一步监听端口。代理启动后绑定127.0.0.1:8787本地工具的所有请求都发到这里。监听本机回环地址很重要避免把端口暴露到局域网或公网。第二步解析请求。代理收到POST /v1/chat/completions把请求体里的 JSON 解析出来。这里需要关注的字段包括model、messages、stream。第三步模型映射。用请求里的model字段查配置表拿到上游真正要调的模型名替换进请求体。如果映射表里没有就原样使用。第四步注入鉴权。代理不依赖本地客户端传的密钥而是统一使用自己配置的上游密钥。这样本地工具里随意填一个占位密钥就行密钥不会散落在多个配置里。第五步转发上游。用替换后的请求体调用上游接口把上游返回的 HTTP 状态码留档。第六步结果回传。这里要区分两种情况。如果上游返回非 2xx 状态码就把错误 body 原样回传让本地工具显示真实错误原因。如果返回 2xx再按stream参数决定是直接回传 JSON还是用 SSE 流式方式逐块转发。这个流程里最容易出错的是第六步。很多初版代理只处理了成功场景把错误状态码也当成成功响应包装了一层结果本地工具永远看不到真实的错误原因排查起来非常痛苦。代理的作用是透明中转不是把错误吞掉。还要注意实际生产环境中不同的本地工具会请求不同的本地端点比如 OpenAI Responses API 的/responses、Chat Completions 的/v1/chat/completions、Anthropic 的/v1/messages。你的代理要在配置里明确支持哪些端点并确保路径转发规则和上游 URL 拼接方式正确。5. 完整示例200 行实现一个可用的小代理下面进入正文最有价值的部分写一个能跑的最小 LLM Proxy。项目大约 200 行已经覆盖了前面说的核心流程。实际生产版本在此基础上扩展到 1000 行并不夸张。5.1 项目结构tiny-llm-proxy/ ├── Cargo.toml ├── config.toml └── src └── main.rs这个结构非常简单。config.toml放配置src/main.rs放全部代码。5.2 Cargo.toml[package] name tiny-llm-proxy version 0.1.0 edition 2021 [dependencies] tokio { version 1, features [full] } axum { version 0.7, features [json, stream] } reqwest { version 0.12, features [json, stream] } serde { version 1, features [derive] } serde_json 1 anyhow 1 toml 0.8 tracing 0.1 tracing-subscriber 0.3这里选tokio作为异步运行时axum作为 Web 框架reqwest负责上游 HTTP 请求。依赖版本以当前 crates.io 最新稳定版为准。如果后续使用的axum升级到 0.8Router和serve的基本用法保持一致差异很小。5.3 config.toml# config.tomltiny-llm-proxy 配置 listen_addr 127.0.0.1:8787 upstream_base https://api.deepseek.com/v1 api_key sk-xxxx default_model deepseek-chat [model_map] # 本地工具请求中的模型名 上游实际模型名 claude-sonnet-4-20250514 deepseek-chat deepseek-chat deepseek-chat5.4 src/main.rsuse axum::{ body::Body, extract::State, http::{header, StatusCode}, routing::{get, post}, Json, Router, }; use serde::Deserialize; use serde_json::{json, Value}; use std::{collections::HashMap, sync::Arc}; #[derive(Clone)] struct AppState { client: reqwest::Client, upstream_base: String, api_key: String, default_model: String, model_map: HashMapString, String, } #[derive(Debug, Deserialize)] struct Config { listen_addr: String, upstream_base: String, api_key: String, default_model: String, #[serde(default)] model_map: HashMapString, String, } impl Config { fn load(path: str) - anyhow::ResultSelf { let content std::fs::read_to_string(path)?; Ok(toml::from_str(content)?) } } #[tokio::main] async fn main() - anyhow::Result() { tracing_subscriber::fmt::init(); let mut cfg Config::load(config.toml)?; // 生产环境推荐用环境变量注入密钥避免写在配置文件里 if let Ok(v) std::env::var(UPSTREAM_API_KEY) { cfg.api_key v; } let state Arc::new(AppState { client: reqwest::Client::builder() .timeout(std::time::Duration::from_secs(600)) .build()?, upstream_base: cfg.upstream_base, api_key: cfg.api_key, default_model: cfg.default_model, model_map: cfg.model_map, }); let app Router::new() .route(/health, get(health)) .route(/v1/chat/completions, post(chat_completions)) .with_state(state); let listener tokio::net::TcpListener::bind(cfg.listen_addr).await?; tracing::info!(tiny-llm-proxy listening on {}, listener.local_addr()?); axum::serve(listener, app).await?; Ok(()) } async fn health() - static str { ok } async fn chat_completions( State(state): StateArcAppState, Json(body): JsonValue, ) - axum::response::Response { let mut body body; if !body.is_object() { return ( StatusCode::BAD_REQUEST, Json(json!({ error: { message: request body must be a JSON object, type: invalid_request_error } })), ) .into_response(); } // 模型映射把本地工具传来的模型名替换为上游真正要调的模型 let model body .get(model) .and_then(|m| m.as_str()) .unwrap_or(state.default_model); let upstream_model state .model_map .get(model) .map(|s| s.as_str()) .unwrap_or(model.as_str()); body[model] json!(upstream_model); let url format!({}/chat/completions, state.upstream_base); let upstream_resp match state .client .post(url) .bearer_auth(state.api_key) .json(body) .send() .await { Ok(resp) resp, Err(e) { return ( StatusCode::BAD_GATEWAY, Json(json!({ error: { message: format!(upstream request failed: {e}), type: upstream_request_error } })), ) .into_response(); } }; let status upstream_resp.status(); let content_type upstream_resp .headers() .get(header::CONTENT_TYPE) .cloned(); // 如果上游返回错误直接把错误 body 回传方便本地工具显示真实原因 if !status.is_success() { let bytes match upstream_resp.bytes().await { Ok(b) b, Err(e) { return ( StatusCode::BAD_GATEWAY, Json(json!({ error: { message: e.to_string() } })), ) .into_response(); } }; let mut builder axum::response::Response::builder().status(status); if let Some(ct) content_type { builder builder.header(header::CONTENT_TYPE, ct); } return builder.body(Body::from(bytes)).unwrap(); } let is_stream body .get(stream) .and_then(|s| s.as_bool()) .unwrap_or(false); // 非流式透传 JSON if !is_stream { let bytes match upstream_resp.bytes().await { Ok(b) b, Err(e) { return ( StatusCode::BAD_GATEWAY, Json(json!({ error: { message: e.to_string() } })), ) .into_response(); } }; let mut builder axum::response::Response::builder().status(status); if let Some(ct)