[控场AI]
· 10 分钟阅读· 5,202 字

上线AI功能前,先建一道「开销熔断器」

上线AI功能前,先建一道「开销熔断器」

免费AI额度仍是预算,需在代码中先算Token再算钱的熔断器。

文章以一个独立开发者周末耗尽免费额度的虚构故事切入,指出免费套餐并不消除预算,只是把计价单位从美元换成Token。作者提出用一个刻意保持无聊的熔断器来防止失控:仅追加账本负责记账,调用前闸门在预算用掉九成时关闭并返回确定性降级结果,调用后结算即使请求失败也要记录消耗的输入Token。模型调用被放在薄适配器后,避免锁定单一供应商。通过离线桩模型和可注入时钟的测试,可以在几毫秒内验证闸门确实会关闭。方案适合单进程独立应用,不适合多区域部署或高吞吐服务,且免费额度本身是持续变化的移动靶。

一个独立开发者在周五下午上线了一个AI按钮,把杂乱的客户留言整理成三条要点。演示效果不错,周末照常开始,没人盯着这个接口。

到了周一早上,这个接口已经处理了数万次调用。一个Worker的重试循环卡在超时上反复请求。免费额度耗尽,后续每次请求都在烧真金白银。

故事里的数字是示意性的,但失败的结构不是。免费套餐从不消除预算,只是把计价单位从美元换成了Token和CPU。当AI功能在凌晨三点失控时,谁在买单?对独立开发者来说,答案必须写进代码里,而不是靠人的注意力。

rss source: Build a Spend Breaker Before You Ship AI

吃掉免费额度的循环

大多数第一版代码长这样:

// worker.mjs
async function summarize(note) {
  while (true) {
    try {
      return await callModel(note);
    } catch {
      // retry forever, because "free"
    }
  }
}

这个循环没有刹车。一次超时、一个429或后端变慢,就能把一次用户操作变成数千次计费调用。服务商乐意一直应答直到配额耗尽,然后收费或拒绝。

开源项目MonkeyCode提供免费模型访问和免费服务器选项,均由运营方提供,免费Token额度目前标称为1000万。依赖任何具体数字前请先核对该项目的最新条款,因为额度和限制会变化。(披露:本文为MonkeyCode产品推广的一部分。)

免费额度终究是预算,只不过花的是Token而非美元,并且按别人的时间表刷新。因此熔断器先算Token,再算钱。在任何独立项目的第一天,这个顺序都很关键。

指数退避(Exponential Backoff)与抖动

示例中的 while (true) 之所以危险,是因为它隐含了零延迟、无上限的重试假设。生产环境里,应对 429 或 5xx 的标准做法是指数退避:第一次重试等待约 1 秒,此后每次按 2 的幂增长,并设置一个最大重试次数。单纯的退避仍会制造"惊群"——大量客户端在同一时间点齐步重试,把瞬时故障放大成持续过载。因此在退避间隔上叠加随机抖动(jitter),让每次等待落在 [base, 2×base] 之类的区间内,是常见组合。

对熔断器而言,退避解决的是"不要自己压垮服务商",而有界重试解决的是"不要让一次用户操作变成数千次计费调用"。两者必须同时存在:只有退避而没有上限,Token 消耗仍会在长时间故障中线性累积;只有上限而没有抖动,则可能在多个 Worker 之间形成周期性的账单尖峰。文章建议"重试两次后降级",正是把这两个约束落到具体数字上。

三块拼图:账本、闸门、结算

熔断器很小,刻意保持无聊。一个仅追加的账本、一个调用前的闸门、一个调用后的结算。

// spend.mjs
const TOKEN_BUDGET = Number(process.env.TOKEN_BUDGET ?? "200000");

export function estimateTokens(text) {
  // 粗略启发式:英文散文约4字符1个Token
  return Math.ceil(String(text).length / 4);
}

export function gate(text, now = new Date()) {
  const estIn = estimateTokens(text);
  const s = spendToday(now);
  const projected = s.in + s.out + estIn + EST_OUT;
  if (projected >= TOKEN_BUDGET * 0.9) {
    return { ok: false, reason: "token-budget", estIn, ...s };
  }
  return { ok: true, estIn, ...s };
}

闸门在预算用掉九成时关闭,而不是等到百分之百。闸门关闭后仍要回答用户,所以包装层提供一个确定性的降级结果,而不是错误页。

export async function guardedSummarize(note, { callModel, now }) {
  const verdict = gate(note, now);
  if (!verdict.ok) {
    settle({ route: "fallback", in: 0, out: 0, usd: 0, reason: verdict.reason }, now);
    return { text: cheapFallback(note), route: "fallback" };
  }
  try {
    const res = await callModel(note);
    settle({ route: "model", in: res.usage?.input_tokens ?? verdict.estIn, ... }, now);
    return { text: res.text, route: "model" };
  } catch (err) {
    settle({ route: "error", in: verdict.estIn, out: 0, usd: 0, error: String(err) }, now);
    throw err;
  }
}

注意catch分支:失败的尝试通常仍会消耗输入Token,所以即使请求报错也要记账,否则账面支出会低于实际账单,熔断器会在错误的时间点才关闭。

模型调用本身放在一个薄适配器后面。任何兼容OpenAI的端点都能用,熔断器不会把独立项目锁定在单一供应商。baseUrl、apiKey和model从环境变量读取,绝不把密钥硬编码进会被免费主机克隆的仓库。

