上周有个做内部工具的朋友来找我看代码,说他的小脚本想换个模型试试,结果一改就得动五个文件。我打开一看:三个模块里各写了一套请求逻辑,超时时间设置不同,错误处理不同,返回结构还要分别解析,连重试都是各写各的。他没有用错什么,只是把"调用模型服务"这件事当成了业务代码在写。
这就是我做 AI-PROXY 的起点。它不是一个花哨的东西:一个跑在自己机器或内网服务器上的网关服务,对外只暴露一套统一的 API 接口,对内按配置把请求转发给不同的模型服务提供方,顺手把密钥管理、限流、重试、日志、流式输出这些都收拢到一处。它解决的是"多家模型服务接口各自一套规矩"带来的请求处理麻烦——地址不一样、鉴权头不一样、参数名不一样、错误码各说各话、流式格式还有细微差别。如果你手上同时挂着两三家服务、或者项目里需要随时切模型对比效果、或者团队里几个人共用几个密钥,那这个东西基本就是为你准备的。哪怕你只是想看看一个网关项目内部是怎么组织的,下面这些结构拆解和踩坑记录也能直接拿去用。
1. 这个网关到底解决什么问题:从三份重复的请求代码说起
1.1 客户端直连厂商接口的四种典型麻烦
先说清楚痛点,不然很容易把网关做成一个"为了架构而架构"的中间层。我在几个小项目里反复遇到的是这四类问题,几乎每一类都让我改过不止一次代码。
第一类是配置漂移。项目 A 里超时设 30 秒,项目 B 里设 60 秒,项目 C 里压根没设。等到某天某家服务响应变慢,三个项目的表现完全不同,排查的时候你得挨个翻配置文件。第二类是鉴权散落。密钥被写在前端环境变量、后端配置、定时任务脚本三处,换一次密钥要发三次版。第三类是错误语义丢失。上游返回一个 429,有的地方当成致命错误直接抛给用户,有的地方默默吞掉,有的地方重试三次反而把额度打得更快。第四类是协议细节耦合。流式输出的解析逻辑散落在每个调用点,上游某个字段改名,你要改 N 个地方。
把这些问题归拢一下会发现,它们都不是业务逻辑,而是接入层的共性工程问题。共性问题的标准解法就是抽一层出来。网关站的位置很清晰:客户端只认一套接口,网关负责翻译成各家能听懂的请求,再把各家的回应翻译回统一格式。
1.2 网关在架构里该站的位置
我在设计的时候给自己定了一条硬规矩:网关不碰业务。它不知道什么是"客服话术"、什么是"代码补全",它只认"一次模型调用请求"。这条规矩的好处是后面加功能时不会失控——你想加一个"按用户限额"的能力,那是网关的事;你想加一个"提示词模板",那就该放到业务层去。
具体的分层是这样:最外面是接入层,做鉴权、请求体大小限制、请求 ID 注入;中间是路由层,根据 model 字段里的厂商前缀挑一个适配器出来;再往里是适配层,负责请求构造、响应解析、错误映射;最里面是传输层,管连接池、超时、重试、流式管道。
这里有一个很多人会忽略的设计决策:model 字段要不要带厂商前缀。我一开始没带,结果出现过一个尴尬情况——两家厂商都有一个叫chat-standard的模型名,路由直接冲突。后来改成厂商名/模型名的格式,比如provider-a/chat-standard,路由逻辑一行就写完了,而且客户端一眼能看出自己调的是谁。代价是客户端要改一下字符串,但这个代价换来的是零歧义。
还有一点值得说:网关要不要做响应缓存。我的结论是默认不做。模型调用本身有随机性,缓存命中率和业务语义都不好把握,而且很容易踩到"用户 A 的问题被返回给了用户 B"这种事故。要做也只能做显式的、带缓存键的调用方主动请求缓存,绝不能在网关层偷偷做。
2. 抹平厂商差异:统一协议与适配器分层
2.1 请求侧的归一化字段设计
统一协议这块,我的原则是只归一化真正通用的字段,其余原样透传。原因是各家都在快速加新参数,你如果硬要穷举,永远追不上。我的做法是把字段分成三档。
| 字段档位 | 典型字段 | 处理方式 |
|---|---|---|
| 核心档 | model、messages、stream、temperature、max_tokens | 强校验,类型不对直接拒绝,不进适配层 |
| 可选档 | top_p、stop、seed、presence_penalty | 校验类型,厂商不支持时静默丢弃并打一条日志 |
| 扩展档 | 各家私有参数、工具调用配置 | 放在extra对象里,由适配器自己决定怎么用 |
扩展档的设计救了我很多次。某家厂商临时加了一个控制输出详细程度的私有参数,我不需要改核心协议,也不需要改其他适配器,只要在那个适配器的构造逻辑里读一下extra就行。客户端那边是多传一个字段,不影响已有调用。
messages的结构也需要归一。统一成role+content两部分,role只允许system、user、assistant三种,content在纯文本场景下是字符串,在多模态场景下允许是数组。这里有个小坑:不同厂商对"系统提示放哪"的处理不一样,有的有独立字段,有的就是 messages 里的一条。适配器要负责转换,不能让客户端感知差异。
另外我加了一个能力声明表,每个适配器在初始化时声明自己支持什么:是否支持流式、是否支持工具调用、最大上下文长度、是否支持图片输入。路由层拿到请求后先查这张表,不支持的能力直接返回一个明确的错误,而不是把请求发出去等上游报一个看不懂的错。
2.2 响应侧最容易漏掉的三类字段
请求侧归一相对容易,响应侧才是真正麻烦的地方。我列一下自己踩过的三类字段。
第一类是用量统计。输入 token、输出 token、总 token,三家给出的字段名和嵌套层级可能都不一样,有的在顶层,有的在usage里,有的流式响应里根本不返回、只在最后一个事件里给一个汇总。我的处理是适配器统一输出一个usage结构,缺失字段填null而不是 0,因为 0 会被下游误当成"确实没消耗"。这个区别在计费对账的时候特别重要。
第二类是结束原因。一条回答为什么结束,可能是正常结束、达到长度上限、被内容策略拦截、调用了工具。这三种在业务侧的处理完全不同:长度上限可能需要续写,被拦截需要换提示词,工具调用需要走下一轮。适配器必须把它映射成有限的几个枚举值,绝不能把上游的原始字符串直接透出去。
第三类是模型版本标识。上游实际使用的模型版本可能和你请求的名字不完全一致,有的会在响应里返回一个更精确的版本号。我把它保留在响应的model_version字段里,日志和排查的时候非常有用——有一次同一个模型名的效果突然变差,就是靠这个字段发现上游悄悄换了版本。
2.3 错误码映射表:把十几种异常压缩成五个
这是我认为网关最有价值的部分之一,也是最容易被做草率的部分。上游的错误码少则七八种、多则十几种,如果原样透传给客户端,客户端就得为每一家写一套判断。我把它压缩成五个:
| 统一错误码 | 含义 | 客户端建议动作 |
|---|---|---|
auth_failed | 密钥无效、权限不足 | 不要重试,检查配置 |
rate_limited | 触发速率或并发限制 | 退避后重试,或换密钥 |
invalid_request | 参数不合法、上下文超长 | 不要重试,修参数 |
upstream_error | 上游 5xx 或连接失败 | 可重试,注意次数 |
timeout | 连接、首字节或整体超时 | 可重试,但要看是否已产生输出 |
映射的时候有个细节要特别注意:不要根据 HTTP 状态码直接映射。我一开始就是这么干的,结果发现某家厂商把"额度用尽"也返回 429,和"速率超限"混在一起。这两者的处理策略完全相反——速率超限等一会儿就好,额度用尽等多久都没用。后来我改成先看响应体里的具体错误标识,再看状态码,才把这两类分开。适配器里的映射函数看起来啰嗦,但它是整个网关里最值得写详细的一个函数。
3. 密钥池、限流与熔断:网关里真正扛流量的那几段
3.1 多密钥轮转与冷却机制
一个人用的时候密钥管理不是什么问题,但只要是多个人共用,或者单密钥的速率上限不够用,你就需要一个密钥池。我的实现是给每个密钥维护一个状态机,状态有三种:active、cooling、disabled。
active是正常可用;cooling是刚刚失败过,暂时不退避;disabled是连续失败太多次或者被手动关掉。状态流转的规则是这样的:失败一次进入cooling,冷却时间从 5 秒起步,每失败一次翻倍,上限 5 分钟;冷却期结束自动回到active,但失败计数不清零,只有成功一次才清零;连续失败超过阈值(我设的是 8 次)就进disabled,需要人工或者定时任务恢复。
选密钥的策略我用的是加权轮询 + 冷却跳过。权重可以手动配,也可以在运行中根据成功率动态调整,但我建议初期别搞动态权重,先把静态权重跑稳。有一件事必须做:同一个请求内的重试要换密钥。如果第一次用密钥 1 撞了速率限制,重试还用密钥 1,那基本等于白等。我的做法是重试时把当前密钥标记为cooling,然后重新走一遍选择逻辑。
注意:密钥池里的密钥本身要脱敏存储。日志里只打后四位,配置接口返回时做掩码,别指望"反正内网"这种理由。
3.2 令牌桶和并发闸门
限流这件事要分两个维度看:速率和并发。速率用令牌桶,并发用信号量,两个都得有。
只做速率不做并发,会遇到一个典型问题:上游的速率限制是按请求数算的,但你这边每个请求都是流式的、要占用连接几十秒,速率没超,连接数先爆了。只做并发不做速率,会遇到另一种问题:短请求打得太快,一秒钟几百个,上游直接给你返回一片 429。
令牌桶的实现没什么特别的,重点是桶的粒度。我按"每个密钥一个桶"来分,而不是全局一个桶。原因是速率限制本质上是上游按密钥算的,你在网关层搞一个全局桶,反而会限制住多密钥的吞吐能力。桶的容量设成和速率一个量级就行,比如 60 次/分钟,桶容量 60,填充速率 1 个/秒。
并发闸门用带缓冲的 channel 实现,获取时非阻塞尝试,拿不到就返回rate_limited而不是排队等待。这里我纠结过要不要排队,最后的结论是不要——排队会让客户端看到一个莫名其妙的长时间挂起,不如快速失败让它自己做退避。排队时长一旦不好控制,反而容易堆积请求把内存吃满。
3.3 重试的边界:哪些能重试,哪些重试就是灾难
重试是最容易写出事故的地方。我给自己列了一份白名单,只有这几类才重试:
- 连接建立失败(TCP 层失败,请求肯定没到上游)
- 上游返回 5xx(但要排除明确表示"请求已受理"的情况)
- 返回
rate_limited(换密钥重试,不是原密钥重试)
明确不重试的:invalid_request(参数问题重试一百次也一样)、auth_failed(密钥问题是配置问题)、以及任何已经开始输出内容的流式请求。最后这条特别重要——流式响应一旦向客户端吐出第一个字符,你就不可能"重来一次"了,因为客户端已经收到一半内容。这种情况下只能把连接关掉,让客户端自己决定怎么处理。
还有一个更隐蔽的问题:重试导致的重复计费。某些情况下上游其实已经处理了请求,只是响应在路上丢了,你重试一次就产生了两份消耗。这个没法完全避免,但可以通过两个手段降低风险:一是限制最大重试次数(我设成 2),二是把重试次数和请求 ID 一起打进日志,事后对账能算清楚多消耗了多少。
4. 流式响应转发:SSE 链路上那些不明显的断点
4.1 chunk 边界与缓冲
流式转发是网关里最容易"看起来能跑、实际上到处是坑"的部分。第一个坑是缓冲。你本地直接跑网关,测试一切正常;一旦前面挂了一层反向代理,客户端就会看到"等十几秒,然后一次性吐出全部内容"。原因是代理默认会缓冲响应体。解决方式是在响应头里加上关闭缓冲的指令,同时在代理配置里也关掉。
location /v1/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_buffering off; proxy_cache off; chunked_transfer_encoding on; proxy_read_timeout 300s; }第二个坑是chunk 边界不保证按事件切分。你从上游读到的一段字节,可能包含半个事件、一个完整事件、或者一个半事件。我一开始天真地按"读到就转发",结果客户端偶尔会解析失败。正确做法是维护一个缓冲区,按事件分隔符切分,切出来的完整事件才转发,剩下的留在缓冲区里等下一段数据。这个逻辑写起来不难,但一定要写,而且要写单元测试,专门测试"半个事件"这种情况。
第三个坑是结束标记。流式响应结束不是一个 TCP 关闭就完事,通常有一个明确的结束事件。适配器要把上游的结束形式统一成一种,并且在结束事件里带上用量统计。我有一次忘了处理某个厂商"结束事件之后再补一个空事件"的行为,导致客户端一直等不到结束,卡在那里直到超时。
4.2 客户端断开之后
这是我踩得最深的一个坑。用户点了"停止生成",浏览器把连接关了,但网关这边如果没感知到,它会继续从上游读数据、继续在内存里拼接、继续占着并发名额,直到上游自己结束。一个人这么干没事,十个人这么干,网关的并发名额就全被"已放弃的请求"占满了。
解决的核心是把客户端的生命周期信号一路传到上游请求。具体做法是用上下文传递取消信号,客户端断开时上下文被取消,上游请求跟着取消,defer里释放并发名额和清理缓冲区。这里有一个必须检查的点:每个获取资源的地方都要有对应的释放,包括信号量、缓冲区、连接。我第一版的泄漏就是因为一个错误分支里提前 return 了,把释放语句跳过了。
还有一种情况是客户端没断开,但是不读数据了。这种在写响应的时候会阻塞,阻塞久了就变成僵尸连接。所以写响应也要设超时,不能无限等。
4.3 超时分层
流式场景下,一个笼统的"总超时"是不够用的。我把它分成四层:
| 超时类型 | 典型值 | 作用 |
|---|---|---|
| 连接超时 | 5s | 建立到上游的 TCP/TLS 连接 |
| 首字节超时 | 30s | 从发出请求到收到第一个数据字节 |
| 事件间隔超时 | 60s | 两个流式事件之间的最大间隔 |
| 整体超时 | 300s | 整个请求从开始到结束的上限 |
为什么要分这么细?因为它们的失败含义完全不同。连接超时说明网络或上游不可达,可以直接重试;首字节超时说明上游接受了请求但迟迟不开始生成,可能是排队,重试要谨慎;事件间隔超时说明生成到一半卡住了,这时候前面积累的内容已经发给客户端了,只能中断;整体超时是兜底,防止极长请求把资源一直占着。
我见过有人只设整体超时,设在 60 秒。结果长文本生成刚到一半就被切断,用户看到的是半截回答加一个错误。分层之后这个问题就没了。
5. 部署与观测:让它能长期跑着
5.1 单二进制与容器两种落地方式
部署上我提供了两条路,因为使用场景差别挺大。个人用和小团队用,我推荐单二进制:编译出来一个文件,配一个 YAML,./ai-proxy --config config.yaml就起来了。好处是没有依赖、没有容器运行时开销、出问题直接看标准输出。配置热加载我做了个简化版——监听配置文件变更,重新解析,能热更新的字段(密钥、限流参数)立即生效,不能热更新的(监听端口)打一条警告让你重启。
团队共用就得用容器了。镜像我用了两阶段构建,最终镜像控制在一个很小的体积。这里有个细节:容器里的时区和日志时间戳很容易出问题,我在 Dockerfile 里显式设了时区,并且在日志里统一用带时区的 ISO 格式,不然排查跨时区问题会疯掉。
健康检查接口也要有,而且要区分"进程活着"和"依赖可用"。我的/healthz只检查进程本身,永远返回 200;/readyz会检查配置文件是否加载成功、密钥池是否至少有一个可用密钥。这两个分开之后,编排系统不会因为某个上游暂时不可用就把整个实例重启掉。
配置这块我给一个最小可用示例,YAML 里的结构大致长这样:
listen: ":8080" auth: client_keys: - name: "team-a" key: "sk-local-xxxxxxxx" rate_limit: 120 concurrency: 8 providers: - name: "provider-a" base_url: "https://api.provider-a.example/v1" adapter: "openai_compatible" keys: - "sk-a-1" - "sk-a-2" timeout: connect: 5s first_byte: 30s idle: 60s total: 300s - name: "provider-b" base_url: "https://api.provider-b.example/v1" adapter: "custom_b" keys: - "sk-b-1"注意auth.client_keys和providers[].keys是两层完全不同的东西:前者是你的调用方用来访问网关的凭证,后者是网关用来访问上游的凭证。这两层千万不要混在一起,我见过有项目把上游密钥直接发给客户端"省事",那等于把密钥公开了。
5.2 请求 ID 串联与日志脱敏
网关是排查问题的枢纽,日志质量直接决定你半夜被叫起来之后要花多久。我做的第一件事是请求 ID 全链路透传:客户端可以带一个,不带就网关生成一个,放进响应头,同时打进所有日志。有了这个 ID,从"用户说卡住了"到"找到那次上游调用"只需要一次检索。
日志内容我分了三类:接入日志(谁调的、调了哪个模型、多大请求体)、上游日志(打到哪个厂商、用的哪个密钥、耗时多少)、结果日志(状态码、统一错误码、token 用量)。三类日志共用同一个请求 ID,但分开输出,方便按需检索。
脱敏是硬性要求。密钥只留后四位,请求体里的敏感字段(用户标识、自定义头)默认不记录全量,只在调试开关打开时记录,而且开关打开时会在日志里打一条显眼的警告。错误信息里也要过滤,上游返回的错误有时候会带上完整请求,直接打到日志里就等于泄漏。
还有一个实践中的小技巧:在第一版就加上耗时分解。连接耗时、首字节耗时、总耗时三个数,单独看没感觉,放在一起对比就能看出很多问题——首字节慢但总耗时正常,说明是排队;首字节正常但总耗时特别长,说明是生成慢或者事件间隔大。
5.3 一次线上延迟抖动的排查链路
说个真实的过程。某天开始,网关的 P95 延迟从 1.2 秒涨到 4 秒多,但错误率没变,上游也没报故障。我是这么一路查下来的。
第一步看耗时分解。数据显示首字节耗时涨了,连接耗时没变。说明请求发出去了,上游迟迟不开始回。第二步按厂商维度切分,发现只有一家涨了,另外两家正常。第三步看密钥维度,发现这家下面有两个密钥,其中一个的延迟明显更高。第四步单独拿这个密钥直接测,确认延迟确实高,另一个密钥正常。
到这一步结论清楚了:是单个密钥的通道质量有问题,不是代码问题。处理方式是把那个密钥手动标记成低权重,观察一段时间,后来就下线换掉了。整个过程从看日志到定位大概十几分钟,靠的就是前面那三个维度的分解——请求 ID、厂商、密钥。如果日志只记了"请求耗时 4 秒",这个排查可能要花一整天。
提示:把"按厂商"和"按密钥"的耗时分布做成两个可以直接查的维度,成本很低,收益极高。这件事我在第二版才补上,后悔没在第一版就做。
6. 作为第一个开源项目,我在维护上学到的东西
6.1 README 决定别人愿不愿意试
作为第一个开源项目,我一开始以为重点在代码质量,后来发现第一道门槛其实是 README。我最初的 README 写了一堆架构图和技术选型理由,结果收到的第一个 issue 是"这东西怎么跑起来"。那一刻我才意识到,来看的人根本还没到关心架构的程度,他们只想知道三件事:这是什么、怎么在五分钟内跑起来、跑起来之后怎么验证它真的在工作。
后来我重写了 README,结构变成:一句话说明用途 → 一个最小的可运行示例(含配置和验证命令)→ 常见场景(切模型、加密钥、看日志)→ 才轮到架构说明。重写之后,重复性的"怎么用"问题少了非常多。
另一个收获是关于示例配置。我提供了一个开箱能跑的配置,把密钥用明显的占位符标出来,并且在启动时检测占位符是否还在,如果在就打一条显眼的提示。这个小小的检查省掉了很多"为什么启动没报错但请求全失败"的困惑。
6.2 issue 里被问最多的三个问题
维护一段时间之后,问题明显收敛到三个,我把它们都写进了文档的显著位置。
第一个是流式不流。九成情况是前面挂了反向代理没关缓冲,剩下的一成是自己代码里手动调了刷新但被前面的中间件缓冲掉了。这个问题我在文档里直接给了一段可以照抄的代理配置,比解释原理有效得多。
第二个是地址拼接出现双斜杠或者少一层路径。配置里的base_url结尾带不带斜杠,拼接出来的路径就差一层。我的处理是在适配器里做规范化:统一去掉结尾斜杠,再拼固定路径。文档里也明确写了"结尾不要带斜杠"。
第三个是超时到底该设多少。这个问题没有标准答案,需要看场景。我在文档里给的是一张推荐表,按"短对话""长文生成""工具调用"三种场景分别给了一组值,并且注明"首字节超时是最需要调的参数",因为大部分人遇到的"卡住"其实都是首字节超时设得太短或者太长。
写到这里,我个人在维护这个项目过程中体会最深的一点是:网关这类基础设施,做得好的标志不是功能多,而是出问题的时候你能三分钟说清楚"哪一段慢了、为什么慢"。代码层面能优化的东西其实有限,真正省时间的是那些提前埋好的观测点和那些写进文档里的"已知坑"。这个项目后面我打算继续补的,是更细的按调用方维度的用量统计,以及一个能直接在浏览器里发请求试一下的调试页面——不过那得等我把手上这堆 issue 先清完。