1. 现象

在 pi-web 的界面里,给自定义模型(自建 vLLM 部署的 Qwen 系 27B 推理模型,OpenAI Responses 兼容协议)选择思考级别(reasoning level)时:

  • 下拉框里最高只有 medium,看不到 xhigh;
  • 在设置里把级别手动改成 xhigh 之后,实际跑的还是 medium;
  • 进程环境变量里也能证实:PI_REASONING_LEVEL=medium。

直观感受是"设置不生效",很像 pi-web 设置页的一个 bug。

2. 排查过程

第一步:确认实际生效值

bash复制

env | grep -iE 'PI_|REASON'
# PI_REASONING_LEVEL=medium

会话进程拿到的就是 medium,说明问题出在"设置值 → 会话环境变量"这条链路上,而不是显示问题。

第二步:排除 pi-web 设置项

检查 pi-web 自身的配置与进程环境:

  • ~/.pi/agent/settings.json 里只有默认 provider/model,没有思考级别相关字段;
  • pi-web 服务进程(pid 1)的环境里没有 PI_REASONING_LEVEL,说明这个值是 pi-web 启动 agent 会话时动态算出来注入的,计算依据在别处。

第三步:找到计算逻辑

顺着 PI_REASONING_LEVEL 在 pi 源码里搜索,定位到 pi-ai 包的 dist/models.js:

javascript复制

const EXTENDED_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];

export function getSupportedThinkingLevels(model) {
  if (!model.reasoning) return ["off"];
  return EXTENDED_THINKING_LEVELS.filter((level) => {
    const mapped = model.thinkingLevelMap?.[level];
    if (mapped === null) return false;              // 显式置 null = 禁用该级别
    if (level === "xhigh" || level === "max")
      return mapped !== undefined;                   // xhigh/max 必须显式列出键才算支持
    return true;
  });
}

export function clampThinkingLevel(model, level) {
  // 请求的级别不受支持时:先向上找最近的可用级别,找不到再向下找
  ...
}

两个关键规则:

  1. thinkingLevelMap 里值为 null 的级别 = 禁用;
  2. xhigh / max 是特例:不在 map 里出现就默认为不支持(其他级别不出现则默认为支持)。

第四步:找到罪魁祸首

打开 ~/.pi/agent/models.json,自定义模型的规格:

json复制

{
  "id": "Qwen3.8-27B",
  "reasoning": true,
  "thinkingLevelMap": {
    "minimal": null,
    "low": null,
    "high": null,
    "max": null
  }
}

对照规则推导该模型实际支持的级别:

级别 map 中的值 判定
off —  支持
minimal null  禁用
low null  禁用
medium (未出现)  支持(默认)
high null  禁用
xhigh (未出现)  不支持(xhigh 必须显式列出)
max null  禁用

结果:只剩 off 和 medium。UI 选择器自然最高只给到 medium;而设置里选的 xhigh 会被 clampThinkingLevel 逐级钳制:

text复制

xhigh(不支持)→ max(null)→ high(null)→ medium(支持)停在 medium

这就是"设置 xhigh 实际跑 medium"的完整解释。

3. 修复

3.1 先验证后端是否真的接受 xhigh

改配置前,直接对 vLLM 后端发探测请求,确认 reasoning_effort: "xhigh" 不会 400:

bash复制

for e in xhigh medium none; do
  curl -s $GATEWAY/v1/chat/completions \
    -H "Authorization: Bearer $API_KEY" \
    -H 'Content-Type: application/json' \
    -d "{\"model\":\"<model>\",\"messages\":[{\"role\":\"user\",\"content\":\"say hi\"}],
         \"max_tokens\":16,\"reasoning_effort\":\"$e\"}"
done

三个值均正常返回,xhigh 可用。

3.2 改模型规格

在 thinkingLevelMap 里补一个显式的 xhigh 键(值就是发给后端的 wire value):

json复制

"thinkingLevelMap": {
  "minimal": null,
  "low": null,
  "high": null,
  "xhigh": "xhigh",
  "max": null
}

新开一个会话即生效(模型规格在会话启动时加载;当前会话不会中途变更)。如果 UI 仍未刷新,重启 pi-web 进程。

4. 教训

  1. "设置不生效"先怀疑生效链路,别急着怀疑设置项本身。 本例里 pi-web 的设置完全没问题,问题出在它下游"按模型规格钳制"这一步。环境变量(PI_REASONING_LEVEL)是把设置值、钳制结果、实际值串起来的最佳观测点。
  2. 特例规则是坑的温床。 xhigh/max "不出现 = 不支持",与其他级别 "不出现 = 支持" 的默认语义恰好相反。这类不对称的隐式默认,读源码前几乎不可能猜对。
  3. 改配置前先探测后端。 先 curl 验证 vLLM 接受 xhigh,再改 map——避免"UI 显示了 xhigh,选上之后整条会话 400"的新 bug。
  4. 自定义 provider 的模型规格是"能力声明",不是"偏好设置"。 thinkingLevelMap 同时决定:UI 显示哪些档位、请求值如何映射到 wire value、不受支持时如何钳制。三件事一个字段管,改的时候要整体想清楚。

5. 速查表

想让某级别出现在选择器里 thinkingLevelMap 怎么配
off / minimal / low / medium / high 键值为非 null 字符串,或不写该键
xhigh / max 必须显式写键,值用非 null 字符串
禁用某个级别 "级别": null
映射到后端不同的 wire value "medium": "low"(UI 显示 medium,实际发 low)

附:排查用到的关键文件

text复制

~/.pi/agent/models.json                          # 自定义模型规格(本案根因所在)
~/.pi/agent/settings.json                        # pi 全局设置(本案无关,用于排除)
node_modules/.../pi-ai/dist/models.js            # getSupportedThinkingLevels / clampThinkingLevel
node_modules/.../pi-ai/dist/api/openai-responses.js  # reasoningEffort → 请求体映射