Agent记忆管理实战:用Tair exhash解决多轮对话状态丢失
2026/9/12 10:51:45 网站建设 项目流程

做 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 不等,但对话轮次可以无限增长。窗口满了就只能丢旧消息,或者手动截断。
  • 成本随长度飙升:每一轮都把完整历史塞进 prompt,Token 消耗是轮次的线性叠加。用户聊 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 状态生命周期:创建、更新、压缩、归档、清理

一个完整的会话状态,从用户进来到离开,会经历这几个阶段:

  1. 创建:用户发起会话,生成 session_id,初始化空状态。
  2. 更新:每轮对话结束,从当前轮次里抽取新的结构化信息,写入状态。
  3. 压缩:上下文达到长度阈值时,把旧的历史消息做摘要,替换到摘要字段。
  4. 归档:会话结束后,保留用户画像类的长期记忆,把短期上下文降级或删除。
  5. 清理:超过 TTL 的数据由存储层自动过期,不需要业务代码追着删。

这个生命周期里,最核心的挑战是“同一份状态在不同阶段要求不同的存活时间”。如果存储结构不支持字段级过期,就只能把所有数据一把梭,要么过早丢失,要么过期不清理。

2.3 状态管理设计要回答四个问题

做存储选型前,我把需求收敛成了四个问题,每个问题都直接决定数据结构:

问题一:持久性要求多高?会话中间状态丢了还能重来,但用户画像丢了就是事故。所以短期态和工作态可以容忍一定丢失,长期态必须可靠。这意味着不能把所有数据都用一个过期策略管理。

问题二:作用域是什么?状态可能属于某个会话(session 维度),也可能属于某个用户(user 维度),还可能属于全局 Agent(Agent 的配置、通用知识)。作用域不同,key 的设计必须不同,否则会出现用户 A 的偏好污染用户 B 的会话。

问题三:时效性怎么定?不能一个 TTL 走天下。比如最近几轮上下文 30 分钟过期,会话摘要 24 小时过期,用户偏好永久有效。这决定了存储结构必须能对局部字段单独设置过期时间。

问题四:并发一致性要求?Agent 在调用工具、重试、异步回调时,可能同时对同一份状态发起写操作。如果后写覆盖先写,用户刚确认的信息可能被一个迟到的旧消息冲掉。所以还需要版本控制或原子操作能力。

这四个问题问完,普通 Redis Hash 已经扛不住了,我这才把目光转向 Tair exhash。

3. 为什么我最终选了 Tair exhash 来做记忆容器

选型这件事,网上有很多对比文章,但大多停留在命令层面。我讲一下自己真实对比过三个方案之后的结论,以及 exhash 到底是改了什么才解决我的问题。

3.1 先说我用过的两个“常规方案”的坑

方案 A:Redis String,value 直接放 JSON。key 用 session_id,value 里塞整个会话状态。优点是实现最快,缺点是每次更新都要先读完整 JSON、反序列化、改字段、再序列化写回。一旦会话状态变大,读写放大非常严重。更要命的是,String 没有字段级过期能力,短期上下文和长期画像混在一起,要么一起留,要么一起删。

方案 B:普通 Redis Hash,field 分别存不同维度的状态。比 String 好一点,可以局部更新某个 field。但普通 Hash 的过期只能作用在整个 key 上。当时我犯过一个错:想给“最近几轮对话”设置 30 分钟过期,于是对整个 Hash 设了 TTL,结果用户画像和偏好设置也一起被清掉了。这个问题不是靠代码能绕过去的,是数据结构本身不支持。

3.2 exhash 到底改了什么

Tair exhash 是阿里云 Tair 自研的增强版 Hash 结构,核心改进是:每个 field 可以独立设置过期时间,普通 Hash 只有一个总 TTL,exhash 把过期能力下沉到了字段级别。

我当时的直观感受是:普通 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 里把上下文捞出来,恢复对话现场。我设计的恢复逻辑分两步:

  1. 先用EXHGETALL读取整个 session key 的 map,或者用EXHMGET只读需要的 field(ctx:recentctx:summarystate:flow)。
  2. 拼装优先级: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:recentstate:flow的过期时间往后拨 30 分钟。这个操作要放在写完之后,避免并发问题。

整体写入按“先业务逻辑、后状态持久化”的顺序,确保每次模型输出被采纳后,状态一定落库。

5.3 上下文超限时的摘要压缩机制