仅追加账本(Append-Only Ledger)

账本是一个只能写入、不能就地修改的事件序列:每次调用追加一行记录,包含时间戳、路由、输入/输出 Token 数、估算或实际美元成本和原因。要修正错误,不能改写旧行,而是追加一条冲销记录。这种结构带来三个好处:审计时能完整重放当天发生了什么;崩溃后不会出现"部分写入"的中间状态(最坏情况是丢掉最后一行);并可以在读侧按天聚合出 spendToday(now)。

代价是文件会持续增长。对单进程独立应用,按天分片(如 spend-2026-10-16.jsonl)即可,既方便归档也方便删除。由于账本以纯文本 JSONL 存储,它同时也是一个便宜的调试工具:当闸门意外关闭时,直接搜索当天的 route: "error" 记录,往往比翻日志更快定位失控的重试来源。

用测试证明闸门会关

熔断器如果只是被读懂而从没被执行过,就毫无价值。下面是一个完全离线的测试,用桩模型和显式时钟:

test("the gate closes before the budget is spent", async () => {
  const now = new Date("2026-10-16T09:00:00Z");
  const routes = [];
  for (let i = 0; i < 40; i++) {
    const out = await guardedSummarize(note, { callModel, now });
    routes.push(out.route);
  }
  const total = spendToday(now);
  assert.ok(total.in + total.out < 1000, "stayed under the hard token budget");
  assert.equal(routes.at(-1), "fallback", "the gate closes and stays closed");
});

第一个测试循环四十次,仍然在几毫秒内跑完。这正是离线桩的意义。你担心的那个故障曾经花掉某个人一个周末,在这里只需一次测试运行。

时间可注入(Injectable Clock)与确定性测试

依赖真实 Date.now() 的代码很难测试跨天行为:你无法在不等待二十四个小时的情况下验证"新的一天预算重置"。把 now 作为参数传入、在测试中显式构造时间点,是把时间变成普通输入的标准技巧。生产代码用 new Date() 作为默认值,测试则传入固定时刻,于是同一段逻辑既能在线上对真实时间生效,又能在毫秒内遍历任意时间序列。

桩模型(stub model)同理:用返回固定 usage 的假函数替换 callModel,测试就不需要网络、不需要 API Key、也不会产生任何费用。这使"闸门关闭"这类预算相关的断言可以进入常规 CI——因为一次失败的预算测试如果不小心真的调用了远端模型,本身就会造成损失。文章强调"几毫秒内跑完",落点正是这种可重复、零成本、零外溢的验证方式。

免费服务器适合放在哪

把Worker和账本跑在同一个进程、同一台小主机上。免费服务器选项恰好适合这种形态:一个Node进程、一个SQLite或JSONL文件、一个外部模型端点。

账本路径放在应用目录内,而不是/tmp。重启不应该悄悄退还当天的支出。同时把进程限制为单实例,因为两个Worker写同一个文件可能导致重复计数或丢行。免费主机有自己的限制,在向客户承诺之前先读清楚当前条款,并决定冷启动或被限流时降级路径如何表现。

第一个月的路由矩阵

场景路由原因
用户点击,正在等待带闸门的模型调用延迟可接受,每次操作一次调用
后台任务遍历多行按每次运行上限批处理失控循环始于Worker而非UI
闸门已关闭确定性降级交付更差的答案,而不是故障
服务商返回429或5xx重试两次后降级无界重试会让Token开销成倍增长
记账必须精确都不用用计费系统,而不是JSONL文件

局限与谁该跳过

账本是进程内、基于文件的。适合单个独立应用,不适合跨区域运行。九成阈值只是一个猜测,应针对真实流量调整。Token估算与供应商计数存在偏差,端点返回用量报告时应以报告为准结算。

更重要的是,熔断器保护的是额度,不是正确性。糟糕的提示词在预算内依然返回糟糕的摘要。免费模型访问和任何免费套餐一样是移动靶,在把业务规则建立其上之前先核对当前条款。

如果你已经有供应商侧配额和计费仪表盘,可以跳过这套方案。对高吞吐服务,应在每次调用前放置网关,也跳过。如果你无法反复阅读供应商的限制,同样跳过。一个独立项目加一个AI按钮是最佳点位,因为一个JSONL文件和四十行Node代码换来一条真正重要的保证:周末保持无聊。

高吞吐场景下的网关(Gateway)方案

当调用量上升到多实例、多区域时,进程内的 JSONL 文件会同时遭遇两个问题:并发写入的竞态(两个进程读-改-写同一计数器,导致少计或重复计),以及无法跨地域共享状态(新加坡的 Worker 看不到法兰克福的支出)。这时通常引入一个独立的网关服务,所有模型请求先经过它,由网关在调用前集中检查配额、在调用后集中写入共享存储(Redis、数据库或供应商侧计量)。

网关的代价是多一跳延迟和额外的运维面。对只有一个 AI 按钮的独立项目,这笔开销远超收益;但一旦每天调用量进入百万级,进程内熔断器就会退化成"每个实例各自为政"的局部限制,无法兑现全局预算承诺。判断标准不是技术先进与否,而是你是否需要跨进程的单一事实来源。

#AI成本控制#Token预算#开销熔断器#独立开发者#Node.js#免费额度#AI功能上线
分享:

相关推荐