一个讲多模态网关的帖子,底下点赞最高的一条评论在聊成本。
不是聊多模态,是聊成本可预测性。原话大概是"透传定价让单位经济性只有等发票到手才知道"。第二条几乎把同样的话说了一遍,补一句他们自己会在调用前先记一个估算。第三条最狠——直接点出估算成本和结算成本不提供相同的保证,还顺手点了分词、缓存块和工具调用三样东西。
一篇标题里写着"一个密钥打通文本、图像、语音"的文章,底下最想聊的是"我发出去的这一个请求到底花了多少钱"。
这件事本身就是答案。多模态是能力问题,成本是纪律问题。做产品的人对纪律问题的痛感永远比能力问题高一个量级——能力不够可以不做,账算不清是每天都要面对的事。
我不打算复述那四个问题。我想做的是把它们拆开,沿着一条请求从进门到出门的路,看每一道关卡上是谁在替谁做决定,以及哪些决定是被上游另一个决定绑死的。
绑死的那种,才是真正的设计。
先把地图铺开
任何"统一入口"的东西,不管上面挂的是 /llm 还是 /v1/chat/completions,剥开之后都是同一副骨架。以那篇里给的调用形态为例,进门的时候大概长这样:
POST /llm
Authorization: Bearer <KEY>
{
"model": "claude-sonnet",
"messages": [{ "role": "user", "content": "Hi" }]
}
模型名可以在 claude-sonnet、gpt-4o、gemini 之间换,鉴权是一套,请求结构是一个。这是聚合层最直观的那部分价值,文本这一路它确实解决了。
但请求进了门之后,要走的是五步,不是一步:
请求进门
│
├─ ① 认证与额度 ───── 这个 KEY 是谁 / 还有没有钱
│
├─ ② 路由与模型解析 ── claude-sonnet → 哪个供应商的哪个端点
│
├─ ③ 成本预检 ─────── 这一发大概多少 / 余额够不够 / 不够就拒
│
├─ ④ dispatch ─────── 真正发出去(可能失败、可能重试、可能回退)
│
└─ ⑤ 结算与记账 ───── 实际花了多少 / 记在哪 / 和 ③ 对不对得上
写成五步而不是四道关,是因为 ④ 和 ⑤ 中间那一段才是整条路径上最容易出事的地方——后面会讲到。
多模态是横着切过来的第二刀。文本、图像、文本转语音、转录这四类能力,每一类的 ② 和 ⑤ 长得都不一样。② 不一样大家都懂——不同供应商的端点、鉴权、参数各写各的;⑤ 不一样才是要命的:计费口径对不齐,你在 ⑤ 上根本没有一个统一的"单位"可以拿来记账。文本那一格,单位是 token;图像那一格,是张数或者某个跟分辨率有关的量;语音那一格,是字符数或者时长。你把这些塞进同一张账本,账本的第一列就该叫"这行我不确定怎么算"。
四步里,①② 是聚合层的传统地盘,OpenRouter 做得很熟了。真正的战场在 ③ 和 ⑤ 之间——而且这两步不是并排的关系,是绑在一起的关系。等下你会看到为什么。
(我上面说"四类能力",其实是文本、图像、TTS、转录,视频还在路上——作者在原文里提了一嘴。视频进来之后就不止四类,这一点后面单独说。)
一个内容团队一天里要碰的三个模态
先说清楚多模态为什么不是花架子。
原文作者的实际需求是:生成脚本、制作缩略图、把内容朗读出来。这三件事在同一个人一天的工作流里是连着的——脚本写完要做封面图,封面图定完要把稿子念出来。三个模态,三个供应商,三份文档,三套鉴权,三张账单。你每换一次供应商,最痛的不是改代码,是重新读一遍文档,弄清楚这家那家的参数叫什么、错误码什么意思、计费按什么单位。
所以"一个密钥覆盖文本、图像、TTS、转录"这句话对做内容的人是真有吸引力的。它不是技术洁癖,它是把一天里三次上下文切换压成一次。
但这话我只说一半:这四个模态里,真正难的只有图像和语音两个,而且难的地方不在于调不通,在于你怎么把它们塞进同一套记账和同一套请求结构里。调通一个模型是最容易的那部分——文档照着抄就行,参数对不上一遍遍试。难的是你试着把它们塞进同一套 schema、同一张账本、同一条预检逻辑的时候,发现这几个模态压根不在一个坐标系里。
③ 和 ⑤ 为什么不能分开谈
透传定价的机制很简单:供应商原价加上一层服务费,原样传给你。这个做法本身没毛病,甚至挺诚实——我不赚差价,我赚手续费。
问题不在它诚不诚实,在于它把一个浮动量原样交给了调用方。
对一个已经在跑的产品来说这不算大问题,你月底看账单,调一调就行。但对一个还没上线的功能来说,这是灾难。你没法在写代码之前回答"这个功能每次调用成本是多少",因为那一头是供应商的价目表,不是你自己的常量。你的单位经济性要等发票。评论里那句"只能在收到发票后才知道",说的就是这个。
我把话说死一点:透传定价在纯文本场景下还能凑合,唯一的原因是文本有一个足够粗的近似单位——token。你能在发请求前把 messages 数一遍,乘一个费率,得到一个差不多的数。它不准,但它是一个数,而且这个数跟实际结算值的偏差是有界的。
图像和语音进来之后,连这个近似都没有了。不同供应商对同一类能力的计费口径根本不同,而你的请求结构里没有任何一个字段能同时表达它们。你没法把"三张 1024×1024 的图"和"一段三千字的朗读"放进同一个预检公式里。
所以透传在多模态上是双重失效:它不给你一个统一的价格单位,也不让你的预检落在同一个地方。③ 和 ⑤ 必须在同一套记账单位上闭合,否则 ③ 算出来的那个数在 ⑤ 对不上,预检就退化成了一道装饰。这也是为什么我说它们不能分开谈——不是设计上想让它们亲近,是它们本来就只能成对出现。
积分制:把价格风险从调用方搬到网关这一侧
积分制就是冲着上面那个问题来的。作者的表述是:积分让用户在请求发出之前就知道价格,而且不受供应商临时调价影响。
这句话里藏了一步很重的棋,很多人读过去了。
"不受供应商调价影响"的意思,翻译成财务语言就是:价格风险从调用方转移到了网关方。供应商涨价,网关不能立刻跟着涨——否则你刚才承诺的"请求前知道价格"就成了一句季度性的谎话。这意味着网关得自己在中间吃下这个波动,自己维护费率表,自己留一块毛利率缓冲垫。
换你写,你未必敢这么写。因为它把一个原本属于调用方的财务问题,变成了你自己的财务问题。它的好处是干净:调用方看到的余额是一个硬数字,可以拿来做产品决策,可以做预算,可以在写代码之前算清楚一个功能每天烧多少。它的代价是网关必须替所有人承担价格波动,而且这个承担是敞口的——供应商季度调价的时候你不跟,你的毛利就被吃掉一块。
评论区有人问了:供应商在季度中间调价,积分模型是吸收还是重新定价。作者的答案是吸收。那接下来的问题就该是:吸收多久、吸收多少、缓冲垫多厚、什么条件下你不得不跟。这些问题有答案之前,积分制是一个承诺,不是一个机制。
我不是在挑刺。我是觉得这个承诺的份量比它表面上看起来重得多,重到它值得单独写一段产品文档,而不是夹在发布帖的一句话里。
③ 上的钉子:预检是近似值,你却拿它当一道硬门
现在进到最值得读的那一段。
第三条评论把话说得很细:分词、缓存块和工具调用会让预检只是近似值;按功能的预算应该在路由请求路径上执行;所有回退尝试应该继承同一个预算,免得重试悄悄把单位成本抬上去。
作者在那条下面的回复我看了两遍。他说:倾向把估算作为硬预检,超过预算就在调度前拒绝,不做事后对账;同时承认这可能在某些边缘情况下过于保守。
这一行是整段的钉子,慢一点看。
拆开:估算是近似值,这一点作者认了,他没打算假装它准。他做的事情是——明知不准,还是拿它当门。这是一个很清醒的取舍:用"确定性地拒绝掉一部分本来合法的请求",换取"绝不超支"这个不变量。
为什么这么选?因为在他的模型里余额是硬的。用户充了积分,这是一个死的数字,不是月底结清的信用额度。硬余额配软预检,唯一的出路就是把预检做保守——保守的代价是误杀,误杀的代价是可用性。
换你写,你的选择取决于一件事:你这个月能不能接受一次超支。能接受(后付费、软天花板、有财务兜底),你就把预检做成软的,事后对账,谁超谁补。不能接受(预付费余额、不想追债、不想在后台看到负余额),你就得跟作者做一样的事,把预检变成硬门,然后承担误杀。
这里没有正确答案,只有你把不确定性放在了哪一头。放哪一头都行,唯一不能做的是不说——因为调用方会根据你放在哪一头,写出完全不同的重试和预算代码。
预检为什么不准:三个来源,一个比一个烦
这一段值得单独拎出来,因为"近似值"三个字太容易滑过去了。
分词。 同一段文本,你数出来的 token 数和供应商数出来的 token 数可以不一样。你的预检用的是你的分词器,供应商用的是它的。这个偏差通常不大,但它是系统性的——某些语言、某些符号、某些特殊 token 上会一直偏。你乘的那个费率是对的,你数的那个数不是。
缓存块。 命中缓存和未命中缓存的价格不是一回事。发请求之前,理论上你知道自己带没带缓存标记;但你不知道供应商那边会不会命中——那是运行时才知道的事,取决于别人最近有没有发过一样的前缀。这一项让预检的方差变大,而不是让它的均值偏移。
工具调用。 这一项最不可控。一次请求在供应商那边可能变成 N 次往返:模型决定调工具,调几次、每次输入多长、结果多长,你在发出去之前全都不知道。一个带工具调用的请求,它的实际成本可以是你预检值的三倍,也可以正好相等,取决于模型当天的心情。
所以那句"估算成本和结算成本不提供相同的保证"不是学术上的严谨,是工程上的提醒。它逼你回答一个具体问题:你的预检是上界估计还是均值估计?
如果是均值,你就会有一半的请求实际花得比预检多,硬门就形同虚设;如果是上界,你会拒掉大量本来花得起钱的请求,代价就是作者自己说的"可能过于保守"。从他"超过预算就在调度前拒绝"这句话看,他选的是上界。上界买来了不超支,卖掉的是可用性。
保守不是问题。问题是保守的量没人看见。
保守的量必须被记账
这是我整篇最想说的一条,评论区里没人提。
你拦掉了多少请求、拦掉的那些里有多少是真超支、有多少是误杀——这三个数如果不落表,你的预检就永远是一个黑箱。你不知道它偏了多少,也不知道往哪个方向偏,更不知道阈值该往上挪一点还是往下挪一点。所有关于"要不要放宽"的讨论,最后都会变成拍脑袋。
做法其实很土:每条请求留下两个数,预检以为要花多少,实际花了多少。两个数之间的差就是你的预检误差,把它的分布画出来,你会立刻看到三件事——误差的中位数是多少、长尾有多长、以及有多少条请求是被预检拒掉而根本没产生结算值的。
第三个数字最容易被忽略,也最重要。因为被拒的那些请求不会产生结算值,你在账本上根本看不到它们。它们只出现在你的拒绝日志里。一个不拒绝任何请求的系统和一个拒绝了很多请求的系统,在账本上看起来是一模一样的。这句话是整段的关键,你要是不信,去翻翻你自己的日志,看看能不能查出上个月有多少次调用因为预检被拦下来了。
成本可观测性和预检是同一件事的两面,不是两个独立的功能。把它们排成两条独立的待办,是你这个季度最容易犯的排期错误。
回退:预算必须挂在逻辑调用上,不是网络请求上
硬预检有一个前提,作者自己承认没想全——他承认之前没完全考虑"所有回退尝试继承同一预算"这件事,说如果之后推出回退路由会确保成立。
这半句自白是整段里最值钱的,因为它点出了硬预检的致命前提:一次请求只花一次钱。
一旦允许回退,这个前提就没了。一条逻辑调用可能变成三次网络请求:
预算检查① 预算检查② 预算检查③
│ │ │
dispatch(claude-sonnet) ── 失败 ─┐ │ │
├─→ dispatch(gpt-4o) ── 失败 ─┐
│ ├─→ dispatch(gemini)
余额:够 ✓ 余额:够 ✓ 余额:够 ✓
注意右边那三个勾。如果每次重试都独立过一遍预算检查,一个余额只够一次调用的用户,可以合法地花掉三次的钱——因为每一次预检看余额的时候,余额都还是够的。余额是在结算的时候才扣的,而在结算之前,这三次检查看到的是同一个数。
重试机制把单位成本悄悄抬高了,账面上却看不出来。这就是评论里那句"避免重试悄悄增加单位成本"的实际形状。
正确的做法只有一个:预算挂在"一次逻辑调用"上,不挂在"一次网络请求"上。dispatch 之前算一次预算,把这份额度锁定在这个逻辑调用上,回退链上所有尝试共享它,链结束才释放。这是路径级的不变量,不是请求级的。
这条链我以前追过,追的正是同一个坑——不是在哪一家的 API 上,是在自己的重试循环里。
这类 bug 的共同特征是:它只在失败率高的时候出现,而失败率高的时候你在忙着救火,不会去看账本。等你发现的时候,账已经多花了两周了。
免费模型那件事:试验环境和生产环境从来不是一个环境
那篇里有一句我觉得写得挺实在的话:免费模型适合做试验,但速率限制比较严,不适合在它上面把正式功能搭起来再迁移。
这句话听着像常识,其实埋了一个更深的雷。
你在免费模型上做试验的时候,你测出来的所有东西都是错的——不是模型能力错,是围绕它的那圈东西全错。速率限制不一样,意味着你的重试策略、你的退避曲线、你的并发上限,在试验期全是在一个错误的环境里调出来的。延迟不一样,意味着你的超时阈值是错的。计费结构不一样(免费),意味着你的预检、你的预算逻辑、你的成本埋点,在试验期根本没被跑过。
然后你迁移到付费模型,代码一行没改,行为全变了。你会以为是模型换了导致的,其实是你那圈外围逻辑从一开始就没在生产形态下验证过。
所以"用免费模型试,然后再迁移"这个路径,真正的成本不是迁移本身,是你在试验期建立了一整套错误的心智模型,而迁移的时候你得把这些全推翻重来。
要么一开始就用生产形态跑(哪怕量很小),要么你就明确把试验期定义成"只验证能力,不验证工程",迁移的时候心里有数——那一圈东西得重新做一遍。这两条路都行,唯一不行的是把试验期的性能数字当真。
静默降级是错的,不是值得商榷
前面那几段都是钱的事,这一段是形态的事。
评论里有人问:积分在会话中途用尽的时候,用户看到的是硬错误,还是会回退到免费模型?作者的回答很干脆:现在的 /llm 不做流式传输,每个请求会在发出前检查积分,不够就返回硬错误,不扣费,不会静默回退。他还说免费模型回退这件事之前没深入想过。
我同意这个选择,而且想说得更强一点:静默降级是错的,不是"值得商榷",是错的。
理由跟礼貌无关,跟可观测性有关。如果一次调用在余额不足时悄悄换成了另一个模型,调用方收到的是一个 200 和一个看起来完全正常的回复。他没法区分"模型今天变笨了"和"我的代码出了问题"。你把一个资源问题伪装成了一个质量问题,而且伪装得很成功。这类 bug 最难查,因为它不报错。
硬错误是对的,但只对了一半。另一半是:硬错误必须带结构化的原因。
调用方需要一眼分出"我的余额没了"和"上游供应商挂了"——因为这两种情况他的处理方式完全相反。前者要去充值,后者要重试。如果两种都返回同一个 429,那调用方的重试逻辑就必然写错:他可能对余额不足疯狂重试,也可能对上游抖动直接放弃。
评论区有人把这条总结成"硬错误与回退属于产品政策,不只是传输层决策",说得准。传输层只负责把结果送出去,产品政策决定这个结果长什么样、能不能被程序分辨。一个网关成熟不成熟,很大程度上看它的错误码是给人看的,还是给程序看的。
"不做流式"和"硬预检"不是两个决定
作者在回答里顺带提了一句"当前 /llm 不做流式传输"。这句话看起来像是附带信息,其实是整个预检设计的地基。
流式一进来,预检和结算就被撕成两半:你没法在发请求前知道这一次会生成多少 token,你只能边发边算,余额随时可能在半途穿仓。摆在面前的就三条路,每条都得认一个代价:
- 允许穿仓——余额可以变负,你事后追;
- 中途掐断——用户拿到半截响应,你得处理这个半截;
- 按最大生成数预留——预检阈值必须再保守一档,误杀更多。
"不做流式"和"硬预检"不是一个决定加另一个决定,它们是同一个决定的两面。你把流式加回来,就等于同时废掉了硬预检。
任何声称"我们既要做流式,又要做硬预检"的方案,中间一定藏着一个你没看见的缓冲区,或者一个你没看见的容忍度。去找它,它一定在。
换你写,你要是非做流式不可——大多数对话产品其实非做不可——那就别硬撑硬预检。老老实实承认你要的是"软预检 + 事后对账 + 一个可以变负的余额 + 一条追缴或者封停的路径"。这个模型听着不体面,但它诚实,而且它把兜底放在哪写清楚了。
多模态这一刀到底切在哪:不是切在调用,是切在 schema
回到多模态。
评论区至少有两个人是同一个做法:文本、图像、语音分给不同供应商,然后在上面盖一个内部接口把差异藏住,换图像供应商只改一个文件。作者自己也是这条路——一个密钥加统一请求结构覆盖文本、图像、文本转语音和转录,视频还在路上。
我要泼一盆冷水:这层内部接口是所有做过这事的人都会写的东西,它不难。难的是你把它统一到哪个粒度。这句话决定了你这个接口三年后是资产还是负债。
粒度上有两个极端:
- 统一能力:你只统一鉴权、计费、重试、日志。上面的参数原样透传,
model怎么写、带哪些参数,调用方自己查文档。这层抽象很薄,几乎不会过时,但它对调用方的帮助也就到此为止——他还是得读三份文档。那篇里说"一行代码切换供应商的优势主要体现在文本场景",说的就是这种薄抽象在文本上够用、一碰到图像语音就露馅。 - 统一协议:你为所有供应商定义一个并集 schema。
messages之外还有图像的尺寸和风格、语音的语速和音色、转录的时间戳格式。这层抽象很厚,调用方很舒服,但 schema 会长得很快——每接一个新供应商,你就得为一个新字段做一次"要不要进公共 schema"的取舍,而且大概率要改。
真正的工作量全在第二种的维护上。你每加一个模态,公共 schema 就多一整个维度的字段;再加一个模态,这两个维度的字段还会互相影响——图像生成的 prompt 能不能先用文本模型改写一遍?语音的文本能不能先走一遍转录?这些组合一旦有人提出来,你的 schema 就得多一层。
多模态聚合的成本不是线性叠加的,是组合出来的。
所以看到"视频生成即将推出"这句话,我的第一反应不是"功能又多了",是"公共 schema 又要长一圈"。这不是批评,这是这条路必然的代价。做薄了没人用,做厚了自己还不起,中间没有第三条路。想清楚你打算把这条债还多久,比想清楚下一个接哪家供应商重要得多。
落地层那几件事:三件是插桩,一件是真的新造
评论区里有一条我觉得概括得最好:聚合解决的是"选择哪个模型",不是"如何稳定落地"。他把剩下的问题列成四件——路由、缓存预热、成本可观测性、故障转移——然后把这整层叫"聚合之上的落地层"。
我认这个分法,但想把它拆得更细,因为这四件事的性质完全不同:三件是往已有路径上插检查点,一件是要新造一条路径。
- 路由:在 ② 和 ③ 之间插一个决策点。它不是新链路,是给已有链路加一层"选谁"的逻辑。真正难的地方不在选谁,在于选出问题之后怎么退——这就接到故障转移了。
- 成本可观测性:在 ③ 之前和 ⑤ 之后各插一个埋点。前面那个记估算,后面那个记实际,两个数之间的差就是你的预检误差。这是最有价值的一个指标,而且按目前看到的情况,它现在没人记。
- 故障转移:在 ② 之后加一条回退边。这条边必须继承源请求的预算,否则就回到上面那个"重试悄悄抬成本"的坑里。它也不是新链路,是给已有链路加分支。
- 缓存预热:这个不一样。它要的是一条在你收到请求之前就已经存在的路径——你先得知道哪些请求会来,才能提前把缓存塞热。这不是插桩,这是把整条链路提前。它省的不是钱,是首字延迟。
前三件都属于同一个模式:在一条已经存在的路径上,找一个正确的位置,插一个检查点;难点不在插桩本身,在于插完之后语义要闭合。预检插上去了,结算就得跟预检对上;回退边插上去了,预算就得跨边共享;埋点插上去了,两个数就得能对账。
插桩本身都是一行代码的事,语义闭合是几十个小时的事。这两个工作量差两个数量级,而排期的时候它们经常被写成同一行。
第四件是另一个物种,别和前三件混在一起排。这是我看这份清单唯一的意见。
换我写,我会钉死这几条不变量
写到这里,把前面散落的东西收成几条我自己的规矩。这几条不是从谁那儿抄的,是从上面那条路径推出来的。
- 预算是路径级的,不是请求级的。 一次逻辑调用一份预算,回退链上所有尝试共享它,链结束才释放。这条不成立,回退就是成本黑洞。
- 估算值和结算值分开记账,差额当指标看。 每条请求留下两个数,预检以为多少和实际多少,看它们的分布。没有这个分布,阈值调不动。
- 被拒绝的请求也记账。 它们不产生结算值,所以在账本上不存在。只记结算值的系统,看不见自己的误杀率。
- 价格风险的归属写进明面。 你承诺"请求前知道价格",就等于承诺了某个时间窗内不跟着供应商调价。窗口多长、缓冲多厚,是文档该写的,不是内部默契。
- 错误码要能被程序分支。 余额不足和上游失败必须是两个可区分的返回,而且稳定。做不到这条,所有调用方的重试逻辑都是错的。
- 静默降级一律不做。 要降级,提前声明降级目标、在返回里标注、让调用方选择加入。
第 3 条是这几条里唯一没人提到的。所有人都盯着"预检准不准",但预检永远不会准——你唯一能做的是知道它偏了多少、往哪个方向偏、以及有多少请求被它挡在门外。
第 4 条是唯一一条从别人回答里推出来的。积分制那段话是全文里分量最重的承诺,压的赌注也最大,但它在原文里只占了一句话。这句话背后的东西,比它表面看起来重得多——重到它应该有一份单独的文档,写清楚窗口多长、缓冲多厚、什么条件下会跟。
第 6 条看着像常识,但常识在执行里最容易打折扣。静默降级的诱惑是"用户不会发现",但用户不会发现的东西,恰恰是最容易变成事故的东西。
下一站
读完这一篇,建议你别急着去注册谁。先打开你自己那条链路上离你最近的一个 handler——就是那个收请求、鉴权、然后往上游发的地方——从第一行 auth 读到最后一行写响应,一边读一边把上面那五步标出来。
四个问题,回答完了你就知道自己站在聚合层还是落地层:
- 额度检查在哪一行?它读的是一个硬余额,还是一个软天花板?
- 预检在哪一行?它和 dispatch 之间隔了几行?中间那几行里,有没有可能在预检之后、dispatch 之前再花一次钱?
- 结算在哪一行?它记的数和预检记的数,是不是在同一张表里?
- 上游返回错误之后,代码走的是哪一条边?这条边上有没有第二次预算检查?
大部分人的答案会是:前两步在,后面三步只有一个影子。
至于那几件还没上的事——按功能分析、预算和使用控制——作者自己承认在路线图上。路线图上的东西不用现在评价,代码落地了再说。源码不会替自己吹,得有人替它说实话;同样的,路线图也不会替自己兑现。
地图给你了。剩下的路在你自己的 repo 里,不在任何一家的文档里。
