《AI Agent 工程实战》9.3 断线重连:Last-Event-ID 续传不丢步

SSE 断线后客户端带 Last-Event-ID 重连,服务端从该 id 之后续推,呼应 3.2 的事件 id。讲清为什么必须服务端保留事件缓冲、缓冲多久、缓冲丢了怎么降级为全量重取;用 httpx 真跑「消费 5 条断开 → 重连补齐 11 条」并断言无重复无缺失。

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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距