27届大模型面试准备(七十二):大模型流式推理服务与长连接工程——SSE、背压与首包优化

27届大模型面试准备(七十二):大模型流式推理服务与长连接工程------SSE、背压与首包优化

引言

前面几篇把"怎么把请求调度到卡上、怎么把卡喂满"讲透了。本篇聊用户真正感知到的那一端:流式输出(打字机效果)。用户问一个问题,前端要能在 200ms 内看到第一个字,然后稳定地一个 token 一个 token 蹦出来,断网还能续上。这背后是 SSE/WebSocket 长连接、服务端背压、首包(TTFT)优化、断点续传一整套工程。也是华为云大模型 API 岗、对话产品后端岗必问。

前文链接:A71 多租户 GPU 虚拟化与推理隔离、A70 推理服务负载均衡与智能请求路由、A61 服务可观测性与延迟吞吐剖析。

复制代码
  Browser/App           网关(LB)              推理服务(vLLM/TGI)         GPU
      │                    │                        │                    │
      │── GET /v1/chat ──▶ │                        │                    │
      │   (SSE, stream)   │── forward ───────────▶ │  prefill           │
      │                    │                        │───────▶            │
      │◀── data: {"照") ──│◀── token#1 ────────────│◀──────             │
      │◀── data: {"片") ──│◀── token#2 ────────────│  decode 流式        │
      │   ...              │   ...                  │  (逐 token 回传)   │
      │◀── data: [DONE] ──│◀── 结束 ───────────────│                    │
      │                    │                        │                    │
   SSE 长连接全程保持,服务端按生成节奏 push,客户端按渲染节奏消费

表:流式协议选型

协议 方向 自动重连 实现复杂度 适用
SSE (text/event-stream) 服务端→客户端单向 浏览器原生 EventSource 低 对话流式输出首选
WebSocket 双向 需自实现 中 需客户端上行(语音/打断)
普通 HTTP + 客户端轮询 双向轮询 无 低但体验差 不支持 SSE 的古老环境

一、SSE 长连接的最小实现

SSE 本质是 Content-Type: text/event-stream 的分块 HTTP 响应,服务端用 flush 把每个 token 立即推给客户端,而不是等全部生成完才返回。

复制代码
# FastAPI 流式端点(SSE)
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import asyncio, json

app = FastAPI()

async def token_stream(prompt: str):
    # 假设 engine.generate_async 是异步逐 token 生成器
    async for piece in engine.generate_async(prompt, stream=True):
        # SSE 格式:以 "data: " 开头,两个换行结尾
        yield f"data: {json.dumps({'content': piece}, ensure_ascii=False)}\n\n"
        await asyncio.sleep(0)   # 让出事件循环,避免阻塞其他连接
    yield "data: [DONE]\n\n"

@app.post("/v1/chat/stream")
async def chat(req: Request):
    body = await req.json()
    return StreamingResponse(
        token_stream(body["prompt"]),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )

关键头:X-Accel-Buffering: no 关掉 Nginx 缓冲,否则 SSE 会被攒批;Cache-Control: no-cache 防止中间代理缓存。

二、服务端背压:生成快于消费怎么办

GPU 生成 token 的速度(可能 100+ tok/s)常快于客户端网络/渲染速度。如果服务端无脑猛推,内存里会堆满未发送的 chunk,连接一多就 OOM。正确做法是背压:客户端消费慢时,服务端生成器被"卡住"。

复制代码
import asyncio

async def backpressure_stream(raw_gen, client_queue_max=128):
    q = asyncio.Queue(maxsize=client_queue_max)   # 有界队列即天然背压
    async def producer():
        async for tok in raw_gen:
            await q.put(tok)        # 队列满则 producer 自动挂起
        await q.put(None)           # 结束哨兵
    async def consumer():
        asyncio.create_task(producer())
        while True:
            tok = await q.get()
            if tok is None:
                yield "data: [DONE]\n\n"; break
            yield f"data: {json.dumps({'content': tok}, ensure_ascii=False)}\n\n"
    async for chunk in consumer():
        yield chunk

当 q 满,producer 的 await q.put 挂起,推理引擎的 generate_async 也跟着停,自然把压力传导回调度层(连续批处理里该请求暂时不取新 token)。这就是"背压链":客户端慢 → 队列满 → 引擎暂停 → GPU 让给其他请求。

三、首包优化(TTFT)

