让大模型会“说话”:基于 Spring WebSocket 的毫秒级流式 RAG 对话

程序员云喜的编程成长之路:流式对话工程实现全解析

大家好,我是程序员云喜,向大家分享我的编程成长之路!

前言

最早让我产生兴趣的,是看豆包的"打字机式"对话效果:它不像普通对话那样卡顿几秒后突然冒出一大段文字,而是像真人一样边思考边表达,随时可以打断,说完还会有一个自然的收尾。后来,在学习RAG的过程中我逐渐了解到了它的本质,所以我把这种体验拆解成了四个关键点:传输、生成、控制和可观测性。这篇文章主要聚焦于"流式对话"的工程实现。

一、为什么要"流式"?

流式传输的核心优势可通过三个典型场景体现:

  1. 首包时间(TTFB)骤降
    用户毫秒见字,体感从"等待答案"变成"看着答案长出来",大幅提升用户体验。
  2. 可中断
    长回复中往往只需要前30%信息,"说到就停"的能力能节省算力和时间------毕竟这年头token成本不低。
  3. 强掌控感
    明确的完成/停止信号,让UI状态可预期、可追踪,避免用户对"是否还在生成"产生困惑。

二、流式的本质

流式的核心逻辑是:把"第一个可用的字"立刻送出去,具体拆解为四层实现:

  • 传输层:用WebSocket,全双工通信,无需轮询。
  • 模型层:使用支持流式输出(SSE/Chunked)的接口。
  • 后端层:不攒整段内容,收到一个token/片段就发送一个chunk。
  • 前端层:增量渲染,自然形成"打字机"效果。

三、消息协议(最小可用设计)

为降低前后端耦合,将流式对话消息简化为四类核心事件,协议越简单,越不容易踩坑。

1. 核心消息格式

json 复制代码
// 1. 流式正文(增量内容)
{ "type": "chunk", "chunk": "......当前片段......", "ts": 1710000000000 }

// 2. 正常完成(生成结束)
{ "type": "completion", "status": "finished", "ts": 1710000002345 }

// 3. 用户主动停止(中断响应)
{ "type": "stop", "message": "响应已停止", "ts": 1710000001234 }

// 4. 兜底报错(异常处理)
{ "type": "error", "message": "AI服务暂不可用,请稍后重试", "ts": 1710000001200 }

2. 前端核心处理逻辑

  • 收到chunk:立即append到页面,实现增量渲染。
  • 收到completion/stop/error:执行收尾操作(如隐藏加载状态、记录埋点)。

3. 协议设计说明

维度 详情
用途 统一前后端事件语义,降低耦合度
输入 后端向前端推送的事件对象
输出 UI渲染、状态流转、埋点数据
关键设计点 事件最小集(避免冗余)、时间戳(排序)、可扩展字段(reqId/convId/seq)
常见坑 事件过多导致状态机复杂、字段未版本化(迭代兼容问题)
扩展方案 增加keepalive(心跳)、tool_call(工具调用)、metadata(元数据)

四、传输层选型:HTTP、SSE 与 WebSocket 的差异与取舍

1. 选型结论

  • 若仅需"单向、持续下行"的简单订阅(如日志推送、行情更新),可选SSE;
  • 若需"低延迟+双向控制"(随时stop、打点、参数热切换、工具调用回传),WebSocket更适配;
  • 从实现复杂度与运维一致性看,WebSocket在流式对话中更省心、更通用。

2. 最小示例对比

(1)HTTP Chunked(浏览器 Fetch Streams)
javascript 复制代码
const resp = await fetch('/api/stream');
const reader = resp.body.getReader();
const decoder = new TextDecoder();
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  append(decoder.decode(value)); // 增量渲染
}
(2)WebSocket(双向控制更简单)
javascript 复制代码
const ws = new WebSocket('wss://host/ws/stream');
// 接收流式内容
ws.onmessage = (e) => append(JSON.parse(e.data).chunk);
// 发送提问请求
ws.send(JSON.stringify({ type: 'ask', text: '你好' }));
// 主动停止生成
ws.send(JSON.stringify({ type: 'stop' }));

五、端到端流程与核心实现

1. 端到端流程要点

  • 传输层"无缓冲心态":收到片段立即发送,不攒包;
  • 用户随时"停止":后端立即停止下发新块,并返回stop确认;
  • 完成有明确信号:便于前端收尾与埋点统计。

2. 后端核心代码实现

(1)会话网关(ChatGateway.java):协议编解码与LLM粘合
java 复制代码
public class ChatGateway {
    private final StreamingLlmClient llm;
    // 会话级停止标志,线程安全
    private final Map<String, Boolean> stopFlags = new ConcurrentHashMap<>();

