题记:重连应该修复传输,不应该偷偷换一道答案。

1. 再请求一次,不等于接着说⌗

假设一个模型已经输出了半段文字,又开始流式返回工具参数,连接突然断了。

最直接的处理是重新发起模型请求。请求内容相同,看起来像“重试”;但对于消费者,这可能已经是另一回事。

前半段文字已经显示,工具参数已经进入累加器。第二次生成如果从头开始,消费者可能得到重复文本、重复参数片段,甚至两次生成拼起来的混合结果。

问题不只是体验不好。重新生成无法天然继承第一次生成的事件历史。

这次改造把恢复放到 SSE 传输层:记录事件游标,连接中断后携带 Last-Event-ID 再请求;如果返回的是从头重放,则核对已经交付的事件前缀,只交付新的部分。

相比把整个模型调用包在重试循环里,这种做法的优势是保留消费者状态,而不是让上层猜哪些内容需要撤回。

但有个前提不能省略:上游必须提供可用的恢复语义。客户端会发一个请求头,不代表服务端就拥有一份可恢复的生成日志。

2. 恢复的位置,比重试的次数重要⌗

新模块 src/llm/sse.ts 提供 consumeSse。接口把职责分成三部分:

入口谁负责什么
request(headers, signal)适配器构造请求,将恢复头合并进去
isStart / isTerminal适配器说明哪类事件表示开始、结束
onEvent消费经过传输层处理的事件

传输层不负责拼接最终答案,也不负责解释工具参数。它管理读取、分帧、游标、恢复与重放检查。

HTTP 字节流
    │
    ▼
UTF-8 解码 / SSE 分帧
    │
    ▼
游标更新 / 重放前缀检查
    │
    ▼
适配器事件处理
    ├─ 文本与思考增量
    ├─ 工具参数增量
    └─ 最终消息组装

此次接入的消息适配器保留同一份事件数组和工具块状态,跨连接继续使用。重复前缀在进入这些累加器之前被丢弃,所以它们不需要各自实现一套“这段是不是见过”的猜测逻辑。

请求构造也保留同一个请求体对象;集成测试进一步断言两次请求的 URL 与序列化请求体一致。改变的是恢复头,不是追加一句“请接着刚才继续”。

后者仍然是新一轮模型交互,不是传输恢复。

3. 两种恢复路径,两种证据⌗

实现允许首次连接之外再重连一次。能够进入这条路径的条件是:首次尝试发生 SseConnectionError,并且当前持有非空事件 ID。

读超时、读取异常、终止事件之前遇到 EOF,都属于这类连接错误。普通回调错误不会被当作断线重试;调用方取消也直接结束。

重连之后,存在两条主要路径。

路径一:上游真的从游标后继续⌗

首次收到: start → A → B → 断开
请求携带: Last-Event-ID: B 的游标
再次收到: C → stop
对外交付: start → A → B → C → stop

这里客户端依赖上游遵守游标契约:返回的确实是相应后缀。

传输层不会把事件 ID 转成整数,也不假设它连续递增。它只是原样携带一个不透明字符串。服务端如何将这个字符串映射到事件位置,是另一侧的职责。

路径二:上游又发来了开始事件⌗

首次收到: start → A → B → 断开
再次收到: start → A → B → C → stop
                   校验并跳过
对外交付: start → A → B → C → stop

看到重连流中的开始事件,传输层进入前缀比对。它记录的签名是:

JSON.stringify([event.event, event.data])

这不是密码学签名,而是用于精确比较的字符串表示。事件类型和 data 都相同,才能跳过;任一位置不同,抛出 SseReplayError。

因此,如果重放变成了 start → X,传输层不会把 X 接到已经交付的 A、B 后面,也不会悄悄清空上层状态,假装第一次从未发生。

已交付的前缀是恢复的约束,不是可以随手覆盖的缓存。

4. 严格比对,买到什么,又放弃什么⌗

这套比对刻意没有使用文本相似度,也没有先解析 JSON 再判断语义等价。

代价是保守:JSON 字段顺序、空白或事件切分发生变化,即使最终文字看起来一样,也可能被判为不同前缀。重复前缀检查发生在适配器过滤之前,带 data 的心跳事件等也可能参与比较。

这是可以理解的取舍。若恢复层开始猜“差不多是不是同一份输出”,它就不再只是恢复传输,而是在裁决两次事件历史能否拼接。

