ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Agent记忆管理实战:用Tair exhash解决多轮对话状态丢失

2026/9/12 10:51:49 拓冰建站 浏览量
Agent记忆管理实战:用Tair exhash解决多轮对话状态丢失 做 Agent 开发的人迟早会被同一个问题卡住对话一长模型就开始“失忆”。用户明明说过预算不超过两千下一轮它还能推荐四千块的旗舰款明明已经拿到订单号工具调用一多就把参数搞丢了。这不是模型笨而是我们把 Agent 记忆和上下文缓存这件事想简单了。我自己在给一套多轮客服 Agent 做状态管理时试过内存 dict、试过 Redis String、试过普通 Hash最后稳定下来的方案是围绕 Tair exhash 做一套分层记忆存储。这篇就把我的数据结构设计、过期策略和踩坑过程完整拆开讲适合正在用 LangGraph、Coze、自研 Agent 框架做多轮对话的开发者也适合后端同学参考。1. Agent“失忆”的真实代价从一次翻车现场说起先讲一个我实际经历过的翻车案例。当时我在做一个电商购物助手用户进来问“有没有适合送礼的机械键盘”聊了五六轮之后已经明确了预算、轴体偏好和颜色要求。结果第八轮的时候Agent 突然推荐了一款青轴 RGB 键盘——用户在前几轮明明说过“不要青轴太吵而且不要 RGB”。问题出在哪不是 prompt 写得不好而是这个购物助手的多轮状态存在单机内存里。当时为了快速上线我用了一个全局 dict 按 session_id 存上下文。表面上没问题但有两个致命隐患第一每次横向扩容或发布重启所有会话状态全部归零第二因为上下文窗口有限我在拼装 prompt 时只保留最近三轮更早的信息直接截断。于是 Agent 看起来“失忆”本质上是状态管理根本没有做持久化和分层所有记忆都压在上下文窗口这个易碎的小盒子里。1.1 上下文窗口不是记忆只是“临时便签”很多人会混淆一个概念大模型的上下文窗口context window不等于记忆。上下文窗口本质上是一次推理时的输入缓冲区模型看完这段 token 之后并不会把它像数据库一样存下来。多轮对话之所以能“记住”前文是因为我们把历史消息重新拼进 prompt 再发一次。这就带来两个硬伤物理上限窗口是有限的几千到几百万 token 不等但对话轮次可以无限增长。窗口满了就只能丢旧消息或者手动截断。成本随长度飙升每一轮都把完整历史塞进 promptToken 消耗是轮次的线性叠加。用户聊 20 轮之后光是历史就能占掉大几千 token响应变慢、费用翻倍。所以我把 Agent 记忆从上下文窗口里拆出来单独做一层外部记忆层。短期对话上下文、长期用户偏好、工具调用中间状态分别用不同的存储策略管理。上下文窗口只保留“当前这轮真正需要的信息”。1.2 Agent 记忆的三个层次短期、长期、工作记忆做记忆管理之前先把“记忆”拆清楚。我自己的划分是这样的记忆层次典型内容生命周期存储要求短期记忆对话态最近几轮对话原文、当前会话的目标、临时约束单次会话通常 30 分钟到几小时读写快、支持局部更新、可整体过期长期记忆用户画像用户偏好、历史订单、身份信息、禁忌项跨会话以周/月计算持久化、不能随手清理、高可靠工作记忆执行态工具调用参数、下一步计划、待确认信息任务执行周期秒到分钟级强一致、并发安全、可回滚在没做这套设计之前我把三类记忆全部塞进一个 JSON String 里一个 key 对应一个 session。结果就是短期记忆需要频繁过期却把长期画像一起带走了工作记忆高并发写同一个 key经常发生后写覆盖先写。意识到这三类记忆的生命周期完全不同之后我才真正决定换数据结构而不是继续在 String 上打补丁。1.3 记忆缺失的三个真实“翻车”场景除了购物助手那次还有三个场景让我印象很深场景 A客服 Agent 拿到了订单号但下一轮工具调用时把参数丢了。原因是工具调用返回后状态没有回写到持久层模型只能靠上下文窗口里的原始输出重新猜测一旦窗口被截断就彻底丢失。场景 B用户说“跟上次一样”时Agent 完全不知道“上次”是什么。因为没有跨会话的长期记忆每次会话都是冷启动。场景 C用户中途纠正说法Agent 还在坚持旧结论。短期记忆里存的是追加式历史没有“覆盖”语义模型容易被长历史里的旧信息带偏。这些翻车不是模型能力问题而是记忆设施缺失。后面我会讲用 Tair exhash 恰好能把这三类记忆都收进一个可管理、可过期、可并发控制的容器里。2. 多轮对话状态管理到底在管哪些东西标题里写的“多轮对话状态管理”很多人误以为就是把聊天记录存下来。我做了之后发现它其实要管三样东西对话历史、结构化状态、以及状态与历史之间的协同。2.1 对话历史、对话状态、上下文缓存的边界先厘清三个概念因为后面所有的设计都建立在这三个概念之上对话历史Chat History用户和 Assistant 之间发生的原始消息序列是给模型看的“原文”。对话状态Conversation State从历史中提取出的结构化关键变量比如用户 ID、订单号、已选配置、当前流程节点。状态是给逻辑用的不一定直接拼进 prompt。上下文缓存Context Cache对重复使用的系统提示、历史消息做 token 级复用减少每次请求的 prompt 拼接开销和时延。它更接近“传输层优化”不解决记忆缺失问题。我在项目里用一句话区分它们历史是素材状态是结论缓存是加速。三者的存储要求完全不同。历史需要按时间追加状态需要支持覆盖更新缓存需要按 key 复用且可以快速失效。如果只用一个 Redis String 全包会非常痛苦。2.2 状态生命周期创建、更新、压缩、归档、清理一个完整的会话状态从用户进来到离开会经历这几个阶段创建用户发起会话生成 session_id初始化空状态。更新每轮对话结束从当前轮次里抽取新的结构化信息写入状态。压缩上下文达到长度阈值时把旧的历史消息做摘要替换到摘要字段。归档会话结束后保留用户画像类的长期记忆把短期上下文降级或删除。清理超过 TTL 的数据由存储层自动过期不需要业务代码追着删。这个生命周期里最核心的挑战是“同一份状态在不同阶段要求不同的存活时间”。如果存储结构不支持字段级过期就只能把所有数据一把梭要么过早丢失要么过期不清理。2.3 状态管理设计要回答四个问题做存储选型前我把需求收敛成了四个问题每个问题都直接决定数据结构问题一持久性要求多高会话中间状态丢了还能重来但用户画像丢了就是事故。所以短期态和工作态可以容忍一定丢失长期态必须可靠。这意味着不能把所有数据都用一个过期策略管理。问题二作用域是什么状态可能属于某个会话session 维度也可能属于某个用户user 维度还可能属于全局 AgentAgent 的配置、通用知识。作用域不同key 的设计必须不同否则会出现用户 A 的偏好污染用户 B 的会话。问题三时效性怎么定不能一个 TTL 走天下。比如最近几轮上下文 30 分钟过期会话摘要 24 小时过期用户偏好永久有效。这决定了存储结构必须能对局部字段单独设置过期时间。问题四并发一致性要求Agent 在调用工具、重试、异步回调时可能同时对同一份状态发起写操作。如果后写覆盖先写用户刚确认的信息可能被一个迟到的旧消息冲掉。所以还需要版本控制或原子操作能力。这四个问题问完普通 Redis Hash 已经扛不住了我这才把目光转向 Tair exhash。3. 为什么我最终选了 Tair exhash 来做记忆容器选型这件事网上有很多对比文章但大多停留在命令层面。我讲一下自己真实对比过三个方案之后的结论以及 exhash 到底是改了什么才解决我的问题。3.1 先说我用过的两个“常规方案”的坑方案 ARedis Stringvalue 直接放 JSON。key 用 session_idvalue 里塞整个会话状态。优点是实现最快缺点是每次更新都要先读完整 JSON、反序列化、改字段、再序列化写回。一旦会话状态变大读写放大非常严重。更要命的是String 没有字段级过期能力短期上下文和长期画像混在一起要么一起留要么一起删。方案 B普通 Redis Hashfield 分别存不同维度的状态。比 String 好一点可以局部更新某个 field。但普通 Hash 的过期只能作用在整个 key 上。当时我犯过一个错想给“最近几轮对话”设置 30 分钟过期于是对整个 Hash 设了 TTL结果用户画像和偏好设置也一起被清掉了。这个问题不是靠代码能绕过去的是数据结构本身不支持。3.2 exhash 到底改了什么Tair exhash 是阿里云 Tair 自研的增强版 Hash 结构核心改进是每个 field 可以独立设置过期时间普通 Hash 只有一个总 TTLexhash 把过期能力下沉到了字段级别。我当时的直观感受是普通 Hash 是一栋只有一个总电闸的楼要么全楼供电要么全楼断电exhash 是每个房间独立电表、独立开关哪个房间没人住就停哪个的电互不影响。除字段级 TTL 外exhash 还提供版本号机制进行读写时携带版本号可以判断数据是否被其他客户端改过这正好解决 Agent 并发写覆盖的问题。3.3 三个候选方案的对比对比维度Redis StringRedis HashTair exhash结构key-valuekey-field-valuekey-field-value局部更新不支持需整体读写支持支持字段级 TTL不支持不支持只有 key 级支持版本控制无无支持 CAS 类操作大 key 问题极易出现可控可控读写放大严重中等低适用场景小状态、低频写简单分组强记忆、多生命周期状态表格里“版本控制”这一行是我决定换 exhash 的关键加分项。Agent 的异步回调、模型重试、多轮并行工具调用都可能导致同一 field 并发写入。没有原子性保障的情况下状态错乱只是时间问题。3.4 两个容易忽略的选型理由除了数据结构本身我当时还考虑了两个实际因素一个是框架无关性。我团队里同时存在 LangGraph 上搭的流程型 Agent 和自研框架写的决策型 Agent它们对记忆的读写模式不一样。exhash 只是一个通用的 KV 存储结构不绑定任何 Agent harness也不要求我改模型调用链路。这样记忆层做一次两个框架都能复用。另一个是运维成本。如果自建 Redis Cluster字段级 TTL、版本控制这些能力要么自己用 Lua 脚本模拟要么干脆没有。Tair 作为托管服务这些能力开箱即用稳定性也有兜底。对于核心业务我愿意把运维风险交给专业服务把精力放在记忆策略本身。4. 基于 exhash 的状态结构设计key、field 与过期策略选型定了之后真正的难点是结构设计。我用了大概两周时间迭代出一套相对通用的方案这里完整拆开讲。4.1 整体架构怎么分层我的架构分三层Agent 应用层LangGraph / 自研 Agent 框架 ↓ 记忆服务层Memory Service封装读写、摘要、过期逻辑 ↓ Tair 访问层Tair 客户端 / Jedis 扩展记忆服务层是核心它对外提供四个能力会话恢复、状态写入、上下文压缩、画像提取。所有 exhash 的命令细节都封装在这一层上层业务不直接感知存储结构。这样做的好处是以后就算从 Tair 换到别的存储业务代码不用动。4.2 key 空间怎么规划key 设计遵循“作用域优先”的原则不同作用域的数据绝不混在一个 key 里key 模式作用域存储内容agent:{agent_id}:session:{session_id}单次会话短期上下文、会话摘要、工作状态agent:{agent_id}:user:{user_id}跨会话用户长期偏好、历史事实、用户画像agent:{agent_id}:globalAgent 全局系统提示词版本、工具配置、通用知识这样切分之后过期策略可以按 key 单独设置不会出现用户级数据跟着会话级 TTL 一起失效。4.3 field 怎么设计这是整个方案里最值得抄作业的部分。我在会话级 key 和用户级 key 里分别设计了这些 field会话级 key存活时间由里面的 field 各自控制field 名内容说明类型过期策略ctx:recent最近 5 轮对话原文JSON 数组短期上下文30 分钟滑动续期ctx:summary历史对话的阶段性摘要中期压缩记忆24 小时活跃则续期state:flow当前对话流程节点、等待用户确认的信息工作状态10 分钟state:tool_result最近一次工具调用的结果缓存临时数据10 分钟用户级 key长期数据不跟随会话过期field 名内容说明类型过期策略profile:prefer用户表达的偏好比如“不要青轴”“预算 3000 以内”长期画像永久profile:facts用户身份、地址、历史订单 ID长期事实永久profile:blacklist用户明确拒绝过的内容负面偏好永久meta:update_seq更新时间序号用于并发判断辅助字段永久这样的 field 拆分把“生命周期不同”的记忆彻底分隔开。短期字段可以让它快速过期长期字段不受影响。4.4 一条真实记录长什么样我举个例子一个用户聊到第八轮时的状态// key: agent:shopping:session:a1b2c3 { ctx:recent: [{\role\:\user\,\content\:\预算3000以内\},{\role\:\assistant\,\content\:\好的3000以内有什么偏好吗\},{\role\:\user\,\content\:\不要青轴太吵\}], ctx:summary: 用户需要送礼用的机械键盘预算3000以内不要青轴倾向安静的红轴或茶轴, state:flow: {\stage\:\collect_presentee\,\next\:\confirm_budget\}, state:tool_result: {\product_page\:1,\filter\:{\switch\:\red\,\budget_max\:3000}} } // key: agent:shopping:user:u_88 { profile:prefer: {\switch_preference\:\no_blue\,\budget_max\:3000,\usage\:\gift\}, profile:facts: {\receiver_addr_hash\:\***\,\recent_order\:\ORD-2024-0921\} }可以看到短期上下文里的原始消息、阶段性摘要、工作流程状态、用户画像都在不同的 field 里过期策略互不影响。这正是 exhash 相对普通 Hash 的核心优势。5. 核心流程落地恢复、写入、摘要压缩与过期结构设计好了接下来是四个核心流程怎么落。我这里给出的是通用逻辑具体命令名以你使用的 Tair 客户端和版本为准重点是思路。5.1 会话恢复从 exhash 重建上下文用户带着 session_id 回来或者在一个 session 里开启新一轮对话时记忆服务层要从 exhash 里把上下文捞出来恢复对话现场。我设计的恢复逻辑分两步先用EXHGETALL读取整个 session key 的 map或者用EXHMGET只读需要的 fieldctx:recent、ctx:summary、state:flow。拼装优先级ctx:recent最近原文优先如果为空说明短期上下文已过期退回到ctx:summary摘要至少让 Agent 记得用户大概聊过什么。这里有个细节ctx:recent被设置为 30 分钟滑动过期用户 40 分钟没说话再回来时短期原文已经没了但摘要还在。这个设计不是为了省存储而是为了让模型不会被太老的原始消息误导。人也是这样太久远的对话你会忘掉具体措辞但记得整体结论。5.2 每轮对话结束后的写回策略每轮对话结束我会做三件事第一把这一轮的 user 消息和 assistant 消息追加到ctx:recent保留最近 5 轮更早的从数组里弹出并同步触发摘要更新逻辑。第二检查并更新state:flow。比如用户说“预算 3000 以内”我会用信息抽取逻辑更新profile:prefer里的budget_max同时把state:flow的流程节点推进一步。第三对短期 field 做续期。通过 exhash 的字段级 TTL 续期能力把ctx:recent和state:flow的过期时间往后拨 30 分钟。这个操作要放在写完之后避免并发问题。整体写入按“先业务逻辑、后状态持久化”的顺序确保每次模型输出被采纳后状态一定落库。5.3 上下文超限时的摘要压缩机制这是多轮对话里最容易被忽略的一环。就算有 exhash 存状态最终发送给模型的 prompt 也不能无限拼原始历史。我的压缩机制是在记忆服务层维护一个估算函数按字符数或 token 数估算当前 prompt 长度。如果ctx:recent加上系统提示已经超过阈值比如 4000 token触发压缩。压缩时把ctx:recent里的旧轮次交给模型生成摘要结果写入ctx:summary并删掉已被摘要覆盖的旧消息。ctx:summary的 TTL 设置为 24 小时如果用户当天持续对话每次压缩都会重置。用生活化类比短期上下文是桌面上的文件摘要压缩是下班前把文件归档进抽屉。桌面永远只留这几天要用的抽屉里存着整个项目的脉络。5.4 过期策略与清理任务exhash 的字段级 TTL 解决了我 70% 的清理工作但还有两件事需要业务代码配合会话结束的归档动作用户主动结束会话或超时退出时把profile:*字段从 session key 合并到 user key。这个动作不能省因为 session 级别的 key 最终会被清掉如果不主动归档跨会话记忆就丢了。定期巡检我写了一个定时任务每天扫描最近活跃的 session key对超过 7 天没活跃的 key 做整体删除避免 exhash 里堆积大量已失效但未清理的 field。6. 踩坑实录四个差点让我放弃的问题再好的设计落地时都会遇到意外。这四个问题是我在实际跑量之后踩到的按排查链路拆开讲希望你不用再走一遍。6.1 坑一给 session key 设 TTL用户画像被一起清掉这个问题前面提过但我要强调一下它有多隐蔽。当时上线第一版我用普通 Hash为了控制存储我对 session key 设了 24 小时过期。看起来没问题直到有用户第二天回来说“昨天不是说好了吗”Agent 一脸茫然。排查链路先看 Tair 里的数据发现 session key 已经没了。顺着逻辑检查发现用户画像存在同一个 key 里key 过期画像也一起消失了。我把用户画像的读取单独打日志发现每次会话结束时要写回 user key但 user key 里数据也是空的。根因用户画像的归档动作发生在会话结束后但 session key 过期可能早于归档执行或者归档时读到的 key 已经被 TTL 删掉画像写入就没有数据来源。解决办法把用户画像数据独立到 user key所有profile:*字段永久保留。session key 只存短期数据。这是我在设计上最值得骄傲的一个修正——它让数据生命周期和业务作用域彻底解耦。6.2 坑二并发写同一 field上下文互相覆盖Agent 框架在处理工具调用时经常会有重试和异步回调。有一次测试发现用户说“改成红色的”但 Agent 过了几轮又显示成蓝色。查日志发现是两个并发请求同时写profile:prefer这个 field请求 A 读到的旧值是蓝色准备改成红色。请求 B 读到的旧值也是蓝色但内容是另一维度的信息写回时把红色覆盖了。排查链路对比时间戳发现两个写操作间隔只有 100 毫秒。看 exhash 的版本号发现两个请求读到的版本号相同后写者没有感知到版本变化。最终确定必须用版本控制即读取时拿到版本号写入时带上版本号如果版本不匹配就重试或合并。解决办法exhash 自带版本机制。我在记忆服务层对profile:*这类长期字段开启版本检查写入时如果发现自己读到的版本号和当前不一致说明被其他并发改了就重新拉取新值再合并写入。这个机制能避免大多数并发覆盖问题。6.3 坑三缓存与落库双写重启后新状态被旧数据回填我们有一个异步任务把 exhash 中的状态定期快照到数据库做灾备。某次发布重启后我发现用户刚更新完的偏好在服务恢复后竟然回退到了十分钟前的值。排查链路看恢复流程发现是从 exhash 读exhash 里数据是对的。但业务代码里还有一个从数据库恢复的兜底逻辑数据库里的快照是十分钟前的旧数据。兜底逻辑在 exhash 读取失败时会触发但这里的问题是它被误触发而且旧数据把新数据覆盖了。根因容灾兜底逻辑没有区分“缓存真的没有数据”和“缓存暂时不可用”。缓存故障时从数据库兜底恢复是合理的但缓存正常只是没有命中某个 field 时不该用数据库的旧快照整体覆盖。解决办法把兜底策略改成“按 field 级别合并”数据库快照只补充 exhash 中不存在或已过期的 field绝不用整份快照覆盖当前状态。同时在 exhash 写入成功之前不更新数据库快照的时间戳避免反向覆盖。6.4 坑四单 field 过大引发热点和读放大上线一段时间后发现某些 session 的ctx:recent字段涨得很快。因为我在某些场景下允许保留 20 轮原文结果一个 field 里塞了几万字符。每次对话都要读整个字段响应时间开始变慢。排查链路用命令查看 field 长度发现最大的ctx:recent超过了 20KB。看热点监控发现这些大 field 的访问频次最高导致单分片负载不均。定位到业务层保留轮数设置太高且没有做长度上限判断。解决办法把保留轮数从 20 轮降到 5 轮同时给ctx:recent增加了最大字符数限制超过限制直接触发摘要压缩。此外我把大字段做了拆片比如ctx:recent:part1、ctx:recent:part2读取时按需组装。虽然代码复杂了一点但热点问题彻底解决了。7. 数据说话改造前后的效果对比这套方案上线稳定运行后我做了一组对比。场景是一套需要跨 15 轮以上对话才能完成配置的客服 Agent对比改造前后一周的线上数据指标改造前内存 dict String改造后Tair exhash服务重启后的会话恢复率0%内存全丢92%短期字段过期摘要仍在首轮响应 P99 时延850ms620ms摘要压缩后 prompt 变短单用户日均 Token 消耗4.2 万2.8 万减少约 33%状态覆盖错误次数/周13 次0 次版本控制生效用户偏好流失率35%3%长期字段独立过期最让我意外的是 Token 消耗的下降。原本以为引入外部记忆会增加开销但因为摘要压缩让进入上下文窗口的历史消息大幅减少整体 Token 反而省了三分之一。这也解释了为什么“把记忆从上下文窗口搬出来”不仅解决准确性问题还能省钱。基于这个效果后续我又做了两个方向的扩展一是把用户画像的 field 做成可检索的结构接入了向量化的思路用户提到“上次那个”时直接从画像里做语义召回二是把工作记忆里的工具调用状态做成事件溯源方便回放和审计。这些在 exhash 之上都成立。最后分享一个我个人的体会Agent 记忆这件事最忌讳的是“全部塞进 prompt”。模型的上下文窗口应该只承载当前这一步推理需要的信息长期记忆放外部存储短期记忆做分层过期工作记忆做并发控制。Tair exhash 恰好补齐了我在单机内存和普通 Redis 上始终绕不过去的三个能力字段级过期、版本控制和大字段的局部更新。如果你正在纠结多轮对话状态管理怎么做可以从这套结构开始改不一定照搬但设计思路大概率能帮你少走几步弯路。