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 满,producerawait 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_iddata 按序号去重拼接。

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

    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 + 多候选)?
相关推荐
deepseek232 小时前
Kimi K3 开放日拆解:2.8T MoE 的 896 专家稀疏路由、KDA 混合注意力与 Infra 三件套
大模型·ai agent·moe
xcLeigh3 小时前
提示词模板库:建立你的专属Prompt兵器库
人工智能·大模型·prompt·提示词
猫先生Mr.Mao4 小时前
大模型之Attention Is All You Need详解:Transformer如何用注意力重写序列建模
深度学习·大模型·transformer·注意力机制·论文解读
thesky1234564 小时前
27届大模型面试准备(七十四):大模型长上下文推理与服务化工程——分块 Prefill、KV 卸载与 Ring Attention
大模型·服务化·chunked prefill·长上下文推理·kv卸载·ring attention·序列并行
寻道码路4 小时前
大模型工程化实战(七):JSON Schema 强约束——模型吐的 json 不合规?schema 校验不通过,重试、兜底、降级一条龙
大模型·agent·rag·json schema·ai工程化·llmops`
KeepSeek5 小时前
大模型推理优化面试题
大模型
deepseek235 小时前
Claude Fable 5.1 深度拆解:推理强度、缓存降价,Agent 工作流成本如何省 45%
大模型·claude·ai agent
七夜zippoe6 小时前
我用 Qwen3.8-Max 搭了一个 AI 作业辅导助手,拍照讲题 + 错题本诊断一次搞定
人工智能·ai·大模型·qwen3.8-max·build with qwen
腾视科技-AI6 小时前
腾视科技AI大模型应用:提效、破局与落地,重塑智能新生态
大数据·人工智能·科技·大模型·ai大模型·腾视科技·ai算力盒