大模型多轮对话状态管理:Spring Boot 中的会话上下文与记忆持久化

一、无状态模型的对话困境:上下文丢失与 Token 爆炸

大模型本身是无状态的——每次调用都是独立的,不保留任何历史信息。多轮对话的"记忆"完全依赖每次请求携带的上下文消息列表。这种设计带来两个核心工程问题:其一,上下文窗口有限(GPT-4 Turbo 为 128K Token),长对话的上下文必然超出限制;其二,每次请求重复发送完整历史,Token 消耗随对话轮次线性增长,10 轮对话的 Token 消耗可能是首轮的 5-10 倍。

更复杂的场景是企业级对话系统:同一用户可能在多个设备上发起对话,需要跨设备同步上下文;客服系统需要将对话转交给人工,人工需要看到完整的对话历史;合规要求对话记录可审计,但原始 Token 数据量巨大,存储成本高昂。

二、对话状态管理架构:从内存缓存到分层存储

多轮对话的状态管理需要解决三个问题:上下文裁剪(在有限窗口内保留最关键的信息)、持久化存储(跨请求、跨设备恢复对话)、以及检索增强(从长期记忆中召回相关知识)。

flowchart TD
    A[用户发送消息] --> B[对话状态管理器]
    B --> C{会话是否存在?}
    C -->|不存在| D[创建新会话]
    C -->|存在| E[从存储加载上下文]
    E --> F[上下文裁剪策略]
    D --> F
    F --> G[滑动窗口 / 摘要压缩 / 语义检索]
    G --> H[组装完整 Prompt]
    H --> I[调用大模型 API]
    I --> J[更新对话历史]
    J --> K[持久化到存储层]
    K --> L[(短期: Redis)]
    K --> M[(长期: 向量数据库)]
    K --> N[(归档: 对象存储)]
    J --> O[返回响应]

三、生产级代码实现:会话管理、上下文裁剪与记忆检索

3.1 对话会话管理

@Service
public class ConversationManager {

    private final ConversationRepository repo;
    private final RedisTemplate<String, Object> redisTemplate;

    private static final Duration SESSION_TTL = Duration.ofHours(24);

    public Conversation getOrCreate(String sessionId) {
        // 优先从 Redis 缓存读取
        String key = "conv:" + sessionId;
        Conversation cached = (Conversation) redisTemplate
            .opsForValue().get(key);
        if (cached != null) {
            return cached;
        }
        // 缓存未命中,从数据库加载
        Conversation conv = repo.findBySessionId(sessionId)
            .orElseGet(() -> {
                Conversation newConv = new Conversation();
                newConv.setSessionId(sessionId);
                newConv.setCreatedAt(Instant.now());
                return newConv;
            });
        // 写入缓存
        redisTemplate.opsForValue().set(key, conv, SESSION_TTL);
        return conv;
    }

    public void save(Conversation conv) {
        repo.save(conv);
        // 更新缓存
        redisTemplate.opsForValue().set(
            "conv:" + conv.getSessionId(), conv, SESSION_TTL);
    }
}

3.2 上下文裁剪策略

public interface ContextPruningStrategy {
    List<Message> prune(List<Message> history, int maxTokens);
}

// 策略一:滑动窗口,保留最近 N 轮对话
@Component
public class SlidingWindowStrategy implements ContextPruningStrategy {

    @Override
    public List<Message> prune(List<Message> history, int maxTokens) {
        int totalTokens = 0;
        List<Message> retained = new ArrayList<>();

        // 从最新消息向前遍历
        for (int i = history.size() - 1; i >= 0; i--) {
            Message msg = history.get(i);
            totalTokens += msg.getTokenCount();
            if (totalTokens > maxTokens) break;
            retained.add(0, msg);
        }
        // 始终保留 System Prompt
        if (!retained.isEmpty()
            && retained.get(0).getRole() != Role.SYSTEM) {
            Message systemPrompt = history.stream()
                .filter(m -> m.getRole() == Role.SYSTEM)
                .findFirst().orElse(null);
            if (systemPrompt != null) {
                retained.add(0, systemPrompt);
            }
        }
        return retained;
    }
}

