自己动手写一个 Node.js 的 AI 中转代理,难点从来不在「怎么转发一个请求」——几十行 httpx/fetch 就能跑通。真正卡住人的是上线之后:上游迟迟不返回要不要断、并发一高就 429 怎么办、某条线路挂了怎么自动换、出了慢请求事后怎么复盘、改完代码怎么确认没退化。这篇按工程落地的顺序拆一遍,每节给判断标准和动作清单,不写死数字,涉及具体口径一律去站内对应页面核对。
自己动手写一个 Node.js 的 AI 中转代理,难点从来不在「怎么转发一个请求」——几十行 fetch 就能跑通。真正卡住人的是上线之后那几件事:上游迟迟不返回要不要断、并发一高就 429 怎么办、某条线路挂了怎么自动换、出了慢请求事后怎么复盘、改完代码怎么确认没退化。
这篇按工程落地的顺序拆一遍,每节给判断标准和动作清单,不写死任何数字。凡是涉及具体延迟口径、线路表现、额度规则这类会变的东西,一律去站内对应页面看当时的数字,别照抄本文里的任何示例。
请求转发与流式回传:Node.js 这一步最容易踩什么
流式是第一个坑。用 fetch 拿到的 response.body 是一支可读流,最省事的做法是直接 pipe 给 res,边收边吐。如果你先 await response.text() 再统一返回,中转层就把流式体验整个吃掉了——客户端要等到最后一个 token 才看到第一个字。另外记得在响应头里关掉反向代理的缓冲(比如加一个禁用缓冲的 header),不然你自己是流式,中间那层却在攒包。
纯 Node 的 http 模块和 fetch 的流类型并不通用,需要用 Readable.fromWeb() 做一次转换。转换之后必须监听客户端断开事件,一断就主动 abort 上游请求。这一步很多人漏掉,后果是客户端早走了,上游还在生成、还在计费,你收不到结果也停不下来。转发前先把请求体读全再改写(换模型名、剥掉不兼容字段),别在流式过程中动它;上游的 status 和 content-type 要透传,不要一律返回 200。
- 流式路径:response.body 直接 pipe 到 res,禁止先整体 text() 再返回
- 响应头:显式关闭中间层缓冲,避免自己流式、别人攒包
- 流类型转换:http 模块与 fetch 流之间用 Readable.fromWeb() 桥接
- 断开处理:监听客户端 close,触发上游 abort,防止空跑计费
- 请求改写:读全请求体后再改,status 与 content-type 原样透传
超时分层:一个总超时不够用
只设一个总超时,在流式场景下几乎没有意义:长回答会被误杀,而真正的卡死又发现不了。至少要分三层——建连超时、首字节超时、空闲间隔超时。第三层是关键,它管的不是「整个请求多久结束」,而是「多久没收到新 chunk」,只要还在吐字就不该断。
分档的思路可以这样:对话类请求对首字节更敏感,卡太久就该换线路;生成长文本对空闲间隔更宽容,只要 chunk 还在来就让它继续。具体秒数不要抄别人的博客,先去站内测速页面量出你自己到各条线路的延迟基线,再往上留一段余量。超时发生时要区分两件事的顺序:先给客户端一个结构明确的错误(带自建错误码),再 abort 上游,否则客户端拿到半截 JSON 解析失败,你排查时连错误类型都分不清。
| 超时类型 | 管什么 | 典型处理 |
|---|---|---|
| 建连超时 | TCP/TLS 建不起来 | 快速失败,计入线路健康度 |
| 首字节超时 | 上游接单后迟迟不出字 | 视线路质量直接切换 |
| 空闲间隔超时 | 流中途长时间没有新 chunk | 断开并返回结构化错误 |
| 整体上限 | 兜底,防止极端挂死 | 仅作为最后一道保险 |
并发与退避:别把上游打爆,也别让自己排队排死
并发闸门要按「上游通道」维度放,而不是全局一个计数器。同一条线路一个在途计数,不同线路互不挤占;某条满了,要么排队要么快速失败,取决于你对首字延迟的容忍度。全局大闸门的问题是:一条慢线路能把所有线路的请求一起堵住。
遇到 429 别立刻重试,那只会把限流打得更死。用指数退避加随机抖动,并且先判断是哪种限流:按次限流的,等一等能过;按并发限流的,得先把在途数降下来才有用。连续失败到阈值就把这条线路临时摘掉,过一段再放回来试探。排队本身要有上限和最长等待时间,超了就直接降级或返回可读错误。
队列不是免费的:它把「现在失败」换成了「等一会儿失败」,只有在等待确实有价值时才划算。
降级与路由:主线路挂了怎么换,换了以后怎么不翻车
降级分三个层级:同厂商换个模型、换一条同协议的其他线路、换到不同协议需要适配层的线路。前两级成本低、可以自动做;第三级要先确认字段能不能对齐,动手前建议先看站内的协议与 base-url 说明,确认请求体、鉴权头、流式格式能不能对得上,别写到一半发现响应结构完全不同。
切换必须是「可观测的显式动作」——每次降级都记一条日志,写明触发的是哪一级、因为什么原因。不然线上时快时慢,你只会觉得玄学。还有一条判断标准:不是所有错误都该降级。参数错、鉴权错这类,换线路也一样失败,只有超时、连接重置、5xx、限流这几类才值得换源,否则你只是把同一个错放大几倍。
| 错误类型 | 该不该降级 | 原因 |
|---|---|---|
| 超时 / 连接重置 | 应该 | 上游链路问题,换源可能恢复 |
| 5xx 服务端错误 | 应该 | 上游自身故障,换源有意义 |
| 429 限流 | 先退避再考虑 | 等一等可能就过了 |
| 401 / 403 鉴权错 | 不应该 | 换了线路同样失败 |
| 400 参数错 | 不应该 | 请求本身有问题,换源无用 |
日志与自测:让一次慢请求和一次改动能被复盘
最小日志字段集建议固定成这样:请求 id、上游线路、模型名、开始/首字节/结束三个时间点、输入输出 token 数、上游状态码、是否降级、重试次数。有了这几个字段,事后拆「慢在哪」和「贵在哪」都能定位到具体环节,而不是看着一条总耗时干瞪眼。
- 验证流式是真流式看首字节时间和结束时间的差距,如果几乎一样,说明中间某层把流攒起来了,回去查 pipe 和缓冲头。
- 验证断开传播客户端主动断开,确认上游请求被 abort,日志里能看到 abort 记录。
- 验证退避逻辑模拟上游返回限流状态,确认走了退避而不是立刻重试,且重试次数有上限。
- 验证降级只降一次主线路故障时确认切换到备用且只切一次,不会在备用上也失败后反复横跳。
- 验证错误结构统一所有失败路径返回同样的错误 JSON 结构,客户端不需要分支解析。
这套自测用例固定下来,每次改完代码跑一遍,比多写两百行功能代码管用。回归要靠一组固定用例,不要靠手感——手感这东西,换个线路、换个时间段就不准了。涉及线路可信度、价格波动的判断,回头到站内的中转站检测和价格追踪页面核对当时的数字,别凭一次测试下结论。
下一步代码跑通之后,线路选得对不对比写得漂不漂亮更影响体验。去站内中转站导航看各家当前的可信度与实测表现,再决定你的默认路由写哪条。→常见问题
中转代理该用纯 http 模块还是 Express/Fastify 这类框架?
看你要不要自己管流。纯 http 模块对流的控制最直接,但路由、解析、错误处理都要手写;框架用起来快,但要确认它不会在中间把响应缓冲起来、也不会对 body 做整体解析。折中做法是框架管路由和中间件,流式那一段用底层 res 直接写。关键判断标准只有一个:首字节能不能第一时间出去。
为什么本地测流式正常,上线就变成一次性返回?
多半是中间还有一层在缓冲:Nginx、CDN 或某些云网关默认会攒够一定大小再发。排查顺序是先直连 Node 端口确认流是通的,再逐层加上代理复测,哪一层加完变慢就锁定那一层,然后针对它关缓冲或开透传。别一上来就改代码,先确认问题在哪一层。
并发一高就被上游限流,是不是只能加钱?
先分清你被限的是「每秒请求数」还是「同时在途数」。前者靠错峰和排队能缓解,后者必须先压住在途并发再谈加量。可以在中转层给每条线路设一个并发上限和排队队列,超时直接返回可读错误而不是硬压。要不要升级额度,去站内对应套餐与价格页面按当时数字算一遍再决定。