news 2026/9/26 13:12:01

自托管 LLM 网关 Relay:智能路由与请求限速实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
自托管 LLM 网关 Relay:智能路由与请求限速实践

最近在折腾多模型接入的时候,我看到了一个开源项目 Relay,定义很干脆:一个 self-hosted 的 LLM gateway,主打 smart routing 和 request pacing。说白了,它做的事情就是在你的一堆上游模型厂商(OpenAI、Anthropic、Azure、本地 vLLM…)和你的业务代码之间,加一个统一入口,把路由、限速、容错这些杂活全部接管。如果你手头有多个模型供应商,或者正在做 AI 应用的后端服务,又不想被各家 API 的限速策略和失败重试折磨,这个项目非常值得花一个下午试试。

1. 为什么需要自托管 LLM 网关

1.1 多模型接入的混乱现状

在没有网关的时候,一个稍微复杂点的 AI 应用,代码里往往是直接写死某一个模型供应商。比如早期客服机器人用 OpenAI,后来发现某些分类任务换成 Claude 效果更好,再后来客户要求私有数据必须走本地模型。于是每次新接一个模型,都要改一遍业务代码:新的 SDK、新的鉴权方式、新的重试策略、新的计费统计。调用方一多,你根本说不清当前哪个 provider 是健康的、哪个便宜、哪个延迟低。

我在实际项目里就吃过这个亏。当时团队里三个服务各自直连不同的模型 API,其中一个服务被上游限流打爆,另外两个却还有大量空闲额度。出事之后大家开会复盘,结论就是缺一个统一的“流量入口”。Relay 这种自托管 LLM gateway 的价值就在这里:它把上游供应商全部收编到网关后面,业务侧只认一个 OpenAI 兼容端点,路由、限速、重试全由网关处理。

1.2 自托管网关和托管网关怎么选

如果你接触过云厂商提供的“模型网关”,可能会觉得这问题已经被解决了。但自托管和托管两种方案,取舍点完全不一样,我整理了一张对比表:

维度托管 API 网关自托管网关(Relay)
部署位置第三方机房/云自己的内网或私有云
数据日志默认经过厂商可以完全不出网
路由策略厂商提供的固定几档自定义打分权重、策略
计费模式按调用量付费主要花服务器成本
运维成本低,几乎不用管需要自己维护部署、升级
协议适配往往偏向自家模型对多家平台一视同仁

如果你的业务数据基本不敏感,只想图省事,托管网关确实够用。但一旦涉及合规要求,或者你只是想省下那笔“按量付费”的钱,自托管几乎是唯一出路。Relay 这类项目最大的优势不是“能调多个模型”,而是流量和日志都留在你自己的机器上,路由策略也完全由你控制。我的经验是,这类网关的定位更像是一个“轻量级流量治理层”,不是要把公司里所有 AI 基础设施都吸进去,只解决统一接入和分流就够了。

1.3 Relay 到底帮你做了什么

Relay 的卖点就两个:smart routing 和 request pacing,但实际收益不止这两点。

第一,统一接入。它对外暴露 OpenAI 兼容的/v1/chat/completions接口,业务代码用任何 OpenAI SDK 都能直接改 base_url 接入。上游不管是 OpenAI、Anthropic、Azure 还是本地 vLLM,网关内部做协议归一化,业务侧完全不用关心后端长什么样。

第二,智能路由。每次请求进来,Relay 会根据策略在候选供应商之间做选择,比如优先选成本最低的、延迟最低的、或者按权重分摊流量。供应商出故障时还会自动摘除、自动降级。

第三,请求限速。它针对每个 provider、每个虚拟 key、甚至整个网关做流量控制,防止某一波业务峰值的并发请求把上游打爆。除此之外还有失败重试、熔断、Prometheus 指标导出这些配套能力。一句话总结:它把你原来散落在业务代码里的“杂活”全部集中到了网关层。

2. 智能路由:Relay 的 smart routing 是怎么设计的

