AI对话四大工程问题完全指南:从竞态到断连的工业级解决方案

AI对话四大工程问题完全指南:从竞态到断连的工业级解决方案

一次流式对话背后,藏着前端工程最复杂的异常处理体系

写在前面

如果你开发过 AI 对话应用(ChatGPT、豆包、文心一言),一定遇到过这些让人抓狂的场景:

  • 快速连发几条消息,结果后发的问题先得到回复,对话顺序全乱套了
  • 流式输出时,文字突然“倒带”或“跳跃”,句子前后颠倒
  • 网络抖了一下,AI 说到一半就停了,怎么点都不继续
  • 用户抱怨“我明明只发了一条,为什么出现了两条一模一样的回复?”

这些问题单独出现时还能容忍,但在高并发、弱网环境下,它们会叠加爆发,直接把用户体验拉到谷底。

这不是某个环节的失误,而是流式长连接场景下的系统性工程挑战。本文将从源码级别,逐一拆解这四大问题的成因,并给出经过大规模生产验证的完整解决方案。


前置知识:流式对话的技术选型

在深入问题之前,有必要明确技术背景。目前主流 AI 对话应用的流式传输方案有两种:

方案实现方式优点缺点
SSE (Server-Sent Events)基于 HTTP/1.1 或 HTTP/2,服务端单向推送协议简单、自带断连检测、支持自动重连浏览器连接数限制(HTTP/1.1 下同源最多 6 个)
Fetch + ReadableStream基于 WHATWG Streams 标准更灵活、支持双向流、HTTP/2 多路复用需要手动处理断连重连、错误处理更复杂

两种方案在字节豆包中混合使用:首屏和历史消息走 Fetch 批量加载,实时对话走 SSE 流式传输。


一、请求竞态(后发先至)

1.1 问题现象

用户快速连续输入三条消息:

1
2
3
用户:今天天气怎么样?
用户:那明天呢?
用户:适合出门吗?

由于请求 A(问天气)计算量大、耗时长,请求 C(问出门)计算量小、先返回。前端按返回顺序渲染,结果“适合出门吗?”先出现,等请求 A 回来时又覆盖成“今天天气怎么样?”,整个对话时间线完全错乱。

1.2 根因分析

本质:异步并发请求的完成时序不可控,前端默认“先返回先渲染”,与用户“先发先展示”的预期矛盾。

更深一层,这个问题在 HTTP/2 多路复用下被放大:多个请求共享同一个 TCP 连接,但服务端处理线程池调度、GC 停顿、下游模型推理时间差异,都会导致响应乱序返回。

1.3 完整解决方案(双模式)

根据业务场景不同,我推荐两套方案,企业级应用通常组合使用

方案 A:取消式(Cancelation)—— 主流首选

核心思想:发新请求时主动终止旧请求,从根源上消除竞态。适合“只关心用户最新意图”的场景。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class RequestManager {
  private controller: AbortController | null = null;
  private requestId = 0;
  private pendingRequests = new Map<number, AbortController>();

  async request<T>(payload: any): Promise<T | null> {
    // 1. 生成请求 ID
    const id = ++this.requestId;
    
    // 2. 取消前序所有请求
    if (this.controller) {
      this.controller.abort();
    }
    
    // 3. 创建新控制器
    this.controller = new AbortController();
    this.pendingRequests.set(id, this.controller);
    
    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        body: JSON.stringify(payload),
        signal: this.controller.signal,
      });
      
      const data = await response.json();
      
      // 4. 竞态裁决:检查是否被后续请求覆盖
      // 注意:这里不直接依赖 AbortError,因为服务端可能仍在处理
      if (this.pendingRequests.has(id) && this.pendingRequests.get(id) === this.controller) {
        this.pendingRequests.delete(id);
        return data;
      }
      return null; // 已被取消或覆盖
    } catch (error) {
      // 主动取消不抛出业务异常
      if (error instanceof Error && error.name === 'AbortError') {
        return null;
      }
      throw error;
    } finally {
      this.pendingRequests.delete(id);
    }
  }
}

关键细节

  • 取消请求后,需在服务端配合实现推理中断,否则 GPU 资源浪费
  • 流式场景下,取消时必须同时关闭 ReadableStream,避免残留数据继续写入 DOM

方案 B:保序式(Sequencing)—— 跨设备协同

