# 常见问题

使用中遇到的问题与历史修复记录。返回 [README](./README.md)。

**国际版页面提示「QODERCLI_PATH 指向了另一版本的 CLI」？** 这是有意的隔离。部分 CN 安装会在
环境里设置 `QODERCLI_PATH`，而两个官方 SDK 都会在读取任何参数之前先看这个变量。如果放行，国际版
会话会启动 `qoderclicn`、拿 CN 账号作答——模型目录和额度都会串台。插件因此只在它不指向另一版本
时采用；国际版自己的覆盖变量是 `QODER_CLI_PATH`。要么装好 `qodercli`（插件会优先用它），要么把
该变量指向国际版 CLI。

**额度卡片显示「无法读取额度」？** 卡片里的文字就是原因：CLI 未安装 / 未登录 / 额度接口暂时
不可用。额度读取走的是 CLI 自己的凭证，所以先确认对应版本的 `status` 能列出用户名；点「刷新额度」
可重试。它失败不会影响模型列表和对话。

**缓存命中一直显示 0%？** v1.0.5 已修。根因不在映射：CLI 每轮都算出了真实用量，但在把用量帧
送出来之前会**主动清零四个 token 计数**（`input_tokens` / `output_tokens` /
`cache_read_input_tokens` / `cache_creation_input_tokens`），只留 `credits` 与
`context_usage_ratio`；它自带一个开关（env 目录里的 `EXPOSE_TOKEN_USAGE`），打开后才原样送出。
[`src/cli.ts`](./src/cli.ts) 现在把这个开关随子进程环境一起交给 CLI——CN 包读
`QODERCN_EXPOSE_TOKEN_USAGE`，国际包读 `QODER_EXPOSE_TOKEN_USAGE`，两个都设，因为由哪个包应答
是查找决定的。

实测（本插件跑通，同一段前缀连续两轮）：

| 轮 | CLI 报的 `input_tokens` | 其中 `cache_read` | 插件上报 | 界面 |
|---|---|---|---|---|
| 1 | 9 811 | 0 | 输入 9 811 / 输出 860 / 缓存 0 | 命中 0% |
| 2 | 10 686 | 9 805 | 输入 881 / 缓存读 9 805 / 输出 100 | 命中 92% |

第二轮那个 `input_tokens` 是**整个 prompt**（CLI 自己的 `context_usage_ratio` 就是
`input_tokens ÷ 窗口`），缓存的那部分含在里面，所以 [`meteredUsage()`](./src/session.ts) 把两个
cache 桶从 `inputTokens` 里减出去——DSH 的四个桶互不重叠，不减等于把缓存前缀算两遍。四个桶（含 0）
与 `totalTokens` 也必须齐全：harness 只在记账完整时展示每轮用量行，少一个桶又没有 `totalTokens`
佐证，整轮的用量详情都不显示。

**哪个用量帧能信？** CLI 会把用量记录挂在**好几个**帧上，
而只有一个是真的属于「这一次请求」：`message_delta`，即每次 provider 请求结束时发出的那一帧。
其余三个都是坑——`message_start` 与组装后的 `assistant` 消息里的四个计数**恒为 0**（请求没结束前
没有数）；`result` 帧则是**内层整轮的总和**，把这个回合里每一次 provider 请求的用量全部相加。

一个宿主 step 只对应一次 provider 请求，但内层的一"轮"要等模型不再调用工具才结束——宿主工具每往返
一次就是一次新请求，全部发生在同一个内层回合里。所以 `result` 帧的数在长 agentic 运行后会是真实
prompt 的若干倍：实测一个让模型连调四次工具的回合，各请求分别用 608/705/771/837，`result` 帧报
**2 921**。若把这个数当成当前上下文，长跑结束后 DSH 的上下文计量会被顶到
`~9.8M / 1M`（100%）并一直挂着，直到下一次取样才回正（开发中确实这么踩过一次）。

现在占用样本只由 [`requestUsageSample()`](./src/session.ts) 提供：仅接受带真实计数（至少一个桶 > 0）
的 `stream_event` 帧，`assistant` 与 `result` 帧一律不采信。`contextUsageRatio` 仍是每请求的
prompt 比率，所以回退路径不受影响。

仍然退回估算的只有三种情况：CLI 版本早于这个开关、SDK 回退到进程内 worker transport（拿不到自定义
环境）、某一轮在结果帧之前就结束了。那时 `usage()` 按「真实用量帧 → CLI 的 context 比率 → 字符
估算」三级回退，上游一旦有数就立刻切过去。

**上下文占用是按什么算的？** 拿到真实计量时就是 provider 自己数的**最后一次请求**的 prompt：
`inputTokens + cacheRead + cacheWrite`，除以窗口。拿不到时，上游每帧仍带 `context_usage_ratio`
——它自己量出的「上下文用了多少」——本插件把它乘上窗口换成 token，再交给 DSH 的计量环。

