0. 一个我答不上来的问题
日志页里躺着一条记录:状态 200,耗时 28 秒,token 数正常。然后有人问了一个特别自然的问题——这 28 秒花在哪了?
日志页里躺着一条记录:状态 200,耗时 28 秒,token 数正常。然后有人问了一个特别自然的问题——这 28 秒花在哪了?
我有一条每夜跑的 E2E:起一套完整的 compose(MySQL、Redis、所有服务),然后用 Playwright 跑管理后台的关键路径。
这篇讲两个性能决策,它们的结论看起来是相反的。
前面 24 篇讲的是"我为什么这么做"。这一篇不一样——它讲具体怎么做。
有一段时间我觉得管理后台"卡"。具体表现是:点侧边栏切到 /dashboard、/usage、/admin/logs,要等大约一秒才出内容。
有一阵子我觉得代码里积了不少问题——文件越来越大、有几处逻辑我自己都记不清了。于是我让一个模型对 develop 做了一轮系统性审查,产出一份问题清单。
这段代码注释是我在 review 自己的时候发现的,编号 platform-L9:
做一个"在浏览器里调模型"的 Playground,聊天界面本身没什么难的:一个输入框、一个消息列表、一个流式渲染。
我第一版的对账逻辑很简单:把钱包余额和账本净额比一下,不一致就报出来。
这篇复盘两次问题。它们表面上看没有关系:
这次重设计的起点不是"界面不好看",是一件更实际的事:字体没覆盖中文。
做这个接口的时候遇到的第一个问题不是技术问题,是语义问题。
第 12 篇讲了三层缓存,但留了一个尾巴没讲:缓存什么时候该失效。
第 1 篇里算过一笔账:relay-gateway 处理一个请求,要打四次下游 gRPC——identity 鉴权、channel 选路、billing 预扣、billing 结算,外加写日志。
我第一版迁移就是一个编号序列:001_xxx.sql、002_xxx.sql……每个文件一个号,跑的时候按号排序。
用户买了一个月的套餐,付了 20 块。用了 10 天,要求退款。
这套结构里真正需要照抄的东西不多,但如果不照抄那几样,后面就很难改回来。这篇分成两部分:第一天必须有的(不做后面会痛),和可以后补的(做早了是浪费)。然后走一遍"加一个资源"的完整步骤。
两次问题表面上没关系:一次是一个渠道上所有模型都不可用,实际只有一个模型坏了;另一次是 /v1/responses 集中返回 502,实际上游返回的是 4xx。但它们形状一样——我的系统在一个更粗的粒度上做了判断,而真实问题发生在更细的粒度上。
一个看起来很简单的问题,答案不简单:用户买了一个月套餐、用了 10 天、要求退款,退多少钱?而这只是开始——订阅怎么处理、已经消耗的额度怎么算、续过费先退哪个、账本上怎么写。这篇讲我怎么处理这三个操作,以及一个跨所有操作的机制:购买时的套餐快照。
做网关时我一开始想"给每个上游接口写一个适配器",列完清单就停住了:embeddings、moderations、images、audio 这些接口的内容我根本不需要理解,原样转发就行。对它们写适配器等于为每个接口维护一套可能与上游产生分歧的结构体。这篇讲这条路——代码只有几十行,但有几个我认为值得说的决定。
两个看起来相反的结论:一个被微基准证明"变慢了 2.1 倍"的优化我最后保留了;一个 P95 回归 69.7% 但成功率反而更好的重构我回滚了。它们背后的标准是同一条——我要的改善必须出现在真实负载的请求级指标上,而不是出现在任何可以被我解释的局部数字里。
我给自己做了一次代码审查,然后否决了它的大部分方案。因为那份清单是在没有读全部代码的情况下写的——比如它说我 domain/ 目录"名不副实",但那条不符合我在仓库规范里定义的准入标准。这篇讲逐条复核的过程:哪些我认了、哪些我否了、哪些我决定什么都不做。
我的每夜 E2E 连续失败过一阵子。按常规做法该加 retry,但我先去看失败之间有没有共同点——结果发现两类完全不同的失败混在一起:一类是它根本没在测我的代码,另一类是我的断言在等一个中间状态。这篇讲这两件事,以及测试矩阵和 mock upstream 的设计。
"服务端 0.8ms,但页面要等一秒"——这个对比是整次排查的转折点。如果我先去优化那个看起来最慢的聚合查询(76+69ms),我优化的只是 15% 的部分。这篇把那一秒拆成四份,逐份讲。
订阅账号会有各种"暂时不能用"的情况:被上游限流了、额度用完了、凭据过期了、上游在抖动。这些状态都要有退出机制——总不能因为一次 429 就永远不再用这个账号。
我第一版的 token 刷新逻辑大概是这样的:
接上游 provider 这件事,我一开始以为是"一家一个适配器"。写了才发现不是:三十多个渠道类型里,真正需要单独写协议转换逻辑的只有四家,其余二十多家全是同一个适配器的不同配置。
做订阅功能的时候,我脑子里只有一个"额度"概念:用户买了套餐,每个月能用 20 美元,超过就停。
我第一版的对账逻辑很简单:把钱包余额和账本净额比一下,不一致就报出来。跑起来之后几乎每个用户都进了异常列表——因为我没想清楚"余额"到底是什么。这篇讲七类核对分别在查什么、为什么它们的容差不一样,以及我为什么坚持让它只报告不修复。
做一个在浏览器里调模型的 Playground,聊天界面本身没什么难的。真正花时间的是一个安全问题:用户要粘贴 API Key 进来,我怎么保证它不落到任何地方——不进 URL、不进浏览器历史、不进 localStorage、不进 Query cache、不进日志。这篇讲这个设计,以及做的时候踩到的两个 SSE 解析的坑。
这次重设计的起点不是"界面不好看",而是一件更实际的事:前端用的 Geist 字体不完整覆盖简体中文,而 logo 的 SVG 里声明了根本没打包的 Inter。字体是可分发资产,牵涉许可证的随附义务,所以我把它当成第一优先级。这篇讲动手前的那份量化基线,以及过程中哪些东西我说服自己放弃了。
有一段时间我的审计日志一条都没记下来,而且无法通过看日志发现——因为日志系统本身没接上。空手而归和从未出发,在观测上是一样的。这篇讲这件事,以及我在指标、审计、追踪这三套东西上的其他取舍。
订阅账号会遇到各种"暂时不能用":被限流、额度用完、凭据过期。这些状态都要有退出机制。自然的做法是定时扫一遍把该恢复的恢复——但 channel-service 有多个副本,于是四个副本会同时扫。这篇讲我怎么把"多副本并发"当成默认前提来设计这两个 sweeper。
第 12 篇讲了三层缓存却留了个尾巴:缓存什么时候该失效。纯 TTL 的答案是"一个被禁用的 token 最多还能用 5 分钟",我不太能接受。这篇讲我最后怎么做的——没有引入消息队列,而是用了一张表加 Redis Streams,以及这条路上踩到的几个坑。
relay 处理一个请求要打四次下游 gRPC,而用户 token、渠道配置这些数据的变更频率是"每分钟几次"、读取频率是"每秒上千次"。这篇讲我怎么搭这个缓存,其中有一个我一开始想错的地方——我一度把"缓存命中"当成了"授权通过"。
我最初以为"额度"只有一个概念:用户买了套餐,每月能用多少,超了就停。写下去才发现上游也在算额度,而且它算的跟我不一样——它报百分比,我记美元;它按自己的 5h/7d 窗口,我按用户的日/周/月。这篇讲我最后怎么同时维护这两套账,以及它们各自最容易出错的地方。
接上游 provider 这件事,我一开始以为是"一家一个适配器"。写到第十家才发现,真正需要单独写协议转换逻辑的只有四家,其余二十多家全是同一个适配器的不同配置。这篇讲这个拆分怎么形成的,以及我在接外部地址时踩到的两条边界——SSRF 防护和流式超时,两条我都做错过。
有一批请求要的模型在所有渠道上都没配置,channel-service 全部返回"没有可用渠道"。这个错误跨 gRPC 回来是 codes.Unknown,而我的熔断器把它算作失败——于是用户传错一个模型名,整个网关的流量都被熔断器拒了。这篇讲这次之后我怎么重排错误分类,以及跨服务降级的其他几处取舍。
并发一上来,上游返回 429,而我当时的逻辑把 429 当失败,重试几次就把一个完全健康的账号标记成不健康、从候选里摘掉。这就是"用满"和"用坏"没分开的后果。这篇讲我最后怎么在限流器、冷却时长、重试策略、健康记录、指标标签每一处把它们分开。
选路这件事比我想的难。优先级相同的渠道怎么分流量、重试该换谁、换的时候会不会误伤别的渠道、授权要不要再验一次、一个渠道坏到什么程度该被摘掉——这篇按我实际动手的顺序讲,中间有两次返工,还有一个后来 review 时自己揪出来的、后果比较严重的 bug。
v0.30.0 是 v0.29.0 之后的 MINOR 启用与稳定性收口版本。v0.29.0 交付的分组重设计 v2(阶段 A–F)全部能力默认关闭、未经真实链路验收;本版按 v0.30 阶段路线图完成 P0 / P1 / P2 三个优先级共 7 项任务:接通部署开关并完成 MySQL / SQLite 真实服务验收,补齐路由 / outbox 可观测性与告警,收口管理台汇总性能与降级语义,为迁移 runner 增加旧元数据表预检,并补齐分组日常操作与账务解释链路。
从一次我自己撞的墙讲起:网关把上游的 4xx 改写成了 502,而终止分支缺日志,我完全看不到发生了什么。顺着这个坑,我把 /v1/chat/completions 这条链路从头到尾捋了一遍,这篇记录其中的选择——哪些是当初顺手写下的、哪些是返工回来的、哪些我到现在也还没底。
这个项目还没上线。我在开发过程中把结构翻了一遍,而且方向还相反:第一次是把一个 2391 行的 internal/relay/server/http.go 拆开,第二次是把拆出去的九个服务收进 Kratos 大仓结构、抽出共享层,并写脚本卡住服务之间互相 import。这篇讲哪些线是我真的按需要切的,哪些只是抄了微服务的目录形状。
v0.29.0 是 micro-one-api 的 MINOR 路由分组重设计版本,交付分组重设计 v2 的全部实施阶段(A–F)。
v0.28.0 是 micro-one-api 项目在模型可观测性方向的一个重要版本。本版新增按来源与上游模型粒度的被动模型健康监测与管理台看板,修复了多轮 /v1/responses Websocket 连接按累计 usage 重复计费的问题,并把 404 模型不可用判定收窄到 API 形状响应体,避免误配 base_url 把整渠道模型标红。
v0.27.0 是 micro-one-api 项目面向个人部署者与运维审计场景的一个收口版本。本版修复了 SQLite Lite 从空环境到首个聊天请求之间的结算死锁与方言 schema 缺口,补齐了可重复验证的部署 Quickstart,新增只读的历史账务审计工具,并把 K3 canonical charge 灰度的验收 SQL 口径与安全依赖基线彻底固化。
micro-one-api 是一个 LLM API 网关,对外同时暴露 OpenAI ChatCompletions(/v1/chat/completions)、OpenAI Responses(/v1/responses)和 Anthropic Messages(/v1/messages)三个端点,对内要对接二十多家协议各异的上游渠道。客户端协议和上游协议任意组合,协议转换就成了网关的核心能力。
本文从“一次调用为什么产生这笔费用”出发,介绍 Token、价格、缓存、订阅和最终入账之间的关系,适合项目使用者、运营人员和开发者阅读。
本文整理 micro-one-api 从 v0.24.0 到 v0.26.5 共 8 个版本的发布内容。这个周期先完成控制台可用性、双语界面与用户协议和隐私协议基础,随后补齐模型模态与每 1M tokens 定价注册表,再进入用量语义与计费审计的核心建设,最后收口 Go 1.27 工具链、Relay 路由、控制台性能和 Responses 来源归因。范围覆盖 v0.23.3..v0.26.5,共 436 个文件变更、+18.1k/-5.7k 行。
micro-one-api v0.23.x 系列(v0.23.0 / v0.23.1 / v0.23.2)是项目在 relay-gateway 执行架构上的一个重要里程碑。这个系列为 /v1/chat/completions 与 /v1/responses 引入了一套 transport-neutral 的 executor 执行层,并通过 token allowlist 灰度门禁、失败矩阵测试和生产 canary 观察,在小流量、可回滚的前提下完成了新旧路径的验证与切换准备。与此同时,三个补丁版本持续加固了渠道健康、用量日志安全、协议兼容和失败边界。