连着折腾了五天,我手搓的情感聊天机器人已经从“人工智障”进化到了“能正常唠嗑”的水平。但上周一个朋友试用后问了一句:“你让它记住我昨天说的烦心事,今天再接着安慰我几句,它做得到吗?”我当时就愣住了。因为我的机器人每次调用大模型接口,都是无状态请求——聊完就忘,典型金鱼记忆。这不行,情感陪伴的核心就是“我记得你”。于是第六天,我决定动手给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 redis,Windows用户可以考虑用WSL或者直接下官方Windows移植版,不过我更推荐在Linux环境里跑。
Python这边只需要一个redis库:
pip install redis安装好之后写个最简单的连接测试:
import redis r = redis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True) print(r.ping()) # 输出 True 表示连接成功注意这里decode_responses=True很重要,它让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(f"session:{user_id}", mapping={ "name": "小美", "pet": "猫", "mood": "疲惫", "last_time": time.time() }) # 读取全部会话信息 info = r.hgetall(f"session:{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 = f"chat:{user_id}:messages" msg = { "role": role, "content": content, "ts": time.time() } r.rpush(key, json.dumps(msg, ensure_ascii=False))注意ensure_ascii=False必须加,否则中文会被转成\uXXXX形式,虽然也能用但可读性很差,而且白白占内存。
读取历史可以这样:
def get_history(user_id, max_turns=10): key = f"chat:{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 f"chat:{user_id}:messages" def _session_key(self, user_id): return f"session:{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_ascii=False)) # 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_turns=10): 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_turns=8) # 组装成大模型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, mapping=fields) # 刷新用户画像的TTL,30天 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然后重启Redis,Python连接时:
r = redis.Redis( host="127.0.0.1", port=6379, password="your_strong_password", decode_responses=True )如果密码错误,会报AuthenticationError,检查一下密码就能解决。
6.2 JSON序列化中文乱码问题
这个问题出现的频率非常高。最开始我用的json.dumps(msg)没加ensure_ascii=False,结果Redis里存的中文全是\u6587\u5b57这种转义符。虽然读取后用json.loads能还原,但带来两个问题:一是占用空间变大,一个汉字变成6个字符;二是在Redis客户端里排查问题时,看到的全是转义符号,非常痛苦。
解决办法就是写入时固定加参数:
json.dumps(msg, ensure_ascii=False)读取时直接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,用户画像和长期摘要用Hash,TTL管好生命周期,LTRIM管好列表长度,这套组合足够覆盖大部分对话场景。做完这一步,我的情感机器人终于不再“翻脸不认人”,而是能像朋友一样“把你说过的话放在心上”。后面我还打算引入向量的方式,让机器人能精准回忆起几十轮之前某个特别重要的细节,那时候再写一篇单独的实战记录。如果你也正在手搓聊天机器人,强烈建议先把今天这套Hash加List加TTL的搭配用熟,这应该是入门记忆持久化最顺手的路子。