09-使用Redis保存大模型多轮对话上下文

使用 Redis 保存大模型多轮对话上下文

系列:Python + FastAPI 大模型应用基础(第 9 篇)

目标:使用 Python 异步 Redis 保存近期对话,理解 TTL、原子写入、用户隔离、缓存回源和并发边界。

1. Redis 在对话系统中解决什么问题

第 8 篇已经使用 MySQL 持久化正式对话。如果每次模型调用都从 MySQL 查询全部历史,会产生:

  • 数据库读取压力;
  • 历史序列化开销;
  • 更高的接口时延;
  • 多个 FastAPI 进程无法共享内存缓存。

Redis 是内存数据库,适合保存"近期、频繁读取、允许过期"的上下文:

text 复制代码
MySQL:正式会话记录,长期保存,可审计
Redis:近期上下文缓存,快速读取,可以失效

推荐的 Cache-Aside(旁路缓存)流程:

text 复制代码
读取对话上下文
    ↓
Redis 命中?
├── 是:直接使用
└── 否:从 MySQL 读取 → 写回 Redis → 使用

Redis 不应成为唯一事实来源。缓存被清空、过期或故障时,系统应该能够从 MySQL 恢复。

2. 为什么不能使用一个全局 Python 列表

python 复制代码
# 错误示例:只属于当前进程,重启后丢失
conversation_history: dict[str, list[dict[str, str]]] = {}

在多进程部署中:

text 复制代码
进程 A 的内存历史 ≠ 进程 B 的内存历史

同一个用户的两次请求可能被负载均衡到不同进程,导致上下文不一致。Redis 提供了多个进程共享的外部状态。

3. Redis 数据结构如何选择

常见选择:

数据结构 适合场景 本文是否采用
String 整段 JSON 一次覆盖
List 按顺序追加和截断消息
Hash 按字段保存会话元数据 可扩展
Sorted Set 按时间或分数排序 特殊场景
Stream 消息事件流和消费者组 异步任务场景

本文使用 Redis List:

text 复制代码
RPUSH:在右侧追加消息
LRANGE:读取消息
LTRIM:只保留最近 N 条
EXPIRE:设置过期时间

4. Key(键)设计

示例:

text 复制代码
ai:context:tenant_001:10001:550e8400-e29b-41d4-a716-446655440000

组成部分:

  • 固定前缀 ai:context
  • 租户标识;
  • 已验证用户 ID;
  • 会话 UUID。

不能只使用 conversation_id 就默认拥有权限。调用缓存前,业务层必须已经验证当前用户确实拥有该会话。

不要使用:

text 复制代码
KEYS ai:context:*

KEYS 可能扫描整个键空间并阻塞 Redis。管理任务应使用 SCAN 分批迭代,普通请求不应遍历全部会话键。

5. 创建项目

text 复制代码
redis_context_demo/
├── context_store.py
├── cache_service.py
├── main.py
├── tests/
│   └── test_context_store.py
└── requirements.txt

requirements.txt

text 复制代码
fastapi>=0.115,<1
uvicorn[standard]>=0.30,<1
redis>=5,<7
pydantic>=2.7,<3

# 测试依赖
pytest>=8,<9
pytest-asyncio>=0.23,<1
fakeredis>=2.23,<3

安装:

powershell 复制代码
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt

6. 定义消息结构与 Redis Store

新建 context_store.py

python 复制代码
import json
import re
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from uuid import UUID

from redis.asyncio import Redis
from redis.exceptions import RedisError


class ContextStoreError(RuntimeError):
    """Redis 上下文存储统一异常。"""


class ContextDataCorruptedError(ContextStoreError):
    """Redis 中的消息结构损坏。"""


@dataclass(frozen=True)
class ContextMessage:
    """缓存中的一条对话消息。"""

    role: str
    content: str
    created_at: str

    @classmethod
    def create(cls, role: str, content: str) -> "ContextMessage":
        """校验并创建消息。"""

        if role not in {"user", "assistant"}:
            # system 规则应由服务端维护,不允许从缓存恢复为可信指令
            raise ValueError("role 只能是 user 或 assistant")

        cleaned_content = content.strip()
        if not cleaned_content:
            raise ValueError("消息内容不能为空")

        return cls(
            role=role,
            content=cleaned_content,
            created_at=datetime.now(timezone.utc).isoformat(),
        )

    def to_json(self) -> str:
        """序列化为紧凑 JSON。"""

        return json.dumps(
            asdict(self),
            ensure_ascii=False,
            separators=(",", ":"),
        )

    @classmethod
    def from_json(cls, raw_value: str) -> "ContextMessage":
        """从 Redis 文本恢复并严格校验。"""

        try:
            data = json.loads(raw_value)
        except json.JSONDecodeError as exc:
            raise ContextDataCorruptedError("消息不是合法 JSON") from exc

        if not isinstance(data, dict):
            raise ContextDataCorruptedError("消息 JSON 必须是对象")

        role = data.get("role")
        content = data.get("content")
        created_at = data.get("created_at")
        if role not in {"user", "assistant"}:
            raise ContextDataCorruptedError("消息 role 不合法")
        if not isinstance(content, str) or not content.strip():
            raise ContextDataCorruptedError("消息 content 不合法")
        if not isinstance(created_at, str) or not created_at:
            raise ContextDataCorruptedError("消息 created_at 不合法")

        return cls(role=role, content=content, created_at=created_at)


