📑 本页目录(点开跳转)
05 · 流式输出
⏱ 64 分钟 | ⭐ 只管后端到 HTTP 这一半 | ⚠️ 本章一半篇幅在讲「代码没错但流式还是坏了」
🎯 一句话
让字一个一个出来,难的不是「怎么吐」,而是「一路上有三个地方会悄悄把它攒起来」。 本章管后端 → HTTP 这一段:怎么吐、怎么在流里报错、怎么在用户关掉页面时停止烧钱。浏览器那一半在第 10 章。
🌊 一、为什么必须流式(一节就够)
一次长回答生成几十秒是常态。非流式下用户从点发送到看见任何东西,等于整段生成时长;流式下这个时间被压到接近一次网络往返。⭐ 总时长一模一样,差别只在用户有没有在等待期间获得反馈——很多人等 5 秒就会重刷,而重刷会再烧一次钱。
⭐ 「为什么要流式」「护栏怎么和流式配合」在 智能体工程 16c 讲过,那边是产品视角。 这一章接着往下讲工程链路:报文怎么编、被谁吃掉了、错了怎么办、断了怎么办。
🔀 二、三种通道,为什么 LLM 场景通常选 SSE
| 通道 | 方向 | 代价 | 什么时候选它 |
|---|---|---|---|
⭐ SSE(text/event-stream) |
服务器 → 客户端单向 | 几乎没有:就是一个没结束的 HTTP 响应 | 默认选它。LLM 输出天然是单向的 |
| WebSocket | 双向 | 要升级协议、要自己做心跳/重连/鉴权;很多代理和企业网关默认不放行 | 真的需要边说边打断:语音、协同编辑、实时打断生成 |
| 裸分块传输(chunked) | 单向 | ⚠️ 你得自己定义「一帧到哪结束」 | 你确实只吐纯文本、且不打算加任何元信息时 |
⭐ SSE 和「裸分块」的关系容易搞混:SSE 就是分块传输,只不过多规定了一件事——帧怎么分(每帧以空行结束)。这点约定换来的是「以后要加错误、用量、工具状态时不用重写前端」(01 章第三节那个决定)。
⚠️ WebSocket 不是「更高级的 SSE」。它多出来的能力是上行,而 LLM 场景的上行只有一句「用户又发了一条消息」,一个普通 POST 就够了。为它上单独的协议,等于把心跳、重连、鉴权、代理兼容四件事全揽到自己身上。
📦 三、SSE 报文长什么样
协议规矩很少,就三条:data: 是内容、空行结束一帧、冒号开头的行是注释(前端会忽略,正好拿来做心跳)。
⭐⭐ 本板块的统一帧格式:每帧一行 data: {JSON},帧间空一行,帧的类型放在 JSON 负载的 type 字段里。
data: {"type":"delta","text":"你"}
data: {"type":"delta","text":"好"}
data: {"type":"usage","in":120,"out":8}
data: {"type":"error","message":"upstream timeout","trace_id":"a1b2c3d4e5f6"}
data: {"type":"done"}
⚠️ SSE 协议本身还有一个 event: 行可以给帧起名字,但本板块不用它 —— 这是「跟着消费方走」的选择。第 10 章会讲:浏览器原生的 EventSource 带不了 Authorization header,所以前端一律用 fetch + ReadableStream 手动解析。而 event: 行只对 EventSource 有意义——手动解析器要么得多写一套状态机记住「上一行的 event 名是什么」,要么干脆把它整行丢掉。
⭐ 把类型放进 JSON 负载,换来的是「一行
JSON.parse拿到全部信息」:解析路径只有一条。 同理结束帧写{"type":"done"}而不是[DONE]—— ⚠️[DONE]不是合法 JSON,会让统一的JSON.parse当场抛SyntaxError,逼前端在解析前先加一次特判。 ⚠️ 错误帧的字段名统一用message(不是code):前端拿它是要显示给人看的,需要一句话,不是一个枚举值。 ⭐⭐ 但错误帧必须多带一个trace_id—— 第 3 章立的原则是「每个错误带trace_id并同时写进日志」,⚠️ 流式这条路上最容易把它漏掉:非流式出错时trace_id在那个 500 的 JSON 里,而流式的状态码早已是 200、那个 JSON 根本不会发出来。漏了的后果很具体:用户说「刚才回答到一半崩了」,你手上什么都没有 —— 而这恰恰是本板块最常见的一种失败。
⚠️ 注意 message 和 trace_id 的分工,两者缺一不可:message 是给人看的一句人话(⚠️ 别塞异常原文,上游报错常带着密钥前缀、内网 IP、SQL 片段);trace_id 是给你查的,它在日志里对应着完整细节。一句人话 + 一个能报的号,就是这一对的全部用意。
# sse.py —— SSE 报文自己编一遍。看清楚字节长什么样,坑就少一半
import json
def sse(payload):
"""把一帧编成 SSE 文本。⭐ 硬规则只有一条:一帧以【空行】结束。
⭐ 本板块约定:payload 永远是 dict,帧类型放在它的 "type" 字段里。"""
body = json.dumps(payload, ensure_ascii=False) # ⭐ 永远 JSON 包一层
lines = ["data: %s" % ln for ln in body.split("\n")] # 兜底:万一真有换行就拆成多行
return "\n".join(lines) + "\n\n" # ⭐ 两个 \n:一个收本行,一个收本帧
def comment(text=""):
return ": %s\n\n" % text # ⭐ 冒号开头 = 注释帧,用来做心跳
if __name__ == "__main__":
wire = "".join([sse({"type": "delta", "text": "退"}),
comment("ping"),
sse({"type": "delta", "text": "第一行\n第二行"}), # ⭐ 内容里带换行
sse({"type": "usage", "in": 120, "out": 340}),
sse({"type": "error", "message": "upstream timeout"}),
sse({"type": "done"})])
print(wire.replace("\n", "\\n\n")) # 把换行显示出来才看得清帧边界
# ⭐ 看第三帧:换行已被 json.dumps 转义成 \n 两个字符,所以它仍然只占【一帧】
⚠️ 两个必踩的坑,都在「换行」上:① 内容里的换行会把一帧劈成两帧——模型输出里有换行是家常便饭,所以正文永远用 JSON 包一层(json.dumps 会把换行转义成 \n 两个字符),别直接 data: 模型原文。② 少一个 \n,帧就永远不完整,前端一直等下一批——症状是「全部内容在最后一瞬间才出现」,和被缓冲一模一样。
⚠️⚠️ 四、一路上吃掉流式的三个地方
本章最贵的一节。 症状永远是同一句话:本地逐字正常,一上线就变成「憋很久,然后一次性全出现」——而你的代码一个字都没错。
| 凶手 | 它的默认行为 | 怎么修 |
|---|---|---|
| ⭐ 反向代理 | Nginx 那类默认 proxy_buffering on:攒够一批再转给客户端 |
那一段配 proxy_buffering off;,或让后端回 X-Accel-Buffering: no 响应头 |
| ⭐ 压缩中间件 | GZip/Brotli 要攒够一个压缩块才有输出 | 对 text/event-stream 关掉压缩(多数中间件支持按 MIME 类型排除) |
| ⭐ 云平台 / 网关 / CDN | 有的会整体缓冲响应体再返回,Serverless 形态尤其常见 | 换成 01 章第六节那五条判据的部署形态;CDN 上给这个路径关缓冲 |
⚠️ 三个凶手的共同点,才是它们难查的原因:① 不报错——没有任何日志说「我缓冲了」;② 本地永远复现不了——本地没有代理、没有 CDN;③ 症状完全一样——三个凶手长得一模一样,光看症状分不出是谁。
🔍 怎么定位:一个探针 + 从里往外逐层测
⭐ 别去读代码找 bug,代码没 bug。 做法:部署一个每秒吐一帧的探针端点,从最里面往外一层层测——直连应用进程 → 容器端口 → 反向代理 → 公网域名,第一处「帧间隔塌成 0」的那层就是凶手。⭐ 判据是帧间隔,不是总时长:被缓冲时总时长完全不变(还是 5 秒),变的是「5 帧在第 5 秒一起到」。
# probe.py —— 流式探针:每秒吐一帧,再自己量【帧间隔】。只用标准库,可直接跑
import http.server, socketserver, threading, time, urllib.request
N, GAP = 5, 1.0 # 吐 5 帧,每帧间隔 1 秒
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("X-Accel-Buffering", "no") # ⭐ 请 Nginx 那一层别缓冲
self.end_headers()
for i in range(N):
self.wfile.write(("data: %d\n\n" % i).encode())
self.wfile.flush() # ⭐ 少了这句,本机这层就先憋住了
time.sleep(GAP)
def log_message(self, *a):
pass # 别把访问日志混进输出
def gaps(url):
t0, marks = time.perf_counter(), []
with urllib.request.urlopen(url) as r:
for line in r: # 逐行读,不等整体读完
if line.strip():
marks.append(time.perf_counter() - t0)
return [b - a for a, b in zip(marks, marks[1:])]
if __name__ == "__main__":
socketserver.TCPServer.allow_reuse_address = True
srv = socketserver.TCPServer(("127.0.0.1", 8123), Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
g = gaps("http://127.0.0.1:8123/")
print("帧间隔:", ["%.2f" % x for x in g])
# ⭐ 间隔接近 GAP = 真流式;全接近 0 而总时长仍是 N*GAP = 中间有人在缓冲
print("判定:", "流式正常" if g and max(g) > GAP / 2 else "有人在缓冲")
srv.shutdown()
🔨 手上没有探针端点时的替身:curl -N <地址>(-N = 关掉 curl 自己的输出缓冲)。字一点一点出来就是通的,憋着不动就是被谁攒了。⚠️ 别用浏览器的开发者工具判断——它自己也会做缓冲和分帧展示,看到的时序不一定是真的。
💓 一个近亲问题:空闲连接被掐。 模型"想"得久一点(长上下文、工具调用中)时,连接上可能几十秒没有任何字节,而 ⚠️ 很多中间设备对空闲连接有超时(🗓️ 具体值看你那层的配置),到点直接掐断,前端只看到「流莫名其妙断了」。解法就是上面那个 comment("ping"):空闲时每隔十几秒发一个注释帧——不是数据、前端会忽略,但连接上有字节在流动。
🛑 读到这里可以停 —— 前半章讲完了(约 27 分钟)。 后半章还有:流式下的错误处理:头已经发出去了 · 断连:用户关了页面,你还在烧钱 · 换个栈怎么对应 回来的时候不用重读,直接从下一节接着看就行。
🚨 五、流式下的错误处理:头已经发出去了
非流式出错很简单:返回 500,前端显示「失败,请重试」。流式下你已经回了 200、还吐了三百个字,这时上游断了——⭐ 状态码在第一帧发出去的那一刻就定死了,改不了。
| 问题 | 处理 |
|---|---|
| 状态码用不了了 | ⭐ 错误必须走流内错误帧(data: {"type":"error","message":"…"}),前端要接住「200 + 半截内容 + error 帧」 |
| 生成器直接抛异常 | ⚠️ 连接当场断开,前端只看到「流没了」——和网络抖动、和正常结束都分不出来。必须自己 try/except 转成错误帧 |
| 怎么区分「正常结束」 | ⭐ 结尾必须有明确的结束帧(data: {"type":"done"}),否则前端分不出「说完了」和「断了」 |
| 半截内容怎么办 | 追加「以上回答未完成」或整块置灰。最糟的是什么都不做——用户会把半句话当成完整答案 |
⭐⭐ 由此推出一条设计铁律:所有可能失败的事,必须全部挤在第一个字之前做完。 鉴权、取历史、检索、输入校验——一旦开始吐字,你就失去了「假装什么都没发生」的权利(这也是 04 章那条「首字之后不能静默重试」的来源)。
# 🗓️ 未实跑 —— 需要 FastAPI 和真实模型。要看的是【三件事的位置】。
import json, logging, uuid
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
log, app = logging.getLogger("api"), FastAPI()
@app.post("/api/chat")
async def chat(req: Request, prompt: str = "hi"):
# ⭐ 鉴权、取历史、检索、输入校验全在这里做完 —— 此刻还能好好地返回 4xx/5xx
tid = uuid.uuid4().hex[:12] # ⭐ 就是 03 章那个 trace_id
async def gen():
try:
async for piece in call_model(prompt): # 你的调用层(04 章)
if await req.is_disconnected(): # ⭐ 客户端走了就别再烧钱
break
yield "data: %s\n\n" % json.dumps(
{"type": "delta", "text": piece}, ensure_ascii=False)
except Exception: # ⭐ 状态码早是 200 了,错误只能顺流走
log.exception("stream failed trace=%s", tid) # ⚠️ 细节只进日志,不进帧
yield "data: %s\n\n" % json.dumps( # ⚠️ message 是给人看的一句话
{"type": "error", "message": "上游调用失败,请重试",
"trace_id": tid}, ensure_ascii=False) # ⭐ 用户能报给你的那个号
yield 'data: {"type":"done"}\n\n' # ⭐ 无论如何都要有结束帧
return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
⚠️ message 里别塞异常原文——它会直接显示在用户屏幕上,可能带出上游地址、栈帧甚至 key 片段。给用户一句人话,把真正的异常记进日志(15 章),两边靠 request_id 对上。
🔌 六、断连:用户关了页面,你还在烧钱
浏览器标签一关,TCP 连接就断了。⚠️ 但你的后端不一定知道——「上游那条流」和「发给用户的那条流」是两个独立连接,上游会一直生成到说完为止,每一段都在计费,而这些字没有任何人看到。两件事必须做:① 感知——每写一帧顺手检查一次连接还在不在(上面那行 is_disconnected());② 收尾——⭐ 在 finally 里关掉上游那条流,因为正常结束、异常、被取消三条路径都会经过它。
# cancel.py —— 客户端断开后,上游那条流会不会跟着停?(可直接跑,对照看得见)
import asyncio
class Upstream:
"""假的上游模型流:打开后就在后台自己生成,只有 close() 能让它停。
真实 SDK 就是这个形状 —— 你这边不读了,对面并不知道。"""
def __init__(self):
self.emitted, self._task = 0, None
def open(self):
async def produce():
while True:
await asyncio.sleep(0.05)
self.emitted += 1 # ⭐ 每 +1 都是真金白银的 output token
self._task = asyncio.create_task(produce())
return self
def close(self):
self._task.cancel()
async def next_piece(self): # 后端每次从上游取一段往下发
await asyncio.sleep(0.05)
return "块%d" % self.emitted
async def bad(up):
"""❌ 没有 finally:任务被取消了,上游还在后台继续生成、继续计费。"""
while True:
await up.next_piece()
async def good(up):
"""✅ finally 里关上游:取消、异常、正常结束三条路都会走到。"""
try:
while True:
await up.next_piece()
finally:
up.close() # ⭐ 这一行就是「别再烧钱了」
async def main():
for name, handler in (("❌ 没有 finally", bad), ("✅ finally 里 close", good)):
up = Upstream().open()
task = asyncio.create_task(handler(up))
await asyncio.sleep(0.3)
task.cancel() # ⭐ 模拟:用户关了页面,连接断开
try:
await task
except asyncio.CancelledError:
pass
n0 = up.emitted
await asyncio.sleep(0.5) # 断开后再等半秒,看它还在不在生成
print("%-18s 断开时已产出 %d 段,半秒后 %d 段" % (name, n0, up.emitted))
up.close()
asyncio.run(main())
实跑结果(本机连跑三次,三次一致):没有 finally 的那版,断开时 5 段、半秒后变成 13 段——⭐ 多出来的 8 段全是白烧的钱,没有任何人看到;有 finally 的那版停在 5 段。⚠️ 具体数字取决于你机器的调度(每段 0.05 秒,半秒的理论上限是 10 段),要看的是方向不是数字:一边继续涨,一边不动。
⚠️ 还要把「谁断的」分清楚并分开记:客户端断开是用户行为,要立刻停止一切工作,它多不多是产品问题;上游断开是故障,要重试或降级。两者混在一个错误计数里,你会以为服务在抖,其实只是大家看完就关。
🔄 七、换个栈怎么对应
| 概念 | Python / FastAPI | Node(Express / Hono) | Go |
|---|---|---|---|
| 分批往外写 | StreamingResponse(生成器) |
多次 res.write() / 返回 ReadableStream |
w.Write() + ⭐ Flusher.Flush() |
| ⚠️ 谁在缓冲 | 框架不缓冲,代理会 | 同左;另外 compression 中间件要按 MIME 排除 |
⭐ ResponseWriter 自己就带缓冲,不 Flush 就攒着 |
| 感知客户端断开 | await request.is_disconnected() |
req.on('close', ...) / AbortSignal |
ctx.Done()(r.Context()) |
| 收尾必做 | finally 里关上游 |
finally / close 事件里关上游 |
defer 里关上游 |
⭐ 这张表真正想说的:「分批往外写」在每个栈里都要显式表达,⚠️ Go 甚至默认就在缓冲。 FastAPI 只是把它藏进了
StreamingResponse这个名字里——不是它替你解决了这个问题。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 10 · 把流式接到界面上 | ⭐ 另一半:本章把帧写进 HTTP,那一章讲浏览器怎么读回来、怎么拼、怎么显示错误帧 |
| 04 · 调用层 | ⭐ 流式把调用层的三件事都改了:读取超时变成「两帧间隔」、首字后不能静默重试、流式响应不能缓存 |
| 12 · 限流、配额与成本护栏 | 断连后白烧的那部分 token 也要计费、也要进配额,否则你的成本账是缺口的 |
| 14 · 容器化与部署 | 第四节三个凶手里有两个(反向代理、平台缓冲)住在那一章 |
| 智能体工程 16c · 接进真实产品 | ⭐ 分工:那边讲「为什么要流式、护栏与流式怎么配合、结构化输出和流式的冲突」,本章讲这条链路的工程细节和坑 |
✅ 检查点
- SSE 和「裸分块传输」的区别只有一点,是哪一点?换来了什么?
- 为什么 LLM 场景通常不需要 WebSocket?上它要额外承担哪四件事?
- 为什么正文内容要用 JSON 包一层再放进
data:?本板块为什么把帧类型放进 JSON 负载,而不用 SSE 的event:行?结束帧为什么不写[DONE]? - 吃掉流式的三个地方分别是什么?它们的共同点是什么?
- 定位缓冲问题的判据为什么是「帧间隔」而不是「总时长」?具体怎么一层层测?
- 流式下为什么不能用状态码报错?错误该怎么传、字段叫什么?没有结束帧会怎样?
- 「所有可能失败的事必须挤在第一个字之前」——为什么?
- 上游流不在
finally里关会怎样?本章那段对照跑出了什么数字?「客户端断开」和「上游断开」又为什么必须分开记?
👀 答案
- SSE 就是分块传输,只是多规定了帧怎么分(每帧以空行结束)。换来的是「以后要加错误、用量、工具状态时不用重写前端」。
- 因为 LLM 输出是单向的,上行只有「用户又发了一条消息」,一个普通 POST 就够。上 WebSocket 等于把心跳、重连、鉴权、代理兼容四件事全揽到自己身上。
- 模型输出里有换行是家常便饭,而 SSE 里换行会把一帧劈成两帧。
json.dumps把换行转义成两个字符,帧就安全了。⭐ 类型放进负载是跟着消费方走:前端用fetch手动解析(EventSource带不了Authorization),而event:行只对EventSource有意义,手动解析器只能多写一套状态机或干脆丢掉它;放进负载则一行JSON.parse拿到全部信息,解析路径只有一条。⚠️ 同理[DONE]不是合法 JSON,会让统一的JSON.parse抛SyntaxError,所以结束帧写成{"type":"done"}。 - 反向代理缓冲(
proxy_buffering on)、压缩中间件(攒够一个压缩块才输出)、云平台/网关/CDN 的响应缓冲。共同点:不报错、本地复现不了、三者症状完全一样。 - 因为被缓冲时总时长完全不变,变的是「所有帧在最后一起到」。测法:部署一个每秒吐一帧的探针,从里往外测——直连应用进程 → 容器端口 → 反向代理 → 公网域名,第一处帧间隔塌成 0 的那层就是凶手。
- ⭐ 状态码在第一帧发出去时就定死成 200 了。错误要走流内错误帧——
data: {"type":"error","message":"…","trace_id":"…"},⚠️ 字段名是message不是code(前端拿它是要显示给人看的),⭐⭐ 而且必须带trace_id:非流式出错时它在那个 500 的 JSON 里,流式的状态码早已是 200、那个 JSON 根本不会发出来,漏了用户就只能告诉你「回答到一半崩了」。前端要接住「200 + 半截内容 + error 帧」。没有结束帧({"type":"done"}),前端分不出「说完了」和「断了」。 - 因为一旦开始吐字,你就失去了「假装什么都没发生」的权利——不能改状态码,也不能静默重试。
- 上游会继续生成、继续计费,而这些字没有任何人看到。对照结果(本机跑三次一致):断开时都是 5 段,半秒后没有
finally的那版变成 13 段(多烧 8 段),有finally的停在 5 段;⚠️ 具体数字随机器调度浮动,要看的是「一边继续涨、一边不动」。分开记是因为:客户端断开是用户行为(要立刻停工,多不多是产品问题),上游断开是故障(要重试或降级);混在一个计数里,你会以为服务在抖,其实只是大家看完就关。
🛑 可以停在这里
⚡ 走神救援
本章只管后端 → HTTP 这一半(浏览器那半在 10 章)。通道选型:⭐ SSE 就是分块传输,只多规定了「帧怎么分」(空行结束一帧),这点约定换来「以后加错误/用量/工具状态不用重写前端」;WebSocket 不是「更高级的 SSE」,它多的是上行,而 LLM 的上行一个普通 POST 就够——为它上协议等于把心跳、重连、鉴权、代理兼容四件事全揽下来。报文三条规矩:
data:是内容、空行结束一帧、冒号开头是注释帧(正好做心跳)。⭐⭐ 本板块统一帧格式:每帧一行data: {JSON},帧间空一行,帧类型放在负载的type字段里(delta/usage/error/done)——⚠️ 不用 SSE 的event:行,因为前端是fetch手动解析(EventSource带不了Authorization),而event:行只对EventSource有意义,手动解析器只能多写状态机或整行丢掉;放进负载则一行JSON.parse拿到全部信息,解析路径只有一条。⚠️ 同理结束帧写{"type":"done"}不写[DONE]——后者不是合法 JSON,会让统一的JSON.parse抛SyntaxError;错误帧字段名统一用message不用code,⭐⭐ 且必须多带一个trace_id(第 3 章的原则,⚠️ 流式最容易漏:非流式时它在那个 500 的 JSON 里,而流式状态码早是 200、那个 JSON 压根不发;漏了用户就只能说「回答到一半崩了」,你查无可查)。⚠️ 两个必踩的坑都在换行上:内容里的换行会把一帧劈成两帧(所以正文永远用 JSON 包一层),少一个\n帧就永远不完整,症状和被缓冲一模一样。⚠️⚠️ 最贵的一节:吃掉流式的三个地方——反向代理(Nginx 默认proxy_buffering on,关掉它或回X-Accel-Buffering: no)、压缩中间件(攒够一个压缩块才输出,对text/event-stream关压缩)、云平台/网关/CDN 的响应缓冲(Serverless 尤其)。难查的原因是共同的:不报错、本地永远复现不了、三者症状完全一样——都是「憋很久然后一次性全出现」,而你的代码一个字都没错。⭐ 定位法:部署一个每秒吐一帧的探针端点,从里往外逐层测(应用进程 → 容器 → 反向代理 → 公网域名),判据是帧间隔不是总时长,第一处间隔塌成 0 的那层就是凶手;没探针就用curl -N,⚠️ 别信浏览器开发者工具的时序。空闲连接会被中间设备掐,解法是每隔十几秒发一个: ping注释帧。错误处理:⭐ 状态码在第一帧发出的那一刻就定死成 200 了,错误只能走流内错误帧(data: {"type":"error","message":"…","trace_id":"…"});生成器直接抛异常会让连接当场断开,和网络抖动、和正常结束都分不出来,所以结尾必须有明确的结束帧{"type":"done"}。⭐⭐ 由此推出铁律:所有可能失败的事必须挤在第一个字之前做完。断连:用户关页面后上游会继续生成继续计费——本章可跑对照的结果是断开时 5 段、半秒后没有finally的那版变成 13 段(⚠️ 数字随机器调度浮动,看的是「一边继续涨、一边不动」),多出的 8 段全是白烧的钱;解法是每帧检查一次is_disconnected(),并在finally里关上游。⚠️ 最后,客户端断开和上游断开要分开记,否则你会把「大家看完就关」当成「服务在抖」。
下一节 👉 06-关系数据库.md