    // 处理"提问"请求
    public void onAsk(WebSocketSession session, String question, List<Map<String,String>> history, String context) {
        stopFlags.put(session.getId(), false); // 初始化停止标志
        llm.stream(
            question, context, history,
            // 收到LLM片段时,推送chunk事件
            chunk -> send(session, Map.of("type","chunk","chunk",chunk,"ts",System.currentTimeMillis())),
            // LLM生成完成时,推送completion事件
            () -> send(session, Map.of("type","completion","status","finished","ts",System.currentTimeMillis())),
            // 发生错误时,推送error事件
            err -> send(session, Map.of("type","error","message","AI服务暂不可用","ts",System.currentTimeMillis())),
            // 停止条件判断(会话级标志)
            () -> Boolean.TRUE.equals(stopFlags.get(session.getId()))
        );
    }

    // 处理"停止"请求
    public void onStop(WebSocketSession session){
        stopFlags.put(session.getId(), true); // 标记会话停止
        // 推送stop事件给前端
        send(session, Map.of("type","stop","message","响应已停止","ts",System.currentTimeMillis()));
    }

    // 安全发送(避免连接异常导致的线程终止)
    private void send(WebSocketSession session, Map<String, Object> data) {
        if (session.isOpen()) {
            try {
                session.sendMessage(new TextMessage(new ObjectMapper().writeValueAsString(data)));
            } catch (IOException e) {
                // 日志记录,避免抛出异常中断流程
                log.error("WebSocket send failed: {}", e.getMessage());
            }
        }
    }
}

代码说明

  • 用途:作为会话入口,负责接收前端指令、调用LLM客户端、推送事件;
  • 关键设计:按会话维护stopFlags(支持并发控制)、safeSend(异常保护);
  • 常见坑:未清理会话资源(内存泄漏)、发送异常未捕获(线程终止);
  • 扩展方案:增加rateLimiter(限流)、metrics(监控钩子)、onClose(资源清理)。
(2)LLM客户端(StreamingLlmClient.java):适配流式LLM/SSE服务
java 复制代码
public class StreamingLlmClient {
    private final WebClient webClient; // 响应式HTTP客户端

    /**
     * 流式调用LLM
     * @param question 用户问题
     * @param context 上下文(如RAG检索结果)
     * @param history 对话历史
     * @param onChunk 片段回调
     * @param onComplete 完成回调
     * @param onError 错误回调
     * @param shouldStop 停止条件
     */
    public void stream(String question, String context, List<Map<String,String>> history,
                       Consumer<String> onChunk, Runnable onComplete,
                       Consumer<Throwable> onError, BooleanSupplier shouldStop){
        // 构建LLM请求体(适配主流模型API格式)
        Map<String,Object> body = Map.of(
            "model","your-model", // 模型名称
            "stream", true, // 开启流式
            "messages", List.of(
                Map.of("role","system","content", buildSystemPrompt(context)), // 系统提示
                // 追加对话历史(省略历史拼接逻辑)
                Map.of("role","user","content", question) // 用户问题
            )
        );

        webClient.post().uri("/v1/chat/completions") // LLM API地址
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(body)
            .retrieve()
            .bodyToFlux(String.class) // 流式接收响应(Flux为响应式流)
            .doOnError(onError) // 错误处理
            .subscribe(raw -> {
                // 检查是否需要停止
                if (shouldStop.getAsBoolean()) {
                    onComplete.run();
                    return;
                }
                // 解析LLM返回的增量文本(需适配具体厂商格式,如OpenAI的SSE)
                String delta = parseDelta(raw);
                if (!delta.isEmpty()) {
                    onChunk.accept(delta); // 回调传递片段
                }
                // 检查是否生成完成(如LLM返回finish_reason或[DONE]标志)
                if (isFinished(raw)) {
                    onComplete.run();
                }
            });
    }

    // 构建系统提示(拼接上下文)
    private String buildSystemPrompt(String context) {
        return "基于以下上下文回答问题:\n" + context;
    }

    // 解析增量文本(示例逻辑,需按实际返回格式调整)
    private String parseDelta(String raw) {
        // 省略具体解析逻辑(如处理SSE的data:前缀、JSON解析delta字段)
        return "解析后的增量文本";
    }

    // 判断是否生成完成(示例逻辑)
    private boolean isFinished(String raw) {
        return raw.contains("[DONE]") || raw.contains("\"finish_reason\":\"stop\"");
    }
}

