1. 多轮对话的上下文管理难题
  2. 对话会话服务的独立价值
  3. 会话数据模型设计
  4. 持久化方案选型:Redis、数据库与混合模式
  5. 上下文窗口与 Token 控制
  6. 上下文压缩与摘要策略
  7. Spring Boot 会话服务实现
  8. 跨服务共享会话状态
  9. 高并发与性能优化
  10. 总结与展望

1.1 为什么多轮对话如此重要

大模型最吸引人的能力之一就是多轮对话。用户不需要一次性把背景信息说完,而是可以像与人交谈一样逐步补充、追问、纠正。这种交互方式极大地降低了使用门槛,也让 AI 应用真正具备“助手”的体感。

然而,多轮对话的背后需要一个关键机制:上下文管理。模型本身是无状态的,它不会“记得”上一轮说了什么。因此,应用层必须把历史对话记录整理好,作为 Prompt 的一部分再次提交给模型。这个看似简单的工作,随着用户量、对话轮数和业务复杂度增加,会带来一系列工程挑战:

  • **Token 爆炸**:上下文越长,单次请求的 Token 数越多,响应时间变长,费用指数级上升。
  • **状态一致性**:如果对话状态散落在业务服务的本地内存中,水平扩展后不同实例看到的状态不一致。
  • **长会话持久化**:用户可能时隔数小时甚至数天继续对话,状态需要可靠持久化。
  • **上下文裁剪**:模型有最大上下文长度限制,必须智能地选择保留哪些历史消息。
  • **多设备同步**:用户在 Web、App、小程序之间切换,需要同步会话状态。
  • **审计与合规**:对话记录可能涉及敏感信息,需要统一审计和留存策略。

1.2 耦合在业务服务中的典型问题

很多早期项目会把会话状态直接存在业务服务里:

@Service
public class ChatService {
    private final Map<String, List<Message>> sessionStore = new ConcurrentHashMap<>();
    public String chat(String sessionId, String userMessage) {
        List<Message> history = sessionStore.computeIfAbsent(sessionId, k -> new ArrayList<>());
        history.add(new Message("user", userMessage));
        String prompt = buildPrompt(history);
        String answer = llmClient.call(prompt);
        history.add(new Message("assistant", answer));
        return answer;
    }
}

这段代码的问题一目了然:

  1. 会话状态存在本地内存,服务重启即丢失。
  2. 多个实例之间无法共享状态,负载均衡后上下文断裂。
  3. 没有 Token 控制,长对话会超出模型上下文上限。
  4. 没有持久化,无法支持历史会话列表、消息搜索、审计分析。
  5. 对话逻辑与业务逻辑耦合,难以复用和维护。

1.3 独立对话会话服务的价值

把对话会话状态从业务服务中拆出来,形成独立的“对话会话服务”,能够获得:

  • **状态集中管理**:所有会话状态统一存储、统一访问、统一失效策略。
  • **水平扩展友好**:业务服务无状态化,可以随意扩缩容。
  • **持久化与审计**:对话记录落库,支持历史查询、统计分析和合规审计。
  • **Token 预算控制**:在会话服务层统一计算和控制上下文长度。
  • **跨端同步**:Web、App、小程序共享同一份会话状态。
  • **多服务共享**:RAG 检索、Agent 编排、工具调用等服务都可以读取同一会话上下文。

2.1 服务边界

对话会话服务应专注于“对话状态”本身,核心能力包括:

  1. 会话生命周期管理:创建、查询、归档、删除。
  2. 消息存储与读取:按时间顺序存储用户与助手消息。
  3. 上下文组装:根据模型要求把历史消息格式化为 Prompt。
  4. Token 计算与控制:统计历史消息 Token 数,超出时进行裁剪或摘要。
  5. 会话元数据管理:主题、标签、用户画像、来源渠道等。
  6. 会话共享与授权:支持多终端、多服务安全访问同一会话。

2.2 在微服务架构中的位置

