版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 A 中标注来源;引用官方原文保持原样,不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准,标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式,也不对任何收益结果作承诺。转载请注明出处。

全栈工程师转到大模型,第一件想做的事通常不是选向量库,是画接口。前后端都写过的人有这么一个条件反射:契约定死,实现随便换。这个反射在做 RAG 应用时是对的,只是大多数人把它用反了——先定检索、后定流式,于是检索每调一次参数,前端就要跟着改一次渲染逻辑。一个人做项目,返工的代价全落在自己身上。

读完你能判断三件事:手上这套流式接口缺了哪个字段;模型给你的流式代码错在哪一行;上线后被反向代理掐断,该改代码还是改配置。

一、一个人做 RAG,接口要抢在检索前面

1.1 五个术语,各自钉住一样东西

先把两个词说成人话。RAG(检索增强生成):先从你自己的文档里查出几段,再把这几段塞进提示词让模型照着答。SSE(Server-Sent Events,服务器推送事件):一条 HTTP 长连接,服务器按行往里写纯文本,客户端逐行解析成事件,单向,只能服务端推给客户端。

全栈岗原本的工程结构里有五个概念,转过来一个都不能丢,因为每一个都对应一类返工:

原岗位的对应物流式里的名字它钉住什么定错了谁返工
接口字段定义(DTO / schema)SSE 事件字段 event / id / data / retry客户端能解析出哪些东西前后端一起返工
分页游标 / 断点续传位点Last-Event-ID断在哪、从哪续服务端取数逻辑重写
登录态传递鉴权(Cookie 或 Authorization 头)这次请求是谁发的网关与前端一起改
接口幂等约定重连重放不重复渲染同一条消息渲染几次前端渲染层重写
网关 / CDN(内容分发网络)配置反向代理(挡在源站前面的转发层)的缓冲与空闲超时字节什么时候到你手上只改配置,代码不动

第二列的五个词里,只有最后一个不在代码里。这也是它最容易被漏掉的原因:本地 127.0.0.1 直连跑得好好的,一挂域名就断,代码一行没错。

1.2 顺序反了,返工是乘法不是加法

先定检索、后定接口,具体会烂在哪?举一个我自己踩的:检索侧有三个可调项——top_k(取前几条文档)、rerank 开关(对候选重排一次)、上下文条数。这三项动任何一个,同一次回答被切成的 SSE 事件条数就变了。

假设第一版 top_k=3,一次回答正好 12 条事件。前端图省事,按「固定 12 条」写了进度条和「还剩几条」的提示。后来 top_k 调到 8,事件变成 20 条,进度条走到 60% 就不动了,用户以为卡死。你没改前端一行代码,前端却坏了。

反过来做:接口里先写死「事件条数不固定、每条带 id、最后一条是 event: done」。这样检索怎么调,前端都不用动。接口定的是「客户端能依赖什么」,不是「这次返回什么」。 前者稳定,后者每次都变。

顺带一个收益:接口先定,检索没做完时就能用假数据把前端调通,一个人也能并行。

1.3 一条判据:客户端只依赖五条约定

开工前能不能写出一页纸,上面只有这五行:客户端只读 id、event、data 三个字段,只在重连时回传 Last-Event-ID,只在 event: done 时收尾。写完这五行,接口就算定住了;写不出来,说明你还在用「这次返回什么」的思维设计接口,检索一改就得返工。

这五行里最容易被删掉的是 id。理由通常是「就一个用户,断了重发一遍不就行了」。第二章会用数字说明,这个「重发一遍」到底意味着什么。

二、本机搭一套最小 SSE:12 条消息、断在第 6 条

2.1 服务端:手写分块,再用 RST 把它掐断