但严格也不是万能。

实现保证的范围不能据此推出的结论
识别到从头重放时,逐项核对已交付前缀可以证明后端没有重新生成
匹配的重放前缀不再交给回调任意上游重复方式都能自动去重
分歧明确报错已交付内容或外部副作用会自动回滚
携带最后记录的非空 ID 重连上游一定接受该 ID,或游标永不过期

尤其要注意:如果上游没有开始事件,而是直接重复某段后缀,这段实现不会靠 ID 建立全局去重集合。它支持的是“按游标续传”和“带开始标记的前缀重放”这两种形态,不是任意乱序事件同步。

同样,两次生成碰巧产生相同前缀,也能通过这项检查。它验证的是观察到的前缀一致性,不是生成过程的身份。

因此,准确的定位是:在明确的上游协议下,减少重复交付并拒绝已观察到的重放分歧;不是端到端 exactly-once 承诺。

5. 游标不能跑在完整事件前面⌗

续传很容易把关注点全放在重连,却忘了最底下的解析器。

TCP 读取边界不是 SSE 事件边界。一次读取可能只有半个 UTF-8 字符,也可能包含多个完整事件和下一个事件的开头。

新解析器按空行形成完整帧。没有形成完整帧的 id 不会提前更新恢复游标。

测试专门构造了这样的尾部:

……前两个完整事件……
id: 3
data: unfinished

连接在这里结束,重连应携带前一个完整帧的游标,而不是 3。否则客户端会宣称自己已经走过一个实际上没有完整收到的事件。

这里也不应把游标叫作“业务处理确认”。实现支持没有 data 的 ID 帧更新游标;它记录的是协议层位置,不是下游事务已经提交的证明。

解析测试还覆盖了拆成单字节输入的 UTF-8、BOM、注释、多行 data、不同换行形式,以及无效 ID 的处理。空 ID 会清除恢复游标,从而禁止这条重连路径。

另一端的边界是结束:EOF 不是成功标记。 这个模块要求适配器定义终止事件,收到它就结束,不必继续等服务器关闭连接;反过来,提前 EOF 也不会被包装成一份完整答案。

6. 测试看的是消费者,不只是连接数⌗

本文依据是落地版本的源码和测试断言,未另行运行测试。

src/llm/sse.test.ts 检查传输层边界:

场景断言
在不同事件位置中断请求保持一致,使用相应游标继续
上游从头重放相同前缀消费者只得到一份事件序列
重放前缀发生变化明确抛出重放错误,不交付分歧事件
没有 ID、或 ID 被清空不发起恢复请求
第二次连接仍提前结束报错,不继续重连
回调中取消不再交付同一缓冲区里的后续事件
收到终止事件,但连接不关闭正常结束,不等 EOF

适配器集成测试在不同位置切断模拟事件流,包括工具参数 JSON 尚未传完的位置,并覆盖从头重放。

它不只断言“发了两次请求”,还检查:

  • 文本和思考增量没有重复;
  • 工具参数片段维持正确的交付序列,最终组装成完整参数;
  • 思考块及其签名字段保留;
  • 输入、输出用量维持预期;
  • 成功恢复不额外上报一次中断用量。

集成调用还把上层 maxRetries 设为零。这让测试区分了两个机制:成功来自流内部的恢复,而不是整个模型调用重新尝试后恰好成功。

这些都是本地模拟服务上的规格证据,不能拿来宣称真实上游普遍支持续传,也不能推出生产成功率。

7. 不要把“更能重试”误认成“更可靠”⌗

这次实现仍有清楚的范围边界。

游标和前缀只保存在当前调用的内存里,不支持进程重启后续接。前缀保存的是事件内容,额外内存随已记录内容增长。恢复预算固定为一次;这限制了本模块的重连行为,并不意味着外层没有自己的重试政策。

此次落地也只接入了一个消息格式适配器,不能把通用接口的存在说成所有模型通道已经具备相同能力。

这些限制没有削弱核心判断:流式恢复应该先维护事件连续性,再谈多试几次。

把重新生成塞进传输重试,表面上提高了成功率,却可能把两份不同的输出揉成一份。把恢复放在能够看到完整事件、游标和已交付前缀的位置,才能明确区分:哪些是旧内容,哪些是新内容,哪些根本不能接。

连接断了,可以重连。历史对不上,就应该报错。

别让一个看似热心的重试按钮,替系统篡改已经说过的话。