核心思想:不取消请求,所有请求结果都返回,但只渲染最新序号的结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class SequentialDispatcher {
  private latestSeq = 0;

  async dispatch(payload: any): Promise<void> {
    const seq = ++this.latestSeq;
    const response = await fetch('/api/chat', {
      method: 'POST',
      body: JSON.stringify(payload),
    });
    const data = await response.json();
    
    // 只有当前序号仍然是最新的才渲染
    if (seq === this.latestSeq) {
      this.render(data);
    }
    // 否则丢弃
  }
}

适用场景

  • 服务端不支持请求取消(如遗留系统)
  • 请求已经触发计费,取消无法退款
  • 多端协同场景(Web + App 同时操作)

1.4 线上坑点

坑点后果解决方案
只靠按钮 loading 防重快速回车、多端同步依然触发竞态必须用 AbortController + 序号双重防御
忽略服务端取消取消前端请求但 GPU 仍在计算服务端监听 close 事件主动中断推理
取消后临时占位残留取消的请求仍显示“正在输入”状态取消时同步清理 UI 占位

二、消息乱序

2.1 问题现象

流式输出时,本该先出现的“今天天气”片段,却比“很不错”片段更晚到达,导致 UI 上显示“很不错今天天气”。

2.2 根因分析

很多人误以为 TCP 保证了有序传输,所以流式数据天然有序。这是致命误解。

乱序的真正来源在应用层:

  1. HTTP/2 多路复用:虽然同一个 TCP 连接有序,但多个流(Stream)独立调度,到达客户端的时间可能交错
  2. 中间代理缓冲:CDN、Nginx 可能对分片做重组或缓存,返回顺序被打乱
  3. 服务端多线程并发写回:不同线程处理不同分片,写回顺序不严格按生成顺序
  4. 断连重连后的补发:补发的分片和正常流式分片可能在客户端交错到达

2.3 完整解决方案(三层防御)

第一层:协议层设计(服务端保序)

每条消息的所有分片必须携带以下元数据:

1
2
3
4
5
6
7
interface ChunkMetadata {
  messageId: string;      // 全局唯一,一次对话唯一
  chunkIndex: number;     // 从 0 开始递增
  totalChunks?: number;   // 总片数(可选,尾部携带)
  isLast: boolean;        // 是否为最后一片
  timestamp: number;      // 服务端生成时间戳
}

服务端硬约束:同一个 messageId 的所有分片在单 TCP 连接内串行写回,禁止多线程并发写出。

第二层:前端有序缓冲区(重排引擎)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class StreamOrderBuffer {
  private buffer = new Map<number, { data: string; isLast: boolean }>();
  private nextExpected = 0;
  private messageId: string;
  private onChunk: (text: string, isLast: boolean) => void;
  private timeoutId: ReturnType<typeof setTimeout> | null = null;
  private readonly MAX_WAIT_MS = 3000;

  constructor(messageId: string, onChunk: (text: string, isLast: boolean) => void) {
    this.messageId = messageId;
    this.onChunk = onChunk;
  }

  push(index: number, data: string, isLast: boolean): void {
    // 1. 去重:已消费过的直接丢弃
    if (index < this.nextExpected) return;
    
    // 2. 存入缓冲区
    this.buffer.set(index, { data, isLast });
    
    // 3. 尝试连续消费
    this.flush();
    
    // 4. 启动超时定时器(首次缺失时触发)
    if (this.buffer.size > 0 && !this.buffer.has(this.nextExpected) && !this.timeoutId) {
      this.startTimeout();
    }
  }

  private flush(): void {
    while (this.buffer.has(this.nextExpected)) {
      const chunk = this.buffer.get(this.nextExpected)!;
      this.onChunk(chunk.data, chunk.isLast);
      this.buffer.delete(this.nextExpected);
      this.nextExpected++;
      
      // 如果是最后一片,完成消费
      if (chunk.isLast) {
        this.clearTimeout();
        return;
      }
    }
  }

  private startTimeout(): void {
    this.timeoutId = setTimeout(() => {
      // 超时兜底:跳过缺失分片,强制拼接
      console.warn(`[StreamOrderBuffer] 消息 ${this.messageId} 缺失分片 ${this.nextExpected},强制继续`);
      this.buffer.delete(this.nextExpected); // 移除占位
      this.nextExpected++;
      this.flush();
      this.timeoutId = null;
    }, this.MAX_WAIT_MS);
  }

  private clearTimeout(): void {
    if (this.timeoutId) {
      clearTimeout(this.timeoutId);
      this.timeoutId = null;
    }
  }
}

第三层:跨消息全局排序