要测断线重连,就得真的断,不能在客户端 if 一下假装断。本机这套用 http.server 起服务,手动写 HTTP/1.1 的 chunked(分块传输:把响应切成若干块,每块前面用十六进制写块长,客户端不必等整包到齐就能开始解析),然后在第 6 条消息的正文中间,用 SO_LINGER=0(套接字选项:关闭时立刻发 RST)发 RST(复位包:直接掀桌关闭连接)强行断开。下面两段是节选,完整版见附表 A 第 11 条。

    def _chunk(self, data: bytes) -> bool:
        try:
            self.wfile.write(b"%X\r\n" % len(data) + data + b"\r\n")
            self.wfile.flush()
            return True
        except Exception:
            return False

    def _idle(self, total: float, hb: float) -> bool:
        """等 total 秒;hb>0 时每 hb 秒写一条注释行保活。"""
        if hb <= 0:
            time.sleep(total)
            return True
        spent = 0.0
        while spent + hb < total:
            time.sleep(hb)
            spent += hb
            if not self._chunk(b": hb\n\n"):
                return False
        if total - spent > 0:
            time.sleep(total - spent)
        return True

_idle 就是心跳的实现:hb 是心跳间隔,每等 hb 秒往连接里写一行 : hb。冒号开头的行在 SSE 里是注释行,客户端收到要整行忽略,但它照样占一个网络包——这一点是第四章部署拓扑的关键。

断点切在 data: 这一行的第 8 个字节上:6 字节是字段前缀,正文只进了 2 字节,正落在第一个汉字中间。客户端解不出半个字,这段残片会被丢掉。真实网络断开从来不会正好断在消息边界。

2.2 客户端:三个缓冲区,自己按行解析

客户端不用浏览器的 EventSource(浏览器自带的 SSE 客户端),改用 http.client 加 read1() 逐块读,自己按规范解析。规范要求每个流关联三个缓冲区:data、event type、last event ID,初始都是空字符串。落到代码里是这样:

            buf += chunk
            while b"\n" in buf:
                line, buf = buf.split(b"\n", 1)
                line = line.rstrip(b"\r").decode("utf-8", "replace")
                if line == "":
                    if data_lines:
                        events.append((cur_id, "\n".join(data_lines)))
                        if cur_id is not None:
                            self.last_id = cur_id
                    data_lines = []
                    cur_id = None
                elif line.startswith(":"):
                    self.hb_lines += 1
                else:
                    field, _, value = line.partition(":")
                    if value.startswith(" "):
                        value = value[1:]
                    if field == "id":
                        cur_id = value
                        if self.early_cursor:
                            self.last_id = value   # ← 这就是「提前推进游标」的错法
                    elif field == "data":
                        data_lines.append(value)

注意 self.last_id = cur_id 的位置:它在空行派发之后,不在 id: 行解析时。early_cursor 那个开关是为了做对照故意加的错法,第三章会给它算账。

数据是 12 条中文消息,共 307 个字符。客户端把收到的每条 data 拼起来,与期望全文逐字比对,就能算出两个数:重复渲染条数(渲染调用次数减唯一消息数)和丢失字符数(307 减实际拼出的字数)。

2.3 五组对照跑出来的数字

五组跑在同一份脚本、同一台机器上。A 到 C 只改协议形态;D 和 E 另把间隔改成 0.60 秒,并加了一个空闲 0.30 秒即断的中继。表里的数字与日志逐位一致:

组形态差异连接次数渲染调用重复条数丢失字数
A裸 data: 行,无 id21750
B带 id: 与 Last-Event-ID21200
C带 id: 但游标提前推进211028
D带 id:,无心跳,过 0.30 秒空闲代理1——送达 1 条
E带 id:,0.10 秒一次心跳,同一代理1——送达 12 条

A 组第一眼像是「没丢东西」,因为 307 个字最后都齐了——但前 5 条被渲染了两遍。日志原样摘录:

── A 裸 data 行(无 id),断线后重连 ──
  第 1 次连接:收到完整事件 5 条,连接被强断=True,残留未成行的字节 8,游标 last_id=None
  重连请求头 Last-Event-ID: (不发送)
  第 2 次连接:收到完整事件 12 条,连接被强断=False
  渲染事件合计 17 条,去重后 12 条,重复渲染 5 条
  期望正文字符 307,实际拼出 307,丢失字符 0

「残留未成行的字节 8」就是那半条消息。它永远等不到换行符,也永远等不到那个空行,所以不会被派发。顺带划清边界:本号 11-01《流式输出断在半句》算的是重试成本,这篇算的是字段与游标。

三、四个字段各管一段:event / id / data / retry

