资讯动态

给聊天机器人装记忆:基于Redis的对话记忆持久化实战

发布时间:2026/10/21 13:46:49 来源:尧图企业网站定制
连着折腾了五天我手搓的情感聊天机器人已经从“人工智障”进化到了“能正常唠嗑”的水平。但上周一个朋友试用后问了一句“你让它记住我昨天说的烦心事今天再接着安慰我几句它做得到吗”我当时就愣住了。因为我的机器人每次调用大模型接口都是无状态请求——聊完就忘典型金鱼记忆。这不行情感陪伴的核心就是“我记得你”。于是第六天我决定动手给AI装上真正的“记忆体”用Redis实现对话记忆持久化。这篇文章就是当天完整的过程记录从数据结构设计、代码实现到踩坑修复全部是实操经验适合同样在手搓聊天机器人的朋友参考。1. 为什么聊天机器人必须要有记忆1.1 没有记忆的AI就像金鱼我的机器人前五天做的事情很简单接收用户消息拼一个系统提示词调用大模型接口把回复返回给用户。每次请求都是独立的模型根本不记得上一轮聊了什么。这种无状态设计在简单问答场景里问题不大但放在情感聊天机器人身上就是灾难。举个例子用户说“我今天被领导批评了心里特别堵”机器人回复了一段安慰的话。用户接着说“你说我该怎么办”如果机器人没有记忆它根本不知道“你说”的“你”指的是它自己也不知道用户刚刚经历了什么只能泛泛而谈“遇到困难要积极面对”这种正确但没用的废话。情感聊天机器人最重要的能力是“共情”而共情的前提是“记得”。用户昨天说家里猫生病了今天说“它好像好一点了”一个合格的陪伴机器人应该能接住这句“它”指代的是猫。没有记忆一切都是空谈。所以我今天的目标很明确给机器人加一个能把对话历史存下来、取出来、用起来的地方。1.2 记忆持久化怎么选从文件到Redis给对话加记忆方案其实不少我一开始想过几种最后选了Redis这里把比较过程写出来。最简单的方案是存在Python的内存变量里比如用一个全局字典存某个用户的历史消息。但这个方案有个致命问题进程一重启记忆全部归零。我的机器人是跑在服务器上的代码更新要重启重启就没记忆这不叫持久化。第二个方案是写JSON文件每个用户一个文件把消息列表追加进去。这个方案对单用户、低并发的情况勉强能用但有两个麻烦一是高并发下多个请求同时写同一个文件容易冲突二是文件会越来越大读取全量历史再截取最近的N条效率很低而且不适合后面做多实例部署。第三个方案是上MySQL或SQLite这类关系型数据库。存储结构化数据当然没问题但对于对话消息这种“高频追加、滚动读取”的场景有点杀鸡用牛刀。我需要一个读写快、支持自动过期清理、天然适配消息列表的数据存储。这时Redis几乎是最好的答案。Redis是内存数据库读写延迟在毫秒级特别适合语言模型这种需要快速拿上下文的场景。它提供了List、Hash、String等多种数据结构消息历史直接用List存用户画像用Hash存非常顺手。更重要的是它原生支持TTL过期时间我可以给每个会话设置“保质期”过期后Redis自动清理不用我手动维护一堆旧数据。虽然Redis是纯内存存储有数据丢失风险但可以通过开启AOF追加日志来兜底。情感聊天机器人对极端一致性要求没那么高偶尔丢一小段历史完全可以接受Redis的性价比就非常合适。2. 动手前的基础准备2.1 安装Redis和redis-py我的服务器环境是Ubuntu安装Redis很简单# Ubuntu / Debian 安装 sudo apt update sudo apt install redis-server # 启动 Redis 服务后台运行 redis-server --daemonize yes # 验证是否正常 redis-cli ping如果看到PONG说明Redis已经跑起来了。macOS用户可以用brew install redisWindows用户可以考虑用WSL或者直接下官方Windows移植版不过我更推荐在Linux环境里跑。Python这边只需要一个redis库pip install redis安装好之后写个最简单的连接测试import redis r redis.Redis(host127.0.0.1, port6379, db0, decode_responsesTrue) print(r.ping()) # 输出 True 表示连接成功注意这里decode_responsesTrue很重要它让Redis返回的直接是字符串而不是bytes省去后面到处解码的麻烦。我一开始没加这个参数写代码时经常被b{...}这种bytes字符串折腾得头疼。2.2 情感机器人现有的对话流程回顾在接入Redis之前先把前五天的代码流程梳理一遍。我的机器人核心就一个函数def chat(user_id, message): # 调用大模型接口传入系统提示词和当前消息 reply call_llm(message) return reply这个函数最大的问题就是没有上下文。今天要做的就是在这个函数里加入“从Redis读取历史→组装成带上下文的对话→调用模型→把新的对话写回Redis”的完整闭环。改造后的伪代码是这样的def chat(user_id, message): # 1. 从Redis读取该用户最近的历史消息 history memory_store.get_history(user_id) # 2. 把历史消息和当前消息一起拼成Prompt prompt build_prompt(history, message) # 3. 调用大模型 reply call_llm(prompt) # 4. 把用户消息和机器人回复都存回Redis memory_store.save_user_message(user_id, message) memory_store.save_assistant_message(user_id, reply) return reply这个流程看起来简单但有几个点需要认真设计历史消息取多少条、怎么存储、怎么避免内存无限膨胀、用户ID怎么区分。这些问题我都在后面的数据结构设计和代码实战里一一解决了。3. 设计对话记忆的数据结构3.1 用Hash存储会话信息和用户画像Redis最灵活的地方在于它的数据结构都能直接用。对于每个用户我用了两类Key一类是长期稳定的会话信息另一类是短期的消息列表。会话信息我用Hash类型。为什么用Hash因为一个用户的画像字段有很多比如昵称、偏好、最近情绪状态等。如果用String得拼接成一个JSON字符串改一个字段就要整个读出来再写回去很麻烦。Hash天然支持对单个字段的读写key: session:{user_id} field: name value: 小美 field: pet value: 猫 field: mood value: 疲惫 field: last_time value: 1712000000.123在Python里操作是这样的# 写入用户会话信息 r.hset(fsession:{user_id}, mapping{ name: 小美, pet: 猫, mood: 疲惫, last_time: time.time() }) # 读取全部会话信息 info r.hgetall(fsession:{user_id})Hash还有一个好处它特别适合存“电量为零也能快速恢复”的关键信息。比如用户上次聊到的话题、用户喜欢被称呼什么读取这些字段比从一大堆消息历史里分析要快得多。我后面还会把LLM提取出的“长期摘要”存到Hash里这样即使消息列表被截断模型也能从摘要里知道用户最重要的信息。3.2 用List维护消息历史消息历史是对话记忆最核心的数据。Redis的List类型天然适合做消息队列和消息记录可以在尾部追加可以按范围取元素可以裁剪多余部分。我设计的消息Key如下key: chat:{user_id}:messages每条消息是一个JSON字符串包含角色、内容和时间戳{role: user, content: 我今天被领导批评了, ts: 1712000000.123}写入时用rpush追加到列表尾部读取时用lrange拿最近N条。代码import json import time def save_message(user_id, role, content): key fchat:{user_id}:messages msg { role: role, content: content, ts: time.time() } r.rpush(key, json.dumps(msg, ensure_asciiFalse))注意ensure_asciiFalse必须加否则中文会被转成\uXXXX形式虽然也能用但可读性很差而且白白占内存。读取历史可以这样def get_history(user_id, max_turns10): key fchat:{user_id}:messages # max_turns 是多少轮对话每轮有 user 和 assistant 两条消息所以乘以2 raw_list r.lrange(key, -max_turns * 2, -1) return [json.loads(item) for item in raw_list]用lrange传负数索引表示从列表末尾往前取。这样只需要最近10轮不管这个用户总共聊了多少条都只取最后20条非常高效。3.3 设置TTL避免记忆无限膨胀对话记忆不能一直留着否则会有两个后果一是Redis内存越来越满最后服务崩溃二是用户隐私风险历史消息存得越久越不安全。所以我给消息列表设置了过期时间默认7天每次有新消息写入时刷新过期时间。# 设置7天后过期每次写入时重新刷新 r.expire(key, 7 * 24 * 3600)这样如果一个用户连续使用TTL会被不断刷新记忆可以一直延续如果7天没来聊天Redis会自动把这条Key删掉相当于“记忆自动消散”。这个设计很符合人类社交的直觉——太久没联系自然就不记得细节了。对于用户画像Hash我设置更长一点的TTL比如30天。因为画像是长期画像不像具体对话内容那么敏感而且如果用户回来画像还在可以更快进入状态。不过如果用户30天没来他的画像也会被清理避免死数据堆积。4. 代码实战接入Redis实现记忆读写4.1 记忆写入把每一次交互都存档我把记忆操作封装成了一个MemoryStore类这样后续维护起来方便。先看写入部分import json import time import redis class MemoryStore: def __init__(self, redis_client): self.r redis_client self.max_messages 50 # 最多保留最近50轮对话 def _messages_key(self, user_id): return fchat:{user_id}:messages def _session_key(self, user_id): return fsession:{user_id} def save_user_message(self, user_id, content): self._save_message(user_id, user, content) def save_assistant_message(self, user_id, content): self._save_message(user_id, assistant, content) def _save_message(self, user_id, role, content): key self._messages_key(user_id) msg { role: role, content: content, ts: time.time() } # 1. 追加新消息 self.r.rpush(key, json.dumps(msg, ensure_asciiFalse)) # 2. 裁剪列表只保留最近 max_messages 轮 # 每轮有2条消息所以要保留 max_messages * 2 条 self.r.ltrim(key, -self.max_messages * 2, -1) # 3. 刷新过期时间 self.r.expire(key, 7 * 24 * 3600)这里有两个关键操作rpush和ltrim配合确保列表永远只保留最近100条消息50轮。之所以要主动裁剪是因为即使设置了TTL在7天内高活跃用户依然可能积累几千条消息读取时虽然只取最近20条但旧的仍然占着内存不如直接剪掉。4.2 记忆读取组装会话上下文给大模型读取历史并转成大模型能用的消息格式这是核心环节class MemoryStore: def get_history(self, user_id, max_turns10): key self._messages_key(user_id) raw_list self.r.lrange(key, -max_turns * 2, -1) if not raw_list: return [] return [json.loads(item) for item in raw_list] def build_messages(user_id, new_message): store memory_store history store.get_history(user_id, max_turns8) # 组装成大模型API需要的messages格式 messages [ {role: system, content: 你是一个温柔、体贴的情感陪伴机器人。} ] for item in history: messages.append({ role: item[role], content: item[content] }) messages.append({role: user, content: new_message}) return messages这里有个细节大模型上下文窗口是有限的不能把所有历史都塞进去。我设置最多取最近8轮对话16条消息加上当前消息。8轮对于情感陪伴场景来说足够覆盖大部分上下文再久远的聊天内容可以通过后面的摘要机制来压缩记忆。组装好messages之后直接调用大模型API把messages传进去就可以得到带记忆的回复。每个大模型厂商的API都大同小异核心就是把原本只传一条消息的地方改成传整个消息列表。4.3 记忆更新处理滚动删除和关键词提取光有消息历史还不够因为消息列表会滚动裁剪以前的关键信息可能被挤掉。所以需要另一个维度的长期记忆用户画像和话题摘要。我写了一个更新会话信息的方法可以在每次对话后调用。提取摘要和关键词可以直接用大模型做也可以用简单的规则。为了控制成本和延迟我选择在每天第一次对话时异步生成一次摘要然后存在Hash里。class MemoryStore: def update_session_meta(self, user_id, **fields): key self._session_key(user_id) self.r.hset(key, mappingfields) # 刷新用户画像的TTL30天 self.r.expire(key, 30 * 24 * 3600) def get_session_meta(self, user_id): key self._session_key(user_id) info self.r.hgetall(key) if not info: return {} return info在实际项目里我会在对话开始前检查Hash里有没有summary字段如果有就把摘要作为额外的系统提示词拼进去。比如摘要里写着“用户养了一只橘猫最近身体不适用户很担心”模型看到后就会自动把猫相关的信息纳入共情范围。这种设计等于把记忆分成了两层短期记忆是最近的List消息长期记忆是Hash里的摘要和画像。两层互相配合既节省模型输入token又能保留关键信息。5. 跑通测试与效果对比5.1 测试脚本模拟三轮对话代码写完了最重要的是跑起来看效果。我写了一个简单的模拟脚本模拟同一个用户连续发三条消息def simulate(): user_id test_user_001 messages [ 最近工作压力好大每天都加班到十点, 是啊感觉身体快垮了也没时间运动, 那你觉得我该怎么调节一下 ] for msg in messages: reply chat(user_id, msg) print(f用户{msg}) print(f机器人{reply}) print(---) if __name__ __main__: simulate()第一次跑的时候我故意把memory_store.get_history暂时返回空列表模拟“没有记忆”的状态。结果第三条消息“那你觉得我该怎么调节一下”的回复是这种“工作虽然重要但健康更重要建议你抽时间运动也可以找人聊聊。”虽然也说到了运动但明显没有接住前文的“加班到十点”“身体快垮了”这些信息回复很泛。这就是不带记忆的效果能说正确的话却说不中听的话。5.2 带记忆和不带记忆的回复对比把记忆功能打开之后同样的三条消息第三条的回复完全不一样了“能理解你现在的疲惫感。你前面提到每天加班到十点身体也发出了信号这种情况下确实很难抽出时间专门运动。不如先从小的改变开始中午午休时下楼走10分钟周末抽出半天彻底放空。要不要我帮你把周末的休息计划安排一下”这个回复引用了前面聊到的细节还给出了具体建议很明显是“记得你在说什么”的。这就是记忆带来的本质区别从“人工智障”到“像个人”。我还做了一个更直观的测试。用户先说“我养了一只猫它今天吐了”下一句是“它之前都好好的突然这样我很慌”。不带记忆的机器人会问“你说的它是谁”而带记忆的机器人会回答“别急猫呕吐可能只是吐毛球先观察它精神状态和食欲如果持续呕吐或者没精神建议尽快去宠物医院”。情感陪伴的效果一下就不一样了。6. 常见问题与避坑经验6.1 Redis连接失败和密码配置问题我在测试过程中遇到最典型的问题是连接不上Redis报错ConnectionRefused。可能原因有几个一是服务没启动先redis-cli ping查一下二是Redis绑定地址是127.0.0.1而不是对外IP如果代码和Redis不在同一台机器需要改bind配置三是protected-mode yes会限制非法地址访问。另外一个更实际的问题是密码。生产环境一定要设置Redis密码不然可能被扫描端口后恶意操作。设置方式# 在 redis.conf 里设置 requirepass your_strong_password然后重启RedisPython连接时r redis.Redis( host127.0.0.1, port6379, passwordyour_strong_password, decode_responsesTrue )如果密码错误会报AuthenticationError检查一下密码就能解决。6.2 JSON序列化中文乱码问题这个问题出现的频率非常高。最开始我用的json.dumps(msg)没加ensure_asciiFalse结果Redis里存的中文全是\u6587\u5b57这种转义符。虽然读取后用json.loads能还原但带来两个问题一是占用空间变大一个汉字变成6个字符二是在Redis客户端里排查问题时看到的全是转义符号非常痛苦。解决办法就是写入时固定加参数json.dumps(msg, ensure_asciiFalse)读取时直接json.loads不需要额外处理因为json.loads会自动把转义符解码回中文。6.3 Redis内存爆炸的预防对话记忆最怕的就是内存失控。虽然我设置了TTL和LTRIM但毕竟Redis是内存数据库还是需要做好兜底策略。建议在redis.conf里开启内存上限和淘汰策略maxmemory 256mb maxmemory-policy allkeys-lruallkeys-lru表示内存满了以后自动淘汰最久没使用的Key。这个策略对对话记忆来说比较合理长期不活跃用户的记忆会被优先清理活跃用户的记忆保留。配合TTL基本能保证Redis长期稳定运行。另外可以定期用redis-cli info memory查看内存使用情况及时发现异常增长。6.4 高并发下的会话顺序问题还有一个容易被忽略的坑如果同一个用户同时发来两条消息两个请求可能先后读取到相同的旧历史导致上下文交叉错乱。比如用户先发了“我今天失恋了”又马上发“我好难过”如果不加控制后一个请求可能没读到前一条“失恋”的记录。解决思路有两种。简单粗暴的做法是在应用层给每个user_id加锁在每个用户的消息处理完成前不允许处理他的下一条消息。更优雅的做法是使用Redis的分布式锁比如SETNX实现或者利用Redis单线程的特性把消息写入和读取放在一个Lua脚本里原子执行。对于个人项目前端已经天然限制了用户不能同时发两条消息所以这个问题影响不大但如果将来要开放API给第三方就需要考虑加锁了。我个人在实际操作中的体会是给聊天机器人装记忆核心不是会用Redis而是懂得把记忆分门别类。短期消息用List用户画像和长期摘要用HashTTL管好生命周期LTRIM管好列表长度这套组合足够覆盖大部分对话场景。做完这一步我的情感机器人终于不再“翻脸不认人”而是能像朋友一样“把你说过的话放在心上”。后面我还打算引入向量的方式让机器人能精准回忆起几十轮之前某个特别重要的细节那时候再写一篇单独的实战记录。如果你也正在手搓聊天机器人强烈建议先把今天这套Hash加List加TTL的搭配用熟这应该是入门记忆持久化最顺手的路子。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