这条回退路径上有个坑（v1.0.1 踩过、v1.0.2 修）：**这个比率的分母是 CLI 实际运行的窗口，
不是你配置的窗口**。
CLI 只接受模型自己声明的窗口（见目录里的 `availableContextWindows`），配一个它不支持的窗口会被
**静默忽略**——例如给只支持 200K 的 `auto` 配 1M，CLI 仍按 200K 跑，若拿 1M 去乘就虚高 5 倍。
所以插件现在分两个窗口：给 DSH 的是你配置的值，给比率做分母的是 `cliContextWindow()`
（模型声明里匹配得上就用你的，匹配不上就用模型自己的）。同时窗口会经
`resolveModel.parameters.contextWindow` 真正下发给 CLI——在此之前它只告诉了 DSH，CLI 一直按默认
200K 跑，配了 1M 也白配。

比率本身是**可信的**：实测在 257/1007/2257 字符三档下，`比率 × 窗口` 与 CLI 自报的
`getContextUsage().usedPercentage` 在量化误差内一致，且随内容线性增长。注意 DSH 环上的总数
（`inputTokens + cacheRead + cacheWrite`）**包含 qodercli 自身的开销**——它自己的系统提示、skills
与工具定义，约 120–200 token。环里「系统提示词 / 工具定义 / 对话消息」三项是 DSH 自己按
`4 字符/token` 估的**本地分类**，合计会低于总数，这是正常的，不是虚高。

**tok/s 为什么偏低？** DSH 算 `tok/s = 输出 token ÷ 解码耗时`，而输出 token 由本插件上报。
v1.0.5 起这个数是 provider 自己数的 `output_tokens`（同一个计量开关），所以下面的估算修正只在拿不到
计量时生效——**凡是模型生成的内容都必须计入**，估算那一侧曾是两个原因，v1.0.3 与 v1.0.4 各修一个：

1. **换算率**（v1.0.3）。实测本后端的分词器：**中文 1.77 字符/token、英文 5.20 字符/token**，
   而原先统一按 `4 字符/token` 估，中文因此被低估 **2.26 倍**。现在按字符集分别计价。
2. **工具调用参数**（v1.0.4）。原先只统计正文与思考，**工具调用的 name/arguments 一个字符都没算**，
   而 agentic 回合里输出的大头正是它——实测一个三次工具调用的回合中占 **86%** 的字符。DSH 自己的
   计量器是把工具调用计价为 `name + arguments` 的（`dsh-token-meter` 的 `estimateContent`），
   漏掉它会让多步任务的 tok/s 低到十分之一量级。

实测效果：同一段中文回复 20.9 → 45.8 tok/s；一个工具调用回合 72.5 → 311.2 tok/s。
计数逻辑在 `src/tokens.ts` 的 `TurnOutputAccounting`，由 14 个单元测试钉住，其中一条专门断言
「工具参数必须计入」。

**设置点了没反应、一直「保存中」？** v0.6.0 已修。原因是 `.volatile()` 字段的改动**不会重启
插件**，而适配器在挂载时把配置抄进了自己的字段，于是永远按旧值作答——写入成功、磁盘正确、
插件读不到。详见 [开发须知](./DEVELOPMENT.md)。

**回答完模型的问题、或工具调用结束后，有时等好几秒才继续，有时干脆不再回复？** v1.0.6 已修。
根因是**工具结果的配对状态被当成了"每轮"的，而它其实是"每会话"的**。

qodercli 会用两个不同身份通知同一次工具调用：`canUseTool` 先拿到它自己的 `tool_use` id，而真正
需要宿主结果的那个 MCP `tools/call` handler **拿不到任何 id**。两者顺序一致，靠一个 FIFO 配对。
实测（真实 CLI，1.1.68）这个 handler 会在**它所属的那一轮已经结束之后**才触发——宿主一看到工具
调用块就立刻把结果交回来，而 CLI 过几毫秒才派发 MCP 消息：

```
6198ms  [endTurn] kind=tool-calls
6198ms  [host] 没有 parked handler -> 缓冲在 key=call_80d292a7…
6217ms  [mcp handler] ENTER | shift 到 toolUseId=undefined
6217ms  [mcp handler] PARK 在 key=anonymous-1
14226ms [mcp handler] TIMED OUT after 8009ms
```

而 `resetTurnState()` 恰恰在**每轮开始时**清空了那个 id 队列，于是把 handler 正要认领的 id 丢掉了：
handler 落到兜底键、park 满整个 `toolResultTimeoutSeconds`（默认 **120 秒**），宿主那条结果则永远
留在缓冲里没人读。**为什么是"有概率"**：宿主工具慢（`pwsh` 跑几秒）时 handler 在轮次结束前就
park 好了，走的是另一条路；工具快（`read`、`ask_user_question`）时才输掉这个竞态。所以"问模型
问题"和"工具刚结束"这两类操作最容易中招。而 120 秒远超人的耐心，表现就是"一直不回复"，只能停掉
对话重发——重发后模型重新发一次调用，这次竞态恰好落在另一边，于是"好了"，但修的是运气不是 bug。

