流式对话接口设计:SSE、WebSocket 与断线重连

0 阅读16分钟

摘要

大模型回答往往需要一段时间。如果后端等模型完整生成后再一次性返回,用户会长时间面对空白页面;如果直接把模型供应商的原始流转发给前端,又会遇到协议不统一、错误难处理、断线后无法恢复和消息状态不一致等问题。

生产级 AI 后端需要把模型流、业务消息、网络连接和客户端重连设计成一条完整链路。本文以企业智能客服与知识库助手为背景,比较 SSE 和 WebSocket 的适用场景,并设计一个基于 SSE 的流式对话接口,同时讨论:

  • SSE、WebSocket 和普通 HTTP 的差异;
  • 流式事件的统一格式;
  • Spring WebFlux 中如何返回 Flux<ServerSentEvent<?>>;
  • 如何保存 RUNNING、COMPLETED、FAILED 和 CANCELLED 状态;
  • 客户端断线后如何通过事件 ID 和消息状态恢复;
  • Nginx、网关、心跳、超时和背压;
  • 什么时候应该使用 WebSocket;
  • 如何让流式接口支持 RAG、工具调用和人工中断。

一、背景与问题

1. 一次性响应的体验问题

非流式接口通常是:

浏览器发送问题
    ↓
后端调用模型
    ↓
模型生成完整答案
    ↓
后端一次性返回

模型生成较长答案时,用户只能等待完整请求结束。流式接口则可以把生成过程拆成多个事件:

请求开始
    ↓
首个 Token
    ↓
内容片段
    ↓
引用信息
    ↓
完成事件

用户可以更早看到反馈,前端也可以显示“正在检索”“正在生成”“正在调用工具”等状态。

2. 流式接口不是简单地返回字符串

一个生产级流式会话至少需要处理:

问题说明
事件类型内容、状态、引用、错误和完成要区分
顺序客户端需要知道事件是否重复或丢失
连接浏览器、网关和模型服务都可能断开
状态数据库要知道生成是否完成
取消用户点击停止后,模型调用和后端任务要结束
重连客户端恢复后不能重复显示整段内容
审计要保存请求、模型、Token 和错误信息
代理缓冲Nginx 可能把多个片段缓存后一次性转发

如果没有统一协议,前端往往只能根据字符串内容猜测当前状态。

3. SSE 和 WebSocket 的定位

SSE 是服务器向客户端持续推送事件的单向通道,建立在 HTTP 之上;WebSocket 是双向通信协议,适合客户端和服务器都需要频繁发送消息的场景。Spring WebFlux 支持响应式 WebSocket,Spring 体系也支持使用 Flux<ServerSentEvent<?>> 返回 SSE。citeturn0search2turn0search8

简单对比:

维度SSEWebSocket
通信方向服务端到客户端为主双向
浏览器 APIEventSource 或 fetch 流WebSocket
协议HTTP text/event-streamWebSocket
自动重连EventSource 有基础机制,应用仍需设计恢复需要应用自行实现
代理兼容通常更简单需要正确支持 Upgrade
适合场景AI 文本生成、进度推送、通知双向协作、实时控制、语音和复杂事件

对于普通 AI 聊天,SSE 通常足够;对于实时语音、多人协作和双向 Agent 控制,WebSocket 更合适。

二、核心概念

1. SSE 事件格式

SSE 使用文本事件流,每个事件由字段组成,常见字段包括:

id: evt_1001
event: delta
data: {"text":"你好"}

事件之间使用空行分隔。建议 data 统一使用 JSON,不要让客户端同时解析纯文本和 JSON 两种格式。

2. 建议的事件类型

AI 对话可以设计为:

事件作用
start服务端已经接受请求并创建生成任务
status检索、工具调用或生成阶段变化
delta一段新增文本
citationRAG 来源或引用
tool_call工具调用开始、参数和结果摘要
usageToken 和费用统计
error发生错误
done正常完成
cancelled用户或服务端取消

