做 AI 对话功能,第一版几乎都是从“等一个完整字符串”开始的。我之前接手过一个客服知识库模拟项目,把 ChatClient 接上大模型后,前端一直用最老实的做法——读完整个 HTTP 响应,再一次性把答案渲染出来。用户点完发送,短则八九秒、长则十几秒的转圈,接着屏幕上猛地冒出一长段文字。用户不止一次反馈“是不是卡死了”“是不是没接收到”。其实模型本身没那么慢,是我压根没把 ChatClient 的流式响应用起来。后来改成流式输出,首屏大概 700 毫秒就开始出字,那种“死等”的焦虑感立刻消失了。
这篇内容就是围绕 ChatClient 与流式响应做的完整实操记录。我会从体验差异、底层原理、接口落地、边界问题和真实踩坑几个方向拆开讲。如果你正打算给聊天功能接入流式输出,或者已经在接但遇到奇奇怪怪的显示问题,这篇应该能帮你省下一整天的排查时间。
1. 没有流式响应的聊天,体验只有及格线
1.1 全量返回到底差在哪
很多刚接触大模型接入的人会对“流式”有个误解:认为流式只是让 UI 好看一点,属于锦上添花。但我测过同一个模型、同一套提示词、同一个网络环境下的两种情况,差距非常明显。全量返回时,用户要等模型把整段回答生成完,网络层再一次性把数据传回前端。一个 500 字左右的回答,从点击发送到看到完整内容,普遍要 8 到 12 秒。
这 8 到 12 秒里用户面对的是空白的聊天窗口或者一个转圈图标,没有任何反馈机制让他判断系统是否还在工作。一旦超过 3 秒,人的焦虑感就会显著上升;超过 5 秒,部分用户会开始重复点击发送,造成并发请求堆积。
流式响应的意义不只是“把等待时间省掉”,而是把一段不可感知的等待过程,拆成了若干个可感知的小反馈。模型生成第一句话通常只需要几百毫秒到 1 秒,后续每秒钟都能看到新的文字出现。对用户来说,他看到的不是“系统卡住了”,而是“系统正在替我想话”,这种心理体验上的差别比技术实现更影响产品评价。
1.2 逐字输出背后其实是一个单向通道
流式响应最常见的落地方式是 SSE(Server-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();这个方法返回的是一个普通字符串,调用方拿到之后爱怎么用怎么用。适合前面说的离线场景、内部处理场景,也适合测试时快速验证提示词效果。
再看流式调用:
Flux<String> answerStream = chatClient.prompt() .system("你是一个严谨的客服助手") .user("用三句话介绍一下退款流程") .stream() .content();返回类型从String变成了Flux<String>。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 Flux<String> stream(@RequestBody ChatRequest request) { return chatClient.prompt() .system("你是一个严谨、友好的客服助手。回答控制在100字以内。") .user(request.message()) .stream() .content(); } }需要注意两点。第一,produces = MediaType.TEXT_EVENT_STREAM_VALUE是必须要写的,它告诉浏览器和前端代码:这个接口返回的是 SSE 流,不是普通 JSON。第二,ChatClient 返回的Flux<String>直接作为接口返回值,底层框架会自动把流里的每个元素包装成 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 里硬编码就完全撑不住了。
我改成了配置注入的方式,把提示词和模型参数放在配置里,通过一个工厂类动态组装 ChatClient:
@Component 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 费用。
前端停止生成的正规做法是使用AbortController:
const 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 多久返回、总共推了几帧、连接是否被中途取消。没有这些基础日志,出现问题就只能靠猜。我后期补过一次这个能力,补的时候才发现历史数据完全空白,很多东西没法回溯。
第三件:流式接入不等于审核可以滞后。内容安全过滤在任何聊天场景里都是底线,流式场景的难点在于内容是一帧一帧到达的,如果等全部生成完再过滤,用户可能已经看到了不安全的内容;如果一帧一帧过滤,又可能因为半句话上下文不足产生误判。常规做法是:首帧快速前置策略结合完整内容异步复查,保证及时发现风险。这块在设计接口时就要预留好,不能等上线后再补。
流式响应本身不复杂,复杂的是它牵涉的边界条件比普通接口多得多。把每一层的超时、取消、解析时机、日志埋点都提前设计好,这个功能才能真正稳定地跑在用户面前。