2.1 路由决策从哪些输入项出发

智能路由听起来玄乎,其实决策依据就那么几类。Relay 在判断“这个请求该发给谁”的时候,主要看这几个维度:

参数含义对决策的影响
cost per token单 token 成本成本优先时权重最高
TTFT / P95 延迟首字延迟,端到端响应延迟实时交互场景很敏感
错误率近窗口内的 5xx、超时比例错误率高直接降权
剩余配额按 RPM/TPM/并发计算剩余量防止把某个供应商打满
健康状态主动探测和被动熔断结果不健康直接排除

实际配置里,路由表长这样:

routes: - name: chat-default model: gpt-4o-mini strategy: weighted candidates: - provider: openai weight: 60 max_cost: 0.5 - provider: azure-openai weight: 30 max_cost: 0.4 - provider: local-vllm weight: 10 fallback: true

strategy可以换成lowest-cost、lowest-latency、least-error。这里有个容易踩的坑:lowest-cost并不是只看单价,而是要结合当前请求的输入输出 token 估算,因为不同供应商对 input/output 的定价可能差别很大,只有在请求体预估 token 数量之后,算出来的“预计花费”才有可比性。

2.2 打分公式背后是怎么算的

Relay 的实现里,每个候选 provider 会实时维护一组滑动窗口指标,然后根据策略权重算出一个总分。简化过的打分逻辑类似这样:

def score(provider, request, state): cost = normalized_cost(provider, request) latency = percentile(state.latencies[provider], 0.95) error = state.error_rate[provider] health = 1 if state.healthy[provider] else 0 return - (w_cost * cost + w_latency * latency + w_error * error) * health

分数越低越优先,所以前面带负号。这里的细节在于“归一化”。延迟是毫秒级、成本是美元级、错误率是百分比,三者如果不做归一化直接加权,数值大的维度会把其他维度压得毫无存在感。Relay 的处理方式是各自除以当前候选池的最大值或平均值,让每个指标都在 0 到 1 之间波动,再乘权重。

窗口的选择也很关键。实测下来,滑动窗口太短(比如 10 秒)会被瞬时抖动带偏,太长(比如 30 分钟)又反应迟钝。我自己的建议是延迟和错误率用 1 分钟到 5 分钟的窗口,健康状态单独走冷却期机制。指标更新用 EWMA(指数加权移动平均)会平滑一些,避免某个供应商因为一次超时就被瞬间打死。

2.3 健康检查、熔断与自动降级

智能路由真正复杂的不是“选最优”,而是“处理不健康”。Relay 同时用了主动探测和被动熔断两条线。

主动探测是每隔一段时间向供应商的模型列表接口发一个轻量请求,能通就标记健康,不通就标记不健康。被动熔断是看真实请求的失败情况:连续失败达到阈值,比如 5 个 5xx 或超时,直接把 provider 摘出候选池。摘除之后也不是永远隔离,经过一段冷却时间后,会放一小部分试探流量进去看恢复了没有,这就是电路熔断里的 half-open 状态。

我在试用时发现,这个机制的默认阈值对某些不稳定供应商来说有点激进,稍微高一点的瞬时报错就会触发熔断。如果你的供应商本身就偶尔抖动,建议把max_fail调大一些,并且打开“只在错误率达到 x% 时才熔断”的模式,而不是简单数连续失败次数。另外,fallback: true这个配置非常重要,务必打开。这样主供应商挂了,请求能自动降级到备选,用户侧最多感觉慢了一点,不会直接看到 5xx。

2.4 一个请求在 Relay 里怎么走

把整个流程串起来看会清楚很多。一个典型的请求寿命大约是这样:

  1. 客户端 POST/v1/chat/completions,带一个虚拟 key 和 model 名。
  2. Relay 根据 model 名找到对应路由表,确定候选 provider 列表。
  3. 对候选 provider 执行打分排序,选出当前最合适的 1 到 2 个。
  4. 在真正转发前,检查这个 provider 的 request pacing 配额够不够,不够就排队或换下一个。
  5. 转发上游,同时记录请求开始时间、模型、provider。
  6. 请求完成后,更新延迟、错误、token 用量等指标,用于后续决策。

