返回文章列表

ModelGate:从一次大模型 API 调用,到一个 Go 网关

从最简单的模型 API 调用出发,记录 ModelGate 如何逐步加入流式代理、限流、幂等、路由、重试、熔断、可观测性与工程化验证。

ModelGate:从一次大模型 API 调用,到一个 Go 网关

如果只是做一个能够调用大模型的 Demo,代码其实不需要多复杂:接收用户输入,拼成请求,调用模型 API,再把响应返回给前端。只要网络正常、上游服务没有报错,这条链路很快就能跑通。

但“能够调用”与“能够稳定地提供服务”是两件不同的事。

当调用方不再只有一个,接入的模型供应商不再只有一家,请求开始出现并发、超时、限额和流式输出时,原本简单的 API 调用会逐渐暴露出更多问题:

  • 不同供应商的接口如何统一?
  • 请求应该被发送给哪个 Provider?
  • 上游返回 429 或 503 时是否应该重试?
  • 某个 Provider 连续失败时,是否还要继续向它发送流量?
  • 用户已经关闭页面,后端怎样停止仍在进行的请求?
  • 多个客户端同时请求时,怎样避免压垮上游?
  • 请求消耗了多少 Token,延迟和错误率是多少?
  • Redis、数据库或某个 Provider 出现故障时,系统会发生什么?

ModelGate 就是围绕这些问题逐步形成的。

它并不是来自真实生产事故的商业系统,而是我用 Go 完成的一个工程实践项目。我希望它不只是“套一层接口调用”,而是尽可能完整地呈现一个 LLM Gateway 需要面对的关键问题。

项目地址:theHerta27/ModelGate

网关并不是多转发一次请求

客户端当然可以直接调用模型供应商,但这意味着每个业务都要分别处理鉴权、协议差异、超时、重试、限流和日志。当供应商或策略发生变化时,多个业务也要分别修改。

网关放在业务和模型之间,首先解决的是“统一入口”问题:

flowchart LR
    Client["LLM / Agent 应用"] --> Gateway["ModelGate"]
    Gateway --> A["Provider A"]
    Gateway --> B["Provider B"]
    Gateway --> C["Mock Provider"]

业务只需要面对一套 OpenAI-compatible API。至于请求最终发往哪个 Provider、是否允许继续请求、失败后能否重试,都由网关统一处理。

不过,统一接口只是第一步。真正让网关变得复杂的,是它必须集中处理请求链路中的各种不确定性。

第一阶段:先建立一条正确的请求链路

ModelGate 最初实现的是 /v1/chat/completions,通过 Provider 接口隔离网关和具体上游。

这层抽象的意义不只是让代码看起来整齐。Handler 不需要知道 DeepSeek 或其他 OpenAI-compatible Provider 的具体调用方式,Provider 也不需要关心 HTTP 路由和客户端响应格式。测试时还可以接入 Mock Provider,在不请求真实模型的情况下验证网关行为。

这个阶段还处理了请求体大小、参数校验和统一错误响应。它们没有路由算法那么显眼,却决定了服务能否在异常输入下保持明确的边界。

到这里,ModelGate 已经不再是把某家模型 API 写死在 Handler 中,但它仍然只能处理一次性返回的普通响应。

第二阶段:流式响应改变了错误处理方式

大模型经常通过 SSE 持续返回内容。服务端不是等完整答案生成后一次返回,而是不断转发增量数据,最后以 [DONE] 结束。

这会带来一个很重要的变化:一旦响应头和第一帧数据已经发送,HTTP 状态码就不能重新修改。

因此,流式请求必须区分两类错误:

  • 第一帧发送前失败:仍然可以返回结构化 JSON 错误;
  • 第一帧发送后失败:连接已经进入流式传输,只能结束当前数据流并记录错误。

ModelGate 在这一阶段增加了 typed chunk、SSE 转发、事件大小限制、EOF 处理和资源释放,同时把客户端取消通过 context.Context 继续传递到上游。

如果用户关闭页面,下游 Context 会被取消,上游请求也应尽快结束。否则用户已经离开,服务器和模型供应商却还在继续消耗连接、计算资源和 Token。

这让我第一次明显感受到:流式代理不只是把上游的字节复制给下游,它还涉及错误边界、取消传播和资源生命周期。

第三阶段:从接口转发走向请求治理

当请求数量增加后,网关还需要回答另外几个问题:谁可以调用、调用多少次、重复请求应该怎么办,以及哪些结果可以复用。

ModelGate 使用 Redis Lua Token Bucket 实现限流。令牌的计算和扣减在 Redis 中原子完成,并使用 Redis TIME 作为统一时间来源,避免多个网关实例依赖各自机器的本地时钟。

对于带有 Idempotency-Key 的非流式请求,网关会区分处理中、已完成、可重放和冲突等状态。相同 Key 与相同请求可以重放已有结果;相同 Key 对应不同请求则返回冲突,避免把两个不同操作错误地当成同一次请求。

响应缓存只用于满足条件的确定性非流式请求,而不是看到 Redis 就缓存所有内容。流式响应、非确定性参数和错误结果都有不同的生命周期,缓存策略必须先说明适用边界。

与此同时,PostgreSQL 用来保存 API Key、Provider、请求记录和 Token Usage 等持久化数据。Redis 更适合承担限流、短期状态和缓存,PostgreSQL 则负责需要长期查询和事务约束的数据。两者并不是互相替代,而是承担不同职责。

