📅 难度:⭐⭐⭐☆☆ 进阶 | 阅读约 20 分钟 | 适用:Spring Boot 3.x | Java 17+ | Redis 6+
一、问题背景:AI 为什么会"失忆"?
前三篇实现的接口都是无状态的单轮对话------每次请求独立发送,模型不记得你上一句说了什么。下面这段对话会让你的 AI 彻底懵:
bash
用户:我叫张三,今年 28 岁。
AI :你好,张三!很高兴认识你。
用户:我叫什么名字?
AI :抱歉,我不知道你的名字。(😅 刚说过的就忘了)
用户:帮我规划一个适合我年龄的健身计划。
AI :请问你多大?(😅 年龄也忘了)
根本原因 :Claude API 本身是无状态的,每次请求之间没有任何记忆。要实现多轮对话,必须由应用层维护对话历史,并在每次请求时将完整历史一并发送给模型。
Claude API 的消息结构如下:
bash
{
"messages": [
{ "role": "user", "content": "我叫张三,今年 28 岁。" },
{ "role": "assistant", "content": "你好,张三!很高兴认识你。" },
{ "role": "user", "content": "我叫什么名字?" }
]
}
只要把历史消息按 user → assistant → user → ... 的顺序拼接进来,模型就有了"记忆"。
1.1 会话管理的核心挑战
| 挑战 | 说明 |
|---|---|
| 存在哪 | 内存?进程重启会丢失;Redis?持久化、支持集群 |
| 多用户隔离 | 不同用户的对话历史不能混在一起 |
| 上下文长度限制 | Claude 有 context window 上限,历史太长需要裁剪 |
| 并发安全 | 同一会话可能被并发请求修改 |
| 会话过期 | 长时间不活跃的会话应自动清理 |
本篇将逐一解决这些问题,最终构建一套生产可用的多轮对话管理系统。
二、整体架构设计
bash
┌──────────────────────────────────────────────────────────────┐
│ 客户端 │
│ 携带 sessionId(首次为空,服务端创建后返回给客户端) │
└───────────────────────────┬──────────────────────────────────┘
│ POST /api/v1/session/chat
│ { sessionId, message }
┌───────────────────────────▼──────────────────────────────────┐
│ ConversationController │
│ • 无 sessionId → 创建新会话 │
│ • 有 sessionId → 继续已有会话 │
└───────────────────────────┬──────────────────────────────────┘
│
┌───────────────────────────▼──────────────────────────────────┐
│ ConversationService │
│ 1. 从 Redis 加载历史消息 │
│ 2. 追加用户新消息 │
│ 3. 调用 ClawChatService(第2篇) │
│ 4. 追加 AI 回复到历史 │
│ 5. 裁剪超长上下文 │
│ 6. 保存回 Redis(重置 TTL) │
└──────────────┬────────────────────────┬──────────────────────┘
│ │
┌──────────────▼──────┐ ┌───────────▼──────────────────────┐
│ ClawChatService │ │ Redis │
│ (第2篇实现) │ │ Key: session:{sessionId} │
│ 发送消息到 Claude │ │ Value: 会话对象(JSON) │
│ API │ │ TTL: 30 分钟(可配置) │
└─────────────────────┘ └──────────────────────────────────┘
三、依赖与配置
3.1 添加 Redis 依赖
XML
<!-- pom.xml -->
<dependencies>
<!-- Redis(Spring Data Redis + Lettuce 客户端) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JSON 序列化(Jackson 已包含在 starter-web 中,无需额外添加) -->
<!-- 连接池(可选,高并发场景推荐) -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
</dependencies>
3.2 Redis 配置
XML
# application.yml
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:} # 无密码留空
database: 0
lettuce:
pool:
max-active: 20 # 最大连接数
max-idle: 10
min-idle: 2
max-wait: 1000ms # 获取连接最大等待时间
app:
claw:
# ... 第2篇已有配置 ...
session:
ttl-minutes: 30 # 会话过期时间(分钟)
max-history-turns: 20 # 最大保留对话轮次
max-context-tokens: 40000 # 上下文 token 上限(超出则裁剪)
max-session-per-user: 10 # 每个用户最多并存的会话数
3.3 Redis 序列化配置
Spring Boot 默认使用 JDK 序列化,可读性差。改为 JSON 序列化:
java
package com.example.openclaw_demo.config;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// Key 使用 String 序列化
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
// Value 使用 Jackson JSON 序列化(带类型信息,支持反序列化多态)
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
Jackson2JsonRedisSerializer<Object> jsonSerializer =
new Jackson2JsonRedisSerializer<>(mapper, Object.class);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
四、核心数据结构设计
4.1 单条消息
java
package com.example.openclaw_demo.domain;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* 单条对话消息
*/
@Getter
public class ConversationMessage {
/** 角色:user 或 assistant */
private final String role;
/** 消息内容 */
private final String content;
/** 创建时间 */
private final LocalDateTime createdAt;
/** 估算的 token 数(用于上下文裁剪) */
private final int estimatedTokens;
@JsonCreator
public ConversationMessage(
@JsonProperty("role") String role,
@JsonProperty("content") String content,
@JsonProperty("createdAt") LocalDateTime createdAt,
@JsonProperty("estimatedTokens") int estimatedTokens) {
this.role = role;
this.content = content;
this.createdAt = createdAt;
this.estimatedTokens = estimatedTokens;
}
/** 工厂方法:创建用户消息 */
public static ConversationMessage ofUser(String content) {
return new ConversationMessage("user", content,
LocalDateTime.now(), estimateTokens(content));
}
/** 工厂方法:创建 AI 消息 */
public static ConversationMessage ofAssistant(String content) {
return new ConversationMessage("assistant", content,
LocalDateTime.now(), estimateTokens(content));
}
/**
* 简单 token 估算:中文约 1.5 字/token,英文约 4 字符/token
* 仅用于裁剪判断,不需要精确
*/
public static int estimateTokens(String text) {
if (text == null || text.isEmpty()) return 0;
long chineseCount = text.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF)
.count();
long otherCount = text.length() - chineseCount;
return (int) (chineseCount / 1.5 + otherCount / 4.0) + 1;
}
}
4.2 会话对象
java
package com.example.openclaw_demo.domain;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* 会话对象:一个用户的一次完整对话上下文
*/
@Data
public class ConversationSession {
/** 会话唯一 ID */
private String sessionId;
/** 所属用户 ID(未登录可用设备 ID 或匿名 ID) */
private String userId;
/** 会话标题(可从第一条消息自动生成) */
private String title;
/** 系统提示词(整个会话使用同一个) */
private String systemPrompt;
/** 对话历史(有序) */
private List<ConversationMessage> messages;
/** 会话创建时间 */
private LocalDateTime createdAt;
/** 最后活跃时间 */
private LocalDateTime lastActiveAt;
/** 总消耗 token 数(累计) */
private int totalTokensUsed;
/** 对话轮次(一问一答算一轮) */
private int turnCount;
/**
* 工厂方法:创建新会话
*/
public static ConversationSession create(String userId, String systemPrompt) {
ConversationSession session = new ConversationSession();
session.sessionId = UUID.randomUUID().toString().replace("-", "");
session.userId = userId;
session.systemPrompt = systemPrompt != null ? systemPrompt : "";
session.messages = new ArrayList<>();
session.createdAt = LocalDateTime.now();
session.lastActiveAt = LocalDateTime.now();
session.totalTokensUsed = 0;
session.turnCount = 0;
return session;
}
/**
* 追加消息并更新活跃时间
*/
public void addMessage(ConversationMessage message) {
this.messages.add(message);
this.lastActiveAt = LocalDateTime.now();
if ("assistant".equals(message.getRole())) {
this.turnCount++;
}
}
/**
* 累计 token 用量
*/
public void addTokensUsed(int tokens) {
this.totalTokensUsed += tokens;
}
/**
* 自动生成标题(取第一条用户消息的前 20 个字符)
*/
public void autoGenerateTitle() {
if (this.title != null) return;
this.messages.stream()
.filter(m -> "user".equals(m.getRole()))
.findFirst()
.ifPresent(m -> {
String raw = m.getContent().replaceAll("\\s+", " ").trim();
this.title = raw.length() > 20 ? raw.substring(0, 20) + "..." : raw;
});
}
}
五、会话存储层:SessionRepository
java
package com.example.openclaw_demo.repository;
import com.example.openclaw_demo.domain.ConversationSession;
import java.util.List;
import java.util.Optional;
/**
* 会话存储接口(面向接口编程,方便切换存储方案)
*/
public interface SessionRepository {
/** 保存或更新会话(同时重置 TTL) */
void save(ConversationSession session);
/** 根据 sessionId 查询会话 */
Optional<ConversationSession> findById(String sessionId);
/** 查询某用户的所有会话 ID(用于限制会话数量) */
List<String> findSessionIdsByUserId(String userId);
/** 删除指定会话 */
void deleteById(String sessionId);
/** 检查会话是否存在 */
boolean existsById(String sessionId);
}
Redis 实现
java
package com.example.openclaw_demo.repository.impl;
import com.example.openclaw_demo.config.SessionProperties;
import com.example.openclaw_demo.domain.ConversationSession;
import com.example.openclaw_demo.repository.SessionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@Slf4j
@Repository
@RequiredArgsConstructor
public class RedisSessionRepository implements SessionRepository {
private final RedisTemplate<String, Object> redisTemplate;
private final SessionProperties sessionProperties;
// Key 设计:
// 会话数据:session:{sessionId}
// 用户会话索引:user:sessions:{userId}(Set 结构)
private static final String SESSION_KEY_PREFIX = "session:";
private static final String USER_SESSIONS_PREFIX = "user:sessions:";
@Override
public void save(ConversationSession session) {
String sessionKey = sessionKey(session.getSessionId());
String userKey = userSessionsKey(session.getUserId());
// 保存会话对象,重置 TTL
redisTemplate.opsForValue().set(
sessionKey,
session,
Duration.ofMinutes(sessionProperties.getTtlMinutes())
);
// 维护用户→会话的索引(Set)
redisTemplate.opsForSet().add(userKey, session.getSessionId());
// 用户索引的 TTL 略长于会话 TTL(避免索引先过期)
redisTemplate.expire(userKey,
Duration.ofMinutes(sessionProperties.getTtlMinutes() + 60));
log.debug("[Session] 保存会话 sessionId={}, userId={}",
session.getSessionId(), session.getUserId());
}
@Override
public Optional<ConversationSession> findById(String sessionId) {
Object value = redisTemplate.opsForValue().get(sessionKey(sessionId));
if (value == null) return Optional.empty();
return Optional.of((ConversationSession) value);
}
@Override
public List<String> findSessionIdsByUserId(String userId) {
Set<Object> members = redisTemplate.opsForSet()
.members(userSessionsKey(userId));
if (members == null) return new ArrayList<>();
return members.stream()
.map(Object::toString)
.toList();
}
@Override
public void deleteById(String sessionId) {
redisTemplate.delete(sessionKey(sessionId));
log.info("[Session] 删除会话 sessionId={}", sessionId);
}
@Override
public boolean existsById(String sessionId) {
return Boolean.TRUE.equals(
redisTemplate.hasKey(sessionKey(sessionId)));
}
private String sessionKey(String sessionId) {
return SESSION_KEY_PREFIX + sessionId;
}
private String userSessionsKey(String userId) {
return USER_SESSIONS_PREFIX + userId;
}
}
SessionProperties 配置类
java
package com.example.openclaw_demo.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "app.session")
public class SessionProperties {
/** 会话 TTL(分钟) */
private int ttlMinutes = 30;
/** 最大保留轮次 */
private int maxHistoryTurns = 20;
/** 上下文 token 上限 */
private int maxContextTokens = 40000;
/** 每个用户最多并存会话数 */
private int maxSessionPerUser = 10;
}
六、上下文裁剪策略
当对话历史过长时,必须裁剪,否则会超出模型的 context window 限制并报错。
6.1 裁剪工具类
java
package com.example.openclaw_demo.util;
import com.example.openclaw_demo.domain.ConversationMessage;
import com.example.openclaw_demo.domain.ConversationSession;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
/**
* 对话上下文裁剪工具
*
* 裁剪策略:
* 1. 轮次超限 → 删除最早的若干轮(保留最近 N 轮)
* 2. Token 超限 → 从最早的消息开始删除,直到 token 数达标
* 3. 始终保留第一条用户消息(作为上下文锚点,可选)
*/
@Slf4j
public class ContextTrimmer {
private ContextTrimmer() {}
/**
* 对会话消息进行裁剪,返回裁剪后可发送给模型的消息列表
*
* @param session 当前会话
* @param maxTurns 最大轮次
* @param maxTokens 最大 token 数
* @return 裁剪后的消息列表(不修改原会话)
*/
public static List<ConversationMessage> trim(
ConversationSession session,
int maxTurns,
int maxTokens) {
List<ConversationMessage> messages = new ArrayList<>(session.getMessages());
// ① 按轮次裁剪
messages = trimByTurns(messages, maxTurns);
// ② 按 token 裁剪
messages = trimByTokens(messages, maxTokens);
if (messages.size() < session.getMessages().size()) {
log.info("[ContextTrimmer] 上下文裁剪:原{}条 → 裁剪后{}条",
session.getMessages().size(), messages.size());
}
return messages;
}
/**
* 按轮次裁剪:保留最近 maxTurns 轮(每轮 = user + assistant 各一条)
*/
private static List<ConversationMessage> trimByTurns(
List<ConversationMessage> messages, int maxTurns) {
// 计算实际轮次(以 assistant 消息数为准)
long assistantCount = messages.stream()
.filter(m -> "assistant".equals(m.getRole()))
.count();
if (assistantCount <= maxTurns) return messages;
// 需要删除的轮次数
long excessTurns = assistantCount - maxTurns;
List<ConversationMessage> result = new ArrayList<>(messages);
// 从头开始删除成对的 user+assistant
int deletedTurns = 0;
while (deletedTurns < excessTurns && result.size() >= 2) {
// 找第一个 user 消息的位置
int userIdx = -1;
for (int i = 0; i < result.size(); i++) {
if ("user".equals(result.get(i).getRole())) {
userIdx = i;
break;
}
}
if (userIdx == -1) break;
// 找紧随其后的 assistant 消息
int assistantIdx = -1;
for (int i = userIdx + 1; i < result.size(); i++) {
if ("assistant".equals(result.get(i).getRole())) {
assistantIdx = i;
break;
}
}
if (assistantIdx == -1) break;
// 删除这一轮(从后往前删,避免索引偏移)
result.remove(assistantIdx);
result.remove(userIdx);
deletedTurns++;
}
return result;
}
/**
* 按 token 裁剪:从最早的消息开始删除,直到总 token 数低于阈值
*/
private static List<ConversationMessage> trimByTokens(
List<ConversationMessage> messages, int maxTokens) {
int totalTokens = messages.stream()
.mapToInt(ConversationMessage::getEstimatedTokens)
.sum();
if (totalTokens <= maxTokens) return messages;
List<ConversationMessage> result = new ArrayList<>(messages);
while (totalTokens > maxTokens && result.size() > 1) {
ConversationMessage removed = result.remove(0);
totalTokens -= removed.getEstimatedTokens();
}
return result;
}
}
七、核心业务层:ConversationService
java
package com.example.openclaw_demo.service;
import com.example.openclaw_demo.dto.ConversationRequest;
import com.example.openclaw_demo.dto.ConversationResponse;
import com.example.openclaw_demo.domain.ConversationSession;
import java.util.List;
/**
* 多轮对话服务接口
*/
public interface ConversationService {
/**
* 发送消息(自动处理会话创建/续话)
*
* @param request 请求(含 sessionId、userId、message)
* @return 响应(含 sessionId、AI 回复、token 用量等)
*/
ConversationResponse chat(ConversationRequest request);
/**
* 获取会话历史
*/
ConversationSession getSession(String sessionId, String userId);
/**
* 删除会话
*/
void deleteSession(String sessionId, String userId);
/**
* 获取用户的所有会话列表(摘要)
*/
List<ConversationSession> listSessions(String userId);
/**
* 清空会话历史(保留会话,清空消息)
*/
void clearHistory(String sessionId, String userId);
}
DTO 定义
java
// ===== ConversationRequest.java =====
package com.example.openclaw_demo.dto;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@Data
public class ConversationRequest {
/**
* 会话 ID
* 为 null 或空 → 服务端创建新会话
* 有值 → 继续已有会话
*/
private String sessionId;
/**
* 用户 ID(必填,用于会话隔离)
* 未登录场景可传设备 ID 或前端生成的匿名 UUID
*/
@NotBlank(message = "userId 不能为空")
private String userId;
/** 用户消息 */
@NotBlank(message = "消息内容不能为空")
@Size(max = 10000)
private String message;
/** 系统提示词(仅创建新会话时有效,续话忽略此字段) */
private String systemPrompt;
/** 是否启用流式输出(本篇先实现非流式,流式版本见扩展部分) */
private boolean stream = false;
}
// ===== ConversationResponse.java =====
package com.example.openclaw_demo.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ConversationResponse {
/** 会话 ID(新建会话时返回,客户端需保存) */
private String sessionId;
/** AI 回复内容 */
private String content;
/** 当前会话总轮次 */
private int turnCount;
/** 本次消耗 token 数 */
private int inputTokens;
private int outputTokens;
/** 当前会话累计消耗 token 数 */
private int totalTokensUsed;
/** 是否为新建会话 */
private boolean newSession;
/** 上下文是否被裁剪 */
private boolean contextTrimmed;
}
ConversationServiceImpl 完整实现
java
package com.example.openclaw_demo.service.impl;
import com.example.openclaw_demo.config.SessionProperties;
import com.example.openclaw_demo.domain.ConversationMessage;
import com.example.openclaw_demo.domain.ConversationSession;
import com.example.openclaw_demo.dto.ChatRequestDTO;
import com.example.openclaw_demo.dto.ChatResponseDTO;
import com.example.openclaw_demo.dto.ConversationRequest;
import com.example.openclaw_demo.dto.ConversationResponse;
import com.example.openclaw_demo.exception.ClawApiException;
import com.example.openclaw_demo.repository.SessionRepository;
import com.example.openclaw_demo.service.ClawChatService;
import com.example.openclaw_demo.service.ConversationService;
import com.example.openclaw_demo.util.ContextTrimmer;
import io.openclaw.model.request.ChatRequest;
import io.openclaw.model.request.Message;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ConversationServiceImpl implements ConversationService {
private final ClawChatService clawChatService;
private final SessionRepository sessionRepository;
private final SessionProperties sessionProperties;
// ----------------------------------------------------------------
// 核心:多轮对话
// ----------------------------------------------------------------
@Override
public ConversationResponse chat(ConversationRequest request) {
// ① 获取或创建会话
boolean isNewSession = false;
ConversationSession session;
if (StringUtils.hasText(request.getSessionId())) {
// 续话:加载已有会话
session = sessionRepository.findById(request.getSessionId())
.orElseThrow(() -> new ClawApiException(
"会话不存在或已过期,请重新开始对话", null));
// 安全校验:确保会话属于当前用户
if (!session.getUserId().equals(request.getUserId())) {
throw new ClawApiException("无权访问此会话", null);
}
} else {
// 新建会话
session = createNewSession(request);
isNewSession = true;
log.info("[Conv] 创建新会话 sessionId={}, userId={}",
session.getSessionId(), request.getUserId());
}
// ② 追加用户消息到历史
ConversationMessage userMsg = ConversationMessage.ofUser(request.getMessage());
session.addMessage(userMsg);
// ③ 上下文裁剪(判断历史是否太长)
int beforeSize = session.getMessages().size();
List<ConversationMessage> contextMessages = ContextTrimmer.trim(
session,
sessionProperties.getMaxHistoryTurns(),
sessionProperties.getMaxContextTokens()
);
boolean contextTrimmed = contextMessages.size() < beforeSize;
// ④ 构建发送给模型的请求(含完整历史)
ChatResponseDTO aiResp = callClaudeWithHistory(session, contextMessages);
// ⑤ 追加 AI 回复到历史
ConversationMessage assistantMsg = ConversationMessage.ofAssistant(aiResp.getContent());
session.addMessage(assistantMsg);
// ⑥ 更新 token 用量并生成标题
session.addTokensUsed(aiResp.getInputTokens() + aiResp.getOutputTokens());
session.autoGenerateTitle();
// ⑦ 持久化会话到 Redis(重置 TTL)
sessionRepository.save(session);
log.info("[Conv] 对话完成 sessionId={}, turn={}, tokens={}/{}",
session.getSessionId(), session.getTurnCount(),
aiResp.getInputTokens(), aiResp.getOutputTokens());
// ⑧ 返回响应
return ConversationResponse.builder()
.sessionId(session.getSessionId())
.content(aiResp.getContent())
.turnCount(session.getTurnCount())
.inputTokens(aiResp.getInputTokens())
.outputTokens(aiResp.getOutputTokens())
.totalTokensUsed(session.getTotalTokensUsed())
.newSession(isNewSession)
.contextTrimmed(contextTrimmed)
.build();
}
// ----------------------------------------------------------------
// 获取会话(含权限校验)
// ----------------------------------------------------------------
@Override
public ConversationSession getSession(String sessionId, String userId) {
ConversationSession session = sessionRepository.findById(sessionId)
.orElseThrow(() -> new ClawApiException("会话不存在或已过期", null));
if (!session.getUserId().equals(userId)) {
throw new ClawApiException("无权访问此会话", null);
}
return session;
}
// ----------------------------------------------------------------
// 删除会话
// ----------------------------------------------------------------
@Override
public void deleteSession(String sessionId, String userId) {
getSession(sessionId, userId); // 触发权限校验
sessionRepository.deleteById(sessionId);
log.info("[Conv] 删除会话 sessionId={}, userId={}", sessionId, userId);
}
// ----------------------------------------------------------------
// 列出用户所有会话
// ----------------------------------------------------------------
@Override
public List<ConversationSession> listSessions(String userId) {
List<String> sessionIds = sessionRepository.findSessionIdsByUserId(userId);
return sessionIds.stream()
.map(id -> sessionRepository.findById(id))
.filter(opt -> opt.isPresent())
.map(opt -> opt.get())
// 只返回属于该用户的(过滤掉 Redis 索引中残留的旧记录)
.filter(s -> userId.equals(s.getUserId()))
// 按最后活跃时间倒序
.sorted((a, b) -> b.getLastActiveAt().compareTo(a.getLastActiveAt()))
.collect(Collectors.toList());
}
// ----------------------------------------------------------------
// 清空历史
// ----------------------------------------------------------------
@Override
public void clearHistory(String sessionId, String userId) {
ConversationSession session = getSession(sessionId, userId);
session.getMessages().clear();
session.setTurnCount(0);
sessionRepository.save(session);
log.info("[Conv] 清空历史 sessionId={}", sessionId);
}
// ================================================================
// 私有方法
// ================================================================
/**
* 创建新会话(含用户会话数限制检查)
*/
private ConversationSession createNewSession(ConversationRequest request) {
// 检查用户会话数是否超限
List<String> existingIds = sessionRepository.findSessionIdsByUserId(request.getUserId());
if (existingIds.size() >= sessionProperties.getMaxSessionPerUser()) {
// 超限策略:删除最早的会话(LRU 近似)
// 实际生产中可以改为拒绝创建并提示用户
String oldestId = existingIds.get(0);
sessionRepository.deleteById(oldestId);
log.info("[Conv] 用户 {} 会话数超限,删除最早会话 {}", request.getUserId(), oldestId);
}
return ConversationSession.create(request.getUserId(), request.getSystemPrompt());
}
/**
* 将历史消息转换为 OpenClAW 格式并调用模型
*/
private ChatResponseDTO callClaudeWithHistory(
ConversationSession session,
List<ConversationMessage> contextMessages) {
// 将我们的消息格式转换为 OpenClAW 的 Message 格式
List<Message> clawMessages = contextMessages.stream()
.map(m -> Message.builder()
.role(m.getRole())
.content(m.getContent())
.build())
.collect(Collectors.toList());
// 使用 OpenClAW 的多消息 API(注意:这里直接调用底层 ClaudeService,
// 因为第2篇的 ClawChatService 只支持单条消息)
// 如果你已经在第2篇的接口中添加了多消息支持,可以替换为 clawChatService.chat()
ChatRequest clawReq = ChatRequest.builder()
.model(session.getSystemPrompt() != null ? "claude-sonnet-4-20250514" : "claude-sonnet-4-20250514")
.maxTokens(sessionProperties.getMaxContextTokens() > 0 ? 1024 : 1024)
.systemPrompt(StringUtils.hasText(session.getSystemPrompt())
? session.getSystemPrompt() : null)
.messages(clawMessages)
.build();
// 通过底层 claudeService 发送多轮消息
// 返回转换为 ChatResponseDTO
try {
var rawResp = clawChatService.chatWithMessages(clawReq);
return rawResp;
} catch (Exception e) {
throw new ClawApiException("AI 调用失败: " + e.getMessage(), e);
}
}
}
💡 关于
chatWithMessages:上面的callClaudeWithHistory需要在ClawChatService接口中新增一个接受List<Message>的方法。下面的扩展章节会给出完整实现。
八、扩展 ClawChatService 支持多消息
在第2篇的 ClawChatService 接口中新增方法:
java
// ClawChatService.java(在第2篇基础上新增)
/**
* 多轮对话:传入完整消息列表
* (内部调用 OpenClAW 的 ChatRequest.messages)
*/
ChatResponseDTO chatWithMessages(io.openclaw.model.request.ChatRequest clawRequest);
在 ClawChatServiceImpl 中实现:
java
// ClawChatServiceImpl.java(新增方法)
@Override
public ChatResponseDTO chatWithMessages(ChatRequest clawRequest) {
long startMs = System.currentTimeMillis();
try {
ChatResponse clawResp = claudeService.chat(clawRequest);
return toResponseDTO(clawResp, System.currentTimeMillis() - startMs);
} catch (ClaudeRateLimitException e) {
log.warn("[ClAW] 多轮对话限流: {}", e.getMessage());
throw new ClawRateLimitException("AI 服务请求频率超限,请稍后重试", e);
} catch (ClaudeApiException e) {
log.error("[ClAW] 多轮对话 API 错误: {}", e.getMessage());
throw new ClawApiException("AI 服务调用失败: " + e.getMessage(), e);
}
}
九、Controller 层
java
package com.example.openclaw_demo.controller;
import com.example.openclaw_demo.common.ApiResult;
import com.example.openclaw_demo.domain.ConversationSession;
import com.example.openclaw_demo.dto.ConversationRequest;
import com.example.openclaw_demo.dto.ConversationResponse;
import com.example.openclaw_demo.service.ConversationService;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/conversation")
@RequiredArgsConstructor
public class ConversationController {
private final ConversationService conversationService;
/**
* POST /api/v1/conversation/chat
* 多轮对话核心接口
*
* 首次请求(新建会话):
* { "userId": "user123", "message": "你好" }
*
* 续话请求(已有会话):
* { "userId": "user123", "sessionId": "abc123", "message": "我叫什么名字?" }
*/
@PostMapping("/chat")
public ApiResult<ConversationResponse> chat(
@RequestBody @Validated ConversationRequest request) {
ConversationResponse response = conversationService.chat(request);
return ApiResult.ok(response);
}
/**
* GET /api/v1/conversation/session/{sessionId}
* 获取会话详情(含完整历史)
*/
@GetMapping("/session/{sessionId}")
public ApiResult<ConversationSession> getSession(
@PathVariable String sessionId,
@RequestParam String userId) {
ConversationSession session = conversationService.getSession(sessionId, userId);
return ApiResult.ok(session);
}
/**
* GET /api/v1/conversation/sessions
* 获取用户所有会话列表
*/
@GetMapping("/sessions")
public ApiResult<List<ConversationSession>> listSessions(
@RequestParam String userId) {
List<ConversationSession> sessions = conversationService.listSessions(userId);
return ApiResult.ok(sessions);
}
/**
* DELETE /api/v1/conversation/session/{sessionId}
* 删除会话
*/
@DeleteMapping("/session/{sessionId}")
public ApiResult<Void> deleteSession(
@PathVariable String sessionId,
@RequestParam String userId) {
conversationService.deleteSession(sessionId, userId);
return ApiResult.ok(null);
}
/**
* POST /api/v1/conversation/session/{sessionId}/clear
* 清空会话历史(保留会话配置)
*/
@PostMapping("/session/{sessionId}/clear")
public ApiResult<Void> clearHistory(
@PathVariable String sessionId,
@RequestParam String userId) {
conversationService.clearHistory(sessionId, userId);
return ApiResult.ok(null);
}
}
十、完整接口联调演示
下面是一段完整的多轮对话请求序列:
Step 1:发起新会话(无 sessionId)
bash
curl -X POST http://localhost:8080/api/v1/conversation/chat \
-H "Content-Type: application/json" \
-d '{
"userId": "user_001",
"message": "你好,我叫张三,今年 28 岁,是一名 Java 开发者。",
"systemPrompt": "你是一位专业的职业顾问,请根据用户的信息提供个性化建议。"
}'
响应(服务端创建会话并返回 sessionId):
bash
{
"success": true,
"code": 200,
"message": "success",
"data": {
"sessionId": "a1b2c3d4e5f6...",
"content": "你好,张三!很高兴认识你。作为一名 28 岁的 Java 开发者...",
"turnCount": 1,
"inputTokens": 62,
"outputTokens": 98,
"totalTokensUsed": 160,
"newSession": true,
"contextTrimmed": false
}
}
Step 2:续话(携带 sessionId)
bash
curl -X POST http://localhost:8080/api/v1/conversation/chat \
-H "Content-Type: application/json" \
-d '{
"userId": "user_001",
"sessionId": "a1b2c3d4e5f6...",
"message": "我叫什么名字?我多大了?"
}'
响应(AI 记住了上下文):
bash
{
"success": true,
"data": {
"sessionId": "a1b2c3d4e5f6...",
"content": "你叫张三,今年 28 岁。",
"turnCount": 2,
"inputTokens": 178,
"outputTokens": 12,
"totalTokensUsed": 350,
"newSession": false,
"contextTrimmed": false
}
}
Step 3:继续深度对话
bash
curl -X POST http://localhost:8080/api/v1/conversation/chat \
-H "Content-Type: application/json" \
-d '{
"userId": "user_001",
"sessionId": "a1b2c3d4e5f6...",
"message": "帮我规划一个适合我年龄和职业的 3 年技术成长路线。"
}'
响应(AI 基于完整上下文给出个性化建议):
bash
{
"success": true,
"data": {
"sessionId": "a1b2c3d4e5f6...",
"content": "张三,根据你 28 岁、Java 开发者的背景,我为你规划以下 3 年路线:\n\n**第1年(夯实基础)**...",
"turnCount": 3,
"contextTrimmed": false
}
}
十一、流式多轮对话(扩展)
将第3篇的 SSE 与本篇的会话管理结合,实现流式多轮对话:
bash
// ClawStreamService.java(新增多轮流式方法)
SseEmitter streamConversationChat(ConversationRequest request);
// ClawStreamServiceImpl.java(实现)
@Override
public SseEmitter streamConversationChat(ConversationRequest request) {
SseEmitter emitter = new SseEmitter(3 * 60 * 1000L);
streamExecutor.execute(() -> {
try {
// ① 获取或创建会话
ConversationSession session = getOrCreateSession(request);
boolean isNew = !StringUtils.hasText(request.getSessionId());
// ② 追加用户消息
session.addMessage(ConversationMessage.ofUser(request.getMessage()));
// ③ 上下文裁剪
List<ConversationMessage> contextMessages = ContextTrimmer.trim(
session, sessionProperties.getMaxHistoryTurns(),
sessionProperties.getMaxContextTokens());
// ④ 先推送 sessionId(新建会话时前端需要保存)
if (isNew) {
emitter.send(SseEmitter.event()
.name("session")
.data("{\"sessionId\":\"" + session.getSessionId() + "\"}"));
}
// ⑤ 流式调用,同时收集完整回复
StringBuilder fullReply = new StringBuilder();
claudeService.streamChat(
buildMultiTurnRequest(session, contextMessages),
token -> {
fullReply.append(token);
try {
emitter.send(SseEmitter.event().name("token").data(token));
} catch (IOException e) {
emitter.completeWithError(e);
}
},
(stopReason, usage) -> {
// ⑥ 将 AI 完整回复保存到会话
session.addMessage(ConversationMessage.ofAssistant(fullReply.toString()));
session.addTokensUsed(usage.getInputTokens() + usage.getOutputTokens());
session.autoGenerateTitle();
sessionRepository.save(session);
try {
emitter.send(SseEmitter.event().name("done")
.data("{\"turnCount\":" + session.getTurnCount()
+ ",\"inputTokens\":" + usage.getInputTokens()
+ ",\"outputTokens\":" + usage.getOutputTokens() + "}"));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
},
error -> {
try {
emitter.send(SseEmitter.event().name("error")
.data("{\"message\":\"" + error.getMessage() + "\"}"));
} catch (IOException ignored) {}
emitter.completeWithError(error);
}
);
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
流式多轮对话 Controller:
bash
/**
* POST /api/v1/conversation/stream/chat
* 流式多轮对话接口
*/
@PostMapping(value = "/stream/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamChat(@RequestBody @Validated ConversationRequest request) {
return clawStreamService.streamConversationChat(request);
}
十二、单元测试
bash
package com.example.openclaw_demo.service;
import com.example.openclaw_demo.config.SessionProperties;
import com.example.openclaw_demo.domain.ConversationMessage;
import com.example.openclaw_demo.domain.ConversationSession;
import com.example.openclaw_demo.dto.ConversationRequest;
import com.example.openclaw_demo.dto.ConversationResponse;
import com.example.openclaw_demo.repository.SessionRepository;
import com.example.openclaw_demo.service.impl.ConversationServiceImpl;
import com.example.openclaw_demo.util.ContextTrimmer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ConversationServiceTest {
@Mock
private ClawChatService clawChatService;
@Mock
private SessionRepository sessionRepository;
@Mock
private SessionProperties sessionProperties;
@InjectMocks
private ConversationServiceImpl conversationService;
@BeforeEach
void setUp() {
when(sessionProperties.getMaxHistoryTurns()).thenReturn(20);
when(sessionProperties.getMaxContextTokens()).thenReturn(40000);
when(sessionProperties.getMaxSessionPerUser()).thenReturn(10);
when(sessionRepository.findSessionIdsByUserId(any())).thenReturn(List.of());
}
@Test
void testNewSession_isCreatedWhenNoSessionId() {
// Given
ConversationRequest req = new ConversationRequest();
req.setUserId("user_001");
req.setMessage("你好");
when(clawChatService.chatWithMessages(any()))
.thenReturn(mockChatResponse("你好!有什么可以帮你?"));
// When
ConversationResponse resp = conversationService.chat(req);
// Then
assertThat(resp.isNewSession()).isTrue();
assertThat(resp.getSessionId()).isNotBlank();
assertThat(resp.getContent()).isEqualTo("你好!有什么可以帮你?");
assertThat(resp.getTurnCount()).isEqualTo(1);
verify(sessionRepository, times(1)).save(any(ConversationSession.class));
}
@Test
void testContinueSession_contextIsPreserved() {
// Given:已有会话,包含一轮历史
ConversationSession existingSession = ConversationSession.create("user_001", "");
existingSession.addMessage(ConversationMessage.ofUser("我叫张三"));
existingSession.addMessage(ConversationMessage.ofAssistant("你好,张三!"));
when(sessionRepository.findById(existingSession.getSessionId()))
.thenReturn(Optional.of(existingSession));
when(clawChatService.chatWithMessages(any()))
.thenReturn(mockChatResponse("你叫张三。"));
ConversationRequest req = new ConversationRequest();
req.setUserId("user_001");
req.setSessionId(existingSession.getSessionId());
req.setMessage("我叫什么名字?");
// When
ConversationResponse resp = conversationService.chat(req);
// Then
assertThat(resp.isNewSession()).isFalse();
assertThat(resp.getContent()).isEqualTo("你叫张三。");
assertThat(resp.getTurnCount()).isEqualTo(2);
}
@Test
void testAccessControl_throwsWhenUserMismatch() {
// Given:会话属于 user_001
ConversationSession session = ConversationSession.create("user_001", "");
when(sessionRepository.findById(session.getSessionId()))
.thenReturn(Optional.of(session));
// user_002 尝试访问 user_001 的会话
ConversationRequest req = new ConversationRequest();
req.setUserId("user_002");
req.setSessionId(session.getSessionId());
req.setMessage("你好");
// Then:应该抛出异常
assertThatThrownBy(() -> conversationService.chat(req))
.hasMessageContaining("无权访问此会话");
}
@Test
void testContextTrimmer_trimsOldMessages() {
// Given:构造 25 轮历史(超过 maxHistoryTurns=20)
ConversationSession session = ConversationSession.create("user_001", "");
for (int i = 0; i < 25; i++) {
session.addMessage(ConversationMessage.ofUser("问题" + i));
session.addMessage(ConversationMessage.ofAssistant("回答" + i));
}
// When
List<ConversationMessage> trimmed = ContextTrimmer.trim(session, 20, 100000);
// Then:trimmed 后最多 20 轮 = 40 条消息(20 user + 20 assistant)
long assistantCount = trimmed.stream()
.filter(m -> "assistant".equals(m.getRole())).count();
assertThat(assistantCount).isLessThanOrEqualTo(20);
}
// 工具方法:构造 Mock 响应
private com.example.openclaw_demo.dto.ChatResponseDTO mockChatResponse(String content) {
return com.example.openclaw_demo.dto.ChatResponseDTO.builder()
.content(content)
.model("claude-sonnet-4-20250514")
.inputTokens(50).outputTokens(30).elapsedMs(1000)
.build();
}
}
十三、完整项目结构
bash
openclaw-demo/
├── src/main/java/com/example/openclaw_demo/
│ │
│ ├── config/
│ │ ├── ClawProperties.java
│ │ ├── RedisConfig.java ★ 新增:Redis JSON 序列化
│ │ ├── SessionProperties.java ★ 新增:会话配置
│ │ └── StreamConfig.java
│ │
│ ├── domain/ ★ 新增包
│ │ ├── ConversationMessage.java ★ 消息领域对象
│ │ └── ConversationSession.java ★ 会话领域对象
│ │
│ ├── repository/ ★ 新增包
│ │ ├── SessionRepository.java ★ 存储接口
│ │ └── impl/
│ │ └── RedisSessionRepository.java ★ Redis 实现
│ │
│ ├── service/
│ │ ├── ClawChatService.java (扩展:新增 chatWithMessages)
│ │ ├── ClawStreamService.java (扩展:新增 streamConversationChat)
│ │ ├── ConversationService.java ★ 新增:多轮对话接口
│ │ └── impl/
│ │ ├── ClawChatServiceImpl.java (扩展)
│ │ ├── ClawStreamServiceImpl.java (扩展)
│ │ └── ConversationServiceImpl.java ★ 新增:多轮对话实现
│ │
│ ├── controller/
│ │ ├── ChatController.java
│ │ ├── ChatStreamController.java
│ │ └── ConversationController.java ★ 新增:多轮对话 Controller
│ │
│ ├── dto/
│ │ ├── ChatRequestDTO.java
│ │ ├── ChatResponseDTO.java
│ │ ├── ConversationRequest.java ★ 新增
│ │ └── ConversationResponse.java ★ 新增
│ │
│ └── util/
│ └── ContextTrimmer.java ★ 新增:上下文裁剪工具
│
└── src/main/resources/
└── application.yml (新增 app.session.* 配置)
十四、常见问题与最佳实践
14.1 userId 如何获取?
生产环境中 userId 一般来自登录态(JWT Token 中解析),不应由前端直接传入。可以用 HandlerMethodArgumentResolver 或 Spring Security 从 Token 中统一提取:
java
// 在 Controller 中用 @AuthenticationPrincipal 替代 requestParam
@PostMapping("/chat")
public ApiResult<ConversationResponse> chat(
@RequestBody @Validated ConversationRequest request,
@AuthenticationPrincipal UserDetails userDetails) {
// 从认证上下文中获取 userId,不信任前端传入的值
request.setUserId(userDetails.getUsername());
return ApiResult.ok(conversationService.chat(request));
}
14.2 Redis 不可用时的降级
生产环境中 Redis 偶尔可能不可用,可以实现一个内存版 SessionRepository 作为降级方案:
java
@Repository
@ConditionalOnProperty(name = "app.session.storage", havingValue = "memory")
public class InMemorySessionRepository implements SessionRepository {
// 使用 ConcurrentHashMap + 定时清理
private final Map<String, ConversationSession> store = new ConcurrentHashMap<>();
// ... 实现省略
}
通过配置项切换:
bash
app:
session:
storage: redis # redis | memory
14.3 token 估算精度
本文使用的简单估算公式并不精确,如果你需要精确计数(比如用于计费),可以接入 tiktoken 或使用 Claude API 返回的实际 token 数(每次请求的 usage 字段)累加。
14.4 会话安全性
- 不要用自增 ID 作为 sessionId:攻击者可枚举,使用 UUID
- 始终校验 userId:避免 IDOR(越权访问他人会话)漏洞
- systemPrompt 不要由前端传入:防止 Prompt 注入攻击,应由后端预定义
十五、本篇总结
本篇在前三篇的基础上构建了完整的多轮对话管理系统,核心知识点回顾:
- 为什么需要应用层管理历史:Claude API 本身无状态,多轮对话靠将历史消息拼接进每次请求实现
- 领域模型设计 :
ConversationMessage(单条消息)+ConversationSession(会话对象),职责清晰 - Redis 存储 :
session:{sessionId}存会话,user:sessions:{userId}建索引,TTL 自动过期 - 上下文裁剪:双重策略------轮次裁剪(删最早的整轮)+ Token 裁剪(从头删直到达标)
- 面向接口 :
SessionRepository接口化,方便切换内存/Redis/数据库存储 - 权限隔离:每次操作校验 sessionId 与 userId 的归属关系,防止越权访问
- 流式多轮:在 token 回调中收集完整回复,最终回调时统一保存到会话
- 生产细节:userId 从认证上下文获取(不信任前端)、降级策略、会话数限制
📌 如果本文对你有帮助,欢迎点赞 👍 收藏 ⭐ 关注,你的支持是我持续创作的动力!
标签 :
Spring Boot多轮对话会话管理RedisOpenClAWClaude API上下文管理Java AI