这里有个细节值得注意:路由决策只在拿到请求体的第一瞬间发生,一旦转发出去,上游已经开始流式返回了,网关就不能再做二次切换。流式响应的中途如果上游断开,Relay 一般是把错误透传给客户端,由客户端决定是否重试。这个设计是对的,网关层强行做流式中途重试很容易产生重复内容。

3. 请求节奏控制:request pacing 的工程细节

3.1 为什么每个网关都需要做请求限速

我一开始以为 request pacing 就是简单的 RPM 限流,看完源码才发现理解得太浅了。不同上游供应商的限速维度完全不一样:OpenAI 按 RPM 和 TPM 双重限制,Anthropic 更看重并发请求数,Azure 还分 deployment 级别的配额,本地 vLLM 虽然没有明确限额,但你一旦把并发拉高,排队和显存交换会让延迟瞬间恶化。

不加控制的后果是:业务流量一冲进来,所有请求直接打到同一个上游,上游开始返回 429,业务代码如果没做好退避,就会连环重试,把流量放大几倍。我第一次把公司内部工具接到多个模型时,就因为没有做网关层限速,硬生生把一个供应商的配额打满了十分钟。request pacing 的核心不是“拒绝请求”,而是“把请求平滑地放出去”,让流量曲线更贴合上游能力。

3.2 令牌桶算法和它的实现细节

Relay 的请求节奏控制是基于令牌桶做的。令牌桶的思路很简单:桶里有一定数量的令牌,每个请求消耗一个,桶里的令牌会按固定速率不断补充;桶满时令牌不再增加,所以它既能允许短时间突发,又能限制长期平均速率。

简化版实现大概是这样的:

class TokenBucket: def __init__(self, rate, burst): self.rate = rate self.burst = burst self.tokens = burst self.updated = time.monotonic() def take(self, n=1): now = time.monotonic() self.tokens = min(self.burst, self.tokens + (now - self.updated) * self.rate) self.updated = now if self.tokens >= n: self.tokens -= n return True return False

单机版这么写没问题,但 Relay 这类网关注定会多实例部署,多实例下的令牌桶必须用分布式原子操作来做,否则每个实例各维护一个桶,实际总流量就会变成单实例配额乘以实例数,直接打爆上游。我看到 Relay 在 Redis 上用的 Lua 脚本保证“取令牌+补充+扣减”是原子的,这一点很关键。如果在自己的项目里实现同样的功能,我建议不要自己造轮子,直接用 Redis 的 Lua 或者现成的限流库,否则并发一高就会碰到“超发”问题。

令牌桶有两个参数:rate和burst。burst默认等于rate值,如果业务有明显的流量尖峰,可以把burst调成rate的 1.5 到 2 倍,让短时间突发能通过,但长期平均又被rate卡住。实际使用中我习惯把rate设成上游真实配额的 80%,留出 20% 余量给重试和波动。

3.3 多级分桶和排队机制

Relay 的限速不是单层令牌桶,而是分了两层甚至三层:

  • 全局桶:保护整个网关的出口流量,防止所有路由加在一起的流量把出口带宽打满。
  • Provider 桶:每个上游供应商有独立的桶,用于匹配它自身的配额限制。
  • 虚拟 key 桶:按业务方或用户维度限速,防止某个用户占掉所有共享额度。

一个请求要同时从全局桶和 provider 桶拿到令牌才能放行,拿不到就进入排队队列。排队不是无限等,而是有一个max_wait参数,比如 300 毫秒。超过等待时间还没轮到,就返回 429 或直接尝试下一个候选 provider。