多条消息之间,每条消息携带全局单调递增序列号,前端消息列表按序列号插入而非按到达时间追加。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class MessageList {
  private messages: Map<number, Message> = new Map();
  private nextSeq = 0;

  // 服务端下发的每条消息都带 seq 字段
  appendMessage(seq: number, content: string): void {
    this.messages.set(seq, { seq, content, timestamp: Date.now() });
    // 只在连续序列号到达时渲染(或按 seq 排序渲染)
    this.renderSorted();
  }

  private renderSorted(): void {
    const sorted = Array.from(this.messages.values()).sort((a, b) => a.seq - b.seq);
    // 渲染排序后的列表
  }
}

2.4 线上坑点

坑点后果解决方案
缓冲区无限增长弱网下缺失分片累积,内存泄漏设置全局缓冲区上限(如 50 条),超出抛弃最旧消息
超时后永久卡住缺失一片导致整条消息无法显示3s 超时后强制跳过,UI 提示“部分内容丢失”
重复分片未去重文本重复出现chunkIndex < nextExpected 直接丢弃

三、断连重连

3.1 问题现象

AI 说到一半,网络切换(WiFi → 5G)、信号中断或服务端重启,流式连接断开。用户看到的是“消息说到一半卡住了”。

3.2 根因分析

SSE 基于 HTTP 长连接,天然存在以下脆弱性:

  • 网络层:切换网络时 TCP 连接断裂
  • 代理层:Nginx 默认 60s 超时,超过后主动断开
  • 服务端层:部署重启、GC 停顿导致连接被强制关闭
  • 客户端层:浏览器 Tab 休眠、移动端 App 切后台导致连接挂起

3.3 完整解决方案(断点续传 + 指数退避)

架构全景

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌─────────────────────────────────────────────────────────────┐
│                   客户端(浏览器/App)                       │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐ │
│  │ 连接状态机   │  │ 退避调度器  │  │ 断点续传管理器      │ │
│  │ (IDLE/连/断) │  │ (指数+抖动) │  │ (lastMsg+lastChunk) │ │
│  └─────────────┘  └─────────────┘  └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
                              │ HTTP/SSE
┌─────────────────────────────────────────────────────────────┐
│                    BFF 接入层(Node.js)                     │
│  ┌──────────────────────────────────────────────────────┐   │
│  │            Redis 缓存(分片暂存 + 断点记录)          │   │
│  │  key: stream:msg_123:chunk_5  value: "今天天气"     │   │
│  │  key: stream:msg_123:status  value: "PROCESSING"    │   │
│  └──────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
                              │ gRPC/HTTP
┌─────────────────────────────────────────────────────────────┐
│              大模型推理服务(GPU 集群)                       │
│              生成 Token → BFF 缓存 → SSE 推送                │
└─────────────────────────────────────────────────────────────┘

核心实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
class StreamReconnector {
  private retryCount = 0;
  private retryDelay = 1000; // 初始 1s
  private readonly MAX_RETRY_DELAY = 16000; // 最大 16s
  private readonly MAX_RETRY_COUNT = 8;
  private timer: ReturnType<typeof setTimeout> | null = null;
  private eventSource: EventSource | null = null;
  private lastReceivedMessageId: string | null = null;
  private lastReceivedChunkIndex = -1;
  
  // 连接状态机
  private state: 'idle' | 'connecting' | 'connected' | 'disconnected' | 'failed' = 'idle';

  connect(options: ConnectOptions): void {
    this.state = 'connecting';
    const url = this.buildUrl(options);
    this.eventSource = new EventSource(url);
    
    this.eventSource.onopen = () => {
      this.state = 'connected';
      this.retryCount = 0;
      this.retryDelay = 1000;
    };
    
    this.eventSource.onmessage = (event) => {
      const chunk = JSON.parse(event.data) as ChunkMetadata;
      // 更新断点记录
      this.lastReceivedMessageId = chunk.messageId;
      this.lastReceivedChunkIndex = chunk.chunkIndex;
      // 交给渲染层
      this.onChunk?.(chunk);
    };
    
    this.eventSource.onerror = () => {
      this.state = 'disconnected';
      this.handleDisconnect();
    };
  }

