AI Agent记忆系统实战:用Redis缓存让读性能提升2倍

95 阅读11分钟

前言

在上一篇文章中,我们通过异步压缩优化,将用户感知延迟从6.17秒降低到0.98秒,实现了6.3倍的性能提升。但在实际测试中,我发现了新的性能瓶颈:每次对话都要查询PostgreSQL读取历史上下文,高并发场景下数据库成为瓶颈

本文将分享如何通过Redis缓存层,将读操作性能提升1.89倍,吞吐量提升2倍

本文是《AI Agent记忆系统实战》系列第三篇,完整代码已开源。


一、为什么需要缓存层?

1.1 真实Agent的工作流程

让我们先看看一个真实的AI Agent是如何工作的:

# 每轮对话的标准流程
async def agent_chat(user_id, session_id, user_message):
    # 1. 读取历史上下文(关键步骤!)
    history = await memory.query_messages(user_id, session_id, limit=50)
    
    # 2. 构造prompt
    prompt = build_prompt(history, user_message)
    
    # 3. 调用LLM
    response = await llm.chat(prompt)
    
    # 4. 保存新消息
    await memory.add_message(user_id, session_id, "assistant", response)
    
    return response

关键发现:每次对话都要读取历史上下文(50-100条消息),这是一个高频读取操作

1.2 PostgreSQL的性能瓶颈

在没有缓存的情况下,每次读取都要查询PostgreSQL:

-- 每次对话都要执行这个查询
SELECT * FROM messages 
WHERE conversation_id = ? 
ORDER BY created_at DESC 
LIMIT 50;

问题出现在哪?

场景问题
单用户长对话查询延迟随消息量增长(100条 vs 10000条)
高并发100个用户同时查询,数据库连接池耗尽
重复查询用户连续对话,相同数据被反复查询

1.3 实测数据对比

我进行了一个真实场景测试:20个用户并发,每用户500条历史,50轮对话,80%读操作

指标无缓存Redis缓存提升
P50延迟11.14ms5.36ms2.08x
读操作平均12.65ms6.70ms1.89x
吞吐量1,688/s3,364/s1.99x 🚀
缓存命中率0%100%-

结论:在读密集场景下,Redis缓存能将性能提升2倍


二、三层记忆架构设计

2.1 整体架构

┌─────────────────────────────────────────────────────┐
│                   AI Agent 对话                      │
└─────────────────────────────────────────────────────┘
                         ↓
          ┌──────────────────────────────┐
          │   短期记忆 (Deque)           │
          │   - 最近10轮对话             │
          │   - 内存存储,立即访问       │
          │   - 响应时间: <1ms           │
          └──────────────────────────────┘
                         ↓ 溢出
          ┌──────────────────────────────┐
          │   中期记忆 (PostgreSQL)      │
          │   - 完整对话历史             │
          │   - 持久化存储               │
          │   - 响应时间: 10-50ms        │
          └──────────────────────────────┘
                         ↑
                    【新增】
          ┌──────────────────────────────┐
          │   缓存层 (Redis)             │
          │   - 热点数据缓存             │
          │   - TTL自动过期              │
          │   - 响应时间: 1-2ms          │
          └──────────────────────────────┘

2.2 读写流程

读取路径(三级缓存)

用户请求
  ↓
Deque(短期记忆)
  ↓ miss
Redis(缓存层)
  ↓ miss  
PostgreSQL(持久层)
  ↓
写回Redis
  ↓
返回结果

写入路径(双写一致)

新消息
  ↓
写入Deque
  ↓ 溢出?
同时写入PostgreSQL + Redis

2.3 关键设计决策

Q1: 为什么选Redis而不是Memcached?

特性RedisMemcached
数据结构支持Hash、List、Set只支持String
持久化支持RDB/AOF不支持
TTL粒度键级别键级别
适用场景复杂数据结构简单KV

选择Redis的原因

  • ✅ 用户画像需要Hash结构
  • ✅ 支持持久化,避免缓存雪崩
  • ✅ 更丰富的数据类型

Q2: 为什么消息用JSON,用户画像用Hash?

# 消息列表:JSON(整体操作)
messages_key = "messages:user_123:session_456"
# 存储:[{role: "user", content: "..."}, {...}]
# 特点:每次读取完整列表,适合用JSON

# 用户画像:Hash(字段操作)
profile_key = "profile:user_123:session_456"
# 存储:{name: "张三", age: "25", interest: "编程"}
# 特点:可以单独更新某个字段,适合用Hash

Q3: TTL如何设置?

# 根据访问频率设置不同TTL
cache_ttl = {
    "messages": 3600,      # 1小时(高频访问)
    "profile": 10800,      # 3小时(中频访问)
    "summary": 7200        # 2小时(低频访问)
}

