前阵子帮一个团队复盘他们的AI Agent应用账单,一个月在模型调用上烧掉的费用,比他们整个微服务集群的机器成本还高。刚开始都以为是模型太贵,排查下来发现真正的问题在于:大量重复的查询、重复的工具调用、重复携带的上下文,每一次都在重新付费。这让我重新意识到一个被很多人低估的事实——Agent系统的Token开销,往往不是模型价格决定的,而是缓存命中率决定的。
这篇文章会从我实际踩过的一些坑出发,把Agent缓存命中率提升和Token成本控制这两件事掰开揉碎讲清楚,覆盖成本结构拆解、缓存架构分层、语义缓存实现、命中率优化方向,以及上线后的指标口径和调优方法。适合正在做AI Agent应用、或者已经被LLM调用账单吓到过的同学参考,看完可以直接在团队里落地。
1. 钱都去哪了:一条Agent消息背后的Token账单明细
1.1 一次Agent调用的Token消耗拆解
很多人对Token成本的认知还停留在"我发给模型一句话,模型回我一段话"这个层面,但实际上Agent场景的开销远比这大得多。
普通聊天场景,一次请求的Token消耗大致是这样:系统提示词 + 历史消息 + 用户输入 = 输入Token,模型回复 = 输出Token。这是"单轮计费"。
但Agent不一样。Agent通常走的是"任务拆解、循环执行、工具调用"的路径,一次用户请求,模型要来回推理多轮。我举一个真实的工作流例子:用户问"帮我把上海这周气温超过30度的日子标到日程里,并设置提醒"。这个请求在执行过程中,至少会发生以下对话轮次:
- 第一轮:系统提示词 + 工具定义 + 用户问题 → 模型输出规划,决定调用天气查询工具;
- 第二轮:系统提示词 + 工具定义 + 历史上下文 + 天气API返回结果 → 模型输出下一步,决定调用日程写入工具;
- 第三轮:系统提示词 + 工具定义 + 全部历史上下文 + 日程工具返回结果 → 模型最终生成确认回复。
注意,每一轮调用,系统提示词、工具定义、历史上下文都会被重复发送一遍。我用一个粗算来展示开销:假设系统提示词 + 工具定义固定占了1500 Token,历史上下文运行到第三轮时膨胀到3000 Token,用户输入300 Token,那么每一轮的输入Token分别是2100、3600、4800左右,三轮加一起输入Token已经过万,再算上每轮几百Token的输出,一次完整执行轻松吃掉12000到15000 Token。
这个数字意味着什么?如果一天有1万个用户发起类似的任务,一天的Token消耗就是1.2亿到1.5亿。即便按相对经济的商用模型定价粗估,一天的成本也在几千元级别,一个月烧出六位数人民币并不夸张。而且这些Token里,相当大一部分是"重复的、可复用的"——这正是我们要下刀的地方。
1.2 Token成本吃掉的三个放大器
复盘了几套Agent系统之后,我总结出Token成本失控的三个主要放大器。
第一个放大器是System Prompt与工具定义的重复携带。这是我见过最普遍的浪费源。很多团队为了让Agent稳定工作,把系统提示词写到几百行,工具定义加了几十个,这些内容在每一轮模型调用里都要完整走一遍。可问题是,这套固定的提示词和工具定义,在短时间内其实是不会变的,明明可以被缓存,却每次都在按全量计费。
第二个放大器是历史上下文的线性膨胀。Agent执行过程中,中间结果、工具返回值会被不断塞进上下文里,越往后输入Token越多。尤其是有些工具返回的是大段JSON、大段日志,Agent为了"记住"这些信息,每一轮都要重新携带,但真正用到的可能只是其中一小段。
第三个放大器是完全没有复用的重复请求。同一个用户隔五分钟问同一个问题,会重新付一次费;十个不同的用户问"你们的退款政策是什么",会付十次费;Agent自己在循环里反复查询同一个订单状态,也会付N次费。这种浪费是最可惜的,因为它不是技术难点,而是架构上没有做缓存导致的白白重复。
1.3 缓存为什么是成本控制的第一抓手
团队在控制Token成本时,通常会想到三条路:换便宜模型、压缩上下文、加缓存。我的经验是,前两条都有明显的天花板和副作用。
换便宜模型,意味着模型推理能力下降,Agent的工具调用准确率和指令遵循能力往往跟着下降,团队又要花大量时间去做提示词补偿,效果还不一定稳。压缩上下文则更危险,Agent本身就需要足够的上下文来维持任务状态,强行裁剪很容易丢失关键信息,导致"管中窥豹"式的错误决策。
相比之下,缓存是收益最直接、副作用最小的一条路。缓存命中一次,省掉的不只是"回答的那几十个输出Token",而是整轮调用里几千甚至上万输入Token加上输出Token的整体开销。而且这是一个线性收益:有30%的请求被缓存命中,整体成本就大约下降三成。这个账算清楚之后,团队共识就很容易达成了。
2. 缓存放在哪几层:LLM侧缓存与应用侧语义缓存的边界划分
2.1 LLM侧缓存:KV Cache与Prompt Cache的省钱原理
很多人不知道,模型厂商在API层面已经做了一层自动缓存,最常见的就是KV Cache和Prompt Cache。
我先用大白话解释一下原理。LLM在生成回复时,需要先把历史Token逐个"读"一遍,生成过程中的中间状态会存放在KV Cache里。同一个前缀如果第二次出现,理论上这些中间状态不需要重新计算。现在的商用API大多在后台做了自动的Prompt缓存:同一段前缀文本在指定时间窗口内再次出现时,这一段的计算开销可以部分甚至全部省去,体现在账单上就是输入Token的价格大幅降低。
这层缓存看起来是自动的,但如果你不主动配合,它发挥的作用非常有限。让我用一次实测来说明:我把系统提示词保持在完全相同的状态下,让它连续处理两个相似请求,第二个请求的输入Token计费确实出现了明显折扣;但一旦我在系统提示词里插入了一个动态时间戳,整段前缀就"脏"了,缓存前缀匹配失败,第二个请求立刻恢复全价计费。
这个发现非常关键。工程上的应对策略就是"前缀静态化":把所有动态信息——当前时间、随机数、用户ID、会话编号——从Prompt前缀中剥离出去,要么放到消息列表的后面,要么放到靠近用户消息的槽位里,让系统提示词和工具定义保持字节级稳定。这个动作几乎零成本,但能实打实吃到LLM侧缓存的折扣红利。
2.2 应用侧语义缓存的职责与适用场景
LLM侧缓存解决的是"同一段前缀重复计算"的问题,但它解决不了"不同人问同一个问题"的问题。这个问题得靠应用侧的语义缓存来解决。
语义缓存的核心思路是:用向量表示用户问题,然后通过相似度匹配,判断当前问题是否与之前某个问题语义等价。如果是,直接复用之前的回复,完全不再调用LLM。
在实际落地中,语义缓存最适合以下几类场景:
- 客服问答Agent:大量用户问的是同一批FAQ,语义高度重复;
- 知识库检索Agent:同一份文档被反复询问,不同的人用不同的问法,但意图完全一致;
- 数据报表Agent:高频的固定分析模板,比如"上周的销售额是多少"这类查询,换个日期就是另一条,但模板本身稳定;
- 多租户共用场景:不同租户问的是同一个规则类问题。
不适合语义缓存的场景也很明确:
- 强个性化任务,必须按用户画像生成差异化内容;
- 强时效性任务,比如实时股价、天气预警,缓存内容很快过期;
- 带写操作副作用的任务,比如下单、删除、转账,这类请求绝对不能从缓存里直接返回结果,否则会引发严重的业务一致性问题。
2.3 缓存代理层的引入与整体架构
把缓存真正落地到Agent系统里,我不建议在业务代码里到处埋点,而是应该引入一个独立的缓存代理层。
这个代理层的位置在用户请求和Agent执行器之间,核心职责有三个:请求分类、缓存判定、结果写回。完整的调用链路是这样的:
用户请求进入系统后,先经过一个请求预处理器,这一步把请求划分为查询类和操作类。操作类请求直接放行到Agent主流程,查询类请求则提取缓存Key,去语义缓存里做相似度判定。如果命中,直接把缓存中的回复返回给用户,不走Agent;如果未命中,请求进入Agent执行器,正常完成任务后,把回答按一定规则写回缓存,再返回给用户。
用这个架构有几个明显好处。第一,对Agent主流程完全无侵入,Agent代码不用知道缓存的存在,后续演进不会互相干扰。第二,缓存代理层可以独立扩缩容,缓存服务扛不住了就多部署几个实例。第三,所有命中与未命中的统计数据都集中在这里,成本核算和效果评估非常方便。
这层代理让我在实践中省下了大量麻烦。有一次业务方要上线新话术,直接从管理端更新了缓存版本号,几分钟内全部流量切换到新内容,完全不需要重启Agent服务。要是没有独立代理层,这种操作会变成一场噩梦。
3. 语义缓存从零到一:向量判定、阈值调参与代码落位
3.1 缓存Key与Value的设计思路:不止是存"问题-答案"
语义缓存最容易犯的错误,是把它当成一个简单的"问题-答案"键值表。实际工程里,Key和Value的设计都要更细。
Key的设计需要组合多个维度。我用一个结构化的方式来表示查询指纹:用户ID的哈希、Agent类型、会话上下文摘要、问题本身的向量。这样做的原因是,不同Agent的回复可能完全不同,同一个用户在个性化场景下的两次相似提问,未必适合互相复用。如果直接把所有问题的向量丢进一个大池子里做相似度匹配,很容易串味。
Value的设计分两种形态。一种缓存完整回复,适合服务台问答、FAQ这类答案独立且稳定的场景;另一种缓存中间过程,适合Agent任务链路中的子结果。中间过程缓存的含义是:不缓存最终回复,而是缓存某个阶段的输出,比如工具调用返回的数据、检索命中的文档片段。这两种形态可以混用,后面第四章我会展开讲。
另外,缓存条目必须附加元数据,至少包含:创建时间、来源Agent版本、缓存内容对应的知识库版本、命中次数、是否曾经被用户纠正过。这些元数据不只是用来排查问题,更是后续淘汰坏缓存条目的依据。
3.2 向量命中判定与动态阈值调参
语义缓存的核心判定逻辑不复杂:把用户问题做Embedding,在向量库里检索最相似的Top K条,计算余弦相似度,超过阈值就命中,否则未命中。真正的难点在于阈值怎么定。
我最早做语义缓存时,把阈值设成了0.95,结果命中率惨不忍睹,只有完全同义改写才能命中,效果还不如精确匹配。后来我把阈值降到0.80,命中率上去了,但新的问题出现了:用户问"怎么退款"和"退款要多久",被语义缓存误判成了同一个问题,返回了完全答非所问的内容。这种"坏命中"对用户体验的伤害非常大。
后来我摸索出一个相对靠谱的调参方法:先拿至少500条真实用户日志,把其中的query两两组对,人工标注"语义相同"和"语义不同"两类,然后分别计算两组对的余弦相似度,画出两条分布曲线,取两条曲线谷值之间的位置作为初始阈值。这个方法不一定能一次到位,但能给出一个合理的起点,后续再根据线上的坏命中率微调。
还有一个经验:对于包含数字、否定词、时间约束的查询,要格外保守。比如"退款金额是多少"和"退款金额不是1000吗",字面相似度很高,但语义完全相反。我的做法是,在缓存判定之前做一个简单的规则检查,如果query包含"不、没、非、高于、低于"这类强约束词,就把阈值临时调高,或者直接放行不参与缓存。
3.3 伪代码实现与工程插入点
我提供一个工程上比较实用的语义缓存伪代码,这段代码可以直接嵌入到请求处理链路的任意位置。
def get_cached_response(query, agent_type, user_id, cache_client, embedding_model, threshold=0.9): # 1. 规则预筛选 if has_strong_constraint(query): # 含否定/数值/时间强约束 return None, {"decision": "skip_by_rule"} # 2. 生成查询向量 query_vec = embedding_model.embed(query) # 3. 在向量库中检索相似历史请求 similar_items = cache_client.vector_search( vector=query_vec, top_k=5, filter={"agent_type": agent_type, "valid": True}, ) # 4. 阈值判定 for item in similar_items: if item.score >= threshold and item.user_id_hash == hash_user(user_id): return item.response, {"decision": "hit", "score": item.score} elif item.score >= threshold and item.scope == "global": return item.response, {"decision": "hit", "score": item.score} return None, {"decision": "miss"} def save_response_to_cache(query, response, agent_type, user_id, cache_client, embedding_model, metadata): # 只在确认是稳定型回复时写缓存 if metadata.get("is_operation", False): return query_vec = embedding_model.embed(query) cache_client.vector_upsert( id=generate_cache_id(query, agent_type), vector=query_vec, payload={ "response": response, "agent_type": agent_type, "user_id_hash": hash_user(user_id), "scope": metadata.get("scope", "user"), "created_at": now(), "agent_version": metadata.get("agent_version", ""), "hit_count": 0, }, )这段代码里有几个细节值得强调。第一,规则预筛选一定要在最前面,跑一次规则判断比算一次向量便宜得多,而且能提前挡住大部分坏命中的风险。第二,检索时的filter条件里加上agent_type,防止不同Agent的回复互相污染。第三,写入缓存的元数据里必须标记作用域,是全局共享还是仅限当前用户,后续判定时要有不同的信任等级。
工程插入点方面,我建议放在两个地方。第一,在Agent主流程入口处,也就是请求分类器之后,这是最主要的判定点;第二,在LLM调用SDK的封装层,这样即便Agent内部某些中间调用也能被拦截缓存。如果团队有独立的API网关,也可以把缓存逻辑做成一个中间件,效果类似。
我用一个简单的表格对比这三个插入点的优劣势:
| 插入点 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|
| Agent主流程入口 | 可完整分类请求、处理写操作限制,缓存维度控制最细 | 需要业务代码配合 | 大多数项目,建议首选 |
| LLM SDK封装层 | 侵入最小,所有Agent调用自动生效 | 很难区分查询类和操作类,缓存Return值容易出错 | 快速验证阶段、已有系统不方便改 |
| API网关中间件 | 对业务完全透明,集中管理 | 无法感知Agent内部状态,只适合纯查询场景 | 纯知识库问答、多Agent共用网关 |
4. 把命中率从20%拉到60%:几个方向的优化路线
4.1 缓存Agent的规划结果:比缓存最终回复更省钱
大多数团队做缓存,第一反应是缓存Agent最终返回给用户的那段话。但是我实践下来,这个做法只适合最简单的FAQ问答,遇到任务型Agent就力不从心了。
更值得做的是缓存Agent的规划结果。所谓规划结果,就是Agent在拿到用户请求后,第一轮推理输出的执行计划,也就是"先做什么、再做什么、调哪些工具"这部分内容。
举个例子。用户问"我最近一个月的消费明细里有哪些异常项",Agent的规划大概率是:先查消费明细表,再算统计异常,然后生成结论。同一个SQL模板、同一个分析逻辑,换个用户、换个时间范围,规划步骤高度相似。如果你把"查询意图 + 数据范围"作为任务指纹,命中后直接把规划结果提取出来,就可以跳过Agent第一轮"从自然语言生成工具调用计划"的推理,省下这轮的输入输出Token。
我实测过这个优化方向,在数据报表类Agent上效果尤其明显。一次典型的报表生成任务,规划轮往往消耗上千Token,缓存规划结果后,这部分开销直接归零,整体Token消耗下降了15%到20%。
4.2 缓存工具调用结果:投入产出比最高的一个方向
如果说有一个方向让我觉得"早知道就早点做",那一定是工具结果缓存。
Agent执行过程中,很大比例的Token消耗发生在工具调用结果的回填上。尤其是那些返回大段数据的工具——查询订单详情、拉取商品信息、查询用户历史记录——一次返回动辄几百上千Token,而且这些数据在短时间内是完全相同的。
我遇到的最典型例子是汇率查询。用户的对话里提到"美元兑人民币汇率",Agent去调汇率API,拿到一段JSON再塞进上下文。五分钟后,另一个用户问同样的问题,Agent又调了一次API,又拿回同样的数据,又塞进上下文。两次之间,汇率可能根本没有任何变化。
工具结果缓存的实现思路是:对工具请求做参数规范化,生成工具调用指纹(工具名 + 参数哈希),然后用TTL控制有效期。对汇率这种强时效数据,TTL设10分钟;对订单状态这种相对稳定的数据,TTL可以设到30分钟甚至更长。这样做的好处是双份的:Agent不需要重复调用外部工具,省了外部API费用和延迟,同时省掉了这部分工具结果Token的重复写入。
4.3 Prompt前缀静态化与动态槽位分离
这个方向我在前面LLM侧缓存部分提到过,但它在应用侧缓存里同样重要。
对一个Agent系统来说,每轮请求带来的Token开销主要可以分为三块:固定的系统提示词、动态的上下文信息、用户输入。如果你能把固定部分做到字节级稳定,那么这部分的Token消耗在LLM侧可以享受Prompt Cache的折扣;而在应用侧,稳定的前缀也意味着你不必为"前缀变了导致缓存Key失效"而烦恼。
实际操作中,我会把系统提示词拆成两个部分:静态的"角色设定+工具规范"部分,以及动态的"当前会话状态"部分。静态部分固定不变;动态部分通过模板变量在消息序列末尾注入,例如"当前时间是2026年5月10日,用户所在时区为UTC+8"。这种"静态前缀 + 动态尾部"的结构,能让两类缓存同时受益。
这里有个小技巧值得分享:把时间、日期这类动态信息放在用户消息之后、模型回复之前的位置。因为很多缓存策略是按消息序列的前缀来匹配的,动态信息越靠后,能保持前缀稳定的区域就越大。
4.4 坏命中的规避:命中率不是越高越好
缓存做得越久,我越意识到一个反直觉的事实:命中率这个单一指标是会骗人的。我见过团队把命中率刷到60%以上,但用户满意度反而下降,原因就是"坏命中"太多——缓存返回了一个语义相似但实际不适用甚至错误的答案。
坏命中通常发生在三类情况里。第一类,用户问法相似但实际意图不同,比如"预约今天下午的会议"和"预约明天下午的会议",字面上高度相似,但结果完全不同。第二类,查询中包含隐藏的上下文依赖,比如用户说"那这个呢",指代的是之前聊过的某个具体对象,单独看这句话根本无法判断缓存是否相关。第三类,缓存内容本身已经过时,但TTL还没到期,例如价格调整后的商品信息。
规避手段我总结为三层防线。第一层,规则层:包含时间词、否定词、指代词、数量变化的query,直接降级为不参与缓存或提高命中阈值。第二层,作用域隔离:个性化相关的问题,缓存只在同一用户ID范围内生效,不做跨用户的全局复用。第三层,反馈闭环:如果用户在命中缓存后发起了"我说的是另一个意思"、点踩或继续追问,系统把这个缓存条目标记为低置信度,超过一定次数直接从缓存库中淘汰。
这三层防线看上去都不复杂,但少了任何一层,缓存都会变成一个"看起来省了钱、实际伤了用户"的隐形炸弹。
5. 工程落地复盘:指标口径、存储选型与上线调优
5.1 命中率指标的正确口径:按请求数还是按Token数
工程落地时,第一个问题就是:命中率到底怎么算?如果口径错了,后续所有优化决策都会被带偏。
按请求数计算命中率,是最常见的做法,但有一个缺陷:它会被大量简单请求拉高。比如系统每天有1000个"你好"级别的问候请求,这些请求全部命中,命中率被抬到50%,但它们的Token消耗占比可能只有2%,对成本控制毫无意义。
我更推荐按Token当量来计算命中率。也就是,每次命中时估算"如果走LLM会消耗多少Token",把命中带来的Token节省量作为分子,把所有请求的总Token消耗量作为分母。这个口径直接反映成本节省效果,和团队最关心的账单联动。
如果指标基建还没建好,还有一个折中方案:同时跟踪"命中率"和"坏命中率",把坏命中率控制在2%以下作为质量红线。只要质量红线不破,命中率提升多少都是收益;一旦坏命中率走高,就停下来先处理质量,而不是继续冲指标。
5.2 存储选型:Redis、内存还是向量数据库
存储选型是工程落地中最容易纠结的部分,我给一个实践中的决策依据。
精确匹配和前缀匹配用Redis就够,这是成本最低的方案。Redis支持哈希和有序集合,做精确命中、计数、TTL都很快。如果团队已经有Redis集群,几乎零成本接入。
语义缓存需要向量检索能力。这里我遇到过两个弯路:第一个弯路是试图纯用Redis做暴力向量检索,数据量小的时候没问题,缓存条目过十万之后延迟开始不可控;第二个弯路是一上来就引入分布式向量数据库,运维成本很高,但初期数据规模根本用不上那么重的组件。
我现在的选型原则是:缓存条目在十万级以下,用轻量级的向量检索方案,比如常用的开源向量库直接内嵌在服务里,一个文件搞定存储,性能完全够用;超过百万级,或者有分布式查询需求,再上独立向量数据库。先用简单的,等规模到了再迁移,不要一步到位。
另外不管是哪种存储,都要做双缓存结构:先查Redis精确匹配,未命中再查向量库。精确匹配的成本是微秒级,向量检索至少是毫秒级,先用廉价的方式过滤一遍,能把向量库的压力降一个量级。
5.3 一致性与TTL、冷启动处理
缓存一致性是工程上最容易翻车的地方,我分享几个具体的处理经验。
数据源变更导致缓存内容失效,最常见的场景是知识库文档更新了,但缓存里的旧答案还在被返回。我的做法是引入版本号机制:知识库每发布一个新版本,就在缓存元数据里写入对应的版本号,语义缓存查询时校验版本号,不一致的条目自动失效。这个机制改造成本不高,但能有效避免"明明更新了知识库,用户看到的还是旧回答"的尴尬。
TTL的设置要有层次。我的经验值是:强时效数据(汇率、库存)TTL 5到10分钟;业务数据(订单状态)TTL 30分钟;知识性内容(FAQ答案)TTL 24到72小时;Agent规划结果这类可复用任务的TTL可以放到一周,取决于底层依赖变化频率。不能用一套固定TTL打天下,否则不是过期太早没法命中,就是过期太晚卖过期答案。
冷启动是另一个值得提前规划的问题。新系统上线时,缓存是空的,前面一段时间的命中率会很低。我的做法是,上线前把历史的热门问答数据离线跑一遍Embedding,批量灌入缓存库,冷启动时间从几天缩短到几小时。后续再通过每天的增量日志把新出现的query补充进去,缓存基本能保持高水位。
5.4 上线后的A/B验证与调优节奏
缓存上线不是"加个层就完事",它需要对用户可见效果做持续验证。我的上线节奏是这样的。
第一步,小流量灰度。先切1%的流量到带缓存的分组,观察响应延迟、坏命中率和用户反馈。这个阶段不要急着看成本节省,先确认没有明显质量事故。
第二步,同query双跑对比。把带缓存和不带缓存的服务部署在并行环境里,用同样的query分别打过去,人工对比两份回复的差异。这个操作虽然原始,但能在早期发现大量阈值不合理、作用域混用的问题。
第三步,逐步放量。在坏命中率稳定低于2%的前提下,流量从5%逐步提到10%、25%、50%,每提升一个档位至少观察一天。如果某个档位出现坏命中率反弹,立即回滚到上一个档位,先调参再放量。
第四步,阈值微调。调整阈值时每次只动0.01到0.02的范围,调整后观察两天再动下一个参数。不要一次调好几个参数,否则出了问题根本定位不到是哪个改动引起的。
第五步,成本效果归因。每周末统计一次Token节省量,按月绘制成本曲线,并和A/B数据做交叉验证。这一步能让团队清楚地看到缓存的ROI,也能在后续申请资源时拿出明确的数据依据。
关于这个"逐步放量"的节奏,我个人的体会是,它最大的价值不是防止出错,而是让团队建立对缓存系统的信心。缓存这个东西,上线简单,调优复杂,但只要指标口径清晰、灰度节奏稳,它的收益曲线一定会上扬,而且越往后越明显。