1. 项目缘起与整体架构思路
1.1 为什么要做统一 API 网关
事情的起点很朴素:我手里同时跑着好几个 AI 应用,每个应用背后对接的模型供应商都不一样。有的走 OpenAI 兼容协议,有的走自家私有协议,有的只提供 gRPC,有的干脆只给了一个 HTTP 裸接口。最开始我的做法很粗暴——每个应用里写一套 SDK,谁用哪个模型就装哪个包。结果就是依赖冲突、密钥散落各处、限流逻辑重复实现、换一个模型要改三四个地方。
当模型数量从个位数涨到几十个、上百个的时候,这套玩法彻底崩了。我统计过一次,光是不同供应商的鉴权方式就有五种:Bearer Token、自定义 Header、签名鉴权、双 Key 组合、还有需要先换临时凭证的。如果每个应用都自己处理这些差异,代码里会充斥着大量与业务无关的适配逻辑。
所以我决定抽一层出来,做一个统一 API 网关。它的核心目标很明确:对上层应用暴露一套统一的接口协议,对下层屏蔽所有模型供应商的差异。应用只需要知道"我要调用某个能力",不需要知道背后是哪个供应商、走什么协议、用什么鉴权。
这个网关要解决的具体问题包括:
- 协议归一:把不同供应商的请求/响应格式统一成一套内部标准格式。
- 鉴权托管:所有密钥集中在网关侧管理,应用侧不再持有任何供应商凭证。
- 路由与降级:同一个能力可以配置多个供应商,主供应商挂了自动切备用。
- 限流与配额:按应用、按模型、按用户维度做速率控制。
- 可观测:统一记录调用量、延迟、错误率、Token 消耗。
适合阅读这篇内容的人:正在做多模型接入的开发者、需要给团队搭建 AI 基础设施的工程师、以及任何被"模型适配"这件事折磨过的人。哪怕你只对接两三个模型,这套思路也能帮你省掉大量重复劳动。
1.2 整体分层设计
我把网关拆成了四层,从下往上依次是:供应商适配层、核心路由层、能力抽象层、接入层。这个分层不是拍脑袋定的,而是根据"变化频率"来划分的——越底层的部分变化越频繁(供应商接口经常调整),越上层越稳定(应用侧协议一旦定好就不该乱动)。
供应商适配层是唯一需要感知具体供应商差异的地方。每个供应商对应一个适配器,适配器负责三件事:把内部标准请求翻译成供应商要求的格式、发起调用、把供应商响应翻译回内部标准格式。新增一个供应商,只需要写一个适配器,其他层完全不用动。
核心路由层负责决策:这次请求该发给谁。它拿到的是"能力标识 + 请求参数",然后根据配置的路由策略(优先级、权重、成本、延迟)选出一个或多个候选供应商。这一层还负责重试、降级、熔断。
能力抽象层是整套设计的灵魂。我把模型能力抽象成了几个稳定的概念,比如"文本生成""向量化""图像理解""语音转写"。应用调用的是能力,不是模型。这样一来,底层换模型对应用完全透明。
接入层对外暴露协议。我同时提供了 OpenAI 兼容接口和一套自定义的 REST 接口。前者是为了让现有工具零改造接入,后者是为了表达一些 OpenAI 协议覆盖不到的能力。
这里有个关键取舍:很多人一上来就想设计一套"完美"的自定义协议,结果发现生态里的工具全都不兼容,最后还得再补一个兼容层。我的建议是,接入层优先兼容主流协议,把自定义能力作为扩展字段挂上去,而不是另起炉灶。
1.3 技术选型背后的考量
语言层面我选了 Go。原因很直接:网关是典型的 IO 密集型服务,需要高并发、低内存占用、快速启动。Go 的 goroutine 模型处理大量并发请求非常合适,而且编译成单二进制部署极其省心。我也考虑过 Node.js,但它在高并发下的内存表现和 CPU 密集场景(比如做响应流式转换)不如 Go 稳。Python 则因为 GIL 和性能问题,不适合做这种高吞吐的中间层。
框架上我没有用重型 Web 框架,而是基于标准库的net/http加一个轻量路由库。网关的核心逻辑是转发和转换,不需要 ORM、不需要模板引擎,引入重框架只会增加心智负担。
配置管理用的是 YAML 文件加环境变量覆盖。为什么不用数据库存配置?因为网关配置的变更频率其实很低,而且需要版本可追溯、可 Review、可回滚。YAML 放进 Git 仓库,配合 CI 做校验和发布,比数据库方案更透明。运行时热加载通过文件监听实现,改配置不用重启。
缓存和限流用 Redis。限流需要跨实例共享计数,本地内存做不到;缓存供应商的临时凭证、模型元信息也用 Redis 比较合适。但要注意,Redis 挂了不能导致网关整体不可用,所以限流和缓存都做了降级策略——Redis 不可用时退化为本地限流、跳过缓存。
2. 核心细节解析与实操要点
2.1 统一请求与响应格式的设计
统一格式的设计是整个项目的基石,设计得好后面一路顺畅,设计得差后面到处打补丁。我踩过的第一个坑就是:一开始把统一格式设计得太贴近某一家供应商,结果接入第二家的时候发现字段对不上,只能加一堆可选字段,最后格式变得又臭又长。
后来我重新设计,原则是只保留跨供应商的共性字段,差异部分放进扩展区。核心请求结构大概是这样:
{ "capability": "text-generation", "model_hint": "fast", "messages": [ {"role": "user", "content": "你好"} ], "params": { "temperature": 0.7, "max_tokens": 1024 }, "extensions": {} }capability是能力标识,这是路由的依据。model_hint是给路由层的提示,比如"我要快的"或"我要便宜的",而不是硬编码具体模型名。messages是对话内容,采用通用的角色-内容结构。params放生成参数。extensions是逃生舱,供应商特有的参数放这里。
响应格式同理,统一成:
{ "id": "req_xxx", "capability": "text-generation", "provider": "provider-a", "model": "model-x", "output": { "text": "...", "finish_reason": "stop" }, "usage": { "input_tokens": 12, "output_tokens": 34 }, "latency_ms": 850 }provider和model字段很重要,方便排查问题和做成本核算。usage统一了 Token 计量口径,不同供应商的计量方式不一样,适配器里要做归一。
实操心得:统一格式一定要预留
extensions和metadata两个自由字段。前者给请求方传供应商特有参数,后者给网关自己塞调试信息。没有这两个字段,你迟早会为了某个供应商的特殊需求去改核心结构。
2.2 供应商适配器的实现模式
适配器我用了接口加注册表的模式。定义一个Provider接口:
type Provider interface { Name() string Capabilities() []string Invoke(ctx context.Context, req *UnifiedRequest) (*UnifiedResponse, error) Stream(ctx context.Context, req *UnifiedRequest) (StreamReader, error) HealthCheck(ctx context.Context) error }每个供应商实现这个接口,然后在初始化时注册到全局注册表。路由层只认接口,不认具体实现。这样新增供应商就是纯增量开发,零侵入。
适配器里最麻烦的是流式响应。不同供应商的流式格式差异极大:有的用 SSE,有的用 chunked JSON,有的在流里夹杂心跳包。我的做法是在适配器内部把各种流统一转换成内部的StreamReader接口,上层只处理标准化的流事件。
另一个坑是错误码映射。供应商 A 返回 429 表示限流,供应商 B 可能返回 200 但 body 里带错误码,供应商 C 用 503。适配器必须把这些都映射成内部统一的错误类型:RateLimited、AuthFailed、ModelOverloaded、InvalidRequest、ProviderError。路由层根据错误类型决定是重试、降级还是直接返回。
func mapError(statusCode int, body []byte) error { switch statusCode { case 401, 403: return ErrAuthFailed case 429: return ErrRateLimited case 500, 502, 503, 504: return ErrProviderError default: // 解析 body 里的错误码 return parseBodyError(body) } }2.3 路由策略与降级机制
路由是网关的大脑。我实现了三种路由策略,可以按能力配置:
优先级路由:给每个供应商配一个优先级,从高到低尝试。适合有明确主备关系的场景。
加权路由:按权重随机分配流量。适合灰度发布或者多供应商分摊成本。
成本优先路由:根据实时价格和预算选择最便宜的可用供应商。适合对成本敏感的场景。
不管用哪种策略,都要配合健康检查和熔断器。健康检查定期探测供应商可用性,熔断器在连续失败达到阈值后暂时摘除该供应商。我用的是滑动窗口统计,窗口内错误率超过 50% 且请求数超过最小样本量就触发熔断,熔断后进入半开状态试探恢复。
降级逻辑是这样的:主供应商调用失败且错误类型可重试(限流、超时、服务端错误),就按路由策略选下一个候选。如果所有候选都失败,返回统一的错误响应,并带上每个供应商的失败原因,方便排查。
注意:降级不是无脑重试。鉴权失败、参数非法这类错误重试没有意义,只会浪费配额。只有限流、超时、服务端错误才值得重试。而且重试要有次数上限和总超时控制,否则一个慢供应商会拖垮整个请求链路。
2.4 密钥管理与安全边界
密钥管理是很多人容易忽视的地方。我见过太多项目把 API Key 硬编码在代码里或者明文放在配置文件里提交到仓库。网关作为密钥的集中持有者,必须把这件事做对。
我的方案是:密钥不落配置文件,而是通过环境变量注入,或者从密钥管理服务动态拉取。配置文件里只写密钥的引用名,比如api_key_ref: provider_a_key,运行时根据引用名去解析真实密钥。这样配置文件可以安全地进仓库,密钥本身走独立的注入通道。
密钥在内存中也要注意:不要打日志、不要在错误信息里回显、不要放进响应头。我专门写了一个脱敏函数,任何要输出密钥的地方都先过一遍脱敏。
func maskKey(key string) string { if len(key) <= 8 { return "****" } return key[:4] + "****" + key[len(key)-4:] }另外,网关对上层应用也要做鉴权。每个应用分配一个网关侧的 Key,网关根据这个 Key 识别应用身份,进而做限流、配额、审计。应用 Key 和供应商 Key 是两套体系,不能混用。
3. 实操过程与核心环节实现
3.1 从零搭建网关服务的完整步骤
先说环境准备。你需要 Go 1.21 以上、Redis 7 以上、以及一个能跑容器的环境(可选,用于本地起 Redis)。项目结构我建议这样组织:
gateway/ cmd/server/main.go internal/ adapter/ # 供应商适配器 router/ # 路由与降级 capability/ # 能力抽象 middleware/ # 鉴权、限流、日志 config/ # 配置加载 configs/ providers.yaml routes.yaml第一步,初始化配置加载模块。用viper或自己写一个简单的 YAML 加载器都行。关键是支持环境变量覆盖,比如GATEWAY_REDIS_ADDR覆盖配置里的 Redis 地址。
第二步,实现适配器注册表。定义一个全局 map,适配器在init()函数里注册自己:
var registry = make(map[string]Provider) func Register(p Provider) { registry[p.Name()] = p } func Get(name string) (Provider, bool) { p, ok := registry[name] return p, ok }第三步,实现路由层。路由层从配置读取每个能力的候选供应商列表和策略,然后按策略选择。核心函数签名大概是Select(capability string, hint string) ([]Provider, error),返回一个有序的候选列表,调用方按顺序尝试。
第四步,实现中间件链。请求进来先过鉴权中间件(校验应用 Key),再过限流中间件(基于 Redis 的令牌桶),再过日志中间件(记录请求元信息),最后进路由处理。
第五步,实现流式转发。这是技术难点。Go 里用http.Flusher把上游的流式响应边读边写回客户端。要注意设置正确的Content-Type和禁用缓冲。
func streamHandler(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", 500) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") reader, err := provider.Stream(ctx, req) if err != nil { /* 处理错误 */ } defer reader.Close() for { event, err := reader.Next() if err == io.EOF { break } if err != nil { /* 处理错误 */ } writeSSE(w, event) flusher.Flush() } }3.2 限流与配额的具体参数计算
限流我用了令牌桶算法,基于 Redis 的 Lua 脚本实现原子操作。为什么用令牌桶而不是固定窗口?因为固定窗口在窗口边界会有突发流量问题,令牌桶能平滑限流。
参数计算是这样的:假设某应用允许每分钟 600 次请求,即平均每秒 10 次。令牌桶的容量设为 10(允许瞬时突发 10 次),补充速率设为 10/秒。这样既能限制平均速率,又能容忍合理的突发。
-- 令牌桶 Lua 脚本 local key = KEYS[1] local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local requested = tonumber(ARGV[4]) local bucket = redis.call('HMGET', key, 'tokens', 'last_refill') local tokens = tonumber(bucket[1]) or capacity local last_refill = tonumber(bucket[2]) or now local elapsed = now - last_refill local refill = elapsed * rate tokens = math.min(capacity, tokens + refill) if tokens >= requested then tokens = tokens - requested redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now) redis.call('EXPIRE', key, 3600) return 1 else redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now) redis.call('EXPIRE', key, 3600) return 0 end配额则是按时间周期累计用量,比如每天 100 万 Token。这个用 Redis 的计数器加过期时间实现,每次调用后异步累加,避免阻塞主流程。
实操心得:限流的粒度要分层。我做了三层——全局层(保护整个网关)、应用层(防止单个应用打满)、供应商层(防止超过供应商配额)。三层都过才放行。只做一层的话,要么保护不了供应商,要么误伤正常应用。
3.3 可观测性的落地实现
可观测性我分了三块:指标、日志、追踪。
指标用 Prometheus 格式暴露。核心指标包括:请求总数(按能力、供应商、状态码分标签)、请求延迟直方图、Token 消耗计数、熔断器状态、限流拒绝数。这些指标通过/metrics端点暴露,Prometheus 定时抓取。
日志用结构化 JSON,每条请求记录:请求 ID、应用 ID、能力、选中的供应商、延迟、Token 用量、错误信息。日志里绝对不能出现密钥和完整请求体(可能含敏感信息),只记录元数据和摘要。
追踪用 OpenTelemetry,给每个请求生成 Trace ID,在调用供应商时创建 Span。这样能清楚看到时间花在哪:是网关内部处理慢,还是供应商响应慢。Trace ID 也会透传给供应商(如果供应商支持),方便端到端排查。
ctx, span := tracer.Start(ctx, "provider.invoke", trace.WithAttributes( attribute.String("provider", p.Name()), attribute.String("capability", req.Capability), )) defer span.End()3.4 配置热加载与灰度发布
配置热加载用文件监听实现。我用的是fsnotify监听配置文件变化,变化后重新解析并原子替换内存中的配置对象。关键是替换要原子,用atomic.Value或者读写锁保护,避免请求读到半新半旧的配置。
灰度发布是路由层的能力。新接入一个供应商时,先给它配很小的权重(比如 5%),观察指标正常后再逐步提高。如果指标异常,把权重调回 0 即可,不用改代码不用重启。
routes: text-generation: strategy: weighted providers: - name: provider-a weight: 95 - name: provider-b weight: 5这套机制让我在接入新供应商时非常从容,先小流量验证,确认稳定再全量,出问题秒回滚。
4. 常见问题与排查技巧实录
4.1 典型问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方法 |
|---|---|---|---|
| 请求全部超时 | 供应商不可达或网络问题 | 检查健康检查状态、抓包看连接 | 触发熔断,切备用供应商 |
| 部分请求 401 | 密钥过期或轮换未同步 | 检查密钥有效期、对比配置 | 更新密钥,加过期预警 |
| 流式响应卡顿 | 缓冲未刷新或上游慢 | 检查 Flusher 调用、看上游延迟 | 确保每 chunk 后 Flush |
| 限流误伤 | 令牌桶参数过小 | 看限流拒绝指标、对比实际 QPS | 调整容量和速率参数 |
| Token 计量不准 | 供应商计量口径差异 | 对比供应商账单和网关统计 | 适配器里做口径归一 |
| 内存持续增长 | 流未关闭或缓存无上限 | 看 goroutine 数和内存曲线 | 确保 defer Close,缓存加 LRU |
4.2 踩过的坑与独家避坑技巧
坑一:连接池配置不当导致端口耗尽。最开始我用默认的 HTTP Client,每个请求新建连接,高并发下大量连接处于 TIME_WAIT 状态,很快就把可用端口耗尽了。后来配置了连接池:MaxIdleConns设为 100,MaxIdleConnsPerHost设为 20,IdleConnTimeout设为 90 秒。这个问题在压测时才会暴露,平时低流量根本发现不了。
坑二:超时设置层层叠加导致实际超时远超预期。网关有总超时,HTTP Client 有超时,供应商自己也有超时。如果总超时 30 秒,Client 超时 30 秒,那实际可能等到 60 秒才返回。我的做法是总超时 30 秒,Client 超时设 25 秒,留 5 秒给网关内部处理和响应写回。每一层都要比上一层短。
坑三:重试放大流量打垮供应商。有一次供应商限流,网关触发重试,结果重试的流量又打到同一个供应商,雪上加霜。后来我加了重试退避(指数退避 + 抖动)和重试预算(单个请求最多重试 2 次,全局重试率超过阈值就停止重试)。
坑四:配置热加载时的竞态。早期版本热加载时直接替换 map,结果并发请求读到正在被写的 map,直接 panic。后来改用sync.Map或者整体替换atomic.Value,彻底解决。
坑五:日志里泄露了敏感信息。有一次排查问题,发现日志里把完整的请求头打出来了,里面包含应用 Key。虽然是自己内部日志,但也是隐患。后来加了统一的脱敏中间件,所有出站日志都过一遍。
独家技巧:给每个供应商适配器加一个"影子模式"。新供应商接入时,先让它在影子模式下接收真实请求的副本,但不返回结果,只记录它的响应和延迟。对比一段时间后,确认它的表现符合预期,再正式切流量。这样能把接入风险降到最低。
4.3 性能调优的实测数据
压测环境:4 核 8G 的容器,Redis 独立部署。测试场景是纯转发(不做复杂转换)的文本生成请求。
| 并发数 | QPS | P99 延迟 | CPU 使用率 | 内存占用 |
|---|---|---|---|---|
| 50 | 1200 | 45ms | 35% | 120MB |
| 200 | 3800 | 78ms | 68% | 180MB |
| 500 | 5200 | 156ms | 89% | 260MB |
| 1000 | 5400 | 320ms | 95% | 340MB |
从数据看,500 并发是个拐点,超过后 QPS 增长放缓而延迟快速上升。所以生产环境我建议单实例控制在 500 并发以内,靠水平扩展扛更高流量。网关本身是无状态的(状态都在 Redis),加实例很轻松。
内存占用主要来自连接池和流式响应的缓冲。如果发现内存异常增长,优先排查是否有流没关闭、连接没归还。
4.4 扩展新供应商的标准流程
最后分享一下我接入新供应商的标准流程,这套流程跑了几十次,已经很顺了:
- 调研接口:拿到供应商文档,确认它的鉴权方式、请求格式、响应格式、流式支持、错误码定义。
- 写适配器:实现
Provider接口,重点处理格式转换和错误映射。先不接流式,把同步调用跑通。 - 本地验证:用真实密钥在本地跑通同步和流式调用,确认响应格式正确。
- 影子模式:部署到测试环境,开影子模式,观察一段时间。
- 小流量灰度:配 5% 权重,观察指标。
- 逐步放量:指标正常就逐步提高权重,直到全量。
- 补充监控:给这个供应商加专属的监控面板和告警规则。
整个过程快的话半天,慢的话两三天,取决于供应商接口的复杂度和稳定性。有了这套流程,接入新供应商不再是令人头疼的事,而是一个可预期、可复现的标准动作。
这套网关跑到现在,支撑了 200 多个模型的统一接入,日均处理请求量在千万级别。回头看,最值得投入的不是某个具体功能,而是那套"能力抽象 + 适配器 + 路由"的架构骨架。骨架搭对了,后面加什么都是顺水推舟;骨架搭错了,每加一个功能都是在还债。