编辑
2026-08-11
默认
00
请注意,本文编写于 46 天前,最后修改于 46 天前,其中某些信息可能已经过时。

目录

我把自己家的 LLM 网关,拆了重造

我把自己家的 LLM 网关,拆了重造

故事是这样的。

前天晚上十一点多,我盯着屏幕上那个 404,一时间无语凝噎。。。

model_not_found。

火山方舟的 Agent plan 模型,在 /v1/chat/completions 上,找不到自己。

可它明明就在那里。六个模型,好好的,躺在渠道列表里,测试的时候 responses 接口全通,怎么换个端点就翻脸不认人了。

我寻思了一下,没寻思明白。

大半夜的,我又不想承认是自己写的代码出了问题,于是把责任先推给文档,翻来覆去看了三遍火山方舟的接入文档,越看越觉得,这文档写的什么玩意,关键信息全藏在犄角旮旯里。

然后我冷静下来,承认现实。代码是我写的,锅大概率也是我的。

这事,还得从头讲起。

我家这台机器上,现在跑着九个 AI Agent,一个视频分析服务,一条 AI 成片流水线,一个 Dify,一个 Open WebUI。

它们全都要调大模型。

而大模型,散落在四个地方。本机 Ollama 上挂着 qwen3.5:9b、qwen3.5:4b 和向量模型 bge-m3,云端有 DeepSeek 官方,有 SiliconFlow,还有火山方舟的 Agent 计划,六个模型,kimi、glm、doubao、deepseek 全都有。

你说巧不巧,四家供应商,四种不同的调用格式,四种不同的计费方式,四种不同的限流脾气。

如果每个服务都自己去对接这四家,这台机器上就会长出四九三十六条又臭又长的调用链,每一条都要自己写重试,写降级,写密钥管理,写用量统计。

想想就头大。

更麻烦的是,这些服务的模型偏好还不一样。有的适合跑视觉分析,有的适合写代码,有的便宜适合批量跑,有的要本地部署图个数据不出门。模型选型这事,每天都在变,今天这个模型涨价了,明天那个模型效果更好了,你要让每个服务都自己跟着改,那改到天荒地老也改不完。

所以我一直用网关。最早用 new-api,后来换 LiteLLM,都是开源的现成方案。

它们能干活。真的能干活。渠道抽象、路由、熔断、用量统计,该有的都有,配好就能跑。

但问题是,出问题的时候,你面对的是一个黑盒。

报错看不懂,日志绕三圈,想改个行为得翻半天文档,改完还不一定生效。而且它们是通用平台,为全世界各种场景设计的,我家这场景其实特别简单,一个统一入口,一个路由降级,一个用量统计,没了。

我记得有一次,LiteLLM 突然开始疯狂报错,我查了一下午,最后发现是它内置的一个什么兼容层跟新版本冲突了,那个兼容层我根本没用过,但它就是能把我整个网关拖垮。你卸载吧,怕别的地方依赖,你不卸载吧,它就在那恶心你。

用着用着我就想,这玩意,是不是有点杀鸡用牛刀了。

说真的,我有时候觉得,LLM 网关这东西,特别像自来水厂。

各家模型厂商是水源地,OpenAI 兼容协议是水管标准,网关就是那个把水处理、加压、送到千家万户的自来水公司。

以前我家用的是别人建的水厂。水能喝,但你永远不知道今天水里加了什么,想查个水质,得翻他们家的说明书,还不一定查得到。水厂扩建了你不想要的功能,你也只能忍着,因为水厂是别人的。

后来我想了想,我家用水量也不大,水厂那套净化工艺一半我都用不上,干脆,自己接一根管子算了。

然后我就干了。

用 Go 写。1.26,本地编译成静态二进制,绕开 docker 里构建那一堆破事。编译出来一个二进制,扔进 alpine 镜像里就能跑,连构建工具链都不用带。

依赖极简,整个项目只引入了一个第三方库,pgx,连 postgres 用的。别的,全标准库。

3185 行代码,15 个测试。这个体量,一个人一天能写完,也正是一个自研网关该有的克制。

部署也简单,一个 alpine 镜像,host 网络,端口 4000,直接怼在机器上。存储复用现有的 postgres,开一个独立库,叫 gllm,谁也不打扰谁。

管理界面?原生 JS,go

直接嵌进二进制里,零前端构建链。七个页签,总览、模型、渠道、Keys、日志、用量、设置,够用了。

