Redis 存 Agent 会话上下文:key 设计、过期策略、数据结构

0 阅读9分钟

面试官问「你们 Agent 的会话上下文存哪」,答「存 Redis」只算及格。真正要讲清的是三件事:key 怎么设计、过期策略怎么定、存什么结构。

按这个顺序答:先分清 Redis 存什么、再讲 key 设计、然后讲过期策略、接着讲数据结构和容量、最后用制度问答项目走一遍。每块一句口播。


一、先分清:Redis 在 Agent 里存什么

Agent 的状态分两类,别混在一起:

类型存什么放哪活多久
会话上下文最近几轮对话、槽位、当前意图Redis分钟到小时级
图检查点图跑到哪、状态各字段、暂停点Postgres / SQLite天到月级
长期记忆用户偏好、历史结论向量库 / 业务库永久

Redis 存会话上下文,检查点存进度,长期记忆进库

Redis 的定位是「热数据」:读写频繁、体积小、可以丢(丢了用户重说一句就行)。它不该存图检查点,更不该存长期记忆。

面试一句话:Redis 存这一单的最近上下文,检查点存进度,长期记忆进库;三者职责不同。


二、key 怎么设计:四段式,带租户和版本

key 四段式设计

key 设计错了,后面全是坑。推荐四段式:

{业务前缀}:{租户ID}:{会话ID}:{数据类型}

具体到制度问答助手:

agent:ctx:{tenantId}:{sessionId}          # 会话上下文(对话 + 槽位)
agent:ctx:{tenantId}:{sessionId}:slots    # 只存槽位(可选,单独更新)
agent:lock:{tenantId}:{sessionId}         # 会话级锁,防并发写
agent:cache:{tenantId}:{queryHash}        # 答案缓存
agent:budget:{tenantId}:{yyyyMM}          # 租户月度成本

四段各自的作用:

作用为什么必须有
业务前缀 agent:隔离命名空间同一个 Redis 可能被多个业务共用,前缀防冲突
租户 {tenantId}多租户隔离便于按租户统计、清理、限流
会话 {sessionId}定位这一单多轮对话靠它串起来
类型后缀区分数据种类同一会话可能有上下文、锁、缓存多种数据

三条硬规则:

  1. 不要用自增 ID 当 sessionId。 用 UUID 或雪花 ID,避免被猜到别人的会话。
  2. 不要用用户 ID 当 key。 一个用户可能开多个会话(多标签页、多设备)。
  3. key 里不要放会变的东西。 比如不要把「当前意图」放进 key,否则意图一变 key 就变,上下文就丢了。

面试一句话:key 四段式:业务前缀 + 租户 + 会话 + 类型;用 UUID 不用自增,用会话 ID 不用用户 ID。


三、过期策略:分层 TTL,滑动续期

TTL 分层与保护

TTL 定太短,用户聊到一半上下文没了;定太长,Redis 被冷会话撑爆。要分层。

3.1 按数据类型定 TTL

数据类型TTL理由
会话上下文30 分钟(滑动续期)用户可能中途走开,回来还能接上
会话锁10 秒防并发写,短到不会卡死
答案缓存1 小时制度类问题重复率高,但制度可能更新
租户月度成本到月底 + 7 天跨月统计需要
长期记忆不过期但存在向量库,不在 Redis

3.2 滑动续期:每次访问重置 TTL

public void touch(String key) {
    // 每次读写都续期,保证活跃会话不过期
    redis.expire(key, Duration.ofMinutes(30));
}

为什么用滑动而不是固定: 固定 TTL 会让聊了 29 分钟的会话突然断掉;滑动 TTL 保证「只要还在用,就一直活着」。

3.3 兜底:冷会话清理

滑动 TTL 有个问题:用户一直挂着不操作,key 会一直占内存。所以要加兜底:

// 记录会话创建时间,超过 4 小时强制过期
public void save(String key, SessionCtx ctx) {
    ctx.setCreatedAt(System.currentTimeMillis());
    redis.set(key, serialize(ctx), Duration.ofMinutes(30));
}