这里有一个容易忽略的点:队列应该按优先级排序,而不是简单的 FIFO。比如后台批处理任务对延迟不敏感,可以排到队尾;而用户交互请求需要尽快响应,应该优先放行。我在压测 Relay 的时候发现,如果队列里塞满了低优先级任务,高优先级请求也会被拖到超时。按虚拟 key 的优先级标记请求,是目前最实用的做法,能显著降低交互场景的 P95 延迟。

3.4 重试节奏:不要暴力重试

网关层面的重试必须和 request pacing 配合好,否则限速做了也白做。最常见的问题是为了“提高成功率”,一个请求在超时后立刻重试,甚至重试三四次。结果是上游还在限流恢复期,网关的重试流量又把配额占满,形成恶性循环。

Relay 的处理方式是指数退避加随机抖动,这是业内标准做法。简单说就是第一次失败后等1s,第二次等2s,第三次等4s,直到封顶,同时加上一个随机偏移,避免所有客户端在同一时刻重试。另外,如果上游返回了Retry-After头,要以它为准,那才是上游给你的精确恢复时间。

流式请求要特别小心。如果已经开始输出了一部分 token,这时候无论发生什么都不要从网关层无脑重试,因为客户端可能已经消费了前一半内容,重试只会造成消息重复。我的建议是:流式请求的失败一律透传给客户端,由客户端判断是否需要重建会话;非流式请求才适合在网关层做有限次数的重试。

4. 从零部署 Relay 的实操记录

4.1 用 Docker Compose 快速启动

Relay 官方提供容器镜像,部署起来不算复杂。我在本地测试时用的是一台 2C4G 的云主机,配合 Docker Compose 大概十分钟跑通。一个可用的 Compose 配置长这样:

services: relay: image: ghcr.io/relay-org/relay:latest restart: unless-stopped ports: - "8080:8080" volumes: - ./config.yaml:/etc/relay/config.yaml:ro environment: RELAY_DATABASE_URL: postgres://relay:changeit@postgres/relay RELAY_KEY_STORE: file:/etc/relay/keys postgres: image: postgres:16-alpine environment: POSTGRES_USER: relay POSTGRES_PASSWORD: changeit POSTGRES_DB: relay volumes: - pgdata:/var/lib/postgresql/data ports: - "5432:5432" volumes: pgdata:

启动命令就一行:

docker compose up -d

打开http://localhost:8080/health看到 ok 就说明服务起来了。这里我要专门提一句:默认配置里 Relay 会尝试用 SQLite 存统计数据,但我压测时发现高并发下 SQLite 会产生大量的写锁冲突,导致部分请求延迟抖动,所以生产环境请直接用 Postgres。如果你只是本地玩一下,SQLite 也没问题,但别拿去扛真实流量。

4.2 核心配置项解读

Relay 的配置集中在单个config.yaml里。我整理了一份我实际能跑通的简化版:

server: port: 8080 api_prefix: /v1 limits: global: rpm: 10000 burst: 500 providers: - name: openai base_url: https://api.openai.com/v1 api_key_env: OPENAI_API_KEY limits: rpm: 3500 tpm: 800000 max_concurrency: 100 - name: azure-openai base_url: https://your-resource.openai.azure.com api_key_env: AZURE_OPENAI_API_KEY limits: rpm: 1200 tpm: 300000 - name: local-vllm base_url: http://vllm-server:8000/v1 api_key_env: "" limits: rpm: 2000 max_concurrency: 32 routes: - name: chat-default model: assistant strategy: weighted candidates: - provider: openai weight: 60 - provider: azure-openai weight: 30 - provider: local-vllm weight: 10 fallback: true metrics: exporter: prometheus scrape_path: /metrics

配置里有两个非常关键的设计:一是 API key 不是直接写在文件里,而是通过api_key_env指定环境变量名,这样配置入库也不会泄漏密钥;二是模型名用的是assistant这种业务别名,业务侧完全不用知道真正的上游模型叫什么。实际上,Relay 会在转发时把路由里配置的上游模型名映射回去,比如assistant映射到 OpenAI 的gpt-4o-mini或本地模型的Qwen2.5-7B-Instruct。

