9.3 断线重连:Last-Event-ID 续传不丢步
9.2 的界面能随事件流生长了,但它默认「连接一直活着」。真实网络不长这样:一次 run 动辄几十秒,中间隔着浏览器、代理、负载均衡,任何一跳抖动都会把长连接掐断。连接一断,用户面前就停在一个带 ▌ 的半截气泡上——如果重连后从头再推一遍,用户会看到回答「倒带重放」;如果干脆丢掉后半段,报告就缺了一块。断线重连要解决的就是:接上,而且不重不漏。
本节把 ResearchCopilot 推进到「连接可恢复」:客户端重连时带
Last-Event-ID头,服务端从该 id 之后续推;用httpx真跑「消费 5 条 → 断开 → 带 id 重连 → 补齐剩余」,并断言 11 条事件无重复、无缺失。
9.3.1 断线为什么是常态
先破除一个直觉:断线不是异常,是常态。一次 Agent run 的 SSE 连接要活几十秒到几分钟,而链路里至少有四处会主动掐它:
- 浏览器/系统:切到后台、锁屏、休眠,连接被回收。
- 代理与负载均衡:多数网关有「空闲超时」(常见 30–60 秒),长时间无数据就断——这正是 3.2.8 要用注释帧做心跳的原因。
- 移动网络:基站切换、信号抖动,TCP 连接直接失效。
- 服务端发布/重启:滚动升级时旧实例的连接被排空。
结论是:任何长连接都必须假设它会断,且能接回去。 而 SSE 恰好把这件事做成了一半——3.2 的每帧都带 id,浏览器端的 EventSource 重连时会自动把最后收到的 id 放进 Last-Event-ID 请求头。服务端只要认这个头,断点续传就成了一半;另一半,是服务端得记得那些已经发出去的事件。
9.3.2 Last-Event-ID:协议自带的断点
回顾 3.2.4 的帧:每帧开头是 id: 5。这个 id 的用途就一条——断点。规则很简单:
| 环节 | 行为 |
|---|---|
| 客户端正常收 | 记住最后收到的 id(浏览器 EventSource 自动做) |
| 连接断开 | 重连时把该 id 放进请求头 Last-Event-ID: 5 |
| 服务端收到 | 只推 id > 5 的事件,从 6 开始 |
| 若没有该头 | 视为首次连接,从头推 |
注意 id 的语义是单调递增,不是「第几条」——它必须全局唯一且递增,因为服务端只做「比大小」这一个判断。本卷用连续整数 1, 2, 3...,够用且好调试。id 不是序号而是游标:客户端不需要理解它,只要原样回传;服务端不需要存「谁收到了哪条」,只要按值过滤。
一个容易踩的坑:Last-Event-ID 是请求头,不是查询参数。浏览器 EventSource 自动带上,但如果你用 fetch 手写 SSE 客户端(9.2.6 那种),就得自己记住 id 并手动加头——漏了这一步,重连就会从头发,用户看到重放。
9.3.3 服务端为什么必须保留事件缓冲
「从 id 之后续推」这句话里藏着最大的实现约束:服务端得能拿到 id 之后的事件。但 run 的事件是边生成边发的——模型吐一个 token 就推一条,推完就丢。重连时若要「从 6 开始」,服务端必须把 6 及以后的事件留着。
这就引出核心设计:服务端为每个 run 维护一份事件缓冲(event buffer),每产出一条事件就 append 进缓冲,推送时从缓冲里按 id 过滤。缓冲让「实时推送」与「按需补发」共用同一份数据——事件不是「发出去就没了」的流,而是「可重放的日志」。这个视角一转,后面所有取舍都顺了:
- 缓冲放哪?短命 run 放内存足够;要跨实例重连(重连可能落到另一台机器)就得进 Redis 或数据库(第 5、6 章)。
- 缓冲留多久?见 9.3.4。
- 重连落到了没有这份缓冲的实例怎么办?进 Redis 就是为这个。
9.3.4 缓冲多久:窗口、淘汰与降级
缓冲不能无限留——run 越长、并发越多,内存越吃紧。生产上通常给缓冲设一个窗口(如「最近 N 条」或「最近 T 分钟」),超出窗口的旧事件被淘汰。于是重连时出现两种情形:
| 断点位置 | 缓冲是否覆盖 | 服务端行为 |
|---|---|---|
Last-Event-ID 在窗口内 | 是 | 正常续推 id > last 的事件 |
Last-Event-ID 早于窗口起点 | 否(已被淘汰) | 发一条 resync 事件,让客户端全量重取当前状态 |
第二行是关键降级:缓冲丢了不等于数据丢了。run 的当前状态(已生成的消息、步骤)本就落在数据库里(第 5 章),服务端可以回一条 resync,客户端收到后改为拉一次「当前快照」端点、把界面整体重建,再从此处继续接流。这样设计的好处是正确性不依赖缓冲窗口的大小——窗口只影响「续传还是重建」这个体验档位,不影响最终结果对错。窗口给大点,多数重连走便宜的续传路径;窗口小点,省内存,多几次全量重建。代价可调,正确性不可调。
9.3.5 真跑:消费 5 条断开,带 id 重连补齐
把服务端写成「按 Last-Event-ID 过滤缓冲」的生成器,客户端用 httpx 的 ASGITransport 进程内打 app(语义与真 HTTP 一致),第一轮读到 5 条就 break(模拟断线),第二轮带 Last-Event-ID: 5 重连:
import asyncio
import json
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
EVENTS = [
(1, "run.started", {"run_id": "r_d98c8f3c"}),
(2, "step", {"index": 0, "kind": "tool_call"}),
(3, "tool_call", {"id": "c1", "name": "search_corpus", "arguments": {"q": "pgvector 召回率"}}),
(4, "tool_result", {"id": "c1", "name": "search_corpus", "result": "命中 3 条"}),
(5, "token", {"text": "pgvector"}),
(6, "token", {"text": " 在"}),
(7, "token", {"text": "低维"}),
(8, "token", {"text": "场景更省"}),
(9, "step", {"index": 1, "kind": "final"}),
(10, "final", {"content": "pgvector 在低维场景更省。"}),
(11, "run.succeeded", {"run_id": "r_d98c8f3c", "steps": 2}),
]
BUFFER_START = 1 # 事件缓冲保留的最早 id
app = FastAPI()
def frame(seq: int, event: str, data: dict) -> str:
return f"id: {seq}\nevent: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def stream_after(last_id: int):
if last_id < BUFFER_START - 1: # 缓冲窗口丢了,降级
yield frame(last_id, "resync", {"from": BUFFER_START})
return
for seq, event, data in EVENTS:
if seq > last_id: # 只推断点之后的
yield frame(seq, event, data)
@app.post("/runs/{rid}/stream")
def stream(rid: str, request: Request) -> StreamingResponse:
last_id = int(request.headers.get("last-event-id", "0"))
return StreamingResponse(stream_after(last_id), media_type="text/event-stream")
async def read_frames(client, url, last_id=0, limit=None):
headers = {"Last-Event-ID": str(last_id)} if last_id else {}
got = []
async with client.stream("POST", url, json={"input": "x"}, headers=headers) as resp:
cur = {}
async for line in resp.aiter_lines():
if line == "":
if "id" in cur:
got.append((int(cur["id"]), cur.get("event", ""), cur.get("data", "")))
cur = {}
if limit and len(got) >= limit:
break # 主动断开,模拟网络中断
elif line.startswith("id:"):
cur["id"] = line[3:].strip()
elif line.startswith("event:"):
cur["event"] = line[6:].strip()
elif line.startswith("data:"):
cur["data"] = line[5:].strip()
return got
async def main() -> None:
transport = httpx.ASGITransport(app=app)
url = "/runs/r_d98c8f3c/stream"
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = await read_frames(client, url, 0, limit=5)
print("连接 1(消费 5 条后主动断开):")
for seq, event, data in first:
print(f" #{seq} {event} {data}")
last = first[-1][0]
print(f" 记录 Last-Event-ID = {last}")
second = await read_frames(client, url, last)
print("连接 2(带 Last-Event-ID 重连,续推剩余):")
for seq, event, data in second:
print(f" #{seq} {event} {data}")
merged = [seq for seq, _, _ in first + second]
print("--- 断言 ---")
print(f"合并 id 序列: {merged}")
print(f"无重复: {len(merged) == len(set(merged))}")
print(f"无缺失: {merged == list(range(1, len(EVENTS) + 1))}")
asyncio.run(main())
本机实测(Python 3.14.6,httpx 0.28.1):
连接 1(消费 5 条后主动断开):
#1 run.started {"run_id": "r_d98c8f3c"}
#2 step {"index": 0, "kind": "tool_call"}
#3 tool_call {"id": "c1", "name": "search_corpus", "arguments": {"q": "pgvector 召回率"}}
#4 tool_result {"id": "c1", "name": "search_corpus", "result": "命中 3 条"}
#5 token {"text": "pgvector"}
记录 Last-Event-ID = 5
连接 2(带 Last-Event-ID 重连,续推剩余):
#6 token {"text": " 在"}
#7 token {"text": "低维"}
#8 token {"text": "场景更省"}
#9 step {"index": 1, "kind": "final"}
#10 final {"content": "pgvector 在低维场景更省。"}
#11 run.succeeded {"run_id": "r_d98c8f3c", "steps": 2}
--- 断言 ---
合并 id 序列: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
无重复: True
无缺失: True
两次连接各收到 5 条与 6 条,合并后正好是 1–11 的完整序列,无重复、无缺失。三条结论:其一,连接 2 从 6 开始而不是从头——stream_after 的 if seq > last_id 生效了;其二,5 与 6 之间没有空隙也没有重叠,说明「按 id 过滤」这个判据是对的;其三,客户端只需记一个整数(last),不需要保存任何已收事件——断点的全部状态就是一个 id,这是这套机制最省事的地方。
9.3.6 真跑:缓冲丢了,降级为全量重取
把缓冲窗口设成「只留最后 4 条」(BUFFER_START = 8),再拿一个早于窗口的断点 3 去重连:
BUFFER_START = 8 # 只保留最后 4 条(8..11),更早的已被淘汰
def stream_after(last_id: int):
if last_id < BUFFER_START - 1: # 断点早于缓冲窗口
yield frame(last_id, "resync",
{"reason": "buffer_evicted", "from": BUFFER_START})
return
for seq, event, data in EVENTS:
if seq > last_id:
yield frame(seq, event, data)
本机实测(带 Last-Event-ID: 3 重连):
id: 3
event: resync
data: {"reason": "buffer_evicted", "from": 8}
服务端没有硬着头皮「从 4 开始推」(那些事件已经没了),而是回一条 resync 事件告诉客户端「缓冲已被淘汰,请从 id 8 起重建」。客户端收到 resync 后的动作是:拉一次当前快照端点、整体重建界面、记下 from 作为新起点继续接流。这条降级路径把「缓冲窗口」从正确性约束降成了性能参数——窗口无论多小,系统都不会推错、不会漏报,只是重连成本高一点。
9.3.7 真实环境 / 真实模型下会怎样
真实环境里这套机制要补三处工程细节:
- 心跳保活。空闲超时的代理会掐断静默连接,所以服务端要定期发注释帧
:\n\n(3.2.4)——它不含事件、不占id,只维持连接存活。心跳间隔要小于链路里最小的空闲超时。 - 缓冲进 Redis。本节的缓冲是进程内列表,重连若落到另一实例就取不到。生产上把「每 run 的事件列表」写进 Redis(第 6 章),带 TTL,多实例共享,滚动发布也不丢。
- 重连要带鉴权。3.2.7 提醒过:每次重连都是全新 HTTP 请求,若用短效 token(第 4 章),得保证重连时仍能带上有效凭证,否则断一次就再也接不回来。
一句话:真实场景里「续传」的形状不变(还是带 Last-Event-ID 求 id > last),变的是缓冲的存放位置与生命周期——从进程内列表,变成带 TTL 的共享存储。判据与断言一行不改。
本节验收清单:
- 每帧带单调递增
id,客户端只需记住最后一条 - 重连请求带
Last-Event-ID头,服务端只推id > last - 服务端有事件缓冲,能覆盖常见断线窗口
- 断点早于窗口时有
resync降级,不硬推 - 真跑断言「无重复、无缺失」通过
9.3.8 小结
- 断线是长连接的常态,任何 SSE 流都必须假设它会断、能接回。
id是单调递增的游标不是序号;重连带Last-Event-ID头,服务端推id > last。- 服务端必须保留事件缓冲——事件是「可重放的日志」,不是「发出去就没了」。
- 缓冲设窗口,窗口外的断点降级为
resync+ 全量重取;代价可调,正确性不可调。 - 生产加固:心跳保活、缓冲进 Redis、重连带鉴权。
第 9 章到此收官:事件有了语言无关的 schema(9.1)、能折成可见界面(9.2)、断了能接回(9.3)。下一章进入 10.1 MCP 客户端接入:minagent 作为 client 连本地 Server ,把外部 MCP Server 变成 ResearchCopilot 的工具源。想回看渲染层怎么消费这条流,翻 9.2 前端渲染:TS 最小聊天 UI 逐步显示 。
阅读导航:上一节:9.2 前端渲染:TS 最小聊天 UI 逐步显示 · 下一节:10.1 MCP 客户端接入:minagent 作为 client 连本地 Server 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。