第四阶段:多个 Provider 带来了路由与故障问题

只有一个 Provider 时,网关并不需要“选择”。接入多个 Provider 后,系统才真正出现路由问题。

ModelGate 实现了 Round Robin 和 Smooth Weighted Round Robin。普通轮询让请求依次分配;平滑加权轮询则根据 Provider 权重分配流量,同时尽量避免高权重节点在短时间内连续接收大量请求。

路由之外还需要限制单个 Provider 的并发量。ModelGate 使用 per-provider Semaphore 控制同时进行的请求数。它与 Token Bucket 解决的问题不同:

  • Token Bucket 控制一段时间内允许多少请求进入;
  • Semaphore 控制某一时刻有多少请求正在执行。

对于临时故障,网关会针对 429、503、Deadline 和部分网络错误进行有限重试,并使用指数退避与 Full Jitter 打散重试时间。重试并不是越多越可靠,如果大量请求立即同时重试,反而可能让正在恢复的上游再次过载。

如果某个 Provider 连续失败,Circuit Breaker 会从 CLOSED 进入 OPEN,暂时阻止请求继续进入;经过等待后进入 HALF_OPEN,只允许少量探测请求判断服务是否恢复。

这些机制组合起来后,请求链路大致变成:

flowchart TD
    Request["请求进入"] --> Governance["鉴权、限流、幂等、缓存"]
    Governance --> Router["路由与并发控制"]
    Router --> Provider["Provider 请求"]
    Provider --> Result{"请求结果"}
    Result -->|成功| Response["返回或流式转发"]
    Result -->|可重试故障| Retry["退避、重试、熔断判断"]
    Retry --> Router

可观测性:系统出错时必须知道发生了什么

如果只有“请求成功”和“请求失败”两种日志,很难判断问题究竟出在客户端、网关还是 Provider。

ModelGate 使用结构化日志记录 Request ID、Provider、尝试次数和错误类型,并通过 Prometheus 采集请求量、延迟、Provider 错误、限流、熔断状态和 Token 使用等指标,再由 Grafana Dashboard 展示。

这里还需要控制指标标签的基数。Request ID、用户 ID 这类几乎不会重复的值适合放进日志,不适合直接作为 Prometheus Label,否则时间序列数量会快速增长。

日志适合追踪一次具体请求,指标适合观察整个系统的趋势。两者解决的问题不同。

为什么使用 Go

选择 Go 并不是因为其他语言无法实现网关,而是它与这个项目的问题比较契合。

HTTP 服务、上游请求和 SSE 都涉及大量网络 I/O;goroutine 适合组织这些相互独立的请求,context.Context 可以贯穿超时与取消传播,接口则适合隔离 Provider、Router 和存储实现。最终程序还能构建为单个二进制文件,便于放进精简容器镜像。

但 Go 的并发写法简洁,并不代表并发正确性会自动得到保证。共享状态、熔断器状态机、缓存预留和资源关闭仍然需要明确的所有权与同步边界。

功能完成不等于项目完成

在项目后期,我逐渐把注意力从“再加一个功能”转向“如何证明已有功能确实可以工作”。

ModelGate 最终提供了多阶段非 root Dockerfile,以及由 Gateway、Redis、PostgreSQL、Prometheus 和 Grafana 组成的 Docker Compose 环境。GitHub Actions 会执行格式检查、go vet、单元测试、Race Detector、构建、真实 Redis/PostgreSQL 集成测试、Docker Build 和完整服务 Smoke Test。

测试也被分成不同层次:

  • 单元测试验证组件行为和故障分支;
  • 集成测试验证真实 Redis Lua、SQL、Migration 和事务;
  • Smoke Test 验证多个服务组合后能否完成一次真实 HTTP 请求;
  • Benchmark 观察 Router、并发限制器和 Gateway 内部路径的相对开销。

这里需要特别说明,项目中的 Benchmark 是进程内微基准,不是生产环境 QPS。Race Detector 通过也只表示测试实际执行到的路径没有发现数据竞争,不能证明所有并发路径都绝对安全。

这个项目目前不是什么

ModelGate 目前停在 V4。它是一个围绕真实工程问题设计的个人项目,但没有经过生产业务规模、真实用户流量和长期线上运行的验证,因此我不会把它称为“生产级高可用网关”。

项目也没有为了增加技术名词而继续引入消息队列。只有当数据证明 Usage 写入已经影响请求延迟、造成数据库连接争用,或者确实需要跨进程的持久化缓冲时,消息队列才有足够明确的引入理由。

对我来说,知道什么时候不增加一个组件,也是系统设计的一部分。

结语

回头看 ModelGate 的演进过程,它并不是从一开始就拥有完整架构,而是从一条最简单的模型调用链路出发,不断处理新出现的问题:

统一接口
→ 流式传输
→ 超时与取消
→ 限流、幂等与缓存
→ 多 Provider 路由
→ 重试、熔断与并发控制
→ 日志、指标与工程化验证

网关的价值并不只是“替业务多转发一次请求”,而是把不同业务都会遇到的治理问题集中起来,形成稳定、统一、可以观察和验证的模型访问入口。

这也是 ModelGate 最终想解决的问题。