拓冰建站拓冰建站
首页 / 资讯中心 / 正文

从零搭建AI对话系统:React+Spring Boot实现流式聊天应用

1. 从“玩具”到“产品”为什么我们要自己搭建AI对话系统最近几个月AI对话应用几乎成了每个开发者茶余饭后的谈资。无论是用现成的API快速调用还是在各种在线平台体验我们似乎已经习惯了“拿来就用”。但不知道你有没有过这样的感觉用别人的接口功能是有了但总感觉隔着一层纱。界面风格改不了对话逻辑定死了数据流向不透明想加个简单的用户历史记录或者做个个性化回复调整都无从下手。更别提那些涉及到私有数据、需要特定业务逻辑集成的场景了现成的方案往往显得笨重且不灵活。这就是我决定动手从零开始搭建一个AI对话系统的初衷。它不仅仅是一个调用API的“玩具”而是一个可以完全掌控、深度定制、并能无缝集成到现有业务中的“产品级”应用。通过React构建灵动的前端交互界面再通过Spring Boot打造稳健可靠的后端服务我们将亲手打通从用户输入到AI回复再到数据持久化的完整链路。这个过程你会深刻理解一个对话系统背后的状态管理、流式响应、上下文处理、错误边界等核心概念这些知识远比单纯调用一个chat.completions.create方法有价值得多。本系列的第一篇我们将聚焦于最核心的骨架搭建。目标是实现一个最简可行版本一个能打字、能发送、能接收并显示AI流式回复的聊天界面以及一个能稳定转发请求、处理响应的后端服务。别小看这个“Hello World”这里面埋着新手最容易踩的坑比如前端EventSource对SSE服务器发送事件的处理、后端如何正确构造OpenAI兼容的响应流、以及前后端联调时令人头疼的CORS和代理问题。我会把这些细节掰开揉碎了讲清楚让你不仅能把项目跑起来更能明白每一个配置项背后的“所以然”。2. 技术栈选型与项目初始化为什么是React Spring Boot在开始敲代码之前花点时间聊聊选型是值得的。市面上框架那么多为什么偏偏是React和Spring Boot这个组合这背后是对于现代Web应用开发效率、生态成熟度以及学习曲线的一个综合考量。前端React TypeScript Vite Ant DesignReact的组件化思想与UI交互密集的聊天应用天生契合。每一个消息气泡、输入框、发送按钮都可以是独立的、状态清晰的组件组合起来却又能构建出复杂的交互逻辑。TypeScript的加入是为了保平安在项目初期就通过类型约束避免许多低级错误尤其是在处理AI接口返回的复杂、嵌套的JSON数据时明确的类型定义能极大提升开发体验。构建工具选择Vite而非传统的Webpack看中的是其极致的启动速度和热更新体验这对需要频繁调整UI的我们来说至关重要。UI库选用Ant Design是因为它提供了一套成熟、美观且企业级的中后台组件让我们能快速搭建出专业的前端界面而无需在CSS细节上过度耗费精力。后端Spring Boot 3.x JDK 17Spring Boot依然是Java领域构建生产级后端服务的不二之选。它“约定大于配置”的理念能让我们免于繁琐的XML配置快速搭建出一个具备完整功能Web、安全、监控等的RESTful API服务。选择最新的3.x版本和JDK 17是为了利用其更好的性能、更现代的API以及对原生编译等未来技术的支持。虽然我们这个初版项目看起来简单但Spring Boot的健壮性为后续添加用户认证、速率限制、多模型路由、对话持久化等高级功能打下了坚实的基础。初始化实战步骤首先我们初始化前端项目。打开终端执行以下命令# 使用Vite官方模板创建ReactTS项目 npm create vitelatest ai-chat-frontend -- --template react-ts cd ai-chat-frontend # 安装核心依赖 npm install # 安装UI库、HTTP客户端、流处理库 npm install antd ant-design/icons axios eventsource-parser # 安装开发依赖用于处理路径别名等可选但推荐 npm install -D types/node # 启动开发服务器验证环境 npm run dev执行成功后访问http://localhost:5173你应该能看到Vite的欢迎页面。接下来我们初始化后端项目。最便捷的方式是使用 Spring Initializr 网站生成项目骨架。在网站上选择Project: MavenLanguage: JavaSpring Boot: 3.2.x (选择当前稳定版)Project Metadata:Group:com.yournameArtifact:ai-chat-backendDependencies: 添加Spring Web和Lombok用于简化Java Bean代码。点击“Generate”下载压缩包解压后用你喜欢的IDE如IntelliJ IDEA打开。或者你也可以使用curl命令快速生成需根据Spring Initializr的API调整curl https://start.spring.io/starter.zip -d typemaven-project -d languagejava -d bootVersion3.2.5 -d baseDirai-chat-backend -d groupIdcom.example -d artifactIdaichat -d nameai-chat-backend -d dependenciesweb,lombok -o backend.zip unzip backend.zip -d ai-chat-backend cd ai-chat-backend使用IDE打开后端项目等待Maven依赖下载完成。尝试运行AiChatBackendApplication主类看到Tomcat启动在8080端口的日志说明后端环境就绪。注意前后端项目分属两个独立的文件夹和端口前端通常5173后端8080这是现代前后端分离开发的典型模式便于独立开发和部署。3. 后端核心构建一个健壮的流式代理API后端的第一要务是构建一个能够安全、可靠、高效地转发请求至AI服务提供商如OpenAI的代理接口。直接从前端调用AI服务的API存在两大问题一是暴露了敏感的API Key存在安全风险二是无法在后端进行统一的权限校验、日志记录、限流熔断等管控。我们的代理API将解决这些问题。3.1 依赖引入与配置管理首先在后端项目的pom.xml文件中我们需要添加用于发送HTTP请求的客户端依赖。Spring Boot虽然提供了RestTemplate但更现代、高效的选择是WebClient它支持响应式编程对流式数据传输尤其友好。同时我们还需要一个JSON处理库。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId /dependency接下来在application.yml或application.properties中配置关键信息。我强烈推荐使用YAML格式结构更清晰。# application.yml server: port: 8080 spring: application: name: ai-chat-backend # AI服务配置这里以OpenAI为例实际可扩展为多模型配置 ai: openai: api-key: ${OPENAI_API_KEY:your-api-key-here} # 从环境变量读取安全 base-url: https://api.openai.com model: gpt-3.5-turbo # 默认模型 timeout: 30000 # 超时时间(毫秒) # 允许跨域的前端地址开发环境 cors: allowed-origins: http://localhost:5173这里有一个关键的安全实践绝对不要将API Key硬编码在代码或配置文件中然后提交到版本库。我们使用${OPENAI_API_KEY:default-value}的语法优先从环境变量OPENAI_API_KEY中读取。在本地运行时可以在启动前设置环境变量在生产环境则通过容器或云平台的环境变量注入。3.2 定义数据模型DTO我们需要定义前后端交互以及向后端AI服务发送请求时所用的数据模型。在com.example.aichat.dto包下创建两个类。ChatRequest.java封装前端发送来的用户消息。package com.example.aichat.dto; import lombok.Data; import java.util.List; Data public class ChatRequest { // 消息列表用于支持多轮对话上下文 private ListMessage messages; // 模型名称允许前端指定可选 private String model; // 流式响应开关必须为true private boolean stream true; Data public static class Message { private String role; // user, assistant, system private String content; } }ChatResponse.java用于非流式响应错误处理等的通用返回结构。package com.example.aichat.dto; import lombok.Data; Data public class ChatResponseT { private Integer code; private String message; private T data; public static T ChatResponseT success(T data) { ChatResponseT response new ChatResponse(); response.setCode(200); response.setMessage(success); response.setData(data); return response; } public static T ChatResponseT error(Integer code, String msg) { ChatResponseT response new ChatResponse(); response.setCode(code); response.setMessage(msg); return response; } }3.3 实现流式代理控制器与服务这是后端最核心的部分。我们将创建一个Controller来接收前端请求再通过一个Service类使用WebClient将请求转发至OpenAI并将OpenAI返回的SSE流原样转发给前端。首先创建配置类WebClientConfig.java用于构建全局的WebClient实例。package com.example.aichat.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.client.WebClient; Configuration public class WebClientConfig { Value(${ai.openai.base-url}) private String openaiBaseUrl; Bean public WebClient openaiWebClient() { return WebClient.builder() .baseUrl(openaiBaseUrl) .defaultHeader(Content-Type, application/json) .build(); } }接着创建服务层OpenAIService.java处理具体的流式转发逻辑。package com.example.aichat.service; import com.example.aichat.dto.ChatRequest; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import java.nio.charset.StandardCharsets; Service public class OpenAIService { private final WebClient webClient; Value(${ai.openai.api-key}) private String apiKey; Value(${ai.openai.model}) private String defaultModel; public OpenAIService(WebClient openaiWebClient) { this.webClient openaiWebClient; } public FluxString streamChatCompletion(ChatRequest request) { // 如果请求未指定模型使用默认模型 if (request.getModel() null || request.getModel().isBlank()) { request.setModel(defaultModel); } return webClient.post() .uri(/v1/chat/completions) .header(HttpHeaders.AUTHORIZATION, Bearer apiKey) .header(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE) // 关键声明接受SSE流 .bodyValue(request) .retrieve() .bodyToFlux(String.class) // 将响应体转换为字符串流 .onErrorResume(e - { // 错误处理将异常信息包装成SSE格式返回给前端避免连接意外中断 String errorJson {\error\: {\message\: \ e.getMessage() \}}; return Flux.just(data: errorJson \n\n); }); } }关键点解析MediaType.TEXT_EVENT_STREAM_VALUE这个请求头至关重要它告诉OpenAI服务器我们需要流式响应。没有它你会收到一个完整的JSON响应然后连接关闭。bodyToFluxStringFlux是Project Reactor中的响应式流发布者代表一个包含0到N个元素的异步序列。这里我们将HTTP响应体作为一个持续的字符串流来处理。onErrorResume网络请求充满不确定性必须做好异常处理。这里我们将任何异常捕获并格式化成前端能识别的SSE数据格式data: {...}\n\n发送回去保证前端能收到错误信息并进行友好提示而不是看到一个突兀的连接中断。最后创建控制器ChatController.java暴露API给前端。package com.example.aichat.controller; import com.example.aichat.dto.ChatRequest; import com.example.aichat.service.OpenAIService; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Flux; RestController RequestMapping(/api/chat) CrossOrigin(origins ${cors.allowed-origins}) // 处理跨域请求 public class ChatController { private final OpenAIService openAIService; public ChatController(OpenAIService openAIService) { this.openAIService openAIService; } PostMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString streamChat(RequestBody ChatRequest request) { // 简单验证 if (request.getMessages() null || request.getMessages().isEmpty()) { return Flux.just(data: {\error\: {\message\: \Messages cannot be empty.\}}\n\n); } // 将流式响应直接返回给前端 return openAIService.streamChatCompletion(request); } }关键点解析produces MediaType.TEXT_EVENT_STREAM_VALUE这个注解声明该接口的响应内容类型是text/event-stream这是SSE协议的标准MIME类型。浏览器或EventSource对象识别到这个类型才会以流的方式处理响应。CrossOrigin在开发环境前端运行在localhost:5173后端在8080属于跨域请求。此注解允许来自指定源的请求。生产环境应通过网关或Nginx进行更精细的CORS控制。直接返回FluxStringSpring WebFlux会处理好背压等响应式细节将服务端产生的字符串流按照SSE格式每个消息以data:开头以两个换行符\n\n结束持续推送给客户端。至此一个具备基本错误处理和流式转发能力的后端代理就完成了。启动后端应用你可以使用Postman或curl测试这个接口看看是否能收到持续的流式数据块。4. 前端核心实现流畅的聊天界面与流式响应处理前端的目标是创建一个直观、响应迅速的聊天界面并能够稳定地接收和处理后端推送过来的SSE流实现打字机式的效果。4.1 项目结构与基础配置首先清理src/App.tsx和src/App.css的默认内容。然后我们修改vite.config.ts配置路径别名让导入更简洁。import { defineConfig } from vite import react from vitejs/plugin-react import path from path export default defineConfig({ plugins: [react()], resolve: { alias: { : path.resolve(__dirname, ./src), }, }, })在tsconfig.json中同步添加路径映射{ compilerOptions: { // ... 其他配置 baseUrl: ., paths: { /*: [src/*] } } }4.2 定义类型与工具函数在src/types/chat.ts中定义类型确保类型安全。export interface Message { id: string; role: user | assistant | system; content: string; timestamp: number; } export interface ChatRequest { messages: OmitMessage, id | timestamp[]; model?: string; stream?: boolean; }在src/utils/api.ts中创建封装好的API请求函数。注意我们这里使用原生的EventSource来接收SSE流因为它足够简单且被现代浏览器广泛支持。对于更复杂的需求如自定义请求头、错误重试可以考虑使用fetch或axios配合手动解析。import { ChatRequest } from /types/chat; const API_BASE_URL import.meta.env.VITE_API_BASE_URL || http://localhost:8080/api; export const streamChatCompletion ( request: ChatRequest, onMessage: (data: string) void, onError?: (error: Event) void, onComplete?: () void ) { // 注意EventSource 只支持 GET 请求且无法自定义Header。 // 因此对于需要POST和认证的场景这个方案不适用。我们将采用更通用的fetch方案。 // 此函数保留作为SSE基础概念的说明。 console.warn(EventSource for POST is not standard. Using fetch instead.); }; // 使用fetch实现支持POST的流式请求 export const streamChatCompletionWithFetch async ( request: ChatRequest, onMessage: (chunk: string) void, onError?: (error: string) void ) { try { const response await fetch(${API_BASE_URL}/chat/stream, { method: POST, headers: { Content-Type: application/json, }, body: JSON.stringify({ ...request, stream: true }), }); if (!response.ok || !response.body) { throw new Error(HTTP error! status: ${response.status}); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); buffer lines.pop() || ; // 最后一行可能是不完整的放回缓冲区 for (const line of lines) { if (line.startsWith(data: )) { const data line.slice(6); // 去掉data: 前缀 if (data [DONE]) { return; // 流结束 } if (data.trim()) { try { onMessage(data); } catch (e) { console.error(Error processing message:, e); } } } } } } catch (error) { console.error(Streaming request failed:, error); onError?.(error instanceof Error ? error.message : Unknown error); } };关键点解析为什么不用EventSource标准EventSource仅支持GET请求且无法设置自定义请求头如Authorization或Content-Type。我们的代理接口是POST且需要传Body因此EventSource不适用。fetchReadableStream方案我们使用Fetch API的响应体response.body它是一个ReadableStream。通过getReader()获取读取器然后在一个循环中不断读取数据块chunk。这是处理流式响应的现代标准方式。数据解析逻辑SSE格式是data: {json}\n\n。我们按\n分割接收到的文本然后检查每一行是否以data:开头。[DONE]是OpenAI流结束的标志。这里我们手动实现了简单的流解析对于生产环境可以考虑使用eventsource-parser等库来更稳健地处理边界情况。4.3 构建聊天主界面组件创建src/components/ChatInterface.tsx这是我们的核心UI组件。import React, { useState, useRef, useEffect } from react; import { Input, Button, Card, Avatar, List, Spin, message } from antd; import { SendOutlined, UserOutlined, RobotOutlined } from ant-design/icons; import { Message } from /types/chat; import { streamChatCompletionWithFetch } from /utils/api; import ./ChatInterface.css; // 稍后创建样式文件 const { TextArea } Input; const ChatInterface: React.FC () { const [messages, setMessages] useStateMessage[]([ { id: 1, role: assistant, content: 你好我是AI助手有什么可以帮你的, timestamp: Date.now() }, ]); const [inputText, setInputText] useState(); const [loading, setLoading] useState(false); const messagesEndRef useRefHTMLDivElement(null); const [currentAssistantMessage, setCurrentAssistantMessage] useState(); // 自动滚动到底部 useEffect(() { messagesEndRef.current?.scrollIntoView({ behavior: smooth }); }, [messages, currentAssistantMessage]); const handleSend async () { if (!inputText.trim() || loading) return; const userMessage: Message { id: Date.now().toString(), role: user, content: inputText.trim(), timestamp: Date.now(), }; // 1. 立即更新UI显示用户消息 setMessages((prev) [...prev, userMessage]); setInputText(); setLoading(true); setCurrentAssistantMessage(); // 开始新的回复清空当前缓存 // 2. 构建请求体 const requestMessages [...messages, userMessage].map(({ role, content }) ({ role, content })); // 3. 调用流式API try { await streamChatCompletionWithFetch( { messages: requestMessages }, (dataChunk) { // 解析后端传回的SSE数据块 try { const parsed JSON.parse(dataChunk); // OpenAI流式响应格式choices[0].delta.content const chunkContent parsed.choices?.[0]?.delta?.content; if (chunkContent) { // 累加片段形成完整的回复 setCurrentAssistantMessage((prev) prev chunkContent); } // 处理错误如API密钥错误在SSE流中也可能返回 if (parsed.error) { message.error(AI服务错误: ${parsed.error.message}); setLoading(false); } } catch (e) { console.error(Failed to parse SSE chunk:, dataChunk, e); } }, (errorMsg) { message.error(请求失败: ${errorMsg}); setLoading(false); } ); // 流式接收完毕 if (currentAssistantMessage) { const assistantMessage: Message { id: (Date.now() 1).toString(), role: assistant, content: currentAssistantMessage, timestamp: Date.now(), }; setMessages((prev) [...prev, assistantMessage]); } setCurrentAssistantMessage(); } catch (error) { message.error(发送请求时发生异常); } finally { setLoading(false); } }; const handleKeyPress (e: React.KeyboardEvent) { if (e.key Enter !e.shiftKey) { e.preventDefault(); handleSend(); } }; return ( div classNamechat-container Card titleAI对话助手 bordered{false} classNamechat-card div classNamemessages-container List dataSource{messages} renderItem{(msg) ( List.Item className{message-item ${msg.role}} List.Item.Meta avatar{ Avatar icon{msg.role user ? UserOutlined / : RobotOutlined /} style{{ backgroundColor: msg.role user ? #1890ff : #52c41a }} / } title{span classNamemessage-role{msg.role user ? 你 : AI助手}/span} description{div classNamemessage-content{msg.content}/div} / /List.Item )} / {/* 正在接收的流式消息 */} {currentAssistantMessage ( List.Item classNamemessage-item assistant List.Item.Meta avatar{Avatar icon{RobotOutlined /} style{{ backgroundColor: #52c41a }} /} title{span classNamemessage-roleAI助手/span} description{ div classNamemessage-content {currentAssistantMessage} span classNamecursor▋/span /div } / /List.Item )} div ref{messagesEndRef} / /div div classNameinput-area TextArea value{inputText} onChange{(e) setInputText(e.target.value)} onKeyDown{handleKeyPress} placeholder输入您的问题按Enter发送ShiftEnter换行... autoSize{{ minRows: 3, maxRows: 6 }} disabled{loading} / Button typeprimary icon{SendOutlined /} onClick{handleSend} loading{loading} classNamesend-button 发送 /Button /div /Card /div ); }; export default ChatInterface;创建对应的样式文件src/components/ChatInterface.css.chat-container { height: 100vh; display: flex; justify-content: center; align-items: center; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); padding: 20px; } .chat-card { width: 100%; max-width: 800px; height: 90vh; display: flex; flex-direction: column; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); } .messages-container { flex: 1; overflow-y: auto; padding: 16px; border-bottom: 1px solid #f0f0f0; margin-bottom: 16px; } .message-item { padding: 12px 0 !important; border-bottom: none !important; } .message-item.user { flex-direction: row-reverse; text-align: right; } .message-item.user .ant-list-item-meta { flex-direction: row-reverse; } .message-item.user .ant-list-item-meta-avatar { margin-left: 12px; margin-right: 0; } .message-item.user .ant-list-item-meta-content { align-items: flex-end; } .message-role { font-weight: 600; font-size: 0.9em; color: #666; } .message-content { background-color: #f9f9f9; padding: 12px 16px; border-radius: 12px; white-space: pre-wrap; word-break: break-word; font-size: 1em; line-height: 1.5; } .message-item.user .message-content { background-color: #1890ff; color: white; border-top-right-radius: 4px; } .message-item.assistant .message-content { background-color: #f0f8ff; border-top-left-radius: 4px; } .cursor { display: inline-block; width: 8px; height: 1.2em; background-color: #52c41a; margin-left: 2px; animation: blink 1s infinite; vertical-align: middle; } keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } .input-area { display: flex; gap: 12px; } .input-area .ant-input { flex: 1; } .send-button { align-self: flex-end; height: auto; }4.4 集成与运行最后在src/App.tsx中引入我们的聊天组件并设置一些全局样式。import React from react; import ChatInterface from ./components/ChatInterface; import antd/dist/reset.css; // 引入Ant Design样式 import ./App.css; function App() { return ( div classNameApp ChatInterface / /div ); } export default App;在src/App.css中添加一点全局样式* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; }现在打开两个终端分别启动前端和后端# 终端1启动后端 cd ai-chat-backend ./mvnw spring-boot:run # 或使用IDE运行 SpringBootApplication # 终端2启动前端 cd ai-chat-frontend npm run dev访问http://localhost:5173在输入框中键入问题点击发送或按Enter键。如果一切配置正确你将看到AI助手开始以流式的方式一个字一个字地“打”出回复。5. 联调必坑指南与核心原理深潜项目跑起来了但真正的挑战才刚刚开始。下面是我在搭建过程中遇到的几个典型问题及其解决方案理解它们能让你对这个系统的运作机制有更深的认识。5.1 跨域CORS问题不仅仅是加个注解虽然我们在后端Controller上加了CrossOrigin注解但这只解决了简单请求Simple Request的问题。对于流式响应text/event-stream这种非简单请求浏览器会先发送一个OPTIONS预检请求Preflight Request。如果后端没有正确处理OPTIONS请求连接依然会失败。解决方案配置全局CORS过滤器显式允许预检请求所需的头信息。// 在Spring Boot主类或一个配置类中 import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.setAllowCredentials(true); // 允许的前端起源生产环境应具体配置 config.addAllowedOriginPattern(*); // 开发环境可用*生产务必指定域名 config.addAllowedHeader(*); config.addAllowedMethod(*); // 对于EventSource或Fetch流式请求需要暴露以下头 config.addExposedHeader(Content-Type); config.addExposedHeader(Cache-Control); config.addExposedHeader(Connection); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }注意addAllowedOriginPattern(*)在开发时方便但生产环境必须替换为具体的域名如https://yourdomain.com否则会带来严重的安全风险。5.2 流式响应中断与连接保持你可能会发现对话时间长或者网络稍有波动流就断了。这是因为SSE连接默认可能受到代理服务器、负载均衡器或浏览器自身的超时设置影响。后端保活策略Spring WebFlux默认的响应超时时间可能不够长。我们可以在配置中调整并主动发送SSE注释行以:开头的行作为心跳包。# application.yml spring: webflux: # 延长响应超时时间可选具体看服务器配置 response-timeout: 30m更主动的做法是在服务端发送数据流时定期发送心跳注释public FluxString streamChatCompletion(ChatRequest request) { return webClient.post() .uri(/v1/chat/completions) // ... 其他配置 .retrieve() .bodyToFlux(String.class) .mergeWith(Flux.interval(Duration.ofSeconds(15)) // 每15秒发送一个心跳 .map(tick - : keepalive\n\n)) .onErrorResume(e - Flux.just(data: errorJson \n\n)); }心跳包 keepalive\n\n会被EventSource客户端自动忽略但能保持TCP连接活跃防止被中间设备因空闲而关闭。前端重连策略前端也需要处理意外断开。可以监听EventSource如果使用的onerror事件实现带退避机制的重连。对于我们使用的fetch方案需要在onError回调中实现重试逻辑并注意避免重复发送消息。5.3 上下文管理与对话记忆目前我们的实现是每次都将完整的消息历史发送给后端。这对于短对话没问题但对话轮次一多消耗的Token数会急剧上升导致成本增加和响应变慢。优化策略摘要式上下文当历史消息超过一定长度或轮次后不再发送原始历史而是由AI对之前的对话进行总结将总结作为新的“系统”消息再附上最近的几条消息。这需要在后端实现一个额外的摘要生成步骤。滑动窗口只发送最近N条消息丢弃更早的历史。实现简单但可能丢失重要的长期上下文。Token数限制在发送请求前计算所有消息的Token总数需要调用模型的Tokenizer或使用近似估算库如tiktoken的Java/JS版本如果超限则从最旧的消息开始移除直到满足限制。这是一个进阶话题在后续的系列文章中我们会专门探讨如何实现一个高效的对话记忆管理器。5.4 错误处理与用户体验流式响应中的错误处理比普通HTTP请求更棘手。错误可能发生在连接建立时、流传输中、或AI服务内部。我们的策略连接/网络错误在前端的streamChatCompletionWithFetch的catch块中捕获通过onError回调通知UI显示友好提示如“网络连接失败请重试”。AI服务错误如额度不足、模型不可用OpenAI的流式接口也会在流中返回格式为{error: {...}}的SSE数据块。我们在前端的onMessage回调中专门解析了parsed.error并调用message.error显示给用户。前端解析错误用try...catch包裹JSON.parse避免因意外的数据格式导致整个应用崩溃并在控制台打印错误日志供调试。一个健壮的系统必须假设所有环节都可能出错并为每一种错误设计降级或恢复方案。例如当流式请求失败时是否可以自动降级为非流式请求或者提示用户“响应可能稍慢”6. 性能优化与生产环境考量虽然我们实现了一个可用的最小版本但要将其用于生产环境还需要考虑以下方面1. 后端异步与非阻塞我们使用了Spring WebFlux和WebClient它们基于Reactor项目是异步非阻塞的。这意味着单个服务线程可以处理大量并发连接非常适合IO密集型的代理服务。确保你的所有操作数据库访问、外部服务调用都是非阻塞的否则会拖累整个系统的响应能力。2. 连接池与超时配置WebClient默认使用Reactor Netty作为客户端需要合理配置连接池大小、读写超时等参数以应对高并发场景。# application.yml spring: webflux: client: max-in-memory-size: 10MB # 响应最大内存大小 # 可以通过自定义WebClient Bean来配置更详细的超时和连接池3. 限流与熔断防止因一个用户的大量请求或AI服务响应慢而拖垮整个后端。可以集成Resilience4j或Sentinel为/api/chat/stream接口添加限流Rate Limiting和熔断器Circuit Breaker。4. 日志与监控记录每个请求的耗时、Token使用量、用户标识如果已登录。这有助于分析使用情况、排查问题和成本核算。可以使用Spring Boot Actuator暴露监控端点并集成Micrometer将指标发送到Prometheus等监控系统。5. 安全性加固API Key管理绝对不要在前端暴露任何AI服务的API Key。我们的代理架构已经解决了这个问题。用户认证与授权在生产环境/api/chat/stream接口必须受保护。可以集成Spring Security要求用户携带有效的JWT Token才能访问。输入输出过滤对用户输入进行基本的清理和长度限制防止Prompt注入攻击。对AI返回的内容也应有过滤机制尤其是面向公众的应用防止生成有害内容。6. 部署与扩展将前后端分别构建为Docker镜像。前端使用Nginx提供静态文件服务后端Spring Boot应用可以多实例部署通过Nginx或API网关进行负载均衡。考虑将对话历史存储到Redis或数据库中以实现多设备同步和长期记忆。搭建这个基础版本就像是盖房子打好了地基和主体框架。它已经具备了核心功能但离一个坚固、美观、功能齐全的“房子”还有距离。在接下来的系列文章中我们会一步步地为它“添砖加瓦”加入用户系统、实现对话持久化、集成向量数据库实现基于私有知识的问答、优化上下文管理策略、尝试不同的AI模型等等。每一个环节都会是新的挑战和收获。希望这篇手把手的指南能让你成功迈出从AI API调用者到AI应用构建者的第一步。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门