流式对话实战:LangChain4j + SSE 打字机效果全链路指南

0 阅读15分钟

流式对话实战: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,首字延迟):

场景总耗时首字延迟用户感受
同步接口8s8s"卡死了?是不是该刷新?"
流式接口8s0.5s"已经在回答了,等着就行"

总耗时一样,体验天差地别。心理学上这叫感知性能:用户对"正在进行"的容忍度远高于"毫无响应"。ChatGPT 们 2023 年就教育完了市场——现在没有打字机效果的 AI 产品,用户第一反应就是"这玩意是不是套壳的"。

流式还带来一个同步接口给不了的能力:过程可见。"正在查询您的积分……""已为您检索到退换货政策"——工具调用和 RAG 检索的中间过程,可以在正文输出前推送给前端。同步接口的 8 秒空白在流式世界里是 8 秒的信息流。

为什么是 SSE 而不是 WebSocket?

对比项SSEWebSocket
方向服务端 → 客户端(单向)双向
协议纯 HTTP独立协议(Upgrade)
断线重连浏览器 EventSource 内置自动重连需自己实现
代理/网关兼容性好(就是普通 HTTP)部分网关需额外配置
适用场景LLM 流式输出(天生单向)聊天室、协同编辑

LLM 对话的流式输出本质是单向推送——用户发完消息后只有服务端在说话。SSE 用最简单的方式覆盖了这个场景,而且 Spring MVC 原生支持(SseEmitter),不需要引入 spring-boot-starter-websocket


一、起点:第一篇里的流式雏形

第一篇里其实留了一个最简流式端点:

// 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;
}

它能跑,但离生产有三个距离:

  1. 裸文本流——前端只能拼接字符串,无法区分"正文 token"和"控制信号",更别说插入工具执行状态
  2. 无生命周期保护——emitter.complete() 调两次直接抛 IllegalStateException;客户端中途关页面,send()IOException 会把异常打回框架回调线程
  3. 无观测——首字延迟多少?token 吞吐多少?一无所知

本篇的策略就是逐个补齐。先看 LangChain4j 1.17.2 流式相关的三个核心 API:

// ① 底层流式模型接口(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 统计,必须定义事件协议:

// 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:

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,同时统计首字延迟:

// 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,其余一字不改

// 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 用链式回调替代了匿名类:

tokenStream
    .onPartialResponse(token -> ...)        // 增量正文
    .onToolExecuted(execution -> ...)       // 工具执行完毕
    .onRetrieved(contents -> ...)           // RAG 检索命中
    .onCompleteResponse(response -> ...)    // 流结束
    .onError(error -> ...)                  // 出错
    .start();                               // 启动流(忘了调用 = 没有任何输出)

这里有个本篇最大的坑,单独提前说(完整过程见踩坑记录):

wiringMode = AiServiceWiringMode.EXPLICIT 不是可选项。 我们的工程因为《Chat Memory 进阶》一篇定义了 4 个 ChatMemoryProvider bean,AUTOMATIC 装配模式会直接抛 IllegalConfigurationException: Conflict: multiple beans of type ChatMemoryProvider应用都启动不了。而且反编译确认:AUTOMATIC 模式下注解里写的 chatMemoryProvider = "xxx" 属性会被完全忽略——指定了也没用,必须切 EXPLICIT 模式。


四、策略三:流式 + Chat Memory——多轮对话的打字机

TokenStream 和记忆是天然兼容的:@MemoryId 隔离会话,流式只管输出。

// 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;
}

测试流程(浏览器直接访问即可):

第一轮:/api/streaming/chat?sessionId=u1&message=我叫张伟,记住我的名字
第二轮:/api/streaming/chat?sessionId=u1&message=我叫什么名字?回答前先思考

第二轮的 SSE 事件流:

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)整体升级为流式版。

// 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);
}

关键在 onToolExecutedonRetrieved 两个回调。同步版里,工具调用是黑盒——用户只能等;流式版里,每次工具执行、每次 RAG 检索都能推事件:

用户:我的手机号是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 事件渲染成"已完成查询:积分余额"的状态卡片。对比一下同一问题的两种体验:

同步版时间线(用户视角):
  0s    发送消息
  0-8s  空白等待(工具在跑、模型在生成,用户不知道)
  8s    一次性出现完整回答

流式版时间线(用户视角):
  0s      发送消息
  0.4s    "好的"(首字出现)
  0.6s    [已完成查询:积分余额](工具状态卡片)
  0.8s    "张伟您好,您当前的积分余额……"(正文逐字推进)
  3s      done(回答完成)

onToolExecuted 回调拿到的 ToolExecution 对象信息相当丰富(1.17.2 实际 API):

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 之间的核心组件,也是生产级细节的集中地:

// 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 吞吐

// 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 随时可查:

{
  "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 大盘。另外三个生产细节:

  1. 心跳保活:LLM 思考 + 工具执行期间可能 30 秒无输出,Nginx/网关默认 60s 超时会掐断连接。加一个 @Scheduled 定时器对活跃 emitter 发 SSE 注释行(: ping\n\n)即可保活
  2. 背压:SseEmitter 底层是 Servlet 异步 IO,发送速率高于客户端消费速率时数据堆在服务端缓冲。LLM 输出速率通常低于网络速率,一般无需处理;极端场景考虑 Reactor + WebFluxFlux<ServerSentEvent>
  3. 重连风暴:见踩坑记录第 2 条——completeWithError() 会让浏览器 EventSource 立即自动重连,错误越密集重连越猛

七、前端消费:完整 EventSource 示例

给前端同学一份能直接用的代码(无依赖,浏览器原生):

<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 导致应用启动失败 ⭐ 本篇最大坑

写完代码编译通过,启动直接爆炸:

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 数量,不看注解属性),指定了照样冲突。

AUTOMATIC 模式: bean 数 == 1 → 装配; > 1 → 抛异常(注解属性被忽略)
EXPLICIT 模式:  属性非空 → 按名字装配; 空 → 跳过(不装配也不报错)

修复:所有 @AiService 接口显式声明 wiringMode = EXPLICIT 并指定 bean 名。注意 EXPLICIT 模式下没指定的组件不会自动装配——chatModel 留空就是真的没有模型,调用时才炸。DashScope starter 的模型 bean 名:qwenChatModel / qwenStreamingChatModel(看 DashScopeAutoConfiguration 的 @Bean 方法名)。

修复后的完整注解:

@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 幂等性

onCompleteResponseonError 理论上只触发其一,但网络异常时回调可能乱序到达。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包路径说明
StreamingChatModeldev.langchain4j.model.chat底层流式模型接口(langchain4j-core)
StreamingChatResponseHandlerdev.langchain4j.model.chat.response流式回调:onPartialResponse / onCompleteResponse / onError
ChatResponsedev.langchain4j.model.chat.response完整响应:aiMessage() / tokenUsage() / finishReason()
TokenStreamdev.langchain4j.serviceAiService 声明式流式返回类型,链式回调 + start()
ToolExecutiondev.langchain4j.service.tool工具执行记录:request() / result() / duration() / hasFailed()
Contentdev.langchain4j.rag.contentRAG 检索内容:textSegment().text()
@AiServicedev.langchain4j.service.spring声明式注解:wiringMode / streamingChatModel / tools / contentRetriever
AiServiceWiringModedev.langchain4j.service.springEXPLICIT(显式指定)/ AUTOMATIC(自动装配,多 bean 冲突)
SseEmitterorg.springframework.web.servlet.mvc.method.annotationSpring MVC 的 SSE 发射器

TokenStream 回调一览

回调触发时机典型用途
onPartialResponse(Consumer<String>)每个增量文本片段打字机正文
onToolExecuted(Consumer<ToolExecution>)每个工具执行完毕过程状态卡片、执行日志
onRetrieved(Consumer<List<Content>>)RAG 检索完成引用来源展示
onCompleteResponse(Consumer<ChatResponse>)流正常结束token 用量统计、停止动画
onError(Consumer<Throwable>)流中出错错误提示、降级
ignoreErrors()配置项:非致命错误不中断流
start()启动流(必须调用)

十、总结:流式改造的 5 步路径

┌──────────────────────────────────────────────────────────────┐
│            流式输出:从裸文本到过程直播                         │
│                                                              │
│  第 1 步   定义事件协议     StreamingEvent record + 5type │
│  第 2 步   手动桥接         StreamingChatModel + SseEmitter   │
│  第 3 步   声明式改造       @AiService 返回 TokenStream       │
│  第 4 步   过程直播         onToolExecuted / onRetrieved      │
│  第 5 步   生产化           桥接器 + TTFT 指标 + 错误语义      │
└──────────────────────────────────────────────────────────────┘

新增文件清单

文件作用
streaming/StreamingEvent.javaSSE 结构化事件模型(record)
streaming/StreamingMetrics.java首字延迟/吞吐/成功率指标
streaming/StreamingSseBridge.javaTokenStream → SseEmitter 桥接器
streaming/StreamingAssistant.java声明式流式对话(记忆版)
streaming/StreamingMallAssistant.java三合一流式商城客服
streaming/StreamingController.javaSSE REST 端点 + 指标端点

同步修复(EXPLICIT 装配):AssistantServiceMemoryAssistantService

测试接口一览

GET /api/streaming/chat-raw?message=...          手动模式流式(结构化事件)
GET /api/streaming/chat?sessionId=...&message=... 声明式流式 + 多轮记忆
GET /api/streaming/mall?sessionId=...&message=... 三合一流式客服(工具+RAG 事件)
GET /api/streaming/metrics                        流式运行指标

核心收获

  1. TTFT 是流式的灵魂——总耗时不变,首字延迟决定用户感知,指标体系围绕它建
  2. 事件协议优先于裸文本——一个 record 的成本,换来工具状态、RAG 引用、统计信息的全维度推送
  3. TokenStream 是声明式的完整闭环——返回类型从 String 换成 TokenStream,AiService 全部能力无损保留
  4. EXPLICIT 装配是多 bean 工程的必修课——AUTOMATIC 模式下注解属性会被忽略,多 bean 直接启动失败
  5. 错误语义要精心设计——completeWithError 会触发 EventSource 重连风暴,error 事件 + 正常关闭才是正解