class RedisContextStore:
    """使用 Redis List 保存近期对话。"""

    TENANT_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")

    def __init__(
        self,
        redis: Redis,
        *,
        ttl_seconds: int = 3600,
        max_messages: int = 20,
    ) -> None:
        if ttl_seconds <= 0:
            raise ValueError("ttl_seconds 必须大于 0")
        if max_messages < 2:
            raise ValueError("max_messages 不能小于 2")

        self.redis = redis
        self.ttl_seconds = ttl_seconds
        self.max_messages = max_messages

    def build_key(
        self,
        tenant_id: str,
        owner_id: int,
        conversation_id: str,
    ) -> str:
        """构造经过校验的 Redis Key,拒绝任意用户输入拼接。"""

        if not self.TENANT_PATTERN.fullmatch(tenant_id):
            raise ValueError("tenant_id 格式不合法")
        if owner_id <= 0:
            raise ValueError("owner_id 必须大于 0")

        try:
            normalized_uuid = str(UUID(conversation_id))
        except ValueError as exc:
            raise ValueError("conversation_id 必须是合法 UUID") from exc

        return (
            f"ai:context:{tenant_id}:{owner_id}:{normalized_uuid}"
        )

    async def append(
        self,
        key: str,
        message: ContextMessage,
    ) -> None:
        """原子执行追加、截断和设置 TTL。"""

        try:
            # transaction=True 会使用 MULTI/EXEC 作为一个事务批次执行
            async with self.redis.pipeline(transaction=True) as pipeline:
                pipeline.rpush(key, message.to_json())
                pipeline.ltrim(key, -self.max_messages, -1)

                # TTL 只在写入时刷新,单纯读取不会永久延长缓存寿命
                pipeline.expire(key, self.ttl_seconds)
                await pipeline.execute()
        except RedisError as exc:
            raise ContextStoreError("写入 Redis 上下文失败") from exc

    async def replace(
        self,
        key: str,
        messages: list[ContextMessage],
    ) -> None:
        """缓存未命中时,用数据库历史重建 Redis List。"""

        trimmed_messages = messages[-self.max_messages :]
        try:
            async with self.redis.pipeline(transaction=True) as pipeline:
                pipeline.delete(key)
                if trimmed_messages:
                    pipeline.rpush(
                        key,
                        *(message.to_json() for message in trimmed_messages),
                    )
                    pipeline.expire(key, self.ttl_seconds)
                await pipeline.execute()
        except RedisError as exc:
            raise ContextStoreError("重建 Redis 上下文失败") from exc

    async def get(self, key: str) -> list[ContextMessage]:
        """按写入顺序读取缓存消息。"""

        try:
            raw_messages = await self.redis.lrange(key, 0, -1)
        except RedisError as exc:
            raise ContextStoreError("读取 Redis 上下文失败") from exc

        # decode_responses=True 时返回 str;配置错误时可能返回 bytes
        if any(not isinstance(item, str) for item in raw_messages):
            raise ContextDataCorruptedError(
                "Redis 客户端必须启用 decode_responses=True"
            )

        return [
            ContextMessage.from_json(item)
            for item in raw_messages
        ]

    async def delete(self, key: str) -> None:
        """用户删除会话时同步删除缓存。"""

        try:
            await self.redis.delete(key)
        except RedisError as exc:
            raise ContextStoreError("删除 Redis 上下文失败") from exc

7. 为什么追加、截断和过期要放在一个事务中

如果分三次独立执行:

text 复制代码
RPUSH 成功
LTRIM 失败
EXPIRE 未执行

这个 Key 可能永久存在并无限增长。

使用 MULTI/EXEC 可以保证命令不会被其他客户端命令插入。需要注意,Redis 事务和关系型数据库事务不同:运行期命令错误不会自动像 MySQL 一样回滚之前命令,因此仍要提前校验参数。

8. 实现 Cache-Aside 回源

新建 cache_service.py

python 复制代码
from collections.abc import Awaitable, Callable

from context_store import (
    ContextMessage,
    ContextStoreError,
    RedisContextStore,
)