public SessionCtx load(String key) {
    SessionCtx ctx = deserialize(redis.get(key));
    if (ctx != null && System.currentTimeMillis() - ctx.getCreatedAt() > 4 * 3600_000L) {
        redis.del(key);      // 超过 4 小时,强制清理
        return null;
    }
    touch(key);              // 否则续期
    return ctx;
}

两层保护: 滑动 TTL 管「活跃会话」,创建时间上限管「僵尸会话」。

3.4 内存兜底:maxmemory-policy

Redis 内存满了要有策略,别让它 OOM:

maxmemory 4gb
maxmemory-policy allkeys-lru
策略适合说明
allkeys-lru会话类数据淘汰最久未用的,会话正好符合
volatile-lru有持久数据混存只淘汰设了 TTL 的
noeviction不能丢数据满了直接报错,会话场景不合适

面试一句话:TTL 分层 + 滑动续期 + 创建时间上限 + LRU 兜底;四层保护,缺一层就会出事。


四、存什么结构:Hash 存上下文,String 存缓存

4.1 会话上下文用 Hash

// key: agent:ctx:{tenantId}:{sessionId}
// field: messages / slots / intent / step
redis.hset(key, "messages", serialize(messages));
redis.hset(key, "slots", serialize(slots));
redis.hset(key, "intent", intent);
redis.hset(key, "step", String.valueOf(step));

为什么用 Hash 不用 String:

结构优点缺点
String(整份 JSON)简单,一次读写改一个字段要读全量、写全量
Hash可单独更新字段,省带宽略复杂

Agent 场景里,槽位更新很频繁(用户一句一句补),用 Hash 只更新 slots 字段,不用把整个上下文读出来再写回去。

4.2 对话历史要截断

不要无限追加对话,否则 Redis 里的 value 会越来越大:

private static final int MAX_TURNS = 10;   // 只留最近 10 轮

public void appendMessage(String key, Message msg) {
    List<Message> messages = loadMessages(key);
    messages.add(msg);
    if (messages.size() > MAX_TURNS * 2) {          // 10 轮 = 20 条
        messages = messages.subList(messages.size() - MAX_TURNS * 2, messages.size());
    }
    redis.hset(key, "messages", serialize(messages));
}

更早的历史怎么办: 压成摘要存进 summary 字段,或者落库。不要全塞 Redis。

4.3 答案缓存用 String

// key: agent:cache:{tenantId}:{queryHash}
// value: 答案 JSON
redis.setex(cacheKey, Duration.ofHours(1), serialize(answer));

缓存用 String 就够,因为它是「整存整取」,不需要改字段。

面试一句话:上下文用 Hash 分字段存,对话只留最近 N 轮,更早的压摘要;缓存用 String。


五、并发与一致性:锁 + 版本号

5.1 会话级锁

同一个会话可能被并发请求(用户连点两次、多标签页),要加锁:

public boolean tryLock(String sessionId) {
    String lockKey = "agent:lock:" + tenantId + ":" + sessionId;
    // SET NX EX:原子操作,10 秒自动释放
    return redis.set(lockKey, "1", SetParams.setParams().nx().ex(10));
}

SET NX EX 而不是 SETNX + EXPIRE,后者不是原子操作,中间挂了会死锁。

5.2 版本号防覆盖

如果不用锁,用乐观锁版本号:

// 读时带版本
SessionCtx ctx = load(key);   // version = 5
// 写时校验版本
boolean ok = redis.eval(
    "if redis.call('hget', KEYS[1], 'version') == ARGV[1] then " +
    "  redis.call('hset', KEYS[1], 'version', ARGV[2]) " +
    "  return 1 else return 0 end",
    List.of(key), List.of("5", "6"));
if (!ok) {
    // 版本冲突,重读重试
}

面试一句话:并发用 SET NX EX 加锁,或版本号乐观锁;不要用非原子的 SETNX + EXPIRE。


