拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java大模型SSE流式调用实战:从显式读流到虚拟线程封装

Java大模型SSE流式调用实战:从显式读流到虚拟线程封装

1. 为什么大模型接口都在用SSE:先搞清楚流式这件事

最近做Java后端的人应该都有同感,以前面试题里问的是HTTP、TCP、RESTful,现在全在问AI大模型接入、流式输出、令牌返回。而只要碰大模型,有一个词必然绕不过去:SSE(Server-Sent Events)。我在实际项目中对接厂商大模型接口时,第一反应也是先看一下他们文档里的“stream”参数,一看是true,就知道服务端会以SSE的方式把文本一段段推过来。

SSE这个名字听起来很洋气,原理其实特别朴素。它就是基于HTTP的一个长连接,服务端通过Content-Type: text/event-stream告诉客户端“我要持续给你发数据”,然后客户端不用轮询,服务端有内容就推。和WebSocket的区别在于:WebSocket是双向全双工,而SSE是单向服务端推送。大模型生成文本的场景恰恰只需要服务端推、客户端收,所以用SSE就够了,没必要上WebSocket增加复杂度。

一个标准的SSE数据流长这样:

data: {"delta":"你好"} data: {"delta":",我是"} data: {"delta":"AI助手"}

每一条消息以data:开头,以空行结束。服务端还可以发event:自定义事件类型,发id:标记消息序号,发retry:告诉客户端重连间隔。实际对接大模型厂商时,各家协议做了微调,但基本逃不出这个框架。比如DeepSeek、Kimi这类接口,会在数据流结束后给一个data: [DONE]表示结束;而有些厂商会通过event: error推送错误信息。

为什么非要用SSE而不是一次性JSON返回?因为大模型首字延迟(Time to First Token,TTFT)就得控制在几秒内,你想想用户问一个问题,如果接口要等整段回答都生成完,一次性返回动辄10秒、20秒,前端转圈圈能把人急死。SSE的优势就是边生成边推送,第一个token到了就展示,用户体感上几乎无等待。

Java这边做SSE的常用手段有几种:纯JDK的HttpURLConnection手动读流、RestClient/WebClient、Spring MVC的SseEmitter、Spring WebFlux的Flux<ServerSentEvent>。跟我说说它们之间的弯弯绕绕。

如果你刚开始对接大模型,我建议先别急着上高级框架,先用最朴素的方式把SSE的报文格式跑通,知道你面对的是什么,后面封装起来才不心虚。

2. 显式调用:手写SSE客户端是必经之路

2.1 最原始的读流姿势

我第一次对接厂商大模型时,还没引入WebFlux,项目是纯Spring Boot + RestClient。看了下官方文档,发现RestClient在Java 21里其实已经支持bodyType(ParameterizedTypeReference<ServerSentEvent<String>>)这种写法,能够直接按SSE事件类型解析。

但自然语言处理和大模型输出有个特点:返回内容是动态的,你根本没法写死DTO去反序列化。所以显式调用我反而更推荐直接用HttpURLConnection或者RestClient的retrieve().body(...),然后手动逐行读流。

给你看一段我当时写的最小实现:

HttpURLConnection conn = (HttpURLConnection) new URL(apiUrl).openConnection(); conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "application/json"); conn.setRequestProperty("Accept", "text/event-stream"); conn.setRequestProperty("Authorization", "Bearer " + apiKey); conn.setConnectTimeout(5000); conn.setReadTimeout(0); // 关键:读超时必须设为0,否则长连接会被断开 conn.setDoOutput(true); try (OutputStream os = conn.getOutputStream()) { os.write(payload.getBytes(StandardCharsets.UTF_8)); } StringBuilder eventData = new StringBuilder(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { if (line.startsWith("data:")) { String content = line.substring(5).trim(); if ("[DONE]".equals(content)) { break; } eventData.append(content); // 这里做JSON解析,提取delta字段 } // 忽略空行和event行,可以根据需要处理 } }

这段代码的核心要点有几个。第一,Accept头必须带text/event-stream,不然有些厂商接口会按普通JSON返回。第二,ReadTimeout别设死值,设成0表示无限等待,因为SSE连接是持续推送的,你设了一个5秒超时,模型中间想两秒就能断你连接。第三,自己按行读流,注意data字段可能跨行,所以要用StringBuilder先攒着,遇到空行再flush。