  private handleDisconnect(): void {
    // 1. 关闭旧连接
    this.eventSource?.close();
    
    // 2. 判断是否达到最大重试次数
    if (this.retryCount >= this.MAX_RETRY_COUNT) {
      this.state = 'failed';
      this.onFail?.('连接失败,请刷新页面重试');
      return;
    }
    
    // 3. 指数退避 + 随机抖动(打散重连峰值)
    const jitter = this.retryDelay * 0.2 * (Math.random() * 2 - 1);
    const delay = Math.max(100, this.retryDelay + jitter);
    
    this.timer = setTimeout(() => {
      this.retryCount++;
      this.retryDelay = Math.min(this.retryDelay * 2, this.MAX_RETRY_DELAY);
      
      // 携带断点信息重连
      this.connectWithResume({
        lastMessageId: this.lastReceivedMessageId,
        lastChunkIndex: this.lastReceivedChunkIndex,
      });
    }, delay);
  }

  // 断点续传:携带最后接收位置
  private buildUrl(options: ConnectOptions): string {
    const params = new URLSearchParams();
    params.set('sessionId', options.sessionId);
    if (this.lastReceivedMessageId) {
      params.set('resumeMessageId', this.lastReceivedMessageId);
      params.set('resumeChunkIndex', String(this.lastReceivedChunkIndex));
    }
    return `/api/chat/stream?${params.toString()}`;
  }
}

服务端处理逻辑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// BFF 层断点续传处理
app.get('/api/chat/stream', async (req, res) => {
  const { sessionId, resumeMessageId, resumeChunkIndex } = req.query;
  
  // 场景1:首次连接(无断点)
  if (!resumeMessageId) {
    const stream = await aiService.generateStream(sessionId);
    return pipeStream(res, stream);
  }
  
  // 场景2:断点续传
  // 优先从 Redis 缓存读取已生成的分片
  const cachedChunks = await redis.lrange(
    `stream:${resumeMessageId}:chunks`,
    parseInt(resumeChunkIndex) + 1,
    -1
  );
  
  if (cachedChunks.length > 0) {
    // 重放缓存分片(快速恢复)
    for (const chunk of cachedChunks) {
      res.write(`data: ${chunk}\n\n`);
    }
    // 检查消息是否已完成
    const status = await redis.get(`stream:${resumeMessageId}:status`);
    if (status === 'DONE') {
      res.end();
      return;
    }
  }
  
  // 缓存缺失:重新请求大模型(降级方案)
  const stream = await aiService.regenerateFrom(sessionId, resumeMessageId);
  return pipeStream(res, stream);
});

3.4 线上坑点

坑点后果解决方案
无限重连服务端雪崩最大重试 8 次,失败后提供手动重连按钮
心跳超时误判网络慢时频繁重连心跳间隔 15s,超时阈值 30s
Redis 缓存过期断点续传失效缓存 TTL 设为 10 分钟,覆盖大部分对话时长
重连期间用户发新消息并发冲突建立发送队列,重连成功后按序发出

四、重复发送

4.1 问题现象

用户吐槽“我只发了一条,为什么出现了两条一模一样的回复?”——在弱网场景下尤其频繁。

4.2 根因分析

重复消息的源头分为三层:

层面具体场景占比
用户操作层弱网下按钮无反馈,用户反复点击、连续按回车~60%
前端逻辑层请求超时自动重试、断连重连后重发~30%
网络层TCP 重传、代理重试,同一条请求被服务端接收多次~10%

核心结论:单点防护无效,必须全链路三层去重。

4.3 完整解决方案(三层防御)

第一层:前端交互防重(入口拦截)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class SendGuard {
  private isSending = false;
  private lastSendTime = 0;
  private readonly DEBOUNCE_MS = 500;

  async send(message: string, callback: (result: any) => void): Promise<void> {
    // 1. 互斥锁
    if (this.isSending) {
      console.warn('[SendGuard] 请求进行中,忽略重复发送');
      return;
    }
    
    // 2. 防抖(leading 触发)
    const now = Date.now();
    if (now - this.lastSendTime < this.DEBOUNCE_MS) {
      console.warn('[SendGuard] 防抖拦截,请勿快速点击');
      return;
    }
    this.lastSendTime = now;
    
    try {
      this.isSending = true;
      // 更新 UI:禁用按钮
      this.updateUI(true);
      
      const result = await this.doRequest(message);
      callback(result);
    } finally {
      this.isSending = false;
      this.updateUI(false);
    }
  }
}

关键细节:异常分支必须重置状态,否则报错后按钮永久禁用。