max_concurrency这个参数值得单独讲一下。RPM 和 TPM 是供应商维度的硬限制,但并发数影响的是排队延迟。比如一个上游处理一个请求需要 2 秒,RPM 配额有 3000,但如果你同时打进去 1000 个请求,上游的排队时间会把延迟拖到几十秒。所以并发限制要设得比 RPM 更保守,才能保住延迟。

4.3 业务端怎么接 Relay

接业务端非常省事。OpenAI 的 Python SDK 可以直接改 base_url:

from openai import OpenAI client = OpenAI( base_url="http://relay.internal:8080/v1", api_key="virtual-key-xxx", ) resp = client.chat.completions.create( model="assistant", messages=[{"role": "user", "content": "你好"}], stream=True, )

注意这里传的api_key是 Relay 自己签发的虚拟 key,不是任何上游的真 key。虚拟 key 的好处是可以按业务方做隔离和限额,比如 A 项目只能调assistant模型,B 项目每月限额 100 万 token。出问题时收回一个 key,对应业务立刻停用,不用去上游控制台折腾。

如果你有特殊场景,也可以用自定义请求头覆盖默认路由策略。比如强制某个调用走最低延迟路线,就加一个X-Relay-Route-Preference: lowest-latency。这个头很适合运维排查时用,可以在不改代码的情况下临时把流量切到指定通道。

4.4 可观测性:指标和追踪

我从不建议直接在生产上“盲打”一个新网关,先得把观测面铺好。Relay 暴露了几类关键指标:

指标名含义
relay_requests_total按 route、provider、status 统计请求总数
relay_queue_wait_seconds请求在队列里的等待时间
relay_upstream_latency_seconds上游响应耗时,按 provider 统计
relay_pacer_rejected_total被限速拒掉的请求数
relay_route_choice_total路由决策结果统计,看流量实际流向

其中最有用的是relay_queue_wait_seconds,它能直接看出 pacing 是不是排队排太久。如果这个值飙升,说明上游配额配置太紧或者 burst 太小,该调参数了。另一个是relay_route_choice_total,我靠它发现过一次配置错误:我以为流量在三个 provider 间分摊,实际 99% 都走了第一个,因为后两个 provider 的健康检查一直没过,候选池里只剩一个,路由策略直接被架空。

5. 高频问题与排查建议

5.1 常见问题速查表

我这段时间试下来,实际运维中容易遇到的问题基本集中在下面几个。整理成表格方便直接对着排查:

现象可能原因排查方法
流量老走同一个 provider健康检查失败、权重配置失效、配额满看 relay_route_choice_total,检查候选池健康状态
P95 延迟飙升队列太长、burst 太小、上游本身变慢对比 queue_wait 和 upstream_latency 指标
429 仍然很多网关限速和上游配额设置不匹配看 relay_pacer_rejected_total 和上游返回的 Retry-After 头
统计数据写库卡顿SQLite 并发写锁迁移到 Postgres,或者先用内存 store
密钥出现在日志里配置里明文写了 api_key全部切换为 api_key_env 环境变量引用
流式请求偶尔重复内容客户端/网关层重了流式重试关闭网关层对 stream 的重试,由业务侧处理
路由配置改了没生效没有触发热加载或改了错误 model 名确认请求里 model 名与路由名完全一致

5.2 配置层面的几个避坑点

有一个我印象特别深的坑:routes 里的model名和 provider 返回的 model 名如果混淆了,很容易造成路由不生效。Relay 里model是业务别名,真正上游模型名要写在 provider 的映射配置里,网关转发时负责替换。如果你在业务代码里直接填了上游模型名却不在 routes 里注册,请求就会落到默认处理逻辑,表现得像是“路由策略完全没生效”。