我跟你说,写完的那一刻,看着 docker compose up 起来,日志打出配置加载完成,12 个模型名,20 条模型路由,那种感觉太爽了。

整个转发链路是这样的,客户端进来,auth 先校验虚拟 key 和配额,限流中间件令牌桶挡一道,然后进路由引擎,优先级排序,加权轮询,故障转移,熔断,多 key 轮询,最后 proxy 做模型映射、SSE 透传、错误规范化,打到上游。

链路看着复杂,其实每个环节都挺朴素。

先说 auth。每个服务发一个虚拟 key,可以配白名单,限定这个 key 只能调哪些模型,还能配配额,用完了自动 429。这样就算哪个服务把 key 泄露了,损失也控制在一个 key 的范围内,不会波及整个网关。

限流是令牌桶,按虚拟 key 每分钟 240 次,按 IP 每分钟 600 次,参数还能在设置页里调。这块有个细节,限流的 key 解析必须支持 IPv6,不然一个 [::1]:8080 就能把限流打穿,这个坑后面代码审查的时候还专门揪出来过。

最有意思的是路由策略。渠道有个优先级字段,数字小的先走。我把本机 Ollama 设成 0,永远优先。

为啥,因为本地模型免费,数据不出本机,还快。

云端是兜底。本地挂了,自动切 DeepSeek,DeepSeek 限流了,自动切 SiliconFlow,再不行还有火山方舟。故障转移的逻辑是,网络错误、5xx、408、429 这种,自动重试下一条目,4xx 业务错误就直接透传,把上游的原始错误原样还给你,不瞎掺和。

熔断也朴素,连续失败三次,临时下线三十秒,冷却完自动恢复。客户端自己断开的不算,这个后面还会提到,是个坑。

还有一个我挺得意的小设计,同一个渠道可以挂多个 api_key,自动轮询。相当于一个水厂给你开了好几个水龙头,一个被限流了,换下一个。

配置热生效,管理 API 写完库,内存缓存自动刷新,不用重启。

这事的爽点在于,以后加模型,不用再折腾镜像、重启、翻配置文件,管理界面点几下,或者 curl 调一下,完事。

写的过程中还有一个细节我印象特别深,就是 SSE 流式透传。

一开始我想省事,给 http.Client 配了个超时。结果一测,流式对话全挂了,说一半就断。

后来才想明白,超时得分开算。响应头等待设个上限,120 秒,响应体的读取不受限制,这样流式长连接才不会被误杀。还有 http.Transport 不能逐请求新建,Clone 会破坏 HTTP/2 协商,上游直接 EOF。

这种坑,不自己写一遍,永远不知道。

还有思考类模型,Ollama 上那个 qwen3.5,回答都在 content 里,reasoning 是思考过程,max_tokens 给少了,content 直接为空。一开始我以为是模型挂了,查了半天,结果就是 token 不够。这种经验,都是踩出来的。

而且你发现没有,越是这种小坑,越是你用别人网关的时候根本不会去想的。因为黑盒帮你兜住了,你也永远不知道里面还有这些门道。自己写一遍,这些门道全成了你的肌肉记忆。

好,回到开头那个 404。

网关是 8 月 10 号上线的,当天全量实测,12 个模型,20 条路由,chat 的,embedding 的,responses 的,流式的,全过。

我以为这就稳了。

第二天凌晨,火山方舟的模型就给我上了一课。

问题出在哪,我排查了一圈,发现是两个坑叠在了一起。

第一个坑,是我对 Agent plan 的认知错了。文档上写的是支持 Responses API,我就把所有条目全配成了 responses 模式。结果实际测下来,人家的 chat completions 端点也是通的,只是我压根没配。

第二个坑,更隐蔽。我的代码里有个 ensureV1 的函数,专门负责给 base_url 补版本段。openai 类型的渠道,base_url 是 https://api.deepseek.com,没有版本段,补一个 /v1,没毛病。

但火山方舟的 base_url 是 https://ark.cn-beijing.volces.com/api/plan/v3,已经带了 /v3。

ensureV1 不认,咔咔又给补了个 /v1。

拼出来变成 /api/plan/v3/v1/chat/completions。

你说这路径,能不难看吗。404 都是给它面子。

这两个坑,一个错在认知,一个错在代码,单独拎出来任何一个都挺好修,叠在一起就特别迷惑。你要是只看日志,看到的是 model_not_found,第一反应绝对是模型没配好,谁能想到是路径被拼成了四层楼呢。

