多轮对话与会话管理:构建上下文感知的 AI 接口【OpenClAW + Spring Boot 系列 第4篇】

📅 难度:⭐⭐⭐☆☆ 进阶 | 阅读约 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 注入攻击,应由后端预定义

十五、本篇总结

本篇在前三篇的基础上构建了完整的多轮对话管理系统,核心知识点回顾:

  1. 为什么需要应用层管理历史:Claude API 本身无状态,多轮对话靠将历史消息拼接进每次请求实现
  2. 领域模型设计ConversationMessage(单条消息)+ ConversationSession(会话对象),职责清晰
  3. Redis 存储session:{sessionId} 存会话,user:sessions:{userId} 建索引,TTL 自动过期
  4. 上下文裁剪:双重策略------轮次裁剪(删最早的整轮)+ Token 裁剪(从头删直到达标)
  5. 面向接口SessionRepository 接口化,方便切换内存/Redis/数据库存储
  6. 权限隔离:每次操作校验 sessionId 与 userId 的归属关系,防止越权访问
  7. 流式多轮:在 token 回调中收集完整回复,最终回调时统一保存到会话
  8. 生产细节:userId 从认证上下文获取(不信任前端)、降级策略、会话数限制

📌 如果本文对你有帮助,欢迎点赞 👍 收藏 ⭐ 关注,你的支持是我持续创作的动力!

标签Spring Boot 多轮对话 会话管理 Redis OpenClAW Claude API 上下文管理 Java AI

相关推荐
用泥种荷花2 小时前
我把一次小程序像素风改版,沉淀成了一个可复用的 Trae Skill
人工智能
deephub2 小时前
【无标题】
人工智能·prompt·大语言模型·claude
数字供应链安全产品选型2 小时前
从模型投毒到提示词注入:悬镜安全如何用AI原生安全体系覆盖AI攻击全链路?
人工智能·安全·ai-native
FluxMelodySun2 小时前
机器学习(三十四) 概率图模型-马尔可夫随机场与条件随机场
人工智能·机器学习
用户5191495848452 小时前
Windows Hypervisor 分区漏洞利用与 IOCTL 通信测试工具
人工智能·aigc
CHENKONG_CK2 小时前
智流链驱动 RFID 混流装配,赋能汽车精益生产
网络·人工智能·tcp/ip·自动化·射频工程·rfid
天天代码码天天2 小时前
C# OnnxRuntime 部署 DAViD 深度估计
人工智能·david 深度估计
qyz_hr2 小时前
2026年AI招聘选购:5大品牌核心差异对比(红海云 / Moka / 北森 / 肯耐珂萨 / 金蝶)
大数据·人工智能
俊哥V2 小时前
每日 AI 研究简报 · 2026-04-20
人工智能·ai