HistoryLoader = Callable[[], Awaitable[list[ContextMessage]]]


class ContextCacheService:
    """编排 Redis 缓存和 MySQL 回源。"""

    def __init__(self, store: RedisContextStore) -> None:
        self.store = store

    async def load_context(
        self,
        key: str,
        mysql_loader: HistoryLoader,
    ) -> list[ContextMessage]:
        """Redis 命中则返回;未命中或故障则从 MySQL 获取。"""

        try:
            cached_messages = await self.store.get(key)
            if cached_messages:
                return cached_messages
        except ContextStoreError:
            # 生产系统必须在这里记录脱敏日志和 Redis 故障指标
            # Redis 故障不应直接让正式会话记录不可用
            pass

        database_messages = await mysql_loader()

        try:
            await self.store.replace(key, database_messages)
        except ContextStoreError:
            # 回写失败不覆盖数据库查询的正确结果
            pass

        return database_messages

上面为了突出控制流省略了日志实现。生产代码不能使用空 pass 静默处理,应记录错误代码、Redis 节点和请求 ID,但不能记录完整用户消息。

9. 在 FastAPI 生命周期中创建 Redis 客户端

新建 main.py

python 复制代码
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from redis.asyncio import Redis

from context_store import ContextMessage, RedisContextStore


def required_env(name: str) -> str:
    value = os.getenv(name, "").strip()
    if not value:
        raise RuntimeError(f"缺少环境变量:{name}")
    return value


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    """创建一个供整个 FastAPI 进程复用的 Redis 连接池。"""

    redis = Redis.from_url(
        required_env("REDIS_URL"),
        # SSE 和 JSON 内容按 UTF-8 字符串处理
        decode_responses=True,
        socket_connect_timeout=3,
        socket_timeout=3,
        health_check_interval=30,
    )

    # 启动时执行只读探测,配置错误则直接启动失败
    await redis.ping()

    app.state.context_store = RedisContextStore(
        redis,
        ttl_seconds=3600,
        max_messages=20,
    )

    yield

    await redis.aclose()


app = FastAPI(
    title="Redis 多轮对话上下文",
    lifespan=lifespan,
)


@app.post("/demo/context/{conversation_id}")
async def append_demo_message(
    conversation_id: str,
    request: Request,
) -> dict[str, int]:
    """仅演示 Store 调用;真实用户身份必须来自认证系统。"""

    store: RedisContextStore = request.app.state.context_store

    # 这里的用户和租户仅为本地演示固定值
    key = store.build_key(
        tenant_id="demo",
        owner_id=10001,
        conversation_id=conversation_id,
    )
    await store.append(
        key,
        ContextMessage.create(
            role="user",
            content="请解释 Redis List。",
        ),
    )
    messages = await store.get(key)
    return {"message_count": len(messages)}

Redis URL 示例:

powershell 复制代码
$env:REDIS_URL = "redis://:URL编码后的密码@127.0.0.1:6379/0"

不要把 Redis 暴露到公网,也不要使用无密码的生产实例。

10. 使用 fakeredis 运行自动化测试

新建 tests/test_context_store.py

python 复制代码
from uuid import uuid4

import fakeredis.aioredis
import pytest

from context_store import ContextMessage, RedisContextStore


@pytest.mark.asyncio
async def test_append_trim_and_ttl() -> None:
    """验证追加、最大消息数量和 TTL。"""

    redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
    store = RedisContextStore(
        redis,
        ttl_seconds=60,
        max_messages=4,
    )
    key = store.build_key("tenant_a", 10001, str(uuid4()))

    for index in range(6):
        await store.append(
            key,
            ContextMessage.create(
                role="user" if index % 2 == 0 else "assistant",
                content=f"消息 {index}",
            ),
        )

    messages = await store.get(key)

    # 只保留最后 4 条
    assert [item.content for item in messages] == [
        "消息 2",
        "消息 3",
        "消息 4",
        "消息 5",
    ]

    ttl = await redis.ttl(key)
    assert 0 < ttl <= 60
    await redis.aclose()


@pytest.mark.asyncio
async def test_replace_and_delete() -> None:
    """验证缓存回源重建和删除。"""

    redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
    store = RedisContextStore(redis, max_messages=10)
    key = store.build_key("tenant_a", 10001, str(uuid4()))

    source_messages = [
        ContextMessage.create("user", "问题"),
        ContextMessage.create("assistant", "回答"),
    ]
    await store.replace(key, source_messages)
    assert await store.get(key) == source_messages

    await store.delete(key)
    assert await store.get(key) == []
    await redis.aclose()

运行:

powershell 复制代码
.\.venv\Scripts\python.exe -m pytest -q

