AI 流式对话与实时聊天平台:SSE 解码竞态、有状态会话隔离与背压取消的 Vue3 + Spring AI 生产级实战

AI 流式对话与实时聊天平台:SSE 解码竞态、有状态会话隔离与背压取消的 Vue3 + Spring AI 生产级实战

注:

博客:

https://blog.csdn.net/badao_liumang_qizhi

一、项目架构与核心设计决策

1.1 整体架构

复制代码
┌──────────────────────────────────────────────────────────────┐
│                   前端 (Vue3 + EventSource/fetch)              │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐           │
│  │聊天面板 │ │会话列表 │ │流式渲染 │ │取消/续传│           │
│  └─────────┘ └─────────┘ └─────────┘ └─────────┘           │
└──────────────────────────────────────────────────────────────┘
                              │ SSE (text/event-stream)
┌──────────────────────────────────────────────────────────────┐
│          后端 (Spring Boot 3.x + WebFlux + Spring AI 1.1)     │
│  ┌──────────────────────────────────────────────────────────┐│
│  │              流式生命周期 (Stream Lifecycle)              ││
│  │  token 事件 → 心跳事件 → 完成事件 / 取消(doOnCancel)      ││
│  │  有状态会话(Redis/ChatMemory) → 并发闸门 → 幂等续接       ││
│  └──────────────────────────────────────────────────────────┘│
│  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐           │
│  │流式输出 │ │会话管理 │ │记忆压缩 │ │并发控制 │           │
│  │Flux     │ │Redis    │ │摘要+窗口│ │信号量   │           │
│  └─────────┘ └─────────┘ └─────────┘ └─────────┘           │
└──────────────────────────────────────────────────────────────┘
                              │
┌──────────────────────────────────────────────────────────────┐
│  MySQL(消息审计/摘要)  Redis(会话状态/并发闸门/续传游标)  │
└──────────────────────────────────────────────────────────────┘

1.2 核心功能

  • SSE 流式输出 :ChatClient.stream().content() 返回 Flux<String>,WebFlux 自动处理 text/event-stream 帧格式,首字延迟从秒级压缩到毫秒级
  • 有状态流式会话:对话上下文存在 Redis(短期)与 MySQL(审计/摘要),进程重启、多实例部署不丢上下文
  • token 级流式渲染:前端以缓冲区 + 事件边界切分解析 SSE,逐 token 渲染,保留换行与前导空格
  • 心跳保活 :服务端每 15 秒发送 heartbeat 事件,防止 Nginx/Tomcat 空闲超时断连
  • 取消生成 :前端 AbortController 中断请求,后端 doOnCancel 感知并释放上游 AI 连接
  • 并发闸门:同一会话同时只允许一个流式生成,防止用户连续发送导致多路流交错写入
  • 断线续接 :每次流式调用分配 invocationId,客户端断线后可携带该标识续接,服务端做幂等去重
  • 记忆压缩:滑动窗口 + 摘要压缩,长对话 token 消耗从线性增长降到可控范围

1.3 四个根本性工程问题

第一个问题:前端 SSE 解析的"解码竞态"。

fetch 的 ReadableStream 返回的 chunk 不保证与 SSE 事件边界对齐 。一个 data: 事件可能被拆到两个 chunk 中,也可能多个事件挤在一个 chunk 里。如果对每个 chunk 直接 split('\n') 并逐行判断 startsWith('data:'),被拆开的事件会被截断,被合并的事件会丢失边界。正确做法是维护一个缓冲区 ,按 \n\n 切分完整事件,剩余的半截留在缓冲区等待下一个 chunk。

第二个问题:token 边界处的"空白丢失"。

流式输出的每个 token 是模型生成的最小片段 ,它可能是一个单词、一个标点、一个空格,甚至是一个换行符。如果在拼接或传输过程中对 token 调用 trim(),换行和前导空格会被吃掉,Markdown 的段落分隔和代码缩进全部失效。这是流式聊天最高频的"隐形 bug"------文本看起来"有",但格式全乱。token 必须原样拼接 ,格式化(marked 渲染)只能在完整消息或渲染时做,绝不能在 token 层面做。

第三个问题:流式会话的"有状态"与"无状态"的边界。

