从用量日志入手控制 Spring AI 成本
文章讨论智能体在生产环境中因小规模低效累积而出现的 API 成本增长。示例通过 Spring AI 读取每次请求的输入、输出及总令牌数,再用 SQL 聚合分析消耗。
AI 深度解读
AI Agent 的账单,往往不是被某一次复杂推理拉高的,而是被每次请求都重复发送的系统提示词、历史消息和工具结果慢慢堆起来的。原文围绕 Spring AI 旅行助手,提出了一套成本优化顺序:先记录 token 用量,再压缩上下文、分配模型、减少重复调用。
这套思路值得借鉴,但文中的节省比例大多没有附测试数据,部分示例也有信息丢失和因果解释不严谨的问题。读它最有用的方式,是拿走排查路径,同时把「省钱且质量不变」当成需要验证的目标。
先查每次发送了什么,再决定从哪里省
作者认为,多数 Agent 的输出 token 只占总量的 10–20%,输入才是主要消耗来源。这个比例没有给出样本范围,不能直接套到代码生成、长文写作等场景,但它提醒了一个容易忽略的事实:用户只输入一句话,模型收到的可能是一大包上下文。
系统提示词、工具定义、历史对话和工具返回值,都可能随请求反复发送。
作者首先用 Spring AI 记录每次调用的用量:
ChatResponse chatResponse = chatClient.prompt()
.user(request.message())
.call()
.chatResponse();
Usage usage = chatResponse.getMetadata().getUsage();
log.info("[cost] conversationId={} inputTokens={} outputTokens={} totalTokens={}",
request.conversationId(),
usage.getPromptTokens(),
usage.getGenerationTokens(),
usage.getTotalTokens());
关键是 chatResponse.getMetadata().getUsage():它把讨论从「感觉上下文太长」变成了可以统计的输入、输出和总 token 数。不过,这段代码只演示日志记录,没有展示如何写入后面查询使用的 request_usage 表。
原文接着查询最近 INTERVAL '7 days' 的 AVG(input_tokens)、AVG(output_tokens)、AVG(total_tokens) 和 MAX(total_tokens)。平均值用来找日常消耗,最大值用来发现异常长对话。
还要区分 token 数和费用。不同模型的单价不同,输入与输出也不能默认按同一价格计算。原文给的是用量排查入口,尚不是完整的费用核算方案。
上下文每重复一次,冗余就多收一次钱
系统提示词是最容易检查的固定开销。作者举例:500-token 的提示词,每天调用 10,000 次,仅指令就要消耗 500 万 token。他把印度旅行助手的长段角色说明压缩为三句,保留服务范围、工具用途和事实来源约束,称 token 减少约 40%。
这里真正可复用的方法,是删掉重复表达,同时保留行为边界。压缩后是否仍能正确调用工具、拒绝编造,需要评测,不能只看文字更短。
历史消息的影响更大。原文用一段 20 轮对话举例:历史约 2,000 tokens,加上新消息 25、系统提示词 80、工具定义 200,单次输入约 2,300 tokens。用户的新问题只占很小一部分。
作者通过 MessageChatMemoryAdvisor,传入 redisChatMemory、AbstractChatMemoryAdvisor.DEFAULT_CHAT_MEMORY_CONVERSATION_ID,把窗口设为最后 10 条消息,而非 20 条。他认为旅行规划通常保留 8–12 条消息就够了,因为用户很少追问 5 轮之前的内容。
这里要看清单位:10 条消息不等于 10 轮对话。 原文也没有交代工具消息在这套配置中的具体计数方式,更没有注明 Spring AI 版本,接入时需要核对项目实际 API。
工具返回值同样会进入历史。作者把景点 JSON 改成简短字符串,声称减少约 60% token。但示例同时删掉了评分、开放时间和 lastUpdated: "2026-09-27",所以不能称为保留了相同信息。若用户接着问几点关门,压缩后的上下文已经无法回答。
应当先决定哪些字段必须保留,再比较 JSON 与文本格式的开销。
模型分流、缓存和批处理,分别省不同的钱
原文把模型分流视为影响最大的手段:查 Mysore 天气只需要简单判断和一次工具调用,规划 Karnataka 的 7 天预算旅行则需要多步推理,不必都使用同一档模型。
下面的代码用简单规则选择模型:
@PostMapping("/chat")
public ResponseEntity<String> chat(@RequestBody ChatRequest request) {
String model = selectModel(request.message());
String response = callAgentWithModel(request, model);
return ResponseEntity.ok(response);
}
private String selectModel(String message) {
// 简单启发式规则,需要根据流量特征继续调整
boolean isComplex = message.length() > 200
|| message.contains("plan")
|| message.contains("itinerary")
|| message.contains("compare")
|| message.toLowerCase().contains("week");
return isComplex ? "gemini-1.5-pro" : "gemini-1.5-flash";
}
最需要审视的是 isComplex:字符数大于 200,或者出现几个英文关键词,并不能稳定代表推理难度。中文请求不会命中这些英文词,而且只有 week 做了小写转换。这段代码适合说明分流思路,不能直接承担通用路由策略。
作者称 Flash 类模型通常便宜 10–20 倍;若简单请求占 70%、复杂请求占 30%,模型费用可下降 60–70%。这些数字依赖价格和流量结构,也隐含两类请求用量相近等条件。原文没有提供实际账单或分类评测结果,示例模型名也不能当作当前选型结论。
缓存解决的是另一部分成本。原文给 getWeather("Mysore") 加上 @Cacheable(value = "weather", key = "#city"),并保留 @Tool 和 @ToolParam,让相同城市查询复用结果。
这能减少天气服务调用,但原文所说的「缓存会让工具回复更短」没有从代码中得到支持。若缓存返回相同字符串,模型仍会收到相同长度的内容,直接节省的是外部工具 API 费用。缓存有效期也没交代,而天气结果显然有时效要求。
作者还建议在系统提示词里禁止重复调用历史中已有结果的工具,声称可减少 20–30% 工具调用。这个比例同样缺少实测依据,规则也需要考虑结果是否过期。
最后是 Batch API。原文将 100 次独立调用作为对照,称批处理约省 50%,结果在 24 小时内返回,适用于文档处理、摘要和夜间评测。它没有指定供应商,也没有给出可运行的批处理接口,因此这些条件需要按实际服务确认,不能套到实时聊天。
我的判断
我认可作者把优化建立在评测之上的顺序:先记录基线通过率,每次只改一项,重新评测,上线后继续观察一周。这比同时缩提示词、砍历史、换模型更容易定位质量变化。
但「不影响质量」在原文中更多是目标,而非已经证明的结论。提示词减少约 40%、工具结果减少约 60%、重复调用减少 20–30%,都没有展示测试集、样本量或对照结果,应当作为经验参考。
最该优先削减的是重复传输,最该谨慎削减的是任务所需信息。
与简单截断历史相比,保留摘要或关键状态也是可比较的做法,但原文没有提供这些方案的成本与质量对照。不能仅凭旅行助手的 8–12 条消息经验,就决定其他 Agent 的窗口。
工具格式也没有统一答案。短文本适合直接交给模型阅读;结构化结果方便程序校验和后续处理。原文的景点示例证明了删字段能变短,却没有证明自然语言一定比同等信息的 JSON 更合适。
能拿来做什么
- 给现有 AI 接口补上用量记录,先观察一周的输入、输出、平均值和最大值,再结合模型单价判断费用集中在哪里。Node 服务也可以沿用这套记录思路,具体用量字段按 SDK 返回值处理。
- 为长对话准备回归样例,覆盖早期约束、连续追问和工具结果复用。逐步缩小历史窗口,确认预算、日期等条件仍能保留,再决定窗口大小。
- 把简单查询和多步规划分组评测,再考虑模型分流。中文业务应使用实际中文请求验证规则,不能照搬
plan、itinerary等关键词。 - 检查工具返回字段和重复调用日志,分别处理无用字段、相同参数重复请求和过期数据。离线摘要、文档处理及评测任务,再单独核对供应商 Batch API 的价格与时限。