
最近折腾大语言模型(LLM)网关,踩了几个成本失控的坑,这篇把 B2B SaaS(面向企业的软件即服务)里的 FinOps(财务运营)架构说清楚。典型事故并不复杂:财务负责人把模型厂商的账单转过来,只标出了一行——一个月花了三倍的钱,收入却纹丝不动。追查后发现,某个使用入门版套餐的租户,每晚都有一个脚本把同一份 40 页合同送去摘要接口,连续请求 600 次;更麻烦的是,SDK(软件开发工具包)调用默认选择了最贵的模型,而整条链路没有任何一层知道这些 Token 属于哪个租户。
这篇内容借助 AI(人工智能)工具辅助整理,但配置、代码路径和技术结论我都逐项核对过。我目前运营 Loop Origin,主要为 B2B SaaS 团队开发定制 Web 应用。这也是整个系列的最后一篇:如何通过网关层,把原本无上限的 LLM 调用,变成可计量、可控预算的标准产品能力。
📚 系列路线图——生产级 AI 与 SaaS 系统工程
- 第 1 篇:构建生产级 MCP(模型上下文协议)服务器与工具调用 AI 智能体:沙箱、Schema 与审批门禁
- 第 2 篇:Laravel 与 Node.js SaaS 零停机数据库迁移:Expand-Contract 与锁超时
- 第 3 篇:基于 Redis/Valkey 的多级缓存与击穿保护:Singleflight 锁与 SWR
- 第 4 篇:SaaS 按量计费与计量架构:事件采集、汇总与 Stripe 发票
- 第 5 篇:低成本生产可观测性:OpenTelemetry 链路追踪、结构化日志与尾部采样
- 第 6 篇(本文):面向 B2B SaaS 的 LLM Gateway 与 FinOps 架构:语义缓存、路由与租户配额
直接给结论:LLM 网关是一个内部服务,所有模型调用都要经过它。它负责识别租户和功能,在调用前检查 Token 预算,命中精确缓存或语义缓存时直接返回,按质量要求把任务路由到成本最低的模型,并在调用结束后记录实际 Token 用量,让成本准确落到对应租户的账本上。
大多数 SaaS 代码库刚开始接入 AI 时,都是控制器直接调用模型厂商的 SDK。第二个功能上线时,开发者复制一遍代码。等第五个功能出现,系统里就有了五套模型选择逻辑、五套重试策略,没有共享缓存,API(应用程序接口)密钥还散落在三个服务里。
此时没人能回答财务一定会问的两个问题:这些 Token 是哪个租户消耗的,以及如果换成便宜模型,这次调用要花多少钱。
网关把四个不该散落在产品代码里的决策集中起来:
Laravel API ──┐ Node workers ─┼─► LLM Gateway ──► quota check (Redis) ──► cache (exact → semantic) MCP agents ──┘ │ │ miss │ ▼ │ router: task class → model tier │ │ └──── usage events (Part 4 meter) ◄── provider A / B / self-hosted
网关既可以采用支持二次配置的开源代理,也可以自行实现一个轻量服务。无论哪种方式,产品代码提交的应该是任务,而不是模型名称:{ tenant_id, feature: "contract_summary", input }。
调用前预留一笔预估 Token 成本,调用结束后再按实际用量结算。用 Lua 脚本在 Redis 或 Valkey 中原子完成检查与预留:
-- KEYS[1] = budget:{tenant}:{yyyy-mm} ARGV[1] = estimate ARGV[2] = monthly limit
local used = tonumber(redis.call('GET', KEYS[1]) or '0')
if used + tonumber(ARGV[1]) > tonumber(ARGV[2]) then return -1
end
redis.call('INCRBY', KEYS[1], ARGV[1])
redis.call('EXPIRE', KEYS[1], 40 * 24 * 3600)
return used + tonumber(ARGV[1])
// gateway/budget.ts
export async function reserve(tenant: Tenant, estimate: number) { const key = `budget:${tenant.id}:${monthKey()}`; const res = await redis.eval(RESERVE_LUA, 1, key, estimate, tenant.plan.monthlyTokens); if (res === -1) throw new QuotaExceeded(tenant.id, tenant.plan.name);
} export async function settle(tenant: Tenant, estimate: number, actual: number) { await redis.incrby(`budget:${tenant.id}:${monthKey()}`, actual - estimate); await meter.emit({ tenant: tenant.id, metric: "llm_tokens", value: actual}); // Part 4 pipeline
}
输入 Token 可以用模型厂商的 tokenizer 估算,再加上请求中的 max_tokens 输出上限。稍微高估没有问题,结算阶段会自动修正;真正重要的是让那个每晚请求 600 次的循环提前撞墙,而不是月底继续收到账单。
先做精确缓存:对标准化请求计算哈希,缓存键包含模型档位、系统提示词版本、输入和 temperature,然后直接查询。它成本低,也没有质量风险。实际跑下来,它命中的比例通常比团队预期得高,因为批处理任务和重试经常重复发送完全相同的输入。
再做语义缓存:对输入生成向量,在按租户和功能隔离的向量索引中检索,只有相似度超过严格阈值时才返回缓存答案。
const hit = await vectors.search({ namespace: `${tenant.id}:${feature}:${promptVersion}`, vector: await embed(input), topK: 1,
});
if (hit && hit.score >= FEATURE_THRESHOLDS[feature]) return hit.payload; // e.g. 0.95
要避免语义缓存变成数据泄露或错误答案生成器,必须守住三条规则:

AI 生成图片。
多数 SaaS AI 功能的难度并不相同。先统一分类,再把每类任务映射到对应模型档位:
| 任务类别 | 功能示例 | 模型档位 | 降级方案 | 是否可缓存 |
|---|---|---|---|---|
| 抽取 / 分类 | 给工单打标签 | 小型快速模型 | 第二个小型模型 | 精确 + 语义 |
| 摘要 | 合同摘要 | 中档模型 | 使用更短输出的较小模型 | 精确 |
| 推理 / 生成 | 起草方案 | 旗舰模型 | 中档模型 + 告警标记 | 很少 |
| 智能体工具调用(MCP) | 多步骤工作流 | 支持工具调用的旗舰模型 | 停止执行并转人工 | 否 |
| 向量嵌入 | 搜索、语义缓存 | 嵌入模型 | 进入队列后重试 | 不适用 |
# gateway/routes.yaml
contract_summary: primary: { provider: provider_a, model: mid-tier, max_tokens: 800 } fallback: { provider: provider_b, model: small, max_tokens: 500 } timeout_ms: 20000 plans: starter: { tier_override: small }
ticket_classify: primary: { provider: self_hosted, model: small } fallback: { provider: provider_a, model: small }
plans 覆盖规则是 FinOps 和定价体系的交汇点:入门版租户使用更便宜的模型档位,企业版租户使用效果更好的档位,成本差异直接体现在定价页面上,而不是悄悄吞进毛利。
切换到更便宜的路由前必须验证。可以用一批真实但已匿名的输入回放两个模型档位,再用自己的评分标准对输出打分。没有评测就切换路由,本质上是在赌。
重试是 LLM 成本悄悄膨胀的重灾区。第 3 篇讲缓存时提过,重试风暴会把一次故障放大成大量请求。在网关层需要做到:
复用第 5 篇的 OpenTelemetry 配置。每次网关调用对应一个 Span,并附带低基数属性:
span.setAttributes({ "llm.feature": feature, "llm.route": route.name, "llm.model_tier": route.tier, "llm.cache": cacheResult, // "exact" | "semantic" | "miss" "llm.tokens.input": usage.input, "llm.tokens.output": usage.output, "tenant.plan": tenant.plan.name, // plan, not tenant ID, to keep metrics cheap
});
基于这些 Span 生成指标,用于观察各功能成本、缓存命中率和降级率。租户 ID 只保留在用量事件和计费账本里,不要放进指标标签,否则监控成本会随着租户数量一起膨胀。
| 指标 | 直接调用 SDK | 接入网关 |
|---|---|---|
| 谁消耗了成本 | 未知 | 每次调用都记录租户和功能 |
| 失控脚本 | 整晚持续运行 | 被月度预算拦截 |
| 重复输入 | 每次都付费 | 命中精确缓存 |
| 模型选择 | 取决于 SDK 默认配置 | 按任务类别和套餐决定 |
| 模型厂商故障 | 功能不可用 | 降级路由 + 熔断器 |
| 计费 | 直接吞进毛利 | 计量后可生成账单(第 4 篇) |
如果现有开源 LLM 代理已经支持你使用的模型厂商、预算和日志能力,可以先用它。等你需要把租户级配额与自有计费数据打通时,再开发一个轻量服务。大多数 B2B SaaS 产品最终都会走到这一步。
在 Redis 中执行预算检查,再查询一次精确缓存,通常只增加几毫秒。语义缓存还需要调用一次嵌入模型,因此只应为命中率足以覆盖成本的业务开启。
没有统一答案。先从严格阈值开始,例如 0.95 的余弦相似度,同时记录接近命中但未命中的样本,人工检查后再针对具体功能逐步放宽。
使用模型厂商的 tokenizer 库统计输入 Token,再加上 max_tokens 输出上限,最终按照厂商返回的真实用量结算。
可以,前提是计量足够准确。把结算后的 Token 用量写入与其他用量事件相同的流水线,再按套餐定价,并在产品界面明确展示限额。
智能体完成一个任务往往要调用很多次模型,因此更需要网关。每次智能体运行都应单独预留预算,超限后停止运行,并触发人工审批门禁。
至此,“生产级 AI 与 SaaS 系统工程”系列就全部结束了。六篇文章里,我们给 MCP 智能体加了沙箱,实现了数据库零停机迁移,挡住了缓存击穿,为用量计费建立了完整流水线,用低成本方式实现了链路追踪,又在每次模型调用前加上了预算和路由。
贯穿这些工作的原则其实很朴素:在每个昂贵或高风险操作前面设置一个明确的控制点,持续测量通过控制点的调用,并让产品代码只表达业务目标,而不是把基础设施选择写死。具体环境如何落地,欢迎在评论区交流。