示例:

{
  "id": "evt_1002",
  "event": "delta",
  "conversationId": "c_001",
  "messageId": "m_002",
  "seq": 2,
  "data": {
    "text": "根据当前资料,"
  },
  "createdAt": "2026-09-24T10:00:02+08:00"
}

3. messageId、requestId 和 eventId

三个 ID 解决不同问题:

ID含义
requestId一次客户端提交请求的幂等标识
messageId数据库中助手消息的业务标识
eventId流式事件的唯一标识,用于重连恢复

不要只使用一个 ID。客户端重试请求时,应通过 requestId 防止重复创建模型任务;客户端恢复流时,应通过 eventId 找到上次收到的位置。

4. 序列号和幂等

每个流式事件建议包含会话内单调递增的 seq:

start      seq=1
status     seq=2
delta      seq=3
delta      seq=4
citation   seq=5
done       seq=6

客户端收到重复事件时,可以通过 eventId 或 seq 去重。服务端写事件时,需要保证同一个消息的序列号不会重复。

5. 流式消息状态

PENDING
  ↓
RUNNING
  ├─ COMPLETED
  ├─ FAILED
  ├─ CANCELLED
  └─ TIMEOUT

生成开始前创建 RUNNING 消息,生成结束后更新最终状态。连接断开和生成取消是两个不同概念:

  • 连接断开:客户端暂时收不到事件;
  • 生成取消:服务端主动停止模型任务。

客户端断开时是否自动取消,要根据产品需求决定。

三、工作原理

1. SSE 流式对话链路

客户端 POST /api/conversations/{id}/messages
    ↓
服务端校验会话和 requestId
    ↓
保存用户消息和助手占位消息
    ↓
返回 SSE 连接
    ↓
发送 start
    ↓
执行检索 / 工具调用 / 模型生成
    ↓
发送 delta、citation 和 status
    ↓
保存最终内容
    ↓
发送 done

SSE 的连接建立和消息提交可以分成两个接口,也可以使用一个 POST 请求直接返回流。实际项目需要兼顾浏览器 API、鉴权方式和重连策略。

2. 两种常见接口设计

方案 A:POST 直接返回 SSE

POST /messages
  请求体:用户问题和 requestId
  响应:text/event-stream

优点:

  • 一次请求完成提交和接收;
  • 可以使用请求体传递复杂参数;
  • 适合 fetch 读取流。

缺点:

  • 原生 EventSource 不能发送 POST 请求体;
  • 重连需要自己重新发起请求;
  • 客户端实现比 GET SSE 稍复杂。

方案 B:POST 提交任务,GET 订阅流

POST /messages
    ↓
返回 messageId / streamId

GET /messages/{messageId}/events
    ↓
返回 text/event-stream

优点:

  • 可用 EventSource;
  • 任务和订阅解耦;
  • 重连更容易使用 Last-Event-ID;
  • 便于多个客户端订阅同一任务。

缺点:

  • 接口数量更多;
  • 需要保存和管理事件;
  • 需要处理任务已完成但客户端尚未订阅的情况。

生产项目通常更推荐方案 B,尤其是需要断线恢复、历史查看和多端同步时。

3. 断线恢复流程

客户端收到 eventId=evt_1004
    ↓
网络断开
    ↓
客户端携带 Last-Event-ID=evt_1004 重连
    ↓
服务端查询该 message 后续事件
    ↓
补发 evt_1005 及之后事件
    ↓
如果已完成,补发 done
    ↓
如果任务仍在运行,继续订阅实时事件

如果服务端没有保存事件,可以选择重新发送当前完整内容,但客户端必须先清空或按消息版本处理,避免文本重复。

4. WebSocket 链路

WebSocket 适合:

客户端 ⇄ 服务端
  ├─ 用户消息
  ├─ 模型片段
  ├─ 工具确认
  ├─ 取消任务
  ├─ 心跳
  └─ 多人协作事件