WebFlux 本身是无状态的,但流式对话的上下文是有状态 的。如果把对话历史存在 ChatSession 实体的内存列表中,进程重启就丢失,多实例部署时不同实例看到的历史不一致。生产级方案必须把会话状态外置到 Redis(热数据)和 MySQL(审计/摘要),ChatMemory 只作为读穿缓存的逻辑层,而不是唯一存储。

第四个问题:取消与背压的缺失。

用户点击"停止生成"时,如果只是前端断开连接而后端继续跑完,模型调用费用照付、连接资源不释放。后端必须通过 doOnCancel 感知客户端断开,取消上游 Flux 订阅,释放 AI 连接。同时,如果前端渲染速度跟不上 token 到达速度,需要有背压策略(丢弃中间 token 或合并渲染),而不是无限缓冲。

类比讲解 :流式对话像现场直播的字幕员。解码竞态是"字幕员把一句话拆成两半发出去,观众看到断句错乱";token 空白丢失是"字幕员把换行删了,所有台词挤成一行";有状态会话是"字幕员的笔记本放在自己桌上,换一个演播室就接不上上一场";取消缺失是"导演喊停了,字幕员还在对着空演播室继续打字"。


二、后端实现

2.1 依赖配置

xml 复制代码
<properties>
    <java.version>21</java.version>
    <spring-ai.version>1.1.4</spring-ai.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
        <version>${spring-ai.version}</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
        <version>${spring-ai.version}</version>
    </dependency>

    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

必须使用 WebFlux,而非 Spring MVC 。WebFlux 的 Flux 返回方式天然处理 SSE 帧格式,且 Spring MVC 的 SseEmitter 路径上某些 HTTP 消息转换器可能引入缓冲,导致"数据不是逐 token 到达,而是一次性吐出"。ChatClient.stream().content() 返回的 Flux<String> 直接作为控制器返回值即可,Spring WebFlux 自动完成 text/event-stream 编码。

2.2 流式服务:完整生命周期

一次流式对话的生命周期包含五类事件:token(增量内容)、heartbeat(保活)、complete(正常结束)、error(异常)、cancelled(用户取消)。服务端必须把这五类事件都显式建模,而不是只发 token 然后靠连接关闭隐式表达结束。

java 复制代码
@Slf4j
@Service
@RequiredArgsConstructor
public class StreamingChatService {

    private final ChatClient chatClient;
    private final ChatMemory chatMemory;
    private final SessionStateRepository sessionState;
    private final ConcurrencyGate concurrencyGate;

    private static final Duration HEARTBEAT_INTERVAL = Duration.ofSeconds(15);

    /**
     * 流式对话:token + 心跳 + 完成/取消/错误 全生命周期
     */
    public Flux<ServerSentEvent<String>> stream(StreamRequest request) {
        String conversationId = request.conversationId();
        String invocationId = request.invocationId() != null
                ? request.invocationId()
                : UUID.randomUUID().toString();

        // 1. 并发闸门:同一会话同时只允许一个生成
        return concurrencyGate.tryAcquire(conversationId)
                .flatMapMany(acquired -> {
                    if (!acquired) {
                        return Flux.just(ServerSentEvent.<String>builder()
                                .event("error")
                                .data("当前会话已有进行中的生成,请先停止或等待完成")
                                .build());
                    }

                    // 2. 持久化 invocation,用于断线续接与幂等
                    sessionState.beginInvocation(conversationId, invocationId);

                    // 3. token 流
                    Flux<ServerSentEvent<String>> tokenStream = chatClient.prompt()
                            .user(request.message())
                            .advisors(spec -> spec.param(
                                    ChatMemory.CONVERSATION_ID, conversationId))
                            .stream()
                            .content()
                            .map(token -> ServerSentEvent.<String>builder()
                                    .event("token")
                                    .data(token)          // 原样透传,绝不 trim
                                    .build())
                            .doOnComplete(() -> sessionState
                                    .completeInvocation(conversationId, invocationId))
                            .doOnCancel(() -> {
                                // 客户端断开:取消上游订阅,释放 AI 连接
                                log.info("流式生成被取消: conversationId={}, invocationId={}",
                                        conversationId, invocationId);
                                sessionState.cancelInvocation(
                                        conversationId, invocationId);
                            })
                            .doFinally(signal -> concurrencyGate.release(conversationId));

                    // 4. 心跳:与 token 流合并,15 秒一次
                    Flux<ServerSentEvent<String>> heartbeat =
                            Flux.interval(HEARTBEAT_INTERVAL)
                                    .map(tick -> ServerSentEvent.<String>builder()
                                            .event("heartbeat")
                                            .data("ping")
                                            .build())
                                    .takeUntilOther(tokenStream.ignoreElements());

                    // 5. 合并:token 优先,心跳在空闲时穿插
                    return Flux.merge(tokenStream, heartbeat)
                            .concatWith(Flux.just(ServerSentEvent.<String>builder()
                                    .event("complete")
                                    .data(invocationId)
                                    .build()))
                            .onErrorResume(e -> {
                                log.error("流式生成失败", e);
                                return Flux.just(ServerSentEvent.<String>builder()
                                        .event("error")
                                        .data("生成失败,请稍后重试")
                                        .build());
                            });
                });
    }
}