3.1 少一个字段,客户端就少一条判断

规范把事件流当纯文本按行解析:第一个冒号前是字段名,后面是值,值开头的那个空格要去掉。四个字段各对应客户端的一条分支:

字段规范里的动作缺了会怎样本文哪一组踩到
data值追加进 data 缓冲区,再补一个换行没数据可派发,等于空响应全组都有
id值不含空字符就写进 last event ID 缓冲区断线后无从续传,只能从头重放A 组
event写进 event type 缓冲区,派发时改事件类型所有消息都变成默认 message,无法区分增量与收尾未单列对照
retry值是纯 ASCII 数字时,改掉重连等待时间客户端按实现默认值重连(规范只说「几秒量级」)未单列对照

retry 这一栏值得单独说:规范没规定默认重连间隔是多少,写的是「一个由实现决定的值,大概在几秒上下」。这句话的意思是,你不写 retry,重连节奏就不在你手里。不同浏览器、不同版本给的值可能不同,你没法用它做退避(失败后逐次拉长重试间隔)。

3.2 Last-Event-ID:规范把「续传」写成了两句话

第一句在解析侧:id 字段的值只要不含空字符,就写进 last event ID 缓冲区。第二句在派发侧,原文是:Set the last event ID string of the event source to the value of the last event ID buffer. 两句话合起来的意思是:客户端记住的是「最后一个完整派发出去的事件」的 id,不是「最后一个看到 id 行的事件」。

重连时,只有这个字符串非空,客户端才会把它塞进 Last-Event-ID 请求头。A 组的日志里写着「重连请求头 Last-Event-ID: (不发送)」,因为裸 data: 从来没有过 id,客户端的 last event ID 字符串一直是空的,它连「我断在哪」都表达不出来。

服务端拿到这个头之后要做的判断,规范没管——它只说这是个 UTF-8 字符串。所以「从下一条继续」这个语义,是你自己实现的。这就是接口契约要写清楚的地方:id 是消息序号,还是数据库里的游标,还是时间戳,必须在文档里写死,否则客户端传上来一个你没预期的格式,服务端只能从头重放。

3.3 游标推进的位置,决定你丢 0 字还是 28 字

B 组和 C 组的唯一差别,是 self.last_id 什么时候赋值。B 组在空行派发后赋,C 组在见到 id: 行就赋。结果:

  • B 组:第一次连接拿到 5 条,游标是 5;重连带 Last-Event-ID: 5,服务端从第 6 条开始补,第二次连接收到 7 条,合计 12 条,重复 0 条,丢失 0 字。
  • C 组:第一次连接同样只拿到 5 条,但游标已经推到 6(因为 id: 6 那行到了,data 只到一半);重连带 Last-Event-ID: 6,服务端从第 7 条补——第 6 条整条没了,丢失 28 字,拼出来只有 279 字。

28 除以 307 是 9.12%。这个错误最难受的地方在于它不报错:连接正常、状态码 200、事件一条条到,只是答案里少了一整句。前端看不出来,用户只会觉得「答得不太对」。

判据:把 last_id 的赋值挪进派发分支,跑一次断线测试看丢失字符数。如果你现在的代码里,last_id 出现在 if field == "id" 的分支里,那它就是 C 组。

四、鉴权、幂等、部署拓扑:三件不在渲染层的事

4.1 鉴权:原生 EventSource 只有 withCredentials 一个开关

这条会让很多人翻车。规范里 EventSource 的初始化字典只有一个成员:withCredentials,布尔值,默认 false。也就是说,浏览器原生的 EventSource 没有给你设置自定义请求头的能力,塞不了 Authorization: Bearer ...。

于是只剩三条路,代价完全不同:

放法怎么做代价什么时候能用
Cookie同源部署不必设;跨子域时把 withCredentials 置 true 并配 CORS(跨域资源共享)凭据还要单独防 CSRF(跨站请求伪造:别的站点借你的登录态发请求)同域或可控子域,首选
Authorization 头用 fetch 流式读,不用原生 EventSource要自己实现解析、重连、Last-Event-ID需要细粒度 token 时
URL query?token=...token 进访问日志、进 Referer、进浏览器历史不建议,除非是短时效一次性票据