这是多轮对话里最容易被忽略的一环。就算有 exhash 存状态,最终发送给模型的 prompt 也不能无限拼原始历史。我的压缩机制是:

  1. 在记忆服务层维护一个估算函数,按字符数或 token 数估算当前 prompt 长度。
  2. 如果ctx:recent加上系统提示已经超过阈值(比如 4000 token),触发压缩。
  3. 压缩时,把ctx:recent里的旧轮次交给模型生成摘要,结果写入ctx:summary,并删掉已被摘要覆盖的旧消息。
  4. 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 一脸茫然。

排查链路:

  1. 先看 Tair 里的数据,发现 session key 已经没了。
  2. 顺着逻辑检查,发现用户画像存在同一个 key 里,key 过期画像也一起消失了。
  3. 我把用户画像的读取单独打日志,发现每次会话结束时要写回 user key,但 user key 里数据也是空的。

根因:用户画像的归档动作发生在会话结束后,但 session key 过期可能早于归档执行,或者归档时读到的 key 已经被 TTL 删掉,画像写入就没有数据来源。

解决办法:把用户画像数据独立到 user key,所有profile:*字段永久保留。session key 只存短期数据。这是我在设计上最值得骄傲的一个修正——它让数据生命周期和业务作用域彻底解耦。

6.2 坑二:并发写同一 field,上下文互相覆盖

Agent 框架在处理工具调用时,经常会有重试和异步回调。有一次测试发现,用户说“改成红色的”,但 Agent 过了几轮又显示成蓝色。查日志发现,是两个并发请求同时写profile:prefer这个 field:

  • 请求 A 读到的旧值是蓝色,准备改成红色。
  • 请求 B 读到的旧值也是蓝色,但内容是另一维度的信息,写回时把红色覆盖了。

排查链路:

  1. 对比时间戳,发现两个写操作间隔只有 100 毫秒。
  2. 看 exhash 的版本号,发现两个请求读到的版本号相同,后写者没有感知到版本变化。
  3. 最终确定必须用版本控制,即读取时拿到版本号,写入时带上版本号,如果版本不匹配就重试或合并。

解决办法:exhash 自带版本机制。我在记忆服务层对profile:*这类长期字段开启版本检查,写入时如果发现自己读到的版本号和当前不一致,说明被其他并发改了,就重新拉取新值再合并写入。这个机制能避免大多数并发覆盖问题。

6.3 坑三:缓存与落库双写,重启后新状态被旧数据回填

我们有一个异步任务,把 exhash 中的状态定期快照到数据库做灾备。某次发布重启后,我发现用户刚更新完的偏好,在服务恢复后竟然回退到了十分钟前的值。

排查链路:

  1. 看恢复流程,发现是从 exhash 读,exhash 里数据是对的。
  2. 但业务代码里还有一个从数据库恢复的兜底逻辑,数据库里的快照是十分钟前的旧数据。
  3. 兜底逻辑在 exhash 读取失败时会触发,但这里的问题是它被误触发,而且旧数据把新数据覆盖了。

根因:容灾兜底逻辑没有区分“缓存真的没有数据”和“缓存暂时不可用”。缓存故障时,从数据库兜底恢复是合理的;但缓存正常只是没有命中某个 field 时,不该用数据库的旧快照整体覆盖。

解决办法:把兜底策略改成“按 field 级别合并”,数据库快照只补充 exhash 中不存在或已过期的 field,绝不用整份快照覆盖当前状态。同时,在 exhash 写入成功之前不更新数据库快照的时间戳,避免反向覆盖。

6.4 坑四:单 field 过大引发热点和读放大

上线一段时间后,发现某些 session 的ctx:recent字段涨得很快。因为我在某些场景下允许保留 20 轮原文,结果一个 field 里塞了几万字符。每次对话都要读整个字段,响应时间开始变慢。

排查链路:

  1. 用命令查看 field 长度,发现最大的ctx:recent超过了 20KB。
  2. 看热点监控,发现这些大 field 的访问频次最高,导致单分片负载不均。
  3. 定位到业务层:保留轮数设置太高,且没有做长度上限判断。

解决办法:把保留轮数从 20 轮降到 5 轮,同时给ctx:recent增加了最大字符数限制,超过限制直接触发摘要压缩。此外,我把大字段做了拆片,比如ctx:recent:part1ctx: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 上始终绕不过去的三个能力:字段级过期、版本控制和大字段的局部更新。如果你正在纠结多轮对话状态管理怎么做,可以从这套结构开始改,不一定照搬,但设计思路大概率能帮你少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询