┌────────────────────────────────────────────────────────────┐
│                         接入层                              │
│     Web 端    │    App 端    │   小程序   │   OpenAPI     │
└─────────────────────────┬──────────────────────────────────┘
                          │
                          ▼
              ┌───────────────────────┐
              │      API 网关          │
              └───────────┬───────────┘
                          │
          ┌───────────────┼───────────────┐
          ▼               ▼               ▼
   ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
   │  业务服务    │ │  Agent 服务  │ │ RAG 检索服务│
   └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
          │               │               │
          └───────────────┼───────────────┘
                          │
                          ▼
              ┌───────────────────────┐
              │    对话会话服务        │
              │  状态 / 上下文 / Token │
              └───────────┬───────────┘
                          │
            ┌─────────────┼─────────────┐
            ▼             ▼             ▼
     ┌──────────┐  ┌──────────┐  ┌──────────┐
     │  Redis   │  │ PostgreSQL│  │ 对象存储  │
     └──────────┘  └──────────┘  └──────────┘

2.3 拆分带来的核心收益

维度

耦合实现

独立会话服务

---

---

---

状态一致性

本地内存,实例间不一致

集中存储,强一致

服务扩展

有状态,扩缩容困难

业务服务完全无状态

持久化

重启丢失

Redis + 数据库双保险

Token 控制

各服务自行实现

统一策略,避免超限

历史管理

难以支持

天然支持查询、搜索、审计

跨端同步

无法做到

多终端共享同一会话

3.1 会话(Conversation)模型

@Entity
@Table(name = "conversation")
public class Conversation {
    @Id
    private String id;
    private String tenantId;
    private String userId;
    private String title;
    private String modelId;
    private String status;  // ACTIVE, ARCHIVED, DELETED
    private int messageCount;
    private int totalTokens;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    @Convert(converter = JsonMapConverter.class)
    private Map<String, Object> metadata;
}

3.2 消息(Message)模型

@Entity
@Table(name = "conversation_message")
public class ConversationMessage {
    @Id
    private String id;
    private String conversationId;
    private String role;  // system, user, assistant, tool
    private String content;
    private String modelId;
    private int tokenCount;
    private int sequence;
    private LocalDateTime createdAt;
    @Convert(converter = JsonMapConverter.class)
    private Map<String, Object> metadata;
}

3.3 消息角色设计

  • **system**:系统提示,定义助手行为、角色、约束。
  • **user**:用户输入,可以包含文本、图片、文件等。
  • **assistant**:模型生成的回复。
  • **tool**:工具调用结果,用于 Function Calling / Agent 场景。

一个典型的 Prompt 组装结构:

[
  { "role": "system", "content": "你是一位专业的客服助手。" },
  { "role": "user", "content": "如何申请退款?" },
  { "role": "assistant", "content": "您可以在订单详情页点击申请退款..." },
  { "role": "user", "content": "已经申请三天了还没处理" }
]

4.1 Redis:高性能热数据

Redis 是对话会话热数据的最佳选择:

  • 读写性能极高,适合高频的“发送消息—读取历史”循环。
  • 支持 TTL,可自动清理过期会话。
  • 数据结构丰富,可用 List、Sorted Set、Hash 存储消息。

Redis 中可以用 Sorted Set 按时间顺序存储消息:

@Service
public class RedisConversationStore {
    private final StringRedisTemplate redis;
    public void appendMessage(String conversationId, ConversationMessage message) {
        String key = "conv:" + conversationId + ":messages";
        String json = JsonUtils.toJson(message);
        redis.opsForZSet().add(key, json, message.getSequence());
        redis.expire(key, Duration.ofHours(24));
    }
    public List<ConversationMessage> getMessages(String conversationId, int start, int end) {
        String key = "conv:" + conversationId + ":messages";
        Set<String> jsonSet = redis.opsForZSet().range(key, start, end);
        return jsonSet.stream().map(JsonUtils::fromJson).collect(Collectors.toList());
    }
}

4.2 关系型数据库:持久化与审计