// 策略二:摘要压缩,将早期对话压缩为摘要
@Component
public class SummaryCompressionStrategy implements ContextPruningStrategy {

    private final LlmClient llmClient;

    @Override
    public List<Message> prune(List<Message> history, int maxTokens) {
        int currentTokens = history.stream()
            .mapToInt(Message::getTokenCount).sum();
        if (currentTokens <= maxTokens) return history;

        // 将前 70% 的对话压缩为摘要
        int splitPoint = (int) (history.size() * 0.7);
        List<Message> toCompress = history.subList(0, splitPoint);
        List<Message> recent = history.subList(splitPoint, history.size());

        String summary = llmClient.summarize(
            toCompress.stream()
                .map(m -> m.getRole() + ": " + m.getContent())
                .collect(Collectors.joining("\n")),
            "请将以上对话压缩为简洁摘要,保留关键信息和决策"
        );

        List<Message> result = new ArrayList<>();
        result.add(new Message(Role.SYSTEM, "对话历史摘要: " + summary));
        result.addAll(recent);
        return result;
    }
}

3.3 长期记忆检索

@Service
public class LongTermMemoryService {

    private final VectorStore vectorStore;
    private final EmbeddingModel embeddingModel;

    public List<String> recall(String query, int topK) {
        float[] queryEmbedding = embeddingModel.embed(query);
        // 从向量数据库检索最相关的记忆片段
        return vectorStore.similaritySearch(
                SearchRequest.query(query)
                    .withTopK(topK)
                    .withSimilarityThreshold(0.7))
            .stream()
            .map(Document::getContent)
            .toList();
    }

    public void store(String sessionId, String content) {
        float[] embedding = embeddingModel.embed(content);
        Document doc = new Document(content,
            Map.of("sessionId", sessionId,
                   "timestamp", Instant.now().toString()));
        vectorStore.add(List.of(doc));
    }
}

四、对话状态管理的架构权衡

摘要压缩的信息损失:将早期对话压缩为摘要,不可避免地丢失细节信息。用户可能在第 20 轮对话中引用第 3 轮的具体数据,而摘要中可能已省略。缓解方案是保留关键实体的原始文本,仅压缩一般性对话内容。但"关键实体"的识别本身需要额外的 NLP 处理,增加了系统复杂度。

向量检索的召回精度:长期记忆依赖向量相似度检索,但语义相似不等于上下文相关。用户问"上次讨论的部署方案",向量检索可能返回多个"部署方案"相关的片段,而无法区分是哪次讨论。需要结合元数据过滤(如时间范围、会话 ID)提高召回精度。

Redis 缓存的一致性风险:对话状态同时存在于 Redis 和数据库中,存在数据不一致的窗口期。Redis 写入成功但数据库写入失败时,缓存中的数据无法持久化。建议采用"先写数据库、再更新缓存"的策略,并设置合理的缓存过期时间作为兜底。

Token 计量的累积误差:上下文裁剪依赖每条消息的 Token 计数,但不同分词器的计数结果可能存在 5%-10% 的偏差。累积多轮后,实际 Token 数可能超出预期,导致 API 调用失败。建议在 Token 计数时预留 10% 的安全余量。

五、总结

多轮对话状态管理的本质是在"有限的上下文窗口"和"无限增长的对话历史"之间找到平衡。本文方案的核心链路为:会话创建与缓存 → 上下文裁剪(滑动窗口 + 摘要压缩)→ 长期记忆检索 → 持久化存储。落地时需重点关注三个参数:滑动窗口保留轮数(建议 10-20 轮)、摘要压缩触发阈值(建议上下文窗口的 70%)、向量检索的 topK 值(建议 3-5)。建议从单轮对话场景起步,逐步引入多轮上下文和长期记忆,并在上线初期密切监控 Token 消耗和裁剪命中率。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