☰
Spring Boot + React 全栈实战:DeepSeek AI 对话系统源码拆解与部署
2026/10/14 22:52:54 网站建设 项目流程

简介:这份资源是面向AI开发入门者与Java、前端开发人员的DeepSeek对话系统完整源码,采用Spring Boot 2.7后端与React 18前端分离架构,配合TypeScript、Ant Design与OkHttp3实现与DeepSeek大模型的无缝对接,帮助缺乏大模型经验的开发者快速搭建专属AI助手。压缩包共30个文件,约200KB,以11个Java后端源码、4个tsx与4个ts前端组件、3个JSON配置及CSS样式、XML、properties等为主,结构精简、层次清晰,便于按模块阅读与二次开发。系统已实现AI聊天、Markdown渲染、代码高亮与一键复制回复等关键功能,并附带详细安装文档与API密钥申请指南,读者可据此理解前后端交互流程、接口调用方式与界面组件组织思路,完成从环境配置到功能扩展的完整实践。目前已有218人学习关注,适合作为大模型应用开发的练手项目。

1. 从一份能跑起来的对话系统源码说起:为什么我盯上了这套 Spring Boot + React 组合

上周有个做企业内训的朋友找我,说想搭一个内部用的 AI 对话工具,要求能私有化部署、能对接大模型 API、前端别太丑、后端别太脆。他试过几个开源方案,要么是 Python 全家桶部署起来依赖打架,要么是前端就一个光秃秃的输入框,连会话列表都没有。我听完直接给他推了这套DeepSeek AI对话系统完整源码——Spring Boot 后端 + React 前端的一键部署包。

这套东西的定位很明确:它不是教你从零训练模型,也不是什么大厂内部架构,而是一个能直接跑起来的对话系统脚手架。后端用 Spring Boot 把会话管理、消息流转、模型调用、流式输出这些脏活累活全包了,前端用 React 做了个像模像样的聊天界面,支持多轮对话、会话历史、Markdown 渲染。你拿到手改改配置就能接自己的模型服务,适合想快速验证 AI 对话产品形态的团队,也适合后端想补前端、前端想学后端的人拿来做全栈练手项目。

我花了一个周末把它拆了一遍,从依赖版本到流式接口的实现细节都过了一遍。下面把我踩过的坑、调过的参数、以及怎么把它改成你自己的东西,按顺序讲清楚。

2. 拆开看骨架:Spring Boot 后端到底管了哪些事

2.1 分层结构与核心依赖

这套源码的后端是标准的 Spring Boot 分层结构,但它在传统 Controller-Service-Mapper 之上加了一层AI 调用适配层。我先把目录结构拉出来看:

src/main/java/com/example/chat/ ├── controller/ # 对外 HTTP 接口 │ ├── ChatController.java │ └── SessionController.java ├── service/ # 业务逻辑 │ ├── ChatService.java │ └── SessionService.java ├── adapter/ # AI 模型适配层(关键) │ ├── AiClient.java │ └── DeepSeekClient.java ├── model/ # 实体与 DTO │ ├── entity/ │ └── dto/ ├── config/ # 配置类 │ ├── WebConfig.java │ └── AiProperties.java └── repository/ # 数据访问

重点在adapter这一层。它定义了一个AiClient接口,把「发消息给模型、拿回复」这件事抽象出来。DeepSeekClient是默认实现,走的是 OpenAI 兼容的/v1/chat/completions接口。这意味着你如果想换成别的模型服务,只要新写一个实现类,在配置里切换一下就行,不用动业务代码。

依赖方面,pom.xml里几个关键项:

依赖版本作用
spring-boot-starter-web3.xWeb 容器与 REST
spring-boot-starter-webflux3.x流式响应(SSE)
spring-boot-starter-data-jpa3.x会话与消息持久化
mysql-connector-j8.x数据库驱动
lombok1.18.x简化实体类
okhttp4.x调用模型 HTTP 接口

这里有个容易翻车的点:WebFlux 和 WebMVC 同时引入时,Spring Boot 默认启动的是 WebMVC。但流式输出需要Flux<String>或SseEmitter,如果你用SseEmitter其实不需要 WebFlux,用Flux才需要。源码里两种都写了,实际生效的是SseEmitter方案,WebFlux 的依赖可以注释掉,能省不少启动时间。

2.2 流式对话接口的实现与参数

对话的核心接口在ChatController里,路径是POST /api/chat/stream。它接收一个 JSON body,返回text/event-stream。我把它简化后的关键代码贴出来:

@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(@RequestBody ChatRequest request) { // 超时时间设 0 表示不超时,由模型侧控制 SseEmitter emitter = new SseEmitter(0L); // 异步处理,避免阻塞 Tomcat 线程 executor.execute(() -> { try { // 1. 保存用户消息 sessionService.saveUserMessage(request.getSessionId(), request.getContent()); // 2. 调用模型适配层,逐块回调 aiClient.streamChat(request.getSessionId(), request.getContent(), chunk -> { try { emitter.send(SseEmitter.event() .data(chunk, MediaType.TEXT_PLAIN)); } catch (IOException e) { emitter.completeWithError(e); } }); // 3. 流结束,保存完整回复 emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }

逻辑说明:SseEmitter是 Spring MVC 对 Server-Sent Events 的封装,比 WebFlux 的Flux更轻量。executor是一个自定义线程池,必须配,否则 Tomcat 的默认 200 线程很快会被长连接占满。aiClient.streamChat的第三个参数是一个Consumer<String>,每收到一个模型返回的 token 块就回调一次。

参数方面,ChatRequest里有两个字段值得注意:

  • sessionId:会话标识,为空时后端会自动生成一个 UUID 并返回给前端。前端拿到后存在localStorage里,下次打开页面自动带上。
  • content:用户输入文本。源码里没做长度限制,实际部署时建议在AiProperties里加一个maxInputLength,默认 2000 字符,超了直接返回 400。

模型调用的参数在DeepSeekClient里硬编码了几个,我建议全部抽到配置文件:

ai: base-url: https://api.deepseek.com api-key: ${AI_API_KEY} model: deepseek-chat temperature: 0.7 max-tokens: 2048 timeout-seconds: 60

temperature控制随机性,做客服场景调到 0.3 更稳,做创意写作调到 1.0。max-tokens别设太大,2048 够大多数对话用,设到 8192 容易触发模型侧的超时。

2.3 会话持久化的表设计与清理策略

JPA 自动建表会生成两张核心表:chat_session和chat_message。我看了下实体定义,chat_message里有个role字段,枚举值是user、assistant、system。这里有个细节:system 消息只在会话创建时插入一条,后续对话不会重复插入,这是对的,否则每次请求都带一遍系统提示词,token 消耗翻倍。

清理策略源码里没做,我建议加一个定时任务:

@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点 public void cleanExpiredSessions() { // 删除30天未活跃的会话及其消息 LocalDateTime expireTime = LocalDateTime.now().minusDays(30); sessionRepository.deleteByLastActiveTimeBefore(expireTime); }

注意deleteByLastActiveTimeBefore要配合@Transactional使用,否则 JPA 会报TransactionRequiredException。另外外键约束要设ON DELETE CASCADE,不然删会话时消息表会报错。

3. 前端不止是壳:React 侧的流式渲染与状态管理

3.1 SSE 连接与消息拼接

前端用的是 React 18 + Vite,没有用 Next.js。聊天核心逻辑在useChat这个自定义 Hook 里。它没有用EventSource,而是用了fetch+ReadableStream,原因是EventSource只能发 GET 请求,没法带 JSON body。

const sendMessage = async (content) => { const response = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId, content }) }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let assistantMessage = ''; while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); // SSE 格式是 "data: xxx\n\n",需要剥掉前缀 const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data:')) { const text = line.slice(5).trim(); if (text) { assistantMessage += text; // 触发 React 重渲染 setMessages(prev => updateLastMessage(prev, assistantMessage)); } } } } };

逻辑说明:decoder.decode(value, { stream: true })里的stream: true很关键,不加的话中文会被截断成乱码。updateLastMessage是一个不可变更新函数,它把最后一条 assistant 消息的内容替换成累积后的assistantMessage。这里有个性能坑:每收到一个字符就 setState 一次,React 会疯狂重渲染。源码里没做节流,我加了一个 50ms 的requestAnimationFrame节流,体感流畅很多。

3.2 会话列表与本地缓存策略

左侧会话列表的数据来源有两个:一是后端GET /api/session/list返回的持久化会话,二是localStorage里缓存的当前会话 ID。源码里的逻辑是:页面加载时先读localStorage的currentSessionId,如果有就拉取该会话的消息;如果没有就创建一个新会话。

这里有个边界情况:用户清了浏览器缓存但后端还有会话记录。源码没处理这种情况,导致会话列表是空的但数据库里有数据。我的改法是:页面加载时先调GET /api/session/list,如果返回非空且localStorage里没有currentSessionId,就默认选中列表第一条。

useEffect(() => { const init = async () => { const sessions = await fetchSessions(); const cachedId = localStorage.getItem('currentSessionId'); if (cachedId && sessions.some(s => s.id === cachedId)) { setCurrentSessionId(cachedId); } else if (sessions.length > 0) { setCurrentSessionId(sessions[0].id); localStorage.setItem('currentSessionId', sessions[0].id); } else { const newSession = await createSession(); setCurrentSessionId(newSession.id); } }; init(); }, []);

3.3 Markdown 渲染与代码块高亮