第三条为什么烂,不用讲安全大道理,讲三个具体的:反向代理默认会把完整 URL 写进访问日志,日志会被集中采集;同源子资源与站内跳转会带完整 URL(含 query),跨域请求默认只带 origin;浏览器历史同步会把 URL 同步到其他设备。一个 token 从这三个口子出去,你都不知道从哪个漏的。

模型写流式代码时最常见的动作,就是给你拼一个 new EventSource("/api/chat?token=" + token)。它没错到跑不起来,它错在把凭证放到了一个会被记录的位置。

4.2 幂等:重放一次不重复渲染,靠的是 id 不是决心

幂等(重复做也不多出效果)这个词在 HTTP 里有明确定义。RFC 9110 的原文是:A request method is considered “idempotent” if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request. 同一节还说,通信在客户端读到响应之前就失败时,幂等方法的请求可以被自动重复。

把这句话搬到流式上:断线重连本质就是一次「自动重复的请求」。如果没有 id,服务端只能把整段回答重发一遍——重发会让服务端再生成一遍,效果与一次不同;客户端多渲染几条只是后果,不是判据。A 组多出来的几次渲染,就是重放的代价。

所以幂等不是靠前端「渲染时判断一下有没有重复」的自觉,是靠协议:每条消息有唯一 id,客户端按 id 去重,服务端按 id 续传。 两个动作缺一个都不行。只有客户端去重、服务端仍从头重发,你只是把重复藏起来了——流量还是发了两遍,长回答下就是两倍带宽。

4.3 部署拓扑:0.30 秒空闲就能掐断一条长连接

部署拓扑指请求从浏览器到源站穿过哪几层。D 组和 E 组的差别只有心跳。我在服务端和客户端之间放了一个 TCP 中继,模拟反向代理:只要 0.30 秒内没有字节流过,就把两侧连接一起掐断。消息间隔设为 0.60 秒——比阈值长,所以不带心跳时,第二条消息还没发出去,连接就没了。

日志里 D 组的结果是:代理主动掐断 1 次,送达 1 条,0.3 秒左右连接就没了;E 组加了每 0.10 秒一行心跳之后,掐断 0 次,送达 12 条,注释行 60 条,全程 7.26 秒。两个耗时都是本文成文时的一次运行,重跑会漂移。一条流第 1 条就死,一条走完全程。

这里有个反常识的细节:心跳不是「让连接不断」,是「让中间设备认为这条连接还在干活」。SSE 的心跳就是一行注释,内容客户端根本不看,它的唯一作用是产生字节。

第二层是浏览器自己的限制。MDN 的 EventSource 页面写得很直接:不用 HTTP/2 时,SSE 受最大连接数限制,这个限制是按浏览器和域名算的,值是 6,Chrome 和 Firefox 都把这个问题标成「不修」;用 HTTP/2 时,并发流数量由服务端与客户端协商,默认 100。

第三层是 CDN 或中间缓存的攒包行为,本文没有实测,只给判定方法:如果总耗时 3 秒、而全部内容在最后 0.5 秒内一次性到齐,就是被攒包了,不是模型慢。

五、模型在这套结构上具体错在哪

5.1 错法一:把 token 拼进 URL

模型给的代码长这样:new EventSource("/api/chat?q=" + q + "&token=" + token)。本地测试一路绿灯。

可观察现象:开发者工具的 Network 面板里,这条请求的 URL 带完整 token;服务端访问日志里也留着。

判定方法:在请求列表里点开这条 SSE 请求,看 Headers 里的 Request URL 有没有 token= 或 key=。有就是这条错。改法只有两个——同源走 Cookie,或者改用 fetch 流式读、把 token 放进 Authorization 头。

5.2 错法二:重连时从头重放

模型写的重连逻辑通常是「断了就重新请求一次」,请求参数一模一样,不带 Last-Event-ID。服务端也没有按 id 续传的能力,于是每次都从头生成。

可观察现象:网络抖动一次,回答的前半段出现两遍。A 组把这个现象量化了——重复渲染 5 条,占 12 条的 41.67%。