设置原则

  • 访问频率高 → TTL短(数据更新快)
  • 访问频率低 → TTL长(减少数据库压力)

三、Redis存储层实现

3.1 核心代码

# src/memory/redis_storage.py

import json
import aioredis
from typing import Dict, List, Optional

class RedisStorage:
    """Redis缓存存储"""
    
    def __init__(
        self,
        host: str = "localhost",
        port: int = 6379,
        db: int = 0,
        default_ttl: int = 3600
    ):
        self.host = host
        self.port = port
        self.db = db
        self.default_ttl = default_ttl
        self.redis = None
    
    async def connect(self):
        """连接Redis"""
        self.redis = await aioredis.from_url(
            f"redis://{self.host}:{self.port}/{self.db}",
            encoding="utf-8",
            decode_responses=True
        )
    
    async def close(self):
        """关闭连接"""
        if self.redis:
            await self.redis.close()
    
    # ========== 消息缓存(String/JSON) ==========
    
    def _message_list_key(self, user_id: str, session_id: str) -> str:
        """消息列表的key"""
        return f"messages:{user_id}:{session_id}"
    
    async def cache_messages(
        self,
        user_id: str,
        session_id: str,
        messages: List[Dict],
        ttl: Optional[int] = None
    ):
        """缓存消息列表"""
        key = self._message_list_key(user_id, session_id)
        json_messages = json.dumps(messages, ensure_ascii=False)
        await self.redis.setex(key, ttl or self.default_ttl, json_messages)
    
    async def get_cached_messages(
        self,
        user_id: str,
        session_id: str
    ) -> Optional[List[Dict]]:
        """获取缓存的消息"""
        key = self._message_list_key(user_id, session_id)
        value = await self.redis.get(key)
        if value:
            return json.loads(value)
        return None
    
    # ========== 用户画像缓存(Hash) ==========
    
    def _profile_key(self, user_id: str, session_id: str) -> str:
        """用户画像的key"""
        return f"profile:{user_id}:{session_id}"
    
    async def cache_profile(
        self,
        user_id: str,
        session_id: str,
        profile: Dict[str, str],
        ttl: Optional[int] = None
    ):
        """缓存用户画像"""
        key = self._profile_key(user_id, session_id)
        await self.redis.hset(key, mapping=profile)
        await self.redis.expire(key, ttl or self.default_ttl)
    
    async def get_cached_profile(
        self,
        user_id: str,
        session_id: str
    ) -> Optional[Dict[str, str]]:
        """获取缓存的用户画像"""
        key = self._profile_key(user_id, session_id)
        profile = await self.redis.hgetall(key)
        return profile if profile else None

3.2 为什么消息用JSON而不是List?

考虑过的方案对比

方案优点缺点
JSON String原子操作,序列化简单需要整体读写
Redis List支持部分读取复杂对象需要额外序列化
Redis Stream天然支持时间序列查询不够灵活

最终选择JSON的原因

# 场景:Agent需要读取最近50条消息
# 方案1:JSON - 一次读取
messages = json.loads(await redis.get(key))[-50:]

# 方案2:List - 需要多次操作
messages = await redis.lrange(key, -50, -1)
# 还需要反序列化每条消息
messages = [json.loads(m) for m in messages]

JSON方案在我们的场景下更简单、更高效。


四、集成到中期记忆

4.1 改造MidTermMemory

# src/memory/mid_term_with_redis.py

class MidTermMemoryWithRedis:
    """带Redis缓存的中期记忆"""
    
    def __init__(
        self,
        pg_storage: PostgreSQLStorage,
        redis_storage: RedisStorage,
        max_turns: int = 10,
        cache_ttl: int = 3600
    ):
        self.short_term = ShortTermMemory(max_turns=max_turns)
        self.pg_storage = pg_storage
        self.redis = redis_storage
        self.cache_ttl = cache_ttl
        
        # 缓存命中率统计
        self.cache_hits = 0
        self.cache_misses = 0
    
    async def add_message(self, user_id, session_id, role, content, tokens=None):
        """添加消息(双写策略)"""
        # 1. 添加到短期记忆
        self.short_term.add_message(role, content)
        
        # 2. 检查是否溢出
        overflow = self.short_term.check_overflow()
        
        if overflow:
            # 3. 溢出消息保存到PostgreSQL
            conv = await self.pg_storage.get_or_create_conversation(
                user_id, session_id
            )
            await self.pg_storage.add_messages(conv.id, overflow)
            
            # 4. 同时更新Redis缓存
            all_messages = await self.pg_storage.query_messages(conv.id)
            messages_dict = [
                {"role": m.role, "content": m.content} 
                for m in all_messages
            ]
            await self.redis.cache_messages(
                user_id, session_id, messages_dict, ttl=self.cache_ttl
            )
    
    async def query_messages(self, user_id, session_id, limit=None):
        """查询消息(三级缓存)"""
        # 1. 先查Redis
        cached = await self.redis.get_cached_messages(user_id, session_id)
        if cached is not None:
            self.cache_hits += 1
            return cached[-limit:] if limit else cached
        
        # 2. Redis miss,查PostgreSQL
        self.cache_misses += 1
        conv = await self.pg_storage.get_or_create_conversation(
            user_id, session_id
        )
        pg_messages = await self.pg_storage.query_messages(conv.id, limit=limit)
        
        # 3. 写回Redis
        messages_dict = [
            {"role": m.role, "content": m.content} 
            for m in pg_messages
        ]
        if messages_dict:
            await self.redis.cache_messages(
                user_id, session_id, messages_dict, ttl=self.cache_ttl
            )
        
        return messages_dict
    
    def get_cache_hit_rate(self) -> float:
        """计算缓存命中率"""
        total = self.cache_hits + self.cache_misses
        return self.cache_hits / total if total > 0 else 0.0

