我之前的 LLM 网关是三件套:Python 的 litellm 负责协议转换,PM2 负责进程守护, 前面再挂一个 Node 反代做鉴权、日志和管理页面。这套组合并不差, 真正的问题是:它把一件事情的三个部分拆到了三个运行时里,而这三个部分又必须一起改。
三件套的问题不在性能
对个人使用来说,性能从来不是瓶颈。真正的摩擦发生在每次想改一点东西的时候:
- 新增一个上游供应商,litellm 的配置和反代的路由要一起动。
- 想在日志里多记一个字段,要同时改反代的中间件和 litellm 的 callback。
- 在管理台改完配置,PM2 需要重启;重启窗口内的请求会失败。
- 排查一个问题,要在三份日志、三个进程状态之间来回跳。
这些摩擦单独看都不大,但它们决定了一件事:你愿不愿意在周末给这套东西加一个小功能。 如果答案经常是「算了」,那架构就已经在拖慢产品了。
先把边界定清楚
重写之前我先列了两张清单。需要的:协议互转、管理台、用量记录、配置热更新。 不需要的:多租户、分布式调度、跨节点负载均衡。使用场景是个人或小团队自托管, 单实例运行。
结论是把它收进一个 Node 进程。这个选择带来两个直接好处:SQLite 可以在进程内使用, 不需要额外的数据库服务;配置可以在内存里重建,不需要重启进程。
它也带来一个硬约束:不能多实例。SQLite 和内存热更新都不支持多写。 我没有试图绕开这个约束,而是把它写进文档,作为架构前提。一个诚实的限制, 比一个假装支持但实际上会出问题的扩展性更有价值。
三个协议,六条链
网关需要处理三种入站协议:OpenAI Chat Completions、Anthropic Messages、 OpenAI Responses。上游也可能是其中任意一种,于是转换矩阵最多有六条链。 客户端的协议不该被上游绑死,这是网关存在的意义。
代码上我把它拆成了两层:
-
ezllm-translation只做纯转换:请求、响应、SSE 帧。 不发请求,不绑框架,不认识业务概念。 -
ezllm-gateway是 SDK 层:Ellm业务入口、 配置校验展开、类型化 hook 管线、流式生命周期。 - 再往上是 server:HTTP、鉴权、管理 API、静态托管。
把 translation 单独拆出来,最重要的原因是可测试性。协议转换的坑几乎都集中在字段映射 和流式帧的顺序上,用真实请求的 fixture 做逐字段回放,比读文档可靠得多。 纯函数没有 IO,测试不需要起服务,也不用花钱调上游。
配置长什么样
供应商、模型别名、密钥引用都在一份 JSON 里。客户端请求的是对外模型名, 网关负责把它映射到某个供应商的真实模型。
{
"port": 29770,
"providers": [
{
"name": "my-anthropic",
"customLlmProvider": "anthropic",
"apiKey": "os.environ/MY_ANTHROPIC_API_KEY",
"models": [
{
"modelName": "my/claude-sonnet-4-5",
"model": "claude-sonnet-4-5"
}
]
}
]
}
apiKey 支持 os.environ/NAME 的写法,密钥本身不落盘。
这一点在个人部署里比看起来重要:配置文件经常会被备份、同步或者误提交。
热更新是一个架构问题
Hono 的路由是静态挂载的。如果直接替换网关实例,已经注册的路由仍然指向旧对象。 我的解决办法是两层结构:外层是一个固定的 Hono 应用,内层是一个可替换的转发器。
// 外层固定,内层可变
app.all('/v1/*', (c) => runtime.current.handle(c))
管理台保存配置时的顺序是:校验并展开配置、原子写入
ezllm.config.json、在内存里重建网关。任何一步失败都不会留下半套状态。
直接手改这个 JSON 文件仍然需要重启。这是有意的:管理台是唯一受支持的热更新入口, 文件监听会引入「改到一半被读到」的竞态。少一个隐式行为,就少一类难查的问题。
monorepo 的依赖方向
仓库现在有 18 个包,分为应用层、库层和插件层。依赖是单向的:
ezllm-translation ← ezllm-gateway ← ezllm-server
ezllm-shared ← ezllm-db ← ezllm-logs ← ezllm-server
ezllm-config ← ezllm-server
内部包的 exports 直接指向 TypeScript 源码,开发时用 tsx 直接运行,
没有构建步骤。这是开发体验最好的一种 monorepo 形态,但发布时有代价:
Node 运行时无法解析 workspace 里的 TS 入口,所以 server 必须打包成自包含单文件。
发布包里的那一个 server.js,就是为此存在的。
我放弃了什么
- 重试、回退、负载均衡:litellm Router 的核心没有移植。个人部署里,上游失败时我更希望看到真实错误,而不是一个被掩盖后的重试结果。
- 多实例:SQLite 与内存状态决定了只能单进程,这是前提,不是缺陷。
- Node 版本宽容:依赖内置
node:sqlite,需要 Node ≥ 22.5。没有为了兼容旧版本引入原生依赖。 - 管理 API 的跨域:管理界面只允许同源访问,跨域部署必须用反代保持同源。网关的
/v1/*仍然开放跨域。
结果
现在启动之后,一个端口上同时有三件事:/v1/* 是网关,
/api/* 是管理台 API,/ 是管理界面。
部署从三套配置变成了一个应用目录,发布是 npx ezllm install。
这不是在替代 litellm。litellm 解决的是通用网关的问题,我解决的是我自己那套组合的问题。 如果哪天真的需要多实例和跨节点路由,这套边界就不够了。到那时候应该换方案, 而不是在这个单进程上继续加抽象层。