AI 回复里经常带 Markdown,源码用了react-markdown+remark-gfm+react-syntax-highlighter。这里有个版本兼容的坑:react-markdown从 v6 升到 v8 后,renderers属性被移除了,改成了components。如果你直接抄网上的老代码,会报renderers is not a function。

正确的写法:

import ReactMarkdown from 'react-markdown'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; <ReactMarkdown components={{ code({ node, inline, className, children, ...props }) { const match = /language-(\w+)/.exec(className || ''); return !inline && match ? ( <SyntaxHighlighter style={oneDark} language={match[1]} PreTag="div" {...props}> {String(children).replace(/\n$/, '')} </SyntaxHighlighter> ) : ( <code className={className} {...props}>{children}</code> ); } }} > {content} </ReactMarkdown>

注意inline这个 prop 在 v8 里也被废弃了,新版本用node.tagName === 'code'来判断。如果你不想折腾,锁死react-markdown@8.0.7这个版本,API 最稳定。

4. 一键部署的真相:Docker Compose 编排与三个必改配置

4.1 docker-compose.yml 逐行拆解

源码根目录有个docker-compose.yml,定义了三个服务:mysql、backend、frontend。我把它精简后贴出来,并标注了每个参数的作用:

version: '3.8' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123456 # 生产环境必须改 MYSQL_DATABASE: chat_db ports: - "3306:3306" volumes: - mysql_data:/var/lib/mysql command: --default-authentication-plugin=mysql_native_password # 上面这行是为了兼容旧版客户端,MySQL 8 默认用 caching_sha2_password backend: build: ./backend ports: - "8080:8080" environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/chat_db?useSSL=false SPRING_DATASOURCE_USERNAME: root SPRING_DATASOURCE_PASSWORD: root123456 AI_API_KEY: ${AI_API_KEY} # 从宿主机环境变量注入 depends_on: - mysql frontend: build: ./frontend ports: - "3000:80" depends_on: - backend volumes: mysql_data:

逻辑说明:depends_on只保证启动顺序,不保证 MySQL 已经初始化完成。后端启动时如果连不上数据库会直接挂掉。稳妥的做法是在后端加一个重试逻辑,或者用wait-for-it.sh脚本。源码里没加,我建议在backend的entrypoint里加一句sleep 10,简单粗暴但有效。

4.2 必须改的三个配置项

第一个:API Key 的注入方式。源码里AI_API_KEY是从环境变量读的,但docker-compose.yml里写的是${AI_API_KEY},这意味着你需要在宿主机先export AI_API_KEY=sk-xxx,或者在同目录建一个.env文件。我见过有人直接把 key 写死在application.yml里然后提交到 Git,这是血泪教训。

第二个:数据库密码。root123456这个密码在源码里出现了三次(compose 文件、后端配置、初始化 SQL),改的时候要全局搜一遍,漏一个就连不上。

第三个:前端 API 代理地址。前端开发环境用 Vite 的proxy把/api转发到localhost:8080,但生产环境(Nginx)里需要配proxy_pass。源码的nginx.conf里写的是http://backend:8080,如果你改了服务名,这里也要同步改。

4.3 启动命令与验证步骤

# 1. 设置 API Key export AI_API_KEY=sk-your-key-here # 2. 构建并启动 docker-compose up -d --build # 3. 查看日志,确认后端启动成功 docker-compose logs -f backend # 看到 "Started ChatApplication in x seconds" 才算成功 # 4. 验证接口 curl -X POST http://localhost:8080/api/chat/stream \ -H "Content-Type: application/json" \ -d '{"sessionId":"test-001","content":"你好"}' # 应该看到流式的 data: 块逐条返回

如果curl返回 401,检查 API Key 是否有效;如果返回 500,看后端日志里有没有Connection refused,那是数据库没连上;如果前端页面空白,打开浏览器控制台看是不是Failed to fetch,那是 Nginx 代理没配好。

5. 避坑与排查:五个让我加班到凌晨的坑

5.1 流式响应被 Nginx 缓冲,前端一次性收到全部内容

现象:本地开发时流式效果正常,部署到 Nginx 后变成等好几秒然后一次性弹出全部回复。

原因:Nginx 默认开启proxy_buffering,它会把后端返回的 SSE 流缓冲起来,攒够一定大小才发给浏览器。

解决:在 Nginx 的location /api块里加两行:

proxy_buffering off; proxy_cache off; proxy_set_header X-Accel-Buffering no;

改完nginx -s reload即可。这个坑我排查了两个小时,一度以为是后端线程池的问题。

5.2 中文乱码:SSE 的 Content-Type 少了 charset

现象:英文回复正常,中文回复变成??????。

原因:SseEmitter默认的Content-Type是text/event-stream,没有指定charset=UTF-8。部分浏览器会按 ISO-8859-1 解码。

解决:在WebConfig里加一个配置:

@Bean public WebMvcConfigurer sseConfigurer() { return new WebMvcConfigurer() { @Override public void configureMessageConverters(List<HttpMessageConverter<?>> converters) { converters.add(0, new StringHttpMessageConverter(StandardCharsets.UTF_8)); } }; }

或者更简单:在application.yml里加spring.mvc.charset=UTF-8。

5.3 长对话导致 OOM:消息表没有分页查询

现象:一个会话聊了上百轮后,后端内存飙升,最终OutOfMemoryError。

原因:SessionService.getMessages(sessionId)一次性把该会话所有消息查出来返回给前端,没有分页。

解决:改成Pageable查询,默认每页 50 条,前端滚动到顶部时加载更多。同时给chat_message表的session_id字段加索引,否则查询会全表扫描。

5.4 模型返回超时:OkHttp 默认 10 秒不够用

现象:长回复生成到一半突然断开,前端收到net::ERR_INCOMPLETE_CHUNKED_ENCODING。

原因:DeepSeekClient里用的OkHttpClient默认readTimeout是 10 秒,而模型生成 2000 token 可能需要 30 秒以上。

解决:在构建OkHttpClient时显式设置超时:

new OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) // 关键 .writeTimeout(30, TimeUnit.SECONDS) .build();

注意readTimeout是两次数据包之间的间隔超时,不是整个请求的总超时,所以设 120 秒是安全的。

5.5 前端重复发送:React StrictMode 导致 useEffect 执行两次

现象:开发环境下,每次发消息后端收到两条一样的请求。

原因:React 18 的StrictMode在开发模式下会故意双调用useEffect,用来暴露副作用清理问题。

解决:在useEffect里加一个AbortController,组件卸载时取消请求:

useEffect(() => { const controller = new AbortController(); fetchData({ signal: controller.signal }); return () => controller.abort(); }, []);

生产构建下StrictMode不会双调用,所以这个坑只在开发时出现,但如果不处理,调试时会很迷惑。

6. 进阶改造:把默认模型换掉并加一层本地缓存

6.1 新增一个模型适配器

假设你要接一个内部部署的模型服务,接口格式和 OpenAI 不一样。你只需要实现AiClient接口:

@Component("internalAiClient") public class InternalAiClient implements AiClient { @Override public void streamChat(String sessionId, String content, Consumer<String> onChunk) { // 1. 从数据库拉取该会话的历史消息,拼成 context List<ChatMessage> history = messageRepository.findBySessionIdOrderByCreateTime(sessionId); // 2. 构造内部模型要求的请求体 Map<String, Object> body = new HashMap<>(); body.put("query", content); body.put("history", history.stream().map(this::toInternalFormat).toList()); // 3. 发 HTTP 请求,逐行读取流式响应 // ... 省略具体 HTTP 调用代码 // 4. 每解析出一个 token,回调 onChunk onChunk.accept(token); } }

然后在application.yml里切换:

ai: client: internalAiClient # 对应 @Component 里的 bean 名称

再在AiClient的注入处加@Qualifier或者用Map<String, AiClient>自动按名称注入。这样你不动一行 Controller 代码就能换模型。

6.2 加一层 Redis 缓存减少重复调用

同一个问题被不同用户问到时,没必要每次都调模型。我加了一个简单的缓存层:

@Autowired private RedisTemplate<String, String> redisTemplate; public String getCachedResponse(String question) { String key = "chat:cache:" + DigestUtils.md5Hex(question); return redisTemplate.opsForValue().get(key); } public void cacheResponse(String question, String answer) { String key = "chat:cache:" + DigestUtils.md5Hex(question); // 缓存 1 小时,避免长期占用内存 redisTemplate.opsForValue().set(key, answer, 1, TimeUnit.HOURS); }

在ChatService里先查缓存,命中就直接返回,没命中再调模型。注意只缓存完全相同的 question,不要做语义相似度匹配,否则容易返回错误答案。缓存 key 用 MD5 是为了避免问题文本里有特殊字符导致 Redis key 非法。

6.3 验证改造是否生效

改完之后别急着上线,按这个顺序验一遍:

验证项方法预期结果
模型切换调/api/chat/stream,看后端日志日志里出现InternalAiClient字样
缓存命中同一个问题连问两次第二次响应时间明显缩短,日志无模型调用记录
缓存过期等 1 小时后重问重新调模型,日志出现调用记录
流式正常浏览器 Network 面板看 EventStream数据逐块到达,不是一次性

最后说个习惯:我每次改完这类对话系统,都会用一个 200 轮的长会话跑一遍压力测试,观察内存和响应时间曲线。有一次就是靠这个发现消息表没加索引,查询时间从 50ms 涨到 3 秒。从那以后我每次加新表都强制走一遍EXPLAIN,确认索引命中才提交。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询