资讯详情

Spring AI ChatClient流式响应实战:告别死等,提升大模型对话体验

📅 2026/10/10 3:21:32 | 华诺云谱 👁 阅读
Spring AI ChatClient流式响应实战:告别死等,提升大模型对话体验
做 AI 对话功能第一版几乎都是从“等一个完整字符串”开始的。我之前接手过一个客服知识库模拟项目把 ChatClient 接上大模型后前端一直用最老实的做法——读完整个 HTTP 响应再一次性把答案渲染出来。用户点完发送短则八九秒、长则十几秒的转圈接着屏幕上猛地冒出一长段文字。用户不止一次反馈“是不是卡死了”“是不是没接收到”。其实模型本身没那么慢是我压根没把 ChatClient 的流式响应用起来。后来改成流式输出首屏大概 700 毫秒就开始出字那种“死等”的焦虑感立刻消失了。这篇内容就是围绕 ChatClient 与流式响应做的完整实操记录。我会从体验差异、底层原理、接口落地、边界问题和真实踩坑几个方向拆开讲。如果你正打算给聊天功能接入流式输出或者已经在接但遇到奇奇怪怪的显示问题这篇应该能帮你省下一整天的排查时间。1. 没有流式响应的聊天体验只有及格线1.1 全量返回到底差在哪很多刚接触大模型接入的人会对“流式”有个误解认为流式只是让 UI 好看一点属于锦上添花。但我测过同一个模型、同一套提示词、同一个网络环境下的两种情况差距非常明显。全量返回时用户要等模型把整段回答生成完网络层再一次性把数据传回前端。一个 500 字左右的回答从点击发送到看到完整内容普遍要 8 到 12 秒。这 8 到 12 秒里用户面对的是空白的聊天窗口或者一个转圈图标没有任何反馈机制让他判断系统是否还在工作。一旦超过 3 秒人的焦虑感就会显著上升超过 5 秒部分用户会开始重复点击发送造成并发请求堆积。流式响应的意义不只是“把等待时间省掉”而是把一段不可感知的等待过程拆成了若干个可感知的小反馈。模型生成第一句话通常只需要几百毫秒到 1 秒后续每秒钟都能看到新的文字出现。对用户来说他看到的不是“系统卡住了”而是“系统正在替我想话”这种心理体验上的差别比技术实现更影响产品评价。1.2 逐字输出背后其实是一个单向通道流式响应最常见的落地方式是 SSEServer-Sent Events中文一般叫服务端推送事件。它的本质是客户端发起一次 HTTP 请求服务端不关闭连接在同一个连接里一块一块地往客户端推数据直到所有数据推完连接才关闭。整个过程是单向的服务端到客户端。这一点和 WebSocket 完全不同。WebSocket 是双向全双工客户端和服务端都可以随时发消息。AI 聊天场景下用户只是在开始时发送一次问题后续所有内容都是模型在生成在推给用户本质上确实不需要双向通道。所以 SSE 比 WebSocket 更适合 AI 对话流式输出实现也更简单它跑在普通 HTTP 之上不需要额外的握手协议前端用原生能力就能解析。我之前遇到过有同事坚持用 WebSocket 做流式对话理由是“以后可能要做用户打断、上传文件、多轮交互”。这些需求确实存在但 WebSocket 带来的复杂度也真实存在状态管理、断线重连、心跳保活、消息顺序保证每一项都比 SSE 重。更合理的做法是默认用 SSE 做输出流真有双向实时交互需求时再单独评估 WebSocket。1.3 什么时候你才真的不需要流式也不能因为流式体验好就所有场景都上流式。内部批量生成摘要、离线处理历史会话、定时爬取内容后再结构化整理这些场景用户根本不在现场也不需要看到逐字输出用全量请求其实更简单——请求超时设置更宽容错误重试逻辑更直接日志也更好打。判断标准就一条生成过程是否需要用户实时感知。需要就上流式不需要全量同步更省事。别为了技术上的“高级感”而给后端架构增加不必要的复杂度。2. ChatClient把“请求-响应”封装成一个友好的门面2.1 为什么选择 ChatClient 而不是直接调 HTTP很多教程会演示用 HTTP 客户端直接调用大模型接口拼 URL、拼 Header、拼 Body、处理鉴权、解析返回 JSON看起来也不复杂。但真正做产品级功能时这套散装代码的痛点非常明显提示词管理散落在各个业务方法里模型切换要改多处配置流式接口又要重新处理一遍 SSE 分帧逻辑出问题时排查链路特别长。ChatClient 这类客户端封装做的事情就是把这些通用逻辑收敛起来。你传入用户消息和系统提示词它负责处理模型供应商的协议细节返回统一的数据结构。同步调用返回字符串流式调用返回一个数据流对象。业务层不需要关心底层走的是哪个模型供应商也不需要关心 SSE 的连接管理只需要按照客户端提供的 API 组织代码逻辑。这个封装的价值在做多个页面、多个功能都调用模型时体现得最明显。比如我有十个功能点都要用对话能力但它们提示词不同、模型参数不同有的需要流式有的只要全量。如果用原生 HTTP每个功能点都要重写一遍请求逻辑用 ChatClient构建部分可以复用差异部分通过配置动态传入。2.2 同步调用与流式调用的代码差在哪ChatClient 的 API 设计得很直观同步和流式在写法上只有最后一步不同。先看同步调用String answer chatClient.prompt() .system(你是一个严谨的客服助手) .user(帮我总结一下这份服务流程) .call() .content();这个方法返回的是一个普通字符串调用方拿到之后爱怎么用怎么用。适合前面说的离线场景、内部处理场景也适合测试时快速验证提示词效果。再看流式调用FluxString answerStream chatClient.prompt() .system(你是一个严谨的客服助手) .user(用三句话介绍一下退款流程) .stream() .content();返回类型从String变成了FluxString。Flux是响应式编程里的数据流代表“未来可能到达的多个元素”。每一个元素就是模型生成的文本片段可能是几个字、一个词、也可能是一小段话取决于模型供应商把输出切成多少块推过来。所以从同步切到流式变化的不是方法名而是思维模式原来你等着一个结果回来然后用它现在你是订阅一个数据流并持续处理它的每个片段。用生活化类比就是原来你去柜台取整份报告现在你是站在打印机旁边纸出来一张拿一张。2.3 必须提前弄懂的两个底层概念token 与 SSE 帧格式做流式响应之前如果对两个概念不清楚后面排查问题会特别吃力。第一个是 token。大模型生成文本时不是按“字”生成的而是按 token 生成的。token 可以理解成模型内部的语言单元中文里一个 token 大约对应半个到一个字英文里一个 token 大约对应四分之三个词。模型生成时是一批批 token 往外吐流式返回给前端的粒度可能是多个 token 组成的片断。所以你在前端看到的“逐字输出”效果其实是前端代码按照接收顺序渲染的结果不是模型真的一个字一个字吐。第二个是 SSE 的帧格式。SSE 协议里服务端推给客户端的每条消息是长这样的data: 这是第一段内容 data: 这是第二段内容每条消息以data:开头以\n\n结尾。前端要正确把一段连续输出还原成完整文本必须按\n\n把数据切成片段再逐个去掉data:前缀。这个逻辑如果处理不好就会出现输出错乱、内容丢失、JSON 解析失败等一堆问题。后面我会专门用一个复盘的例子说明。3. 从一个实际项目出发流式对话接口的完整落地过程3.1 后端用 ChatClient 暴露一个流式接口我当时做的项目是一个智能客服辅助工具用户在前端输入问题后端把问题转发给大模型模型生成的内容推到前端。刚开始用同步方式后来全部切成了流式。下面是我整理后的后端实现核心代码。RestController RequestMapping(/api/chat) public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient builder.build(); } PostMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString stream(RequestBody ChatRequest request) { return chatClient.prompt() .system(你是一个严谨、友好的客服助手。回答控制在100字以内。) .user(request.message()) .stream() .content(); } }需要注意两点。第一produces MediaType.TEXT_EVENT_STREAM_VALUE是必须要写的它告诉浏览器和前端代码这个接口返回的是 SSE 流不是普通 JSON。第二ChatClient 返回的FluxString直接作为接口返回值底层框架会自动把流里的每个元素包装成 SSE 消息推给客户端不需要你自己处理data:前缀。这个接口跑起来后我在后端日志里看到的输出是类似下面的样子doOnNext: 您好 doOnNext: 您的退款 doOnNext: 申请已经在 doOnNext: 处理中每个日志片段就是一次Flux元素到达。这说明流式链路已经通了剩下的工作在前端。3.2 前端消费 SSE别再用 EventSource 硬接很多前端同学接到流式接口后第一反应就是用EventSource。它能自动处理 SSE 协议用起来很省事。但它有一个硬限制只支持 GET 请求。实际业务里聊天接口通常需要把用户消息放在请求体里还要带用户身份信息、会话 ID、业务参数等这些往往超过 URL 能塞下的范围也不适合放在 URL 里。所以线上项目多数用 POST 接口做流式对话。POST 方式就不能用 EventSource 了需要用fetch结合ReadableStream手动读取。我封装了一段可以直接复制到项目里的代码async function streamChat(message) { const controller new AbortController(); const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message }), signal: controller.signal }); if (!response.ok) { throw new Error(网络请求失败); } 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 frames buffer.split(\n\n); // 最后一段可能不完整存回buffer等下一批数据到了再拼 buffer frames.pop(); for (const frame of frames) { const line frame.trim(); if (!line.startsWith(data:)) continue; const data line.replace(/^data:\s*/, ).trim(); if (data [DONE]) return; appendMessage(data); } } }这段代码里最容易踩的坑是buffer frames.pop()这行。SSE 数据是分块到达的最后一块经常是不完整的比如只到了一半就被切断了。如果不对不完整块做暂存处理直接按\n\n解析最后一条消息必然丢失。我第一次调这个逻辑时没有暂存结果发现每次回答的最后一个字都不见了排查了很久才意识到是这个原因。3.3 演进提示词和模型参数集中管理第一个版本我直接在 Controller 里写死了系统提示词。功能上线后运营提了一堆新需求不同客服场景要不同人设有的场景要简洁回复有的要详细指引还有的要用不同模型。这时候再在 Controller 里硬编码就完全撑不住了。我改成了配置注入的方式把提示词和模型参数放在配置里通过一个工厂类动态组装 ChatClientComponent public class ChatClientFactory { private final ChatClient.Builder builder; public ChatClientFactory(ChatClient.Builder builder) { this.builder builder; } public ChatClient create(String scene) { return builder .defaultSystem(buildSystemPrompt(scene)) .defaultOptions(buildModelOptions(scene)) .build(); } }这样做的好处是每个业务场景只需要传入场景标识就能拿到一套独立配置的客户端实例。新增一个场景只需要增加配置和提示词不用改动调用方代码。测试不同模型时也只需要切换配置项不需要重新编译。这个步骤本身不难但它是在流式链路稳定之后才值得做的优化。一开始就把配置搞得很复杂反而会干扰对流式本身问题的排查。4. 流式接口设计里比代码更重要的边界问题4.1 超时和心跳你的网关、服务端、前端可能同时断链普通 HTTP 接口的超时设置一般是 5 秒或 10 秒但流式接口完全不能用这个标准。大模型生成一个较长回答可能持续二十秒甚至更久如果还用默认超时连接会在生成中途被断开前端看到的就是半句话戛然而止。流式接口的超时要分三层分别考虑。网络层要做好配置默认超时时间适当调长同时服务端接口不要随意设置响应截止时间。网关层要注意空闲超时时间如果模型在回复前要“思考”很久比如内部在检索知识库这段时间没有数据推给前端网关可能判定连接空闲然后断开。前端侧请求超时也不能设成固定值有的前端库默认超时有上限遇到长回答会提前抛错。针对模型生成前的静默期比较有效的做法是后端在收到请求后先发送一个注释帧类似 SSE 里的心跳包比如data:注释帧会让连接保持活跃又不会污染最终输出内容。我在项目里给网关层配置了空闲超时时间实测如果模型思考超过 15 秒连接就会被网关断开。加了定期心跳之后这个问题就稳定解决了。4.2 取消生成用户关掉页面之后上游可能还在消耗费用流式连接还有一个容易被忽略的问题用户发起请求后觉得回答不对直接关掉了页面或者点了一个“停止生成”按钮。表面上页面断了但服务端的流可能没有同步结束模型还在继续生成文字还在消耗 token 费用。前端停止生成的正规做法是使用AbortControllerconst controller new AbortController(); // 点击停止时调用 controller.abort();fetch请求传入了signal调用abort()后浏览器会中断连接。但对后端来说连接断开只是客户端不再接收数据Flux 流本身如果不监听取消信号可能还是会继续拉取模型输出。响应式编程天然支持取消。当订阅关系因为连接断开而取消时Reactor 会向上游传递取消信号只要 ChatClient 的底层实现遵循了这个约定模型生成也会被中止。但这个链条依赖每个环节都实现正确。我建议在日志里记录取消事件观察连接断开时是否真的触发了生成中止。上线前模拟一次长回答然后点击停止再确认后台模型调用确实提前结束这一步不能省。4.3 输出质量的边界Markdown、JSON、长文本分片流式响应真正难处理的地方不是怎么接而是接回来之后怎么用。前端拿到的是碎成很多段的文本中间夹杂着模型输出的 Markdown 标记、代码块、甚至结构化 JSON。直接拼接后渲染会遇到几个典型问题。第一个问题是 Markdown 渲染。模型输出一段列表时可能会先吐出- 第一项然后隔一会儿才吐出- 第二项。如果每收到一段就重新渲染一次页面上的列表会频繁跳动。更稳妥的做法是把收到的内容累计到一个变量里渲染时用完整文本重新渲染。代价是每收到一段都要重新渲染一次 Markdown性能上要权衡。我的做法是设置一个 200 毫秒的节流窗口内容频繁到达时只保留最后一次渲染任务减少页面更新频率。第二个问题是结构化输出。模型被要求输出 JSON 时流式返回会把 JSON 切成很多碎片。前端如果试图在收到第一段时就开始解析 JSON几乎一定会报错。通常需要按特殊标记比如“数据结构结束标记”来做增量解析或者干脆等流结束再统一解析。下面的复盘案例里我把一次典型崩溃的排查过程完整记录下来这个思路可以直接复用。5. 一次“流式拼接导致 JSON 崩了”的完整复盘5.1 问题现象结构化卡片时好时坏当时业务做了一个“智能摘要卡片”功能模型输出一段结构化 JSON前端解析后在页面上渲染成卡片。上线后 QA 反馈卡片内容一会儿显示正常一会儿整个区域空白。空白还不是固定的有时候第一次打开是好的第二次就坏了。一开始我猜测是模型输出不稳定毕竟大模型偶尔抽风也正常。但 QA 说空白频率有点高大概两三成明显不是偶发情况。于是我决定从头到尾查一遍链路。5.2 排查链路从前端拼接到后端日志一层层还原我现打开浏览器开发者工具看网络面板里 SSE 流的一帧帧数据。发现模型输出的 JSON 确实被拆成了很多段。有的帧是片段开头有的帧是片段中间有的帧是片段结尾。这是流式输出本身就该有的状态本身不是问题。问题出在前端对 JSON 的解析时机。我最初写的解析逻辑是把收到的内容累加到一个变量里只要发现字符串以{开头并且以}结尾就尝试JSON.parse。这个逻辑在普通全量返回时没问题但在流式场景下会踩中一个很微妙的坑模型输出的 JSON 中间可能会有嵌套的}比如数组里对象结束、外层对象结束。前端累积到第一个}时就自认为 JSON 完整了开始解析但此时实际只拿到了前半段数据解析自然失败渲染区就空白了。为了验证这个判断我在后端加了日志打印 Flux 流的前几个片段看到类似这样的输出片段1: {title: 退款进度, items: [ 片段2: {label: 提交申请, status: done}, 片段3: {label: 等待审核, status: processing}如果前端的“看到第一个右花括号就解析”逻辑触发那在片段 2 就到了那个位置但它后面还有片段整个 JSON 根本不完整。解析失败的瞬间前端 catch 住了异常但没给用户任何提示于是就是空白。5.3 修复方案与验证查清原因后我没有去改 JSON 解析的复杂规则而是从生成协议上做了调整。具体做法是在提示词里明确要求模型先输出一个固定的起始标记例如DATA START再输出 JSON在输出的末尾还要输出一个结束标记例如DATA END。前端拿到内容后先判断结束标记是否已经出现只有出现结束标记时才开始真正解析 JSON。这样就绕开了“判断 JSON 结构是否完整”这种不稳定的方案。解析逻辑变成了“先找结束标记找到了再整体解析”确定性大幅提升。改完之后我把验证步骤拉到了 100 次请求每次要求模型输出至少包含 5 个字段的嵌套 JSON。以前那种空白问题一次都没再出现。后续面对其他流式解析场景我也沿用这个模式模型在输出结构化内容时明确约定的起始和结束标记不依赖对输出内容的启发式判断。6. 如果重新做一次流式对话我会最先确认这三件事经过这个阶段的折腾再回头看流式响应这个能力我自己总结出三条不算代码、但比代码更重要的经验。第一件先确认模型供应商或接入网关的 SSE 行为是否规范。有些供应商返回的数据里一个超大 chunk 可能包含多条 SSE 消息有些则会在空隙里插入状态文本。用 ChatClient 之前先手工模拟一次流式请求把原始响应体打印出来确认边界格式。这一步能避免后期大量无头绪的排查。第二件把会话 ID、消息 ID、计时指标从第一天就埋好。流式场景比普通接口多了很多看不见的状态连接何时建立、首个 token 多久返回、总共推了几帧、连接是否被中途取消。没有这些基础日志出现问题就只能靠猜。我后期补过一次这个能力补的时候才发现历史数据完全空白很多东西没法回溯。第三件流式接入不等于审核可以滞后。内容安全过滤在任何聊天场景里都是底线流式场景的难点在于内容是一帧一帧到达的如果等全部生成完再过滤用户可能已经看到了不安全的内容如果一帧一帧过滤又可能因为半句话上下文不足产生误判。常规做法是首帧快速前置策略结合完整内容异步复查保证及时发现风险。这块在设计接口时就要预留好不能等上线后再补。流式响应本身不复杂复杂的是它牵涉的边界条件比普通接口多得多。把每一层的超时、取消、解析时机、日志埋点都提前设计好这个功能才能真正稳定地跑在用户面前。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