Spring AI 如何限制令牌预算与工具循环
Token Allowance 为 Spring AI ChatClient 增加用户、租户和会话级令牌及金额预算,并限制单次请求的模型调用次数。文章解释了请求限流为何无法控制工具循环和历史上下文累积带来的费用。
AI 深度解读
一次 /agent 请求,可能触发 40 次模型调用;每轮还会把不断增长的对话重新发给服务商。HTTP 限流器只记了一次,模型账单却按每次调用的 token 累加。
作者为此做了 Token Allowance,给 Spring AI 加上 token、美元预算和单次请求的调用上限。它真正值得读的地方,是预算检查该放在哪、并发怎样扣款,以及拿不到可靠用量时怎么处理。这些问题同样值得做 AI 应用的程序员提前想清楚。
限制请求次数,为什么管不住模型账单
Bucket4j、Resilience4j 可以限制请求频率,但一次「hi」和带着 30 条历史消息的聊天,成本并不相同。工具调用又会继续放大差距:模型决定调用工具、读取结果、再次推理,每轮都可能产生新的模型费用。
所以作者把控制单位下沉到每次模型调用,并允许按用户、租户、会话设置预算,另加全局额度和单次请求上限。
这个 Spring Boot starter 的 Maven 坐标是 io.github.ultron03:token-allowance-spring-boot-starter:0.3.0。下面的配置同时约束用户日预算、全局月预算和工具循环次数:
token-allowance:
pricing:
models:
- name: gpt-4o-mini
input-per-mtok: "0.15"
output-per-mtok: "0.60"
limits:
- { scope: user, window: daily, max-cost: "0.50" }
- { scope: global, window: monthly, max-cost: "200.00" }
per-request:
max-model-calls: 10
这里要盯住 input-per-mtok 和 output-per-mtok:单位是美元/百万 token,价格需要手工填写。这是原文配置示例,不能直接当作当前报价。
控制器继续注入 Spring AI 提供的 ChatClient.Builder。用户身份依次取自调用中的 advisor 参数、Spring Security principal,以及网关设置的请求头。用户当天花满 0.50 美元后,后续请求会返回 HTTP/1.1 429 Too Many Requests,内容类型为 application/problem+json,错误标题为 Token budget exceeded。
示例中的 Retry-After: 41520 表示距离当天窗口重置还有 41520 秒,并非固定等待时长。
检查放错位置,工具循环就拦不住
Spring AI 会按 getOrder() 执行 advisor 链。注册工具时,ChatClient 会加入 ToolCallingAdvisor,工具循环就在它内部:执行后续链、检查模型是否要求调用工具、运行工具,再执行后续链。
作者先做了一个小实验:假模型调用一次工具后回答,在 ToolCallingAdvisor 前后各放一个计数 advisor。前面的计数是 1,后面是 2。
这是文中明确做过的验证,也是整个设计的支点。
循环前面的 advisor 每个请求只经过一次,适合检查用户是否已经超额;循环后面的 advisor 每次模型调用都会经过,才能在第 10 次调用之后阻止下一次。如果两处都累计用量,同一轮工具调用又会被重复收费。
因此,Token Allowance 分成两个职责:Admission 在循环之前检查准入,Metering 紧挨模型执行每次调用的计量。
端点还可以拥有自己的限制,例如给 agent 设置最多 5 次模型调用、单次请求最多 0.05 美元:
ChatClient agentClient = builder
.defaultAdvisors(a -> a
.param(RequestLimitParams.MAX_MODEL_CALLS, 5)
.param(RequestLimitParams.MAX_REQUEST_COST, "0.05"))
.build();
关键是两个 RequestLimitParams 参数:它们作用于这个客户端所承载的请求。完成 5 次模型调用后,下一次会收到说明命中哪项限制的 429。
演示使用 managerOf 工具遍历组织关系,在 Gemini profile 下触发上限。作者提到,llama3.2 这类小型本地模型经常提前停止调用工具,因此不容易复现长循环。五次 Gemini 调用按 Google 公布的费率折算为 $0.000418;这是演示中的成本计算,不能理解成免费层实际扣了这笔钱。
先预占额度,才能挡住并发穿透
假设用户已经花了 40 美分,上限是 50 美分。两个并行请求都预计花费 8 美分。如果执行顺序是「查余额、调模型、记账」,它们都会看到还剩 10 美分,最终累计到 56 美分。
作者改为先预占:每次调用之前,原子检查用户、租户、全局等所有相关预算,并一起写入预估占用。任意一项不足,全部不预占,也不调用模型。
这样,第一个请求把占用从 40 推到 48 美分,第二个请求只能看到剩余 2 美分,便无法再预占 8 美分。调用结束后,再用服务商报告的真实用量替换预占值。
但预估会引出另一种体验问题。
默认算法按提示词每 3 个字符估算 1 个 token,再加请求设置的最大输出 token;没有设置时按 1,024 计算。作者认为,这对英文和代码偏保守。但用户只剩 800 token 时,即使答案实际只需 100 token,也可能被拒绝。
0.3.0 增加了 estimate.output-tokens,允许按典型答案长度预占。代价是长回答仍会全额入账,实际用量超过预估的部分可能让预算超限。
预占解决的是并发抢余额,预估误差仍然需要承担。
金额使用 long 保存微美元,不用 double。理由包括精确比较限额、适配只处理整数的 Redis INCRBY,以及保证测试结果确定。每次调用按总成本向上取整一次。
多实例部署则需要共享账本。默认内存存储适合单实例;Redis 版本分别用一个 Lua 脚本完成预占和结算。所有 key 带 {ta} hash tag,落到同一个 Redis Cluster 节点,才能在一个脚本里处理相关预算。
Redis 故障时,fail-closed 返回 503,fail-open 放行且不计量。默认策略随模式变化:只记录超额的 observe 模式放行,执行拦截的 enforce 模式拒绝。
服务商都报 token,为什么还不能直接计费
接口字段相似,不代表统计口径相同。
OpenAI 的 prompt_tokens 包含缓存读取的 token;Anthropic 的 input_tokens 只统计最后一个缓存断点之后的部分,缓存读写另行报告。一个带大型系统提示词的 Anthropic 缓存请求,可能只显示 input_tokens: 12。
Spring AI 却把二者都映射为 promptTokens。直接拿它乘单价,Anthropic 缓存调用看起来就接近免费。作者因此先统一输入用量口径,再分别计算缓存读写费用。
流式响应还有另一层问题:Ollama 每个分块都可能携带 usage 对象,但最终分块之前一直是 0/0;OpenAI-compatible 端点如果关闭 stream usage,整个响应都可能是 0/0。
因此不能只检查 usage 是否为 null。这个实现把零用量视为缺失,改收预占估算值,并通过指标标记本次使用了估算。
这条兜底规则也说明,它维护的是应用侧预算账,不能替代服务商账单。
我的判断
我认可作者先验证 advisor 顺序,再确定计量位置的做法。请求限流仍然能防止入口被高频访问,token 预算则约束模型消耗,两者解决不同问题,适合一起使用。
但文中没有并发压测、吞吐开销或其他预算方案的对照数据。「每 3 个字符估算 1 个 token」也是经验规则,原文没有证明它适用于中文或混合语言输入。
另外,只有 ChatClient 调用会被统计。直接调用 ChatModel、使用 embedding 模型都会绕过 advisor。固定窗口也存在边界突发:一分钟额度可以在 10:00:59 花完,到了 10:01:00 再花一轮。加上预估偏差,它更适合作为应用层成本护栏。
价格还需自行维护。作者称没有服务商提供价格查询 API,并计划加入内置价格表,但文中没有给出这一说法的覆盖范围。实际使用时,应确认自己所用模型的价格来源和更新方式。
能拿来做什么
- 如果正在用 Spring AI 做工具型 agent,可以先设置
per-request.max-model-calls,为异常循环提供明确终点,再逐步加入用户、租户和全局预算。 - 上线前先用
observe模式收集用量,将估算成本与服务商账单核对,再决定输出预占长度和拦截阈值。 - 多实例服务应使用共享预算存储,并提前确定 Redis 故障时返回 503 还是允许继续调用,让故障行为符合业务预期。
- 给聊天页面接入 429 和
Retry-After提示,同时排查后端是否存在直接调用ChatModel的路径,避免界面显示有预算限制,实际仍有调用绕过计量。