它可以在同一个连接中处理客户端上行和服务端下行,但应用需要自己定义事件协议、鉴权、心跳、重连和订阅关系。

5. 取消生成

取消操作应沿着完整链路传播:

用户点击停止
    ↓
前端发送 cancel 请求
    ↓
服务端找到 requestId / messageId
    ↓
取消 Reactive Subscription 或模型请求
    ↓
停止工具和后续处理
    ↓
消息状态改为 CANCELLED
    ↓
发送 cancelled 事件

只关闭浏览器连接,不一定能停止模型供应商的生成。后端必须保存任务引用,并在取消时调用对应的取消逻辑。

四、实战示例

下面使用 Spring Boot WebFlux 和 Spring AI,设计“提交消息”和“订阅事件”两个接口。

1. Maven 依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
</dependencies>

Spring AI 的 ChatClient 流式调用可以返回 Flux<String>,再由业务层转换成自己的事件模型。SSE 本身使用 text/event-stream,不要直接把供应商原始事件格式暴露给前端。

2. 事件对象

public record StreamEvent(
        String id,
        String event,
        String conversationId,
        String messageId,
        long seq,
        Object data,
        Instant createdAt) {
}

public record MessageSubmitRequest(
        String content,
        String requestId) {
}

public record MessageSubmitResponse(
        String conversationId,
        String messageId,
        String streamId) {
}

生产环境中,事件数据应按事件类型使用不同 DTO,不要把所有内容都定义成 Map<String, Object>,否则前端和日志难以验证。

3. 提交消息

@RestController
@RequestMapping("/api/conversations")
public class ConversationController {

    private final ConversationMessageService messageService;

    public ConversationController(
            ConversationMessageService messageService) {
        this.messageService = messageService;
    }

    @PostMapping("/{conversationId}/messages")
    public Mono<MessageSubmitResponse> submit(
            @PathVariable String conversationId,
            @RequestBody MessageSubmitRequest request,
            CurrentUser currentUser) {

        return messageService.prepare(
                        currentUser,
                        conversationId,
                        request
                )
                .map(PreparedMessage::toResponse);
    }
}

prepare 方法在短事务内完成:

  1. 校验会话归属;
  2. 根据 requestId 做幂等;
  3. 保存用户消息;
  4. 创建 RUNNING 的助手消息;
  5. 创建 streamId;
  6. 投递生成任务。

不要在数据库事务中同步等待模型生成。

4. 订阅 SSE

