Spring AI2.0 + DeepSeek 完整版实战:上下文记忆|内存/Redis/官方ChatMemory三种方案|流式SSE|Tool工具调用|结构化输出|超时异常处理

博客简介:基于 Spring‑AI 2.0.0‑M1 + DeepSeek,从零实现一个工业可用对话服务。包含手写内存会话管理器、手写Redis会话存储、SpringAI官方ChatMemory三套上下文记忆实现、SSE流式输出、token日志打印、防上下文溢出、系统角色词分离、结构化输出、@Tool函数调用能力,为后续Agent开发打好基础
环境:JDK17 + SpringBoot3.4 + Spring‑AI2.0‑M1 + DeepSeek(OpenAI兼容协议) + Redis

摘要:本文详细介绍了基于Spring AI 2.0与DeepSeek构建工业级对话服务的完整实现方案。核心内容包括三种上下文记忆管理方案(手写内存管理器、手写Redis存储、官方ChatMemory组件)、SSE流式输出、Tool工具调用、结构化输出以及异常处理机制。文章提供了完整的代码示例和配置,涵盖了从环境搭建到生产部署的全流程,为后续Agent开发奠定坚实基础。

一、整体功能清单

  1. ✅ 独立分离System系统提示词,可全局/会话单独设置AI角色
  2. ✅ 手写内存上下文管理器,多轮短期对话、自动裁剪防止上下文溢出
  3. 手写Redis上下文存储器,手动序列化存储会话,处理异常脏数据问题
  4. ✅ Spring AI 官方 ChatMemory 组件实现上下文记忆
  5. ✅ SSE流式输出、打字机效果,打印每段token分片日志
  6. ✅ 全局超时、网络异常、模型报错统一异常处理
  7. ✅ 结构化输出(Pojo实体返回)
  8. @Tool注解,大模型主动调用Java本地方法,Agent工具调用基础
  9. ✅ AI回答结果缓存、会话隔离(不同sessionId独立对话)