修复倒不难,三处。ensureV1 改成版本感知,v3 之后不再补 v1,proxy.go 和 admin.go 两处都要改。models 表加了个唯一约束带 mode,同一个上游模型,chat 和 responses 各一条路由,互不打架。渠道名统一叫 Agent plan,seed 脚本同步。

改完重新 build,chat 通了,responses 通了,openai 通了,ollama 通了,流式也通了。

全绿。

这个坑的教训挺通用的,对接上游协议,别只信文档,要信实测端点。写拼接逻辑,版本段感知是基本功。

说到这,我得再交代一件事。

开发完成后,我做了一轮全量代码审查。自己写的代码,自己审,最容易审出什么,最容易审出「我觉得没问题」。

但这次还真揪出了五个实打实的 bug。

第一个,4xx 错误响应体丢失。4xx 分支里先 drain 再 readBody,同一个 Body 读两遍,第二遍读出来是空的。结果就是,上游报错说「你 key 过期了」,客户端收到的是空消息,等于把上游的话吞了。这个 bug 最坑的地方在于,单测全绿,因为测试只断言了状态码,没断言错误体内容。

第二个,重试连接泄漏。5xx 重试的循环里,lastRes 被覆盖前没关旧 Body,中间那些响应体全泄漏了。平时没事,高并发一来,连接数蹭蹭往上涨。这种 bug 不压测根本发现不了,但压测的时候你又会怀疑是网络问题,特别迷惑。

第三个,客户端断开误触发熔断。tryOne 返回 err 的时候无条件记失败,但客户端主动断开,context.Canceled,这算哪门子上游失败。结果就是,几个用户同时关页面,网关熔断了,所有人一起遭殃。这个是我觉得最阴的一个,因为你的网关明明没坏,上游明明没坏,就是用户手滑关了页面,结果全服务跟着熔断三十秒。

第四个,clientIP 不支持 IPv6。手写了一个从右往左找冒号的截端口逻辑,遇到 [::1]:8080 直接懵,纯 IPv6 直接截成空串。限流要么失效,要么全归到同一个 key 上。你说手写解析干嘛呢,标准库 net.SplitHostPort 摆在那,一行的事。

第五个,限流器的 burst 字段数据竞争。一个无锁读,一个锁下写,go test -race 一跑必报。这个倒是测试能抓到的,所以我也挺庆幸自己写了 race 测试,不然上线之后哪天炸了,又是一场悬案。

这五个 bug,单测基本都跑不出来,除了 race。全是读代码读出来的。

你问我自研值不值,这就是答案的一部分。代码是你自己写的,每一行都能看懂,bug 长在哪,翻两页源码就能定位。换成 new-api,这五个问题,你可能得在 issue 区潜水三天才能找到答案,还不一定有。

当然,自研也有代价。

功能要自己维护,开源网关的高级功能,计费、多租户、审计,我们全都没有。协议更新,新上游接入,都要自己跟。写代码一小时,审代码一下午,这个成本是实打实的。

而且说实话,有些坑我到现在都还没踩完。Ollama 的推理队列之前被一个长任务占满过,整个网关的请求全超时,我一开始还以为是网关的问题,排查半天才发现是 Ollama 那边排队排到天荒地老。这种跨服务的连锁反应,自研网关也躲不掉,只能靠经验积累。

但对我这个场景来说,值。

因为我要的从来不是功能全,是一个透明的、可控的、长在自己机器上的总闸。

现在这个总闸,还保留了回滚的退路。litellm 和 new-api 的目录原样躺着,compose 里注释段还在,哪天我后悔了,docker compose 改两行,一分钟切回去。

用我自己的话说,这叫防御性开放,我选择自己接水管,但不对水厂锁门。

回到开头那个比喻。自来水厂,你可以选别人建的,也可以自己接一根管子。

我选了自己接。

不是因为我多厉害,恰恰相反,是因为我太清楚了,我这辈子要喝的水,就那么几口,自己知根知底的水,喝着才踏实。

而且你想想,这个网关它服务的对象,是我家里那九个 AI Agent,是每天跑着的视频分析,是 AI 成片流水线,它们才是真正喝水的住户。我这个接水管的,只是想让每家每户打开水龙头就有水,还知道水是从哪来的。

网关这东西,说大不大,说小不小,但它是我家所有 AI 服务的第一道门。

门,还是自己装的好。

以上,既然看到这里了,如果觉得不错,随手点个赞、在看、转发三连吧,如果想第一时间收到推送,也可以给我个星标⭐~

谢谢你看我的文章,我们,下次再见。

本文作者:丘丘

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!