心跳的必要性 :Nginx 的默认 proxy_read_timeout 是 60 秒,Tomcat 的默认连接超时是 30 秒。如果模型生成过程中出现较长的静默(例如推理模型在"思考"阶段不吐 token),连接会被中间层当作空闲连接回收。15 秒一次的心跳远小于这些超时值,保证连接始终被认定为活跃。

doOnCancel 与取消语义 :当客户端调用 AbortController.abort() 断开连接时,WebFlux 会取消下游订阅,doOnCancel 被触发。此时取消上游 Flux 订阅,AI 连接随之释放,避免"用户已经走了、模型还在跑"的浪费。

2.3 并发闸门:同一会话的串行化

同一会话如果同时存在两个流式生成,会出现 token 交错写入、上下文互相污染、消息顺序错乱。并发闸门用 Redis 的 SET NX 实现会话级互斥:

java 复制代码
@Component
@RequiredArgsConstructor
public class ConcurrencyGate {

    private final ReactiveStringRedisTemplate redis;
    private static final Duration TTL = Duration.ofMinutes(10);

    public Mono<Boolean> tryAcquire(String conversationId) {
        return redis.opsForValue().setIfAbsent(
                "chat:gen:lock:" + conversationId, "1", TTL);
    }

    public Mono<Void> release(String conversationId) {
        return redis.delete("chat:gen:lock:" + conversationId).then();
    }
}

与"每个请求一个线程"的差异 :WebFlux 是事件循环模型,不需要线程池来隔离请求,但业务语义上的互斥仍然需要显式实现。并发闸门锁的是"会话",不是"连接"------用户可以在多个会话中并行生成,但同一会话内必须串行。

2.4 会话状态:Redis 热数据 + MySQL 审计

ChatMemory 是逻辑层 ,ChatMemoryRepository 是存储层 。生产环境使用 JDBC 持久化,保证进程重启不丢历史。但流式的"进行中"状态(invocationId、是否已取消、断线游标)不适合放在 ChatMemory 中,需要单独的 Redis 结构。

java 复制代码
@Component
@RequiredArgsConstructor
public class SessionStateRepository {

    private final ReactiveStringRedisTemplate redis;

    public Mono<Void> beginInvocation(String conversationId, String invocationId) {
        return redis.opsForHash().putAll(
                "chat:invocation:" + conversationId,
                Map.of("invocationId", invocationId,
                        "status", "RUNNING",
                        "startedAt", Instant.now().toString())
        ).then();
    }

    public Mono<Void> completeInvocation(String conversationId, String invocationId) {
        return redis.opsForHash().put(
                "chat:invocation:" + conversationId, "status", "COMPLETED"
        ).then();
    }

    public Mono<Void> cancelInvocation(String conversationId, String invocationId) {
        return redis.opsForHash().put(
                "chat:invocation:" + conversationId, "status", "CANCELLED"
        ).then();
    }
}

2.5 记忆压缩:滑动窗口 + 摘要

MessageWindowChatMemory 的默认行为是"保留最近 N 条消息,丢弃更早的"。这在短对话中没问题,但超过 20 轮后,早期上下文(如用户在第一轮说的约束条件)会被丢弃,导致后续回答偏离。生产级方案是滑动窗口 + 摘要压缩:窗口外的旧消息不直接丢弃,而是先让模型生成一段摘要,把摘要作为一条特殊的系统消息保留在窗口头部。

