全栈转大模型:一个人做 RAG,先把流式接口定死
版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 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: 行,无 id | 2 | 17 | 5 | 0 |
| B | 带 id: 与 Last-Event-ID | 2 | 12 | 0 | 0 |
| C | 带 id: 但游标提前推进 | 2 | 11 | 0 | 28 |
| 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 进 URL | Request 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 2026 | https://html.spec.whatwg.org/multipage/server-sent-events.html | 2.3、5.3 |
| 2 | 空行派发时才把 last event ID 缓冲区写入事件源 | HTML Living Standard §9.2.6,WHATWG | 章节 9.2.6;2026-11-03 | https://html.spec.whatwg.org/multipage/server-sent-events.html | 3.2、3.3 |
| 3 | id 字段写入缓冲区;retry 只接受 ASCII 数字 | HTML Living Standard §9.2.6,WHATWG | 章节 9.2.6;2026-11-03 | https://html.spec.whatwg.org/multipage/server-sent-events.html | 3.1 |
| 4 | 重连时间为实现自定义值,「大概在几秒上下」 | 同上,§9.2.2 | 章节 9.2.2;2026-11-03 | https://html.spec.whatwg.org/multipage/server-sent-events.html | 3.1 |
| 5 | Last-Event-ID 头只在 last event ID 非空时才设置 | HTML Living Standard §9.2.3 Processing model,WHATWG | 章节 9.2.3(第 5 步);§9.2.4 定义取值空间;2026-11-03 | https://html.spec.whatwg.org/multipage/server-sent-events.html | 3.2 |
| 6 | 初始化字典只有 withCredentials 一个成员 | 同上,§9.2.2 | 章节 9.2.2;2026-11-03 | https://html.spec.whatwg.org/multipage/server-sent-events.html | 4.1 |
| 7 | 用 204 响应可让客户端停止重连 | HTML Living Standard §9.2.1,WHATWG | 章节 9.2.1 原文;§9.2.2 非 200 即 fail;2026-11-03 | https://html.spec.whatwg.org/multipage/server-sent-events.html | 7.2 决策表 |
| 8 | 幂等定义;PUT、DELETE 与安全方法幂等;读响应前通信失败可自动重试 | RFC 9110: HTTP Semantics §9.2.2,IETF | RFC 9110;2022-06 | https://www.rfc-editor.org/rfc/rfc9110.txt | 4.2 |
| 9 | 分块传输把内容切成带长度前缀的块 | RFC 9112: HTTP/1.1 §7.1,IETF | RFC 9112;2022-06 | https://www.rfc-editor.org/rfc/rfc9112.txt | 2.1、5.3 |
| 10 | 非 HTTP/2 时每浏览器每域名 6 条 SSE 连接;HTTP/2 并发流默认 100 | EventSource,MDN Web Docs | 无文号;截至 2026-11-04 | https://developer.mozilla.org/en-US/docs/Web/API/EventSource | 4.3、7.1 |
| 11 | 12 条消息、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 书籍:看行业落地案例和别人怎么做的时候用得上
- 大模型零基础到精通教学视频:跟着敲一遍,比只读文档快得多
资料是我自己整理的,放在下面这个码上,扫码即可获取:
添加时备注「AI」,优先通过。
资料按「先路线、再动手、最后查漏」的顺序整理好了,建议先看学习路线那一份,照着它挑一条适合自己当前基础的路径再往下看。
更多推荐








所有评论(0)