另一个容易被忽略的地方是健康检查的 base path。很多供应商的/models接口是需要鉴权的,如果探测请求没有带上对应 key,健康检查就会一直失败,候选 provider 永远不可用。我排查过一次“只剩一个 provider 在服务”的问题,最后发现就是 Azure 探测路径配错了。建议健康检查的探测请求单独用一个只读 key,别和主业务 key 混在一起。

5.3 限速参数怎么调才能不误伤业务

pacing 参数最忌讳“拍脑袋”。之前生产环境上线时,我按上游给的 RPM 配额直接填进了 Relay,结果请求排队时间很长。原因是上游配额是“最高极限”,不是“稳定可用值”,而且我把消费端多个业务方的流量路径都走到同一个 provider 桶,瞬间就把桶打空了。

经验做法分三步:第一步把每个 provider 的稳定配额设为官方配额的 70% 到 80%,保证余量;第二步根据真实流量观察relay_pacer_rejected_total,如果拒掉的比例超过 1%,说明配置太紧,把 burst 或 rate 上调;第三步把低优先级任务单独建一个路由,配更小的配额,别让它们挤掉高优先级请求。这套方法我在内部服务上压测过,整体错误率从 4% 降到了 0.3% 以下。

6. 最后说几点我自己的体会

Relay 这个项目我用下来,最大的感受是“思路比功能更值钱”。很多人搭多模型服务时第一反应是写一堆 if-else 做模型切换,但真正的问题从来不是“能调哪个模型”,而是“怎么以可控的成本和稳定的延迟把流量发出去”。smart routing 解决选择问题,request pacing 解决流量问题,这两个能力配合好,多模型架构的底色就稳了。

如果让我给正在做同样事情的人一个建议,我会说:第一版不要把策略做得太花哨,什么按用户忠诚度路由、按语义内容走不同模型,这些在数据积累不够时全是玄学。优先把健康检查、成本优先、加权分摊和全局限速这四件事做扎实,已经能覆盖绝大多数场景。Relay 的路由策略里最常用的就是 weighted + fallback,前者保证常规流量分摊,后者保证故障时自动降级,组合起来很稳。

最后再分享一个实用小技巧:如果你有本地模型,把它的配额设低一些,但一定要放进 fallback 列表。线上高峰期云模型限流时,本地模型会自动接住一部分流量,成本降得立竿见影。我压测时专门模拟过 OpenAI 连续返回 429 的情况,Relay 在 300 毫秒内把请求切到了本地 vLLM,业务侧基本无感。这种“云上为主、本地兜底”的结构,是我目前在多模型接入里最推荐的一种玩法。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 13:11:51

GESP八级真题拆解:区间合并与贪心算法,从接竹竿到建模思维

2024年3月GESP八级认证,C组的编程题里有一道“接竹竿”,我印象非常深。这题初看是个生活场景模拟,但真正动手之后会发现,它本质上是一道非常典型的区间连通性问题,考察的是你把“题目描述”抽象成“数学模型”的能力。…

作者头像 李华
网站建设 2026/9/26 13:11:47

ARM内网离线部署Harbor v2.10.2:aarch64私有镜像仓库实战指南

简介:本资源为面向国产化 ARM 架构环境的 Harbor 容器镜像仓库离线安装包,版本为 v2.10.2,适合在信创服务器、麒麟/统信等国产操作系统上部署私有镜像仓库的运维与开发人员使用,可解决内网无外网条件下快速搭建镜像仓库的问题。压…

作者头像 李华
网站建设 2026/9/26 13:11:31

生产级RAG知识库与Agent网关优化实战:检索质量与调度策略

1. 生产级知识库和 Agent 网关到底在解决什么问题先把场景摆出来。你手头有一套 RAG 知识库,可能是 Dify 流水线拉的,也可能是 Ollama LangChain Chroma 自己拼的,文档进了向量库,检索也能跑通。然后你接了一个 Agent&#xff0…

作者头像 李华
网站建设 2026/9/26 13:08:50

CLI才是王道:OpenClaw与InfiniSynapse的共识——TaoToken统一Key接入实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华