M19 · 用户级 credit 配额与用量计费#
一句话:项目里已经有两把「测量 token」的尺子了,但没有一把能拦住一个人连发一百条 query。
一、背景:已有的两把尺子都不管用#
需求很直白:「每个用户限制一个 token 使用量,到顶就不能用了,类似 Claude 或 ChatGPT 的 credit。」
动手前先查了一遍现状,结果发现项目里已经有两层 token 记账了——这让这个里程碑的第一个问题变成了: 既然都记了,为什么还拦不住?
- 一层是事后测量:每轮跑完,把这一轮各次模型调用的 token 聚合出来,发日志和 Langfuse(携带上下文 大小、缓存命中率)。它只「看」,不「拦」,也从不落库。
- 一层是单次任务的成本闸:一次任务(一棵 fork 树)烧过预算,就在请求模型前把「成本放大器」工具 (fork、检索、品类洞察)从模型可见的工具表里摘掉,逼它用现有候选收尾。这把闸是真的会拦的。
但它拦的是单条 query 把 Agent 带进无底洞。它对「同一个人连发一百条合规 query」完全无感——每一条都 在预算内、每一条都合规,加起来照样把账单打穿。而且它住在进程内的一个字典里,任务一结束就清掉: 它压根不记得你是谁、昨天烧了多少。
所以缺的那把尺子有两个特征,正好是已有两把都不具备的:跨会话累计,且跟人绑定。这决定了它必须 落库——这是这个里程碑几乎所有设计的起点。
二、关键决策与取舍#
1. 判定口径:为什么是「成本」,不是用户嘴里说的「token 数」#
需求原话是「限制 token 使用量」,但我最后按**成本(美元)**计费,理由是 prompt cache。
这个项目在多轮追问时会大量命中缓存前缀,命中的那部分 input token 单价只有原价的四分之一。如果按 token 裸数记账,等于把「靠缓存省下来的钱」当成用户真花的钱——追问越多的用户被罚得越狠。而追问恰恰 是这个产品最希望用户做的事(一轮轮收敛需求,正是购物 Agent 的价值所在)。按成本计费,缓存省下来的钱 就真的省给了用户。
计价逻辑不用新写:单次任务的那把成本闸早就有 input / output / cache_read 三档单价了,直接复用。
2. 但用户看到的不是美元,是 credit#
成本是我们的口径,不该泄给用户:它一是暴露了供应商费率,二是会让人误以为这里能充值(而这只是个 demo,没有支付)。所以对外一律换算成整数 credit(1 credit = $0.001)。
这个刻度是挑过的:一次典型的购物任务约 10~50 credit,默认日额 2000 credit——数字落在「几百到几千」这个 人类好读的区间。要是 1 credit = $1,用户余额就永远是「1.85」这种小数,毫无手感。
3. 周期:跨天「换一行账」,而不是写个定时任务去清零#
用户选了每日重置。最直觉的实现是起一个定时任务,每天零点把所有人的用量归零——但那是错的形状。
账本的主键是 (用户, UTC 自然日)。新的一天第一次记账时,自然 upsert 出一行新的、从 0 起算;老行留着
就是历史账单(将来要画「近 30 天用量曲线」直接查它)。重置不是一个动作,而是「换了一行账」的副作用
——不需要定时任务,也就不存在「定时任务挂了导致全体用户第二天用不了」这种事故。
用 UTC 而不是服务器本地时区:时区一改,用户的额度会凭空多出或蒸发一段。
4. 一行汇总,而不是一张流水表#
配额判定是最热的读路径(每次发任务查一次、前端余额条也查)。流水表要 SUM() 全表扫,而一行汇总一次
主键命中就拿到累计值。
那审计怎么办?——流水本来就有。每一轮对话的 token 与成本早就落在对话消息里了(前端在每轮右下角 显示的「用时 / token 消耗」就是它)。没必要为了审计再存第二份。
5. 记账要不要「实时」:不要#
一个很自然的想法是每次模型调用一返回就往库里写一笔,让余额条实时往下掉。我没这么做:一次任务有几十 次模型调用,那就是几十次数据库写,换来的只是一个更炫的进度条。改成任务收尾时一次性记全树成本 (主 loop + 所有 fork 出来的子 Agent),一次任务一次写。
代价是诚实的:任务进行中余额条不动,跑完才掉。这对一个 demo 完全够。
6. 最后一次任务能透支多少:把剩余额度压成本次任务的上限#
这是我觉得整个里程碑里最值得讲的一个设计。
入口闸只能判「你还有没有余额」——判完就放行了。那么一个只剩 $0.01 的用户,照样能起一趟烧满 $0.50 的任务:闸放了行,之后就没人管了。他一天能这么透支多少次?只要每天留一分钱不用完,就能每次白嫖近半个 任务预算。
修法不是把入口闸修得更严(余额不足就不让发?那用户最后那点额度永远用不掉),而是让两把闸咬合:
任务开跑时,把「今日剩余额度」压成这次任务的成本上限——取 min(单任务预算, 今日剩余)。这样即便入口
放行了最后一次任务,它也只能透支到「额度刚好用尽」为止,超出部分由原有的那把硬闸夺权收尾。
两把闸各管各的维度(一次任务 / 一整天),但在这里咬合成一个连续的防线。
7. 配额只在「开着鉴权」时生效#
鉴权关闭的 demo / 本地开发模式下,所有人共用一个假身份。对这个假身份记账,等于「一个人烧完,全体停用」 ——既不公平,也拦不住真正想薅的人(不登录就没有身份可限,那道门本来就该由鉴权来关)。所以配额和鉴权 是捆绑的:没有可信身份,就没有可执行的配额。
三、三个「差一点就错了」的地方#
一、取消任务时,账差点不记#
记账写在任务收尾的清理段里。而这条路径最常见的触发者恰恰是「用户点了取消」——此时这个任务已经被 取消了,代码里任何一个需要等待的操作(比如写数据库)都会在第一个挂起点就被打断。
结果就是:用户烧完 token 再点取消,就能一分钱不花。 而且这个白嫖姿势毫无门槛——跑到一半觉得结果 不满意,随手一点取消,本来就是最自然的用户行为。
修法是让记账在一个「盾牌」后面跑完(取消信号照常向上传播,但记账那件事本身不被打断)。
这里的判断是:token 已经烧掉了,就必须记账。 记账的依据是「花没花钱」,不是「任务成没成功」—— 超时同理。这条原则一确定,取消 / 超时 / 异常三条路径就都归位了。
二、额度耗尽,差点把「回复 Agent 的提问」也锁死#
前端的做法是:额度耗尽就把输入框禁用。写完才反应过来——Agent 会主动向用户提问(澄清需求时会停下来 等一句话)。
如果这时候把输入框一锁:那一轮任务早在入口就放行了、成本上限也压好了,它正停在那儿等用户答话。用户 既回不了话,任务也只能一路等到超时——用户白等一场,而那一轮的 token 照样烧了。
所以锁的粒度必须是「不能再起新任务」,而不是「不能打字」。已经放行的任务有权把话说完。
顺带一个同类的口子:空态那几个「示例意图」卡片当时还能点,点下去只会打一次注定被拒的请求,再把这一轮 标红。输入框锁了,就得把所有能起新任务的入口一起锁。
三、一个折腾了很久的幽灵:改了配置却完全不生效#
验收时要让前端连到我临时起的后端,于是改了 Vite 的代理端口。改完毫无反应,请求还是打到老后端。 重启、清缓存、换启动方式,全都不行——但代理明明是生效的(请求确实被转发给了某个后端)。
真相是:目录里躺着一个 vite.config.js——它是某次 TypeScript 编译带 emit 跑出来的产物,被
gitignore 了所以看不见,而 Vite 优先读 .js 而不是 .ts。我改的那份 .ts 从头到尾没人读过。
这个坑的可怕之处在于它完全静默:配置改了、服务重启了、看起来一切正常,只是你的改动被一个隐形文件
彻底覆盖了。它不属于这个里程碑,但它意味着这个仓库里任何人对 Vite 配置的改动都是无效的——待办是
直接删掉那两个编译残留(vite.config.js / vite.config.d.ts)。
教训:当「改了配置却完全不生效」时,先怀疑「我改的这个文件根本没被读」,而不是反复怀疑自己改错了值。
四、验收:真的把额度烧穿了一遍#
后端 779 个测试全绿,新增的 6 条覆盖:累加、跨日重置、耗尽后拒任务、demo 模式不设闸、剩余额度压低任务 上限。
但真正让我确信的还是浏览器里那一遍:注册一个账号 → 烧到 850/1000,顶栏余额条变成琥珀色的「150 credits」→ 再烧穿 → 余额条变红「0 credits」、输入框灰掉、弹出「今日 credit 已用完,Wed 08:00 重置后 可继续」,而后端对新任务如实返回 402。
重置时刻这一项特意看了:后端存的是 UTC 零点,前端显示的是「Wed 08:00」——它按用户本地时区(UTC+8) 换算了。要是原样显示 UTC,用户会以为额度在半夜某个奇怪的钟点才恢复。
五、面试可讲点#
- 「已经有的东西为什么不够用」比「要做什么」更值得先想清楚。 项目里已有两层 token 记账,需求方 和我第一反应都是「再加一个限制」;但真正的问题是:已有的闸是进程内的、单次任务的,而需求要的是 跨会话的、跟人绑定的。想清这两个维度的差别,落库、按人聚合、按天分行,全都是必然推论。
- 计费口径的选择是产品决策,不是技术细节。 按裸 token 计费会惩罚多轮追问的用户,而多轮追问正是这个 产品的核心价值。按成本计费(缓存命中享折扣),等于把技术优化的红利真的返给用户。
- 「重置」不该是一个动作。 定时清零要维护一个定时任务,而且它挂了就是全体用户第二天用不了。改成 按「(用户, 日期)」分行,重置成了「换一行账」的自然结果——把状态变更转成数据分区,是消除定时任务的 常见手法。
- 闸门之间要咬合,不能各管各的。 入口只判「有没有余额」,那最后一次任务就能透支整整一个任务预算。 把「今日剩余」压成「本次任务上限」,两把闸就接成了一条连续防线。
- 记账的依据是「花没花钱」,不是「任务成没成功」。 由此推出:取消、超时、异常全都要记账——而取消 路径恰恰是最容易漏的那条(任务已被取消,你的记账代码根本跑不完)。这是个白嫖漏洞:烧完再取消就免单。
- 静默失效的配置。 一个 gitignore 掉的编译产物
vite.config.js悄悄覆盖了vite.config.ts,让所有 配置改动无效。排查时的关键一问是「我改的这个文件,到底有没有被读过」。
可能的追问:
- 为什么不实时扣费?—— 一次任务几十次模型调用,实时扣就是几十次数据库写,只为一个更炫的进度条。任务 收尾一次性记全树成本,代价是任务进行中余额条不动(已如实标注)。
- 并发发任务会不会把额度算漏?—— 累加是在数据库里做加法(
cost = cost + ?),不是「读出来、加完、写 回去」;后者在并发下必然丢更新。首次插入撞车由唯一索引挡下,重跑一次累加即可。 - 用户能不能透支?—— 能,但有界:最多透支到「额度刚好用尽」。因为任务上限已被压成今日剩余,越线后 原有的硬闸会摘掉成本放大器工具、逼它收尾。说清边界比假装没有边界更重要。
- 为什么 demo 模式不限?—— 没有可信身份就没有可执行的配额。鉴权关闭时所有人是同一个假身份,限它等于 「一个人烧完全体停用」,还拦不住真想薅的人。