# 一次模型请求：流式、工具并发与恢复

> runModelBackedTurnStep 的内部：请求怎样发出、输出预算怎么算，流式事件怎样被消费与投影，只读工具怎样边流边执行、账本记什么；输出截断后如何续写，断流与取消怎样恢复或落盘，错误如何分类、各层重试几次，用量与缓存命中怎样统计。

- 作者：David（道雾轩）
- 专栏：ZCode 源码解读（https://daiw.net/manual/zcode.md）
- 最后更新：2026-09-21
- 原文：https://daiw.net/manual/zcode/model-step
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

回合循环每转一圈调用一次 `runModelBackedTurnStep`（`apps/zcode-cli/packages/core/src/runtime/methods/turn-model-step.ts:88`），本书把它叫作“模型步”：一次模型请求，对应一条助手消息。它把[上一篇](https://daiw.net/manual/zcode/turn-loop)准备好的消息发出去，边收流边落盘，把完整的工具调用交给执行器，并在截断、断流、超窗、取消时决定续跑、重发还是报错，最后返回 `continue`、`output_continuation` 或 `break` 交回循环（`turn-model-step.ts:86`）。

代码在 `apps/zcode-cli/packages/core/src/runtime` 的 `methods` 与 `helpers` 两处。适配层（Vercel AI SDK 之上的 provider 实现与它自己的重试）只点到交界，内部见[模型适配层](https://daiw.net/manual/zcode/model-adapters)。下表路径相对 `runtime`：

| 文件 | 职责 |
| --- | --- |
| `methods/turn-model-step.ts` | 模型步主体：落盘、请求、结果分类、交给工具执行 |
| `methods/model.ts` | `runModelTextRequest`：组装调用上下文、消费流式事件 |
| `methods/model-streaming-event*.ts`、`reasoning-stream.ts` | 流式事件的有序写队列；思考块归并 |
| `methods/streaming-tool-coordinator.ts`、`streaming-tool-synthetic-result.ts`、`helpers/streaming-tool-ledger.ts` | 边流执行、合成结果、工具账本与恢复锚点 |
| `methods/turn-output-token-continuation.ts` | 输出截断后的续写 |
| `methods/streaming-recovery.ts`、`cancelled-stream-persistence.ts` | 断流恢复；取消时落盘已到达的输出 |
| `helpers/model-errors.ts`、`turn-errors.ts`、`provider-business-error.ts`、`model-tool-call-validation.ts` | 错误识别、分类与归一 |
| `methods/turn-model-step-usage.ts`、`turn-nested-model-usage.ts`、`usage-observability.ts` | 用量、缓存命中与用量表 |
| `methods/model-token-limits.ts`、`runtime-model.ts`、`turn-model.ts` | 输出预算；模型句柄上的调用上下文 |

## 一次模型步的时序

```mermaid
sequenceDiagram
  participant L as 回合循环
  participant S as 模型步
  participant R as runModelTextRequest
  participant A as 适配层 streamText
  participant C as 流式工具协调器
  participant X as 工具执行器
  L->>S: runModelBackedTurnStep(messages, tools)
  S->>S: 落 assistant 消息、step-start、ModelRequest 事件
  S->>R: 算好 maxOutputTokens 后发起
  R->>A: 带调用上下文的 streamText
  A-->>R: text_delta、reasoning_delta
  R-->>R: 写入 ModelStreaming 事件队列
  A-->>R: tool_call（只读工具）
  R->>C: accept(toolCall)
  C->>X: 单独成批，立即执行
  A-->>R: tool_call（其余工具）
  R->>C: accept，只登记
  A-->>R: finish（finishReason、usage）
  R-->>S: RuntimeModelTextResult
  S->>S: 落 reasoning 与 text part，发 ModelComplete，写用量
  S->>C: drain(executableToolCalls)
  C-->>S: 边流执行的结果
  S->>X: executeToolCallsForModelStep 执行其余工具
  X-->>S: 按声明顺序的结果
  S-->>L: continue、output_continuation 或 break
```

## 发请求之前

模型步先落一条空的助手消息和一个 `step-start` part，再发 `ModelRequest` 事件（`turn-model-step.ts:175`、`turn-model-step.ts:193`）。事件里的消息是去掉了续写提示的“可记录”版本，自动续写提示只属于本次请求，不进持久轨迹（`turn-model-step.ts:196`）。

**输出预算**分两步算。基线取模型声明的输出上限，模型没声明时用 32000（`apps/zcode-cli/packages/core/src/runtime/methods/model-token-limits.ts:5`、`model-token-limits.ts:7`）；再按剩余窗口封顶，取基线与“上下文窗口减去当前输入估算再减 1000”中较小的一个，窗口未知或算出来不是正数就保留基线（`model-token-limits.ts:34`）。当前输入的估算以最近一条已提交助手消息的 provider 用量为基线，加上其后消息的本地估算，找不到基线才全部本地估算（`apps/zcode-cli/packages/core/src/runtime/methods/compact.ts:313`、`compact.ts:342`）。运行时配置 `modelContextBudgetStrategy` 的 `legacy` 取值只为入参兼容保留，所有请求都走这套封顶（`model-token-limits.ts:23`）。

**调用上下文**分三层。`createRuntimeModel` 在建句柄时绑定重试预算与准入端口，这样工具内部的模型调用、压缩、标题生成也都受同一道闸门约束（`apps/zcode-cli/packages/core/src/runtime/methods/runtime-model.ts:32`）；工作流子会话拿无上限的重试预算，其余沿用适配层默认（`apps/zcode-cli/packages/core/src/runtime/methods/model-request-session-type.ts:21`）。`createTurnModel` 再挂一个“每次尝试前刷新运行时请求头”的回调（`apps/zcode-cli/packages/core/src/runtime/methods/turn-model.ts:27`）。`runModelTextRequest` 最后补上本次调用的语义：`operation` 为 `agent_step`，断流恢复发起的请求标 `callCause: "recovery"`，状态汇把网络状态转成 `ModelNetworkStatus` 事件，并把恢复序号传下去以放宽空闲超时（`apps/zcode-cli/packages/core/src/runtime/methods/model.ts:80`）。

发出前，消息里的图片、PDF 等附件还会按模型支持的输入格式与媒体预算改写（`model.ts:53`、`model.ts:62`）。运行时配置 `modelStreaming` 不是 `on` 时走非流式的 `generateText`（`model.ts:141`），CLI 的 `-p` 与 TUI 都显式设为 `on`（`apps/zcode-cli/packages/cli/src/prompt-command.ts:233`、`apps/zcode-cli/packages/cli/src/tui-prompt-handler.ts:163`），下文只讲流式路径。

## 流式事件：消费与投影

适配层产出的事件类型定义在 `apps/zcode-cli/packages/contracts/src/model/index.ts:715`。`runModelTextRequest` 用一个 `for await` 逐个消费（`model.ts:233`），一边累加结果，一边把大部分事件投影成 `ModelStreaming` 会话事件（载荷见 `apps/zcode-cli/packages/contracts/src/events/session.events.ts:707`）：

| 适配层事件 | 怎样消费 | 出处 |
| --- | --- | --- |
| `start`、`text_start`、`text_end` | 原样投影 | `model.ts:235` |
| `text_delta` | 累加正文，通知协调器计字节，刷新快照 | `model.ts:255` |
| `reasoning_start`、`reasoning_delta`、`reasoning_end` | 按 id 归并思考块；有的 provider 不发 start，没有 id 的增量落到默认块 | `model.ts:278`、`apps/zcode-cli/packages/core/src/runtime/methods/reasoning-stream.ts:3` |
| `tool_input_start`、`tool_input_delta`、`tool_input_end` | 参数增量按调用缓冲，遇到真实或 JSON 转义的换行、攒满 4096 个字符或参数结束时刷出 | `model.ts:28`、`model.ts:211` |
| `tool_call` | 归一工具名、按 id 去重，排空写队列后交给协调器 | `model.ts:378`、`model.ts:390` |
| `finish` | 记下 `finishReason`、`usage`、`providerMetadata` | `model.ts:423` |
| `error` | 排空写队列后抛出归一化的错误，保留业务错误码 | `model.ts:437` |
| `compact_stream_boundary` | 不处理，只供压缩回放 | `model/index.ts:721` |

参数增量遇换行就刷，是为了让 Write、Edit 的行数尽快出现在界面上（`model.ts:221`）。写事件则经过一条有序写队列，而不是每个 token 同步落库（`apps/zcode-cli/packages/core/src/runtime/methods/model-streaming-event-queue.ts:39`）：

```ts
    enqueue(payload: ModelStreamingPayload): void {
      assertNoWriteFailure();
      pendingWrites += 1;
      // 逐个 token 同步 append 会让 provider SSE reader 停在
      // iterator.next() 之外，已经到达的帧要等落库/通知完成后才被消费。
      // 这里把 append 串成有序写队列，读取侧继续 drain provider 队列；
      // finish / error / tool_call 边界再显式 drain，保持原有顺序语义。
      tail = tail
        .then(async () => {
          if (writeFailure) {
            return;
          }
          await params.runtime.emitModelStreamingEvent(payload, params.traceContext, params.events);
        })
        .catch((error: unknown) => {
          writeFailure ??= error;
        })
        .finally(() => {
          pendingWrites -= 1;
        });
    },
```

积压的写达到 128 条时，读取侧先等队列排空，形成背压（`model-streaming-event-queue.ts:4`、`model-streaming-event-queue.ts:61`）；任何一次写失败都会在下一次入队时抛出。

流结束后还有两道检查。结束原因表明上下文超窗时只记日志、照常返回：这里提前抛错会被通用的断流恢复接管，绕过回合层的被动压缩（`model.ts:463`）。没有文本、没有工具调用、结束原因既不是 `stop` 也不是 `tool-calls`、用量又是 0 的“可疑空结果”则直接抛错（`model.ts:481`），分类见后文。

## 边流边执行：流式工具协调器

每个定稿的 `tool_call` 都会交给协调器的 `accept`（`turn-model-step.ts:258`、`apps/zcode-cli/packages/core/src/runtime/methods/streaming-tool-coordinator.ts:73`）。provider 自己执行的调用直接跳过，其余先登记，再看能不能不等流结束就开跑（`streaming-tool-coordinator.ts:339`）：

```ts
function shouldExecuteToolDuringStream(
  runtime: AgentRuntimeInternal,
  toolCall: ModelToolCall,
): boolean {
  if (toolCall.providerExecuted) return false;
  if (toolCall.name.trim().length === 0) return false;
  if (runtime.config.modelStreaming !== "on") return false;
  if ((runtime.config.streamingToolExecution ?? STREAMING_TOOL_EXECUTION_MODE) === "off") {
    return false;
  }
  const entry = runtime.registry.get(toolCall.name);
  if (!entry) return false;
  const metadata = entry.metadata;
  const sideEffectScope = entry.permission?.sideEffectScope ?? metadata.sideEffectScope;
  const requiresUserInteraction = entry.requiresUserInteraction ?? metadata.requiresUserInteraction;
  return (
    metadata.readOnly &&
    metadata.concurrentSafe &&
    !metadata.destructive &&
    !metadata.needsApproval &&
    !requiresUserInteraction &&
    sideEffectScope === "none"
  );
}
```

`streamingToolExecution` 只有 `off` 与 `readOnly` 两个值，缺省 `readOnly`（`streaming-tool-coordinator.ts:39`、`apps/zcode-cli/packages/core/src/runtime/types.ts:128`）。按工具声明，`Read`、`Grep`、`Glob` 这类满足条件（`apps/zcode-cli/packages/core/src/tool/handlers/read.ts:470`）；`WebFetch`、`WebSearch` 虽只读但副作用范围是 `network`（`apps/zcode-cli/packages/core/src/tool/handlers/webfetch.ts:206`、`apps/zcode-cli/packages/core/src/tool/handlers/websearch.ts:144`），`Bash` 注册时 `readOnly` 为 `false`（`apps/zcode-cli/packages/core/src/tool/handlers/bash.ts:451`），都要等流结束。工具声明的各个字段见[工具契约、注册表与可见性](https://daiw.net/manual/zcode/tool-contract)。

并发规则因此很简单：

- **合格的调用一到就单独成批。** `executeDuringStream` 先落 `pending` part，声明序号取它在已登记调用里的位置，再 `scheduleTools([toolCall])` 生成只含一项的计划交给执行器（`streaming-tool-coordinator.ts:85`、`streaming-tool-coordinator.ts:271`）。它与仍在进行的流并行，多个合格调用之间是否重叠只取决于到达时机，不经过同一个批次。
- **其余调用等流结束再成批。** 它们在模型步末尾由 `executeToolCallsForModelStep` 一次交给执行器，由调度器按只读、并发安全等声明分组（见[执行器：调度、审批、超时与结果](https://daiw.net/manual/zcode/tool-executor)）。
- **合并按声明顺序。** `drain` 按声明顺序等待所有边流句柄（`streaming-tool-coordinator.ts:128`），结果和其余调用的结果一起按声明顺序回填历史，模型看到的顺序与它写出的一致。
- **失败就退回。** 边流执行本身出错时记一条日志，这个调用退回流结束后的常规执行（`streaming-tool-coordinator.ts:89`）。模型请求失败或被取消时，`abandon` 中止所有边流句柄、写账本，最多再等 250 毫秒（`streaming-tool-coordinator.ts:38`、`streaming-tool-coordinator.ts:103`）。

### 账本与锚点

工具从定稿到结果提交的每一步都写一条 `StreamingToolLedgerUpdated` 事件（`apps/zcode-cli/packages/core/src/runtime/helpers/streaming-tool-ledger.ts:61`），载荷带工具的只读、破坏性、并发安全与副作用范围，以及 `executionTiming`（`during_stream` 或 `end_of_stream`）。状态取值定义在 `apps/zcode-cli/packages/contracts/src/events/stream-recovery.events.ts:6`：

| 状态 | 含义 |
| --- | --- |
| `tool_call_closed` | 调用已定稿，已落 `pending` part |
| `tool_queued` | 已排进执行计划 |
| `tool_started` | 所在批次开始执行，part 改为 `running` |
| `tool_result_committed` | 结果已写回历史，带提交时间、结果 part 与恢复锚点 ID |
| `tool_cancelled`、`tool_abandoned` | 取消，或模型请求失败后放弃 |
| `tool_input_streaming`、`recovery_blocked` | 契约里有，但非测试代码没有任何地方产出；`StreamRecoveryBlocked` 事件同样没有产出方 |

每提交一个结果，还会写一个恢复锚点 `StreamRecoveryAnchorCreated`，ID 形如 `${assistantMessageId}:${toolCallId}:tool-result`（`streaming-tool-ledger.ts:54`、`streaming-tool-ledger.ts:92`）。锚点标记“到这里为止的历史可以安全重放”，断流恢复就从最近的锚点接着请求。账本的 `attemptId` 不论时机都是 `${assistantMessageId}:end-of-stream`（`streaming-tool-ledger.ts:50`）。

## 输出被截断：自动续写

分类在 `apps/zcode-cli/packages/core/src/runtime/methods/turn-output-token-continuation.ts:12`：

```ts
const OUTPUT_TOKEN_CONTINUE_PROMPT =
  "Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces.";

export const OUTPUT_TOKEN_LIMIT_ERROR_MESSAGE =
  "The model's response exceeded the output token maximum.";

const MAX_OUTPUT_TOKEN_CONTINUATIONS = 3;
const OUTPUT_LIMIT_RAW_REASONS = new Set([
  "max_tokens",
  "max_output_tokens",
  "model_context_window_exceeded",
]);

type OutputTokenContinuationDecision = "continue" | "exhausted" | "none";

export function classifyOutputTokenContinuation(input: {
  finishReason: string | undefined;
  rawFinishReason: string | undefined;
  toolCallCount: number;
  continuationCount: number;
}): OutputTokenContinuationDecision {
  if (input.toolCallCount > 0) return "none";
  if (!isOutputTokenLimitFinishReason(input.finishReason, input.rawFinishReason)) {
    return "none";
  }
  return input.continuationCount < MAX_OUTPUT_TOKEN_CONTINUATIONS ? "continue" : "exhausted";
}
```

有工具调用时不续写。结束原因为 `length`，或 provider 原始原因落在上面三个值里，就算截断；成功响应带 `model_context_window_exceeded` 也归入续写，注释说明这是有意为之：输入没超窗、只是输入加输出填满了窗口，不等于请求失败，不该抢先触发被动压缩（`turn-output-token-continuation.ts:44`）。续写的过程：

1. 结束原因统一改记为 `length`（`turn-model-step.ts:550`），这段半截回答作为一条独立的助手消息提交、落盘（`turn-model-step.ts:651`）。
2. 把续写提示作为 user 条目追加进回合内请求历史，它带 `queryScope` 标记，不进规范历史、不落库（`turn-output-token-continuation.ts:139`）；返回 `output_continuation`（`turn-model-step.ts:664`）。
3. 下一轮循环照常做 microcompact 与自动压缩，但跳过吸收运行时命令和几类提醒（`apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts:49`），模型接着上文往下写。
4. 次数在一次正常结束、或断流恢复提交了工具结果时清零（`turn-model-step.ts:707`、`turn-model-step.ts:284`）。第 4 次仍被截断，就另落一条“错误载体”助手消息，与半截输出分成两条，压缩时才能重放半截输出、排除这条只供展示的错误（`apps/zcode-cli/packages/core/src/runtime/methods/turn-stop.ts:120`），然后抛出可恢复的 `ModelError`，`providerCode` 为 `model_output_limit_exceeded`（`turn-model-step.ts:667`）。

所谓“拼接”并不发生在字符串层面：每一段都是一条单独的助手消息，按顺序排在历史里。代价是 `state.modelResponse` 每步都被覆盖（`turn-model-step.ts:444`），`TurnComplete.response` 只含最后一段；从代码看，`-p` 文本模式打印的正是它（`prompt-command.ts:330`、`prompt-command.ts:416`），续写过的回答只会打出最后一段，见[命令行入口、无头模式与打包](https://daiw.net/manual/zcode/cli-surface)。

## 断流、繁忙与取消

重试分两层。适配层先把 `start`、`tool_input_*`、空增量这类“前奏”事件暂存，只要还没吐出真正的正文、思考或完整工具调用，失败就能透明重试（`apps/zcode-cli/packages/adapters/src/model/stream-retry-boundary.ts:6`）；默认重试 10 次，间隔 2 秒起、每次翻倍、封顶 60 秒并加抖动，可用 `ZCODE_MODEL_RETRY_MAX_RETRIES` 等环境变量调整（`apps/zcode-cli/packages/adapters/src/model/retry-policy.ts:13`、`retry-policy.ts:18`）。越过这条边界之后，就轮到 core 的断流恢复，每个回合最多 10 次（`apps/zcode-cli/packages/core/src/runtime/methods/streaming-recovery.ts:14`）。模型步的 `catch` 按下图决策（`turn-model-step.ts:264`）：

```mermaid
flowchart TD
  E["模型请求抛错"] --> B{"未取消且本回合恢复不足 10 次？"}
  B -->|是| T{"已收到完整 tool_call？"}
  B -->|否| P{"Start Plan 繁忙且可重试？"}
  T -->|是| TR["保留已完成结果，其余合成失败结果，从最新工具结果锚点续跑"]
  T -->|否| Q{"已输出正文或思考，且错误属瞬态？"}
  Q -->|是| QR["作废半截输出，从上一条消息锚点重发"]
  Q -->|否| P
  P -->|是| PR["关闭空 assistant，等 1 秒或 2 秒后重发"]
  P -->|否| C{"用户取消？"}
  C -->|是| CS["落盘已到达的正文与思考，按取消收尾"]
  C -->|否| X{"上下文超窗？"}
  X -->|是| RC["被动压缩，成功则重跑本步"]
  X -->|否| F["给 assistant 写错误并抛出"]
  RC -->|失败| F
```

**已有工具调用定稿**时不看错误类型，直接进入工具恢复（`streaming-tool-coordinator.ts:169`）：

```ts
      abortController.abort();
      const recoveryAttempt = beginStreamRecoveryAttempt(state);
      const toolCalls = Array.from(acceptedToolCalls.values());
      const settledResults = await collectCompletedResults(handles, toolCalls);
      const settledResultById = new Map(
        settledResults.map((result) => [result.toolCallId, result]),
      );
      const streamedToolResults = toolCalls.map(
        (toolCall) =>
          settledResultById.get(toolCall.id as ToolCallId) ??
          createSyntheticStreamedToolResult(
            toolCall,
            handles.has(toolCall.id) ? "unknown_execution_state" : "not_executed",
          ),
      );
      await emitStreamRecoveryStarted(runtime, state, recoveryEventOptions, error, recoveryAttempt);
      state.modelResponse = "";
      state.modelStepCount += 1;
      recordModelHistoryRound(state);
      state.toolCallCount += streamedToolResults.length;
      // 合并修复：恢复请求依赖 assistant tool-call 与随后 tool result 成对出现。
      // 因此必须同步推进本轮 request history，不能只更新 canonical history。
      commitTurnRequestEntries(runtime, state.turnRequestState, [
        createRuntimeAssistantEntry("", toolCalls, undefined, options.model),
      ]);
      state.turnMachine = new TurnMachineImpl(state.turnMachine.receiveModelResponse(""));
```

边流执行已完成的结果（每个最多再等 250 毫秒）原样保留；其余调用得到合成的失败结果（`apps/zcode-cli/packages/core/src/runtime/methods/streaming-tool-synthetic-result.ts:15`）：从没开跑的，告诉模型“按失败处理，不要盲目重试”；已开跑但没交结果的，告诉模型“副作用未知，重试前先检查现状”。流里已吐出的正文被丢弃，只保留工具调用；这些结果随后按常规路径回填历史，下一次请求带着恢复状态发出。

**没有工具调用、但已吐出正文或思考**时，只有错误属于瞬态（错误自带 `retryable`，或错误码、原因、消息表明是超时、限流、服务端错误、网络错误、流空闲超时）才恢复（`streaming-recovery.ts:223`、`streaming-recovery.ts:297`）。思考增量同样算输出，因为它为了实时展示也越过了适配层的重试边界（`streaming-recovery.ts:230`）。半截输出所在的助手消息被标成 `StreamRecoveryDiscarded`（`streaming-recovery.ts:242`），同一请求从上一条消息的锚点重发。两种恢复都先发 `StreamRecoveryStarted`，再依次发出选定锚点、丢弃尾部、开始重试三个事件，并把来源请求 ID 挂到下一次 `model_request_started` 上（`streaming-recovery.ts:140`、`streaming-recovery.ts:198`）。适配层还据恢复序号放宽流空闲超时：基准是 `modelStream.idleTimeoutMs`，默认 600000 毫秒（`apps/zcode-cli/packages/contracts/src/config/index.ts:284`），每多恢复一次加 30000 毫秒（`apps/zcode-cli/packages/adapters/src/model/stream-idle-timeout.ts:23`）。

**Start Plan 繁忙**是给 Start Plan 账号开的特例：provider 为 `account:bigmodel-start-plan` 或 `account:zai-start-plan`、业务码为 3008、3009、3010 之一、且不是会话的第一个回合时，首 token 前被并发限制拒绝可以再试（`streaming-recovery.ts:16`、`streaming-recovery.ts:103`）。等待时长按与断流恢复共用的计数取：计数为 0、1 时分别等 1000、2000 毫秒，到 2 以后不再等；此后再遇到这类繁忙错误，只要本回合恢复或重试过，就改报为“自动重试已达上限”（`turn-model-step.ts:356`、`streaming-recovery.ts:66`），界面横幅显示“当前系统繁忙，当前自动重试已达到最大次数”（`packages/ui/src/i18n/locales/zh-CN.ts:5210`）。界面靠比对错误消息原文识别这种情况（`packages/ui/src/lib/providerBusinessError.ts:227`），而 `apps/zcode-cli/AGENTS.md:92` 要求不依赖错误文本做流程判断。套餐本身见[账号、Coding Plan 与闲时计划](https://daiw.net/manual/zcode/accounts-plans)。

**用户取消**时，成功路径上的落盘不会执行，`persistCancelledStreamSnapshot` 把已到达本进程的思考与正文补写成 part（`apps/zcode-cli/packages/core/src/runtime/methods/cancelled-stream-persistence.ts:7`），同时把这段半截输出提交进内存历史，保证当前进程与冷恢复看到的历史一致（`turn-model-step.ts:378`）；助手消息的错误里记 `turnResult: "cancelled"`，冷恢复据此区分用户停止与真实失败（`turn-model-step.ts:420`）。回合最终发的是 `resultType` 为 `cancelled` 的 `TurnComplete`。

界面上，适配层重试与 core 恢复都投影成协议快照的 `control.apiRetry`（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/product-projection.ts:2378`），桌面端输入栏显示“重新连接中... n/N”（`zh-CN.ts:4143`）；丢弃尾部的事件会把半截的输出行标为中断，恢复出来的流另起新行（`product-projection.ts:2391`）。

## 错误分类

回合层的归类入口是 `createTurnFailureError`（`apps/zcode-cli/packages/core/src/runtime/helpers/turn-errors.ts:88`）。识别都顺着 `cause` 链最多查 7 层，看类型、错误码与原因字段，部分判断也匹配消息文本。表中“可恢复、可重试”对应错误上的 `recoverable` 与 `retryable`，未显式设置时两者都是 `false`（`apps/zcode-cli/packages/contracts/src/errors/index.ts:88`）：

| 情形 | 归为 | 可恢复、可重试 | 出处 |
| --- | --- | --- | --- |
| 中止信号已触发，或 `AbortError`、`MODEL_REQUEST_CANCELLED` 等 | `TurnCancelled` | 是、否 | `turn-errors.ts:218` |
| 类型、码或原因命中 `context_length_exceeded`、`prompt_too_long` 等标记，或消息含“context 超出”一类字样；结束原因带超窗标记 | `ModelContextExceeded` | 是、是 | `apps/zcode-cli/packages/core/src/runtime/helpers/model-errors.ts:202`、`model-errors.ts:304`、`model-errors.ts:26` |
| 快速回填熔断 | `ModelContextExceeded`，原因 `compact_rapid_refill_breaker` | 是、是 | `model-errors.ts:44` |
| provider 业务错误：`success` 为 `false` 或业务码非零 | `ModelError`，带 `providerCode` | 是、否 | `apps/zcode-cli/packages/core/src/runtime/helpers/provider-business-error.ts:40`、`provider-business-error.ts:61` |
| 可疑空结果；若 `providerMetadata` 里藏着业务错误则改报业务错误 | `ModelError`，原因 `empty_model_response` | 是、是 | `model-errors.ts:67`、`model-errors.ts:126` |
| 工具名为空且无法闭合 | `ModelError` | 是、否 | `apps/zcode-cli/packages/core/src/runtime/helpers/model-tool-call-validation.ts:82` |
| 续写耗尽 | `ModelError`，`model_output_limit_exceeded` | 是、否 | `turn-model-step.ts:667` |
| 宿主以专用标记中止回合 | `UnknownError`，事件里用宿主给的码，发 `TurnError` 而不按取消处理 | 是、否 | `turn-errors.ts:33`、`turn-errors.ts:93` |
| 其他 | `UnknownError` 包装，消息为 `Turn execution failed` | 否、否 | `turn-errors.ts:120` |

几条补充：

- 除了上一节的断流与繁忙重试，超窗是模型步里唯一就地恢复的错误：每个模型步最多被动压缩一次，成功后重建状态机重跑（`turn-model-step.ts:749`、`turn-model-step.ts:793`）；这个标记在一个工具批次完成或进入续写时复位（`apps/zcode-cli/packages/core/src/runtime/methods/turn-loop-state.ts:182`、`turn-model-step.ts:662`）。压缩本身见[上下文压缩](https://daiw.net/manual/zcode/compaction)。
- 空工具名不一定报错：只要调用带着合法 ID、又不是 provider 执行的，就原样保留空名，让执行器走“注册表查无此工具”的路径给模型一个可恢复的结果（`model-tool-call-validation.ts:68`）。
- 异常检测方面，`ModelAnomalyWarning` 的类别有四个（`session.events.ts:721`），实际产出的只有[上一篇](https://daiw.net/manual/zcode/turn-loop)讲的 `tool_call_budget` 与 `repeated_tool_call`，`provider_finish_mismatch`、`malformed_tool_call` 没有产出方。provider 在结果里报告的模型身份也不采信，助手消息一律归到本轮绑定的模型（`turn-output-token-continuation.ts:118`）。

## 用量与缓存命中

模型步成功后发一条 `ModelComplete`（`turn-model-step.ts:595`，载荷见 `session.events.ts:761`）：正文、结束原因、用量与工具调用数之外，主会话还带上下文窗口大小，免得长程任务里输入栏的上下文计量条拿不到分母而隐藏（`turn-model-step.ts:599`）；主会话与子 Agent 的步骤在没有工具调用时附带本回合的文件变更摘要（`turn-model-step.ts:590`）；主会话还带缓存命中统计（`apps/zcode-cli/packages/core/src/runtime/methods/turn-model-step-usage.ts:126`）：

```ts
  // AI SDK v6 已把 Anthropic cache read/write 并入 inputTokens；
  // 缓存命中率的分母应使用 total input，不能再把 cache 字段重复加到分母或上下文用量里。
  const inputTokens = modelUsageInputWindowTokens(usage) ?? 0;
  const cacheReadTokens = nonNegativeInteger(usage.cacheReadTokens) ?? 0;
  const cacheWriteTokens = nonNegativeInteger(usage.cacheWriteTokens) ?? 0;
  // ...
  const aggregate = runtime.mainTurnCacheHitAggregate;
  return {
    inputTokens,
    cacheReadTokens,
    cacheWriteTokens,
    latestHitRate: inputTokens > 0 ? cacheReadTokens / inputTokens : null,
    hitRate:
      aggregate.totalInputTokens > 0
        ? aggregate.totalCacheReadTokens / aggregate.totalInputTokens
        : null,
```

`latestHitRate` 是本次请求的命中率，`hitRate` 是会话累计值。累计数挂在运行时上，恢复会话或回退时从落库的助手消息 tokens 重算，压缩摘要不计入（`turn-model-step-usage.ts:66`、`apps/zcode-cli/packages/core/src/runtime/methods/resume.ts:220`）。另有一份更粗的缓存状态：回合开始时置为未命中，某步读到缓存就置为命中，随 `TurnComplete` 的 `cacheStats` 发出（`apps/zcode-cli/packages/core/src/runtime/methods/turn.ts:556`、`turn-model-step.ts:448`）。

回合用量等于本回合所有 `ModelComplete` 的用量之和（`session.events.ts:1155`）。工具内部也会调模型，结果里带 `modelUsage` 的，额外补发一条结束原因为 `tool_internal` 的 `ModelComplete`，一并计入（`apps/zcode-cli/packages/core/src/runtime/methods/turn-nested-model-usage.ts:18`）。落库方面，每次模型请求、每个回合、每个工具调用各写一行，分别进 `model_usage`、`turn_usage`、`tool_usage` 三张表（`apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/usage.ts:32`、`usage.ts:169`、`usage.ts:262`，表结构见[SQLite 会话库](https://daiw.net/manual/zcode/session-store)）。模型请求那一行的首 token 时间取第一个非空的正文或思考增量，重试次数数的是 `model_retry_scheduled` 状态事件（`apps/zcode-cli/packages/core/src/runtime/methods/usage-observability.ts:65`、`usage-observability.ts:373`）；归属的模型取模型步开始时的快照，运行中切模不会把旧请求记到新模型头上（`turn-model-step-usage.ts:33`）。这些写入都是尽力而为，失败只记警告（`usage-observability.ts:120`）。

下一篇：[会话事件流与持久化投影](https://daiw.net/manual/zcode/session-events)——这些 `ModelStreaming`、`ModelComplete`、账本与恢复事件，怎样被投影成消息与 part，落进会话库。