java 复制代码
@Service
@RequiredArgsConstructor
public class CompressingChatMemory implements ChatMemory {

    private final ChatMemory delegate;          // MessageWindowChatMemory
    private final ChatClient summarizer;
    private final MessageSummaryRepository summaryRepo;

    private static final int SUMMARY_TRIGGER = 20;

    @Override
    public List<Message> get(String conversationId) {
        List<Message> window = delegate.get(conversationId);
        summaryRepo.findLatest(conversationId)
                .map(MessageSummary::getContent)
                .ifPresent(summary -> window.add(0,
                        new SystemMessage("【早期对话摘要】" + summary)));
        return window;
    }

    @Override
    public void add(String conversationId, List<Message> messages) {
        delegate.add(conversationId, messages);

        List<Message> all = delegate.get(conversationId);
        if (all.size() > SUMMARY_TRIGGER) {
            // 将窗口外的消息压缩为摘要
            List<Message> overflow = all.subList(0, all.size() - SUMMARY_TRIGGER);
            String summary = summarizer.prompt()
                    .user("将以下对话压缩为一段简要摘要,保留关键约束和事实:\n"
                            + render(overflow))
                    .call()
                    .content();
            summaryRepo.save(new MessageSummary(conversationId, summary));
        }
    }

    @Override
    public void clear(String conversationId) {
        delegate.clear(conversationId);
        summaryRepo.deleteByConversationId(conversationId);
    }
}

注意 Spring AI 的版本演进 :ChatMemory 在 2.1 版本将被 Session API 取代,Session API 内置了事件溯源和上下文压缩能力,并且"压缩时保证完整轮次要么全保留要么全丢弃",避免 MessageWindowChatMemory 可能出现的"保留了工具调用但丢掉了工具结果"的断裂。当前基于 1.1.x 的实现应在设计上预留迁移空间------把压缩逻辑封装在独立的 CompressingChatMemory 中,而非散落在业务代码里,未来替换为 Session API 时成本最低。

2.6 断线续接:invocationId 幂等

流式生成过程中,客户端可能因网络切换、页面刷新而断线。如果没有续接机制,用户只能重新发送问题,而服务端可能已经生成了大半内容。invocationId 的作用是让客户端在重连时携带同一个标识,服务端判断该调用是否已完成、是否仍在运行。

java 复制代码
@GetMapping(value = "/stream/{invocationId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> resume(@PathVariable String invocationId,
                                             @RequestParam String conversationId) {
    return sessionState.getStatus(conversationId)
            .flatMapMany(status -> switch (status) {
                case "COMPLETED" -> Flux.just(ServerSentEvent.<String>builder()
                        .event("complete").data(invocationId).build());
                case "CANCELLED" -> Flux.just(ServerSentEvent.<String>builder()
                        .event("cancelled").data(invocationId).build());
                case "RUNNING" -> Flux.just(ServerSentEvent.<String>builder()
                        .event("error")
                        .data("该调用仍在运行中,请等待完成或新开会话")
                        .build());
                default -> Flux.empty();
            });
}

续接不等于"从断点继续吐 token" 。流式 token 是不可重放的------AI 连接已经断开,无法从中途继续。invocationId 的正确语义是幂等告知:告诉客户端"你上次的调用是什么状态",客户端据此决定是展示已完成的内容、重新发送、还是提示用户。这比"假装还能接着流"更诚实,也更可靠。

2.7 REST 控制器

