断的是连接,别把答案也重来一遍

题记:重连应该修复传输,不应该偷偷换一道答案。
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. 不要把“更能重试”误认成“更可靠”⌗
这次实现仍有清楚的范围边界。
游标和前缀只保存在当前调用的内存里,不支持进程重启后续接。前缀保存的是事件内容,额外内存随已记录内容增长。恢复预算固定为一次;这限制了本模块的重连行为,并不意味着外层没有自己的重试政策。
此次落地也只接入了一个消息格式适配器,不能把通用接口的存在说成所有模型通道已经具备相同能力。
这些限制没有削弱核心判断:流式恢复应该先维护事件连续性,再谈多试几次。
把重新生成塞进传输重试,表面上提高了成功率,却可能把两份不同的输出揉成一份。把恢复放在能够看到完整事件、游标和已交付前缀的位置,才能明确区分:哪些是旧内容,哪些是新内容,哪些根本不能接。
连接断了,可以重连。历史对不上,就应该报错。
别让一个看似热心的重试按钮,替系统篡改已经说过的话。