4.2 缓存更新策略

关键问题:如何保证缓存和数据库的一致性?

我们采用的是双写 + TTL过期策略:

# 写入流程
async def add_message(...):
    # 1. 写数据库
    await pg_storage.add_messages(...)
    
    # 2. 更新缓存(全量刷新)
    all_messages = await pg_storage.query_messages(...)
    await redis.cache_messages(..., ttl=3600)

为什么不用删除缓存策略?

# 方案1:删除缓存(Cache-Aside模式)
await pg_storage.add_messages(...)
await redis.delete(key)  # 删除旧缓存

# 问题:下次读取时cache miss,增加延迟

# 方案2:更新缓存(Write-Through模式) ⭐
await pg_storage.add_messages(...)
await redis.cache_messages(...)  # 立即更新

# 优点:下次读取立即命中缓存

五、性能测试

5.1 测试设计

为什么要模拟真实Agent场景?

之前的简单测试没有体现Redis的价值,因为:

  • ❌ 数据量太小(每用户只有几十条消息)
  • ❌ 读操作太少(70%写 vs 实际Agent 80%读)
  • ❌ 读取量太小(每次1条 vs 实际需要50条上下文)

改进后的测试配置

NUM_USERS = 20              # 并发用户数
HISTORY_MESSAGES = 500      # 每用户历史消息
TEST_ROUNDS = 50            # 测试轮数
READ_RATIO = 0.8            # 80%读操作
CONTEXT_SIZE = 50           # 每次读50条(模拟上下文窗口)

# 总数据量:20 × 500 = 10,000条消息
# 总操作数:20 × 50 = 1,000次操作
# 读操作数:1,000 × 80% = 800次读取

5.2 测试结果

延迟对比

指标无RedisRedis缓存提升倍数
P50延迟11.14ms5.36ms2.08x
P95延迟22.25ms12.54ms1.77x
P99延迟45.85ms20.57ms2.23x
平均延迟11.00ms5.75ms1.91x

读写性能对比

操作类型无RedisRedis缓存提升倍数
读操作平均12.65ms6.70ms1.89x
读操作P9519.27ms12.28ms1.57x
写操作平均4.79ms1.84ms2.60x

吞吐量对比

指标无RedisRedis缓存提升倍数
吞吐量1,688/s3,364/s1.99x 🚀
总耗时0.59s0.30s节省49.8%
缓存命中率0%100%-

5.3 关键发现

1. Redis对读密集场景提升明显

读操作占比:80%
读操作提升:1.89倍
整体提升:2.08倍

结论:读操作越多,Redis优势越明显

2. 缓存命中率达到100%

测试场景:多轮连续对话
首次查询:Cache Miss → PostgreSQL
后续查询:Cache Hit → Redis

命中率:800次读取中800次命中 = 100%

3. 吞吐量翻倍的原因

无Redis:所有请求打到PostgreSQL
- 连接池限制:30个连接
- 排队等待:请求阻塞

有Redis:90%+请求命中缓存
- 直接从Redis返回
- PostgreSQL压力降低90%
- 吞吐量提升2倍

六、实战经验总结

6.1 Redis最佳实践

1. TTL设置建议

# 根据数据特点设置不同TTL
cache_config = {
    # 高频读取、更新快 → 短TTL
    "messages": 3600,          # 1小时
    
    # 中频读取、更新慢 → 中TTL
    "summary": 7200,           # 2小时
    
    # 低频读取、更新慢 → 长TTL
    "profile": 10800,          # 3小时
}

原则

  • TTL太短:缓存失效频繁,失去意义
  • TTL太长:数据可能过期,占用内存
  • 根据业务特点平衡

2. 缓存键命名规范

