# 决策记录：为什么移除本地 logits 选项评分

日期：2026-09-20。状态：**已否决**，实现已从代码中移除（`lib/classifier-logits.js`、sidecar 服务、
`classifier: logits` 与 `logitsShadow`）。本文件保留结论与数据来源，避免以后重新踩同一个坑。

## 当初想做什么

把判断从生成里拆出来，用本地小模型**读候选项 token 的 logits**，一次前向传播得到一个
`hard / normal / easy` 的概率分布，并用其中的 margin 作为"拿不准就回退"的闸门。
思路来自业界 2026-09 关于 TypeSafe Jev 的讨论。

## 为什么否决

在这台机器上用 `Qwen3-4B-Instruct-2507-4bit`（MLX，4-bit，GPU）对**真实会话里抽出的
请求**做了轮换候选顺序的对照实验。结论：

1. **判定随候选顺序翻转。** 10 个真实样本里 9 个在六种候选顺序下给出了不止一个档位。
   例如 `你继续` 的六次结果是 3 次 `normal` / 3 次 `hard`。
2. **翻转发生在最高置信度上。** 上面那六次的 margin 分别是
   0.97 / 1.00 / 0.98 / 1.00 / 0.93 / 0.89 —— 模型每次都"非常确定"，
   而答案是自相矛盾的。**因此 margin 闸门无法拦住这种错误**，它也失去了存在的理由。
3. **轮换顺序取平均救不回来。** 对六次结果取平均后是
   `hard 0.497 / normal 0.500 / easy 0.003`，即 margin 0.003 的死平局。
   这说明模型对这些输入本来就没有信号，"高置信度"完全是位置效应。
4. **只在答案本就明显时才稳定**（如 `继续加别的` 六次全判 `hard`）。
   也就是说它在最需要判断力的模糊输入上恰好是噪声 —— 对分类器而言这是最糟的工作特性。

## 还在的结论

- 判断的**输入表征**比换判断模型重要得多。同一批真实请求里，只把上下文从"最后一条消息"
  换成"任务 + 最近对话 + 当前步骤状态"，`修`、`完了吗`、`你继续` 这类短请求才变得可判。
  这项改进以 `lib/routing-state.js` 的形式保留了下来。
- 短请求确实需要上下文：39 个真实会话里抽出的 193 条人类请求中，42% 短于 15 字符、
  69% 不含任何任务动词。难度信息不在消息里。
- 本地 4B 读 **24k** token 上下文需要 **148 秒**，**32k** 时 KV cache 需要 9.7 GB，
  超过本机 Metal 单缓冲区上限 9.53 GB。即便判定机制成立，本机也装不下真实上下文。

## 数据来源

- 会话样本来自 `~/.dsh/sessions`（本机私有，未进仓库）。
- 提取与对照脚本在 `~/jev-local/`：`extract_real.py`（193 条请求统计）、
  `extract_pairs.py` / `real_pairs.json`（46 对真实短请求 + 上下文）、
  `bias_probe.py`、`prefill2.py`（上下文长度与 KV cache 上限）。
- 这些脚本**没有随仓库发布**，需要时在本机重新运行。

## 没有验证的部分

以下都没有测过，所以不能推广成"这条路整体不成立"：

- 只测了 4B；没测更大或专门为判定训练过的模型。
- 只测了三个语义高度重叠的档位描述；二选一或类别分离明显的判定没测。
- 没有人工标签，所以**没有任何准确率结论**。
- 早期 12 条探针的标签由助手编写，其准确率与 `logitsMinMargin: 0.7` 的依据已撤回。

## 要重新验证的话

如果将来要做"每步工具调用是否危险、要不要拦"这类判定（单步、二选一、类别分离明显），
可以重开这套实验台：引擎与脚本仍在本机 `~/jev-local/`。但**必须先做轮换顺序对照**，
否则测到的可能只是位置效应。