判定方法:在浏览器里把网络切成离线两秒再切回来,看页面上有没有出现重复段落;或者看服务端日志里同一个会话是否出现了两次「从第 1 条开始」的记录。改法是两条一起改:服务端认 Last-Event-ID,客户端在派发后推进游标。

5.3 错法三:把 SSE 当成 chunked 的 JSON 流

这是最隐蔽的一种。模型知道响应是分块的,就写了一个「每读到一块就 JSON.parse 一次」的解析器。问题在于 SSE 的分块边界和消息边界毫无关系:一个块可能包含一条半消息,一条消息也可能横跨三个块。

可观察现象:大部分时候正常,偶尔最后半句话没了,或者控制台里出现解析失败的报错(本文不复述英文报错原文)。原因就是最后那个不完整的块被丢掉了。

判定方法:在解析器里打点,记录每次解析的输入长度。如果输入长度在 20 到 60 字节之间跳动、而且和消息条数对不上,那就是按块解析了。正确做法是按行解析、按空行派发——规范里那三句话(攒 data、空行派发、流结束时丢弃未完成事件)就是全部的解析规则。

错法可观察现象一行判定改动落点
token 进 URLRequest URL 带 token=看请求 URL鉴权方式
重连从头重放前半段出现两遍断网两秒再恢复服务端 + 游标
按块解析 JSON最后半句丢失、偶发解析失败记录每次解析的输入长度解析器改成按行

这份流式接口契约清单:把本文的五个术语、三种错法的判定方法,以及一份能直接填的接口字段表整理成了一页纸。放在资料包里,扫码即可获取:

配套资料获取

六、反直觉:流式不是为了首字更快

6.1 大家以为在优化首字,实际在放大错误处理的次数

大家以为:流式的价值是首字延迟低,用户不用等整段生成完就能看到字,体验更好。所以做流式的目标是「把第一个字节尽快吐出来」。

实际是:流式把一个「一次成功或一次失败」的请求,拆成了 N 段「每一段都可能成功或失败」的拼接。 首字延迟只描述第 1 段,而用户真正读到的完整答案在第 N 段。你优化的指标只覆盖了最不容易出错的那一段。

这个转换的代价可以数出来。一次 12 条事件的回答,非流式下是 1 次请求、1 次成败判断;流式下是 12 次派发,任何一次断线都要重新判断「我渲染到哪了」。A 组的实测是:断 1 次,连接 2 次,渲染调用 17 次——一次成败判断变成了 17 次拼接,其中 5 次是纯粹的重复劳动。

6.2 把「N 次拼接」算成数字

同一次回答、同一个断点,三种实现只在两个判断上分出高下:

实现重复条数为 0丢失字数为 0
A 裸 data否(5 条)是
B 有 id是是
C 提前游标是否(28 字)

两列一起看才有意义:「不重复」和「不丢字」是两个独立的指标,只优化一个会把另一个搞坏。 三行里只有 B 组两个都成立,而 A 和 C 恰好是两种最常见的实现——一种根本不记 id,一种记了 id 但记早了。

这两个数不用肉眼数,脚本里一个函数就够:

def measure(events):
    datas = [d for _, d in events]
    seen = {}
    for d in datas:
        seen.setdefault(d, 0)
        seen[d] += 1
    unique = list(seen.keys())
    dup = len(datas) - len(unique)
    expected = "".join(MSGS)
    got = "".join(unique)
    return {
        "event_total": len(datas),
        "unique": len(unique),
        "dup": dup,
        "got_len": len(got),
        "lost": len(expected) - len(got),
    }

它的假设是每条消息正文不重复;如果回答里真会出现两段一模一样的话,就得改成按 id 去重。

再加一层:D 组在没有心跳的情况下,0.30 秒空闲就被掐断,12 条只送到 1 条,送达比例 8.33%。按每组连接只送到 1 条推算,要救回来得重连 11 次,每一次都要重新走一遍游标判断。也就是说,一次回答里,「判断我在哪」这个动作的执行次数,等于 1 加断线次数。断线越频繁,这个判断越容易写错,而它写错的后果不是报错,是静默少一句。

6.3 所以 TTFT 这个指标会骗你

