Java 程序员第 44 阶段08:大模型微服务拆分,独立服务解耦便于扩容维护,对话会话服务:多轮上下文状态管理独立化
- 多轮对话的上下文管理难题
- 对话会话服务的独立价值
- 会话数据模型设计
- 持久化方案选型:Redis、数据库与混合模式
- 上下文窗口与 Token 控制
- 上下文压缩与摘要策略
- Spring Boot 会话服务实现
- 跨服务共享会话状态
- 高并发与性能优化
- 总结与展望
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;
}
}
这段代码的问题一目了然:
- 会话状态存在本地内存,服务重启即丢失。
- 多个实例之间无法共享状态,负载均衡后上下文断裂。
- 没有 Token 控制,长对话会超出模型上下文上限。
- 没有持久化,无法支持历史会话列表、消息搜索、审计分析。
- 对话逻辑与业务逻辑耦合,难以复用和维护。
1.3 独立对话会话服务的价值
把对话会话状态从业务服务中拆出来,形成独立的“对话会话服务”,能够获得:
- **状态集中管理**:所有会话状态统一存储、统一访问、统一失效策略。
- **水平扩展友好**:业务服务无状态化,可以随意扩缩容。
- **持久化与审计**:对话记录落库,支持历史查询、统计分析和合规审计。
- **Token 预算控制**:在会话服务层统一计算和控制上下文长度。
- **跨端同步**:Web、App、小程序共享同一份会话状态。
- **多服务共享**:RAG 检索、Agent 编排、工具调用等服务都可以读取同一会话上下文。
2.1 服务边界
对话会话服务应专注于“对话状态”本身,核心能力包括:
- 会话生命周期管理:创建、查询、归档、删除。
- 消息存储与读取:按时间顺序存储用户与助手消息。
- 上下文组装:根据模型要求把历史消息格式化为 Prompt。
- Token 计算与控制:统计历史消息 Token 数,超出时进行裁剪或摘要。
- 会话元数据管理:主题、标签、用户画像、来源渠道等。
- 会话共享与授权:支持多终端、多服务安全访问同一会话。
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 计算方式
常见做法有三种:
- **调用 Tokenizer 精确计算**:使用对应模型的 Tokenizer(如 tiktoken、SentencePiece)计算。
- **估算公式**:中文按 1 token ≈ 1.5~2 个字符,英文按 1 token ≈ 0.75 个单词估算。
- **模型返回的 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 预算与上下文压缩算法。
- 消息追加的幂等性与并发安全。
- 跨服务共享会话状态时的权限与审计。
- 高并发场景下的缓存、分页和连接池优化。
下一篇文章,我们将进入大模型路由网关的讨论,探讨如何在多模型、多供应商、多版本的复杂环境下,统一调度流量、实现负载均衡和故障转移。敬请期待。
更多推荐


所有评论(0)