fakeredis 可以验证基本命令和业务逻辑,但不能替代真实 Redis 的网络、持久化、主从切换、Cluster 和故障恢复测试。

11. Redis 故障时应该怎么办

如果 MySQL 是事实来源,推荐:

text 复制代码
Redis 读取失败
    ↓
记录监控和脱敏日志
    ↓
从 MySQL 加载有限历史
    ↓
继续模型调用
    ↓
异步或尽力回写 Redis

如果 MySQL 也不可用,则不能假装有历史。应明确降级为"无法加载上下文",或者拒绝需要多轮语义的请求。

12. TTL 应该怎么设置

TTL 没有通用最佳值,需要考虑:

  • 用户多久会继续同一会话;
  • Redis 内存预算;
  • 历史从 MySQL 回源的成本;
  • 对话数据敏感程度;
  • 合规保留要求。

本文选择"写入时刷新 TTL,读取不刷新"。这样只读访问不会让敏感缓存永久存活。

13. 对抗性审查

13.1 Redis 不是权限系统

Key 中包含用户 ID 不代表已经鉴权。用户身份必须来自验证后的 Token 或 Session,并且 MySQL 查询仍要检查所有权。

13.2 List 截断可能破坏问答对

按消息条数保留最后 N 条,可能只剩 Assistant 回答而丢掉对应 User 问题。生产代码更适合按 Turn(轮次)保存,或每次按两条成对截断。

13.3 消息数量不等于 Token 预算

20 条短消息和 20 条长合同占用完全不同。下一篇将按模型 Token 预算构建上下文。

13.4 多请求可能交错

Redis 事务只能保证一次追加操作原子,不能保证"用户问题---调用模型---助手回答"整个长流程原子。同一会话并发控制仍应使用第 8 篇的 pending 状态或可靠分布式任务机制。

13.5 缓存内容仍然是敏感数据

Redis 需要网络隔离、认证、TLS(跨不可信网络时)、最小权限、备份策略和数据过期策略。不能因为数据会过期就忽略安全。

13.6 缓存击穿

一个热门会话过期时,大量请求可能同时回源 MySQL。可以使用短期互斥、单飞请求或后台刷新,但锁必须有超时、唯一令牌和安全释放逻辑。

13.7 Redis 持久化不等于业务持久化

RDB 或 AOF 能提高 Redis 恢复能力,但不能自动替代关系数据库中的业务约束、审计和所有权模型。

14. 本篇总结

本文完成了 Redis 上下文缓存基础:

  • MySQL 保存正式记录,Redis 保存近期缓存;
  • Redis List 负责顺序追加和有限截断;
  • MULTI/EXEC 原子执行追加、截断和过期;
  • Key 包含经过验证的租户、用户和会话标识;
  • 缓存未命中或故障时回源 MySQL;
  • TTL 在写入时刷新;
  • 使用 fakeredis 验证核心行为;
  • 明确消息条数、并发和 Token 预算仍需进一步处理。

下一篇将实现基于 Token 预算的滑动窗口、摘要压缩和重要信息保留。

15. 练习题

  1. 把消息缓存改成以 Turn 为单位,避免截断半个问答;
  2. 增加 Redis 命中率、回源次数和读取时延指标;
  3. 用户删除会话后同时删除 MySQL 和 Redis 数据;
  4. 使用真实 Redis 测试 TTL 和连接失败降级;
  5. 设计缓存击穿保护,但不要使用无超时的锁;
  6. 对比"读取刷新 TTL"和"写入刷新 TTL"的数据保留差异。
相关推荐
秋田君1 小时前
QT_QT布局常用类QSplitter窗口分割类与QDockWidget窗口停靠类
开发语言·数据库·qt
小果因子实验室1 小时前
策略研究--qmt实现新股自动申购卖出策略
数据库
Darling噜啦啦1 小时前
从零实战Milvus向量数据库:用RAG打造你的AI日记助手
数据库
淼澄研学2 小时前
PyTorch深度学习实战:5个核心方法从0到1构建神经网络
前端·数据库·python
脑子进水养啥鱼?2 小时前
where id > ‘5‘ 的查询 sql 查不到 id = ‘10‘ 的数据?
数据库·sql·postgresql
吴声子夜歌2 小时前
MongoDB 4.2——分片简介
数据库·mongodb
腾渊信息科技公司2 小时前
工业时序数据存储选型实战:从PostgreSQL到TDengine,查询从15秒到200ms
数据库·postgresql·tdengine
TDengine (老段)2 小时前
TDengine Node.js 与 C# 连接器 — Web 服务与 .NET 集成
大数据·数据库·node.js·c#·.net·时序数据库·tdengine
l1t3 小时前
PostgreSQL 11–18 中的 SQL 改进:个人精选-1
数据库·sql·postgresql