# 流式解析与事件 · 从 SSE 字节到屏幕

> 从模型吐出的第一个字节，到你屏幕上跳动的文字，中间是一条 SSE 解析流水线加一套事件词汇。这一章看 grok 怎么把原始字节变成 SamplingEvent，以及贯穿其中的「错误保真」与两套重试预算。

- 作者：David（道雾轩）
- 专栏：Grok Build 源码解读（https://daiw.org/manual/grok-build.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/grok-build/streaming-events
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 流式解析与事件 · 从 SSE 字节到屏幕

模型的回复是**流式**的（Server-Sent Events）。这一章顺着字节流，看它怎么变成你屏幕上跳动的字，以及路上那些「出错了怎么办」的机关。

## L1：把字节变成类型化事件

`SamplingClient`（`client.rs`）拿到 HTTP 响应后，跑一条 SSE 解析流水线：

```text
response.bytes_stream()
  → 去掉 UTF-8 BOM
  → .eventsource()  解出一帧帧 SSE
  → .scan(...) 状态机  处理 [DONE]、doom-loop 检查帧、流内错误
  → 反序列化成后端专属的类型化事件
  → .boxed()
```

其中 `.scan` 状态机很值得看：`[DONE]` 结束；doom-loop 的检查帧要**吞掉**（否则会让类型反序列化失败；这种帧只出现在 Responses 流里）；流内错误转成 `StreamError`。反序列化还有个宽容处理——遇到 async-openai 建模不了的 xAI 扩展工具（`x_search`），先剥掉再重试，绝不因为一个不认识的字段整条流崩掉。

## L2：归一成 `SamplingEvent`

L1 的原始事件进 L2 流变换，吐出统一的 `SamplingEvent`（`events.rs`）。这套词汇是上层唯一要认的东西：

| 事件 | 含义 |
| --- | --- |
| `StreamStarted` / `FirstToken` | 流开始 / 首 token(TTFT 就从这里量) |
| `ChannelToken{channel, text}` | 一段文本；channel 分 `Text` 与 `Reasoning`(思考) |
| `ToolCallDelta` | 工具调用的增量(名字、参数一点点拼) |
| `BackendToolCallStarted/Completed` | 服务端托管工具(web/x 搜索)的开始/结束 |
| `Retrying` | 正在重试(带原因、次数) |
| `ImagesStripped` / `DoomLoopSignals` | 重试前剥掉了请求里的图片 / 被丢弃的那次尝试上服务端报的 doom-loop 标签 |
| `ResponseStarted` / `ReasoningCompleted` | 仅 Messages 后端：开流时的消息 id 与用量 / 思考块结束时的签名 |
| `Completed{response}` / `Failed{error}` | 终止：成功 / 失败 |

这些事件流到会话循环，再由 [TUI](https://daiw.org/manual/grok-build/render-pipeline) 画进滚动缓冲——就是你看到的实时输出。

## 一个精妙点：错误保真跨层

这是我最欣赏的设计。L1 的错误是**信息丰富**的 `SamplingError`（含 `reqwest::Error`），但它**不能 Clone、不能序列化**。而 L2 的事件要能序列化（要过通道、要落日志），只能带一个「有损」的 `SamplingErrorInfo`。

问题来了：重试决策需要**丰富**的错误（才能分清是 401 还是 413 还是限流）。grok 的解法是 `tee_errors`（`actor/request_task.rs`）——用一个 `Arc<Mutex<Option<SamplingError>>>` 单元格，在原始流上「三通」一下，把**第一个**丰富错误截留进单元格，同时不改变流本身。actor 于是能从单元格里取回丰富错误做精确分类，取不到再用 `synthesize_from_info` 兜底重建。

## 两套独立的重试预算

出错重试是 agent 稳定性的命门。采样器把重试分成**两套互不相干的预算**：

- **传输/空响应预算**(默认 `DEFAULT_MAX_RETRIES=15`，可用 `GROK_MAX_RETRIES` 覆盖；指数退避从 2 秒起、封顶 30 秒，带 ±20% 抖动)：网络抖动、5xx、空回复走这套。
- **doom-loop 预算**(`DoomLoopRecoveryPolicy` 的 `max_retries`，默认 2 次，可配 0～5)：模型陷入复读机式失控走这套(详见[打断与防失控](https://daiw.org/manual/grok-build/interjection-doom-loop))。

两套分开的意义：一次 doom 重采样不该消耗传输预算，反之亦然。核心是 `classify_error`(`retry.rs`)把错误映射成 `RetryDecision`：

| 决策 | 触发 |
| --- | --- |
| `RetryWithBackoff` | 限流 429(读 `Retry-After`，退避 + 抖动)；默认阈值 `RATE_LIMIT_RETRY_THRESHOLD=2`，采样器只再试一次，子代理更长的限流等待交给会话层的预算 |
| `RetryWithImageStrip` | 413 / 请求体字节超限 / 图片处理失败 → 剥掉图片再试 |
| `RetryWithClientRebuild` | 可重试错误(5xx、传输错误)的第一次重试 → 换不带连接池的 HTTP/1.1 连接，逃离被污染的连接池 |
| `Retry` | 之后的普通重试；doom-loop 重采样也走它(只等 250 毫秒以内的抖动) |
| `EmitToSession` | 鉴权/加密内容错 → 交给会话去刷新 token |
| `Fatal` | 被否决的重试(上下文超长、`x-should-retry:false`)、预算耗尽 |

还有**双 idle 计时器**防「连接活着但不出字」(默认 300 秒)：一个管「流干脆不 yield 了」，一个管「一直发空 keepalive 但没实质进展」，任一超时都报 `IdleTimeout`。采样器这一层放弃了，也不等于这一轮就失败：会话层对流停滞、传输错误这类瞬时故障还有一道 turn 级重投(见[主循环](https://daiw.org/manual/grok-build/agent-loop))。

## 和《从 LLM 到 Coding Agent》对照

对照那本的 [streaming](https://daiw.org/manual/llm-to-agent/streaming) 与 [error-recovery](https://daiw.org/manual/llm-to-agent/error-recovery)：最小实现里，流式是 `for await chunk`，错误恢复是 `try/catch + retry`。grok 的增量全在「真实网络很脏」这件事上——BOM、宽容反序列化、错误保真、分类重试、连接池自愈、双超时。这些不改变主线逻辑，却决定了产品在烂网络下会不会崩。

---

上一篇：[三种 API 流](https://daiw.org/manual/grok-build/api-streams) · 下一篇：[会话状态 · chat-state](https://daiw.org/manual/grok-build/chat-state)
