LLM 流式响应的中断恢复工程:客户端断线、上游 502、运维下毒,三类场景下如何续传不重复扣 token

上周我们一个客户的 AI 写作产品出了次成本异常。日均大模型 API 账单从 580 元跳到 1670 元,QPS 没涨,模型也没换。

排查到第三天才发现根因:他们前端默认的 SSE 重连策略,是把 messages 整段重新 POST 一次,连续三天的某次 CDN 抖动让用户平均触发了 2.3 次重连。每一次都把同一个 prompt 重新计费一次输入 token,外加把已经生成到一半的输出又跑了一遍。

这不是单点 bug。我盘了一下手上跟踪的 9 个生产级 LLM 应用,有 6 个的"流式中断恢复"逻辑就是简单 retry 整段。剩下 3 个做了点事,但都只解决了一类场景。

这篇文章想说清楚一件事:LLM 流式中断恢复不是一个开关,是一个三层架构问题。中断有三类不同的源头,每一类需要不同的层去兜底。下面是这三层的工程实现、决策依据,和我们踩过的几个坑。

1. 先把"流式中断"拆成三类,不要混在一起谈

我见过的设计错位,几乎都是因为把这三种场景当成一种问题处理。它们的发生频率、责任边界、修复成本都不一样。

场景类型 触发原因 频率(线上观测) 谁该兜底
客户端断线 用户刷新、切前后台、WiFi → 4G、Tab 被浏览器 throttle 每千次会话 ≈ 18~30 次 客户端 + Gateway
上游 API 抖动 502 / 504 / finish_reason=incomplete / read timeout 每万次请求 ≈ 5~12 次(高峰更高) Gateway + 上游 retry 协议
自身运维下毒 滚动重启、扩缩容、sticky session 失效、K8s 优雅退出超时 每次 deploy 影响 5%~30% 在途请求 Gateway + 部署策略

我把这三类的频率单独写出来是想强调:客户端断线是绝对大头,但它常常被忽略,因为开发者本地测试时不会刷浏览器。运维下毒频率不高但杀伤力大------一次 deploy 把几百个在途的 Agent 任务全打断,客诉爆得很难看。

2. L1 客户端 cursor:最便宜,覆盖最大头场景

L1 解决的是"用户已经看到的内容不要再付费生成一次"。核心是两件事:

  • 客户端记录已经收到的 token offset 或者 event id
  • 重连时通过 Last-Event-ID 告诉 Gateway "我看到哪了"

2.1 协议设计:每个 chunk 带 event id

很多团队的 SSE 协议长这样:

css 复制代码
data: {"choices":[{"delta":{"content":"Hello"}}]}

data: {"choices":[{"delta":{"content":" world"}}]}

这种协议没法 resume。因为客户端断开后回来,Gateway 不知道客户端走到哪了。

改成下面这种:

vbnet 复制代码
id: rsp_abc123:0
event: chunk
data: {"delta":"Hello","usage_delta":{"output_tokens":1}}

id: rsp_abc123:1
event: chunk
data: {"delta":" world","usage_delta":{"output_tokens":1}}

id: rsp_abc123:done
event: done
data: {"finish_reason":"stop","total_usage":{"input_tokens":42,"output_tokens":2}}

注意三个点:

  1. id<响应ID>:<序号> 格式,标准 EventSource 浏览器实现会自动把它放进 Last-Event-ID header
  2. usage_delta 让客户端能在断线时知道"我已经实打实消费了多少 token"
  3. done 事件有显式 id,重连时如果上一次已经收到 done,Gateway 直接 304 别再重算

2.2 客户端实现:浏览器原生 EventSource 不够用

浏览器原生 EventSource 不支持自定义 header(包括认证),也不支持 POST。生产代码基本都会用 fetch + 手写 stream 解析。下面是一段 TypeScript 实现,关键是 localStorage 持久化 cursor:

typescript 复制代码
interface StreamCursor {
  responseId: string;
  lastEventId: string;
  contentBuffer: string;
  usageSoFar: { input: number; output: number };
}

const CURSOR_KEY = 'llm_stream_cursor_v1';

