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

流式对话实战: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;
}

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

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

关键在 onToolExecutedonRetrieved 两个回调。同步版里,工具调用是黑盒------用户只能等;流式版里,每次工具执行、每次 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 大盘。另外三个生产细节:

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

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 包路径 说明
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 装配):AssistantServiceMemoryAssistantService

测试接口一览

ini 复制代码
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 事件 + 正常关闭才是正解
相关推荐
白色机械键盘1 小时前
领域大模型微调数据集构建实战
大数据·人工智能
极客互动API1 小时前
企业微信 iPad 协议服务搭建与 AI 回调实战
网络·汇编·人工智能·微信·企业微信·ipad
空堂与归1 小时前
token畅享?FreeToken单卡5090跑满血DeepSeek_V4Flash
人工智能·free token
AI码农小姐姐1 小时前
AI漫剧用什么软件制作?知漫剧小说导入成片教程
人工智能
ycjunhua1 小时前
Spring AI 2.0 GA:ToolCallingAdvisor 重构与 Java Agent 新范式
java·人工智能·spring
二川bro2 小时前
零门槛上手DeepSeek Harness,从零自制arxiv搜索插件
人工智能
cxr8282 小时前
数学的本质:关系、模式与不变量的结构世界
人工智能·算法·机器学习
东莞和裕包装2 小时前
如何管控广州定制纸箱生产中的啤切精度与印刷色差质量问题
大数据·运维·网络·人工智能
AIDANHANG2 小时前
放开靠时间戳对日志前先核请求ID跨服务传播与采样关联
前端·人工智能