代码说明

  • 用途:适配任意支持流式的LLM/SSE服务(如OpenAI、本地化模型);
  • 关键设计:parseDelta/isFinished可插拔(适配不同厂商)、响应式流(背压支持);
  • 常见坑:把控制token(如[DONE])当文本输出、遗漏上游完成信号(导致"悬挂");
  • 扩展方案:增加onToolCall(工具调用回调)、onCitation(引用回调)、多路模型竞速(racing)。
(3)两段代码协作关系
模块 核心职责
ChatGateway(网关) 会话管理、指令接收(ask/stop)、事件推送(chunk/completion等)
StreamingLlmClient LLM请求构建、流式响应解析、回调触发(onChunk/onComplete)、停止条件判断

两者共同构成流式对话系统:网关负责"协议交互",LLM客户端负责"模型通信",分工协作实现端到端流式能力。

六、工程落地关键细节

1. WebSocket 配置与跨域(WebSocketConfig.java)

java 复制代码
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    private final ChatWebSocketHandler handler;

    // 构造注入处理器
    public WebSocketConfig(ChatWebSocketHandler handler) { 
        this.handler = handler; 
    }

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(handler, "/ws/stream") // 注册WebSocket端点
                .setAllowedOrigins("https://your-frontend.example.com") // 精确CORS白名单(避免*)
                .addInterceptors(new WebSocketHandshakeInterceptor() {
                    // 握手期鉴权(如Cookie/Token验证)
                    @Override
                    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
                        String token = request.getHeaders().getFirst("Authorization");
                        // 鉴权逻辑(省略,如验证token有效性)
                        if (token == null || !isValidToken(token)) {
                            response.setStatusCode(HttpStatus.UNAUTHORIZED);
                            return false;
                        }
                        return true;
                    }
                });
    }

    // 简单的token验证(示例)
    private boolean isValidToken(String token) {
        // 实际项目中对接认证服务(如OAuth2、JWT验证)
        return "valid-token".equals(token);
    }
}

关键要点

  • 安全:精确CORS白名单(禁用*)、握手期鉴权(避免未授权连接);
  • 运维:集群场景需配置粘性会话(Session Affinity);
  • 常见坑:代理未透传Upgrade/Connection头(导致WebSocket握手失败)、网关切断超时连接;
  • 扩展:网关配置proxy_read_timeout(延长超时)、开启压缩(减少带宽)。

2. 前端最小实现与重连(ws-client.js)

javascript 复制代码
let ws;
let backoff = 500; // 重连退避时间(初始500ms)
const SESSION_ID = localStorage.getItem('sessionId') || generateSessionId(); // 会话ID(持久化)

// 生成唯一会话ID
function generateSessionId() {
    const id = `session_${Date.now()}_${Math.random().toString(36).slice(2)}`;
    localStorage.setItem('sessionId', id);
    return id;
}

// 连接WebSocket
function connect() {
    // 构建连接URL(携带会话ID)
    const wsUrl = `wss://host/ws/stream?sessionId=${SESSION_ID}`;
    ws = new WebSocket(wsUrl);

    // 连接成功:重置退避时间
    ws.onopen = () => {
        backoff = 500;
        console.log('WebSocket connected');
    };

    // 接收消息:处理四类事件
    ws.onmessage = (ev) => {
        const msg = JSON.parse(ev.data);
        switch (msg.type) {
            case 'chunk':
                appendToUI(msg.chunk); // 增量渲染
                break;
            case 'completion':
                finishUI(); // 收尾(如隐藏加载动画)
                recordMetric('completion', msg.ts); // 埋点
                break;
            case 'stop':
                stopUI(); // 显示"已停止"状态
                break;
            case 'error':
                showErrorUI(msg.message); // 显示错误提示
                break;
        }
    };

    // 连接关闭:指数退避重连(最大10秒)
    ws.onclose = (ev) => {
        console.log('WebSocket closed, reconnecting...');
        setTimeout(connect, Math.min(backoff *= 2, 10000));
    };

    // 连接错误:同close逻辑(触发重连)
    ws.onerror = (err) => {
        console.error('WebSocket error:', err);
        ws.close();
    };
}

// 发送提问请求
function ask(text) {
    if (ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({
            type: 'ask',
            text: text,
            sessionId: SESSION_ID
        }));
    } else {
        showErrorUI('连接未就绪,请稍后重试');
    }
}

// 发送停止请求
function stop() {
    if (ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({
            type: 'stop',
            sessionId: SESSION_ID
        }));
    }
}

// 初始化连接
connect();

关键要点

  • 重连策略:指数退避(避免高频重试压垮服务);
  • 性能优化:合并极短间隔的chunk(减少DOM频繁操作导致的抖动);
  • 常见坑:未处理断线重连后的消息顺序(导致UI乱序);
  • 扩展方案:加入seq字段(消息排序)、本地缓冲(重连后补传)。

