让大模型会“说话”:基于 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 放进生产。

相关推荐
用户298698530142 分钟前
.NET 文档自动化:Spire.Doc 设置奇偶页页眉/页脚的最佳实践
后端·c#·.net
码路飞31 分钟前
GPT-5.3 Instant 终于学会好好说话了,顺手对比了下同天发布的 Gemini 3.1 Flash-Lite
java·javascript
序安InToo33 分钟前
第6课|注释与代码风格
后端·操作系统·嵌入式
xyy12334 分钟前
C#: Newtonsoft.Json 到 System.Text.Json 迁移避坑指南
后端
洋洋技术笔记36 分钟前
Spring Boot Web MVC配置详解
spring boot·后端
JxWang0537 分钟前
VS Code 配置 Markdown 环境
后端
navms40 分钟前
搞懂线程池,先把 Worker 机制啃明白
后端
JxWang0540 分钟前
离线数仓的优化及重构
后端
Nyarlathotep011341 分钟前
gin01:初探gin的启动
后端·go
JxWang0541 分钟前
安卓手机配置通用多屏协同及自动化脚本
后端