@GetMapping(
        value = "/messages/{messageId}/events",
        produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<StreamEvent>> events(
        @PathVariable String messageId,
        @RequestHeader(value = "Last-Event-ID", required = false)
        String lastEventId,
        CurrentUser currentUser) {

    return eventService.subscribe(
            currentUser,
            messageId,
            lastEventId
    ).map(event -> ServerSentEvent.<StreamEvent>builder()
            .id(event.id())
            .event(event.event())
            .data(event)
            .build());
}

Spring WebFlux 支持将 Flux<ServerSentEvent<?>> 作为响应返回;SSE 端点应设置 MediaType.TEXT_EVENT_STREAM_VALUE。citeturn0search8turn0search11

5. 生成事件流

@Service
public class GenerationEventService {

    private final ChatClient chatClient;
    private final EventRepository eventRepository;
    private final MessageRepository messageRepository;

    public Flux<StreamEvent> generate(
            PreparedMessage prepared,
            String systemPrompt) {

        String messageId = prepared.assistantMessageId();
        AtomicLong sequence = new AtomicLong(
                eventRepository.nextSequence(messageId)
        );

        Flux<StreamEvent> start = Flux.just(
                event("start", messageId, sequence.incrementAndGet(),
                        Map.of("status", "RUNNING"))
        );

        Flux<StreamEvent> deltas = chatClient.prompt()
                .system(systemPrompt)
                .user(prepared.userContent())
                .stream()
                .content()
                .map(text -> event(
                        "delta",
                        messageId,
                        sequence.incrementAndGet(),
                        Map.of("text", text)
                ));

        Flux<StreamEvent> done = Mono.fromRunnable(() -> {
            messageRepository.markCompleted(messageId);
            eventRepository.append(
                    event("done", messageId,
                            sequence.incrementAndGet(),
                            Map.of("status", "COMPLETED"))
            );
        }).thenMany(Flux.empty());

        return start.concatWith(deltas)
                .concatWith(done)
                .onErrorResume(error -> {
                    messageRepository.markFailed(
                            messageId,
                            error.getClass().getSimpleName()
                    );
                    return Flux.just(event(
                            "error",
                            messageId,
                            sequence.incrementAndGet(),
                            Map.of("message", "生成失败")
                    ));
                });
    }

    private StreamEvent event(
            String type,
            String messageId,
            long seq,
            Object data) {
        return new StreamEvent(
                "evt_" + UUID.randomUUID(),
                type,
                null,
                messageId,
                seq,
                data,
                Instant.now()
        );
    }
}

示例省略了事件持久化细节。生产实现不能只在 map 中创建事件,还要保证事件按顺序写入、重复订阅可补发、完成状态不会被迟到错误覆盖。

6. 前端使用 fetch 读取 SSE

POST 提交加 GET 订阅时,前端可以使用 fetch:

async function readEvents(messageId, lastEventId) {
  const response = await fetch(
    `/api/conversations/messages/${messageId}/events`,
    {
      headers: lastEventId
        ? { "Last-Event-ID": lastEventId }
        : {}
    }
  );

  if (!response.ok || !response.body) {
    throw new Error("stream connection failed");
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) {
      break;
    }

    buffer += decoder.decode(value, { stream: true });
    const events = buffer.split("\n\n");
    buffer = events.pop() ?? "";

    for (const raw of events) {
      handleSseEvent(raw);
    }
  }
}

如果使用原生 EventSource,接口通常采用 GET;无论使用哪种方式,都要保存最后收到的 eventId,供断线恢复。

7. 前端处理事件

function handleSseEvent(raw) {
  const event = parseSse(raw);
  if (!event.id || receivedEventIds.has(event.id)) {
    return;
  }

  receivedEventIds.add(event.id);
  lastEventId = event.id;

  switch (event.event) {
    case "start":
      setGenerating(true);
      break;
    case "delta":
      appendAssistantText(event.data.text);
      break;
    case "citation":
      addCitation(event.data);
      break;
    case "error":
      showError(event.data.message);
      setGenerating(false);
      break;
    case "done":
      setGenerating(false);
      break;
  }
}

长时间运行时,客户端不能无限保存所有已接收事件。可以使用消息序列号和最近窗口做去重,或在收到 done 后清理事件 ID 集合。

8. 心跳事件

代理和负载均衡器可能会关闭长时间没有数据的连接。服务端可以发送注释或心跳事件:

Flux<StreamEvent> heartbeat = Flux.interval(Duration.ofSeconds(15))
        .map(tick -> event(
                "heartbeat",
                messageId,
                sequence.incrementAndGet(),
                Map.of("time", Instant.now())
        ));

心跳不应该被当成模型内容,也不应该写入最终助手消息。

9. 取消接口

@PostMapping("/messages/{messageId}/cancel")
public Mono<Void> cancel(
        @PathVariable String messageId,
        CurrentUser currentUser) {
    return generationService.cancel(currentUser, messageId);
}

服务层需要:

public Mono<Void> cancel(CurrentUser user, String messageId) {
    return repository.findRunningMessage(
                    user.tenantId(),
                    user.userId(),
                    messageId
            )
            .switchIfEmpty(Mono.error(
                    new ResponseStatusException(HttpStatus.NOT_FOUND)
            ))
            .flatMap(message -> {
                taskRegistry.cancel(message.requestId());
                return repository.markCancelled(message.id());
            })
            .then();
}

