流式对话实战:LangChain4j + SSE 打字机效果全链路指南
本文是 LangChain4j 实战系列的第六篇。前五篇我们搭好了智能客服(Memory + Tools + RAG 三合一)、优化了 RAG 检索、打磨了工具调用和会话管理、补齐了生产工程化------但所有接口都是同步阻塞的:用户发一句话,盯着空白屏幕干等 8 秒,然后"啪"一次性甩出全部回答。
本篇把这些能力全部升级为流式输出 :0.5 秒出第一个字,打字机效果逐字推进,工具执行过程和 RAG 引用来源实时可见。所有代码基于 LangChain4j 1.17.2 + Spring Boot 3.5.0 + DashScope(通义千问 qwen-plus)。
为什么必须做流式?
先看一组感知数据(TTFT:Time To First Token,首字延迟):
| 场景 | 总耗时 | 首字延迟 | 用户感受 |
|---|---|---|---|
| 同步接口 | 8s | 8s | "卡死了?是不是该刷新?" |
| 流式接口 | 8s | 0.5s | "已经在回答了,等着就行" |
总耗时一样,体验天差地别。心理学上这叫感知性能:用户对"正在进行"的容忍度远高于"毫无响应"。ChatGPT 们 2023 年就教育完了市场------现在没有打字机效果的 AI 产品,用户第一反应就是"这玩意是不是套壳的"。
流式还带来一个同步接口给不了的能力:过程可见。"正在查询您的积分......""已为您检索到退换货政策"------工具调用和 RAG 检索的中间过程,可以在正文输出前推送给前端。同步接口的 8 秒空白在流式世界里是 8 秒的信息流。
为什么是 SSE 而不是 WebSocket?
| 对比项 | SSE | WebSocket |
|---|---|---|
| 方向 | 服务端 → 客户端(单向) | 双向 |
| 协议 | 纯 HTTP | 独立协议(Upgrade) |
| 断线重连 | 浏览器 EventSource 内置自动重连 | 需自己实现 |
| 代理/网关兼容性 | 好(就是普通 HTTP) | 部分网关需额外配置 |
| 适用场景 | LLM 流式输出(天生单向) | 聊天室、协同编辑 |
LLM 对话的流式输出本质是单向推送------用户发完消息后只有服务端在说话。SSE 用最简单的方式覆盖了这个场景,而且 Spring MVC 原生支持(SseEmitter),不需要引入 spring-boot-starter-websocket。
一、起点:第一篇里的流式雏形
第一篇里其实留了一个最简流式端点:
java
// StreamingChatController.java --- 第一篇版本(裸文本流)
@GetMapping(produces = "text/event-stream")
public SseEmitter stream(@RequestParam String message) {
SseEmitter emitter = new SseEmitter(120000L);
streamingChatModel.chat(message, new StreamingChatResponseHandler() {
@Override
public void onPartialResponse(String partialResponse) {
emitter.send(SseEmitter.event().data(partialResponse));
}
@Override
public void onCompleteResponse(ChatResponse completeResponse) {
emitter.send(SseEmitter.event().data("[DONE]"));
emitter.complete();
}
@Override
public void onError(Throwable error) {
emitter.completeWithError(error);
}
});
return emitter;
}
它能跑,但离生产有三个距离:
- 裸文本流------前端只能拼接字符串,无法区分"正文 token"和"控制信号",更别说插入工具执行状态
- 无生命周期保护 ------
emitter.complete()调两次直接抛IllegalStateException;客户端中途关页面,send()抛IOException会把异常打回框架回调线程 - 无观测------首字延迟多少?token 吞吐多少?一无所知
本篇的策略就是逐个补齐。先看 LangChain4j 1.17.2 流式相关的三个核心 API:
java
// ① 底层流式模型接口(DashScope starter 自动配置为 QwenStreamingChatModel)
public interface StreamingChatModel {
void chat(String userMessage, StreamingChatResponseHandler handler);
void chat(List<ChatMessage> messages, StreamingChatResponseHandler handler);
}
// ② 流式回调:token 粒度的推送
public interface StreamingChatResponseHandler {
void onPartialResponse(String partialResponse); // 每收到一个增量片段
void onCompleteResponse(ChatResponse completeResponse); // 流结束(含 tokenUsage)
void onError(Throwable error); // 流中出错
}
// ③ AiService 声明式流式返回类型(重点,后面讲)
public interface TokenStream {
TokenStream onPartialResponse(Consumer<String> consumer);
TokenStream onToolExecuted(Consumer<ToolExecution> consumer);
TokenStream onRetrieved(Consumer<List<Content>> consumer);
TokenStream onCompleteResponse(Consumer<ChatResponse> consumer);
TokenStream onError(Consumer<Throwable> consumer);
void start(); // 注意:不调用 start() 流不会启动
}
StreamingChatResponseHandler 在 1.17.2 里还有 onPartialThinking(思维链)、onPartialToolCall(工具参数增量)等新回调,都是 default 方法,按需覆写即可。
二、策略一:结构化事件流------从"裸文本"到"协议"
流式输出的第一个架构决策:推送什么格式的数据。
裸文本(data:你 data:好)只能支持打字机一种效果。要展示工具执行状态、RAG 引用来源、token 统计,必须定义事件协议:
java
// streaming/StreamingEvent.java
public record StreamingEvent(String type, Object data, long timestamp) {
public static final String TYPE_TOKEN = "token"; // 增量正文
public static final String TYPE_TOOL = "tool"; // 工具执行完毕
public static final String TYPE_RETRIEVED = "retrieved"; // RAG 命中
public static final String TYPE_DONE = "done"; // 流结束(含统计)
public static final String TYPE_ERROR = "error"; // 流错误
public static StreamingEvent token(String partialText) {
return new StreamingEvent(TYPE_TOKEN, partialText, System.currentTimeMillis());
}
public static StreamingEvent tool(String name, String arguments, String result, long durationMs) {
return new StreamingEvent(TYPE_TOOL, Map.of(
"name", name, "arguments", arguments,
"result", result, "durationMs", durationMs),
System.currentTimeMillis());
}
// retrieved / done / error 工厂方法同理,见项目源码
}
前端拿到的每一条消息都是 JSON:
vbnet
data:{"type":"token","data":"您","timestamp":1787470000000}
data:{"type":"token","data":"的","timestamp":1787470000030}
data:{"type":"tool","data":{"name":"queryPointsBalance","arguments":"{\"phone\":\"138...\"}","result":"12500分","durationMs":38},"timestamp":...}
data:{"type":"done","data":{"inputTokens":52,"outputTokens":238,"firstTokenLatencyMs":412},"timestamp":...}
type 字段让前端可以分发渲染:token 拼进正文气泡,tool 显示成状态卡片,done 触发停止动画。一个 record,把流式输出从"打字机"升级成了"过程直播"。
策略一对应的端点直接操作 StreamingChatModel,同时统计首字延迟:
java
// streaming/StreamingController.java(节选)
@GetMapping(value = "/chat-raw", produces = "text/event-stream")
public SseEmitter chatRaw(@RequestParam String message) {
SseEmitter emitter = new SseEmitter(180_000L);
long startTime = System.currentTimeMillis();
volatile long firstTokenTime = -1; // 演示用;完整版封装在 StreamingMetrics 里
streamingChatModel.chat(message, new StreamingChatResponseHandler() {
@Override
public void onPartialResponse(String partialResponse) {
if (firstTokenTime == -1) firstTokenTime = System.currentTimeMillis();
send(emitter, StreamingEvent.token(partialResponse));
}
@Override
public void onCompleteResponse(ChatResponse completeResponse) {
TokenUsage usage = completeResponse.tokenUsage(); // 1.17.2:流结束可拿到用量
Map<String, Object> stats = new LinkedHashMap<>();
if (usage != null) {
stats.put("inputTokens", usage.inputTokenCount());
stats.put("outputTokens", usage.outputTokenCount());
}
stats.put("firstTokenLatencyMs", firstTokenTime - startTime);
send(emitter, StreamingEvent.done(stats));
emitter.complete();
}
@Override
public void onError(Throwable error) {
send(emitter, StreamingEvent.error(error.getMessage()));
emitter.complete(); // 注意:不是 completeWithError,原因见踩坑记录
}
});
return emitter;
}
注意 onCompleteResponse 里的 tokenUsage() ------流式模式下 DashScope 会在最后一个 chunk 附带用量统计,LangChain4j 把它装进 ChatResponse。做成本核算不用自己数 chunk。
三、策略二:声明式流式------TokenStream 让代码减半
手动桥接的样板代码(匿名类、回调嵌套)写一次就够了。日常开发用 @AiService 声明式接口,方法返回值从 String 换成 TokenStream,其余一字不改:
java
// streaming/StreamingAssistant.java
@AiService(
wiringMode = AiServiceWiringMode.EXPLICIT,
streamingChatModel = "qwenStreamingChatModel",
chatMemoryProvider = "chatMemoryProvider"
)
public interface StreamingAssistant {
@SystemMessage("你是XX商城的智能客服助手,用中文回答,语气亲切专业。")
TokenStream chat(@MemoryId String sessionId, @UserMessage String userMessage);
}
LangChain4j 检测到返回类型是 TokenStream,自动改用 StreamingChatModel 发起请求;@MemoryId、@SystemMessage、@Tool、RAG------所有 AiService 能力照常工作。
TokenStream 用链式回调替代了匿名类:
java
tokenStream
.onPartialResponse(token -> ...) // 增量正文
.onToolExecuted(execution -> ...) // 工具执行完毕
.onRetrieved(contents -> ...) // RAG 检索命中
.onCompleteResponse(response -> ...) // 流结束
.onError(error -> ...) // 出错
.start(); // 启动流(忘了调用 = 没有任何输出)
这里有个本篇最大的坑,单独提前说(完整过程见踩坑记录):
wiringMode = AiServiceWiringMode.EXPLICIT不是可选项。 我们的工程因为《Chat Memory 进阶》一篇定义了 4 个ChatMemoryProviderbean,AUTOMATIC装配模式会直接抛IllegalConfigurationException: Conflict: multiple beans of type ChatMemoryProvider,应用都启动不了 。而且反编译确认:AUTOMATIC 模式下注解里写的chatMemoryProvider = "xxx"属性会被完全忽略------指定了也没用,必须切 EXPLICIT 模式。
四、策略三:流式 + Chat Memory------多轮对话的打字机
TokenStream 和记忆是天然兼容的:@MemoryId 隔离会话,流式只管输出。
java
// streaming/StreamingController.java(节选)
@GetMapping(value = "/chat", produces = "text/event-stream")
public SseEmitter chat(@RequestParam String sessionId, @RequestParam String message) {
SseEmitter emitter = new SseEmitter(180_000L);
TokenStream tokenStream = streamingAssistant.chat(sessionId, message);
bridge.bridge(tokenStream, emitter, metrics.newRecord());
return emitter;
}
测试流程(浏览器直接访问即可):
ini
第一轮:/api/streaming/chat?sessionId=u1&message=我叫张伟,记住我的名字
第二轮:/api/streaming/chat?sessionId=u1&message=我叫什么名字?回答前先思考
第二轮的 SSE 事件流:
css
data:{"type":"token","data":"您","timestamp":...}
data:{"type":"token","data":"刚才","timestamp":...}
data:{"type":"token","data":"说您叫张伟","timestamp":...}
...
data:{"type":"done","data":{"inputTokens":47,"outputTokens":31,"firstTokenLatencyMs":389},"timestamp":...}
记忆在流式场景下有个额外收益:done 事件里的 inputTokens 会随着对话轮次增长 ,用户聊得越多、上下文越长、单次请求成本越高------这个数据同步接口要专门埋点才能拿到,流式的 onCompleteResponse 白送。
五、策略四:三合一流式商城客服------过程直播
这是本篇的主菜:把第一篇的同步版商城客服(Memory + Tools + RAG)整体升级为流式版。
java
// streaming/StreamingMallAssistant.java
@AiService(
wiringMode = AiServiceWiringMode.EXPLICIT,
streamingChatModel = "qwenStreamingChatModel",
chatMemoryProvider = "chatMemoryProvider",
tools = {"mallToolService"}, // 积分/商品/订单工具
contentRetriever = "contentRetriever" // 商城政策知识库
)
public interface StreamingMallAssistant {
@SystemMessage("你是「XX商城」的智能客服助手......" +
"需要手机号时,如果用户之前已经提供过,直接使用记忆中的手机号......")
TokenStream chat(@MemoryId String sessionId, @UserMessage String message);
}
关键在 onToolExecuted 和 onRetrieved 两个回调。同步版里,工具调用是黑盒------用户只能等;流式版里,每次工具执行、每次 RAG 检索都能推事件:
vbnet
用户:我的手机号是13800138001,帮我查下积分
data:{"type":"token","data":"好的","timestamp":...}
data:{"type":"tool","data":{"name":"queryPointsBalance","arguments":"{\"phone\":\"13800138001\"}","result":"张伟,当前积分余额:12500分","durationMs":41},"timestamp":...}
data:{"type":"token","data":"张伟","timestamp":...}
data:{"type":"token","data":"您好","timestamp":...}
...
data:{"type":"done","data":{"inputTokens":89,"outputTokens":156,"firstTokenLatencyMs":402},"timestamp":...}
前端可以把 tool 事件渲染成"已完成查询:积分余额"的状态卡片。对比一下同一问题的两种体验:
bash
同步版时间线(用户视角):
0s 发送消息
0-8s 空白等待(工具在跑、模型在生成,用户不知道)
8s 一次性出现完整回答
流式版时间线(用户视角):
0s 发送消息
0.4s "好的"(首字出现)
0.6s [已完成查询:积分余额](工具状态卡片)
0.8s "张伟您好,您当前的积分余额......"(正文逐字推进)
3s done(回答完成)
onToolExecuted 回调拿到的 ToolExecution 对象信息相当丰富(1.17.2 实际 API):
java
public class ToolExecution {
public ToolExecutionRequest request(); // id() / name() / arguments()
public String result(); // 工具返回值
public boolean hasFailed(); // 是否失败
public LocalDateTime startTime(); // 开始时间
public LocalDateTime finishTime(); // 结束时间
public Duration duration(); // 耗时
}
注意多工具场景:LLM 可能连续调用多个工具(查积分 → 搜商品 → 下单),每执行完一个就触发一次 onToolExecuted,正文 token 在全部工具执行完后才流出。前端把每个 tool 事件追加成一条状态记录,就是完整的"执行日志"。
六、策略五:生产级细节------桥接器、指标、错误处理
策略一到四的端点里反复出现一个 bridge.bridge(tokenStream, emitter, record)------这是流式与 SSE 之间的核心组件,也是生产级细节的集中地:
java
// streaming/StreamingSseBridge.java(完整实现,核心设计逐条注释)
@Component
public class StreamingSseBridge {
private final ObjectMapper objectMapper;
private final StreamingMetrics metrics;
public void bridge(TokenStream tokenStream, SseEmitter emitter,
StreamingMetrics.StreamRecord record) {
AtomicBoolean finished = new AtomicBoolean(false);
// ① 生命周期钩子:客户端断开/超时时置位,停止后续推送
emitter.onCompletion(() -> finished.set(true));
emitter.onTimeout(() -> {
finished.set(true);
emitter.complete();
});
emitter.onError(t -> finished.set(true));
tokenStream
.onPartialResponse(token -> {
if (finished.get()) return; // ② 幂等保护
metrics.markFirstToken(record); // 记录首字延迟
sendEvent(emitter, finished, StreamingEvent.token(token));
})
.onToolExecuted(execution -> {
if (finished.get()) return;
var request = execution.request();
sendEvent(emitter, finished, StreamingEvent.tool(
request.name(), request.arguments(),
execution.result(),
execution.duration() == null ? -1 : execution.duration().toMillis()));
})
.onRetrieved(contents -> {
if (finished.get()) return;
List<String> snippets = contents.stream()
.map(Content::textSegment)
.map(segment -> segment.text())
.map(text -> text.length() > 120
? text.substring(0, 120) + "..." : text) // 截断防刷屏
.toList();
sendEvent(emitter, finished, StreamingEvent.retrieved(snippets));
})
.onCompleteResponse(response -> {
// ③ CAS 保证 complete 只执行一次
if (!finished.compareAndSet(false, true)) return;
metrics.recordComplete(record, response.tokenUsage());
sendEvent(emitter, finished,
StreamingEvent.done(buildStats(record, response.tokenUsage())));
emitter.complete();
})
.onError(error -> {
if (!finished.compareAndSet(false, true)) return;
metrics.recordError();
// ④ 关键:发 error 事件后正常关闭,而非 completeWithError
sendEvent(emitter, finished, StreamingEvent.error(error.getMessage()));
emitter.complete();
});
tokenStream.start(); // ⑤ 别忘了启动
}
private void sendEvent(SseEmitter emitter, AtomicBoolean finished, StreamingEvent event) {
if (finished.get()) return;
try {
// 手动序列化,绕开 HttpMessageConverter 协商的不确定性
emitter.send(SseEmitter.event().data(objectMapper.writeValueAsString(event)));
} catch (Exception e) {
// 客户端断开是常态(用户关页面),降级为 warn,不让异常打回回调线程
log.warn("SSE 发送失败(客户端可能已断开): {}", e.getMessage());
finished.set(true);
try { emitter.completeWithError(e); } catch (Exception ignore) {}
}
}
}
配套的指标组件只盯两个数------首字延迟 和 token 吞吐:
java
// streaming/StreamingMetrics.java(节选)
@Component
public class StreamingMetrics {
public static class StreamRecord {
final long startTime = System.currentTimeMillis();
volatile long firstTokenTime = -1;
final LongAdder chunkCount = new LongAdder();
// markFirstToken() 用 volatile 保证多回调线程下只记一次
}
public Map<String, Object> snapshot() {
long completed = completedRequests.sum();
return Map.of(
"totalRequests", totalRequests.sum(),
"completedRequests", completed,
"failedRequests", failedRequests.sum(),
"avgFirstTokenLatencyMs", completed == 0 ? 0
: firstTokenLatencySum.sum() / completed,
"totalOutputTokens", totalOutputTokens.sum());
}
}
GET /api/streaming/metrics 随时可查:
json
{
"totalRequests": 128,
"completedRequests": 121,
"failedRequests": 7,
"avgFirstTokenLatencyMs": 436,
"totalChunks": 8917,
"totalOutputTokens": 23456
}
生产建议 :这里的内存计数是演示级实现;真实项目把 markFirstToken / recordComplete 挂到 Micrometer(Timer.builder("llm.first.token.latency").register(registry)),Grafana 直接出 TTFT 大盘。另外三个生产细节:
- 心跳保活 :LLM 思考 + 工具执行期间可能 30 秒无输出,Nginx/网关默认 60s 超时会掐断连接。加一个
@Scheduled定时器对活跃 emitter 发 SSE 注释行(: ping\n\n)即可保活 - 背压 :SseEmitter 底层是 Servlet 异步 IO,发送速率高于客户端消费速率时数据堆在服务端缓冲。LLM 输出速率通常低于网络速率,一般无需处理;极端场景考虑 Reactor +
WebFlux的Flux<ServerSentEvent> - 重连风暴 :见踩坑记录第 2 条------
completeWithError()会让浏览器EventSource立即自动重连,错误越密集重连越猛
七、前端消费:完整 EventSource 示例
给前端同学一份能直接用的代码(无依赖,浏览器原生):
html
<div id="status"></div>
<div id="bubble"></div>
<div id="refs"></div>
<script>
const bubble = document.getElementById('bubble');
const status = document.getElementById('status');
const refs = document.getElementById('refs');
// EventSource 只支持 GET;复杂参数用 POST + fetch ReadableStream 的方案
const url = '/api/streaming/mall?sessionId=u1&message=' +
encodeURIComponent('我的手机号是13800138001,帮我查下积分');
const source = new EventSource(url);
source.onmessage = (e) => {
const event = JSON.parse(e.data);
switch (event.type) {
case 'token':
bubble.textContent += event.data; // 打字机正文
break;
case 'tool':
const t = event.data;
status.textContent = `已完成 ${t.name}(${t.durationMs}ms):${t.result}`;
break;
case 'retrieved':
refs.innerHTML = event.data
.map(s => `<div class="ref">引用:${s}</div>`).join('');
break;
case 'done':
console.log('统计:', event.data); // firstTokenLatencyMs 等
source.close(); // 主动关闭,防止 EventSource 自动重连
break;
case 'error':
status.textContent = '出错了:' + event.data;
source.close();
break;
}
};
source.onerror = () => { /* 网络断开,EventSource 会自动重连;需要终止时调用 source.close() */ };
</script>
两个细节:done/error 后主动 source.close() ------不关闭的话 EventSource 认为连接异常断开,会自动重连导致重复请求;GET 参数要 encodeURIComponent------中文消息不编码会在部分网关被拒。
八、踩坑记录(全部实测)
坑 1:多个 ChatMemoryProvider bean 导致应用启动失败 ⭐ 本篇最大坑
写完代码编译通过,启动直接爆炸:
yaml
dev.langchain4j.service.IllegalConfigurationException:
Conflict: multiple beans of type dev.langchain4j.memory.chat.ChatMemoryProvider
are found: [chatMemoryProvider, messageWindowProvider, tokenWindowProvider, dynamicWindowProvider].
原因:@AiService 默认 AUTOMATIC 装配模式,工程里有 4 个 ChatMemoryProvider bean(ChatMemory 篇引入了 3 个),框架无法唯一确定。更坑的是 :反编译 AiServicesAutoConfig.addBeanReference() 确认------AUTOMATIC 模式下你在注解里写的 chatMemoryProvider = "xxx" 会被完全忽略(只看 bean 数量,不看注解属性),指定了照样冲突。
css
AUTOMATIC 模式: bean 数 == 1 → 装配; > 1 → 抛异常(注解属性被忽略)
EXPLICIT 模式: 属性非空 → 按名字装配; 空 → 跳过(不装配也不报错)
修复:所有 @AiService 接口显式声明 wiringMode = EXPLICIT 并指定 bean 名。注意 EXPLICIT 模式下没指定的组件不会自动装配 ------chatModel 留空就是真的没有模型,调用时才炸。DashScope starter 的模型 bean 名:qwenChatModel / qwenStreamingChatModel(看 DashScopeAutoConfiguration 的 @Bean 方法名)。
修复后的完整注解:
java
@AiService(
wiringMode = AiServiceWiringMode.EXPLICIT,
streamingChatModel = "qwenStreamingChatModel",
chatMemoryProvider = "chatMemoryProvider",
tools = {"mallToolService"},
contentRetriever = "contentRetriever"
)
坑 2:completeWithError 引发重连风暴
流出错时如果调 emitter.completeWithError(error),浏览器 EventSource 的行为是立即自动重连------同一个问题错误越密集,重连越猛,LLM 接口瞬间被打爆。
正确做法:发一条 error 事件(正常 SSE 数据),再 emitter.complete()(正常关闭)。前端收到 error 事件后 source.close() 终止重连。
坑 3:SseEmitter 的 complete 幂等性
onCompleteResponse 和 onError 理论上只触发其一,但网络异常时回调可能乱序到达。emitter.complete() 调两次抛 IllegalStateException。用 AtomicBoolean.compareAndSet(false, true) 保证完成逻辑只走一次,所有 send 前先检查标志位。
坑 4:忘了 start()
TokenStream 是惰性的,链好回调后必须调用 start() 才真正发起请求。症状:接口秒回、一个事件都没有、日志里也没有 LLM 调用记录。链式写法把 start() 放链尾最不容易忘。
坑 5:SSE 数据里的换行符
SSE 协议用 \n\n 分隔事件,data: 后的内容遇到换行会被拆成多条 data。所以事件必须序列化成单行 JSON(Jackson 默认就是不换行的)。千万别直接 emitter.send(含换行的文本)。
九、LangChain4j 1.17.2 流式 API 速查表
| API | 包路径 | 说明 |
|---|---|---|
StreamingChatModel |
dev.langchain4j.model.chat |
底层流式模型接口(langchain4j-core) |
StreamingChatResponseHandler |
dev.langchain4j.model.chat.response |
流式回调:onPartialResponse / onCompleteResponse / onError |
ChatResponse |
dev.langchain4j.model.chat.response |
完整响应:aiMessage() / tokenUsage() / finishReason() |
TokenStream |
dev.langchain4j.service |
AiService 声明式流式返回类型,链式回调 + start() |
ToolExecution |
dev.langchain4j.service.tool |
工具执行记录:request() / result() / duration() / hasFailed() |
Content |
dev.langchain4j.rag.content |
RAG 检索内容:textSegment().text() |
@AiService |
dev.langchain4j.service.spring |
声明式注解:wiringMode / streamingChatModel / tools / contentRetriever |
AiServiceWiringMode |
dev.langchain4j.service.spring |
EXPLICIT(显式指定)/ AUTOMATIC(自动装配,多 bean 冲突) |
SseEmitter |
org.springframework.web.servlet.mvc.method.annotation |
Spring MVC 的 SSE 发射器 |
TokenStream 回调一览
| 回调 | 触发时机 | 典型用途 |
|---|---|---|
onPartialResponse(Consumer<String>) |
每个增量文本片段 | 打字机正文 |
onToolExecuted(Consumer<ToolExecution>) |
每个工具执行完毕 | 过程状态卡片、执行日志 |
onRetrieved(Consumer<List<Content>>) |
RAG 检索完成 | 引用来源展示 |
onCompleteResponse(Consumer<ChatResponse>) |
流正常结束 | token 用量统计、停止动画 |
onError(Consumer<Throwable>) |
流中出错 | 错误提示、降级 |
ignoreErrors() |
--- | 配置项:非致命错误不中断流 |
start() |
--- | 启动流(必须调用) |
十、总结:流式改造的 5 步路径
typescript
┌──────────────────────────────────────────────────────────────┐
│ 流式输出:从裸文本到过程直播 │
│ │
│ 第 1 步 定义事件协议 StreamingEvent record + 5 种 type │
│ 第 2 步 手动桥接 StreamingChatModel + SseEmitter │
│ 第 3 步 声明式改造 @AiService 返回 TokenStream │
│ 第 4 步 过程直播 onToolExecuted / onRetrieved │
│ 第 5 步 生产化 桥接器 + TTFT 指标 + 错误语义 │
└──────────────────────────────────────────────────────────────┘
新增文件清单
| 文件 | 作用 |
|---|---|
streaming/StreamingEvent.java |
SSE 结构化事件模型(record) |
streaming/StreamingMetrics.java |
首字延迟/吞吐/成功率指标 |
streaming/StreamingSseBridge.java |
TokenStream → SseEmitter 桥接器 |
streaming/StreamingAssistant.java |
声明式流式对话(记忆版) |
streaming/StreamingMallAssistant.java |
三合一流式商城客服 |
streaming/StreamingController.java |
SSE REST 端点 + 指标端点 |
同步修复(EXPLICIT 装配):AssistantService、MemoryAssistantService。
测试接口一览
ini
GET /api/streaming/chat-raw?message=... 手动模式流式(结构化事件)
GET /api/streaming/chat?sessionId=...&message=... 声明式流式 + 多轮记忆
GET /api/streaming/mall?sessionId=...&message=... 三合一流式客服(工具+RAG 事件)
GET /api/streaming/metrics 流式运行指标
核心收获
- TTFT 是流式的灵魂------总耗时不变,首字延迟决定用户感知,指标体系围绕它建
- 事件协议优先于裸文本------一个 record 的成本,换来工具状态、RAG 引用、统计信息的全维度推送
- TokenStream 是声明式的完整闭环------返回类型从 String 换成 TokenStream,AiService 全部能力无损保留
- EXPLICIT 装配是多 bean 工程的必修课------AUTOMATIC 模式下注解属性会被忽略,多 bean 直接启动失败
- 错误语义要精心设计------completeWithError 会触发 EventSource 重连风暴,error 事件 + 正常关闭才是正解