做 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 状态生命周期:创建、更新、压缩、归档、清理
一个完整的会话状态,从用户进来到离开,会经历这几个阶段:
- 创建:用户发起会话,生成 session_id,初始化空状态。
- 更新:每轮对话结束,从当前轮次里抽取新的结构化信息,写入状态。
- 压缩:上下文达到长度阈值时,把旧的历史消息做摘要,替换到摘要字段。
- 归档:会话结束后,保留用户画像类的长期记忆,把短期上下文降级或删除。
- 清理:超过 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 String | Redis Hash | Tair exhash |
|---|---|---|---|
| 结构 | key-value | key-field-value | key-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}:global | Agent 全局 | 系统提示词版本、工具配置、通用知识 |
这样切分之后,过期策略可以按 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只读需要的 field(ctx: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 时延 | 850ms | 620ms(摘要压缩后 prompt 变短) |
| 单用户日均 Token 消耗 | 4.2 万 | 2.8 万(减少约 33%) |
| 状态覆盖错误次数/周 | 13 次 | 0 次(版本控制生效) |
| 用户偏好流失率 | 35% | 3%(长期字段独立过期) |
最让我意外的是 Token 消耗的下降。原本以为引入外部记忆会增加开销,但因为摘要压缩让进入上下文窗口的历史消息大幅减少,整体 Token 反而省了三分之一。这也解释了为什么“把记忆从上下文窗口搬出来”不仅解决准确性问题,还能省钱。
基于这个效果,后续我又做了两个方向的扩展:一是把用户画像的 field 做成可检索的结构,接入了向量化的思路,用户提到“上次那个”时,直接从画像里做语义召回;二是把工作记忆里的工具调用状态做成事件溯源,方便回放和审计。这些在 exhash 之上都成立。
最后分享一个我个人的体会:Agent 记忆这件事,最忌讳的是“全部塞进 prompt”。模型的上下文窗口应该只承载当前这一步推理需要的信息,长期记忆放外部存储,短期记忆做分层过期,工作记忆做并发控制。Tair exhash 恰好补齐了我在单机内存和普通 Redis 上始终绕不过去的三个能力:字段级过期、版本控制和大字段的局部更新。如果你正在纠结多轮对话状态管理怎么做,可以从这套结构开始改,不一定照搬,但设计思路大概率能帮你少走几步弯路。