# 采样器 · 用 actor 模型管住并发的模型请求

> grok 和模型对话这一侧叫「采样器」。它用一个 actor 把「并发、取消、重试」收在一处，无锁地管理多个同时进行的模型请求。这一章看它的三层设计与 actor 骨架。

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

# 采样器 · 用 actor 模型管住并发的模型请求

[上一章](https://daiw.org/manual/grok-build/agent-loop)看了主循环怎么在「模型 ↔ 工具」之间转。转向模型的那一侧，grok 叫它**采样器（sampler）**——`crates/codegen/xai-grok-sampler`。别被名字骗了，它不只是「发个 HTTP 请求」：一个交互式 agent 会同时有多个模型请求在飞（主对话、子任务、后台压缩……），每个都可能要**取消**、要**重试**，还不能互相踩。采样器就是把这些收在一处的组件。

## 三层设计

采样器把自己劈成三层，各管一件事（`lib.rs` 开头就自述了这个结构）：

| 层 | 类型 | 职责 |
| --- | --- | --- |
| **L1 客户端** | `SamplingClient` | 发请求、解 SSE，吐出「原始类型化 chunk 流」 |
| **L2 流变换** | `stream::*` | 把三种后端的原始 chunk 统一成同一套 `SamplingEvent` |
| **L3 actor** | `SamplerActor` / `SamplerHandle` | 并发、重试、取消、事件协调 |

这一章看 L3（actor），[下一章](https://daiw.org/manual/grok-build/api-streams)看 L2（三种流怎么统一），[再下一章](https://daiw.org/manual/grok-build/streaming-events)看 L1 的 SSE 解析与事件。

## actor：一个命令循环 + 每请求一个 task

`SamplerActor`（`actor/mod.rs`）的骨架非常经典：

- 它自己是**单个 tokio task**，跑一个 `run()` 循环，从一条 `mpsc` 命令通道收命令（`Submit` / `Cancel` / `UpdateConfig` / `IsActive` / `ActiveCount`）。
- 每来一个 `Submit`，它**不自己去跑请求**，而是 `tokio::spawn` 一个 task 丢进 `JoinSet<RequestId>`，让流式工作并发进行。
- 因为所有状态（`active_requests: HashMap`）只在这一个 actor task 里读写，**不需要任何锁**——这就是 actor 模式最舒服的地方：把「共享状态」变成「单线程状态 + 消息传递」。

```mermaid
flowchart LR
  H["SamplerHandle<br/>(可克隆, 只裹 cmd_tx)"] -->|Submit / Cancel| A["SamplerActor<br/>run() 命令循环"]
  A -->|tokio::spawn| J["JoinSet: 每请求一个 task"]
  J -->|SamplingEvent| E["event_tx → 会话/UI"]
  J -->|完成/失败| A
```

循环里有个细节值得学：`tokio::select!` 用了 **`biased`**，优先处理 `JoinSet` 里已完成的 task（把它从 `active_requests` 清掉）再收新命令。顺序颠倒的话，刚结束的请求可能还挂在表里，`active_count()` 就不准了。

## handle：外面怎么用它

其它 crate 不直接碰 actor，只拿一个 `SamplerHandle`（`handle.rs`）。它 `#[derive(Clone)]`、内部只包一个 `cmd_tx`，所以**克隆极便宜**——谁想发请求就克隆一份。actor 在所有 handle 都 drop 后自动退出（命令通道收到 `None`）。

两种用法：

- **发了不管**：`submit(id, request)`、`cancel(id)`——事件走共享的 `event_tx` 通道异步流出。
- **发了等结果**：`submit_and_collect(id, request)` 返回 `CompletionResult`（即 `Result<(ConversationResponse, InferenceLatencyStats), SamplingError>`），源码注释点名的用户是压缩、摘要、`/btw` 旁路提问这类「不需要流式 UI、只要最终结果」的场景。它的加强版 `submit_and_collect_with_metadata` 还会带回这次请求的 doom-loop 信号与恢复记录——主循环每次采样走的就是它，事件照样实时流向 UI。

## 取消：一条 token 串起来

取消是 agent 的刚需（你按 <kbd>Ctrl+C</kbd> 取消当前 turn、或子任务被叫停）。grok 用 `tokio_util::sync::CancellationToken`：

1. `SamplerHandle::cancel(id)` 发 `Cancel` 命令；
2. actor 在 `active_requests` 里找到那个请求的 token，`.cancel()`；
3. 正在跑的 task 在流循环里用 `biased` select **优先**监听 `cancel_token.cancelled()`，一触发就停，产出 `Cancelled`。

还有两个巧思：**同 `RequestId` 重复 `Submit` 会取消旧的**（后来者胜）；`submit_and_collect` 系列（实现在 `submit_and_collect_with_metadata` 里）额外挂一个 RAII 守卫 `CancelOnDrop`——future 被 drop（调用方放弃等待）时自动取消在途请求，不留孤儿。

<Callout type="info">
  **用法照进来**：你加 `--log-sampling`（隐藏参数，也可以设 `GROK_LOG_SAMPLING=1`）时，每个请求的生命周期（重试、耗时、token）会写进 `~/.grok/logs/sampling.jsonl`——事件由这一层的 `sampling_log.rs` 以 tracing 形式打出，再由 `xai-grok-telemetry` 里的专用 layer 落盘。想亲眼看 actor 在忙什么，开它。
</Callout>

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

那本的 [裸 LLM API](https://daiw.org/manual/llm-to-agent/raw-llm-api) 章讲了「一次模型调用要处理哪些事」。grok 把这些事——并发、取消、重试、流式——从会话逻辑里抽出来，做成一个可复用的 actor（它甚至复用了 `xai-hunk-tracker` 的 actor 套路）。这就是「教学最小实现只需一个 `await`，生产实现需要一个 actor」的典型例子。

---

上一篇：[Agent loop 主循环](https://daiw.org/manual/grok-build/agent-loop) · 下一篇：[三种 API 流怎么统一](https://daiw.org/manual/grok-build/api-streams)