Redis 虽然快,但存在数据丢失风险。重要会话需要异步落库:

  • 写入 Redis 后,通过消息队列或定时任务把消息同步到 PostgreSQL/MySQL。
  • 关系型数据库用于历史会话列表、消息搜索、审计报表。
  • 会话归档后可以从 Redis 中移除,仅保留在数据库中。

4.3 混合模式:热数据在 Redis,冷数据在数据库

推荐的生产架构是混合模式:

  • 活跃会话放在 Redis,保证低延迟。
  • 消息异步写入数据库,用于持久化和分析。
  • 会话超过一定时间不活跃后,从 Redis 过期,后续访问从数据库加载。
  • 归档会话只保留在数据库,必要时再加载到 Redis。

@Service
public class HybridConversationStore {
    private final RedisConversationStore redisStore;
    private final ConversationRepository dbRepository;
    public List<ConversationMessage> getRecentMessages(String conversationId, int limit) {
        List<ConversationMessage> messages = redisStore.getMessages(conversationId, -limit, -1);
        if (!messages.isEmpty()) {
            return messages;
        }
        return dbRepository.findTopMessages(conversationId, limit);
    }
}

5.1 为什么需要 Token 预算

大模型每次请求都有上下文长度限制,例如:

  • GPT-3.5-turbo:16K
  • GPT-4-turbo:128K
  • Claude 3:200K
  • 国产大模型:常见 8K、32K、128K

超出限制会导致请求直接被拒绝。即使不超限,过长的上下文也会增加延迟和费用。因此,必须在会话服务层对 Token 数做预算管理。

5.2 Token 计算方式

常见做法有三种:

  1. **调用 Tokenizer 精确计算**:使用对应模型的 Tokenizer(如 tiktoken、SentencePiece)计算。
  2. **估算公式**:中文按 1 token ≈ 1.5~2 个字符,英文按 1 token ≈ 0.75 个单词估算。
  3. **模型返回的 usage**:在每次调用后从响应中读取实际 token 数,更新到数据库。

Java 中可以使用 jtokkit 库来近似计算 OpenAI 模型的 Token:

<dependency>
    <groupId>com.knuddels</groupId>
    <artifactId>jtokkit</artifactId>
    <version>1.0.0</version>
</dependency>

@Component
public class TokenEstimator {
    private final Encoding encoding = Encodings.newDefaultEncodingRegistry()
        .getEncoding(EncodingType.CL100K_BASE);
    public int count(String text) {
        return encoding.countTokens(text);
    }
    public int countMessages(List<ConversationMessage> messages) {
        int total = 0;
        for (ConversationMessage msg : messages) {
            total += count(msg.getRole()) + count(msg.getContent()) + 4;
        }
        return total + 2;
    }
}

5.3 Token 预算策略

假设模型最大上下文为 8K,我们可以分配预算:

  • System Prompt:固定 500 token
  • RAG 检索结果:最多 2000 token
  • 历史对话:最多 5000 token
  • 预留:500 token

当历史对话超过预算时,需要进行裁剪或摘要。

6.1 滑动窗口裁剪

最简单的方式是只保留最近 N 轮对话。这种方式实现简单,但会丢失早期关键信息。

public List<ConversationMessage> trimByWindow(List<ConversationMessage> messages, int maxRounds) {
    return messages.subList(Math.max(0, messages.size() - maxRounds * 2), messages.size());
}

6.2 按 Token 预算裁剪

更精确的做法是按 Token 数从后向前保留,直到满足预算:

public List<ConversationMessage> trimByTokenBudget(
        List<ConversationMessage> messages,
        int maxTokens) {
    List<ConversationMessage> result = new ArrayList<>();
    int total = 0;
    for (int i = messages.size() - 1; i >= 0; i--) {
        ConversationMessage msg = messages.get(i);
        int tokens = tokenEstimator.count(msg.getContent());
        if (total + tokens > maxTokens) break;
        result.add(0, msg);
        total += tokens;
    }
    return result;
}

6.3 摘要压缩

