SamplingEvent 事件枚举 本节摘要:Sampler 对外说话,用的不是原始的 HTTP chunk,也不是某个后端的私有格式,而是一套统一的 SamplingEvent 事件枚举(源码在 )。无论后端是 OpenAI 风格的 responses、chatcompletions,还是 Anthropic 风格的 messages,sampler 都把它们吐成同一套事件。这种统一是 sampler 最有价值的设计——让 shell 层的桥、让 UI、让所有上层都不必关心后端差异。本节会逐个讲解 SamplingEvent 的主要变体,说明它们的含义、触发时机,以及第 3 章那座桥如何消费它们。 一、为什么需要统一事件 在讲事件之前,先理解为什么需要统一。
本节摘要:Sampler 对外说话,用的不是原始的 HTTP chunk,也不是某个后端的私有格式,而是一套统一的 SamplingEvent 事件枚举(源码在
xai-grok-sampler/src/events.rs)。无论后端是 OpenAI 风格的 responses、chat_completions,还是 Anthropic 风格的 messages,sampler 都把它们吐成同一套事件。这种统一是 sampler 最有价值的设计——让 shell 层的桥、让 UI、让所有上层都不必关心后端差异。本节会逐个讲解 SamplingEvent 的主要变体,说明它们的含义、触发时机,以及第 3 章那座桥如何消费它们。
在讲事件之前,先理解为什么需要统一。
不同的模型 API 后端,响应格式各不相同:
如果 shell 层直接接触这些原始格式,会发生什么?
统一事件的好处恰恰相反:
这就是「适配器模式」在流式响应上的应用——sampler 是适配器,把多种后端适配成一种事件。
一次完整的请求,事件大致按这个生命周期产生:
Submit 命令到达 ↓ StreamStarted 流建立,header 已读 ↓ FirstToken (可选)首个内容 token ↓ ChannelToken × N 正文/推理 token 流 ToolCallDelta × M 工具调用增量 BackendToolCallStarted/Completed (可选)后端托管工具 ModelMetadata (可选)响应 header 的模型元信息 ↓ [若遇可重试错误] Retrying 正在重试 ↓ (重新建立流) ↓ Completed 成功完成 或 Failed 彻底失败
这个序列不是死板的——某些事件可能不出现(如没有工具调用就没有 ToolCallDelta),某些可能重复(Retrying 可能多次)。但整体生命周期是清晰的。
下面逐个讲解主要的事件变体(简化命名,实际 Rust 枚举变体名可能略有不同):
StreamStarted { request_id, ts }
含义:HTTP 流已建立,响应 header 已成功读取。此时还没开始读 body,但连接是通的、请求被服务端接受了。
桥如何消费:通常用于更新 UI 状态(从「等待」变成「正在接收」),记录延迟指标(从 Submit 到 StreamStarted 的时间)。
FirstToken { request_id }
含义:收到了第一个实际内容 token。这是「首字延迟」(time to first token)的测量点——从流建立到第一个 token 的时间,是流式体验的关键指标。
桥如何消费:记录首字延迟(用于遥测与 UI 显示「模型开始响应了」)。
关键概念:首字延迟是流式体验的核心指标。它决定了用户按下回车后要等多久才看到第一个字。流式之所以重要,正是因为它把这个等待从「全部生成完才显示」缩短到「生成第一个字就显示」,大幅改善感知速度。
ChannelToken { channel, text, chunk_index }
含义:模型生成了一个 token,带 channel 标识区分这是正文还是推理过程(reasoning)。chunk_index 是序号,用于排序与去重。
channel 的意义:新一代「会思考的模型」在给出最终答案前,会先输出一段「内部推理」。channel 让上层知道这个 token 是「思考过程」还是「正式回答」,可以分别渲染(如把推理过程用淡色或折叠显示)。
桥如何消费:
ToolCallDelta { tool_index, id, name, arguments_delta }
含义:模型在生成一个工具调用,这是它的增量参数。tool_index 标识是第几个工具调用(一次响应可能含多个),arguments_delta 是参数 JSON 的一个片段。
为什么是增量:模型生成工具调用参数,就像生成正文一样,是逐 token 的。比如要生成 {"command": "ls -la"},模型会先吐 {"comm,再吐 and": "l,再吐 s -la"},桥要把这些片段拼起来。
桥如何消费:维护一个按 tool_index 索引的累积缓冲,把每个 delta 追加到对应工具的参数字符串。流结束时,这个字符串能解析成完整的参数 JSON。
BackendToolCallStarted { call_id, name } BackendToolCallCompleted { call_id, name, result }
含义:某些后端支持「服务端托管的工具」——工具不是在本地执行,而是由服务端代为执行(最典型的是 web search)。这两个事件标记这种后端工具的开始与完成。
与本地工具的区别:本地工具(如 ReadFile、bash)由 Grok Build 自己执行,产生 tool_result;后端工具由服务端执行,sampler 只是观察并转发结果。
桥如何消费:转发给 UI 显示(让用户知道「服务端正在搜索资料」),把结果纳入最终响应。
ModelMetadata { metadata }
含义:响应 header 或流里携带的模型元信息,如实际用的模型名(可能与请求的不同,服务端可能路由到变体)、版本、限流信息等。
桥如何消费:记录到指标,可能更新 UI 显示的模型名。
Retrying { attempt, max_retries, kind, reason, doom_loop_* }
含义:遇到了可重试的错误,sampler 正在按 RetryPolicy 退避后重试。attempt 是第几次重试,max_retries 是上限,kind 是错误类型,reason 是人类可读的原因。doom_loop 相关字段(若存在)提示是否疑似死循环。
桥如何消费:
Completed { response, metrics }
含义:流成功结束,sampler 给出最终的完整响应(response,可能含正文、工具调用、用量等)与指标(metrics,如延迟、重试次数、token 用量)。
桥如何消费:用 response 与 metrics 填充累积结果,break 退出事件循环,把结果作为 SamplerTurnOutcome::Response 返回给循环。
Failed { error: SamplingErrorInfo }
含义:sampler 用尽重试仍失败,或遇到了不可重试的错误。error 里带错误类型(kind)与详细信息。
桥如何消费:不在这里崩溃,而是按 error.kind 翻译:
Failed 事件里的 error 带 kind,这是第 3 章错误分层的基础。主要类型:
SamplingErrorKind: ├── Auth 认证失效(401),→ RefreshAuthAndResubmit ├── Http HTTP 错误(非 401/429/5xx),通常可重试 ├── Api API 层错误(服务端返回的错误 JSON) ├── Serialization 序列化/反序列化错误(请求或响应格式问题) ├── IdleTimeout 空闲超时(流建立后长时间无数据),可重试 ├── RateLimited 限流(429),可重试 ├── EmptyResponse 空响应(流建立但没内容),可重试 ├── MaxTokensTruncation 因 max_tokens 截断,可能与上下文超限相关 └── DoomLoopDetected 疑似死循环,触发恢复策略
这些 kind 既是「错误性质的分类」,也是「如何处理的依据」。sampler 自己用 kind 判断要不要重试(Http、IdleTimeout、RateLimited、EmptyResponse 等可重试;Auth、Serialization 等不重试);shell 的桥用 kind 判断翻译成哪种 outcome。
回看这套事件,「统一」体现在几个层面:
概念统一
不同后端都有「一个 token」「一个工具调用增量」「一次完成」「一次失败」这些概念。SamplingEvent 把它们映射到统一的变体,无论后端怎么叫。
结构统一
每个事件有相似的结构(request_id、时间戳、相关数据),便于通用处理。
生命周期统一
无论后端,事件都遵循 StreamStarted → ... → Completed/Failed 的生命周期,桥可以统一处理。
错误统一
SamplingErrorKind 把各种后端的错误归并到一套类型,既用于 sampler 内的重试决策,也用于 shell 的翻译。
关键概念:SamplingEvent 是 sampler 最有价值的抽象。它把「后端的多样性」封装在 sampler 内部,对上层只暴露一套统一语言。这种「内部适配,外部统一」是分层架构的精髓,也是 Grok Build 能同时支持多种模型后端的关键。
把第 3 章那座桥的消费逻辑,与本章的事件对应起来:
| 事件 | 桥的动作 | 对循环的影响 |
|---|---|---|
| StreamStarted | 更新 UI 状态 | 无 |
| FirstToken | 记录首字延迟 | 无 |
| ChannelToken | 转发 UI + 累积 | 无 |
| ToolCallDelta | 累积工具参数 | 无 |
| BackendToolCall* | 转发 UI + 纳入响应 | 无 |
| ModelMetadata | 记录指标 | 无 |
| Retrying | 通知 UI | 不结束,继续等 |
| Completed | 填充 + break | Response outcome |
| Failed(Auth) | 翻译 | RefreshAuthAndResubmit |
| Failed(超限) | 翻译 | CompactAndResubmit |
| Failed(其他) | 翻译 | 致命错误,结束 |
这张表把事件与桥、与循环的对应关系一网打尽。建议结合第 3 章第 05 节的桥伪代码反复对照。
理解了 SamplingEvent 这套统一事件,下一节我们看「这些事件是怎么从原始 chunk 变来的」——也就是 L2 transform。每种后端有自己的 transform,把后端私有的 chunk 格式转换成统一的 SamplingEvent。这是 sampler 内部「适配」的具体发生地,也是「统一」得以实现的机制。
下一节,我们拆解 L2 transform——看三种后端的原始格式如何被转换成统一的 SamplingEvent。