SpringAI流式对话全栈实战:后端SSE推送与前端解析
一个“SpringAI 前端”的组合很容易让人误解为随便写个HTTP接口调大模型、再拿React套个壳就行。但实际上真正有价值的工程重点在两个地方一是后端能不能把大模型的“打字机式”返回以标准SSE协议完整地推到浏览器二是前端怎么把这段连续的数据流解析、渲染成对话体验。如果你正在做毕业设计、企业内部AI助手、或者面试准备时想搞一个真正能跑的AI对话项目这篇内容可以直接照着落地。我会把工程搭建、接口设计、前端对接、踩坑排查全部拆开讲并且用实际代码说明每一步为什么这么做。1. 项目整体拆解与方案选型1.1 这个项目到底要解决什么问题用户给的是一个很宽泛的标题SpringAI流式对话(带前端)。拆开看核心目标非常明确把大模型能力集成进一个Web项目并且完成普通对话和流式输出两种交互。“带前端”这三个字看起来只是附加条件但实际决定了一个项目的完整度——你能在浏览器里看到字一个一个蹦出来而不是只能拿着curl命令去终端验证接口这两者的工程难度是完全不同的。真正的痛点往往不是“调通API”而是三件事响应模式大模型接口既支持一次性返回完整文本也支持按Token逐步返回后端如何选择、如何封装直接决定用户体验。传输协议逐字返回需要长连接HTTP轮询会造成大量无效请求SSEServer-Sent Events是目前最实用的方案但很多人对它的理解停留在“事件流”三个字上实际编码时经常踩坑。前端状态管理流式数据是一段一段被接收的如何拼接、如何去重、如何维护对话历史比普通AJAX请求复杂得多。1.2 为什么选SpringAI而不是手动拼HTTP请求有人会觉得大模型API无非就是POST一个JSON过去用HttpClient或者RestTemplate都能搞定何必引入SpringAI框架“引入活字印刷技术而非每次拿毛笔抄书”就是这个道理。SpringAI的核心价值是它把大模型调用抽象成了和Spring生态一致的编程模型整个抽象层次可以分成三块ChatModel / ChatClient统一各种大模型提供方的接口。你换模型供应商只需要改配置不用重写业务代码。结构化输出支持Entity、文档、普通文本应有尽有。Tool Calling函数调用让模型具备调用业务方法的能力这个能力在很多实际项目中非常实用。从工程维护角度看SpringAI还有一个优势它深度集成了Spring Boot的自动配置与指标监控。你可以通过Actuator观测调用量、耗时、异常率与现有运维体系无缝打通。手动拼HTTP虽然“肉眼可见的简单”但遇到超时重试、Token计费、历史记忆这类需求时代码量会迅速膨胀。1.3 整体技术方案与运行流程我采用的方案是经典的全栈分层结构既不过度设计又能较好体现流式交互的完整链路。层级技术选型承担职责前端原生HTML JavaScript渲染对话界面、发起请求、解析SSE流后端接入层Spring Boot Controller接收前端请求、调用AI接口、返回Data StreamAI集成层Spring AI Starter统一大模型接口、处理Prompt、管理Token大模型服务OpenAI兼容接口提供对话能力、按流返回内容片段整体数据流是这样的用户在页面输入问题。浏览器通过fetch发起带text/event-stream标识的请求到Spring Boot接口。Controller返回FluxStringSpring WebMVC自动将每个元素按SSE格式写入响应。浏览器端通过ReadableStream逐块读取数据并实时插入页面DOM。回答结束后前端保存完整回复到本地消息列表。这套流程比传统“提交-等待-刷新”的方案要顺畅得多用户感知到的响应延迟能缩短到百毫秒级别因为模型开始输出第一个Token时页面就能动起来不需要等全文生成完。2. 工程搭建与前置准备2.1 版本选型别盲目追新也别困在旧版SpringAI迭代速度非常快不同版本的包名和API差异很大。我建议选择一份稳定组合长期使用Spring Boot 3.3.x Spring AI 1.0.0 JDK 17。这套组合兼容性最稳官方文档和社区讨论也最丰富网上能找到的Demo基本都能直接跑。有一点必须提醒Spring AI的Maven坐标经历过几次变化。在1.0.0版本中OpenAI的Starter名为spring-ai-openai-spring-boot-starter在更早的0.8.x版本中由于OpenAI模型列表调整配置项的默认值不同。所以网上那些教程如果与你使用的版本不完全一致不要硬套优先看官方文档对应版本的迁移说明。2.2 构建基础工程结构我推荐使用Spring Initializr生成骨架也可以直接手写Maven工程。无论哪种方式最终的依赖结构如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.3.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId /dependency /dependencies注意Spring AI 1.0.0 需要在其BOM中声明版本号否则依赖解析会报错。需要在dependencyManagement里添加dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement不写BOM会出现Unable to find dependency问题这就是第一次搭建项目最常见的报错。2.3 配置文件密钥与模型参数在src/main/resources/application.yml中写入spring: ai: openai: api-key: ${OPENAI_API_KEY} base-url: ${OPENAI_BASE_URL} chat: options: model: gpt-4o-mini temperature: 0.7 max-tokens: 1024环境变量这里能很好解决密钥泄密问题不要把真实API Key直接写在配置文件里。部署时在服务器环境变量中注入值即可。关于base-url各家服务商包括企业私有化部署的网关会提供不同的API地址按实际接入信息填写即可。另外temperature控制输出的随机性。做通用对话机器人时0.7是一个比较平衡的值既不过于机械也不至于天马行空。如果是客服问答类场景建议降到0.2以下确保回答更可预测。3. 后端核心从同步对话到流式输出3.1 先跑通同步对话接口为了保证基础功能可用先实现一个最简单的同步接口这也是整个项目的“最小可行性验证”。同步接口的核心API是chatModel.call(prompt)它会等待大模型生成完整回答后一次性返回。RestController RequestMapping(/api/chat) public class ChatController { private final ChatModel chatModel; public ChatController(ChatModel chatModel) { this.chatModel chatModel; } GetMapping(/sync) public String chat(RequestParam String message) { return chatModel.call(message); } }启动项目后访问http://localhost:8080/api/chat/sync?message你好就能看到完整回复。这一步跑通的意义在于验证三件事依赖是否正确、API密钥是否有效、网络链路是否正常。如果连同步接口都报错后面流式优化就不用谈排查浪费的时间更不值得。3.2 流式返回的核心原理Flux与SSE流式对话的基础是响应式编程中的Flux。你可以把它理解为一个“数据管道”源源不断地往外推数据。Spring AI的chatModel.stream(prompt)方法返回的就是FluxString每个元素是模型返回的一个文本片段前端拿到这些片段后逐个渲染。但Spring框架并不会自动把这个Flux变成浏览器能识别的SSE格式需要接口声明返回类型为text/event-stream。Spring WebMVC在处理FluxString并配合produces MediaType.TEXT_EVENT_STREAM_VALUE时会把每个元素按SSE协议写成data: 这是第一个片段 data: 这是第二个片段浏览器端的EventSource或者fetch的ReadableStream都可以解析这种格式。这里有个很关键的区别SSE是单向的只能服务器推送给浏览器如果你的需求是浏览器实时推送音频流给服务器那才需要WebSocket。对话场景完全用不到WebSocket。3.3 流式接口完整实现RestController RequestMapping(/api/chat) public class ChatController { private final ChatModel chatModel; public ChatController(ChatModel chatModel) { this.chatModel chatModel; } GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(RequestParam String message) { return chatModel.stream(message); } }就这几行代码核心功能已经出来了。chatModel.stream(message)返回的FluxString会随着模型生成过程逐个发射数据片段。前端收到一个片段就往界面上追加一次。但我个人建议在实际项目中用ChatClient而不是直接用ChatModel因为ChatClient提供了更加丰富的APIRestController RequestMapping(/api/chat) public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient builder.build(); } GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(RequestParam String message) { return chatClient.prompt() .user(message) .stream() .content(); } }这两种写法在当前版本都能正常运行但ChatClient的扩展性更强。它天然支持添加系统提示词、历史消息、Tool调用是后续做复杂业务推荐的入口。有一点容易忽略直接返回FluxString时如果模型在调用过程中抛异常流会被异常终止而前端只能看到连接断开。一个稳妥的做法是加上错误兜底把异常信息也作为一段文本返回给用户展示GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(RequestParam String message) { return chatClient.prompt() .user(message) .stream() .content() .onErrorResume(e - Flux.just(【系统提示】请求异常 e.getMessage())); }3.4 跨域配置增加前后端分离的可能性我做这个项目时直接用Spring Boot的静态页面托管前端放在src/main/resources/static目录下这样不存在跨域问题。如果你打算把前端拆成单独的Vue工程独立部署就必须解决跨域。推荐采用Spring的WebMvcConfigurer配置跨域规则而不是在每个接口上加CrossOrigin注解全局统一管理更清晰Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, OPTIONS) .allowedHeaders(*) .allowCredentials(true); } }作为一个提醒allowedOrigins不要用*的同时还开启allowCredentials(true)浏览器会直接拒绝。要么明确指定域名要么不携带Cookie这个细节非常容易让新手卡半天。4. 前端让对话真正“流”起来4.1 页面结构设计前端页面不需要复杂框架用一个HTML文件就能完整演示。我把它拆成以下结构顶部标题栏显示“AI对话助手”。中间消息区纵向排列用户消息与AI回复采用左右分栏。底部输入区文本输入框加发送按钮。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleSpringAI 流式对话/title style body { font-family: Microsoft YaHei, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; } #chat-box { border: 1px solid #ddd; height: 500px; overflow-y: auto; padding: 10px; margin-bottom: 10px; } .msg { margin: 8px 0; padding: 8px 12px; border-radius: 8px; } .user { background: #e3f2fd; text-align: right; } .ai { background: #f5f5f5; white-space: pre-wrap; } /style /head body h2SpringAI 流式对话/h2 div idchat-box/div input idinput typetext placeholder输入你的问题按回车发送 stylewidth: 80%; padding: 8px; button idsend发送/button script srcapp.js/script /body /html4.2 fetch流式读取核心逻辑解析前端最核心的代码是使用fetch读取流式响应。这里有个选择是用EventSource还是fetchEventSource用起来更简洁但它只支持GET请求且无法自定义请求头这在需要鉴权或传递复杂参数时非常受限。fetch配合ReadableStream虽然代码多一些但胜在通用性强。我项目中采用fetch方案const chatBox document.getElementById(chat-box); const input document.getElementById(input); const sendBtn document.getElementById(send); function addMessage(role, text) { const div document.createElement(div); div.className msg (role user ? user : ai); div.textContent text; chatBox.appendChild(div); chatBox.scrollTop chatBox.scrollHeight; return div; } async function sendMessage() { const message input.value.trim(); if (!message) return; addMessage(user, message); input.value ; const aiDiv addMessage(ai, ); const response await fetch(/api/chat/stream?message encodeURIComponent(message), { headers: { Accept: text/event-stream } }); if (!response.ok) { aiDiv.textContent 请求失败 response.status; return; } const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; let fullText ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // SSE数据以\n\n分隔可能存在半个事件需要缓存拼接 const events buffer.split(\n\n); buffer events.pop(); for (const event of events) { const lines event.split(\n); for (const line of lines) { if (line.startsWith(data:)) { const data line.slice(5).trim(); if (data) { fullText data; aiDiv.textContent fullText; chatBox.scrollTop chatBox.scrollHeight; } } } } } } sendBtn.addEventListener(click, sendMessage); input.addEventListener(keydown, (e) { if (e.key Enter) sendMessage(); });这段代码里有几个细节值得展开TextDecoder的stream: true参数因为网络传输是按字节分块的一个中文字符可能被拆成两个chunk发送如果不加这个参数第二次解码时会因为字节不完整产生乱码。加stream: true后解码器会内部缓存不完整的字节序列等后续字节到齐再拼接输出。buffer变量SSE事件之间以空行分隔但一次网络read可能读取多个事件也可能只读取半个事件。所以需要把字符串暂存在buffer中先按\n\n分割把最后一个不完整的事件留在buffer里等下一块数据到达后继续拼接。边收边渲染收到一个数据块就立刻更新aiDiv.textContent这就是“打字机效果”的来源。整个过程不用刷新页面不需要重新发送请求。4.3 用户交互细节优化一个看起来很简单但体验影响很大的点是回车发送。我见过很多项目做这个功能时直接在keydown事件里判断e.key Enter就调用发送结果用户使用中文输入法时在拼音输入面板里按回车确认候选词也会触发发送逻辑导致话还没打完就发出去了。正确的处理方式是判断e.isComposinginput.addEventListener(keydown, (e) { if (e.key Enter !e.isComposing) sendMessage(); });isComposing为true表示当前正处于输入法组合状态此时回车是确认候选词不应触发发送。这个问题在用户使用中文聊天时几乎必现因此值得提前处理。发送按钮的防重复提交也很重要。模型流式返回可能持续几十秒如果用户狂点发送会创建多个并发SSE连接造成资源浪费。处理方案是简单加一个isPending标志位let isPending false; async function sendMessage() { if (isPending) return; isPending true; // ... 原有逻辑 isPending false; }4.4 接口加载状态与异常提示为了提升健壮性我建议在发送后立即清空输入框并在AI消息区域先显示“思考中...”之类的占位文案收到第一个数据块后再替换。如果等待之后一直没有数据说明后端接口真出了问题这时候要在界面上明确提示而不是让用户干等。5. 联调踩坑与排查实录5.1 接口通了但页面没有流式效果很多人的第一反应是后端没有配SSE但实际上最容易出错的地方在前端。我之前调试时发现接口返回到全量内容页面一次性全部显示完全没有“打字机”的感觉。排查后确认是因为Postman默认不会逐段接收SSE响应它会把整个流缓冲完成后一次展示。所以用Postman测流式接口时看不到流式效果是正常的不代表后端接口有问题。如果前端确实一次性拿到全部数据问题大概率出在代理或网关的缓冲机制上。部署在Nginx后面时Nginx默认有proxy_buffering on它会缓冲上游响应直到全部结束才发给浏览器把流式硬生生变成了非流式。解决办法是关闭该代理的缓冲location /api/ { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ; proxy_http_version 1.1; chunked_transfer_encoding on; }如果SSE响应还要经过Spring Cloud Gateway这类网关服务也要注意网关层不能默认缓冲响应体。5.2 中文乱码或首字丢失出现乱码最直接的原因是字符编码不一致。前端fetch解析时已经通过TextDecoder处理了但后端在写入SSE时如果没有明确指定字符集某些运行环境下可能按平台默认编码输出导致中文变成问号。Spring Boot 3默认的字符集配置通常没有问题不放心的话可以显式声明server: servlet: encoding: charset: UTF-8 enabled: true force: true“首字丢失”则是另一个坑。SSE事件流中Spring写出的响应有时会在事件之间附带id、event字段前端如果只处理data:前缀的行而忽略了data后面的空数据就可能丢弃一部分。建议日志打印原始响应体确认实际发送的格式后再调整解析逻辑。5.3 首字延迟时间太长首字延迟TTFB是流式对话体验的关键指标。用户发一句话如果过了三四秒页面还没动静就会觉得系统卡死。影响首字延迟的因素有两个模型端侧的响应速度选择延迟更低的模型比如某些轻量型号通常比大参数量模型首字更快。请求排队Spring Boot默认的Tomcat线程池如果被同步阻塞请求占满流式请求就会排队。建议对调用大模型的场景限制并发数并设置合理的超时时间。配置如下spring: ai: openai: chat: options: temperature: 0.7 client: connect-timeout: 10s read-timeout: 60sread-timeout不能设太短因为流式返回是持续传输的过程两个数据块之间的间隔可能达到数秒如果超时时间小于模型的思考时间连接会提前断开。5.4 流式响应中断与重连策略网络抖动时SSE连接可能意外断开。前端当前实现中reader.read()抛出异常后流会静默结束用户看到的回答是残缺的。比较完善的做法是捕获异常后展示“连接中断”并允许用户点击重试。后端也可以做一层容错在Flux管道中加入重试逻辑但这里我要说一个原则——不要盲目对流转接口整体重试。如果模型已经生成了600个Token最后100个Token网络失败整体重试会让用户重新等一遍全部内容体验更差。更好的做法是让前端记录已经显示的内容只对缺失部分发起请求。不过这个方案实现成本较高一般项目够用就行不需要过度设计。5.5 Token上限导致回答被截断max-tokens设置太小长回答会被截断。Spring AI的max-tokens默认值偏保守如果对话内容特别长模型还没写完就停了响应流结束前端看起来像“死机”。排查时可以在日志中打印完成的finish_reason字段如果出现length而不是stop就是Token溢出。设置合理的max-tokens并不是越大越好因为API费用是按Token计算的而且过大的值并不一定提升回答质量。3000左右比较适合通用聊天长文生成类任务再单独调大。5.6 上下文丢失为什么AI不记得前几轮对话很多人的第一个版本做完后都会遇到一个问题每轮对话都是独立的AI不记得用户几分钟前说过什么。这是因为当前接口每次只把当前问题发给模型模型天然没有“记忆”。要解决这个问题需要维护会话历史在每次请求时把之前的对话内容一并放入Prompt。Spring AI的ChatClient提供了很好的支持。一个简单实现是前端每轮对话后维护一个messages数组或者后端用内存存储会话数据。考虑到简单演示的场景我把会话管理的实现放在后端创建独立的Memory结构Service public class ChatSessionService { private final MapString, ListString sessionHistory new ConcurrentHashMap(); public void addMessage(String sessionId, String role, String content) { sessionHistory.computeIfAbsent(sessionId, k - new ArrayList()).add(role : content); } public ListString getHistory(String sessionId) { return sessionHistory.getOrDefault(sessionId, List.of()); } public void clear(String sessionId) { sessionHistory.remove(sessionId); } }然后在前端为每次会话生成UUID发送消息时带上sessionId后端把历史拼接成Prompt再交给模型。生产环境当然要换用Redis或数据库做持久化但这段代码能直观说明会话记忆的核心原理。6. 测试与性能观察6.1 后端接口测试指南流式接口的测试要区分两类场景。第一类是接口连通性测试用curl即可curl -N -H Accept: text/event-stream http://localhost:8080/api/chat/stream?message你好-N参数禁止curl缓冲输出让数据一到就立即打印。如果看到一段一段的data:格式文本说明后端流式输出正常。curl是万能验证工具前端用不用fetch都不影响这个验证手段。第二类是自动化的单元测试。Spring AI提供了MockChatModel作为测试替身可以避免在测试中真实调用付费APITest void testStreamWithMock() { ChatClient client ChatClient.create(new MockChatModel()); FluxString result client.prompt().user(test).stream().content(); StepVerifier.create(result) .expectNextCount(1) .verifyComplete(); }6.2 页面加载与并发参数为了观察前端在真实使用中的表现我做了几个简单但直观的测量指标实测值说明首字平均延迟300ms左右依赖模型与网络链路每秒渲染Token数约50~80与模型输出速度和前端渲染开销有关并发数影响超过10个并发请求明显变慢需要后端限流生产部署要加一层限流。最简单的做法是引入Bucket4j或者直接用Resilience4j的RateLimiter限制每秒最多允许N个请求。这是防止免费开放接口被刷爆的重要防线。6.3 浏览器开发者工具的实战排查技巧流式对话的排查非常依赖浏览器Network面板。这里有个技巧打开F12点击接口请求查看“响应”标签页如果流式响应正常数据会像日志一样不断追加如果数据一次到位说明被缓冲了如果数据一直不出现优先检查后端是否真正返回text/event-stream查看响应头中Content-Type格式应该是text/event-stream;charsetUTF-8。开发者工具的“EventStream”面板如果有会将SSE单独展示非常适合观察事件结构。没有的话在Console里临时写一段fetch代码打印原始响应也是可以的。7. 扩展方向与个人经验小结7.1 从“能用”到“好用”多轮对话与Markdown渲染如果只做基本的流式文本展示项目很快就会用完。真正的对话机器人至少要再加三个能力Markdown渲染大模型返回的内容经常包含代码块、列表、表格。纯文本展示体验不佳建议前端引入marked.js或highlight.js将AI回复按Markdown渲染为富文本。对话历史管理支持新建会话、历史会话列表、删除会话这是从Demo走向产品的关键一步。停止生成用户点击“停止”按钮后前端主动断开reader.cancel()并通知后端取消本次请求。实现这个功能时前端需要保存AbortController的引用。7.2 工具调用让AI真正能“做事”Spring AI的Tool注解可以让你把任意Java方法暴露给模型作为工具这个功能非常强大。举一个实际场景Component public class WeatherTools { Tool(name getWeatherByCity, description 根据城市名称查询天气) public String getWeatherByCity(String city) { // 调用天气API或查数据库 return city 今天晴25度; } }模型在对话过程中发现用户问“北京天气如何”时会主动调用这个方法把返回结果整合进回答。这就是Function Calling能力的体现。加上这个能力后你的AI对话机器人就不只是“聊天”了而是能真实完成业务动作的智能体雏形。7.3 前端技能在AI项目中的价值再认识最近“前端被AI替代”的说法不少但我个人在做这类项目的实际体会是流式交互越是普及前端要做的事情越精细。解析SSE、处理输入法组合状态、管理并发连接、优化渲染性能这些能力不仅没有过时反而因为AI对话场景的出现变得更重要。对刚接触这个技术栈的朋友我的建议非常简单先用最小代码把流式请求跑通再逐层增加会话、工具调用等能力。不要在第一天就去追求完美的工程架构先让一个字一个字往外蹦的效果出现在浏览器里那种成就感会支撑你继续把项目打磨下去。7.4 最后分享一个小经验调通流式对话后我踩过最大的一次坑是“前后端各改一版结果谁都觉得问题在对方”。那次折腾了快两个小时最后发现是笔记本电脑的电源策略导致无线网卡休眠SSE连接断开后前端没有做自动重连用户看到的自然是“AI说到一半就不说了”。所以做流式功能一定要把“断线重连”四个字写进需求不要指望网络永远稳定。前端在连接断开2秒后自动重建请求并在界面上提示“正在重新连接”这种细节对用户体验的帮助比优化模型参数还明显。如果你也正在折腾SpringAI建议按这个顺序推进同步接口 - 流式接口 - 前端SSE解析 - 会话上下文 - 工具调用。每个阶段都有可以肉眼验证的效果单是看着AI在浏览器里像真人打字一样回复就已经值回搭建成本了。