用户最在意的是"多久出第一个字"。TTFT = 路由耗时 + 排队 + Prefill 耗时 + 首 token 网络传输。优化点:

  1. 预取/预热:长连接复用,避免每次握手建连。

  2. 优先级队列:流式对话请求进高优队列,减少排队。

  3. Prefix Cache:系统提示词命中前缀缓存,Prefill 直接复用 KV,省掉重复计算。

  4. 分块 Prefill:超大上下文时把 Prefill 切成多块穿插 decode,避免单次卡死。

    网关层首包计时(观测 TTFT 分布)

    import time
    @app.middleware("http")
    async def measure_ttft(request, call_next):
    start = time.perf_counter()
    response = await call_next(request)
    # 用 response 的流式回调记录首个 chunk 到达时刻
    request.state.ttft_start = start
    return response

    在 token_stream 第一个 yield 时打点:ttft = now - request.state.ttft_start

四、断线续传与去重

弱网环境下 SSE 可能中途断开。浏览器 EventSource 会自动重连,但重连会重新发请求、从头生成,浪费且重复。生产做法:

  1. 服务端给每条流式会话发 stream_id,客户端重连带 Last-Event-ID。

  2. 网关按 stream_id 缓存最近 N 个已发 token,重连后从断点续推,不重新进模型。

  3. 客户端做幂等:同一 stream_id 的 data 按序号去重拼接。

    断点续传:网关缓存已发事件

    class StreamResumer:
    def init(self):
    self.sent = {} # stream_id -> list[(seq, payload)]

    复制代码
     def on_send(self, sid, seq, payload):
         self.sent.setdefault(sid, []).append((seq, payload))
    
     def resume_from(self, sid, last_seq):
         # 只补发 last_seq 之后的事件
         return [(s, p) for s, p in self.sent.get(sid, []) if s > last_seq]

五、长连接资源治理

每个 SSE 连接占一个 worker 协程 + 一个网关连接。万人同时对话,连接数爆炸。治理:

  • 连接数上限 + 空闲超时(无 token 超过 N 秒断连)。
  • 网关层用支持长连接的反向代理(如 Envoy),而非短连接为主的 Nginx 默认配置。
  • 多实例下,流式会话要粘性(同一 stream 落到同一后端),否则续传缓存失效。

面试速答

问:SSE 和 WebSocket 怎么选?

答:纯服务端推(对话输出)用 SSE,浏览器原生 EventSource 自动重连、实现简单;需要客户端上行(语音打断、实时控制)才上 WebSocket。

问:服务端生成比客户端消费快,怎么不 OOM?

答:有界队列做背压,队列满时推理生成器挂起,压力传导回引擎和调度层,让 GPU 服务其他请求,而不是在内存堆 chunk。

问:断网重连为什么会重复输出,怎么解?

答:EventSource 重连会重新请求从头生成。解法:服务端发 stream_id + 序号,重连带 Last-Event-ID,网关从断点续推,客户端按序号去重。

高频追问清单

  1. Nginx 默认会缓冲 SSE 吗?哪个响应头能关掉?
  2. 背压链断在哪一层最危险(网关/引擎/客户端)?
  3. 流式会话在多副本部署下怎么做粘性路由和续传缓存?
  4. TTFT 的构成拆解,哪一段最难优化、为什么?
  5. Prefix Cache 对首包优化的收益上限受什么限制?
  6. 万人长连接下,网关连接数和后端 worker 怎么配比?
  7. 客户端怎么区分"模型真的说完了"和"网络断了"?
  8. 流式输出如何保证 token 顺序不乱(并发 decode + 多候选)?
相关推荐
像风一样自由20206 小时前
3.Dockerfile如何构建一个Python算法镜像
开发语言·人工智能·python·大模型
猿界零零七9 小时前
Agent 开发面试题--Day002【Agent和Workflow的有什么区别】
人工智能·大模型
大鹏的NLP博客9 小时前
从格赖斯合作原则看大语言模型与提示词工程
人工智能·自然语言处理·大模型·agent
寻道码路10 小时前
AI Agent 工程化开发实战(五):持久化与断点恢复——Checkpointer 与 Store
人工智能·大模型·checkpoint·持久化·ai agent·langgraph·ai工程化
虎鲸不是鱼10 小时前
AI 自动化逆向(三十六)先找不加密的部分
人工智能·自动化·大模型·逆向·ai逆向
学IT的周星星11 小时前
本地部署与开源模型:不想花钱怎么办
人工智能·开源·大模型·llm·agent
重生之我在番茄自学网安拯救世界1 天前
AI 开发者日报 第1期(本周精选)| 插件市场投毒、智能体搞出“官僚饥荒村”,十年前的 CNN 打赢大模型
人工智能·网络安全·大模型·ai编程·智能体
遇码1 天前
2026-11-01-daw-retry-banner-body
人工智能·ai·rust·数据分析·大模型·agent
猿界零零七1 天前
Agent 开发面试题--day001
人工智能·大模型
艾莉丝努力练剑1 天前
【AI大模型接入SDK】ChatServer问题修复
c++·学习·ai·大模型·sdk