对于长会话,可以对早期对话生成摘要,用摘要替代原始消息:

public List<ConversationMessage> compressWithSummary(
        List<ConversationMessage> messages,
        int maxTokens,
        LlmClient llmClient) {
    List<ConversationMessage> recent = trimByTokenBudget(messages, maxTokens / 2);
    List<ConversationMessage> old = messages.subList(0, messages.size() - recent.size());
    if (!old.isEmpty()) {
        String summaryPrompt = "请对以下对话进行简要总结,保留关键事实和用户需求:\n" +
            old.stream().map(m -> m.getRole() + ": " + m.getContent())
               .collect(Collectors.joining("\n"));
        String summary = llmClient.call(summaryPrompt);
        recent.add(0, new ConversationMessage("system",
            "历史对话摘要:" + summary));
    }
    return recent;
}

6.4 分层摘要策略

更高级的压缩策略是分层摘要:

  • 每 N 轮生成一次局部摘要。
  • 多个局部摘要再生成全局摘要。
  • 组装 Prompt 时优先使用最近的原始消息,其次使用局部摘要,最后使用全局摘要。

这种方式在保留关键信息的同时,显著降低 Token 消耗。

7.1 项目结构

conversation-service/
├── src/main/java/com/example/conversation/
│   ├── api/
│   │   └── ConversationController.java
│   ├── application/
│   │   ├── ConversationService.java
│   │   ├── MessageService.java
│   │   └── ContextBuilder.java
│   ├── domain/
│   │   ├── Conversation.java
│   │   └── ConversationMessage.java
│   ├── infrastructure/
│   │   ├── RedisConversationStore.java
│   │   ├── JpaConversationRepository.java
│   │   └── TokenEstimator.java
│   └── ConversationServiceApplication.java
└── application.yml

7.2 会话服务核心实现

@Service
public class ConversationService {
    private final RedisConversationStore redisStore;
    private final ConversationRepository dbRepository;
    private final TokenEstimator tokenEstimator;
    private final ContextBuilder contextBuilder;
    public Conversation createConversation(CreateConversationRequest request) {
        Conversation conv = Conversation.builder()
            .id(UUID.randomUUID().toString())
            .tenantId(request.getTenantId())
            .userId(request.getUserId())
            .title(request.getTitle())
            .modelId(request.getModelId())
            .status("ACTIVE")
            .messageCount(0)
            .totalTokens(0)
            .createdAt(LocalDateTime.now())
            .updatedAt(LocalDateTime.now())
            .build();
        return dbRepository.save(conv);
    }
    public void appendMessage(String conversationId, ConversationMessage message) {
        redisStore.appendMessage(conversationId, message);
        asyncPersist(conversationId, message);
    }
    public List<ConversationMessage> buildContext(String conversationId,
                                                   String systemPrompt,
                                                   int maxContextTokens) {
        List<ConversationMessage> all = redisStore.getMessages(conversationId, 0, -1);
        List<ConversationMessage> context = new ArrayList<>();
        context.add(new ConversationMessage("system", systemPrompt));
        context.addAll(contextBuilder.trimByTokenBudget(all, maxContextTokens));
        return context;
    }
    @Async
    public void asyncPersist(String conversationId, ConversationMessage message) {
        dbRepository.save(message);
        dbRepository.incrementMessageCount(conversationId);
    }
}

7.3 控制器接口