取消接口必须校验当前用户有权操作该消息,不能仅凭 messageId 直接取消其他用户的任务。

10. Nginx 配置

SSE 代理需要关闭缓冲:

location /api/conversations/ {
    proxy_pass http://ai_backend;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 1h;
    add_header X-Accel-Buffering no;
}

同时检查云负载均衡、API Gateway 和 WAF 是否有更短的空闲超时或响应缓冲设置。

11. WebSocket 的 Spring WebFlux 示例

当业务需要双向事件时,可以使用 WebSocket:

@Configuration
public class WebSocketConfig {

    @Bean
    HandlerMapping webSocketMapping(ChatWebSocketHandler handler) {
        Map<String, WebSocketHandler> map = Map.of(
                "/ws/conversation", handler
        );

        SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
        mapping.setOrder(-1);
        mapping.setUrlMap(map);
        return mapping;
    }
}

WebSocket 消息也建议使用统一 JSON 事件:

{
  "type": "user_message",
  "requestId": "req_001",
  "conversationId": "c_001",
  "payload": {
    "text": "帮我总结这份文档"
  }
}

不要因为使用 WebSocket 就跳过消息持久化和权限校验。

五、常见问题与实践建议

1. 为什么前端收到的不是逐字输出

可能原因:

  • 模型本身没有流式返回;
  • Spring AI 流式接口被错误地收集成完整字符串;
  • WebFlux 之外的阻塞代码阻塞事件循环;
  • Nginx 或网关开启了缓冲;
  • 前端读取逻辑等待完整响应;
  • 服务器或代理压缩了响应;
  • 事件没有按 SSE 格式发送。

排查时可以从浏览器 Network、应用日志、网关日志和模型调用日志逐层确认首个片段在哪里被延迟。

2. 客户端断开后,模型还在继续生成

连接生命周期不一定等于模型任务生命周期。使用 doOnCancel 或任务注册表监听取消,并将取消信号传递给模型调用:

return modelFlux
        .doOnCancel(() -> taskRegistry.cancel(requestId))
        .doFinally(signal -> metrics.record(signal));

如果供应商 API 不支持取消,至少要停止后续结果消费,并确保不会继续向已失效的客户端发送。

3. 如何避免重复显示

客户端需要同时处理:

  • eventId 去重;
  • seq 顺序检查;
  • 重连后的补发;
  • 已完成消息不再追加;
  • 重新生成使用新的 messageId。

服务端也不能因为客户端重连,就重复创建一条助手消息。

4. SSE 是否支持双向通信

SSE 本身是服务端到客户端的事件流。客户端可以通过普通 POST、PUT 或 DELETE 发送消息和取消请求,但不是同一条 SSE 连接上的双向消息。

如果业务需要客户端持续向服务端发送控制事件,WebSocket 更自然。

5. 断线重连应该从哪里恢复

优先使用服务端保存的事件:

Last-Event-ID
    ↓
查询 message_event
    ↓
补发未收到事件
    ↓
继续实时订阅

如果事件只保存在内存,服务重启后无法恢复。至少要保存最终消息状态和最终正文;对关键场景再保存事件流。

6. 是否应该把每个 delta 都写数据库

一般不建议每个 Token 片段都写数据库:

  • 写入量巨大;
  • 数据库锁和索引压力增加;
  • 查询最终消息时没有必要;
  • 重放事件的收益有限。

常见方案:

  • delta 只通过内存或 Redis 传输;
  • 每隔固定时间批量保存;
  • 只保存关键状态事件;
  • 最终完成时写入完整内容;
  • 对需要审计的场景保存压缩后的事件流。

7. 连接超时怎么处理

需要同时检查:

  • 浏览器 fetch 超时;
  • Spring WebFlux 请求超时;
  • Servlet 或 Netty 超时;
  • Nginx proxy_read_timeout;
  • 云负载均衡空闲超时;
  • 模型供应商响应超时。

