ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Agent长期记忆性能优化:前缀缓存与记忆分层实战

Agent长期记忆性能优化:前缀缓存与记忆分层实战 1. 从一次线上事故说起记忆越长Agent越呆1.1 现象多轮任务后TTFT飙升先说个真实场景。我们线上的一个Agent服务跑到第15轮左右用户体感明显变卡发一句话要等好几秒才出第一个字。我拉监控一看TTFTTime To First Token从刚开局的800ms一路涨到了4.2秒而且还在涨。当时的第一反应是显存爆了还是并发上来了结果查了一圈GPU利用率和显存占用都正常就响应慢。后来逐帧看请求日志发现一个规律请求越长TTFT越慢而变长的部分几乎全是记忆注入的上下文。这个Agent早期设计比较粗暴长期记忆、对话历史、工具返回结果一股脑全拼到prompt里。每轮请求都在把这大几千甚至上万个token重新编码一遍。换句话说模型每轮都在重读一遍此前说过的话哪怕这些话一个字都没变过。这就是长期记忆在Agent落地时最容易踩的坑记忆是有价值但全量重算的代价没人付得起。当时的优化方向有两个——要么减少注入量要么想办法让推理引擎记住已经算过的东西。前者是内容工程后者就是我们这篇文章的主角前缀缓存。1.2 根因记忆全部塞进上下文每次请求全量重算拆开看当时的请求结构大概是这样的[System Prompt]固定约900 token [工具Schema定义]固定约1200 token [长期记忆-用户画像]基本固定约800 token [长期记忆-历史会话摘要]每次涨一点约2000 token [短期记忆-最近10轮对话]每轮都在变约3000 token [当前用户请求]每次不同这个结构里前三块加起来接近3000 token它们在一整个会话周期内几乎不变。但Agent每发起一次LLM调用这3000 token都得重新过一遍Transformer的prefill阶段重新算一遍KV Cache。当时测算了下成本一次带长期记忆的请求prefill计算量占了总计算量的70%以上而这其中又有60%是重复计算。说白了你花钱买了三倍的算力其中两份是在做无用功。更痛的是这个问题会随着记忆增长越来越严重。短期记忆有滑动窗口兜底长期记忆却只会越来越多。用户画像越丰富、历史越久重复计算的占比就越高。这不是什么偶发故障是架构设计上注定的性能衰减。1.3 长期记忆在这个系统里的真实定位在展开优化方案之前先明确一个概念Agent里的长期记忆不是简单地把历史消息堆在一起。它要解决的问题是——跨会话地保留用户的关键信息并在合适的时机把它们取出来参与决策。我在4.6版本里把长期记忆拆成了三层记忆层级存储介质更新频率典型内容工作记忆上下文窗口每轮变化当前任务的中间状态、工具返回情景记忆向量数据库会话结束后写入对话摘要、关键事件、用户偏好语义记忆结构化存储低频更新用户画像、业务规则、领域知识而不管是哪一层最终进入LLM视野的方式都是拼进提示词。这就决定了长期记忆系统的性能瓶颈最终会落在提示词拼接和推理引擎的重复计算上。理解了这一层就能明白为什么非得在前缀缓存上做文章。2. 前缀缓存不是玄学KV Cache、Token序列与命中条件2.1 KV Cache推理引擎到底在缓存什么要搞懂前缀缓存先得知道Transformer推理时的两个阶段。Preflfill预填充阶段把整个prompt一次性算一遍生成每个token对应的Key向量和Value向量存在显存里。Decode生成阶段逐个token地生成回复每一步都通过Attention机制读取之前所有token的K和V。这个之前所有token的K和V就是KV Cache。它的作用很直白decode阶段不需要回头重新算历史token的K和V直接查缓存就行。那多轮对话里发生了什么假设你的prompt是系统提示 用户说A 助手回B 用户再说C第二轮的完整prompt是系统提示 用户说A 助手回B 用户说C。注意前三个部分和第一轮完全一样。但如果你不做特殊处理推理引擎会把这整段当成全新请求从第一个token开始重新走一遍prefill。前三个部分明明算过一遍却得再算一遍。前缀缓存要干的事情就是把这些重复部分直接复用。推理引擎在计算新的请求前先看看缓存池里有没有现成的、能够匹配上的前缀。有就只算增量部分。2.2 前缀缓存的三个硬性条件实际接入之后我才发现前缀缓存能不能命中有非常硬的条件。不满足那它就是一个永不生效的摆设。条件一前缀必须绝对一致。缓存匹配是基于token序列的精确匹配不是语义匹配。你改了系统提示里的一个标点符号或者调整了工具Schema里某个字段的排列顺序从那个位置开始后面所有的缓存全部作废。注意是从改动位置开始往后的所有部分都失效不只是改动那一处。条件二前缀必须位于请求的开头。这个听起来像废话但真的有人把动态内容放在系统提示前面。比如有些方案会在prompt最前面加一个request_id或时间戳这等于给每个请求都换了个独一无二的前缀你不管后面拼什么缓存都匹配不上。条件三缓存池有容量上限有淘汰策略。显存是有限的缓存池不可能无限存。常用的策略类同操作系统页面置换LRU、LFU或者按token年龄淘汰。这意味着命中率不是100%——有些前缀明明算过但缓存池满了被踢掉了。你需要在容量和命中率之间找平衡。2.3 Agent场景为什么天然适合前缀缓存Agent的提示词结构和普通聊天有个很大的区别它的固定成分占比高得惊人。一个典型的Agent调用系统提示覆盖了角色定义、任务约束、输出格式规范工具定义覆盖了所有可用工具的名称、描述、参数Schema。光这两块动辄几千token而且在一个版本周期内基本不动。再加上长期记忆里的用户画像、历史摘要这些也是低频变化的部分。反观普通聊天prompt里占大头的是对话历史每一轮都在增长前缀稳定度其实不高。Agent则不同——它的长上下文里大量内容是声明性的不是对话性的。声明性内容天然稳定天然适合被缓存。这也解释了为什么业内做Agent框架的几乎都在往这个方向使劲vLLM的Automatic Prefix Caching、SGLang的RadixAttention、各家推理API的Prompt Caching本质都是在吃这一点红利。3. 4.6版本的记忆-缓存协同设计3.1 记忆分层工作记忆、情景记忆、语义记忆4.6版本做的最核心的一件事是把长期记忆从前面的一锅炖改成了分层管理。我在1.3节列了张表这里展开讲讲每一层怎么落地。工作记忆对应的是当前任务上下文包括最近几轮对话、正在执行的工具调用链。它的特点是变化快、必须全量保留适合放在提示词的最后部。这一部分不追求缓存命中因为它本来就是动态的。情景记忆是历史会话的压缩产物。我们会话结束后跑一个异步任务把整段对话摘要成结构化事件提取涉及的用户偏好、业务事实、关键决策写进向量库。下次请求时根据当前任务做向量检索取top-k片段注入提示词。语义记忆是更稳定的知识类似用户画像和业务规则。比如用户偏好简洁回复、用户所在项目是XX这类信息通常只在用户属性变更时更新。这一层放进缓存前缀区收益最大。3.2 提示词模板的前缀稳定、后缀可变原则这是本次优化里最核心的一条设计原则我直接拎出来说。提示词拼接顺序从前往后依次是[系统提示] 固定版本级稳定 [工具Schema] 固定版本级稳定 [语义记忆区] 低频更新用户画像/业务规则 [情景记忆区] 中频更新最近检索到的历史事件 [工作记忆区] 高频更新最近对话工具结果 [当前指令] 每次变化为什么这样排因为前缀缓存算的是从开头到第一个变化位置之前的公共部分。把最稳定的放最前面把最动态的放最后面能让可复用的前缀尽可能长。举个例子。系统提示工具Schema语义记忆区这三块加起来3000 token在一次会话中完全不变。那第二次请求时前3000 token的KV Cache直接复用只需要重新计算后续的对话、检索记忆和当前指令。prefill的计算量锐减延迟自然就下来了。这里有个细节很多人会忽略工具Schema的排列顺序是稳定性的隐性杀手。有些动态注册工具的Agent每轮请求都会根据当前任务重新筛选可用的工具集今天3个明天5个顺序还随机。这会导致工具Schema这一块没法缓存。4.6版本的做法是工具集按固定顺序输出缺的工具用disabled标记而不是从列表里消失。这样Schema结构始终稳定缓存才能接上。3.3 长期记忆的内容工程让记忆既不变又有用优化记忆的缓存性能不等于让记忆内容一成不变——那就成死记硬背了。关键是在稳定和有效之间找平衡。先说语义记忆区。我们的做法是给用户画像做版本化。每当检测到用户属性变化不是原地修改旧画像而是生成一个新的画像版本。同一会话周期内Agent始终引用同一个版本保证提示词前缀稳定。版本切换发生在会话边界下一轮新会话开始才生效。这样设计的副作用是可能有一点点信息滞后但对绝大多数业务场景来说一个会话周期内使用同一版本的用户画像完全够用。再说情景记忆区。这部分天然是动态的因为每次检索到的历史事件不同。但我发现可以优化它的变化粒度。之前是把整段情景记忆当作一个块塞进提示词每次只要top-k结果有变动整个块全部失效。后来改成按记忆条目独立拼装每条记忆前面加固定格式的元信息头。这样虽然整体前缀还是变但至少每条记忆内部的结构是稳定的——系统在解析标记时也能更快定位。这里有个技巧是给情景记忆做分级检索。日常请求只检索top-3记忆遇到复杂任务才扩大到top-10。控制注入量的同时也在控制前缀的变化幅度。3.4 记忆更新与缓存失效的折中策略记忆系统跑了一段时间后会遇到一个矛盾记忆更新越频繁缓存失效范围越大缓存命中率要求越高记忆更新就越滞后。这个矛盾是绕不开的只能折中。我最终定的策略是批量更新 版本切换。具体来说工作记忆每轮正常更新这部分本来就不指望缓存情景记忆在会话结束后的异步任务里集中写入影响的是下一轮会话语义记忆则只在发生实质性变更时更新并且总是在当前会话结束之后才切换到新版本。这样做的好处很直观一个会话内部前缀中的语义记忆区始终是同一份数据缓存命中率自然高。坏处是跨会话之间第一轮请求的缓存必然失效——但这是可以接受的因为一次会话往往动辄十几轮第一轮失效换来后面十几轮的命中很划算。另外我还给场景记忆加了一个预热机制每次语义记忆更新后系统会自动触发一次空请求主动把新的前缀算一遍存入缓存池。这样用户发起第一轮真实请求时缓存已经准备好了。4. 推理引擎接入与性能验证4.1 vLLM Automatic Prefix Caching 的接入配置我们线上用的推理引擎是vLLM它提供了开箱即用的Automatic Prefix CachingAPC接入成本比我预想的低很多。起服务时加一个参数vllm serve /models/agent-llm \ --enable-prefix-caching \ --max-model-len 32768 \ --gpu-memory-utilization 0.9关键就是--enable-prefix-caching这个开关。打开之后vLLM会自动对请求的token序列做哈希匹配重复前缀直接复用KV Cache。不过这里有个版本差异需要提醒老版本vLLM对前缀缓存的支持不够完善建议直接用v0.6.0以上的版本。另外如果用的是SGLang对应的是RadixAttention机制原理类似用Radix树做最长前缀匹配效果比纯哈希更好一点。还有一个容易忽略的点前缀缓存和PagedAttention的block大小有关系。vLLM把KV Cache按固定大小的block管理默认block_size是16。如果前缀token数不是block_size的整数倍最后一块会有一部分浪费影响缓存效率。实测下来把前缀控制在block_size倍数附近能略微提升命中率但这个收益很小一般不作为主要优化方向。4.2 命中率怎么统计日志、指标与线上监控配置开关打开不等于优化完成你得有指标监控它到底有没有生效。vLLM本身暴露了Prometheus metrics其中和前缀缓存最相关的指标是vllm:prefix_cache_hit_rate整体命中率所有请求前缀命中的token占比。vllm:num_preemptions被抢占的序列数。如果这个指标持续走高说明缓存池容量吃紧。我在Grafana里配了一个面板按小时聚合展示命中率。正常情况下单会话内命中率能到80%以上如果发现某个时段命中率骤降多半是发了新版模型或者改了提示词模板。另外一个笨办法但非常有效直接在业务日志里记录每轮请求的prompt_tokens和cached_tokens。vLLM的response对象里带着这些字段记录下来按会话维度算缓存占比。这个数据更贴近真实业务感知比看全局指标更直观。4.3 压测数据TTFT、吞吐与成本对比优化完成之后我做了一轮压测对照组是优化前的全量重算方案实验组是前缀缓存记忆分层方案。测试用的是单条会话20轮的对话负载结果如下指标优化前优化后变化平均TTFT2.8s0.9s下降68%第95分位TTFT4.2s1.5s下降64%prefill计算量1100万token/小时380万token/小时下降65%有效吞吐token/s接续提升有限明显提升接近2倍单会话平均成本5.2元2.1元下降60%我特别关注的是prefill计算量这一项。因为按token计费的API场景下很多供应商对prompt缓存token有折扣价。自建推理场景下降低prefill计算量直接意味着更低的GPU占用和更快的并发响应。这套优化做完TTFT的改善立竿见影用户体感从卡顿回到流畅。5. 实践中踩过的坑与排查链路5.1 缓存未命中的排查顺序配置做完之后我一度以为大功告成。结果上线第二天看了下监控命中率只有12%。当时整个人都不好了。排查了一圈踩了一串坑。这里按排查顺序把链路写出来方便你直接对照。第一步确认vLLM版本和启动参数。先用vllm --version确认版本再看启动命令里有没有--enable-prefix-caching。当时我们用的是自研的部署脚本有一次更新脚本时这个参数被覆盖掉了服务重启后缓存就静默失效。这是最简单也最容易被忽略的原因。第二步检查提示词模板是否稳定。打开请求日志把相邻两轮请求的prompt做diff。如果差异出现在系统提示或工具Schema区域说明提示词构建逻辑不稳定。我们当时就发现每次请求都会把当前时间戳拼进系统提示的某个角落导致前缀每次都不一样。删掉之后命中率直接从12%跳到了65%。第三步检查多轮对话的拼接是否重叠或遗漏。Agent自己生成的内容如果拼回上下文时带了特殊标记或者做了格式化可能影响token序列。这个在5.3节详细说。第四步检查缓存池容量。如果前缀很长、并发量又大缓存池很快会被挤满老前缀被淘汰。这时候命中率天花板低就得考虑增大gpu-memory-utilization或者压缩前缀长度。5.2 Batch请求对前缀缓存的隐性破坏还遇到过一个比较隐蔽的问题离线批处理任务会和在线服务共享同一个vLLM实例导致缓存命中率剧烈波动。原因不难理解。Batch任务里的请求通常来自不同的场景前缀五花八门每个请求都会在缓存池里占用不少空间。当Batch任务大批量打进来时在线会话的前缀被挤出去等在线请求再进来时缓存已经没了只能重新算。我最初以为认知里的vLLM APC是全局共享的总容量就那么多Batch和在线之间天然存在竞争。解决方案也简单粗暴把Batch任务和在线服务的推理实例拆开。Batch任务用的实例不开启前缀缓存或者设置独立的缓存池避免互相污染。5.3 记忆滑动窗口与缓存失效的博弈最后要说的是记忆滑动窗口和前缀缓存之间的天然矛盾这个坑我花了蛮长时间才绕明白。之前短期记忆用的是固定窗口比如最近10轮对话。第一轮请求是第1轮到第10轮第二轮请求是第2轮到第11轮。表面上只多了一轮、少了一轮但问题在于第1轮的对话已经从窗口头部移除了而第2轮对话又新增到了窗口尾部。中间那段虽然还在但前缀是从第2轮重新开始接的和原来的前缀对不上缓存自然失效。换句话说滑动窗口的每一次移动都会把整体前缀切断导致从第1轮对话之后的所有内容包括长期记忆区都得重算。解决思路是把变的部分从不变的部分里彻底剥离开来[固定前缀区] 系统提示 工具Schema 语义记忆长时间稳定 [滚动记忆区] 历史消息 事件 最近对话固定格式逐轮追加 [当前请求区] 最外层用户请求 本轮工具调用滚动记忆区固定为逐条追加永不截断的模式。当会话过长时不是从头部移除历史消息而是把更早的消息摘要成一条结构化事件移动到情景记忆区。这样滚动记忆区本身从一个连续增长的序列变成了前缀保持连续 末尾追加新条目前缀稳定性大幅提升。虽然这个方案带来的代价是——消息超长时有可能突破上下文窗口。所以我加了一个总长度保护当滚动记忆区token数超过阈值时触发一次强制摘要压缩把更早的消息合并成摘要替换掉原文。压缩之后虽然前缀会变但是是可控地变变化点发生在压缩触发的那一轮而不是每一轮都变。实测下来这个方案让单会话内从第3轮开始的TTFT基本保持稳定不再随轮次增长。缓存命中率在长会话场景从35%拉升到了82%以上。5.4 多轮对话拼接逻辑不一致导致前缀断裂这个坑值得单独拿出来说。Agent在对话过程中会有工具调用工具返回的结果要拼回上下文。第一版实现里我们把工具返回结果以用户角色的消息形式插入对话历史。问题来了vLLM等引擎对角色标记的处理是敏感的同一个prompt内容用|im_start|user包裹和用|im_start|tool包裹算出来的KV完全不同。有一阵子命中率掉到50%左右排查了很久最后发现是工具调用结果的角色标记在某一版本更新后从tool被改成了user而请求日志里沿用旧版本的缓存键。这个一眼看上去不是问题的点直接让所有包含工具调用的历史上下文全部失效。排查方法是在日志里打印出每次请求实际的prompt哈希值然后按会话顺序排出来。哈希值的变化点就是前缀断裂的位置再看那附近的上下文拼接逻辑。我自己最后的规则很简单让拼进上下文的每一段内容的格式在版本生命周期内绝对固定改动必须走版本号隔离。任何提示词模板的修改都视为一次新的缓存前缀上线前主动预热而不是改完就上线、指望缓存继续命中。写在最后这一轮优化做下来我的整体体会是前缀缓存不是推理引擎的事是Agent架构设计的事。引擎只是给了你一把好用的工具前缀能不能命中取决于提示词结构、记忆管理、上下文拼接这些上层的设计决策。如果你想在自己项目里复刻这套方案我建议按这个顺序来先把提示词结构固定下来确定系统提示-工具Schema-语义记忆-情景记忆-工作记忆的拼接顺序。打开推理引擎的前缀缓存开关。把请求日志里的token统计接起来确认命中率基线。再回过头来处理记忆层的更新策略确保前缀稳定、后缀可变。最后才是压测优化缓存池容量等细枝末节。最后分享一个操作层面的小技巧在Agent开发环境里写一个提示词模板变更检查的单元测试。每次改提示词模板测试自动计算新模板和旧模板的公共前缀长度如果低于某个阈值说明你的改动把稳定区动得太狠了需要重新考虑结构。这个测试帮我挡掉了好多次无意识的缓存破坏算是这次优化里最有性价比的工程质量投入。
返回列表