
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缓存加 LRU4.2 踩过的坑与独家避坑技巧坑一连接池配置不当导致端口耗尽。最开始我用默认的 HTTP Client每个请求新建连接高并发下大量连接处于 TIME_WAIT 状态很快就把可用端口耗尽了。后来配置了连接池MaxIdleConns设为 100MaxIdleConnsPerHost设为 20IdleConnTimeout设为 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 独立部署。测试场景是纯转发不做复杂转换的文本生成请求。并发数QPSP99 延迟CPU 使用率内存占用50120045ms35%120MB200380078ms68%180MB5005200156ms89%260MB10005400320ms95%340MB从数据看500 并发是个拐点超过后 QPS 增长放缓而延迟快速上升。所以生产环境我建议单实例控制在 500 并发以内靠水平扩展扛更高流量。网关本身是无状态的状态都在 Redis加实例很轻松。内存占用主要来自连接池和流式响应的缓冲。如果发现内存异常增长优先排查是否有流没关闭、连接没归还。4.4 扩展新供应商的标准流程最后分享一下我接入新供应商的标准流程这套流程跑了几十次已经很顺了调研接口拿到供应商文档确认它的鉴权方式、请求格式、响应格式、流式支持、错误码定义。写适配器实现Provider接口重点处理格式转换和错误映射。先不接流式把同步调用跑通。本地验证用真实密钥在本地跑通同步和流式调用确认响应格式正确。影子模式部署到测试环境开影子模式观察一段时间。小流量灰度配 5% 权重观察指标。逐步放量指标正常就逐步提高权重直到全量。补充监控给这个供应商加专属的监控面板和告警规则。整个过程快的话半天慢的话两三天取决于供应商接口的复杂度和稳定性。有了这套流程接入新供应商不再是令人头疼的事而是一个可预期、可复现的标准动作。这套网关跑到现在支撑了 200 多个模型的统一接入日均处理请求量在千万级别。回头看最值得投入的不是某个具体功能而是那套能力抽象 适配器 路由的架构骨架。骨架搭对了后面加什么都是顺水推舟骨架搭错了每加一个功能都是在还债。