# 推荐:分层命名,便于管理
"messages:{user_id}:{session_id}"
"profile:{user_id}:{session_id}"
"summary:{user_id}:{session_id}:{summary_id}"

# 好处:
# 1. 清晰的命名空间
# 2. 便于批量删除(pattern匹配)
# 3. 便于监控统计

3. 缓存更新时机

# 场景1:写入新消息
async def add_message(...):
    await db.save()
    await cache.update()  # 立即更新

# 场景2:批量操作
async def import_history(...):
    await db.bulk_insert()
    # 不更新缓存,等待TTL过期
    
# 原则:频繁操作立即更新,批量操作延迟更新

6.2 踩过的坑

坑1:并发session导致的连接冲突

# 错误做法:多个协程共享一个session
session = async_session_maker()
tasks = [task1(session), task2(session), ...]
await asyncio.gather(*tasks)

# 报错:Method 'close()' can't be called here

# 正确做法:每个协程创建独立session
async def task(session_maker):
    session = session_maker()  # 独立session
    try:
        await do_work(session)
    finally:
        await session.close()

坑2:缓存雪崩

# 问题:所有缓存同时过期
for user in users:
    await redis.setex(key, ttl=3600, value)

# 解决:随机TTL
import random
ttl = 3600 + random.randint(0, 600)  # 3600-4200秒
await redis.setex(key, ttl, value)

坑3:大key问题

# 问题:单个key过大(>10MB)
messages = [...]  # 10000条消息
await redis.set(key, json.dumps(messages))

# 解决:分页存储
for i in range(0, len(messages), 100):
    chunk = messages[i:i+100]
    await redis.set(f"{key}:page:{i//100}", json.dumps(chunk))

6.3 生产环境建议

1. 监控指标

# 必须监控的指标
metrics = {
    "cache_hit_rate": 0.95,        # 命中率 >90%
    "avg_latency": 5.0,            # 平均延迟 <10ms
    "redis_memory": "500MB",       # 内存使用
    "redis_connections": 20,       # 连接数
}

# 告警阈值
if cache_hit_rate < 0.8:
    alert("缓存命中率过低")
if avg_latency > 20:
    alert("Redis延迟过高")

2. 降级方案

async def query_messages_with_fallback(...):
    try:
        # 尝试从Redis读取
        cached = await redis.get_cached_messages(...)
        if cached:
            return cached
    except RedisError:
        logger.warning("Redis不可用,降级到PostgreSQL")
    
    # 降级:直接查PostgreSQL
    return await pg_storage.query_messages(...)

3. 容量规划

# 估算Redis内存需求
单条消息大小:200 bytes
单用户消息数:500条
单用户占用:200 * 500 = 100KB

活跃用户数:10000
总内存需求:100KB * 10000 = 1GB

# 建议:预留2-3倍buffer
推荐配置:2-3GB内存

七、下一步计划

7.1 长期记忆层

当前已完成:短期记忆(Deque)+ 中期记忆(PostgreSQL + Redis)

下一步:长期记忆层(向量数据库)

中期记忆(对话历史)
      ↓ 定期提取
长期记忆(知识图谱)
      ↓
- 用户画像
- 对话摘要
- 实体关系
- 语义向量

7.2 集成到真实Agent

将记忆系统集成到我的agent-orchestration-patterns项目:

# ReAct模式 + 记忆系统
async def react_with_memory(question):
    # 1. 读取历史上下文
    history = await memory.query_messages(user_id, session_id, limit=10)
    
    # 2. ReAct推理
    thought = await llm.think(question, history)
    action = await llm.act(thought)
    observation = await tools.execute(action)
    
    # 3. 保存记忆
    await memory.add_message(user_id, session_id, "assistant", observation)

总结

本文分享了如何通过Redis缓存层优化AI Agent记忆系统的性能:

核心收获

  1. 架构设计:三层记忆架构(Deque → Redis → PostgreSQL)
  2. 性能提升:读操作快1.89倍,吞吐量翻倍
  3. 实战经验:TTL设置、降级方案、容量规划

关键数据

  • P50延迟降低:11.14ms → 5.36ms(2.08倍
  • 读操作提升:12.65ms → 6.70ms(1.89倍
  • 吞吐量提升:1,688/s → 3,364/s(1.99倍
  • 缓存命中率:100%

适用场景

  • ✅ 读密集型应用(Agent、聊天机器人)
  • ✅ 高并发场景(100+用户同时在线)
  • ✅ 长对话历史(500+消息)

下一篇文章将分享长期记忆层的设计与实现,敬请期待!


完整代码已开源:[GitHub链接] 系列文章

  • 第一篇:三层记忆架构设计
  • 第二篇:异步压缩优化(6.3倍提升)
  • 第三篇:Redis缓存优化(2倍提升)← 本文

欢迎关注、点赞、收藏!有问题欢迎评论区讨论 🚀