async function streamWithResume(
  endpoint: string,
  payload: object,
  onDelta: (text: string) => void
): Promise<void> {
  // 1) 读取上次断点
  const cached = localStorage.getItem(CURSOR_KEY);
  const cursor: StreamCursor | null = cached ? JSON.parse(cached) : null;

  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    'Accept': 'text/event-stream',
  };
  if (cursor) {
    headers['Last-Event-ID'] = cursor.lastEventId;
    headers['X-Resume-Response-Id'] = cursor.responseId;
    // 已经显示过的文本先回填,避免闪烁
    onDelta(cursor.contentBuffer);
  }

  const res = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify(payload),
  });

  if (!res.ok || !res.body) throw new Error(`stream failed: ${res.status}`);

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let state: StreamCursor = cursor ?? {
    responseId: '',
    lastEventId: '',
    contentBuffer: '',
    usageSoFar: { input: 0, output: 0 },
  };

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });

      // 按 \n\n 切分 SSE 事件
      let sep;
      while ((sep = buffer.indexOf('\n\n')) >= 0) {
        const raw = buffer.slice(0, sep);
        buffer = buffer.slice(sep + 2);
        const event = parseSseEvent(raw);
        if (!event) continue;

        if (event.id) state.lastEventId = event.id;
        if (event.event === 'chunk') {
          const data = JSON.parse(event.data);
          state.contentBuffer += data.delta;
          state.usageSoFar.output += data.usage_delta?.output_tokens ?? 0;
          onDelta(data.delta);
          localStorage.setItem(CURSOR_KEY, JSON.stringify(state));
        } else if (event.event === 'done') {
          localStorage.removeItem(CURSOR_KEY);
          return;
        }
      }
    }
  } catch (err) {
    // 网络断开:cursor 已经持久化,下次进入此函数会自动续传
    throw err;
  }
}

function parseSseEvent(raw: string): { id?: string; event?: string; data: string } | null {
  const lines = raw.split('\n');
  const out: any = { data: '' };
  for (const line of lines) {
    if (line.startsWith('id:')) out.id = line.slice(3).trim();
    else if (line.startsWith('event:')) out.event = line.slice(6).trim();
    else if (line.startsWith('data:')) out.data += line.slice(5).trim();
  }
  return out.data ? out : null;
}

2.3 L1 单独上线的效果

上述代码在前面那个客户的产品里上线后,token 异常账单的根因消失。我们对比了上线前后 14 天的数据:

指标 L1 上线前 L1 上线后
平均每会话重复输入 token 1.46× 基线 1.04× 基线
断线重连时用户感知"白等" 100% 0%(已显示文本会回填)
大模型 API 月账单(同 QPS) ¥49 800 ¥29 900

省的不是优化算出来的,是之前白付的钱不再付了

3. L2 Gateway replay buffer:处理上游和运维的双重抖动

L1 只能解决"客户端断、再回来"。它解决不了:

  • 上游大模型 API 给我返了 200 OK 但中途 incomplete,客户端这边已经显示了 800 字,要怎么续?
  • Gateway 自己滚动重启了,buffer 没了,客户端拿着 Last-Event-ID 来要也没人认。

这两类问题都需要 Gateway 侧有一个可被多实例共享、可被时间窗口内 replay 的 buffer

3.1 Redis Streams 作为 replay buffer

我们选 Redis Streams 不是因为它快,是因为它天然带 event id + 范围查询。每个流式响应有一个独立的 stream key:

yaml 复制代码
stream:rsp:rsp_abc123
  - 0-0: {event: meta, response_id: rsp_abc123, model: deepseek-r1, input_tokens: 42}
  - 0-1: {event: chunk, seq: 0, delta: "Hello", output_tokens: 1}
  - 0-2: {event: chunk, seq: 1, delta: " world", output_tokens: 1}
  - 0-3: {event: done, finish_reason: stop}

key 上挂 TTL(我们默认 300s,能覆盖 99% 的客户端重连),超期自动回收。

Gateway 关键路径:

typescript 复制代码
async function streamProxy(req: Request): Promise<Response> {
  const resumeId = req.headers.get('X-Resume-Response-Id');
  const lastEventId = req.headers.get('Last-Event-ID');

  // 1) Resume 路径:直接从 buffer replay,不再去上游
  if (resumeId && lastEventId) {
    const remaining = await redis.xrange(
      `stream:rsp:${resumeId}`,
      `(${lastEventId}`,  // ( 表示开区间
      '+',
    );
    if (remaining.length > 0) {
      return new Response(
        renderSseFromRedisEntries(remaining),
        { headers: { 'Content-Type': 'text/event-stream' } },
      );
    }
    // buffer 空了:fallthrough 走 L3 prefix 续生成(见下一节)
  }

  // 2) 正常路径:从上游拉,边写 Redis 边转发
  return startUpstreamAndTee(req);
}

startUpstreamAndTee 实现里有一个很容易踩的坑 :必须先写 Redis 再发给客户端。反过来的话,客户端断开 + Gateway 来不及 fsync 就重启 = buffer 里没这一段,客户端再来时无法续。顺序错了就等于没做 L2。

3.2 多实例 + 运维下毒的处理

L2 还有一个隐藏价值是让滚动重启不再致命。因为 buffer 在 Redis,被打掉的 Gateway 实例不持有任何独占状态,客户端重连到新实例照样能续上。

但有两个细节必须处理:

(a) 上游连接的"接管" 。如果原 Gateway 实例还活着但正在 SIGTERM 优雅退出,它持有的上游 HTTP 连接得继续把上游 chunks 写到 Redis 直到 done 或 timeout,不能因为 SIGTERM 就立刻 close 。我们的做法是收到 SIGTERM 后,新请求拒绝、在途请求允许跑完,最长等 90 秒。terminationGracePeriodSeconds 也得调到 120。

(b) Redis 本身的高可用 。如果你用单实例 Redis,那 L2 就把 SPOF 从 Gateway 转移到了 Redis。我们生产用的是 Redis Sentinel + 三副本,并对 xaddWAIT 1 100ms 保证至少一个 replica 收到再返回。这点成本换来的是 deploy 期间在途请求成功率从 70% 拉到 99.2%。

3.3 Buffer 不是无限的:淘汰策略

每个流的 buffer key 占内存大约 = chunk 数 × 平均 chunk 200B。一条长输出 4k token 大概 4000 个 chunk × 200B ≈ 800KB。我们线上同时在跑 ~600 个会话,峰值 buffer 内存约 500MB,控制得住。

为了防止异常爆内存,做两层保护:

  • 单 key MAXLEN ≈ 10000:超过自动 trim(用 xadd MAXLEN ~,性能更好)
  • 全局 Redis maxmemory-policy: volatile-lru,且只把 buffer 类 key 设 TTL

3.4 关键指标

L2 上线后必须监控的指标:

ini 复制代码
gateway_stream_resume_total{result="replay_hit"|"replay_miss"|"buffer_evicted"}
gateway_stream_buffer_bytes  # 当前 buffer 总占用
gateway_upstream_chunk_to_redis_lag_ms  # tee 延迟,过高说明 Redis 慢了拖累流式体验

replay_hit / (replay_hit + replay_miss) 我们线上稳定在 96~98%,剩下的 miss 主要是 TTL 过期或者用户离开超过 5 分钟。

4. L3 上游 prefix 复用:超长输出的最后兜底

L2 解决了"我有 buffer 时怎么 replay"。但有一种情况 buffer 里也没有:上游在 chunk #3000 时 502 了,buffer 里只有 1~3000,第 3001 个 token 谁来生成?

L3 的工作是让上游基于已生成的前缀,继续生成尾部 。这不是简单 retry,是把 buffer 里已有的 assistant 输出作为下一轮 messages 的 assistant turn 注入。

4.1 协议层做法

主流国产模型(DeepSeek-V3、通义 Qwen-Max、智谱 GLM-4 等)都支持 messages 数组里把 assistant turn 放在最后,模型会"接着写"。所以 L3 的核心代码很短:

typescript 复制代码
async function resumeWithPrefix(
  originalRequest: ChatRequest,
  alreadyGenerated: string,
): Promise<Response> {
  const messages = [
    ...originalRequest.messages,
    { role: 'assistant', content: alreadyGenerated },
  ];

  return upstream.chat({
    ...originalRequest,
    messages,
    // 一些模型需要显式提示"继续"
    // 可以在 system prompt 中加 "Continue the assistant message naturally."
  });
}

