1. 从“一把梭”到LLM网关:我为什么愿意折腾自托管
1.1 代码里写死各家SDK的那段日子
先说个真实的场景。假设你的产品已经接了三家模型:OpenAI 的 GPT 系列负责通用对话、某家国产大模型负责中文长文本总结、还有个本地部署的开源模型负责敏感数据脱敏处理。这听起来很美好,但实际代码层会变成什么样子?回调函数各写各的、请求超时重试逻辑五花八门、每个供应商的鉴权头都不一样,更别提有的模型 SDK 有自己的一套流式解析协议。换一个模型或者加一个供应商,就得动业务代码,出一次线上问题就要翻半天日志才能定位到是哪个上游。
大多数团队走到这一步,第一个想法是“我们搞个抽象层吧”。抽象层确实能解决一部分问题,但它往往只停留在接口统一上。真正的难点在于:请求发给谁、什么时候发、发失败了怎么办、整体预算怎么管控、不同业务线之间的优先级怎么调度。这些东西如果你全部在业务代码里实现,代码会膨胀得非常快,而且每一处调用点都得遵循同一套逻辑,这几乎是人力维护的噩梦。所以我更倾向于把这一层能力独立出来,做成一个基础设施组件——这正是我开始认真考虑自建 LLM 网关的原因。
Relay 这个项目就是在这种背景下出现的。它本质上是一个 self-hosted 的 LLM gateway,你可以把它理解成所有 AI 请求的“统一交通枢纽”。业务服务不再直接面对各式各样的模型 SDK,而是只跟 Relay 对话。Relay 负责做 smart routing 和 request pacing,也就是智能路由和请求节奏控制。当时我第一眼在 Show HN 上看到这个项目名,并没有觉得它有多特别,但看完它的设计思路和实践细节之后,我开始意识到,这可能是很多 AI 应用团队迟早要面对的一类基础设施。
1.2 Relay 想解决的三个具体问题
第一个问题,是连接管理。没有网关的时候,每一台业务服务器都要跟各家模型服务保持连接,连接池参数、超时时间、重试策略都得自己调。一旦模型服务端出现抖动,上游的故障会直接传导到你的所有业务节点。有了网关,上游连接集中收敛在 Relay 这一层,业务侧只需要维护与 Relay 之间的一条稳定连接即可。
第二个问题,是请求分发策略。多个模型并存时,你不能简单轮询或者写死路由。要考虑模型能力边界,比如某个模型虽然便宜但支持的工具调用能力弱,某个模型延迟低但上下文窗口小。这些判断不应该让业务开发每次手动指定,而是让网关把路由规则沉淀下来,业务侧只需表达需求和预算约束。
第三个问题,是流量整形。LLM 接口一个很反直觉的特点在于,单次请求的耗时长且方差大,大量突发请求会瞬间打满上游的配额,而一旦达到供应商的 token 速率限制,等到的往往是一串 429 错误。Relay 用 request pacing 的机制去平滑请求的发出速率,把“一波流”变成“匀速跑”。这跟传统限流(rate limiting)的思路不一样,它不是简单地拒绝多余请求,而是让请求排队并按节奏放行,从而维持整体吞吐的稳定。
这三个问题,覆盖了接入层、策略层、流量层。理解了它到底在解决什么,后面所有设计细节才能串得起来。
2. 为什么不直接用云服务商提供的托管网关
2.1 托管的网关服务看起来很省事,但也有不少“不对味”的地方
现在各大云平台基本都推出了自己的 LLM 网关或 API 管理产品,托管网关最直接的吸引力是免运维:不用自己部署,控制台点几下就能完成模型路由配置。如果你是一个小团队,想在一天之内跑通所有实验,托管服务确实香。但我个人在实践中对托管网关有几处始终不太满意的点。
第一是数据链路和隐私控制。请求会先经过别人的网关,再转发到模型供应商。这个过程中如果你有敏感的业务数据,就会多一跳不可控的传输。对于有数据合规要求的业务,自托管网关是更容易说清楚的方案,因为数据从你的 VPC 到模型提供方之间,所有的中间环节都是你自己控制的。当然,自托管不意味着绝对安全,但至少安全边界是你能看得见、摸得着的。
第二是策略灵活度。托管网关提供的能力永远是“别人替你定义好的能力”。比如我想实现一个非常细粒度的成本路由策略,要求当某个项目的日预算用完时,自动把请求降级到开源模型;或者我想在网关层做一套特殊的语义缓存逻辑,只缓存特定领域的问法。这些需求在小厂商那里很难被快速响应,等你提工单走流程,业务早就变了。
第三是供应商锁定。托管网关通常绑定其云平台的生态,你用的路由规则 DSL、监控面板、告警体系都是他们家的私有格式。如果哪天想整体迁移到其他平台,等于网关层全部要重新实现。自托管方案至少还有迁移的可能性,代码、配置、数据库都在自己手里。
2.2 自托管给团队协作方式带来的改变
除了安全和灵活度,我觉得自托管网关还有一个很实际的收益,是让“谁能调模型、花了多少钱、响应快不快”这件事在团队内部变得透明。Relay 这类网关可以暴露统一的内部 API,前端团队、后端团队、数据分析团队都走同一个入口,同时网关可以按项目、按部门、按调用方身份去划分 token 配额和速率限制。
举一个我实际经历过的场景:市场部门的活动页想接入 AI 客服,他们跟开发提需求,开发初期并不确定这个功能会调用多少次,也不知道会不会被某个恶意的刷量逻辑打爆。如果每个业务线各自去申请模型 API key,那预算和用量就彻底失控了。但如果一切流量都过 Relay,就可以在网关层先给活动页项目一个较保守的配额,观察几天再动态调整。这个能力带来的不是技术便利,而是管理上的确定性。
再者,自托管网关天然适合多云多模型的架构。今天业务可能需要某个模型的新版本——从 OpenAI 切到别的模型,在传统模式下可能牵扯业务代码改造;在网关模式下,你只需要修改路由规则,把原有逻辑转发到新的模型端点即可。业务侧甚至无感知,这种“低耦合”是我认为 LLM 网关最大的价值之一。
2.3 部署边界与安全模型
当然,自托管也有它自己的代价。最明显的是多了一个需要维护的服务,这对小团队来说是一个真实的负担。所以一定要控制它的部署半径:Relay 不需要跟业务应用部署在同一台机器上,但也不建议直接暴露在公网。我个人的实践是把 Relay 放在内网的一个独立节点上,通过内部 DNS 或服务网格供各业务单元访问。
鉴权方面,网关层必须支持两种凭证的分离。一种是上行凭证,即 Relay 访问各家模型供应商时使用的 API key;另一种是下行凭证,即业务服务访问 Relay 时使用的密钥。这两种凭证如果混在一起管理,很容易出现权限过大的问题。合理的做法是:下行凭证按项目维度分发,每个项目只能访问它被允许的模型组;上行凭证统一由网关持有,业务开发者永远看不到真实的模型厂商密钥。
至于数据安全,网关层虽然是中转站,但它不应该承担存储对话内容的角色,除非你明确开启了日志或审计功能。Relay 这类设计的核心原则是“不落盘不窥探”,正常转发模式下所有内容只存在于内存里,转发完成后即释放。这样即便网关节点被攻破,数据泄露面也控制在最小程度。
3. Smart Routing 的工程解读:好网关是调度中心,不是分流器
3.1 “路由”与“分流”在认知上根本不同
如果说只按照固定比例把流量分给不同模型,那 Relay 和其他普通负载均衡器就没有本质区别了。Smart routing 的含义要深刻得多:每一次请求进来,网关要根据这个请求的内容向量、上下文长度、业务优先级、当前各后端服务的健康状态和成本预算等维度,动态计算一个最优的转发目标。这是一个持续在线决策的过程,不是一份静态配置。
我拿现实中的例子打比方。一个网约车平台的调度中心,并不是把乘客给离他最近的司机就行,它要综合考虑路况、司机当前的接单方向、乘客到达目的地的概率分布、平台的溢价规则。Smart routing 之于模型请求,好比是同一个调度逻辑在模型世界的复刻。一个简单的请求,未必非要走最贵的旗舰模型,也许 70 亿参数的小模型就能产生同样的输出质量;而一个高难度的代码生成任务,如果错误地路由到小参数模型,输出质量可能惨不忍睹,还得重试一次,整体成本反而更高。
3.2 影响路由决策的几个关键因素
先说能力域。这是最基础的维度,某些模型支持工具调用和结构化输出,某些模型不支持;某些模型对英文的理解强于中文,某些则专门做了中文语料优化;某些模型上下文窗口能达到 128K 以上,另外一些只有 16K。Relay 在注册每个上游模型时,会带上一份能力清单。路由过滤器在请求进入时先做能力匹配,凡是不满足请求要求的候选模型,直接在预选阶段淘汰。
其次是延迟和成本。这里要引入“代价函数”的概念。一个请求如果属于低延迟要求,比如聊天交互场景,那么网关应该倾向选择历史 P95 延迟更低的模型;如果一个请求是后台离线批处理任务,延迟要求不高,那网关可以走价格更便宜、吞吐更多的模型。更高级的做法是引入用户自定义的预算上下文——同一个请求,如果这个用户已经在本月消耗了较多数量的 token,网关可以选择降级到一个成本更低的方案。
再者是实时健康状态。LLM 供应商并不总是稳定,某个时段可能某个模型端点的错误率飙升或平均响应时间明显恶化。Relay 会根据滑动窗口内的失败率指标对上游进行打分,一旦连续失败次数超过阈值,就会自动把流量切走。这种熔断机制不像某些网关那样直接一刀切拒绝所有请求,而是转移到后备模型继续完成服务。
3.3 路由策略的可视化与可干预性
路由决策如果是一个黑盒,那团队成员根本不敢把流量放心交给它。这也是我从 Relay 的设计里比较欣赏的一点:它把每个请求的最终路由结果和原因暴露成结构化日志。比如用户可以清晰地看到“此请求被路由到模型 B,原因是模型 A 的预估成本超过当前项目预算阈值,模型 C 的上游健康度评分低于 60”。这种可解释性对于排查线上模糊问题至关重要。
实际落地时,我会建议把路由策略划分成三层:全局默认策略、项目级覆盖策略、请求级标记策略。全局默认策略负责那些没有特定需求的普通流量;项目级策略让不同业务线可以使用自己特定的模型偏好;请求级标记则允许业务侧在下发请求时附加一个 hint,比如“这单我就要用最高质量的模型,预算不敏感”。三层之间按优先级从低到高叠加,既保证了默认兜底,又提供了灵活性。
配置方式上,我倾向于用 YAML 或 JSON 描述路由规则,而不是在面板里点来点去。规则文件入库后走 CI 流程,变更可审计可回滚。比如:
routes: - id: default-chat match: purpose: chat max_context_tokens: 8192 candidates: - model: "llama-70b-local" weight: 0.1 cost_score: 1.0 - model: "gpt-4o-mini" weight: 0.6 cost_score: 0.6 - model: "claude-sonnet" weight: 0.3 cost_score: 0.4 policy: optimization_target: latency轻量的路由规则本身不需要太复杂,真正复杂的是规则背后对每个模型的实际观测数据。所以 Relay 这类网关一定要内置完善的指标采集和反馈机制,路由决策质量是随着运行时间推移逐步提升的,不是靠拍脑袋配出来的。
4. Request Pacing 的细节学问:和限流是两码事
4.1 限流常用“堵”的,pacing 讲究“疏”的
很多网关产品都宣传自己具备限流能力,但普通的 rate limiting 本质上是在单位时间窗口内允许固定数量的请求,超过部分要么直接拒绝,要么简单丢弃。这种策略对传统 API 场景可能够用,但对于 LLM 场景往往适得其反。
你可以想象一下:某个业务在秒级内突然收到大量请求,限流器直接给上游放行了一大批,结果上游供应商在毫秒级就返回 429 限流错误,网关为了让业务方成功拿到结果,又触发了一轮重试,这轮重试再次集中在同一时刻涌向上游,结果就是流量不仅没有被削峰,反而被自己放大了。Request pacing 的思路不一样,它更像是一个“流量整形器”,把原本尖锐的突发流量波形抹成平缓的斜坡,让请求以可控的速率流入上游,而不是一次性全部涌入。
4.2 令牌桶在 LLM 场景里的改造
Relay 实现 pacing 的底层机制,本质上是对经典令牌桶算法做了一些适配。我把业务里的配置模型简化描述一下:你会定义一个全局令牌池,令牌以固定的速率持续补充;每个请求进来时,需要消耗一定数量的令牌才允许继续转发;如果令牌不够,请求不是被拒绝,而是进入一个队列,等待后续令牌生成后再按下先进先出的顺序执行。
但这里有一个建模细节值得注意:LLM 请求的消耗不是简单的“每次消耗一个令牌”,而应该考虑请求上下文长度和可能产生的大输出。一个 128K 上下文的请求和一个 512 token 的短请求,对上游造成的压力完全是两个量级。所以我更推荐按 token 预估量加权重,而不是按请求数。Relay 的配置也支持这种维度,你可以设定一个普通请求的估算能耗,再设定长上下文请求的倍率系数,使流量整形更加贴近上游的真实消耗模型。
pacing 还有一个很容易被忽略的维度是“请求之间的最小间隔”。即便令牌充足,也要确保相邻两个请求之间的时间间隔不至于过密。原因在于很多上游供应商的限流指标不仅看每分钟 token 总量,还会看每秒请求数。只做总令牌控制而不做并发控制,仍可能触发另一套限流规则。所以在网关层应该同时配置并发上限和令牌桶速率,两者共同作用才能真正稳住上游消费曲线。
# 简化版 pacing 决策逻辑 class PacingDecision: def __init__(self, min_interval_ms, token_bucket, max_concurrency): self.min_interval_ns = min_interval_ms * 1_000_000 self.last_send_ts = 0 self.token_bucket = token_bucket self.inflight = 0 self.max_concurrency = max_concurrency def acquire(self) -> bool: now = time.time_ns() if self.inflight >= self.max_concurrency: return False if now - self.last_send_ts < self.min_interval_ns: return False if not self.token_bucket.take(1): return False self.last_send_ts = now self.inflight += 1 return True这份伪代码看起来简单,但它体现了一个核心观点:pacing 是一种多维约束,不是单看某一个指标。实际生产环境中,我往往会为不同优先级配置不同的队列,关键业务请求可以插队到低优先级请求之前,低优先级请求可以选择等待更长时间或者被降级到更便宜的模型。
4.3 参数整定的经验值
太多人把 pacing 配置想得太复杂,一上来就整一堆参数,结果效果很差。我建议从三个最基本的参数入手:目标 QPS、平均 token 消耗率、上游可接受的最大并发数。
举个例子,如果你有一个上游模型支持 300 并发,而你平时的请求分布中 80% 请求的上下文加输出在 2000 token 左右,剩余的 20% 在 8000 token 左右,那么你可以先把单请求平均 token 消耗按 3200 来估算。假设上游限制是每分钟 900K token,那你在网关层设定的全局速率建议不要超过 200K token/min 或 150K token/min,留出一半的缓冲给突发和重试,这个缓冲比例是我测了很多次觉得比较安全的底线。你可以用压力测试工具一点一点加压,找到平滑区和陡峭区之间的拐点,再反向推算网关参数,而不是在网上抄一份配置就直接上线。
5. 部署 Relay 的完整落地记录与实际踩坑
5.1 部署形态:最简单的方案其实够用了
Relay 的部署形式足够轻量,官方提供了 Docker 镜像,也支持 Docker Compose 一键拉起。我再强调一下网络规划:网关节点需要能访问目标模型供应商的 API 端点,通常建议放在有外网出口的 DMZ 区或 NAT 网络内,跟业务应用之间通过内部网络通信。别图方便把 Relay 的端口直接绑定到公网,除非你明确知道自己正在承担怎样的安全风险。
我的部署方案很简单,一台 2 核 4G 的云主机足以支撑 200 QPS 的普通业务量。原因在于 LLM 网关本身不做推理请求,主要开销是转发、缓存和策略计算,属于典型的 IO 密集组件,对 CPU 和内存的要求并不高。如果你在网关里启用了语义缓存这类重逻辑,才需要升级到 4 核 8G 或者更高规格。我一直遵循一个原则:网关的容器内存要预留足够大的缓冲,尤其是用了内存型缓存时,避免在流量高峰因为 GC 抖动导致请求超时。
5.2 配置实战:从零搭一个带路由和 pacing 的转发链路
下面给一个我实际用的部署配置骨架,删掉了内部细节,保留了核心框架:
version: "3.9" services: relay: image: relay:latest restart: always ports: - "8080:8080" environment: RELAY_CONTROL_API_PORT: 9090 RELAY_STORAGE_DRIVER: redis RELAY_REDIS_ADDR: relay-redis:6379 volumes: - ./config/relay.yaml:/etc/relay/config.yaml:ro depends_on: - relay-redis relay-redis: image: redis:7-alpine restart: always volumes: - relay-redis-data:/data volumes: relay-redis-data:运行时核心配置文件里有这么几段是必须仔细设计的:
providers: openai: base_url: "https://api.openai.com/v1" api_key_env: "OPENAI_API_KEY" models: gpt-4o: { knowledge: [general, code], max_tokens: 128000 } gpt-4o-mini: { knowledge: [general, chat], max_tokens: 128000 } anthropic: base_url: "https://api.anthropic.com/v1" api_key_env: "ANTHROPIC_API_KEY" models: claude-sonnet: { knowledge: [general, longtext], max_tokens: 200000 } local-llama: base_url: "http://10.0.0.8:8080/v1" api_key_env: "LOCAL_LLM_DUMMY_KEY" models: llama-70b: { knowledge: [general, code], max_tokens: 8192 } routing: default: local-llama rules: - match: { max_context_tokens: 4096, need_tool_call: false } candidates: [gpt-4o-mini, llama-70b] - match: { need_tool_call: true } candidates: [gpt-4o, claude-sonnet] pacing: enabled: true token_bucket: tokens_per_refill: 5000 refill_interval_ms: 1000 bucket_capacity: 50000 min_interval_ms: 50 max_concurrency: 100配好之后,业务侧只需要把请求发到 Relay 的/v1/chat/completions端点,返回结构尽量兼容 OpenAI 的格式。这意味着你现有代码里的 OpenAI SDK 几乎可以无痛切换过去,只要把 base_url 改为 Relay 的地址就行。这一步做对了,团队里其他同事接入时的学习成本几乎是零。
5.3 踩坑记录:超时、重试与 429 风暴
第一个坑是超时层级。模型供应商的请求耗时本身就长,如果你只设置了网关到上游的超时时间,却忽略了业务到网关的超时时间,那么会出现一种诡异的现象:业务侧等不及主动断开,但 Relay 仍然把请求转发给上游,导致上游继续产生计费 token,响应结果却无处可去。正确做法是永远保证业务到网关的超时大于网关到上游的超时,并且预留出排队和重试的时间。
第二个坑是把重试当默认手段。当上游真的发生故障时,如果没有熔断机制,重试反而会加重上游压力,形成“重试风暴”。我的建议是:重试只用于超时和 5xx 类错误,绝不用于 4xx 类业务错误;并且每增加一次重试,要把退避时间指数递增。如果你同时开着网关层的 pacing,重试请求也应该进入同一个 pacing 队列,而不是绕过队列直接发出。
第三个坑是上游流式响应的连接管理。LLM 接口大量使用 SSE 流式响应。一些网关产品在转发 SSE 时,由于缓冲设置不合理,导致客户端很久才收到第一个 token,体验极差。要留意 Relay 里对流式响应的 flush 策略,确保每收到上游的一个数据块就立即向下游转发,而不是积攒到一定量再统一推送。这个细节在交互式对话场景中基本决定产品体验好坏。
第四个坑是监控指标的缺失。不少人部署完网关就算完事,直到线上出问题才开始焦虑。我其实建议部署第一天就把指标接好,至少要能看到这几条曲线:各上游模型的请求量、错误量、响应延迟分位数、令牌消耗速率、PACING 队列长度、被降级的请求数。队列长度是一个很重要的信号,如果它持续增长,说明你的上游消费能力已经接近瓶颈,该扩容或换更快的模型了。没有指标,这些东西全都是盲人摸象。
5.4 从单网关到高可用
单实例部署的 Relay 能解决大部分问题,但如果业务对可用性要求高,还要考虑多实例部署。由于 Relay 把状态存储在 Redis 中,它天然支持多副本横向扩展。这意味着你可以在多台机器上各起一个 Relay 实例,前面加一个负载均衡器,比如直接用 Nginx 或云厂商的 LB,后面共享同一套 Redis 数据。
不过要注意的是,多实例部署后,Pacing 的令牌桶就需要从本地内存迁移到 Redis 的原子操作上。这时候就不能再用简单的take(1)本地判断了,而是要改用 Lua 脚本或 Redis 事务来保证令牌扣减的原子性。代价是每次取令牌多了一两次 Redis RTT,但对跨实例共享配额这件事来说,这点开销是必须接受的。如果你对配额一致性要求不是极致,也可以采用“本地令牌桶,定时同步全局配额”的方案,性能更好,但会有一定的配额超发窗口。两种方案各有取舍,我这里更倾向于告诉你要根据业务对配额精确度的容忍度来选择。
6. 一些真实经验:什么情况下不要上网关,什么情况下果断上
6.1 不建议上网关的两种场景
如果团队只是做内部实验,每天调用量不到几百次,那么引入网关反而是一种负担。多一个组件意味着多一个故障点,尤其是当你还没有专职基础设施人员时,出问题排查链路会变长。这种阶段最重要的是把事情跑通,用最简单的 SDK 直连反而更高效。
另外,如果你们所有的模型都来自同一个供应商,而且短期内完全没有更换供应商的计划,网关的智能路由能力对你来说可能就是多余功能。你更需要的是一个统一的密钥管理平台和请求日志系统,而不是一个完整的网关。但我自己见过不少项目,起初觉得单供应商够用,结果不到一年就引入第二个模型,到时候再迁移,成本远高于一开始就预留入口。所以即便不上网关,也建议在代码层面向 OpenAI 标准协议对齐,给自己留一条后路。
6.2 果断上网关的信号
当一个项目里出现以下三个信号中的两个,我就会认真评估引入自托管 LLM 网关:一是业务方开始频繁询问“这个模型为什么贵”“换成另一个模型行不行”;二是代码仓库里有多个模型 SDK 且每一个的调用量都不小;三是出现过一次因突发流量触发上游限流导致线上事故的问题。这些信号说明,模型调用这件事已经不是“写几个接口”的事情,而是变成了一个需要策略、预算和治理的基础设施问题。
从我几个月的使用体验看,Relay 这类 self-hosted 网关带来的最大收益不是某一个单点功能的高超,而是把“模型接入”“路由策略”“流量治理”“成本观测”这些事情统一收拢到一处。你可以说它不性感,但生产环境的稳定性往往就是靠这种不性感的模块撑起来的。
最后分享一个小技巧:配置路由规则时,永远要留一个无条件兜底模型,避免所有候选模型同时熔断导致请求无路可走。我把兜底模型设置为本地部署的小参数模型,质量差一点没关系,关键时刻能保命。这一点,是我踩了一天故障之后总结出来的最实在的一条经验。