多轮对话缓存:增量存储与检索历史轮次 KV

FreeGuideOnline 最新 2026-06-29

Key: session:<session_id> Value: { "meta": { "created_at": "2025-01-01T00:00:00Z", "updated_at": "..." }, "turns": [ { "turn_id": "turn:1", "role": "user", "content": "你好,请帮我规划一次旅行。" }, { "turn_id": "turn:2", "role": "assistant", "content": "好的,请问您想去哪里?" } ] }


### 为什么要用 turn_id

每个轮次都分配一个唯一 ID(例如 `turn:递增序号` 或 UUID),这使我们能够:

- 精确更新某一条消息(如加入评分、用户反馈)。
- 增量插入新轮次时避免重复。
- 在检索时按 ID 范围切片,实现「跳过前 N 轮」或「仅取最近 M 轮」。

## 增量写入实现

增量写入的核心操作是:**读取当前历史 → 追加新消息 → 写回存储**。为了保证原子性,在生产环境中通常使用 Lua 脚本或事务。

下面用一个 Python 伪代码示例展示基于 Redis 的增量写入逻辑:

```python
import json
import redis

r = redis.Redis()

def append_message(session_id: str, role: str, content: str):
    key = f"session:{session_id}"
    # 1. 读取已有数据
    data = r.get(key)
    if data:
        session = json.loads(data)
    else:
        session = {"meta": {}, "turns": []}
    
    # 2. 生成新的 turn
    new_turn_id = f"turn:{len(session['turns']) + 1}"
    new_turn = {
        "turn_id": new_turn_id,
        "role": role,
        "content": content
    }
    
    # 3. 追加到轮次列表
    session["turns"].append(new_turn)
    session["meta"]["updated_at"] = "..."  # 更新元数据
    
    # 4. 写回 Redis
    r.set(key, json.dumps(session))
    return new_turn_id

如果并发不高,以上逻辑已足够。当存在大量并发时,可以改用 Redis List 结构天然支持原子追加:

RPUSH session:<session_id>:turns '{"role":"user","content":"..."}'

这样无需读取全部历史,只做一次追加操作就能完成增量写入,性能极高。

检索历史轮次

检索通常分为两种需求:获取完整上下文获取最近的N轮

获取完整上下文

直接拉取整个 turns 列表即可。但在轮次过长时需要配合上下文窗口裁剪,避免超出模型最大 token 限制。

def get_full_history(session_id: str):
    key = f"session:{session_id}"
    data = r.get(key)
    if not data:
        return []
    session = json.loads(data)
    return session["turns"]

获取最近的 N 轮

利用 Redis List 的负索引范围读取:

LRANGE session:<session_id>:turns -N -1

如果使用字符串存储,则在内存中切片:

def get_recent_turns(session_id: str, n: int):
    turns = get_full_history(session_id)
    return turns[-n:] if n > 0 else []

按轮次 ID 范围检索

在需要回溯特定片段时,可以存储 turn_id 到内容的映射,或者直接在列表中遍历筛选。更高效的方式是为轮次单独建立 Sorted Set(按时间戳或序号排序),然后按分页取出。

上下文窗口管理策略

多轮对话缓存需要和 token 预算 协同工作。你可以实现一个函数,从最新轮次向前累加,直到总 token 数接近上限:

def trim_context(turns, max_tokens, token_counter):
    selected = []
    total = 0
    for turn in reversed(turns):
        turn_tokens = token_counter(turn["content"])
        if total + turn_tokens > max_tokens:
            break
        selected.insert(0, turn)
        total += turn_tokens
    return selected