TTFT(首字延迟)测的是第一个字节到达的时间。在上面这套结构里,它只覆盖 12 段里的第 1 段。一个把 TTFT 从 800 毫秒优化到 300 毫秒的实现,如果重连时从头重放,用户在长回答里看到的仍然是重复段落;如果游标提前推进,用户看到的仍然是少一句。

替代指标有两个,都能自己算:拼合完整度(最终拼出的字数除以期望字数,B 组是 307 除以 307)和重复条数(渲染调用减唯一消息数,A 组是 5)。这两个数跑一遍断线测试就有。

七、边界与决策表

7.1 这套做法在什么情况下不成立

下面四类场景不适用。

响应短于 1 秒、输出不到 200 字的请求,别上 SSE。 这种请求用一次性 JSON 返回,分块传输和重连管理都是纯成本:你要多维护三个缓冲区、一个游标、一条心跳,换来的首字收益可能只有几百毫秒。

单机内网、没有反向代理、没有 CDN 的部署,心跳可以省,但 id 不能省。 D 组那 1 次掐断是代理造成的,去掉代理就不会发生;可断线重连是新连接,跟有没有代理无关,id 还是要发。

生成不可重放的场景,Last-Event-ID 补偿不成立。 如果后端每次生成的文本都不一样(采样没固定),或者中途还调了有副作用的工具,那「从第 7 条续传」根本做不到——你没法让模型从半句话接着往下说。这种场景只有两个选择:服务端把已生成的片段缓存下来按 id 回放,或者干脆不重连,让用户重问一次。

多标签页场景要算连接数。 截至 2026-11-04,HTTP/1.1 下浏览器对同一域名的 SSE 连接上限是 6 条,开三个标签页就吃掉一半。用 HTTP/2 部署可以绕开:要求的是浏览器到你这台入口这一跳走 h2;反向代理到源站那一跳用 HTTP/1.1 长连接复用就行,跟浏览器侧的连接计数无关。

7.2 if-则-what

如果就先做判据 / 停手条件
接口里还没有 id 字段先加 id,再加别的断线测试里重复条数为 0 才算过
客户端 last_id 在 id: 行赋值挪到空行派发之后丢失字数从 28 变成 0
重连请求没带 Last-Event-ID服务端先支持这个头服务端日志出现「从第 N 条续传」
上线后长回答频繁中断先加心跳注释行,间隔小于代理空闲超时的一半掐断次数从 1 降到 0
网关日志里出现 token=改鉴权方式Request URL 里不再有凭证
输出不到 200 字、响应不到 1 秒回到一次性 JSON别为了流式而流式
客户端断线后一直重连、停不下来服务端回 204 停止重连日志里不再出现新的重连请求
生成不可重放服务端缓存已生成片段缓存命中不了就别承诺续传

表里第三行和第五行并列最该先做:一个管断线后会不会重复,一个管凭证会不会外泄,都只改一处。

7.3 明天就能改的三件事

第一件,把 last_id 的赋值挪进派发分支,跑一次断线测试,看丢失字符数。第二件,给流式路由加一行心跳,间隔取代理空闲超时的一半。第三件,把接口文档里那句「返回 SSE 流」改成字段清单,写清 id 的语义。

三件事都不用改检索,也不用重跑评测:接口先行的好处,是把返工挡在调参之前。

这份断线重连测试脚本:本文那套最小 SSE 服务端、客户端解析器和五组对照,可以直接跑在你的机器上,只用标准库。放在资料包里,扫码即可获取:

配套资料获取

附表 A:本文引用事实与出处对照表