java 复制代码
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatController {

    private final StreamingChatService chatService;

    @PostMapping(value = "/stream",
            produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> stream(
            @RequestBody @Valid StreamRequest request) {
        return chatService.stream(request);
    }

    public record StreamRequest(
            @NotBlank String conversationId,
            String invocationId,
            @NotBlank String message) {}
}

三、前端实现

3.1 SSE 解析:缓冲区 + 事件边界

前端不使用 EventSource,而是使用 fetch + ReadableStream,因为 EventSource 只支持 GET 请求,且无法自定义请求头、无法携带复杂的请求体。fetch 的流式读取需要手动处理 SSE 帧格式,核心是缓冲区:

ts 复制代码
interface SSEEvent {
  event: string
  data: string
}

const parseSSE = (buffer: string, chunk: string) => {
  const combined = buffer + chunk
  const blocks = combined.split('\n\n')
  const remaining = blocks.pop() ?? ''   // 最后一块可能不完整,留到下次
  const events: SSEEvent[] = []

  for (const block of blocks) {
    let event = 'message'
    const dataLines: string[] = []
    for (const line of block.split('\n')) {
      if (line.startsWith('event:')) {
        event = line.slice(6).trim()
      } else if (line.startsWith('data:')) {
        // 只去掉 data: 后的一个前导空格,保留内容本身的空白
        const raw = line.slice(5)
        dataLines.push(raw.startsWith(' ') ? raw.slice(1) : raw)
      }
    }
    if (dataLines.length > 0) {
      events.push({ event, data: dataLines.join('\n') })
    }
  }
  return { events, remaining }
}

为什么不能对 data 做 trim() :SSE 规范中 data: 后的第一个空格是分隔符,需要去掉;但内容中的换行、缩进、首尾空格都是 token 的一部分,trim() 会破坏 Markdown 格式。上面的实现只去掉一个分隔空格,其余原样保留。

3.2 流式发送与取消

ts 复制代码
const streamChat = async (message: string, conversationId: string) => {
  const controller = new AbortController()
  abortRef.value = controller
  isStreaming.value = true
  streamingContent.value = ''

  try {
    const response = await fetch('/api/chat/stream', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        conversationId,
        invocationId: currentInvocationId.value,
        message
      }),
      signal: controller.signal
    })

    const reader = response.body!.getReader()
    const decoder = new TextDecoder()
    let buffer = ''

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

      const { events, remaining } = parseSSE(buffer, decoder.decode(value, { stream: true }))
      buffer = remaining

      for (const evt of events) {
        switch (evt.event) {
          case 'token':
            streamingContent.value += evt.data   // 原样拼接,不 trim
            break
          case 'complete':
            isStreaming.value = false
            messages.value.push({
              role: 'assistant',
              content: streamingContent.value,
              createdAt: new Date().toISOString()
            })
            streamingContent.value = ''
            break
          case 'error':
            isStreaming.value = false
            ElMessage.error(evt.data)
            break
          case 'heartbeat':
            // 心跳只用于保活,前端可选择静默处理或刷新"生成中"指示
            break
        }
      }
    }
  } catch (e: any) {
    if (e.name === 'AbortError') {
      // 用户主动取消,保留已生成的部分内容
      if (streamingContent.value) {
        messages.value.push({
          role: 'assistant',
          content: streamingContent.value + '\n\n*[已停止生成]*',
          createdAt: new Date().toISOString()
        })
      }
    } else {
      ElMessage.error('连接异常')
    }
  } finally {
    isStreaming.value = false
    abortRef.value = null
  }
}

const stopGeneration = () => {
  abortRef.value?.abort()
}

TextDecoder 的 { stream: true } :多字节字符(如中文)的 UTF-8 编码可能被拆到两个 chunk 中。{ stream: true } 让 decoder 保留不完整的字节序列,在下一个 chunk 到达时拼接解码,避免出现乱码。

3.3 渲染:流式期间与完成后的差异

流式期间逐 token 拼接的内容是不完整的 Markdown ,直接交给 marked 渲染可能导致代码块未闭合、列表格式闪烁。生产级做法是:流式期间使用纯文本 + 保留换行 的渲染方式,完成后才用 marked 渲染最终 Markdown。

vue 复制代码
<template>
  <div class="msg-text">
    <div v-if="msg.streaming" class="streaming-plain">
      {{ msg.content }}
    </div>
    <div v-else v-html="renderMarkdown(msg.content)" />
  </div>
</template>

<script setup lang="ts">
import { marked } from 'marked'
import DOMPurify from 'dompurify'

const renderMarkdown = (content: string) =>
  DOMPurify.sanitize(marked.parse(content) as string)
</script>

<style scoped>
.streaming-plain {
  white-space: pre-wrap;   /* 保留换行与空格 */
  word-break: break-word;
}
</style>

white-space: pre-wrap 是流式期间保留换行的关键------{``{ msg.content }} 在普通 div 中会把换行折叠成空格,加了这个样式才能让 token 中的 \n 真正显示为换行。


四、生产级扩展

4.1 Nginx 配置:关闭缓冲与延长超时

