🏠 总目录📚 本教程 05 · 流式输出
📑 本页目录(点开跳转)

05 · 流式输出

64 分钟 | ⭐ 只管后端到 HTTP 这一半 | ⚠️ 本章一半篇幅在讲「代码没错但流式还是坏了」


🎯 一句话

让字一个一个出来,难的不是「怎么吐」,而是「一路上有三个地方会悄悄把它攒起来」。 本章管后端 → HTTP 这一段:怎么吐、怎么在流里报错、怎么在用户关掉页面时停止烧钱。浏览器那一半在第 10 章。


🌊 一、为什么必须流式(一节就够)

一次长回答生成几十秒是常态。非流式下用户从点发送到看见任何东西,等于整段生成时长;流式下这个时间被压到接近一次网络往返。⭐ 总时长一模一样,差别只在用户有没有在等待期间获得反馈——很多人等 5 秒就会重刷,而重刷会再烧一次钱

⭐ 「为什么要流式」「护栏怎么和流式配合」在 智能体工程 16c 讲过,那边是产品视角。 这一章接着往下讲工程链路:报文怎么编、被谁吃掉了、错了怎么办、断了怎么办。


🔀 二、三种通道,为什么 LLM 场景通常选 SSE

通道 方向 代价 什么时候选它
SSEtext/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 根本不会发出来。漏了的后果很具体:用户说「刚才回答到一半崩了」,你手上什么都没有 —— 而这恰恰是本板块最常见的一种失败。

⚠️ 注意 messagetrace_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 · 接进真实产品 ⭐ 分工:那边讲「为什么要流式、护栏与流式怎么配合、结构化输出和流式的冲突」,本章讲这条链路的工程细节和坑

✅ 检查点

  1. SSE 和「裸分块传输」的区别只有一点,是哪一点?换来了什么?
  2. 为什么 LLM 场景通常不需要 WebSocket?上它要额外承担哪四件事?
  3. 为什么正文内容要用 JSON 包一层再放进 data:?本板块为什么把帧类型放进 JSON 负载,而不用 SSE 的 event: 行?结束帧为什么不写 [DONE]
  4. 吃掉流式的三个地方分别是什么?它们的共同点是什么?
  5. 定位缓冲问题的判据为什么是「帧间隔」而不是「总时长」?具体怎么一层层测?
  6. 流式下为什么不能用状态码报错?错误该怎么传、字段叫什么?没有结束帧会怎样?
  7. 「所有可能失败的事必须挤在第一个字之前」——为什么?
  8. 上游流不在 finally 里关会怎样?本章那段对照跑出了什么数字?「客户端断开」和「上游断开」又为什么必须分开记?
👀 答案
  1. SSE 就是分块传输,只是多规定了帧怎么分(每帧以空行结束)。换来的是「以后要加错误、用量、工具状态时不用重写前端」。
  2. 因为 LLM 输出是单向的,上行只有「用户又发了一条消息」,一个普通 POST 就够。上 WebSocket 等于把心跳、重连、鉴权、代理兼容四件事全揽到自己身上。
  3. 模型输出里有换行是家常便饭,而 SSE 里换行会把一帧劈成两帧json.dumps 把换行转义成两个字符,帧就安全了。⭐ 类型放进负载是跟着消费方走:前端用 fetch 手动解析(EventSource 带不了 Authorization),而 event: 行只对 EventSource 有意义,手动解析器只能多写一套状态机或干脆丢掉它;放进负载则一行 JSON.parse 拿到全部信息,解析路径只有一条。⚠️ 同理 [DONE] 不是合法 JSON,会让统一的 JSON.parseSyntaxError,所以结束帧写成 {"type":"done"}
  4. 反向代理缓冲proxy_buffering on)、压缩中间件(攒够一个压缩块才输出)、云平台/网关/CDN 的响应缓冲。共同点:不报错、本地复现不了、三者症状完全一样
  5. 因为被缓冲时总时长完全不变,变的是「所有帧在最后一起到」。测法:部署一个每秒吐一帧的探针,从里往外测——直连应用进程 → 容器端口 → 反向代理 → 公网域名,第一处帧间隔塌成 0 的那层就是凶手
  6. 状态码在第一帧发出去时就定死成 200 了。错误要走流内错误帧——data: {"type":"error","message":"…","trace_id":"…"},⚠️ 字段名是 message 不是 code(前端拿它是要显示给人看的),⭐⭐ 而且必须带 trace_id:非流式出错时它在那个 500 的 JSON 里,流式的状态码早已是 200、那个 JSON 根本不会发出来,漏了用户就只能告诉你「回答到一半崩了」。前端要接住「200 + 半截内容 + error 帧」。没有结束帧({"type":"done"}),前端分不出「说完了」和「断了」
  7. 因为一旦开始吐字,你就失去了「假装什么都没发生」的权利——不能改状态码,也不能静默重试。
  8. 上游会继续生成、继续计费,而这些字没有任何人看到。对照结果(本机跑三次一致):断开时都是 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.parseSyntaxError;错误帧字段名统一用 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

打卡记录保存在你的浏览器里,首页能看到总进度