轻量 Agent 的会话管理:Redis 与本地缓存的混合存储策略与过期设计
轻量 Agent 的会话管理Redis 与本地缓存的混合存储策略与过期设计一、Agent 会话的存储悖论既需要毫秒级读取又需要持久化Agent 的每一次对话都有一个上下文——之前的对话历史、调用过的工具、中间的计算状态。这些数据如果每次都从数据库加载IO 开销会显著增加响应延迟。如果完全放在内存中服务重启后所有会话丢失用户会发现自己和 Agent 的对话被重置了。这就是会话存储的经典两难内存快但不持久数据库持久但不够快。Redis 似乎是最佳选择——它既有内存的速度又有 AOF/RDB 的持久化。但对于独立开发者或小团队维护的 Agent 项目单独部署 Redis 集群是一种奢侈。混合存储策略提供了一种折中方案热数据在本地内存LRU Cache全量数据持久化到 SQLite 或文件。graph TB A[读取会话] -- B{本地 LRU Cache 命中?} B --|是| C[直接返回br/ 0.1ms] B --|否| D{Redis 可用且命中?} D --|是| E[返回 写回本地 Cachebr/1-5ms] D --|否| F[从持久化层读取br/SQLite/文件] F -- G[返回 写回 Cachebr/5-50ms] H[写入会话] -- I[更新本地 Cache] I -- J{Redis 可用?} J --|是| K[异步写入 Redis] J --|否| L[标记待同步] I -- M[异步持久化] style C fill:#51cf66,color:#fff style G fill:#ffd43b,color:#000二、混合存储的三层架构本地 → Redis → 持久化Layer 1 — 本地 LRU Cache最快但容量小使用固定大小的 LRU最近最少使用缓存存储最热的会话。当 Agent 正在处理一个会话时这个会话的数据会被频繁读写本地缓存避免了网络 IO。关键在于缓存大小的设定。如果 Agent 的并发会话数峰值是 50本地缓存设为 100 就足够。超出部分自动被 LRU 淘汰。Layer 2 — Redis快且支持过期但需要额外运维Redis 的 TTL过期时间非常适合会话管理。每个会话可以设置一个过期时间如 24 小时超时自动清理。Redis 还提供原子操作如追加对话历史不用加锁。但如果 Redis 不可用网络故障、资源不足系统不能崩溃。降级策略是跳过 Redis 直接读写持久化层。Layer 3 — 持久化存储最慢但可靠性最高SQLite 是一个出乎意料的好选择。它是单文件数据库不需要独立的服务进程读性能对于几百条记录来说是亚毫秒级别的。对于独立开发者SQLite 比 PostgreSQL 更适合——零运维。三、混合存储的完整实现package session import ( context encoding/json fmt sync time lru github.com/hashicorp/golang-lru/v2 ) // Session 数据结构 type Session struct { ID string json:id UserID string json:user_id Messages []Message json:messages State string json:state // Agent 当前状态 Metadata map[string]interface{} json:metadata CreatedAt time.Time json:created_at UpdatedAt time.Time json:updated_at TTL int64 json:ttl // 过期时间戳 } type Message struct { Role string json:role Content string json:content Time int64 json:time } // Store 混合存储管理器 type Store struct { local *lru.Cache[string, *Session] // 本地 LRU 缓存 redis *RedisClient // Redis可选可为 nil db *SQLiteStore // SQLite 持久化 mu sync.RWMutex closed bool } // 存储接口方便替换后端 type Backend interface { Get(ctx context.Context, id string) (*Session, error) Set(ctx context.Context, session *Session) error Delete(ctx context.Context, id string) error ListByUser(ctx context.Context, userID string) ([]*Session, error) } func NewStore(redisURL string, dbPath string, localSize int) (*Store, error) { // 初始化本地缓存 cache, err : lru.New[string, *Session](localSize) if err ! nil { return nil, fmt.Errorf(create lru cache: %w, err) } // 初始化 SQLite sqlite, err : NewSQLiteStore(dbPath) if err ! nil { return nil, fmt.Errorf(create sqlite store: %w, err) } // Redis 是可选的 var redis *RedisClient if redisURL ! { redis, err NewRedisClient(redisURL) if err ! nil { // Redis 失败不应该阻止启动记录警告即可 fmt.Printf(WARNING: Redis unavailable: %v\n, err) redis nil } } return Store{ local: cache, redis: redis, db: sqlite, }, nil } // Get 读取会话按优先级本地 → Redis → SQLite func (s *Store) Get(ctx context.Context, id string) (*Session, error) { // Layer 1: 本地缓存 if sess, ok : s.local.Get(id); ok { if !sess.IsExpired() { return sess, nil } // 过期了从本地缓存中移除 s.local.Remove(id) } // Layer 2: Redis if s.redis ! nil { sess, err : s.redis.Get(ctx, id) if err nil sess ! nil { if !sess.IsExpired() { s.local.Add(id, sess) // 回写到本地缓存 return sess, nil } s.redis.Delete(ctx, id) } } // Layer 3: SQLite sess, err : s.db.Get(ctx, id) if err ! nil { return nil, fmt.Errorf(get session from db: %w, err) } if sess nil { return nil, fmt.Errorf(session %s not found, id) } if sess.IsExpired() { s.db.Delete(ctx, id) return nil, fmt.Errorf(session %s expired, id) } // 回写缓存 s.local.Add(id, sess) if s.redis ! nil { go s.redis.Set(context.Background(), sess) // 异步写 Redis } return sess, nil } // Set 保存会话同步写本地 异步写 Redis/SQLite func (s *Store) Set(ctx context.Context, session *Session) error { session.UpdatedAt time.Now() // Layer 1: 立即更新本地缓存 s.local.Add(session.ID, session) // Layer 2 3: 异步写入 go func() { if s.redis ! nil { if err : s.redis.Set(context.Background(), session); err ! nil { fmt.Printf(WARNING: redis set failed: %v\n, err) } } if err : s.db.Set(context.Background(), session); err ! nil { fmt.Printf(ERROR: db set failed: %v\n, err) } }() return nil } // Delete 删除会话 func (s *Store) Delete(ctx context.Context, id string) error { s.local.Remove(id) var errs []error if s.redis ! nil { if err : s.redis.Delete(ctx, id); err ! nil { errs append(errs, fmt.Errorf(redis: %w, err)) } } if err : s.db.Delete(ctx, id); err ! nil { errs append(errs, fmt.Errorf(db: %w, err)) } if len(errs) 0 { return fmt.Errorf(delete failures: %v, errs) } return nil } // IsExpired 检查会话是否过期 func (s *Session) IsExpired() bool { if s.TTL 0 { return false // 0 表示永不过期 } return time.Now().Unix() s.TTL } // Cleanup 后台定期清理过期会话 func (s *Store) StartCleanup(interval time.Duration) { go func() { ticker : time.NewTicker(interval) defer ticker.Stop() for range ticker.C { s.cleanupExpired() } }() } func (s *Store) cleanupExpired() { // 本地缓存中的过期会话由 LRU 自动处理 // 这里清理持久化存储中的过期会话 expired, _ : s.db.FindExpired() for _, id : range expired { s.Delete(context.Background(), id) } } func (s *Store) Close() error { s.mu.Lock() defer s.mu.Unlock() s.closed true // 等待异步写入完成 time.Sleep(100 * time.Millisecond) return s.db.Close() }四、混合存储的权衡与陷阱数据一致性异步写入可能导致本地缓存已更新但数据库尚未写入的窗口。如果此时服务崩溃最近一次会话更新可能丢失。对于聊天应用这个风险可以接受。对于金融或医疗等严格一致性要求的场景应改为同步写入。内存占用本地 LRU 缓存的大小需要根据实际的并发会话数和每条会话的平均大小来设定。一条会话可能有 50 条消息约 10KB。缓存 100 条会话约 1MB——影响微乎其微。Redis 故障降级当 Redis 不可用时Store 自动跳过 Redis 层直接读写 SQLite。这时响应延迟会从毫秒级升到 10-50 毫秒但服务不会崩溃。不适用场景需要跨实例共享会话的分布式部署本地缓存不可共享会话量极大10 万SQLite 的写入性能成为瓶颈实时协作场景多个 Agent 同时修改同一个会话五、总结混合存储策略用最小的运维成本实现了快速读取 可靠持久化的平衡。本地缓存处理热点会话的读写Redis/SQLite 提供持久化和更大容量的存储。落地路径先从最简单的 SQLite 本地 LRU Cache 开始零运维依赖当并发量增长到 SQLite 成为瓶颈时引入 Redis 作为中间缓存层当需要多实例部署时考虑将 SQLite 替换为 PostgreSQL。少即是多。对于独立开发者SQLite 本地 LRU Cache 已经是一个够用 90% 场景的会话存储方案。在真正需要 Redis 之前不要引入 Redis。