sampler 桥接:runturnviasampler 本节摘要:在前几节拆解的循环里,有一个关键步骤反复出现—— 。它是 shell 层与 sampler 层之间的桥梁:把组装好的请求交给 sampler,收集 sampler 吐回的流式事件,翻译成循环能理解的三种结果(Response、CompactAndResubmit、RefreshAuthAndResubmit)。本节会拆解这座桥的内部结构,看清流式 token 如何被收集、工具调用增量如何累积、完成与失败如何被翻译、以及桥为何要把「语义错误」上浮给循环决策。理解这一节,shell 与 sampler 的边界就彻底清晰了,也为第 4 章深入 sampler 内部做好准备。
本节摘要:在前几节拆解的循环里,有一个关键步骤反复出现——
run_turn_via_sampler(request)。它是 shell 层与 sampler 层之间的桥梁:把组装好的请求交给 sampler,收集 sampler 吐回的流式事件,翻译成循环能理解的三种结果(Response、CompactAndResubmit、RefreshAuthAndResubmit)。本节会拆解这座桥的内部结构,看清流式 token 如何被收集、工具调用增量如何累积、完成与失败如何被翻译、以及桥为何要把「语义错误」上浮给循环决策。理解这一节,shell 与 sampler 的边界就彻底清晰了,也为第 4 章深入 sampler 内部做好准备。
回顾架构,shell 与 sampler 是两个独立的层,各自有自己的 Actor。它们之间不是直接函数调用,而是 Actor 消息通信。但 process_conversation_turn 这个循环需要的是一个同步感的体验:「我给你一个请求,你给我一个结果」。
这座桥(run_turn_via_sampler)的作用,就是把异步的、流式的、事件驱动的 sampler 交互,封装成循环可以直接消费的形式。它做三件事:
没有这座桥,循环要直接处理 sampler 的复杂事件流,代码会乱;有了桥,循环只关心「调一次,得一个结果」,简洁清晰。
源码在 xai-grok-shell/src/session/acp_session_impl/sampler_turn.rs。简化伪代码:
async fn run_turn_via_sampler(request) -> SamplerTurnOutcome { # ① 提交请求,获得事件流与完成回调 let (event_rx, completion_rx) = sampler_handle.submit(request, config); # ② 边收事件边处理 let mut accumulated = AccumulatedResponse::new(); while let Some(event) = event_rx.recv().await { match event { ChannelToken { channel, text } => { # 流式 token:转发给 UI 实时显示 发 ACP 通知给 pager(让 UI 渲染这个 token) accumulated.append_token(channel, text); } ToolCallDelta { tool_index, name, args_delta } => { # 工具调用增量:累积起来 accumulated.append_tool_delta(tool_index, name, args_delta); } ModelMetadata { metadata } => { accumulated.record_metadata(metadata); } Retrying { attempt, kind, ... } => { # 重试中:通知 UI,继续等 发 ACP 通知:"正在重试(第 N 次)..." # 不结束循环,继续 recv } Completed { response, metrics } => { # 完成:用最终响应填充 accumulated accumulated.finalize(response, metrics); break; } Failed { error } => { # 失败:翻译成循环能处理的 outcome return translate_failure(error); } } } # ③ 把累积结果转成 outcome return SamplerTurnOutcome::Response(accumulated.into_response()); }
这个结构揭示了桥的三个核心动作:提交、收集与转发、翻译。下面逐一展开。
let (event_rx, completion_rx) = sampler_handle.submit(request, config);
桥通过 sampler_handle(指向 SamplerActor 的句柄)提交一个 Submit 命令。这个命令带:
sampler 收到 Submit 后,注册一个 ActiveRequest,派生一个独立的异步 task 处理这次流式调用(第 4 章详谈),然后给桥返回两个通道:
提交后,桥进入「收事件」循环,不阻塞等待——它边收边处理,实现流式体验。
桥的核心是一个 while let Some(event) = event_rx.recv() 循环,逐个处理 sampler 吐来的事件。几类关键事件:
ChannelToken { channel, text }
模型生成了一个 token。channel 标明这是正文还是推理过程(reasoning)。桥做两件事:
注意「转发」与「累积」是并行的——转发让用户立刻看到,累积是为了最终持有完整文本。这种「边显示边存」的设计,是流式体验的基础。
ToolCallDelta { tool_index, name, arguments_delta }
模型在生成一个工具调用,但它不是一次性给出完整参数,而是增量地吐出。比如要调用 bash 工具,模型会先吐 "comma,再吐 nd": "ls,再吐 "},桥要把这些片段拼起来。
桥维护一个按 tool_index 索引的累积缓冲,把每个 delta 追加到对应工具的参数字符串上。流结束时,这个字符串能解析成完整的工具调用参数 JSON。
关键概念:模型生成工具调用是「流式」的,就像它生成正文一样。桥要在流式增量与「完整工具调用」之间做缓冲与拼接,这是 ReAct 循环里一个容易被忽视但很重要的细节。
Retrying { attempt, max_retries, kind, reason, ... }
sampler 遇到了可重试的错误(网络抖动、限流等),正在按重试策略退避重试。桥不结束循环,只是通知 UI「正在重试」,然后继续等下一个事件。这让重试对用户可见但不打断——用户看到「重试中」,但不必干预。
注意:Retrying 事件里的 kind 是错误类型(网络、限流、空响应等),桥可以据此决定要不要给 UI 更详细的提示。但桥自己不处理重试逻辑——重试是 sampler 的职责,桥只是旁观。
Completed { response, metrics }
流成功结束,sampler 给出最终的完整响应(可能包含正文、工具调用、用量等)与指标(延迟、重试次数等)。桥用这些填充 accumulated,然后 break 退出循环。
Failed { error }
sampler 用尽重试仍失败,或遇到了不可重试的错误。桥不在这里崩溃,而是把错误翻译成循环能决策的 outcome(下一节详谈)。
桥最精妙的部分是「翻译」——把 sampler 的「成功/失败」二分,翻译成循环能决策的三种 outcome:
enum SamplerTurnOutcome { Response(完整响应), # 正常拿到响应 CompactAndResubmit, # 上下文超限,需压缩后重发 RefreshAuthAndResubmit, # 认证失效,需刷新后重试 }
Response:正常情况。流成功完成,响应里可能有正文、可能有工具调用。循环拿到后进入决策阶段(执行工具 or 结束)。
CompactAndResubmit:遇到「上下文超限」错误。这通常表现为服务端返回一个特定的错误码或消息,说明请求的 token 数超过模型窗口。桥识别出这类错误后,不当作普通失败,而是翻译成 CompactAndResubmit。循环收到后会触发上下文压缩,然后 continue 用更短的请求重试。
RefreshAuthAndResubmit:遇到「认证失效」错误(通常是 401)。这说明 access_token 过期或无效。桥翻译成这个 outcome,循环收到后会刷新认证(用 refresh_token 换新 access_token,或触发重新登录流程),backoff 一会儿,然后 continue 重试。
翻译的依据:桥怎么知道一个失败是「上下文超限」还是「认证失效」还是「普通失败」?依据是 SamplingErrorKind(第 4 章详谈)——sampler 在 Failed 事件里带上错误类型,桥按类型分流:
fn translate_failure(error) -> SamplerTurnOutcome { match error.kind { MaxTokensTruncation | 上下文超限相关 => CompactAndResubmit, Auth => RefreshAuthAndResubmit, 其他 => 真正失败,抛给循环作为致命错误 } }
关键概念:桥把「错误」分成了两类:可恢复的语义错误(上下文超限、认证失效)与不可恢复的致命错误(其他)。可恢复的翻译成 Resubmit outcome,让循环用 continue 处理;不可恢复的才真正中断。这种分层让 Agent 对常见的服务端波动具有韧性。
把桥的结构看清楚,有几个设计要点值得琢磨:
桥本身不做业务决策——不决定要不要重试(那是 sampler)、不决定要不要压缩(那是循环)、不决定工具怎么执行(那是 tool_bridge)。它只做「翻译与转发」。这种纯粹的适配器角色,让桥的代码相对简单,也容易测试。
桥对每个 token / delta 都做两件事:转发给 UI + 累积到本地。这两个动作并行进行,既保证了用户体验(实时显示),又保证了最终持有完整数据(用于后续决策)。这是流式系统的常见模式。
sampler 的重试(Retrying 事件)对桥是「事件流的一部分」,桥不参与重试决策,只通知 UI。这让重试策略的调整(改退避时间、改最大次数)完全局限在 sampler 内,不影响桥与循环。
桥只翻译「语义错误」(上下文、认证),其他错误原样上浮。这避免了桥变成一个「什么错都处理」的大杂烩,保持了职责聚焦。更深层的错误处理(如 doom-loop 检测)在别处。
如果用户中途取消(Cancel 命令),桥不会自己中断事件流——它会收到一个 Cancelled 事件(来自 sampler,因为 sampler 的 request_task 响应了取消令牌),然后正常退出循环。取消的实际动作(中断 HTTP 请求)在 sampler 里发生,桥只是感知结果。这种分工让取消逻辑集中在一处。
最后,把桥放回上一节的循环里,看它如何嵌入:
process_conversation_turn: loop { 准备工具、组装请求 match run_turn_via_sampler(request) { ← 本节的桥 Response(resp) => { if resp 含工具调用: 执行工具,塞回历史,continue else: 收尾,return Done } CompactAndResubmit => { 压缩上下文,continue } RefreshAuthAndResubmit => { 刷认证 + backoff,continue } } }
桥是循环里「调用模型」这一步的全部封装。循环不直接接触 sampler 的事件流,也不直接处理 HTTP 细节——它只看到三种干净的 outcome。这种分层让循环的代码简洁,也让 sampler 的演进(如支持新后端、改重试策略)不影响循环。
本节讲的是「桥」,桥的另一端是 sampler。下一章我们会深入 sampler 内部,看清:
理解了本节的桥,第 4 章的 sampler 就有了清晰的「上游」——你知道 sampler 吐出的每个事件,是如何被桥消费、翻译、转发的。这种上下游的连贯,是理解整个系统的关键。
下一节,我们收束本章,讲清 shell 层如何处理错误——特别是 sampler 的传输错误与 shell 的语义错误的分层,以及上下文超限与认证失效这两种「可恢复错误」的完整处理流程。