设置很长的超时不能替代心跳、断线恢复和任务状态持久化。

8. SSE 和 WebSocket 如何选择

建议:

文本生成、RAG 回答、进度推送
    → SSE

语音流、多人协作、双向 Agent 控制
    → WebSocket

普通短回答、无需实时反馈
    → 普通 HTTP

不要为了“实时”而默认引入 WebSocket。复杂协议和连接管理会增加开发与运维成本。

六、进阶思考

1. 统一事件协议

无论使用 SSE 还是 WebSocket,都可以复用同一套业务事件:

transport:
  SSE / WebSocket

event:
  start / status / delta / citation / tool_call / done

这样前端只需要适配传输层,业务事件和消息状态保持一致。

2. 流式 RAG

RAG 对话可以发送阶段事件:

start
    ↓
status: RETRIEVING
    ↓
citation: 命中资料
    ↓
status: GENERATING
    ↓
delta
    ↓
done

不要把内部检索过程中的敏感内容全部暴露给前端。引用事件只返回用户有权限看到的来源摘要。

3. 流式工具调用

Agent 任务可以产生:

tool_call_started
    ↓
tool_approval_required
    ↓
tool_call_completed
    ↓
delta

高风险工具调用需要暂停流,等待用户确认。确认应绑定到具体的工具名称、参数摘要、会话和过期时间,不能使用一个永久有效的“已同意”标志。

4. 多端订阅

当同一个会话同时在 Web、移动端和企业 IM 中打开时,需要将生成任务和订阅者分开:

一个 message generation
    ├─ Web subscriber
    ├─ Mobile subscriber
    └─ IM subscriber

每个订阅者记录自己的最后事件 ID,不能因为一个客户端断线就取消整个生成任务。

5. 背压和慢客户端

如果模型生成很快,而客户端处理很慢,事件会在内存中堆积。需要考虑:

  • 限制每个连接的缓冲;
  • 合并短 delta;
  • 对慢客户端主动断开并允许恢复;
  • 不让一个客户端拖慢整个生成任务;
  • 将事件写入 Redis Stream 或消息队列;
  • 设置最大消息和最大连接数。

6. 可靠性状态机

建议将消息状态和连接状态分开:

消息状态:
PENDING → RUNNING → COMPLETED / FAILED / CANCELLED

连接状态:
CONNECTED → DISCONNECTED → RECONNECTING → CONNECTED

连接断开不应该直接把消息状态改为失败。只有模型任务真正失败或被取消时,才更新消息最终状态。

7. 观测指标

流式接口至少记录:

指标作用
首 Token 延迟衡量用户首次反馈速度
总生成耗时衡量模型和系统性能
事件数量判断片段粒度和传输压力
重连次数判断网络稳定性
取消率衡量用户是否等待不住
错误率判断模型、网关和业务故障
完成率衡量任务最终成功情况
断线恢复成功率衡量可靠性

将 requestId、messageId 和 eventId 放入日志关联字段,才能从用户请求追踪到模型调用和最终消息。

结论

流式对话接口的核心不是把模型输出拆成小字符串,而是建立一套可恢复、可审计、可取消、可观测的消息事件系统。

本文的关键实践包括:

  • 普通文本生成优先考虑 SSE,双向实时交互再使用 WebSocket;
  • 用 start、status、delta、citation、error 和 done 统一事件协议;
  • 使用 requestId 做提交幂等,使用 messageId 管理业务消息,使用 eventId 支持恢复;
  • 生成开始前创建 RUNNING 助手消息;
  • 将连接断开和生成取消分开处理;
  • 使用事件序列号和 Last-Event-ID 实现断线恢复;
  • 配置网关关闭缓冲并发送心跳;
  • 高风险工具调用和提交动作需要人工确认;
  • 记录首 Token 延迟、总耗时、重连和取消指标。

完成这套基础链路后,后续可以继续接入多端订阅、流式 RAG、Agent 工具调用和消息事件持久化。

参考资料