六、容量估算:一个会话占多少

面试官爱问「你们 Redis 多大」。要能算出来。

单个会话的体积:

部分大小
最近 10 轮对话约 4 KB
槽位约 0.5 KB
意图、步数等约 0.2 KB
合计约 5 KB

总量估算:

并发会话数内存占用
1,000约 5 MB
10,000约 50 MB
100,000约 500 MB

结论: 会话上下文很轻,4GB 的 Redis 能扛几十万并发会话。真正吃内存的是答案缓存和向量,不是会话。

面试一句话:单会话约 5KB,10 万会话约 500MB;会话不是内存瓶颈,缓存和向量才是。


七、总结(面试大约 40 秒)

Redis 在 Agent 里存会话上下文,不存图检查点,也不存长期记忆。
key 用四段式:业务前缀 + 租户 + 会话 + 类型;用 UUID 不用自增,用会话 ID 不用用户 ID。
TTL 分层:上下文 30 分钟滑动续期,锁 10 秒,缓存 1 小时;再加创建时间上限清僵尸会话,配 LRU 兜底。
结构上,上下文用 Hash 分字段存,对话只留最近 10 轮,更早的压摘要;缓存用 String。
并发用 SET NX EX 加锁或版本号乐观锁。单会话约 5KB,10 万会话约 500MB。

压轴一句:key 定归属,TTL 定生死,结构定效率;三者定好,Redis 才扛得住。


八、结合项目:制度问答助手怎么存

还是制度问答助手。用户先说「帮我请明天的假」,隔一轮补「原因是家里有事」。

项目:会话上下文进 Redis,检查点进库

  1. key: agent:ctx:tenant_001:sess_a1b2c3,租户 + 会话 ID 定位这一单。
  2. 写入: 第一句进来,hset 写入 messagesslots(日期=明天),TTL 30 分钟。
  3. 续期: 第二句进来,先 loadtouch,TTL 重置为 30 分钟。
  4. 合并: 补的原因写进 slots 字段,日期还在(Hash 分字段更新,不会覆盖)。
  5. 截断: 聊到第 12 轮,只留最近 10 轮,更早的压成 summary
  6. 边界: 图检查点(跑到哪一步)存 Postgres;用户偏好存向量库;Redis 只放热上下文。

评测就三句:并发写有没有丢槽位;TTL 到期后能不能正确重开;超过 10 轮后关键信息有没有丢。


九、面试可能追问(短答)

Q:Redis 挂了会话就丢了怎么办?
A:会话上下文可以丢,用户重说一句就行。真正不能丢的是图检查点,那个存 Postgres。所以 Redis 挂了降级为「无上下文」,不阻塞主流程。

Q:为什么不用 Redis 存图检查点?
A:检查点要长期保留、要能查历史、要事务保证,Redis 是内存数据库,不适合。职责分开:Redis 管热上下文,Postgres 管检查点。

Q:TTL 定 30 分钟怎么来的?
A:按用户行为定的。大部分用户一轮对话在几分钟内完成,30 分钟能覆盖「中途走开再回来」的场景。太长会积压冷会话,太短会断上下文。

Q:滑动续期会不会导致 key 永远不过期?
A:会,所以要加创建时间上限(如 4 小时)。两层保护:滑动 TTL 管活跃,创建时间管僵尸。

Q:多租户怎么隔离?
A:key 里带 tenantId,天然隔离。清理、统计、限流都能按租户维度做。

Q:会话上下文和检查点会不会重复存?
A:会有一部分重叠(比如槽位),但职责不同。Redis 的可以丢,检查点的不能丢。宁可少量冗余,也不要混在一起。

Q:Redis 集群下 key 怎么分布?
A:用 Hash Tag 把同一会话的多个 key 放到同一个 slot,比如 agent:ctx:{tenant_001:sess_a1b2c3},大括号内的部分决定分片,保证同会话操作在一个节点上。