文章分析输出及推理令牌、不断增长的对话历史和重复静态上下文如何推高 LLM 成本,并讨论 Spring AI 中的聊天记忆与提示缓存控制。重点是通过限制生成、管理历史和复用输入降低调用费用。
假设指标已经就位,每项任务都使用了真正适合它的模型,并且每项功能都有自己的 client——这些是第 1 部分讨论的内容。接下来要关注的是,这些 client 发送和接收了什么。
这一部分讨论如何控制输出 token 的生成、聊天记忆以及静态输入 token。
Driver #3——输出与推理 token:成本更高的方向
Driver #4——对话历史:每一轮都要为整段聊天付费
Driver #5——重复的静态内容:为相同 token 反复支付全价
价格说明:需要比较比例的地方采用公开标价,其他地方则以每百万输入 token 1 美元作为示例费率。完整说明见第 1 部分。
模型生成的每个 token,其成本都是你发送一个 token 的数倍。推理模型还会生成隐藏的“思考”token,而这些 token 也会按照更高的输出费率计费。
这种差距在每份价格表上都能看到。以 OpenAI 的价格表为例(2026 年 8 月),gpt-5 的输入价格是每百万 token 1.25 美元,输出价格是 10 美元——相差 8 倍。在 Anthropic 的价格表中,Sonnet 5 的输入价格是 2 美元,输出价格是 10 美元——相差 5 倍。(这是截至 2026 年 8 月 31 日的推广价格;标准价格为 3 美元/15 美元,仍然保持 5 倍的比例。)
具体数字经常变化,但规律始终没变:多年以来,输出 token 的成本一直是输入 token 的数倍。推理会让问题变得更加严重。一个模型可能会消耗 2,000 个思考 token,最终只生成 200 个 token 的答案,因此你总共要为 2,200 个输出 token 付费——是用户实际看到文本量的 11 倍。在繁忙的 endpoint 上,一个啰嗦的模型最终产生的成本,可能超过所有输入流量的总成本。
Spring AI 在这里提供了两种控制手段。第一种适用于所有地方:在 provider 支持的情况下,ChatOptions.builder().maxTokens() 可以提供一种可移植的 completion 长度限制。你应该把它看作防止响应失控的安全网,而不是提升质量的工具——被截断的答案仍然会按全部用量计费,因此还应配合要求简短回答的 prompt 指令使用。第二种控制手段由 provider 决定,并被隔离在对应 provider 的 options 对象中:OpenAI 提供 reasoning-effort 设置,Anthropic 则提供思考 token 预算。
// Provider-independent token limit
ChatOptions.Builder capped = ChatOptions.builder()
.maxTokens(400);
// Provider-specific reasoning control, isolated in one options object
OpenAiChatOptions.Builder lowEffort = OpenAiChatOptions.builder()
.model("gpt-5-mini")
.reasoningEffort("low")
.maxCompletionTokens(400);
// Ollama: disable reasoning for thinking-capable models
OllamaOptions.Builder noThink = OllamaOptions.builder()
.model("qwen3")
.think(false);
这里还需要注意一项 2.0 升级变化:Anthropic 模块的 maxTokens 默认值从 500 提高到了 4096。如果你之前依赖旧的 500 token 上限来限制响应成本——哪怕你自己没有意识到——升级之后,响应长度现在最多可能达到原来的 8 倍。如果想保留原来的行为,请显式设置这个限制。
还有一个陷阱值得单独警告。包括 qwen3 和 deepseek-r1 在内的多款热门 Ollama 模型默认启用推理。本地开发时不存在按 token 计费的 API 账单,因此你很容易忽略某种 prompt 模式已经依赖上了冗长的推理过程。把相同的 prompt 迁移到按推理 token 计费的 provider 后,这些隐藏推理就可能成为 completion 成本的一部分。对于提取、分类或格式转换等简单工作负载,本地开发时也应该禁用或限制思考。这样能让 token 用量、延迟和生产成本更容易预测。
LLM 本身没有内置记忆,因此实践中的“记忆”,其实意味着每次请求都要发送完整的对话历史。过去的每条消息都会再次计费,就像它们是全新的输入一样。
成本增长的速度可能比你想象的更快。假设 system prompt 包含 500 个 token,用户消息大约包含 50 个 token,回复大约包含 200 个 token。每完成一轮对话,历史记录就会增加约 250 个 token,而之后的每一轮都必须再次携带这些历史。消息窗口可以限制每次请求包含多少历史记录。这里必须注意两个细节:窗口统计的是已存储消息的数量,因此 10 条消息意味着最近 5 组完整的用户—assistant 交互;当前用户消息始终在窗口之外单独传递。因此,从第 6 轮开始,每次请求的大小都相同:500 + 1,250 + 50 = 1,800 个输入 token。
如果不设置限制,每一轮的成本会线性增长,但整个会话的成本增长速度要快得多:一个 20 轮会话总共会发送约 58,500 个输入 token,而一个 50 轮的客服聊天会发送约 333,750 个——这还只是单个用户。相同的 50 轮聊天如果使用 10 条消息的窗口,则大约会发送 86,250 个 token,约为不设上限时总量的四分之一。
这里还有一个值得了解的 Spring AI 2.0 细节:现在清理历史记录时会删除完整的 turn。一轮 turn 从用户消息开始,因此保留下来的窗口始终会以用户消息开头,而 maxMessages 只是上限,并不保证一定保留这么多消息。对于普通聊天,应该选择偶数大小的窗口,以便它能自然对应完整的对话交互。
Spring AI 在这里提供的控制手段是 MessageWindowChatMemory:这是一个最多保留 N 条消息的滑动窗口,通过 memory advisor 添加:
@Bean
ChatClient chatClient(ChatClient.Builder builder,
ChatMemoryRepository repository) {
ChatMemory memory = MessageWindowChatMemory.builder()
.chatMemoryRepository(repository)
.maxMessages(10) // overrides the default 20-message window
.build();
return builder
.defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build())
.build();
}
注入该 client 的服务会在每次调用时选择相应的对话:
chatClient.prompt()
.user(message)
// mandatory in Spring AI 2.0 — there is no default conversation id
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
.call()
.content();
Spring AI 的 Chat Memory 参考文档将 ChatMemory 和 ChatMemoryRepository 放在一起介绍。Spring AI 会自动完成这套配置,包括一个内存 repository 和一个保留 20 条消息的窗口。应该把 maxMessages 视为一项成本控制决策,而不仅仅是一个便利设置:由简短问答组成的 20 条消息窗口可能很便宜,但如果是长对话,同样大小的窗口可能会让每次请求额外增加数千个 token。MessageWindowChatMemory 限制的是消息数量,而不是 token 数量,因此应根据对话通常包含的 token 数量以及实际需要的上下文来选择窗口大小。
还要记住两件事。第一,窗口越小,成本越低,但模型能记住的内容也越少。模型确实会忘记被删除的 turn,因此应该选择业务场景能够接受的最小窗口,而不是一味选择尽可能小的窗口。第二,窗口也会影响 Driver #5:prompt 开头保持稳定——先是 system prompt,然后是最早的历史记录——是 provider 端缓存良好工作的前提。如果过于激进地删除消息,导致 prompt 开头每一轮都发生变化,就可能失去缓存折扣。
对于非常长的会话,VectorStoreChatMemoryAdvisor 是另一种选择。它将历史记录存入 vector store,只把与当前问题相关的消息重新加入上下文,因此无论会话持续多久,每轮输入大小都能保持稳定。其代价是:每条消息都必须转换成 embedding 并存储(见第 4 部分);而且重新加入的历史记录会随问题变化,不利于缓存。接下来就来讨论缓存。
system prompt、工具定义(包括 schema)和 few-shot 示例往往在不同请求之间保持不变,同时它们又都是模型输入上下文的一部分。如果没有缓存,provider 通常会在每次请求中重新处理这些重复的输入 token,并再次收费。Prompt caching 可以降低这部分成本:支持该功能的 provider 会存储已经处理过的 prompt 前缀,当相同内容被重复使用时,按更低的输入 token 费率计费。每个 provider 实现缓存的方式都不同,并且各自拥有不同的适用条件、过期机制和配置规则。
Spring AI 通过 Anthropic 和 AWS Bedrock 各自专用的枚举,提供了具名缓存策略:SYSTEM_ONLY、TOOLS_ONLY、SYSTEM_AND_TOOLS 和 CONVERSATION_HISTORY。这些策略定义了 Spring AI 在哪里放置缓存断点,同时遵守 provider 的限制;但缓存生命周期和过期仍由底层 provider 管理:
AnthropicChatOptions.builder()
.cacheOptions(AnthropicCacheOptions.builder()
.strategy(AnthropicCacheStrategy.SYSTEM_AND_TOOLS)
.build())
Anthropic prompt caching 的缓存写入价格较高,缓存读取价格则较低,因此对于包含重复、稳定 prompt 的工作负载,这项功能很有价值。例如,按每百万输入 token 1 美元计算,一个包含 2,000 个 token 的 system prompt 每月发送 300,000 次,如果不使用缓存,成本为 600 美元;如果缓存命中率很高,并且缓存读取按 0.1 倍费率计费,成本可以降至约 60 美元。主要需要关注的是缓存生命周期和流量模式:缓存会过期,低流量 endpoint 可能需要支付缓存写入成本,却无法获得足够多的缓存命中来抵消这部分开销。
对于 Agent 风格的工作负载,还有一项 Anthropic 专用设置值得了解:cacheToolResults。使用 CONVERSATION_HISTORY 缓存时,每一轮工具调用都会在默认缓存点之后加入工具结果,因此在后续轮次中,这些工具输出会被作为新的、未缓存的输入计费。启用 cacheToolResults 后,缓存点会移动到最后一个工具结果处,使下一轮可以直接从缓存读取上一轮通常很大的工具输出,而不必再次处理。这与工具 schema 和 Agent 循环直接相关(见第 3 部分)。
至少包含 1,024 个 token 的 prompt 会被自动缓存,无须修改代码。GPT-5.6 系列带来了两项变化,而且都会影响账单。
第一,缓存写入按未缓存输入费率的 1.25 倍计费。在更早的模型中,缓存写入是免费的。
第二,服务会在断点处缓存精确匹配的前缀。默认情况下,它会在最新一条用户消息或工具消息处放置一个隐式断点,并且不再回退到该断点之前最长的匹配前缀。因此,即使一个请求与上一个请求共享了数千个完全相同的 token,也可能报告缓存 token 数量为零,并再次为发生变化的前缀支付写入费用。
Spring AI 为你提供的控制手段是 cache key。共享同一前缀的请求应该使用相同的 key:
OpenAiChatOptions.Builder shared = OpenAiChatOptions.builder()
.model("gpt-5.6-terra")
.promptCacheKey("support-assistant-v1");
或者使用 spring.ai.openai.chat.prompt-cache-key=support-assistant-v1。在 GPT-5.6 及更高版本中,要实现更可靠的匹配,必须提供这个 key。每个 key 的流量应尽量保持在每分钟约 15 个请求;对于更加繁忙的工作负载,应将流量拆分到多个 key。
Spring AI 2.0 不支持显式设置缓存断点,因此在 OpenAI 上,可缓存前缀完全取决于 prompt 自身的结构——这使得下面介绍的“静态内容优先”规则成为成本控制手段,而不仅仅是一种风格偏好。此外,Spring AI 只会报告 OpenAI 的缓存读取,不会报告缓存写入,所以 1.25 倍的写入费用不会出现在你的 token 指标中。你需要改为在 provider 的 dashboard 上监控它。
本地模型也会使用缓存,但它节省的是时间,而不是金钱。在模型回答之前,必须先处理 prompt 中的每个 token——这是 GPU 上成本很高的“阅读”步骤。引擎会把这一步的结果保存在内存中,也就是 KV cache。如果下一个请求以完全相同的文本开头,引擎就会跳过这部分,只处理新增的内容。结果就是:首个 token 的生成速度更快,每块 GPU 能处理更多请求。但账单金额不会减少,因为本地运行不存在按 token 收费。缓存也只会在模型保持加载时存在——Ollama 会在模型空闲 5 分钟后将其卸载,不过可以通过 keep_alive 让模型在内存中保留更长时间。
需要注意的是,缓存只能从 prompt 开头开始匹配:一旦遇到第一个不同的 token,之后的所有内容都必须重新处理。因此,“静态内容优先”的规则在这里同样适用。
provider 的 prompt caching 机制依赖于对 prompt 开头进行匹配。缓存命中要求前缀完全相同,因此固定内容——指令、示例和工具定义——应该放在变化内容之前。如果在 system prompt 顶部加入一个时间戳,就可能导致其后的整个缓存前缀失效。对于支持多块 system 缓存的 provider,例如 Anthropic 和 AWS Bedrock Converse,Spring AI 2.0 支持将静态与动态的 SystemMessage block 分离。使用 .multiBlockSystemCaching(true) 后,Spring AI 可以保留可缓存的 system block,同时允许后续会变化的 system 内容留在缓存前缀之外。
即使暂时还没有启用缓存,也应该按照这种方式组织 prompt——只有这样,各个 provider 的缓存无论是自动还是显式启用,才能真正发挥作用。
不过,单靠 prompt 结构能够优化的程度终究有限。本部分讨论的一切,都假设内容属于你自己:你的 system prompt、你的对话、你的示例。下一部分将处理由应用自动加入的上下文——来自 vector store 的文档 chunk,以及你连接的每个 server 所提供的工具定义。两者都会成为输入 token,无论模型是否使用都会被发送,而且两者的规模取决于你建立了多少索引,而不是你实际需要多少内容。
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。