三种 API 后端的 L2 transform 本节摘要:上一节我们看到 sampler 吐出统一的 SamplingEvent,但模型 API 的原始响应格式却因后端而异。把「后端私有的原始格式」转换成「统一事件」的工作,由 L2 transform 完成(源码在 )。Grok Build 支持三种后端:OpenAI 风格的 responses、chatcompletions,以及 Anthropic 风格的 messages。每种有自己的 transform,把自家的 chunk 流转换成统一的 SamplingEvent。本节会讲清这三种后端的格式差异、L2 transform 的工作方式,以及「collect」这一步如何把事件流聚合成最终响应。
本节摘要:上一节我们看到 sampler 吐出统一的 SamplingEvent,但模型 API 的原始响应格式却因后端而异。把「后端私有的原始格式」转换成「统一事件」的工作,由 L2 transform 完成(源码在
xai-grok-sampler/src/stream/)。Grok Build 支持三种后端:OpenAI 风格的 responses、chat_completions,以及 Anthropic 风格的 messages。每种有自己的 transform,把自家的 chunk 流转换成统一的 SamplingEvent。本节会讲清这三种后端的格式差异、L2 transform 的工作方式,以及「collect」这一步如何把事件流聚合成最终响应。理解这一节,你就彻底明白 sampler 内部「适配」是如何发生的。
在讲 transform 之前,先理解为什么 Grok Build 要支持三种后端。
历史与现实的原因
LLM 的 API 生态没有统一标准。最早 OpenAI 用 Chat Completions API,后来推出更新的 Responses API;Anthropic 用自己的 Messages API;各家厂商(包括 xAI)往往兼容其中一种或多种。一个想兼容多种模型的工具,必须支持多种后端协议。
Grok Build 的支持
Grok Build 默认用 responses 后端(从 default_models.json 看到 "api_backend": "responses")。但同时支持 chat_completions 与 messages,这让用户可以:
第 6 章会讲「自定义模型集成」,届时你会看到如何配置一个 OpenAI 或 Anthropic 兼容的端点。本节聚焦后端格式如何被 sampler 处理。
三种后端都是基于 HTTP 的流式响应(通常是 SSE,Server-Sent Events),但具体的 chunk 结构不同。下面用高度简化的方式说明差异(实际格式更复杂,以各后端官方文档为准):
chunk 大致长这样(简化):
event: response.output_text.delta data: {"delta": "Hello"} event: response.function_call_arguments.delta data: {"tool_index": 0, "delta": "{\"comm"} event: response.completed data: {"response": {... 完整响应 ...}}
特征:
event: 标明事件类型data: 是 JSONchunk 大致长这样(简化):
data: {"choices": [{"delta": {"content": "Hello"}}]} data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"arguments": "{\"comm"}}]}}]} data: [DONE]
特征:
[DONE]chunk 大致长这样(简化):
event: content_block_delta data: {"delta": {"text": "Hello"}} event: content_block_delta data: {"delta": {"type": "input_json_delta", "partial_json": "{\"comm"}} event: message_stop data: {}
特征:
核心观察:三种后端都在表达「文本增量、工具调用增量、完成」这些相同概念,但用完全不同的字段名、结构、结束标志。这就是需要 transform 的原因。
L2 transform 是「把后端私有格式翻译成统一 SamplingEvent」的函数。每种后端一个:
stream/mod.rs: pub mod chat_completions; // stream_chat_completions pub mod messages; // stream_messages pub mod responses; // stream_responses pub mod collect; // collect_response(聚合成最终响应)
每个 transform 的核心任务是:
async fn stream_<backend>(raw_chunk_stream) -> Stream<SamplingEvent> { for raw_chunk in raw_chunk_stream { # ① 解析原始 chunk(按本后端的格式) let parsed = parse(raw_chunk) # ② 按解析结果,映射成零或多个 SamplingEvent let events = match parsed { 文本增量 => vec![ChannelToken { channel: Text, text: ... }], 工具调用增量 => vec![ToolCallDelta { tool_index: ..., arguments_delta: ... }], 完成事件 => vec![Completed { response: ..., metrics: ... }], 错误 => vec![Failed { error: ... }], 其他(忽略/元信息) => vec![], } # ③ 把事件吐出去 for ev in events { yield ev } } }
关键点:
举个例子,三种后端都产生 ChannelToken,但来源不同:
responses 后端: event: response.output_text.delta → ChannelToken(Text) chat_completions 后端: choices[0].delta.content → ChannelToken(Text) messages 后端: content_block_delta 里 delta.text → ChannelToken(Text)
transform 内部就是这些「字段到事件」的映射规则。
回顾第 01 节的 request_task,它根据 config.api_backend 选 transform:
async fn request_task(request_id, request, config, ...): let response_stream = match config.api_backend { Responses => client.conversation_stream_responses(request), ChatCompletions => client.conversation_stream_chat_completions(request), Messages => client.conversation_stream_messages(request), } # response_stream 已经是「原始 chunk 流」 # 接下来用对应的 transform 把它变成 SamplingEvent 流 let event_stream = match config.api_backend { Responses => stream_responses(response_stream), ChatCompletions => stream_chat_completions(response_stream), Messages => stream_messages(response_stream), } # 现在 event_stream 吐的是统一 SamplingEvent for ev in event_stream { 发出 Retrying 事件前,先检查 cancel_token event_tx.send(ev).await }
两层抽象:
这种「先拿原始,再 transform」的两层设计,让 HTTP 通信与格式解析分离——HTTP 层不必关心格式,格式层不必关心网络。
事件流虽然统一,但它是「增量」的——一个个 token、一个个工具调用片段。最终的 ConversationResponse 需要「完整」的对象。这个「从增量到完整」的聚合工作,由 collect 模块完成:
async fn collect_response(event_stream) -> ConversationResponse { let mut text = String::new() let mut reasoning = String::new() let mut tool_calls = Vec::new() let mut usage = None for ev in event_stream { match ev { ChannelToken { channel: Text, text: t } => text.push_str(&t), ChannelToken { channel: Reasoning, text: t } => reasoning.push_str(&t), ToolCallDelta { tool_index, arguments_delta, ... } => { # 按 tool_index 累积参数 tool_calls[tool_index].arguments.push_str(&arguments_delta) } Completed { response, metrics } => { # 用服务端给的完整响应(如果后端在完成时给了) return response } ... } } # 若后端没在 Completed 里给完整响应,自己拼一个 return ConversationResponse { text, reasoning, tool_calls, usage, ... } }
collect 的两种来源:
response.completed),collect 直接用它这种「优先用服务端的,否则自己拼」的策略,既保证准确性(服务端的更权威),又保证健壮性(后端不给也能工作)。
把三种 transform 看下来,有几个设计要点值得琢磨:
理想情况下,transform 不持有状态——每个原始 chunk 独立映射成事件。但实际上,某些后端的工具调用增量需要按 tool_index 累积,transform 内部会有少量状态。即便如此,这种状态是局部的、与单次请求绑定的,不影响其他请求。
transform 只负责「格式转换」,不做「错误恢复」。遇到原始流里的错误,transform 把它翻译成 Failed 事件;重试决策在 request_task 里(根据错误 kind 与 RetryPolicy)。这种分离让 transform 简单聚焦。
取消由 request_task 的 select! 处理(响应 cancel_token)。transform 本身不感知取消——它只是被 request_task 调用,task 取消时 transform 也随之结束。这让 transform 不必关心取消逻辑。
transform 把 chunk 变事件,collect 把事件变最终响应。这两步分离,让它们各自聚焦——transform 关心「这一刻发生了什么」,collect 关心「整体意味着什么」。
把 L2 transform 的设计放回更大的语境,「统一」带来的具体好处:
好处一:新增后端只改 sampler
想支持一个新的后端(如 Google Gemini 的 API),只需在 sampler 里加一个 stream_gemini transform 与对应的 conversation_stream_gemini,shell 层一行代码都不用改。这种可扩展性是统一事件的核心价值。
好处二:后端格式变化被吸收
某个后端改了字段名(如 delta 改成 chunk),只需改对应 transform,上层无感。这种「变化局部化」让系统对后端演进具有韧性。
好处三:工具调用、推理过程等概念统一处理
不同后端对「工具调用」「推理过程」的表达不同,但都映射到 ToolCallDelta、ChannelToken(Reasoning)等统一事件。上层处理这些概念时,代码只有一份。
好处四:错误处理统一
不同后端的错误格式各异,但都翻译成 SamplingErrorKind 的同一套类型。这让 sampler 的重试决策与 shell 的翻译逻辑只写一份。
关键概念:L2 transform 是「适配器模式」的教科书式应用。它把「变化的(后端格式)」与「不变的(统一事件)」分离,让变化被局部吸收,让不变得以复用。这是应对「多个外部系统,各自演进」这类问题的经典解法。
本节讲的三种后端,直接关联第 6 章的「自定义模型集成」。届时你会看到:
理解了本节的 transform,第 6 章的自定义模型集成就是「配置一个端点 + 选一个后端」的简单操作——因为 sampler 已经支持了这些后端的解析。
下一节,我们看清默认模型从哪里来、运行中如何切换模型——default_models.json 与 UpdateConfig 命令、models_manager 广播机制。