二、Maven完整依赖 pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.0</version>
        <relativePath/>
    </parent>

    <groupId>com.ai</groupId>
    <artifactId>springai‑deepseek‑full‑demo</artifactId>
    <version>0.0.1‑SNAPSHOT</version>

    <properties>
        <java.version>17</java.version>
        <spring-ai.version>2.0.0-M1</spring-ai.version>
    </properties>

    <repositories>
        <repository>
            <id>spring‑milestone</id>
            <url>https://repo.spring.io/milestone</url>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
    </repositories>

    <dependencies>
        <!--web SSE-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- SpringAI openai兼容deepseek -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring‑ai‑openai‑spring‑boot‑starter</artifactId>
            <version>${spring-ai.version}</version>
        </dependency>
        <!--tool调用、chatmemory内存组件-->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring‑ai‑chat‑memory</artifactId>
            <version>${spring-ai.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!--json序列化-->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

三、application.yml配置

server:
  port: 8080
  # http请求超时
  tomcat:
    connection-timeout: 30s

spring:
  redis:
    host: 127.0.0.1
    port: 6379
    password:
    database: 0
  ai:
    openai:
      base-url: https://api.deepseek.com
      api-key: sk-xxxxxxx
      chat:
        options:
          model: deepseek-chat
          temperature: 0.7
          max-tokens: 2048
#自定义全局AI角色配置
ai:
  system-prompt: "你是一名资深Java架构师,回答简洁专业,优先给出代码方案,遇到时间查询可以调用工具函数"
  #单会话最大保存消息轮次,防止上下文溢出
  max-history-size: 8
  #redis会话过期时间 单位秒
  session-expire-second: 3600
  #模型调用超时
  llm-timeout: 25000

四、DeepSeek基础配置类(解决2.x baseUrl不生效)

com.ai.config.DeepSeekAiConfig

package com.ai.config;

import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import java.time.Duration;

@Configuration
public class DeepSeekAiConfig {

    @Value("${spring.ai.openai.base-url}")
    private String baseUrl;
    @Value("${spring.ai.openai.api-key}")
    private String apiKey;
    @Value("${spring.ai.openai.chat.options.model}")
    private String model;
    @Value("${ai.llm-timeout}")
    private Long llmTimeout;

    @Bean
    public OpenAiApi openAiApi(RestClient.Builder restClientBuilder){
        // 设置http超时,解决大模型长时间响应超时问题
        RestClient.Builder builder = restClientBuilder
                .defaultTimeout(Duration.ofMillis(llmTimeout));
        return new OpenAiApi(baseUrl,apiKey,builder);
    }

    @Bean
    public OpenAiChatModel openAiChatModel(OpenAiApi openAiApi){
        OpenAiChatOptions options = OpenAiChatOptions.builder()
                .model(model)
                .temperature(0.7)
                .build();
        return new OpenAiChatModel(openAiApi,options);
    }
}

关键点:SpringAI2.x自动配置无法修改base‑url,必须手动构建OpenAiApi;在这里统一设置模型http超时时间,拦截模型长时间无响应问题

五、公共DTO、常量、异常实体

5.1 流式请求DTO

package com.ai.dto;
import lombok.Data;
@Data
public class ChatReqDTO {
    //会话唯一id,用来隔离不同用户上下文
    private String sessionId;
    //用户提问
    private String message;
    //可选:临时覆盖全局system角色词
    private String systemPrompt;
}

5.2 消息实体,上下文存储对象

package com.ai.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.ai.chat.messages.MessageType;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class SessionMessage {
    private MessageType type;
    private String content;
}

5.3 自定义业务异常

package com.ai.exception;

public class LlmTimeoutException extends RuntimeException{
    public LlmTimeoutException(String msg){
        super(msg);
    }
}

方案一:手写内存会话管理器(内存存储上下文)

特点:进程内存保存会话,重启丢失;自带消息裁剪,防止上下文溢出,适合测试环境
com.ai.memory.InMemorySessionManager

package com.ai.memory;

import com.ai.entity.SessionMessage;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.ai.chat.messages.MessageType;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class InMemorySessionManager {

    @Value("${ai.max-history-size}")
    private Integer maxHistorySize;

    // key:sessionId  value:当前会话全部消息
    private final Map<String, List<SessionMessage>> sessionMap = new ConcurrentHashMap<>();

    /**
     * 获取会话历史
     */
    public List<SessionMessage> getHistory(String sessionId){
        return sessionMap.getOrDefault(sessionId,new ArrayList<>());
    }

    /**
     * 保存单条消息
     */
    public void addMessage(String sessionId, MessageType type,String content){
        List<SessionMessage> msgList = getHistory(sessionId);
        msgList.add(new SessionMessage(type,content));
        // 上下文溢出裁剪策略:超过最大轮次,删除最早的用户+AI一对消息
        if(msgList.size()>maxHistorySize){
            //移除头部两条(一问一答)
            msgList.remove(0);
            msgList.remove(0);
        }
        sessionMap.put(sessionId,msgList);
    }

    /**
     * 清空会话
     */
    public void clear(String sessionId){
        sessionMap.remove(sessionId);
    }
}

溢出策略说明:简单实用,当消息总数超过配置阈值,删掉最早一轮问答,控制token总量,避免请求大模型时报上下文超长错误。生产环境复杂策略可以改成token计数裁剪

方案二:手写Redis存储器存储上下文记忆

解决痛点:手动Redis存储会话;先落库再请求大模型、异常回滚脏数据,持久化会话,服务重启上下文不丢失
com.ai.memory.RedisSessionManager

package com.ai.memory;

import com.alibaba.fastjson2.JSON;
import com.ai.entity.SessionMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.ai.chat.messages.MessageType;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

@Component
public class RedisSessionManager {
    private static final String SESSION_PREFIX = "ai:session:";

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Value("${ai.session-expire-second}")
    private Long expireSecond;
    @Value("${ai.max-history-size}")
    private Integer maxHistorySize;

    /**
     * 获取会话
     */
    public List<SessionMessage> getHistory(String sessionId){
        String key = SESSION_PREFIX+sessionId;
        String json = redisTemplate.opsForValue().get(key);
        if(json==null){
            return new ArrayList<>();
        }
        return JSON.parseArray(json,SessionMessage.class);
    }

    /**
     * 保存消息
     * @param tempBackup 临时备份key,异常时用来回滚
     */
    public void addMessage(String sessionId, MessageType type, String content,String tempBackup){
        String key = SESSION_PREFIX+sessionId;
        List<SessionMessage> history = getHistory(sessionId);
        //备份修改前的数据,出现异常用来恢复,解决脏数据
        String oldData = JSON.toJSONString(history);
        redisTemplate.opsForValue().set(tempBackup,oldData,30, TimeUnit.SECONDS);

        history.add(new SessionMessage(type,content));
        //裁剪
        if(history.size()>maxHistorySize){
            history.remove(0);
            history.remove(0);
        }
        String newJson = JSON.toJSONString(history);
        redisTemplate.opsForValue().set(key,newJson,expireSecond,TimeUnit.SECONDS);
    }

    /**
     * 请求异常:回滚会话,恢复保存前状态,避免只存了用户提问,AI回答失败造成脏上下文
     */
    public void rollback(String tempBackupKey,String sessionId){
        String backup = redisTemplate.opsForValue().get(tempBackupKey);
        if(backup!=null){
            redisTemplate.opsForValue().set(SESSION_PREFIX+sessionId,backup,expireSecond,TimeUnit.SECONDS);
        }
        redisTemplate.delete(tempBackupKey);
    }

    public void clear(String sessionId){
        redisTemplate.delete(SESSION_PREFIX+sessionId);
    }
}

核心脏数据解决方案:新增消息之前先备份原会话到临时key;当调用DeepSeek接口抛出超时/网络异常时,执行rollback方法,回滚Redis会话,防止用户问题存入、AI回答失败导致上下文错乱。

方案三:Spring AI官方ChatMemory组件存储上下文

SpringAI内置开箱即用记忆组件,支持InMemoryChatMemory、RedisChatMemory(需要引入依赖),会话隔离。

//bean注册 内存版官方chatMemory
@Bean
public ChatMemory chatMemory(){
    //内存版,每个会话独立
    return new InMemoryChatMemory();
}

使用方式:

//1.读取历史消息
List<Message> history = chatMemory.get(sessionId);
//2.添加用户消息
chatMemory.add(sessionId,new UserMessage(userInput));
//3.得到ai回复后存入
chatMemory.add(sessionId,new AssistantMessage(aiAnswer));

对比三种记忆方案
|方案|优点|缺点|适用场景|
|—|—|—|—|
|手写内存管理器|完全可控,裁剪策略自定义,零第三方依赖|进程重启丢失|开发、单元测试|
|手写Redis管理器|持久化,脏数据回滚机制,高度可控|需要手动序列化、自己维护过期、裁剪|正式生产环境|
|官方ChatMemory|开箱即用,代码最少,spring生态原生|裁剪策略自由度低,redis版需要额外配置|快速原型、中小型项目|

六、Tool工具调用实现,@Tool注解 Agent基础

实现一个简单获取当前时间的工具,大模型可以自主决定是否调用Java方法
com.ai.tool.AiTools

package com.ai.tool;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Component
public class AiTools {

    @Tool(description = "获取当前系统时间,当用户询问现在几点,当前日期的时候调用")
    public String getCurrentTime(){
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        return "当前时间:"+ LocalDateTime.now().format(formatter);
    }
}

使用的时候,构建ChatModel调用时注入工具集合,大模型可以自动判断什么时候调用这个Java函数,这是Agent智能体最底层基础能力

七、结构化输出实体

强制大模型返回固定JSON结构

package com.ai.dto;
import lombok.Data;
@Data
public class ArticleResp {
    private String title;
    private String summary;
    private String content;
}

八、流式SSE控制器 + token打印 + 异常处理 + Redis手写记忆示例

com.ai.controller.StreamChatController

package com.ai.controller;

import com.ai.dto.ChatReqDTO;
import com.ai.exception.LlmTimeoutException;
import com.ai.memory.RedisSessionManager;
import com.ai.tool.AiTools;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.*;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Slf4j
@RestController
@RequestMapping("/chat")
public class StreamChatController {

    @Resource
    private ChatModel chatModel;
    @Resource
    private RedisSessionManager redisSessionManager;
    @Resource
    private AiTools aiTools;

    @Value("${ai.system-prompt}")
    private String globalSystemPrompt;

    /**
     * SSE流式接口,手写Redis上下文存储
     */
    @PostMapping(value = "/stream-redis",produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamChatRedis(@RequestBody ChatReqDTO dto){
        String sessionId = dto.getSessionId();
        String userMsg = dto.getMessage();
        //异常回滚临时备份key
        String tempBackupKey = "temp:backup:"+ UUID.randomUUID();

        return Flux.create(sink->{
            try{
                //1、读取redis历史上下文
                List<SessionMessage> history = redisSessionManager.getHistory(sessionId);
                List<Message> messages = new ArrayList<>();

                //2、优先使用传入的system词,没有就取全局
                String sysPrompt = dto.getSystemPrompt()==null?globalSystemPrompt:dto.getSystemPrompt();
                messages.add(new SystemMessage(sysPrompt));

                //3、还原历史对话
                for(SessionMessage sm : history){
                    if(sm.getType()== MessageType.USER){
                        messages.add(new UserMessage(sm.getContent()));
                    }else if(sm.getType()==MessageType.ASSISTANT){
                        messages.add(new AssistantMessage(sm.getContent()));
                    }
                }
                //4、添加当前用户提问,先保存到redis(带备份)
                redisSessionManager.addMessage(sessionId,MessageType.USER,userMsg,tempBackupKey);
                messages.add(new UserMessage(userMsg));

                //5、注册Tool工具回调
                ToolCallbackProvider toolProvider = MethodToolCallbackProvider.builder()
                        .toolObjects(aiTools).build();

                Prompt prompt = new Prompt(messages,
                        OpenAiChatOptions.builder()
                                .toolCallbacks(toolProvider.getToolCallbacks())
                                .build());

                //6、流式请求大模型
                Flux<ChatResponse> stream = chatModel.stream(prompt);
                StringBuilder fullAnswer = new StringBuilder();

                stream.subscribe(resp->{
                    //分片token打印日志
                    String chunk = resp.getResult().getOutput().getText();
                    log.info("token分片:{}",chunk);
                    fullAnswer.append(chunk);
                    sink.next(chunk);
                },error->{
                    //异常分支:执行redis会话回滚,清除刚刚存入的用户消息,防止脏数据
                    log.error("大模型调用异常",error);
                    redisSessionManager.rollback(tempBackupKey,sessionId);
                    sink.error(new LlmTimeoutException("模型调用超时或者接口异常"));
                },()->{
                    //流式正常结束,把AI完整回答存入Redis
                    redisSessionManager.addMessage(sessionId,MessageType.ASSISTANT,fullAnswer.toString(),tempBackupKey);
                    sink.complete();
                });
            }catch (Exception e){
                redisSessionManager.rollback(tempBackupKey,sessionId);
                sink.error(e);
            }
        });
    }
}

核心逻辑拆解

  1. 请求进来,先从Redis读取历史对话
  2. System提示词单独分离,支持会话级覆盖全局角色
  3. 写入用户提问前先备份,一旦后续调用DeepSeek报错,执行回滚,避免脏上下文
  4. 流式返回,实时打印每一块token分片日志
  5. 流式全部接收完毕,才把AI完整答案存入Redis
  6. 自动注入Tool工具,大模型可主动调用Java方法

九、全局异常处理器

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    @ExceptionHandler(LlmTimeoutException.class)
    public ResponseEntity<String> llmTimeout(LlmTimeoutException e){
        log.error("模型超时异常:{}",e.getMessage());
        return ResponseEntity.status(504).body("大模型服务调用超时,请稍后重试");
    }
    @ExceptionHandler(Exception.class)
    public ResponseEntity<String> globalError(Exception e){
        log.error("系统异常",e);
        return ResponseEntity.status(500).body("AI服务内部异常");
    }
}

十、三种上下文方案接口总结

  1. /chat/stream‑memory 使用手写内存管理器,适合本地调试
  2. /chat/stream‑redis 手写Redis存储、脏数据回滚,生产首选
  3. /chat/stream‑official SpringAI官方ChatMemory实现,代码最简

十一、踩坑汇总

  1. SpringAI2.x自动配置无法覆盖DeepSeek域名,必须手动构建OpenAiApi Bean
  2. 先存用户消息再调用模型,如果接口失败,必须回滚会话,否则上下文错乱
  3. 一定要限制历史消息数量,不然token溢出报错
  4. Tool调用必须通过MethodToolCallbackProvider注册工具Bean
  5. SSE流式必须捕获每一段token分片日志,方便线上排查输出异常

十二、后续Agent拓展方向

  1. PGVector向量库接入RAG知识库
  2. 多工具编排、多Agent任务分发
  3. token精确计数上下文裁剪(目前是轮次裁剪,可以升级)
  4. 对话结果全局缓存,相同问题直接返回缓存
Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