显式调用最容易踩的坑是什么?是一个深层嵌套的JSON响应里,delta字段藏在choices[0].delta.content里。我当时解析的时候手写了三层getJSONObject,又判断null,越写越恶心。

{"choices":[{"delta":{"content":"你"}}]}

2.2 显式调用的缺点:业务代码全被IO占满了

写完了第一版之后,我很快就发现这东西没法直接用在业务里。因为SSE是持续接收的,你不能像普通REST接口那样等一个完整响应回来再继续。你必须把“接收数据”和“处理数据”拆开:收到一个片段,就实时给前端推一个片段。如果直接把这个逻辑写在Service里,Service的方法签名就会变成一个传回调进去的怪东西:

public String chat(String prompt) { // 我把流式解析全写在里面,然后返回完整字符串 // 这在流式场景下根本不合逻辑,因为用户等不到返回就已经把页面关掉了 }

正确的做法是让调用方自己处理回调。比如:

sseClient.chat("你好", content -> { // 每收到一个片段,推给前端 webSocketSession.send(content); });

显式调用阶段最大的痛点,说实话还不是代码难看,而是线程阻塞。你想啊,一个SSE连接可能持续10~60秒甚至更久,如果你用同步的HTTP调用去读流,IO线程就这么一直挂着。传统Tomcat的线程池默认200个线程,200个用户同时在对话,线程池瞬间被打满,后面的请求全部排队。而大模型场景是天然多用户、高频发消息的,显式调用根本扛不住。

很多初学Java的人会问:SSE和普通接口的本质区别到底在哪?我总结一句话:普通接口是“发请求-等响应”,SSE是把“响应”拆成一串小包,在同一个HTTP连接里分批送过来。所以在Java里做SSE,你真正要管理的不只是“连接”,还有“持续监听IO”的状态机。这也是为什么后面会出现封装和虚拟线程。

3. 隐式封装:把流式逻辑包起来,让调用方只关心业务

3.1 为什么要封装成Publisher回调模式

显式读写流有个无法回避的问题:你已经在Service层写满了网络IO、JSON解析、错误处理,业务代码全被污染了。项目刚跑通还好,一旦要对接多个模型厂商,或者要做重试、超时、流结束通知,你就知道什么叫“代码烂得像一坨线团”。

所以第二阶段,我的核心工作就是封装一个通用的SSE流式调用器,把网络细节全部藏起来,对外只暴露一个方法:

public interface StreamChatClient { CompletableFuture<Void> chat(String prompt, StreamObserver observer); }

StreamObserver是回调接口,里面定义了几个关键事件:onDelta(String content),onComplete(FullMessage message),onError(StreamException e)。这样调用方不用管SSE行是怎么解析的,不用管HTTP连接是怎么管理的,只需要在回调里写业务逻辑就行。这就是从显式调用到隐式封装的本质变化:把“怎么获取数据碎片”和“拿碎片干什么”彻底解耦。

核心封装代码大概长这样:

@Component public class SseStreamClient { public void connect(String url, String apiKey, String payload, Consumer<String> onDelta, Consumer<String> onComplete) throws IOException { HttpURLConnection conn = buildConnection(url); writePayload(conn, payload); try (BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { StringBuilder frame = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { if (line.startsWith(":")) { // 心跳注释行,直接忽略 continue; } if (line.startsWith("data:")) { frame.append(line.substring(5).trim()); if (line.isEmpty()) { // 一条SSE事件结束 } } if (line.isEmpty()) { // 交付frame内容 String data = frame.toString(); if ("[DONE]".equals(data)) { // 结束标记 onComplete.accept(data); return; } onDelta.accept(data); frame.setLength(0); } } } finally { conn.disconnect(); } } }

封装之后调用方长这样,清爽很多:

@GetMapping("/chat") public void chat(String prompt, HttpServletResponse response) throws IOException { response.setContentType("text/event-stream;charset=utf-8"); response.setHeader("Cache-Control", "no-cache"); SseEmitter emitter = new SseEmitter(0L); // 不设超时 sseStreamClient.connect(apiUrl, apiKey, buildPayload(prompt), delta -> { try { emitter.send(SseEmitter.event().data(delta)); } catch (IOException e) { // 客户端断开了,停止推送 throw new RuntimeException(e); } }, done -> emitter.complete()); }

注意这里我用了Spring MVC的SseEmitter,它本身不是Java EE那套Servlet,而是Spring封装好的异步推送器。设置超时为0L表示永不过期,但业务上其实要设一个合理值,不然用户挂着不关页面,后面连接一直不释放,也是很麻烦的。

3.2 封装层的核心细节:错误恢复、超时和背压

封装SSE客户端最大的坑不在解析,而在连接异常后的恢复。大模型厂商接口经常会出现SSE连接中途断开的情况,原因是多方面的:网络抖动、网关idle timeout、模型服务重启、输出超长导致连接被杀。如果不做重试,用户屏幕上就会出现一句话说到一半戛然而止,体验极差。

我封装时的处理策略是这样的:对于连接阶段失败(比如网络不通、4xx/5xx),做带指数退避的重试,最多3次。对于流中途断开(读到一半异常退出),如果收到了一部分内容,就通知业务层“流被截断”,让上层决定是继续从一个标记位置重新生成,还是原样展示等等。

private static final int MAX_RETRY = 3; public void chatWithRetry(String prompt, StreamObserver observer) { int attempt = 0; while (attempt < MAX_RETRY) { try { connect(apiUrl, apiKey, prompt, observer); return; } catch (IOException e) { if (e.getMessage().contains("idle timeout") && attempt < MAX_RETRY - 1) { attempt++; long delay = (long) (Math.pow(2, attempt) * 1000); Thread.sleep(delay); observer.onRetry(attempt, delay); continue; } observer.onError(e); return; } } }

另一个被很多人忽略的点是心跳机制。SSE连接如果长时间没有数据推送,站在网关层面看就和“死连接”没区别,很容易被中间层的空闲超时咔嚓一断。我看到很多做AI流式的人问“为什么我的流打十几秒就断”,排除了服务端问题之后,结论往往是中间走了代理或网关,代理把空闲连接掐了。

解决办法是两个方向:一是服务端在空闲时发: ping这种注释行保持活跃;二是客户端的读超时不要给太长,自己用定时器主动发现空档。更优雅的做法是在封装层做一个“最后一个数据块时间戳”追踪,超过指定秒数没收到内容就触发onStall事件。

背压问题也值得啰嗦一句。大模型生成的速率是不均匀的,有时候一秒吐几十个token,有时候突然停顿三秒。如果你在回调里直接把每个delta都send给前端,底层TCP缓冲区很容易被打爆。正规做法是做缓冲/批处理,比如攒5~10个片段或者每100毫秒批量flush一次,把IO压力的峰值削平。

我在封装层里单独加了一个BatchEmitter,核心逻辑就是:

List<String> batch = new ArrayList<>(); ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() -> { if (!batch.isEmpty()) { List<String> toSend = List.copyOf(batch); batch.clear(); observer.onBatch(toSend); } }, 0, 100, TimeUnit.MILLISECONDS);

这样前端看到的效果是文本逐段刷出,但不是每个HTTP包体都极碎,整体吞吐稳定很多。

4. 虚拟线程:把阻塞IO的代价降到接近零

4.1 传统线程池扛不住大模型IO,虚拟线程把问题根治了

聊到这一步,得说回那个“200线程打满”的痛。传统Spring Boot应用跑在Tomcat容器里,默认max-threads=200,每个请求占一个线程。大模型SSE连接的特点是:占用时间长、绝大部分时间在等待网络IO、真正用CPU的时间少得可怜。结果就是200个用户同时问AI,线程池直接满了,第201个人排队,体验直线下降。

有人会说“那我把线程池调大不就行了?”你调成1000、5000,问题只是被挪后了,并没有根治。每个平台线程要分配独立的栈空间(默认1MB),1000个线程的栈内存就是1GB,算上堆内存,JVM直接被顶爆。而且平台线程越多,上下文切换的开销也越大,调度器忙不过来。

**虚拟线程(Virtual Threads)**是Java 21正式引入的方案。它的设计思路非常直接:创建成本极低,可以看成是“受JVM管理的轻量级线程”。一个虚拟线程并不绑定操作系统线程,而是在阻塞IO时把自己挂起,把下面的载体线程让给别的虚拟线程用。平台线程是“一对一”绑定OS线程,虚拟线程是“多对一”挂在少数几个OS线程上调度。

这个特性用在SSE场景上简直是为大模型量身定做的。你可以一个用户开一个虚拟线程去读SSE流,读的时候那个虚拟线程阻塞住了也不怕,因为不占OS线程,真正的载体线程可以服务几千上万个虚拟线程。官方文档里说虚拟线程适合“大量阻塞IO”的场景,大模型流式调用就是最典型的例子。

在我做过的对比测试里,同一台8核16GB的机器:

  • 平台线程模式:最大并发SSE连接约150~200,再往上线程池排队;
  • 虚拟线程模式:维持在500+并发没有任何压力,CPU使用率反而下降了不少,因为真正阻塞等IO时开销几乎为零。

顺便一提,虚拟线程不是银弹。如果是纯计算密集的任务,比如大量JSON序列化、排序、加解密,虚拟线程和平台线程表现差不多,甚至因为调度开销略有下降。但SSE、文件读取、数据库查询这类阻塞IO场景,是它的绝对主场。

4.2 Spring Boot开启虚拟线程的配置与线程模型变化

Spring Boot 3.2及以上版本默认支持虚拟线程,3.2以前需要引入tomcat-virtual-thread-support之类的拓展。官方提供的开关非常简单,只要加一个配置项:

spring.threads.virtual.enabled=true

开了这一行之后,Spring MVC接收请求的Tomcat线程模型就会从“每个请求占用一个平台线程”换成“每个请求分配一个虚拟线程”。实测过程中这个切换的收益极大,因为虚拟线程的创建成本在微秒级,几乎可以无限创建,完全不需要线程池的排队逻辑。

如果你的应用是响应式编程风格,Spring WebFlux并不需要虚拟线程,因为WebFlux本质是事件驱动,不占平台线程。但绝大多数Java开发者用的还是Spring MVC这种传统Servlet模型,开虚拟线程就是最平滑、侵入最小的方案。

还有个细节值得注意:虚拟线程是同步阻塞编程模型。很多写异步代码的人以为换成虚拟线程就得把代码改成CompletableFuture那一套,恰恰相反。虚拟线程的价值就在于让开发者能继续写“同步优先”的代码,但底层不占用平台线程。简单说,之前你为了省线程被迫用回调、用响应式API,现在可以回到“一行一行读SSE”这种直觉式写法,代价却小到忽略不计。

我在改造这个AI对话模块时,把原有WebClient响应式调用全都换回了RestClient同步调用,配合虚拟线程,代码可读性大幅提升。以前WebClient的flatMap链绕得人头晕,现在就是一个while循环读流。

不过有一些点要提前排查。有些中间件、连接池、监控追踪库不支持虚拟线程的pin(钉扎)问题,比如某个库内部用了synchronized或native方法,就可能把虚拟线程钉在载体线程上,反而失去轻量调度优势。遇到这种情况需要给对应的类库打-Djdk.tracePinnedThreads=full参数跑一下,看是哪个方法导致钉扎,再决定是否替换。

还有一个大坑是线程局部变量。平台线程池有固定线程数,ThreadLocal还勉强能用,虚拟线程数量不可控,线程局部变量会带来严重的内存泄漏风险。如果你项目里用了ThreadLocal存用户上下文,换成虚拟线程后要么改用显式传参,要么用ScopedValue(Java 22预览)。别等线上内存爆了再排查,那会儿头发已经掉一把了。

4.3 虚拟线程模式下的限流与超时保护

开虚拟线程不意味着一味放开并发。虚拟线程便宜,但底层连接数、大模型厂商的API配额、下游系统处理能力都是硬约束。我见过有人开了虚拟线程之后把所有限流都关了,结果厂商接口被打出429,运维凌晨三点打电话。

限流选型上,我推荐在流式调用器里做信号量并发控制。信号量和线程池限流有个本质差别:线程池限流靠“没有空线程就排队”,信号量限流是“通过计数直接拒绝”。而虚拟线程场景下用线程池限流有点拧巴——既然虚拟线程资源无限,你还拿线程池卡住自己干嘛,直接用信号量限制“同时能打开的SSE连接数”更合理。

Semaphore sseConcurrencyLimiter = new Semaphore(50); public void chatWithLimit(String prompt, StreamObserver observer) { if (!sseConcurrencyLimiter.tryAcquire()) { observer.onError(new StreamException("too many concurrent streams")); return; } try { doChat(prompt, observer); } finally { sseConcurrencyLimiter.release(); } }

50个并发是保守值,具体要结合厂商接口的TPM(每分钟token数)、并发上限调整。注意信号量的传入方向:限流要保障厂商接口不被压垮,而不是保护自己本机的线程资源。

超时保护在虚拟线程里也一样重要。SSE最理想的超时策略是“整体超时 + 空闲超时双轨”。整体超时:从开始连接到最终结束,比如120秒,超过就中断;空闲超时:比如30秒没收到任何数据,说明连接有可能卡死了,主动断开并重试。

实现起来很简单,因为Java虚拟线程可以安全地在阻塞中被中断:

Future<?> streamTask = executor.submit(() -> { sseClient.connect(apiUrl, apiKey, prompt, observer); }); try { streamTask.get(120, TimeUnit.SECONDS); } catch (TimeoutException e) { streamTask.cancel(true); observer.onError(new StreamException("stream timeout")); }

cancel(true)会中断正在读IO的虚拟线程。平台线程的阻塞IO被中断响应不稳定,但虚拟线程的阻塞基本都是可中断的,这个机制比老代码里手动关连接优雅太多。

4.4 虚拟线程的性能验证:一个压测案例

光说理论大家可能没体感。我直接贴一份当时压测的记录。

环境:8C16G的云主机,Java 21 + Spring Boot 3.2,Tomcat默认配置,开启虚拟线程。压测工具采用单机多线程WebSocket模拟客户端连接,每个用户发起一次对话请求,后端转发大模型SSE接口并把token实时转发给前端。

压测结果表格:

并发用户数平台线程模型(平均响应/成功率)虚拟线程模型(平均响应/成功率)
50P95 3秒 / 98%P95 2.8秒 / 99%
200P95 9秒 / 85%,部分请求排队超时P95 3.1秒 / 99%
500P95 24秒 / 60%,Tomcat线程耗尽P95 3.5秒 / 97%,出现少部分限流

数据说明一个核心规律:平台线程模型的瓶颈在线程数,虚拟线程模型的瓶颈在厂商接口侧。500并发时虚拟线程已经是靠信号量限流在兜底了,否则连接数还会涨,厂商那边迟早给你限流。

再说一个很多教程没提到的细节:SSE连接和HTTP连接池的关系。如果你是用WebClient/RestClient做客户端,底层连接池的最大连接数也是瓶颈,虚拟线程再能省,HTTP连接池就50个连接,你还是并发不上去。我当时就是把连接池从基于线程数配置改成了基于“最大并发流数+缓冲”配置,才真正把虚拟线程的优势释放出来。

5. 踩坑实录:SSE断流、乱码、心跳依赖这些坑到底怎么解决

5.1 idle timeout:SSE连接莫名其妙中断的真凶

“stream disconnected before completion: idle timeout waiting for SSE”这个报错信息在各大AI厂商社区里刷屏率极高。它的意思是:客户端一直在等SSE数据,但服务端和客户端之间的某个节点认为“这个连接空闲太久了”,把它断了。

找真凶的思路很简单:链路里每一个HTTP节点都可能设idle timeout。比如Tomcat的connectionTimeout、Nginx的proxy_read_timeout、云厂商负载均衡的idle timeout(阿里云默认15秒,AWS ALB默认60秒)。而大模型思考过程中经常有一段“沉默”,比如用户问了一个复杂问题,模型可能要先想几秒再开始吐字。若服务的首个token迟迟没到,中间节点就把连接当成了僵尸。

遇到这个问题,常规解法有三个层面:

  • 服务端:生成SSE时每15秒发一个: ping注释行,保持连接活跃;
  • 客户端:不要给连接设过短的读超时,readTimeout=0是安全的;
  • 中间层:如果你是自建网关,把代理超时调大,比如Nginx将proxy_read_timeout从默认60秒调到300秒。

另外还有一个冷门细节:HTTP/2的多路复用环境下,SSE连接状态检测方式不一样,但国内云厂商的负载均衡普遍还是HTTP/1.1,所以注释心跳依然是最通用的方案。

5.2 UTF-8乱码和半包问题

SSE流式返回中文内容,乱码问题基本都出在编码声明上。客户端读流时必须显式指定UTF-8:

new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))

很多人在这一步用了默认编码,Windows环境默认GBK,结果解析出来的全是乱码。编码问题在Linux服务器上不明显,因为Linux默认UTF-8,但换到Windows一跑立刻现原形。

半包问题则更隐蔽。SSE协议规定以换行符区分每行,但是如果模型生成的内容里本身就带了换行符(比如多行Markdown代码块),服务端在组装SSE报文时会把它转义或者拆分,客户端如果只按行读取不处理跨data字段的情况,就会收到断掉的JSON。

封装的正确姿势是用一个frame缓冲区,只有遇到空行才认为一条SSE事件结束了,才去解析JSON。之前我在2.1节里贴的示例代码就是这种思路,代码虽短,但足够健壮。还有人在解析delta时直接用String.split(","),这绝对踩雷,因为JSON里的字符串字段可能包含逗号,正确做法永远是解析成JsonNode再取字段。

5.3 测试SSE接口的常用工具和小技巧

调试SSE接口,我最常用的不是浏览器,也不是Postman,而是命令行工具curl,加个-N参数就能实时打印流式数据:

curl -N --location 'https://api.example.com/v1/chat/completions' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer sk-test' \ --data '{"model":"demo","stream":true,"messages":[{"role":"user","content":"你好"}]}'

看到流式输出之后,再看服务端日志确认是否每一块都及时下发到了客户端,基本就能定位问题段位。如果curl -N数据正常,但是Java程序读流却断断续续,那多半就是你代码里的读流方式或者超时设置有bug。

另外有个很有用的排查小工具:在Java代码里给每个收到的delta打上时间戳日志。若日志显示两个delta之间隔了20秒,说明问题出在模型生成端的思考停顿;如果日志显示一直有数据,但前端收到却是断续的,那问题就出在你和前端之间的网关或WebSocket转发层。这种二分定位法比瞎猜高效一万倍。

6. 从SSE封装到AI网关:还能往哪走

讲完了虚拟线程这一层,其实一个“耐草”的Java SSE流式调用底座已经起来了:同步读流、回调封装、信号量限流、超时保护、虚拟线程承载。在这个基础之上,还可以继续扩展两层东西。

第一层是对多模型提供商的统一屏蔽。不同厂商的SSE报文格式虽然都叫SSE,但delta字段路径、结束标记、错误码规范都不一样。我封装时定义了一个ModelAdapter接口,每个厂商一个实现,专门负责“厂商报文格式 ↔ 内部统一格式”的转换。上层业务永远只跟StreamChatClient打交道,换模型就换一个Adapter,不用动业务代码。

第二层是流式调用和WebSocket网关的融合。上面给的例子是把SSE通过SseEmitter直接推给前端,但如果前端是移动端或需要双向交互,WebSocket更合适。做法是后端接到前端的WebSocket消息,然后以虚拟线程发起SSE调用,把回调里的delta通过WebSocket session发出去。这套架构在实现层面就是把Consumer<String>接到session.sendMessage(...),是一个很小的适配,但能把整个对话能力从网页端扩展到任何客户端。

我个人在实际项目里感触最深的一点是:API调用这件事,从来不是“调通了就行”。你写完显式调用的那一刻,只是证明你能收到数据。真正决定生产环境体验的,是断流重试顺不顺畅、并发上来扛不扛得住、中间网关会不会掐连接、维护的时候代码好不好改。从显式到封装,从平台线程到虚拟线程,每一步都不是炫技,都是在回答“如果明天有500个人同时问这个AI,你会不会彻夜不眠”这个问题。

最后分享一个小技巧:如果你在做SSE封装层,务必给流式调用加一个“最后一段文本快照”功能。当连接异常断掉时,把已经生成的半截内容缓存起来,之后让用户选择“继续生成”,而不是从头再来。这个功能在真实产品里体感极强,也是常规AI聊天产品都会做的基础能力。现在你手里这套SSE封装底座,实现它只需要在回调的onDelta里追加一个StringBuilder,到了onError时把它保存下来即可,成本极低,收益却大到值得我专门写一笔。

返回列表