即使服务端正确流式输出,Nginx 的默认 proxy_buffering 会把响应缓冲到完整后再发给客户端,导致"流式变批量"。必须在 location 中显式关闭:

nginx 复制代码
location /api/chat/ {
    proxy_pass http://backend;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 300s;
    proxy_http_version 1.1;
}

proxy_read_timeout 应设为大于模型最长生成时间(如 5 分钟),并配合服务端心跳,确保长生成不被中间层切断。

4.2 可观测性

指标 采集方式 告警阈值
chat.stream.ttft 首 token 到达耗时 p95 > 3s 需检查模型或网络
chat.stream.duration 端到端生成耗时 p95 > 业务 SLA
chat.stream.cancel.rate 用户取消比例 突增说明生成质量或速度下降
chat.stream.error.rate 流式错误率 > 2% 需排查上游模型
chat.concurrency.rejected 并发闸门拒绝数 持续 > 0 说明用户连续发送频繁
chat.heartbeat.missed 心跳发送失败次数 > 0 说明连接已断开但未清理

4.3 安全边界

  • conversationId 归属校验 :每次流式请求必须校验 conversationId 属于当前用户,防止通过猜测 ID 读取他人对话。
  • token 内容过滤 :流式 token 直接透传给前端,如果模型输出包含 HTML/脚本,前端 v-html 之前必须经过 DOMPurify 清洗。
  • 取消的幂等性 :doOnCancel 可能被多次触发(连接断开、超时、客户端主动取消),cancelInvocation 必须是幂等的,不能因为重复调用而报错。

五、总结

本文构建了一个生产级 AI 流式对话平台,核心设计决策如下:

能力 实现方案 关键收益
流式输出 WebFlux + ChatClient.stream().content() 返回 Flux<String> Spring 自动处理 SSE 帧,无缓冲
心跳保活 Flux.interval(15s) 合并进 token 流 防止 Nginx/Tomcat 空闲超时断连
取消生成 前端 AbortController + 后端 doOnCancel 释放 AI 连接,停止计费
解码健壮性 前端缓冲区 + \n\n 事件边界切分 + TextDecoder({stream:true}) 解决 chunk 与事件不对齐、多字节字符断裂
token 完整性 data: 后只去一个分隔空格,绝不 trim() 保留换行与代码缩进,Markdown 不塌陷
会话有状态 ChatMemory + JDBC 持久化 + Redis 运行态 重启不丢上下文,多实例一致
记忆压缩 滑动窗口 + 摘要注入窗口头部 长对话 token 可控,早期约束不丢失
并发控制 Redis SET NX 会话级闸门 同一会话不出现 token 交错
断线续接 invocationId + 状态查询 客户端重连后可幂等恢复,不盲目重发

核心认知 :流式聊天的难点不在"调通 stream()",而在让流在不可靠的网络和中间件中完整、有序、可取消地到达用户 。解码缓冲区解决"到达的 chunk 不完整",token 原样透传解决"到达的内容被篡改",心跳解决"连接被中间层回收",doOnCancel 解决"用户走了资源还在跑",并发闸门解决"同一会话多路交错"。五者叠加,流式对话才从一个"逐字打印的效果"变成一个"可运营的实时系统"。

相关推荐
云创智城-yuncitys1 小时前
YunParking 4.0:JDK 17 与 Spring Boot 3.2 的停车云升级
java·开发语言·spring boot
文丑颜不良啊1 小时前
LeetCode1661~1680题目解析
java·leetcode
子非鱼@Itfuture1 小时前
关于前后端联调中登录状态/登录信息失效?
java·前端
郭小李1 小时前
openFeign入门
java·开发语言·前端
孟健1 小时前
我把9个AI助理迁到了Grok Bot,连远程服务器都省了
人工智能·aigc·ai编程
AI+程序员在路上2 小时前
RV1126B 上支持的模型及全流程应用--从CNN及语音到 LLMVLM 与跨平台选型
linux·c语言·人工智能
wljt2 小时前
公司二-面试题
java·开发语言·mybatis
AI备案指南-满满2 小时前
算法备案审核不通过,一般是什么原因?
java·人工智能·算法·大模型备案·算法备案·大模型登记
这个DBA有点耶2 小时前
连拿六年第一后宣布出海:国产数据库凭什么敢撕掉“平替”的标签?
数据库·人工智能·架构