@RestController
@RequestMapping("/api/v1/conversations")
public class ConversationController {
    private final ConversationService conversationService;
    @PostMapping
    public ResponseEntity<Conversation> create(@RequestBody @Valid CreateConversationRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(conversationService.createConversation(request));
    }
    @GetMapping("/{conversationId}/messages")
    public ResponseEntity<List<ConversationMessage>> getMessages(
            @PathVariable String conversationId,
            @RequestParam(defaultValue = "50") int limit) {
        return ResponseEntity.ok(
            conversationService.getRecentMessages(conversationId, limit));
    }
    @PostMapping("/{conversationId}/messages")
    public ResponseEntity<Void> addMessage(
            @PathVariable String conversationId,
            @RequestBody @Valid ConversationMessage message) {
        message.setConversationId(conversationId);
        conversationService.appendMessage(conversationId, message);
        return ResponseEntity.ok().build();
    }
    @GetMapping("/{conversationId}/context")
    public ResponseEntity<List<ConversationMessage>> buildContext(
            @PathVariable String conversationId,
            @RequestParam String systemPrompt,
            @RequestParam(defaultValue = "4000") int maxTokens) {
        return ResponseEntity.ok(
            conversationService.buildContext(conversationId, systemPrompt, maxTokens));
    }
}

8.1 会话服务作为状态中心

在 Agent 或多服务协作场景中,多个服务可能需要读取或更新同一会话状态。例如:

  • 业务服务创建会话并发送用户消息。
  • Agent 服务读取历史上下文,决定调用哪些工具。
  • 工具服务执行后把结果作为 `tool` 消息写回会话。
  • RAG 服务可以根据会话历史判断是否需要检索补充。

因此,会话服务应提供标准化的消息追加和上下文读取接口,所有服务都通过它访问会话状态,避免各自维护一份副本。

8.2 消息追加的幂等性

跨服务写入时需要防止重复写入。可以为每条消息生成唯一 messageId,写入前检查是否已存在:

public void appendMessage(String conversationId, ConversationMessage message) {
    if (message.getId() == null) {
        message.setId(UUID.randomUUID().toString());
    }
    String dedupKey = "conv:" + conversationId + ":msg:" + message.getId();
    Boolean absent = redis.opsForValue().setIfAbsent(dedupKey, "1", Duration.ofMinutes(10));
    if (Boolean.TRUE.equals(absent)) {
        redisStore.appendMessage(conversationId, message);
        asyncPersist(conversationId, message);
    }
}

8.3 事件通知

会话状态变化时,可以通过事件总线通知相关服务:

public record ConversationUpdatedEvent(
    String conversationId,
    String tenantId,
    String userId,
    String lastMessageRole,
    LocalDateTime updatedAt
) {}

订阅方如推荐服务、分析服务可以据此更新用户画像或触发后续动作。

9.1 读写分离

对话会话的读远大于写。可以把:

  • 最近消息读取走 Redis 主从。
  • 历史会话列表查询走数据库只读副本。
  • 消息写入先写 Redis,再异步落库。

9.2 缓存预热

用户打开应用时通常会拉取最近会话列表,可以在登录时预加载其最近 N 个活跃会话到 Redis。

9.3 分页与懒加载

历史消息列表不应一次性返回全部,应支持游标分页:

@GetMapping("/{conversationId}/messages")
public ResponseEntity<Page<ConversationMessage>> getMessages(
        @PathVariable String conversationId,
        @RequestParam(required = false) String cursor,
        @RequestParam(defaultValue = "20") int size) {
    return ResponseEntity.ok(
        conversationService.getMessagesBeforeCursor(conversationId, cursor, size));
}

9.4 连接池与序列化

Redis 连接池应合理配置,避免高并发下连接耗尽。消息 JSON 序列化应选择高性能库,如 Jackson 或 Gson,并开启压缩选项。

对话会话服务的独立化,是大模型应用从“单次问答”走向“持续对话”的关键基础设施。通过独立服务,我们解决了状态一致性、持久化、Token 控制和跨端同步等核心问题,让业务服务可以无状态地水平扩展。

Java 程序员在实现对话会话服务时,应重点关注:

  • 热数据与冷数据的分层存储策略。
  • Token 预算与上下文压缩算法。
  • 消息追加的幂等性与并发安全。
  • 跨服务共享会话状态时的权限与审计。
  • 高并发场景下的缓存、分页和连接池优化。

下一篇文章,我们将进入大模型路由网关的讨论,探讨如何在多模型、多供应商、多版本的复杂环境下,统一调度流量、实现负载均衡和故障转移。敬请期待。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