七、核心优化方向

完成与中断:用"信号",别靠"感觉"

  • 完成优先用上游结束事件;没有就用"2 秒无增长 + 最长 30 秒"兜底;
  • 用户停止:设置会话级停止标志,后端不再下发新 chunk,并立即返回 type=stop 确认;
  • 错误:统一 type=error 文案,避免泄露内部细节;日志里保留堆栈。

流控与背压:既"丝滑"又省资源

  • chunk 粒度:20--50 tokens/次较均衡;
  • 组包策略:按 token 数或字符数聚合,控制 flush 频率;
  • 背压:使用响应式流/缓冲队列,检测发送阻塞时丢弃低优先级日志类事件;
  • 并发:限制并发会话与单会话速率,避免模型和网络打满。

示例(响应式背压):

java 复制代码
webClient.post().uri("/v1/chat/completions")
  .retrieve()
  .bodyToFlux(String.class)
  .onBackpressureBuffer(1024)
  .limitRate(64)
  .subscribe(raw -> { /* parse + onChunk */ });

连接管理与保活

  • 心跳:定期 ping/pong 或发送 keepalive 事件,监测断线;
  • 超时:上游请求、会话无活动、全局最长会话时间分别设定;
  • 重连:前端指数退避重连,恢复后允许"继续追问"而不丢上下文。

会话记忆:克制的"短期记忆"

  • 只记最近 20 条消息,TTL 7 天;
  • 存角色、文本、时间戳,必要时脱敏/截断;
  • conversation:{id} 存历史;用户与会话用 user:{uid}:current_conversation 关联。

监控指标:盯住"可感知"的

  • 体验:首包时间、平均完成时间、chunk 速率(chunk/s);
  • 行为:停止率、继续追问率、重试率;
  • 稳定:错误率、P95 延迟、连接断开率、重连成功率;
  • 资源:会话并发数、平均 chunk 大小、带宽占用。

指标命名建议:

  • stream_ttfb_msstream_chunks_per_secondstream_completion_ratio
  • stream_stop_ratiostream_error_ratiostream_disconnect_ratio
  • 日志携带 reqId/sessionId/convId/userScopedId,便于串联。

我踩过的坑(以及规避)

  • 只发整段 → 体验崩。一定要增量转发。
  • 权限写进 must → 分数乱/缓存差。权限放 filter
  • 上下文太"长篇大论" → 模型跑偏。限制段长与段数。
  • 没有完成信号 → 前端不知何时收尾。务必有 completion
  • 停止只关前端 → 后端还在流,浪费算力。要会话级停止标志。
  • 把错误细节回给用户 → 安全隐患。统一文案,细节留日志。

一套可以"先用起来"的默认组合

  • WebSocket:四类消息 chunk/completion/stop/error;
  • 流控:20--50 tokens/次、按需聚合 flush;
  • 完成:上游结束事件 → 2 秒无增长 → 全局超时;
  • 停止:会话停止标志 + 立即返回 stop
  • 记忆:最近 20 条、TTL 7 天;
  • 保活:ping/pong 心跳 + 指数退避重连;
  • 监控:TTFB、chunk/s、停止率、错误率、断线率。

结语

所谓"豆包式"的一句一句,本质是"把第一口热乎的字先端上来"。当传输、生成、检索、控制协同起来,用户不再"等答案",而是在"看它开口说话"。按上面的最小组合做起,你很快就能把"会说话"的 RAG 放进生产。

相关推荐
铅笔侠_小龙虾2 小时前
JVM 目录
java·jvm
用户6120414922132 小时前
jsp+servlet做的医院挂号看诊管理系统
java·javascript·mysql
€8112 小时前
Java入门级教程21——Java 缓存技术、RMI远程方法调用、多线程分割大文件
java·开发语言·java缓存代理模式的实现·java rmi远程方法调用·多线程分割大文件
渣哥2 小时前
Java线程池那些坑:我与线程池的恩怨情仇
java
hour_go2 小时前
BPEL:企业流程自动化的幕后指挥家
java·运维·自动化
建群新人小猿2 小时前
客户标签自动管理:标签自动化运营,画像持久保鲜
android·java·大数据·前端·git
龙茶清欢3 小时前
3、推荐统一使用 ResponseEntity<T> 作为控制器返回类型
java·spring boot·spring cloud
shark_chili3 小时前
网卡数据包处理全攻略:DMA、中断、NAPI机制深度解析
后端
RoyLin3 小时前
命名实体识别
前端·后端·typescript