一次模型请求:流式、工具并发与恢复
runModelBackedTurnStep 的内部:请求怎样发出、输出预算怎么算,流式事件怎样被消费与投影,只读工具怎样边流边执行、账本记什么;输出截断后如何续写,断流与取消怎样恢复或落盘,错误如何分类、各层重试几次,用量与缓存命中怎样统计。
回合循环每转一圈调用一次 runModelBackedTurnStep(apps/zcode-cli/packages/core/src/runtime/methods/turn-model-step.ts:88),本书把它叫作“模型步”:一次模型请求,对应一条助手消息。它把上一篇准备好的消息发出去,边收流边落盘,把完整的工具调用交给执行器,并在截断、断流、超窗、取消时决定续跑、重发还是报错,最后返回 continue、output_continuation 或 break 交回循环(turn-model-step.ts:86)。
代码在 apps/zcode-cli/packages/core/src/runtime 的 methods 与 helpers 两处。适配层(Vercel AI SDK 之上的 provider 实现与它自己的重试)只点到交界,内部见模型适配层。下表路径相对 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 | 输出预算;模型句柄上的调用上下文 |
一次模型步的时序
发请求之前
模型步先落一条空的助手消息和一个 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):
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):
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),都要等流结束。工具声明的各个字段见工具契约、注册表与可见性。
并发规则因此很简单:
- 合格的调用一到就单独成批。
executeDuringStream先落pendingpart,声明序号取它在已登记调用里的位置,再scheduleTools([toolCall])生成只含一项的计划交给执行器(streaming-tool-coordinator.ts:85、streaming-tool-coordinator.ts:271)。它与仍在进行的流并行,多个合格调用之间是否重叠只取决于到达时机,不经过同一个批次。 - 其余调用等流结束再成批。 它们在模型步末尾由
executeToolCallsForModelStep一次交给执行器,由调度器按只读、并发安全等声明分组(见执行器:调度、审批、超时与结果)。 - 合并按声明顺序。
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:
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)。续写的过程:
- 结束原因统一改记为
length(turn-model-step.ts:550),这段半截回答作为一条独立的助手消息提交、落盘(turn-model-step.ts:651)。 - 把续写提示作为 user 条目追加进回合内请求历史,它带
queryScope标记,不进规范历史、不落库(turn-output-token-continuation.ts:139);返回output_continuation(turn-model-step.ts:664)。 - 下一轮循环照常做 microcompact 与自动压缩,但跳过吸收运行时命令和几类提醒(
apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts:49),模型接着上文往下写。 - 次数在一次正常结束、或断流恢复提交了工具结果时清零(
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),续写过的回答只会打出最后一段,见命令行入口、无头模式与打包。
断流、繁忙与取消
重试分两层。适配层先把 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):
已有工具调用定稿时不看错误类型,直接进入工具恢复(streaming-tool-coordinator.ts:169):
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 与闲时计划。
用户取消时,成功路径上的落盘不会执行,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)。压缩本身见上下文压缩。 - 空工具名不一定报错:只要调用带着合法 ID、又不是 provider 执行的,就原样保留空名,让执行器走“注册表查无此工具”的路径给模型一个可恢复的结果(
model-tool-call-validation.ts:68)。 - 异常检测方面,
ModelAnomalyWarning的类别有四个(session.events.ts:721),实际产出的只有上一篇讲的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):
// 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 会话库)。模型请求那一行的首 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)。
下一篇:会话事件流与持久化投影——这些 ModelStreaming、ModelComplete、账本与恢复事件,怎样被投影成消息与 part,落进会话库。