现在配对状态由会话持有，只在**确实会取消派发**的地方丢弃：轮次被 abort / 失败时丢掉这一轮没能
认领的通知（否则会把下一轮的 handler 挤错一个位置），会话销毁时整体释放。逻辑抽成了
`ToolResultPairing`，由 7 条单元测试钉住；其中"快结果必须交给轮次结束后才到达的 handler"一条
是会分辨的那条——在它与 `claim` 之间插一次 `forgetUnclaimed()`（即旧 `resetTurnState()` 在轮次
边界做的事），它就会 park 满超时并拿到 `toolUseId=anonymous-1` 的取消文本，与线上实测的失败
形态逐字一致。

**同一个症状，换了一个原因？** v1.0.7 已修。v1.0.6 之后日志里仍能看到 120 秒超时，但这次不是配对
丢了，而是**超时用错了对象**。

宿主要等这一轮的**所有**工具执行完，才在下一次模型请求里一次性带回全部结果。所以只要有一个工具
跑得久（构建、目录扫描、自带 300 秒预算的 `pwsh`），同批里早已完成的调用也得一起等——它的结果
明明已经就绪，只等着随下一次请求送达。旧实现从 handler 进入时开始计 120 秒，到点就把这次调用当成
"宿主没回"取消掉：

```
10:07:53  [mcp handler] PARK call_00_QFqbaum…   （write，结果其实立刻就有）
10:07:53  [host]         pwsh 开始执行（自身预算 300 秒）
10:09:53  [mcp handler] TIMED OUT after 120s    ← 把 write 误杀
10:10:38  [host]         第一次请求到达，带着全部三条结果
```

单工具同样会中：10-20 16:41 的一次 `pwsh` 超过 120 秒，16:43 被超时取消，而宿主当时还在执行它。

现在**成对调用不再有墙钟期限**：结果只可能随宿主的下一次请求到达，等多久取决于宿主工具，而不是
一个常数。真正能判定"这次调用没人会回答"的信号也正是那次请求本身——请求边界
（`beginHostRequest()`）会立即结算宿主没有带回结果的已 park 调用，并把尚未触发的通知标记为已作废
（handler 之后到达时直接拿到取消结果，而不是再 park 两分钟）。只有**完全没配上对**的 handler
（拿不到 `toolUseId`，无法把结果与调用对应）才继续用 `toolResultTimeoutSeconds` 兜底，默认仍是
120 秒。

**工具调用结束后整轮再也不回复？** v1.0.8 已修。v1.0.7 拿掉了成对调用的墙钟，但漏了一种**没人能
来救**的情况：请求边界只结算"它运行时已经 park 好"的 handler，而**边界之后**才到达的通知它管不
到——如果那次调用宿主压根没见过，就永远不会有人结算它，而唯一能结算它的下一次请求边界，偏偏要等
这一轮先结束，这一轮又正卡在这个 handler 上。

触发它的是一次**裸名工具调用**。内层模型偶尔不写限定名（`write` 而不是 `mcp__dsh-host__write`），
qodercli 自己解析不到，就地判错并**不等宿主**继续跑它自己的循环——内层循环因此比宿主的轮次边界跑
前面一轮；它随即带限定名重试，而重试的工具块落在**两次宿主请求之间的窗口**里，会话当时没有可投递
的宿主流，块被丢弃，宿主**从未见过这次调用**，但 CLI 照样把它派发给了 MCP handler：

```
22:38:49.444 [cli]  模型以裸名 write 调用 -> Error: Tool "write" not found（就地失败，不等宿主）
22:38:49.506 [host] 宿主照常执行 write，文件确实写出了；22:38:52.812 开下一轮
22:38:52.786 [cli]  带限定名重试的工具块到达 -> 此刻没有宿主流 -> 丢弃（宿主从未见到）
22:40:37.687 [mcp handler] PARK call_2b54e8a6…   ← 没有任何请求会为它带回结果
22:48:24.594 [user] 点停止 -> CLI 取消挂起的工具调用，轮次才解开（实测静默 572 秒）
```

现在 `claim()` 在 park 之前先确认**这次调用宿主确实见过**（`hostVisible`：适配器真的把工具块投递
给宿主的那些 id）。宿主没见过的 id 立即拿到与请求边界相同的取消文本，不再空等；宿主见过的调用依旧
**没有墙钟**，`pwsh` 跑几分钟照样等它的真实结果。判别性的一条测试是「宿主从未见过的调用要立即失
败」——修复前它会 park 满 600 秒（测试超时失败），修复后立刻返回取消结果。