第二层:前端请求幂等(逻辑层去重)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class IdempotentRequester {
  private pendingRequests = new Set<string>();

  async request(payload: any): Promise<any> {
    // 1. 生成全局唯一请求 ID(使用 UUID v7,含时间戳)
    const requestId = this.generateRequestId();
    
    // 2. 检查是否已有相同请求在处理中
    if (this.pendingRequests.has(requestId)) {
      console.warn('[IdempotentRequester] 重复请求被拦截:', requestId);
      return this.waitForExistingRequest(requestId);
    }
    
    // 3. 标记为处理中
    this.pendingRequests.add(requestId);
    
    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: {
          'X-Request-ID': requestId,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      });
      
      const data = await response.json();
      this.pendingRequests.delete(requestId);
      return data;
    } catch (error) {
      this.pendingRequests.delete(requestId);
      throw error;
    }
  }

  private generateRequestId(): string {
    // UUID v7:时间戳 + 随机数,保证单调递增
    return crypto.randomUUID();
  }
}

第三层:服务端幂等去重(最终兜底)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// BFF 层幂等处理
class IdempotencyMiddleware {
  async handle(req: Request, next: () => Promise<Response>): Promise<Response> {
    const requestId = req.headers.get('X-Request-ID');
    if (!requestId) {
      return new Response('Missing X-Request-ID', { status: 400 });
    }
    
    const cacheKey = `idempotent:${requestId}`;
    
    // 1. 检查缓存
    const cached = await redis.get(cacheKey);
    if (cached) {
      const result = JSON.parse(cached);
      return new Response(JSON.stringify(result), {
        status: 200,
        headers: { 'X-Cache': 'HIT' },
      });
    }
    
    // 2. SETNX 原子操作:防止并发冲突
    const acquired = await redis.set(cacheKey, 'PROCESSING', {
      nx: true, // 不存在才设置
      ex: 300, // 5 分钟过期
    });
    
    if (!acquired) {
      // 并发冲突:等待第一个请求完成
      return this.waitForResult(cacheKey);
    }
    
    try {
      // 3. 执行业务逻辑
      const result = await next();
      
      // 4. 缓存结果
      await redis.set(cacheKey, JSON.stringify(result), { ex: 300 });
      return result;
    } catch (error) {
      // 处理失败时删除缓存,允许重试
      await redis.del(cacheKey);
      throw error;
    }
  }
}

4.4 线上坑点

坑点后果解决方案
只靠前端防重多端登录时防重失效必须依赖服务端幂等
缓存过期太短长对话中途重试被误判为新请求TTL 设置为 10 分钟
服务端崩溃后缓存未清除请求被死锁阻塞加心跳续期 + 失败主动删除

五、四大问题的协同关系与架构总结

这四个问题并非孤立存在,它们之间存在级联放大效应:

1
2
3
4
5
6
7
用户快速发送 → 请求竞态(问题一)
网络抖动 → 分片乱序(问题二)
连接断开 → 断连重连(问题三)
超时重试 → 重复发送(问题四)

因此,一个健壮的 AI 对话系统必须统一设计,而非各自为政。

最终架构全景

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
┌─────────────────────────────────────────────────────────────────────┐
│                         客户端(UI Layer)                         │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │                      ChatSessionManager                      │  │
│  │  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────┐ │  │
│  │  │竞态控制模块  │ │乱序缓冲模块 │ │断连重连模块 │ │幂等模块│ │  │
│  │  │(AbortCtrl)  │ │(OrderBuffer)│ │(Reconnector)│ │(Idemp) │ │  │
│  │  └─────────────┘ └─────────────┘ └─────────────┘ └───────┘ │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                              │                                     │
│                         SSE / Fetch                               │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│                        BFF 接入层(Node.js)                       │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │  │
│  │  │ 推理中断     │  │ 分片缓存    │  │ 幂等去重           │  │  │
│  │  │ (Abort 监听) │  │ (Redis)    │  │ (SETNX + 缓存)     │  │  │
│  │  └─────────────┘  └─────────────┘  └─────────────────────┘  │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                              │                                     │
│                          gRPC / HTTP                              │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│                   大模型推理服务(GPU 集群)                        │
│               LLM Inference Engine (vLLM / TensorRT-LLM)           │
└─────────────────────────────────────────────────────────────────────┘

写在最后

AI 对话的四大工程问题——竞态、乱序、断连、重复——本质上是分布式系统在弱网环境下的经典难题在 AI 场景的投射

解决它们的核心思想可以总结为三句话:

  1. 序号仲裁:用全局唯一的 messageId + chunkIndex 给每个数据包打上“身份证”,前端不再依赖到达顺序做业务决策
  2. 状态外置:将连接状态、断点位置从客户端内存移到服务端 Redis,实现真正的断点续传
  3. 幂等闭环:从前端防抖到服务端 SETNX,全链路保证一次操作只生效一次