但有几个非常容易翻车的细节:

  1. 不要在 messages 里塞 markdown fence 。如果之前生成到一半正好停在 ``````````` 里面,模型续写时会再开一个 fence,导致输出嵌套。我们的做法是 trim 最后一个未闭合的 fence,由 Gateway 补齐。
  2. 温度和 seed 要保持一致,否则续写的风格会断层(虽然技术上能跑,用户能看出来)。
  3. 续写不要再走 cache :部分国产模型支持 prompt caching,可以把原始 prompt 当 hit 省钱;但如果你的 buffer 里 alreadyGenerated 是动态的,这部分不能 cache,要把 cache_control 只标在原始 messages 上。
  4. 算账要小心:L3 让你输入 token 重新付了一次(原 prompt + 已生成前缀),等于把"省的钱"还回去一部分。但比 L0 整段 retry 仍然便宜,因为输出 token 只生成尾部。

4.2 L3 触发条件:不要默认开

L3 比 L1、L2 都贵。我们的策略是只在 buffer 已经存在且证明上游 abort 才触发

arduino 复制代码
触发 L3 ⇔ Gateway buffer 有 N 个 chunks 且最后一个不是 done
         且上游返回非 2xx 或 finish_reason ∈ {incomplete, length, error}
         且原请求 max_tokens - 已生成 > 200

最后一个条件是经验:剩余 token 太少(< 200),不如直接返回当前的,标记 finish_reason: truncated_by_upstream,让上层业务决定要不要重发。

5. 三层决策表:你的应用该上到哪一层?

你的应用形态 推荐层级 理由
单轮问答、输出 < 500 token L1 即可 中断概率低,L2 成本不划算
聊天 / Copilot 长会话 L1 + L2 必上 用户刷新和滚动重启都频繁
代码生成 / 长推理 / Agent 任务 (> 30s) L1 + L2 + L3 全上 上游 incomplete 概率显著上升
Server-to-Server,无 UI 跳过 L1,只做 L2 + 上游 retry 没有客户端断线,但你的服务有重启
内部 demo / POC 全跳过 不值得维护,挂了重发就行

附上各层成本估算(按 1000 万 token/月、¥20/M token 估算):

  • L1:开发一次性约 2 人天,几乎无运行时成本
  • L2:开发约 4 人天 + Redis 内存 ≈ 1GB(~¥150/月)+ 监控
  • L3:开发约 3 人天,但触发时输入 token 多付一次,按 5% 的请求触发算月增 ¥100

总体投入 < ¥500/月 + 9 人天,换回的是流式应用上线后不会因为成本异常被老板叫去开复盘会

6. 我们踩过的三个坑(按出问题顺序)

坑 1:Last-Event-ID 在 nginx 反代里被吃掉

我们 Gateway 前面挂了一层 nginx 做 TLS termination。默认 nginx 不转发 Last-Event-ID(它没有进 proxy_pass_request_headers 默认清单的麻烦,但实际我们排查发现是一个 Lua 脚本在做认证时把所有非白名单 header 删了)。结果就是 Gateway 永远收不到 cursor,L1 完全没生效。

教训:任何 SSE 改造,第一步是用 curl -N 直接打 Gateway,看 header 是不是干净到达

坑 2:Redis Streams 的 ID 单调性被 client 时钟搞坏

我们一开始用 xadd * ... 让 Redis 自动生成 ID。后来发现 Gateway 多实例时,如果 NTP 没对齐,两个实例写同一个 stream 会有 ID 倒序xrange 跨越倒序段时会丢事件。

解决方案:用 <毫秒时间>-<序号> 自己分配 ID,序号在 Gateway 进程内用原子计数器。

坑 3:客户端 localStorage 在 iOS Safari 私有模式里写不进去

cursor 存 localStorage 看起来很合理。但 iOS Safari 私有模式 localStorage setItem 会抛 QuotaExceededError,整个流式就崩了。

修:所有 localStorage.setItem 包 try/catch,失败就降级到 in-memory 单次会话 cursor(刷新就丢,但至少不崩)。

7. 上线 checklist

把这些挂在 PR 模板里,少踩坑:

  • SSE 协议里每个 chunk 有显式 id
  • id 格式可以反推 response_id 和 seq
  • Gateway 写 buffer 先于写客户端
  • Redis key 有 TTL,且 MAXLEN 控制单 key 大小
  • Gateway SIGTERM 后允许在途流跑完(grace > 上游 timeout)
  • Last-Event-ID 在所有反代层都 pass-through
  • L3 触发条件里包含 max_tokens 剩余 > 200
  • 监控里有 replay_hit_ratioduplicate_input_token_ratiobuffer_eviction_rate
  • 客户端 cursor 持久化失败时降级而不是抛错

8. 写在最后

LLM 应用上线半年后我有一个体感:真正烧钱的不是模型太贵,是工程没做好导致同一个 token 你为它付了 2~3 次钱。中断恢复就是这类问题里最隐蔽的一个------它不会让你 P0,但每个月账单异常你又说不出哪里多。

三层架构不复杂,关键是想清楚每一层兜的是哪一类断线,不要试图用一个开关解决所有问题。

下一篇我打算写流式 + 多模型 fallback 串起来时的那些边缘 case:上游 A 断了切到 B,已经生成的前缀塞回去 B 它能接得住吗?欢迎评论里聊你遇到的具体场景。

相关推荐
AINative软件工程1 天前
AI Coding 上线后,我们团队失效了的 6 条研发管理规则
openai
AINative软件工程1 天前
LLM 应用的 Canary 发布工程实践:模型升级不停服的灰度切流、回滚与流量染色
openai
小黄人软件2 天前
Claude和Codex下载离线包 安装遇到问题:windows无法访问指定设备 路径 文件 应用无法打开也无法卸载,解决了
人工智能·microsoft·openai·codex
AINative软件工程2 天前
AI Agent 的内存工程实践:短期、长期与外部记忆的架构选型与生产落地
openai
武子康2 天前
调查研究-171 什么是 Aha Moment:从「被使用」到「被需要」的关键瞬间
人工智能·openai
再玩一会儿看代码2 天前
2026 年 ChatGPT 套餐怎么选?Free、Go、Plus、Pro、Business、Enterprise 一次讲清楚
人工智能·gpt·chatgpt·golang·openai·codex
吴佳浩3 天前
炸裂!!!给 codeX 装上本地大脑:cc-switch_Ollama 接入全记录
人工智能·rust·openai