/v1/chat/completions 端点,接受你自己的 bearer token,将请求转发到 Venice,支持流式响应,并发出有用的 OpenTelemetry span 和指标。
对完整的代码实现感兴趣?请查看 GitHub 仓库。
前置条件
- Rust 1.92+
- Docker 和 Docker Compose
- 一个 Venice API 密钥
curl- 对 Rust Web 服务的基本熟悉
我们要构建什么
参考实现是一个小型 Rust 服务,它由几个清晰的部分组成:| 部分 | 功能 |
|---|---|
| Axum 路由 | 提供 /healthz 和 /v1/chat/completions |
| 认证提取器 | 使用 Postgres 中的哈希密钥验证网关的 bearer token |
| 限流器 | 使用存储在 Postgres 中的固定窗口 |
| Venice 客户端 | 代理非流式和流式的 chat completion 请求 |
| 遥测 | 记录 GenAI span、token 使用量、Venice 计费成本、延迟以及流式时序 |
| Docker Compose | 在本地运行 Postgres 和网关 |

创建 Rust 服务
从一个新的 Rust 二进制项目开始:Cargo.toml 中添加我们需要的依赖——代码片段中带有说明:
加载配置
网关从环境变量中读取一切配置。对于这样的基础设施代码,环境变量是一个不错的默认选择,因为同一个二进制文件可以在本地、Docker Compose 或托管环境中运行,而无需单独的配置文件格式。此外,许多提供商允许你在其容器运行时中将你自己的环境变量存储为密钥。这通常比使用类似dotenv(在 Rust 中是 dotenvy,因为原始的 dotenv crate 大部分已经被弃用)之类的东西要安全得多。
创建 src/config.rs:
- 数据库 URL
- 你的 Venice API 密钥
VENICE_BASE_URL 指向 https://api.venice.ai/api/v1,而 CAPTURE_GENAI_CONTENT 默认为 false,这样除非你有意启用,否则不会记录 prompt 内容。
第二个默认值更重要。网关可以看到流经它的每一条 prompt 和响应,但可观测性不应自动变成内容捕获。在大多数生产系统中,token 数量、延迟、模型名称、状态码和计费元数据对于运维来说已经足够。一般来说,在生产环境中记录 prompt 和对话不仅可能带来隐私责任——它还可能带来存储上的负担。加入这些内容意味着创建具有极高基数(即数据集中数据的唯一性程度)的 span 和 trace。这可能会让你在可观测性数据中的搜索变得非常昂贵,同时在检索数据时也可能拖累性能。
创建数据库 schema
接下来,创建migrations/0001_api_keys.sql。
我们只将每个网关 API 密钥的前 12 个字符作为查找前缀存储,再加上完整密钥的 SHA-256 哈希。这样网关可以快速找到候选行,而无需存储原始凭证。
前缀不是秘密。它的存在是为了建立索引。哈希才是证明调用方提供了完整密钥的东西。这与许多 API 密钥系统采用的基本形式相同:向调用方一次性展示原始密钥、存储一个不可逆的表示形式,并保留一个短前缀用于查找和支持工作流。
- API 密钥永远不会以明文形式存储。
- 限流设置必须为正。
- 已撤销的密钥不能保持激活状态。
- 一个限流窗口通过密钥、起始时间和窗口长度被唯一标识。
构建 Venice 客户端
接下来,我们将创建src/venice.rs。客户端只需要知道上游的 chat completions URL、Venice API 密钥,以及要对瞬时故障重试多少次。
让这个封装保持精简是有意为之——网关不应该重新实现 Venice 的整个 API。从基本层面来说,网关的工作是附加服务器端凭证、应用超时、对可以安全重试的请求进行重试,并以路由器可以转发的形式返回上游响应。
EventSource:
serde_json::Value。这是一个有意的兼容性选择。如果我们在 Rust 中对每个可能的 chat-completion 字段建模,那么每次上游 API 添加一个有用的选项,我们都必须更新网关。通过只在别处解析我们需要的部分,我们让更新的 Venice 参数可以无需网关发布即可透传。
共享应用状态
创建src/state.rs:
PgPool 本身就是一个共享的连接池句柄,Arc<Config> 也让配置的克隆成本很低。
这让每个 handler 都能访问同样的三样东西:不可变的配置、池化的数据库连接以及 Venice 客户端。把它们放在一个 AppState 中,也让后续的测试更简单,因为 handler 通过 Axum state 而不是读取全局变量来接收依赖。
对网关 API 密钥进行身份验证
客户端像这样发送它的网关密钥:src/auth.rs 并实现一个 Axum 提取器。提取器允许受保护的 handler 声明它需要一个已经过身份验证的密钥:
- 解析 bearer token。
- 取前 12 个字节作为密钥前缀。
- 用 SHA-256 对完整的候选 token 进行哈希。
- 通过前缀加载激活的密钥行。
- 以恒定时间比较存储的哈希与候选哈希。
AuthenticatedApiKey 的路由不可能在函数体内意外跳过身份验证;Axum 必须在 handler 运行之前构造出这个值。这让受保护的路径易于审计。
添加固定窗口限流
创建src/rate_limit.rs。限流器使用一条 SQL 语句来插入一个新窗口或递增现有窗口:
WHERE api_key_rate_limit_windows.request_count < $3 这一子句是关键。当窗口已经满时,Postgres 不会更新该行,RETURNING 也不会产生任何行。handler 可以将其转换为带有 Retry-After 响应头的 429 Too Many Requests 响应。
固定窗口不是最复杂的限流器,但它易于讲解、易于查看,对于一个网关教程来说也足够好用。它的取舍在于流量可能会在窗口边界附近聚集。如果你需要在大规模下更平滑的行为,令牌桶或由 Redis 支持的滑动窗口限流器是很自然的下一步。
返回 OpenAI 风格的错误
创建src/error.rs,并让应用错误实现 IntoResponse:
构建路由
现在我们可以在src/router.rs 中把 HTTP 路由连接起来:
AuthenticatedApiKey。如果身份验证失败,Axum 根本不会进入 handler 主体:
model、messages 和 stream。JSON 请求体中的其他所有内容都会透传到 Venice。这让网关与你之后可能要使用的提供商功能保持兼容。
handler 也让两种响应模式变得明确。非流式请求等待 Venice 返回完整的 JSON 响应,然后在把字节发送给下游之前记录响应元数据。流式请求会立即返回一个 text/event-stream 响应体,其背后是一个异步流。这种拆分让非流式路径保持简单,同时又让流式路径有足够的控制力,可以在数据块经过时对其进行观测。
支持流式响应
流式的 chat completion 使用 server-sent events。Venice 发送 SSE 数据,网关再把这些数据转发回客户端。 网关应当避免缓冲整个流,因为那样会违背流式的初衷。用户关心的是首个 token 的到达时间,而不仅仅是最终 token 的到达时间。通过在上游事件到达时逐个转发,客户端可以在模型还在生成时就渲染部分输出。 创建src/sse.rs:
data: ... 事件到达,流以 data: [DONE] 结束。
流观察器也是我们可以在不改变客户端所见内容的情况下收集元数据的地方。每个数据块都以 SSE 格式转发,但网关仍然可以在这些数据块经过时监视响应 ID、finish 原因、token 使用量、成本字段和时序信息。
记录 GenAI 遥测
网关很有用,因为每个请求都会经过一个地方。这让它成为记录模型、延迟、token 使用量、finish 原因、计费成本和流式时序的绝佳位置。 创建src/telemetry.rs,从解析请求开始:
id:
启动服务器
现在在src/main.rs 中把所有内容连接起来:
- 读取配置。
- 初始化遥测。
- 连接 Postgres。
- 运行 SQLx 迁移。
- 构建共享的应用状态。
- 启动 Axum 服务器。
docker compose up 可以让整个技术栈进入可用状态。在更大的生产部署中,你可能更愿意把迁移作为独立的发布步骤来运行,这样在新的网关实例启动之前,schema 变更可以先经过审核并被应用。
为本地环境填充网关密钥种子数据
对于本地开发,创建scripts/seed_api_key.sh。这个脚本会通过存储前缀和 SHA-256 哈希,将一个网关 API 密钥插入到 Postgres 中:
本地运行
要在本地运行,我们将使用 Docker Compose 同时启动网关和 Postgres。这让教程具有可复现性:读者不需要手动配置数据库,而网关可以使用与容器化部署中相同形状的DATABASE_URL。
-d 标志以分离模式运行它(如果你想在之后使用终端做其他事),然后使用 docker compose down 来移除它。
在另一个终端中,为开发用的网关密钥填充种子数据:
5432 端口,请移除 Compose 中 Postgres 服务的主机端口映射。网关只需要在 Docker 的内部网络上访问到 Postgres 即可。
需要牢记的重点是:Venice API 密钥只应存在于网关环境中。客户端请求应该使用填充的网关密钥。这种分离正是把网关放在模型提供商前面的全部意义所在。
测试网关
首先,检查健康状态:扩展这个网关
这个网关刻意做得很小,但它给了你一个坚实的基础。好的下一步包括:- 添加按主体的预算和每月消费上限。
- 在同一个兼容 OpenAI 的 API 后面支持多个上游提供商。
- 存储请求元数据用于审计日志,同时默认关闭 prompt 日志记录。
- 添加一个管理 API,用于创建、撤销和轮换网关密钥。
- 为每个 API 密钥添加模型白名单。
- 如果你需要在多个网关实例间进行低延迟的限流,添加 Redis 或其他共享存储。