序号本文用到的事实文件全名与作者/机构编号或文号 + 日期链接本文位置
1流结束时未完成的事件直接丢弃HTML Living Standard §9.2.6 Interpreting an event stream,WHATWG章节 9.2.6;页面标注 Last Updated 3 November 2026https://html.spec.whatwg.org/multipage/server-sent-events.html2.3、5.3
2空行派发时才把 last event ID 缓冲区写入事件源HTML Living Standard §9.2.6,WHATWG章节 9.2.6;2026-11-03https://html.spec.whatwg.org/multipage/server-sent-events.html3.2、3.3
3id 字段写入缓冲区;retry 只接受 ASCII 数字HTML Living Standard §9.2.6,WHATWG章节 9.2.6;2026-11-03https://html.spec.whatwg.org/multipage/server-sent-events.html3.1
4重连时间为实现自定义值,「大概在几秒上下」同上,§9.2.2章节 9.2.2;2026-11-03https://html.spec.whatwg.org/multipage/server-sent-events.html3.1
5Last-Event-ID 头只在 last event ID 非空时才设置HTML Living Standard §9.2.3 Processing model,WHATWG章节 9.2.3(第 5 步);§9.2.4 定义取值空间;2026-11-03https://html.spec.whatwg.org/multipage/server-sent-events.html3.2
6初始化字典只有 withCredentials 一个成员同上,§9.2.2章节 9.2.2;2026-11-03https://html.spec.whatwg.org/multipage/server-sent-events.html4.1
7用 204 响应可让客户端停止重连HTML Living Standard §9.2.1,WHATWG章节 9.2.1 原文;§9.2.2 非 200 即 fail;2026-11-03https://html.spec.whatwg.org/multipage/server-sent-events.html7.2 决策表
8幂等定义;PUT、DELETE 与安全方法幂等;读响应前通信失败可自动重试RFC 9110: HTTP Semantics §9.2.2,IETFRFC 9110;2022-06https://www.rfc-editor.org/rfc/rfc9110.txt4.2
9分块传输把内容切成带长度前缀的块RFC 9112: HTTP/1.1 §7.1,IETFRFC 9112;2022-06https://www.rfc-editor.org/rfc/rfc9112.txt2.1、5.3
10非 HTTP/2 时每浏览器每域名 6 条 SSE 连接;HTTP/2 并发流默认 100EventSource,MDN Web Docs无文号;截至 2026-11-04https://developer.mozilla.org/en-US/docs/Web/API/EventSource4.3、7.1
1112 条消息、307 字、五组对照全部数字本机实测(脚本 work/2026-11-04-N01_SSE重连实测.py,日志同名 .log,Python 3.13.14,标准库)2026-11-04 运行本地文件2.3、3.3、4.3、6.2

附表 B:术语速查表

术语一句话解释在本文哪里用到
SSE一条 HTTP 长连接,服务端按行推纯文本1.1、2.1
事件字段event / id / data / retry 四个键,各管一条客户端分支3.1
Last-Event-ID重连请求头,告诉服务端上次收到哪条3.2、4.2
游标客户端记的「已派发到第几条」3.3、6.1
心跳注释行冒号开头的空行,只为产生流量保活2.1、4.3
幂等重复执行同一请求,效果与执行一次相同4.2
分块传输响应按长度前缀切块发,客户端不必等整包2.1、5.3
反向代理空闲超时多久没有字节流过就掐断连接4.3、7.1
首字延迟从发请求到收到第一个字节的时间6.3
重放断线后把已发过的内容再发一遍4.2、5.2

写在最后:这篇用到的资料

写这篇文章时,把相关的官方文档和源码又翻了一遍,顺手也整理了几份配套的东西:

  • 大模型学习路线图:从零基础到能自己动手做 Agent,按阶段说明每一步该学什么、哪些可以先跳过
  • 《LangChain + LangGraph + MCP 智能体开发实战》视频课:7 个模块,从私有化部署、Embedding+RAG 到 MCP+Agent 全流程
  • AI 大模型知识库(在线可查):Agent Skills 从入门到落地、Claude Skills 完全指南等专题,按目录浏览即可
  • 640 套 AI 大模型行业报告 + 经典 PDF 书籍:看行业落地案例和别人怎么做的时候用得上
  • 大模型零基础到精通教学视频:跟着敲一遍,比只读文档快得多

资料是我自己整理的,放在下面这个码上,扫码即可获取:

配套资料获取
课程目录:私有化部署与 LangChain
课程总目录:7 个模块
在线知识库:Agent Skills 创建全流程
课程目录:LangGraph 开发智能体
在线知识库:Claude Skills 完全指南

添加时备注「AI」,优先通过。

资料按「先路线、再动手、最后查漏」的顺序整理好了,建议先看学习路线那一份,照着它挑一条适合自己当前基础的路径再往下看。

Logo

更多推荐