DeepSeek Harness(dsh)从零到全栈【5】模型配置深入:provider、compat 与把任何端点变成一条路由

5 模型配置深入:provider、compat 与把任何端点变成一条路由

本章导读 :你在「设置 → 模型」里粘过一个 DeepSeek 密钥,对话就跑起来了,但那只用到了 dsh 模型配置能力的十分之一。本章把剩下九分之十讲透:provider/model 的二层模型与"路由"概念、$DSH_HOME/settings.yaml 的完整结构、目录提供方与自定义提供方的差别、视觉模型的模态声明("断言不是检查")、compat 请求兼容性开关(解决 OpenAI 兼容网关 400 的钥匙)、本地 Ollama/vLLM 接入,以及"换模型为什么会开新纪元"。读完你能把公司内网网关、本地小模型、官方 API 挂进同一个 dsh,并且知道每个字段在源码里由谁消费。(版本基准:dsh 0.1.2-alpha.1。)
摘要 :本章深入讲解 dsh 的模型配置体系。核心是"provider 是路由、model 是模型 id"的二层模型,以及"断言不是检查"这一贯穿始终的原则。你将学会通过 settings.yaml 配置 DeepSeek 官方、目录提供方和自定义 OpenAI 兼容端点三种接入方式;理解视觉模型的模态声明(input)和推理能力声明(reasoningEfforts)为何是"断言"而非"检查";掌握 compat 开关如何解决 OpenAI 兼容网关的 400 错误;并了解本地模型(Ollama/vLLM)的接入要点、多模型切换与"纪元"(epoch)的关系。读完你能把公司内网网关、本地小模型、官方 API 挂进同一个 dsh,并知道每个字段在源码里由谁消费。

文章目录

  • [5 模型配置深入:provider、compat 与把任何端点变成一条路由](#5 模型配置深入:provider、compat 与把任何端点变成一条路由)
    • 学习目标
    • 正文
      • [1. 模型在 dsh 里不是一行配置,而是一条"路由"](#1. 模型在 dsh 里不是一行配置,而是一条"路由")
      • [2. 配置从哪里来:settings.yaml 与凭据引用](#2. 配置从哪里来:settings.yaml 与凭据引用)
      • [3. DeepSeek 官方接入:一张卡片背后的完整链路](#3. DeepSeek 官方接入:一张卡片背后的完整链路)
      • [4. 添加目录提供方:Anthropic、OpenAI 与"原生认证"](#4. 添加目录提供方:Anthropic、OpenAI 与"原生认证")
      • [5. 自定义提供方:把任何 OpenAI 兼容端点变成一条路由](#5. 自定义提供方:把任何 OpenAI 兼容端点变成一条路由)
      • [6. 模态声明:视觉模型为什么"发了图就被拒"](#6. 模态声明:视觉模型为什么"发了图就被拒")
      • [6. 模态声明:视觉模型为什么"发了图就被拒"](#6. 模态声明:视觉模型为什么"发了图就被拒")
      • [7. 推理能力声明:让第三方模型也显示"推理等级"](#7. 推理能力声明:让第三方模型也显示"推理等级")
        • [7.1 理解 `reasoningEfforts` 的键值对](#7.1 理解 reasoningEfforts 的键值对)
        • [7.2 默认档位:`reasoning` 与 `agent-default-model.reasoningEffort`](#7.2 默认档位:reasoningagent-default-model.reasoningEffort)
        • [7.3 确认协议与排查](#7.3 确认协议与排查)
      • [8. dsh-better-reasoning-effort 插件补上官方缺的 UI](#8. dsh-better-reasoning-effort 插件补上官方缺的 UI)
        • [8.1 安装](#8.1 安装)
        • [8.2 它解决了什么](#8.2 它解决了什么)
        • [8.3 插件能做什么 / 不能做什么](#8.3 插件能做什么 / 不能做什么)
      • [9. compat:让请求说网关听得懂的方言](#9. compat:让请求说网关听得懂的方言)
      • [10. 本地模型:Ollama 与 vLLM 以 OpenAI 兼容方式接入](#10. 本地模型:Ollama 与 vLLM 以 OpenAI 兼容方式接入)
      • [11. 多模型、默认模型与"纪元"](#11. 多模型、默认模型与"纪元")
      • [12. 排错速查:providers.zh.md 排错节全解](#12. 排错速查:providers.zh.md 排错节全解)
    • 动手实验
      • [实验 1(主实验,约 10 分钟):接 Ollama 跑通一次对话](#实验 1(主实验,约 10 分钟):接 Ollama 跑通一次对话)
      • [实验 2(约 3 分钟):验证凭据是"按请求解析"的](#实验 2(约 3 分钟):验证凭据是"按请求解析"的)
      • [实验 3(约 5 分钟,可选):亲手触发一次"断言不是检查"](#实验 3(约 5 分钟,可选):亲手触发一次"断言不是检查")
    • 常见坑
    • 小结
    • 参考资料

学习目标

  • 能说清 provider(路由)与 model(模型 id)的分工,以及 dsh 自带哪两个 LLM 适配器、各自负责什么。
  • 能独立完成 DeepSeek 官方、目录提供方、自定义 OpenAI 兼容端点三种接入,并说清各自认证方式的差别。
  • 能解释"断言不是检查":input: [text, image]compat 开关为什么不会被 harness 校验,出错时由谁拒绝请求。
  • 能对着报错选对 compat 开关(supportsDeveloperRole / maxTokensField / thinkingFormat......),修通一个拒绝所有请求的网关。
  • 能用 Ollama 跑通一次完整对话,并解释换模型时 epoch(纪元)为什么会变。

正文

1. 模型在 dsh 里不是一行配置,而是一条"路由"

  • 先纠正一个直觉。在大多数工具里,"配置模型"就是填一个模型名字符串。在 dsh 里,每次模型请求由 GenerateOptions 完整描述,其中有两个关键字段(这里的事件与术语约定沿用DeepSeek Harness(dsh)从零到全栈【3】核心概念地图:一篇讲透DSH全部术语的术语表):
    • provider注册路由(route),选择由哪个适配器实例服务这次请求;
    • model模型 id,原样传给该适配器,适配器不再做白名单校验。
  • dsh 对模型的配置是两层 的:外层 provider 决定"发到哪个端点、走什么协议、用什么凭据",内层 model 只是一张工单上的名字。这个分工写在 ctx.llmLlmRuntime)服务约定里:适配器插件调用 ctx.llm.registerAdapter(providers, adapter) 注册它拥有的一条或多条路由;"该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id"。
  • dsh 随产品自带两个 LLM 适配器,官方笔记称它们为"孪生适配器"(twin LLM adapters):
bash 复制代码
                       ctx.llm(LlmRuntime:适配器注册表 + 流式调用 API)
                          │
      ┌───────────────────┴────────────────────────────────────┐
      ▼                                                        ▼
dsh-llm-deepseek                                       dsh-llm-pi-ai
拥有唯一路由 deepseek-official                          拥有 providers 字典里你声明的每条路由
直接 fetch + DeepSeek chat-completions                 经 pi-ai 库服务目录提供方与自声明网关
(Files API 图片管线、thinking 策略)                   (Anthropic/OpenAI/Azure/Bedrock/Vertex/
                                                         任意 OpenAI 兼容端点......)
      │                                                             │
      └──────────────── 两条路由名不冲突,可以同时挂载 ────────────────┘

为什么是两个?因为两件事很难用一个适配器做好:对 DeepSeek 官方协议做深度定制(图片走 Files API、thinking 序列化、缓存计量复现),与对几十家提供方做广泛的协议覆盖。pi-ai 路线靠的是 @earendil-works/pi-ai 库的提供方目录;直连路线靠的是 packages/llm/llm-deepseek/src/ 下手写的协议序列化。你在 Web UI「设置 → 模型」页配置的一切,最终都落进这两个插件之一的 settings 分节。

  • 路由之上还有一层"确切模型元数据"。ctx.llm.resolveModelInfo(provider, model) 会向拥有该路由的适配器查询一次 LlmResolvedModelInfo:除了身份,还可能带上上下文容量(contextWindow)、适配器配置的每次请求输出默认值(defaultMaxTokens)、该模型可选择的推理强度列表与部署默认值。docs/subsystems/llm-streaming.zh.md 强调这些"对正确性敏感的元数据"与参考目录分开解析、归服务该确切路由的适配器所有------"字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效"。同样地,模型条目上的 inputModalities(请求模态)语义很精细:缺失表示"未知",而显式的空声明是一种"负能力"。
  • packages/llm/ 的目录结构也值得扫一眼,提供方实现的相关内容一目了然:
bash 复制代码
packages/llm/
├── llm/            # dsh-llm:provider 无关的服务契约(ctx.llm、StreamChunk、GenerateOptions)
├── llm-deepseek/   # dsh-llm-deepseek:deepseek-official 直连适配器
├── llm-pi-ai/      # dsh-llm-pi-ai:多提供方适配器(本章主角)
├── llm-retry/      # dsh-llm-retry:按路由 retryPolicy 执行重试
├── deepseek-llm-api-extensions/  # DeepSeek 请求顶层扩展字段注册表
├── plugin-package-inventory-deepseek/
└── token-meter/    # dsh-token-meter:token 计量

2. 配置从哪里来:settings.yaml 与凭据引用

  • 模型相关的用户配置有三个入口,优先级和适用场景不同:
  1. Web UI 设置页:适合填密钥、添加提供方。保存立即生效。
  2. $DSH_HOME/settings.yaml :用户设置文档,按 namespace 分节(llm-pi-ai:llm-deepseek: 等)。表单没有暴露的字段(inputcompatcontextWindow......)都写在这里。官方文档原话:"表单里没有这两个字段;请在 $DSH_HOME/settings.yaml 的路由上更正"。
  3. 组合层 cordis.yml :部署者声明初始路由的地方。用户层可以新增路由、覆盖组合路由的字段,但不能移除组合路由 ------"删除 cordis.yml 提供的提供方属于组合变更"。
  • 设置系统的机制在 docs/subsystems/settings.zh.md:每个 namespace 的值按"schema 默认值 → 组合 base → 用户分节"三层解析,用户分节的每次提交都会发出 settings/updated 事件。对模型配置而言,这意味着一个关键体验:所有模型变更在下一次请求时生效,不需要重启服务器dsh-llm-pi-ai 的 README 说得更具体:profile 通过 settings seam 每次操作重新读取,"编辑用户设置文档即可改变下一个请求,无需重启"

  • 两个与"改配置"直接相关的保护机制也出自同一份文档。其一是写入时拒绝 :适配器无法服务的分节(例如手工声明路由缺 api/baseURL/非空 models)会在写入处被点名拒绝------settings.mutate 回答 settings-rejected------而不是先存下来让该 namespace 下每条路由悄悄失效;若你绕过界面直接改文件导致某个已存分节失效,它会保留该 namespace 的"最后有效值"并告警,正在跑的路由不会被外部的坏编辑卡死。其二是分层合并对字典键没有删除base 层声明的 reasoningEfforts 等级、modelOverrides 条目或 compat 字段,可以被用户层覆盖,但不能被移除------想让某个组合层字段彻底消失,属于组合变更,不是 settings 能表达的。

密钥则走另一条通道。providers.zh.md 原话:

密钥是只写的。保存后,页面只会收到脱敏描述符,永远不会收到明文密钥。密钥存储在$DSH_HOME/.credentials.yaml 中,settings 只保留它的凭据引用。

"凭据引用"(CredentialRef)就是一个环境变量名(如 apiKeyEnv: DEEPSEEK_API_KEY)。它有三个特性,每个都直接影响你的日常:

  • 按操作解析,绝不缓存:"LLM 适配器每次模型请求解析一次,因此轮换后的凭据无需任何重启即可作用于紧随其后的下一次请求。"
  • 分层供值 :本地提供方按 env(进程环境)、file(提供方管理的存储,即 .credentials.yaml)、project-env.env 文件)、user-env 四层解析;凭据 seam 先于进程环境被查询。
  • 空值即不存在:"空的存储值在任何地方都视为不存在"------一个空字符串密钥永远不会伪装成已配置。

配置文件里永远不出现明文密钥 ,只有 apiKeyEnv 这样的引用名。这是审计和提交配置文件到 git 时的重要安全垫。

⚠️ 常见误区 :把 API 密钥直接写进 settings.yaml 的某个字段里。pi-ai 的 profile schema 里根本没有 apiKey 字段,凭据只能以 apiKeyEnv 引用(或登录提供方的 OAuth 记录)存在。写进去的密钥不会被读取,还会污染你版本控制的配置文件。

3. DeepSeek 官方接入:一张卡片背后的完整链路

  • 打开设置 → 模型 ,DeepSeek 卡片只提供一个 API 密钥字段。输入 platform.deepseek.com 签发的密钥并保存,就这样。但幕后发生了三件事:
  1. 密钥被写入 $DSH_HOME/.credentials.yaml,settings 里只留下凭据引用(默认引用名是 DEEPSEEK_API_KEY)。
  2. dsh-llm-deepseekdeepseek-official 路由随即可用。它的连接端点、目录、密钥、thinking策略,按请求重新解析。
  3. 适配器公布一份建议性模型目录:deepseek-v4-flash(适合专注任务、快速经济)、deepseek-v4-pro(复杂/质量关键任务)、deepseek-v4-flash-vision-exp(支持图像输入),每个模型都有 1,000,000 token 上下文窗口。

  • DeepSeek 官方路由的可调字段都在 llm-deepseek: settings 分节下,常用的几个字段:
yaml 复制代码
# 示例代码:$DSH_HOME/settings.yaml 中的 llm-deepseek 分节(字段与默认值来自包 README)
llm-deepseek:
  baseURL: https://api.deepseek.com   # 默认值;设置 $DEEPSEEK_BASE_URL 时优先
  thinking: enabled                   # 部署策略;disabled 把所有请求锁定为 off
  reasoningEffort: high               # 默认强度:off | low | high | max
  maxTokens: 256000                   # 单次请求输出上限
  defaultContextWindow: 1000000       # 无精确值模型的容量回退

两个容易被忽略的语义:

  • reasoningEffort 是请求级默认值,不是硬编码low/high/maxreasoning_effort 序列化发往提供方;off 则序列化为 thinking: { type: 'disabled' } 并省略字段,从而对拒绝未知强度取值的网关保持协议拼写有效。不支持的取值会在网络 I/O 之前以 UNSUPPORTED_REASONING_EFFORT 失败。
  • 辅助调用有自己的策略purpose: 'session-title' 的请求(自动会话标题)会强制关闭 thinking,把有界输出留给可见标题文本。

还有三条边界值得知道。第一,显式给出 models 列表会整体替换默认目录,但"未列出的模型 id 仍作为纯文本路由原样通过",新发布的 DeepSeek 模型不需要等适配器更新。第二,maxTokens: 256000 是适配器每次请求的输出默认值,"模型自身上限与显式请求值优先"。第三,远程流有看门狗,streamIdleTimeoutMs 默认 300,000 毫秒(五分钟),"单次流读取未完成的最大提供方空闲时间",到期映射为 TIMEOUT 失败。

💡 深挖 :providers.zh.md有一句容易误读的话------"DeepSeek 自身的 chat-completions 路由是纯文本的,且无法通过配置改变"。它说的是 pi-ai 目录里的 DeepSeek 条目:经 dsh-llm-pi-ai 服务的那条路由按目录声明只收文本。给视觉模型 deepseek-v4-flash-vision-exp 发图片的正确路径是 deepseek-official 直连路由,它有独立的图片管线(Files API 上传、超预算最旧优先卸载、内联 base64 回退)。同一家厂商的两条路由,图片能力不同,这正是"路由决定请求形状"的活例子。

4. 添加目录提供方:Anthropic、OpenAI 与"原生认证"

  • 选择添加提供方,可以接入 pi-ai 已安装目录里的提供方(Anthropic、OpenAI 等)。目录的价值在于:端点、协议和模型列表都是现成的,"已安装目录会提供端点、协议和模型列表",你只需要填认证。
  • 但"只需要填认证"不等于"都是 API 密钥"。providers.zh.md 原话:

使用原生认证的提供方需要各自的原生凭据。Bedrock、Vertex、Azure 和 Codex 分别使用 AWS 凭据与区域、ADC 项目、api-version 和 OAuth;只填写 API 密钥字段无法完成配置。

提供方 认证方式
Anthropic / OpenAI(直连 API) API 密钥
Bedrock AWS 凭据 + 区域
Vertex ADC(Application Default Credentials)项目
Azure api-version 等部署参数
Codex OAuth 登录

pi-ai README 的已知限制补上了另外一半规则:像 Bedrock、Vertex、Azure、Codex 这样的认证流程,没法用"密钥 + 端点 + 标头"几个字段描述完整,所以不能通过 profile 手工声明------"提供的协议集合刻意比 pi-ai 的完整 API 集合更窄"。翻译一下:目录里提供的提供方可以用,但它们的 Bedrock/Vertex/Azure 变体,没办法靠"自定义提供方"绕出来;只有 Codex 是个例外,可以走授权流程的 OAuth grant 登录。另外,目录提供方还有个特点------"使用已安装目录,不发起网络请求"。模型列表直接来自本地目录,所以配置界面里的"获取可用模型"对它们是即时回答。

5. 自定义提供方:把任何 OpenAI 兼容端点变成一条路由

  • 公司网关、自建服务器、目录里没有的提供方,选添加自定义提供方 。表单要求五样东西(providers.zh.md 原文):"提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。"

    其中有一条铁律值得单独强调:

Provider ID 是永久的,因为请求、已保存会话、模型默认值和凭据引用都会使用它。如需重命名提供方,请添加新提供方并删除旧提供方。显示名称、基础 URL、协议、凭据和模型仍可编辑。

为什么 ID 改不得?因为你已保存的每一条会话日志里,assistant 消息的来源都记录着 provider: <id>model: <id>AssistantProvenance,见 docs/subsystems/llm-streaming.zh.md);凭据引用、默认模型选择也都按这个键存放。改名等于让所有历史记录悬空。pi-ai README 还补充了一个细节:落在"小写连字符标识符"文法之外的手工路由键无法使用登录提供方(记录写入会以 UNSTORABLE_PROVIDER_ID 拒绝),这类路由只能用 apiKeyEnv 认证。

"获取可用模型"按钮 做的是一次真实网络请求:查询表单当前显示的基础 URL 和凭据,调用 OpenAI 兼容的 GET /models 端点。三个要点:

  • 查询结果只是候选,"选择候选项只会更新草稿;保存前不会存储提供方"。settings.yaml 仍然是决定路由服务内容的唯一事实。
  • 端点不提供 GET /models 的服务,直接手动输入模型名即可,模型 id 本来就"原样传到协议,新增模型无需重新注册"。
  • 返回 401 时先查密钥,这是模型发现问题排错的第一条(providers.zh.md:"获取可用模型返回 401:检查密钥")。

自定义路由的生命周期里还有两个值得记住的点。

其一是休眠挂载dsh-llm-pi-ai "可以零路由休眠挂载,一旦 settings 分节提供 profile 便立即激活它们"------providers 字典为空(或省略)本身就是合法配置,插件先以零路由姿态注册进可配置提供方目录,配置界面因此能在任何路由真正存在之前展示完整的"可配置提供方"列表(含每条的"已声明/目录"状态 declared)。

其二是原子重注册 :"当路由集合或某路由的重试策略变化时,插件会原子地重新注册:冲突路由会让此前路由继续服务"------你保存一个有问题的配置不会让正在服务的路由出现空档。另外,每条路由只有一种协议格式api 是路由级字段),"混合协议目录路由无法承载另一协议格式的模型;把提供方拆到两个路由键是变通办法"。

6. 模态声明:视觉模型为什么"发了图就被拒"

  • 手动录入的模型默认按纯文本对待。这不是 dsh 偷懒,而是逻辑必然。providers.zh.md 原话:"手动输入的模型在自己声明之前一律按纯文本对待,因为没有任何环节能去询问端点接受哪些模态。给这类模型附加图片,会在发送前就被拒绝"。所以自定义提供方下的视觉模型要多声明一行。表单没有这个字段,请到 $DSH_HOME/settings.yaml
yaml 复制代码
# 来自 docs/user/guide/providers.zh.md(官方示例,原样摘录)
llm-pi-ai:
  providers:
    my-gateway:
      apiKeyEnv: GATEWAY_API_KEY
      api: openai-completions
      baseURL: https://gateway.example/v1
      models:
        - id: legacy-chat
        - id: vision-preview
          input: [text, image]

围绕 input 有一组精确到苛刻的语义,全部来自 providers.zh.md 与生成配置目录:

  • input 只接受 textimage,只作用于声明的那个模型。
  • 省略与空列表同义 (模型级):都表示"沿用已安装目录为该模型记录的模态;目录未描述的模型则回退到该路由的 defaultInput"。
  • defaultInput 是回退值不是覆盖值 ,默认 [text]:它只为目录未描述的模型作答,绝不会把目录中本就具备图片能力的模型的该能力去掉。全部手录模型都吃图时,在路由上写一次 defaultInput: [text, image] 即可,不必逐个模型写。
  • 收窄 目录模型的模态(比如网关不支持目录声称的图片能力),用 modelOverrides,以模型 id 为键(目录提供方没有 models 列表可写,这是唯一入口):
yaml 复制代码
# 来自 docs/user/guide/providers.zh.md(官方示例)
llm-pi-ai:
  providers:
    anthropic:
      modelOverrides:
        claude-sonnet-4-5:
          input: [text]
  • 模型自身的列表之外,每个列表至少要写一项模态;未知模态在任何位置写入都会被拒绝。
  • 收窄之后记得开新会话:图片附件留在会话日志里,"在会话离开它之前,同一个请求会不断重复"。

然后是本节的题眼,官方原话:

这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供方拒绝该请求。

  • "断言不是检查"(an assertion, not a check)是理解 dsh 模型配置的万能钥匙:harness 从不探测你的端点能接受什么,它只是忠实地记录你告诉它的话,并据此决定发不发、怎么发。声明了假能力,拦截发生在提供方那一端(请求中途 400);漏声明了真能力,拦截发生在发送之前(harness 拒绝附加图片)。配置的全部艺术,就是把断言写对。

  • 把这个原则放到线上看一遍。config-catalog 里 PiAiModelProfile.input 的 JSDoc 原话:"This is a claim about the endpoint, not a check of it: nothing interrogates a gateway for what it accepts, so a model claiming images its endpoint refuses is refused by the provider instead, mid-turn."------注意 mid-turn :拒绝发生在轮次进行中,此时系统提示词、历史、工具 schema 都已经组装并发出了。pi-ai README 的已知限制一节也重申:"模态声明不受校验------声明 image 而其网关不支持的模型会在提示词准入后被提供方拒绝。持久图片仍留在历史中,同一误声明模型可能再次失败;切换到纯文本模型仍然可行,因为共享 LLM 运行时会针对该请求把图片引用投影为稳定文本。"最后半句是个实用逃生通道:误声明的路由救不回来时,同一会话切到纯文本模型仍能继续,因为运行时会把图片投影成文本占位而不是让整个历史报废。

6. 模态声明:视觉模型为什么"发了图就被拒"

手动录入的模型默认按纯文本对待。这不是 dsh 偷懒,而是逻辑必然------providers.zh.md 原话:"手动输入的模型在自己声明之前一律按纯文本对待,因为没有任何环节能去询问端点接受哪些模态。给这类模型附加图片,会在发送前就被拒绝,并点名该模型。"

所以自定义提供方下的视觉模型要多声明一行。表单没有这个字段,请到 $DSH_HOME/settings.yaml

yaml 复制代码
# 来自 docs/user/guide/providers.zh.md(官方示例,原样摘录)
llm-pi-ai:
  providers:
    my-gateway:
      apiKeyEnv: GATEWAY_API_KEY
      api: openai-completions
      baseURL: https://gateway.example/v1
      models:
        - id: legacy-chat
        - id: vision-preview
          input: [text, image]

围绕 input 有一组精确到苛刻的语义,全部来自 providers.zh.md 与生成配置目录(docs/config-catalog.zh.mdPiAiModelProfile / PiAiProviderProfile):

  • input 只接受 textimage(模态词汇表 ModelModalityMap 就这两项,见 packages/llm/llm/src/types.ts),只作用于声明的那个模型。
  • 省略与空列表同义 (模型级):都表示"沿用已安装目录为该模型记录的模态;目录未描述的模型则回退到该路由的 defaultInput"。
  • defaultInput 是回退值不是覆盖值 ,默认 [text]:它只为目录未描述的模型作答,绝不会把目录中本就具备图片能力的模型的该能力去掉。全部手录模型都吃图时,在路由上写一次 defaultInput: [text, image] 即可,不必逐个模型写。
  • 收窄 目录模型的模态(比如网关不支持目录声称的图片能力),用 modelOverrides,以模型 id 为键(目录提供方没有 models 列表可写,这是唯一入口):
yaml 复制代码
# 来自 docs/user/guide/providers.zh.md(官方示例)
llm-pi-ai:
  providers:
    anthropic:
      modelOverrides:
        claude-sonnet-4-5:
          input: [text]
  • 模型自身的列表之外,每个列表至少要写一项模态;未知模态在任何位置写入都会被拒绝。
  • 收窄之后记得开新会话:图片附件留在会话日志里,"在会话离开它之前,同一个请求会不断重复"(providers.zh.md 排错节)。

然后是本节的题眼,官方原话:

这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供方拒绝该请求。

"断言不是检查"(an assertion, not a check)是理解 dsh 模型配置的万能钥匙:harness 从不探测你的端点能接受什么,它只是忠实地记录你告诉它的话,并据此决定发不发、怎么发。声明了假能力,拦截发生在提供方那一端(请求中途 400);漏声明了真能力,拦截发生在发送之前(harness 拒绝附加图片)。配置的全部艺术,就是把断言写对。

把这个原则放到线上看一遍。config-catalog 里 PiAiModelProfile.input 的 JSDoc 原话:"This is a claim about the endpoint, not a check of it: nothing interrogates a gateway for what it accepts, so a model claiming images its endpoint refuses is refused by the provider instead, mid-turn."------注意 mid-turn :拒绝发生在轮次进行中,此时系统提示词、历史、工具 schema 都已经组装并发出了。pi-ai README 的已知限制一节也重申:"模态声明不受校验------声明 image 而其网关不支持的模型会在提示词准入后被提供方拒绝。持久图片仍留在历史中,同一误声明模型可能再次失败;切换到纯文本模型仍然可行,因为共享 LLM 运行时会针对该请求把图片引用投影为稳定文本。"最后半句是个实用逃生通道:误声明的路由救不回来时,同一会话切到纯文本模型仍能继续,因为运行时会把图片投影成文本占位而不是让整个历史报废。

7. 推理能力声明:让第三方模型也显示"推理等级"

  • 第 6 节的 input 决定"模型吃不吃图",这一节讲它的孪生兄弟------reasoningEfforts ,决定"推理等级"下拉框在模型选择器里出不出来"。这个字段和 input 一样:它是模型级 能力断言,Web 表单没有对应控件(CustomProviderCard.tsx 的注释说得直白:"deliberately no reasoning-effort control"),只能写进 $DSH_HOME/settings.yaml

  • 先看现象。同一个 deepseek 模型,经 deepseek-official 直连路由 选择时,模型选择器里会有一个"推理等级"下拉(Off / Low / High / Max);而通过 New API 之类第三方网关 接入后,这个下拉消失了。两次都是 deepseek 的模型,为什么一个有一个没有?

  • 关键在源码里。前端 ModelSelect.tsx 只在一行地方决定是否显示下拉框(packages/client/ui-model-selection/src/client/ModelSelect.tsx):

ts 复制代码
const reasoning = currentChoice?.model.reasoning   // ① 取模型的 reasoning 元数据
const effortLabel = reasoning === undefined
  ? undefined                                     // ② 为 undefined 就不显示下拉框
  : ...
  • 也就是说,model.reasoning 存在才显示推理等级 。这个字段来自适配器 resolveModel() 的返回值 LlmResolvedModelInfo.reasoning,而两个适配器给出的默认值天差地别:
适配器 路由 reasoning 的来源
dsh-llm-deepseek deepseek-official 适配器硬编码 4 档(/low/high/max),永远有值
dsh-llm-pi-ai 你声明的每条(含 New API) 取决于模型条目的 reasoningEfforts没声明就是 undefined

对于第三方网关模型,dsh-llm-pi-airesolveModelReasoning()没有配置 reasoningEfforts 返回 { reasoning: false }------"非推理模型"------于是前端拿到的 model.reasoningundefined,下拉框整个不渲染:

ts 复制代码
// packages/llm/llm-pi-ai/src/catalog.ts
function resolveModelReasoning(provider, entry, base) {
  const efforts = entry.reasoningEfforts
  if (efforts === undefined) {
    // // 手动声明的模型 base 为 undefined → { reasoning: false } → 无推理等级
    return { reasoning: base?.reasoning ?? false }
  }
  ...
}
  • 这不是 bug,而是"断言不是检查"的又一例 :pi-ai 无法从端点 URL 推断一个未知名网关的推理能力,所以默认当它不存在。你只要在 models 条目里显式声明即可,前端下拉框自动出现(无需重启):
yaml 复制代码
# 示例代码:$DSH_HOME/settings.yaml 中的 llm-pi-ai 分节(示例,字段名对照 docs/config-catalog.zh.md)
llm-pi-ai:
  providers:
    my-newapi:                          # 你声明的 New API 网关路由
      apiKeyEnv: NEW_API_API_KEY
      api: openai-responses             # 或 openai-completions,按你网关实际协议
      baseURL: https://your-gateway/v1
      models:
        - id: deepseek-v4-flash-vision-exp
          name: deepseek-v4-flash-vision-exp
          reasoningEfforts:             # ★ 关键:声明推理等级
            off: null                   # null = 支持 off,但不发送该字段(见下)
            low: low
            high: high
            max: max
7.1 理解 reasoningEfforts 的键值对
  • reasoningEfforts 的值是一个字典 ,键值含义正好对上 compat 那句"每个键都是选择器提供的等级,其值是该等级过线的拼写":
    • :pi-ai 内置思考等级词表 off / minimal / low / medium / high / xhigh / max 之一(packages/llm/llm-pi-ai/src/catalog.tsTHINKING_LEVELS)。选择器会按此顺序展示这些档位。
    • 值(非 off :发送到网关的 wire 字符串。若你的网关用 ultra 表示最高档,写成 max: ultra 即可。每个非 off 键都必须给一个非空字符串。
    • off 特例 :只有 off 可以留空,写作 off: null------表示"支持关闭推理,但关闭时不发送该字段"(大多数 OpenAI 兼容网关对"关闭"的约定就是省略字段)。所以 off: null 是标准写法。
    • falsereasoningEfforts: false 显式声明"非推理模型",用于把一个确实不推理的模型从选择器里去掉推理等级。

一个关键语义:键的「集合」决定选择器显示哪些档位 。上面示例声明了 off/low/high/max,选择器就显示这 4 档;漏写 max 用户就选不到最高档。这正好对应 dsh-llm-deepseek 硬编码的 4 档,你可以在 pi-ai 词表内按需增删。

7.2 默认档位:reasoningagent-default-model.reasoningEffort
  • reasoningEfforts 只定义"能选哪些档 ",不决定当前选哪档 。当前档位(即没有手动改过时表头上显示、并随请求发送的那一档)由 llm-pi-ai profile 的 reasoning 字段控制:
yaml 复制代码
llm-pi-ai:
  providers:
    my-newapi:
      # ...
      reasoning: high        # 该路由下所有模型的默认推理等级(需是 reasoningEfforts 中的键)
      models:
        - id: deepseek-v4-flash-vision-exp
          reasoningEfforts:
            off: null
            low: low
            high: high
            max: max

这里 reasoning: high路由级的,作用于该路由下所有模型;省略时表头显示"提供方默认"。

如果你想新会话 的默认模型直接带档位,更直接的是在 agent-default-model(dsh 的默认模型设置节,见 packages/core/agent-default-model/src/index.ts)里配:

yaml 复制代码
agent-default-model:
  provider: new-api
  model: deepseek-v4-flash-vision-exp
  reasoningEffort: high    # 新建会话的默认模型选择(ModelSelection)

两处区别:llm-pi-ai.reasoning 是"模型目录"里的默认档位;agent-default-model.reasoningEffort 是"默认模型选择"的档位(它最终会被解析进 ModelSelection.reasoningEffort)。你不用两处都配------只在 agent-default-model 里给出 reasoningEffort,并配合 reasoningEfforts 即可。

7.3 确认协议与排查
  • 协议兼容reasoningEfforts 决定"前端显示哪些档位",而请求以哪种方式传输(reasoning_effort 字段、thinkingFormat 等由 compat 决定,两者是叠加关系 :前者定"档位",后者定"怎么发"。若你的网关用 openai-responsesopenai-completions 协议但不识别 reasoning 类的字段,可能还需配合 compat.supportsReasoningEffort: false(或调整 thinkingFormat,见第 9 节)。
  • 排查顺序 :配置后下拉仍不出现,依次检查------ ① reasoningEfforts 是否写在正确的 models[i] 条目下且缩进与 id 同级;② 路由的 apibaseURL 是否正确;③ 用 ctx.llm.resolveModelInfo(provider, model) 确认返回值中 reasoning 字段已被填充,被填充前端自然显示。

8. dsh-better-reasoning-effort 插件补上官方缺的 UI

  • 看到这里你可能会问:inputreasoningEfforts 都是 Web 表单刻意不暴露 的字段,那像我这样天天和第三方网关打交道、又不想记 off: null 这种写法的用户,就只能手编 settings.yaml 吗?

不一定 。dsh 的插件机制(profile 的 bundle 层)允许第三方在官方「模型」页里注入 UI------dsh-better-reasoning-effort 就是干这个的。它把第 6、7 节所有手写的 YAML,都搬回了官方模型编辑卡里,加一个勾选框和一个按钮就搞定。

⚠️ 版本匹配 :以 v0.3.4 为例,它的 peerDependencies 覆盖 ^0.1.2-alpha.1(插件 README 说明 0.1.2-alpha 线对应 @0.3.4),与本教程的 0.1.2-alpha.1 完全兼容。装新版本前先确认你的 dsh 版本在它的 peer 范围内,兼容性随 dsh 升级可能变化。

8.1 安装
bash 复制代码
# 在 dsh 的 web profile 下(将 my-profile 换成你的 profile 名,如 web 或 web-src)
dsh plugin --profile my-profile add dsh-better-reasoning-effort

dsh plugin 本质是在 profile 目录下执行 pnpm add,然后自动把声明了 dsh.bundle.patch 的包并入 profile 的 bundle 层(源码见 apps/cli/src/plugin.tsreconcilePlugins)。装完重启 dsh 才会加载新的 bundle。

8.2 它解决了什么

装完后,官方「模型 → 编辑 → 自定义设置 → 模型行展开区」里会多出一个编辑块,和上下文窗口、最大输出并列。它把第 6、7 节手写的两块配置,都变成了可视化操作:

你之前手写的 YAML 插件里的可视化操作
input: [text, image](第 6 节) 勾选「图片输入」
reasoningEfforts: { off: null, low: low, ... }(第 7 节) 勾选档位(off/low/high/max...)+ 填线上取值
compat: { thinkingFormat: ... } 按协议自动生成,或点「自动适配」按知识库/协议推断填

它还会自动适配 :内置了 DeepSeek / OpenAI / Claude / Qwen / GLM / Kimi 等主流模型的知识库,能根据你路由的协议(openai-completions / openai-responses / anthropic-messages)和端点,一键填入推荐的档位与取值。你也可以关闭自动填充(host 配置 autofill: false),只在需要时用「自动适配」按钮手动触发。

位置 :由于这个插件是给第三方手工声明模型 补 UI 的,所以它编辑的依旧是 llm-pi-ai 路由下 models[i]reasoningEffortsinput 字段,写回的也是 settings.yaml------即前面几节所有手写的字段。它没有改变任何底层语义,只是让你不再需要记 off: null 的写法,也更不容易缩进错

8.3 插件能做什么 / 不能做什么
  • :勾选「图片输入」声明模态;设置推理等级档位;「自动适配」按知识库+协议推断一键填充;清空/恢复声明。
  • 不能 :省去那些已经写进 settings.yaml 的验证逻辑------它写回的仍然是标准的 input / reasoningEfforts,不合法时同样会在保存时被拒绝,只是 UI 层替你产生了等价的手写配置。

为什么官方不做? 官方代码注释(CustomProviderCard.tsx)写得很委婉:"deliberately no reasoning-effort control"(刻意不提供推理等级控件)------官方认为"effort 是 per-model 能力,provider 级旋钮可能弄坏部分模型"。而社区插件选择了直接暴露 per-model 的精确控制,在模型行内提供细粒度编辑。两者不冲突:官方保持简洁,想要更省事就装插件。

9. compat:让请求说网关听得懂的方言

模态之外,第二类"断言"是请求兼容性(compat)开关。场景是 providers.zh.md 描述的窘境:"网关可能持有可用的密钥、地址也通得到,却仍然拒绝每一个请求。"

根因在于 pi-ai 决定请求形状的方式,"pi-ai 依据端点的 URL 决定请求的形状,系统提示词由哪个角色承载、输出上限写在哪个字段、思考级别如何传输,而对于它无法识别的地址,会当作 OpenAI 本身来对待。多数 OpenAI 兼容网关至少会拒绝 OpenAI 所接受的某一样东西。"

其中两样占了绝大多数(原话):

  1. "声明了推理能力的模型,其系统提示词会以 role: "developer" 发出,很多网关直接拒绝",解法 compat.supportsDeveloperRole: false,改回 system 角色承载。
  2. "输出上限则写作 max_completion_tokens,只认 max_tokens 的服务端会拒绝",解法 compat.maxTokensField: max_tokens

把"请求形状"拆开看,compat 开关管的就是官方引文里那三个轴:系统提示词由哪个角色承载system vs developer)、输出上限写在哪个字段max_completion_tokens vs max_tokens)、思考级别如何传输thinkingFormatrequiresThinkingAsText 等)。同一个"给模型 8192 输出上限"的意图,两种网关听到的是两种方言------这是纯配置差异,不是 bug:

bash 复制代码
// 示例代码(示意,非仓库摘录):同一意图的两种"方言"
pi-ai 默认(按 OpenAI 本身对待你的网关):     设 supportsDeveloperRole:false 之后:
{ "messages": [                               { "messages": [
    { "role": "developer",                        { "role": "system",
      "content": "You are..." },                    "content": "You are..." },
    ... ],                                        ... ],
  "max_completion_tokens": 8192 }               "max_tokens": 8192 }
yaml 复制代码
# 来自 docs/user/guide/providers.zh.md(官方示例)
llm-pi-ai:
  providers:
    my-gateway:
      apiKeyEnv: GATEWAY_API_KEY
      api: openai-completions
      baseURL: https://gateway.example/v1
      compat:
        supportsDeveloperRole: false
        maxTokensField: max_tokens
      models:
        - id: my-model
  • 优先级链providers.zh.md):"路由的 compat 是其模型的默认值,模型自身的则逐字段胜出"→ 两者都未设置的字段"沿用已安装 catalog 为该模型记录的值;catalog 也未描述的,落到 pi-ai 的检测"。所以你可以只给某一个推理模型单独改一个字段,无需重述整条路由:
yaml 复制代码
# 来自 docs/user/guide/providers.zh.md(官方示例)
      models:
        - id: my-model
        - id: my-reasoner
          compat:
            thinkingFormat: deepseek

三条纪律同样来自官方文档:

  • 空值键被拒绝而不是被忽略supportsDeveloperRole:(冒号后留空)会在写入处被拒,"因为空值会抹掉 catalog 已知的信息,却又没有给出任何替代"。
  • 开关随协议而异 :每个开关归属于声明了它的那些协议。"在某个 api 上合法的开关,在另一个上可能被拒绝------报错会点名该协议实际提供哪些。"比如 supportsTemperature 只属于 anthropic-messages
  • 同样是断言:"设置一个网关其实并不需要的开关,只是发出一个不同的请求而已。"多设不会报错,但请求形状就变了。

全部开关、取值与所属协议列在生成的配置参考 docs/config-catalog.zh.mdPiAiCompatProfile 一节,"该参考派生自源码,因此不会落后于适配器实际接受的内容")。下表摘录 openai-completions 协议下最常用的开关(字段名与语义均对照 config-catalog 原文核对):

开关 作用(一句话)
supportsDeveloperRole 端点是否接受 developer 角色承载系统提示词;false 保持 system
maxTokensField 输出上限写在哪个字段(max_tokens / max_completion_tokens
supportsReasoningEffort 是否接受 reasoning_effort 字段
supportsUsageInStreaming 是否接受 stream_options: {include_usage: true}
supportsFinishReason 流里是否带 finish_reasonfalse 让 pi-ai 在流结束时推断
requiresThinkingAsText thinking 块是否必须以 <thinking> 定界符的文本传输
requiresAssistantAfterToolResult 工具结果之后、下一条 user 消息前是否必须补 assistant 消息
requiresToolResultName 工具结果是否必须带 name
thinkingFormat 思考级别的线上格式(官方示例取值:deepseek;另有 baseten、两种 chat-template 格式)
supportsThinkingTokenBudget 是否接受 thinking_token_budget(vLLM 推理上限)
supportsStore 是否接受 store 字段
supportsStrictMode 工具定义是否接受 strict

anthropic-messages 协议另有 supportsTemperatureforceAdaptiveThinkingallowEmptySignature 等自己的开关,见同一参考。)

社区真实 400 案例复盘 :一位用户把 dsh 指向公司内部 OpenAI 兼容网关,密钥正确、curl 通,Web UI 里却每个请求 400。排查顺序应当是:先确认 400 响应体里网关异常的字段,最常见的两条是 "unknown role 'developer'" 与 "unknown field max_completion_tokens";分别对应 compat.supportsDeveloperRole: falsecompat.maxTokensField: max_tokens。只有推理模型(reasoner 类)失败而普通模型正常时,几乎可以断定是第一条。官方排错节原话:"先在路由上设 compat.supportsDeveloperRole: falsecompat.maxTokensField: max_tokens。"

💡 深挖 :为什么 dsh 不"自动探测"?因为探测本身也是一个请求,而且探测失败与业务失败无法区分。dsh 的选择是把不可推断的事实交给用户断言(config-catalog 里 PiAiCompatProfile 的 JSDoc 原话:"every field here is one a deployment must be able to state because nothing can infer it"------私有网关的 URL 什么都不说),把可推断的事实留给目录(named vendor 的 compat 由 catalog 设定)与 pi-ai 的 URL 检测兜底。三层各管一段,谁也不猜。

10. 本地模型:Ollama 与 vLLM 以 OpenAI 兼容方式接入

  • Ollama、vLLM、LM Studio 等本地推理服务器都提供 OpenAI 兼容端点,因此在 dsh 里它们都是"自定义提供方":api: openai-completions + 本地 baseURL。完整步骤(以LM Studio为例):
  1. 启动本地服务:ollama serve(默认监听 127.0.0.1:1234)。
  2. 确认 OpenAI 兼容层活着:curl http://127.0.0.1:1234/v1/models 应返回模型列表 JSON------这个端点就是"获取可用模型"按钮要调用的。
  3. $DSH_HOME/settings.yaml 声明路由(字段全部出自 config-catalog 的 PiAiProviderProfile / PiAiModelProfile):
yaml 复制代码
# 示例代码:接 Ollama 的最小路由(字段名对照 docs/config-catalog.zh.md)
llm-pi-ai:
  providers:
    ollama-local:                    # 小写 Provider ID,永久不可改
      displayName: LM Studio
      apiKeyEnv: LM_Studio_API_KEY      # 占位凭据引用,见下文说明
      api: openai-completions
      baseURL: http://127.0.0.1:1234/v1
      models:
        - id: qwen3.5-9b
          name: qwen3.5-9b
          contextWindow: 32768       # 手动设置:LM Studio实际生效的上下文一致
  1. LM_Studio_API_KEY 一个非空占位值并启动 dsh(PowerShell):$env:LM_Studio_API_KEY = "ollama"; pnpm dsh web
  2. 「设置 → 模型」里应出现"LM Studio";在模型选择器选中 qwen3.5-9b,发一句"用一句话介绍你自己",流式回复即到。

三个容易踩中的坑,各有官方依据:

  • 占位凭据是必需的 。pi-ai README 已知限制原话:"pi-ai 的 OpenAI 兼容实现仍要求 API 密钥或 Authorization 标头,因此无密钥本地服务器需要由 apiKeyEnv 引用或 headers 中的 Authorization 条目提供的占位凭据。"不设引用的路由会停在"已配置但无密钥"(configured-but-keyless)状态,请求以 MISSING_CREDENTIAL 失败。若你不想动环境变量,等价做法是给路由加 headers: { Authorization: "Bearer ollama" }(profile 的 headers 是纯字符串字典,harness 归属头会赢得保留名冲突)------但包文档随即提醒:"headers 可以携带 redactor 永远看不到的凭据",凡是真密钥都应该走 apiKeyEnv 引用而不是 headers。
  • contextWindow 要手动对齐 。目录未描述的模型采用路由的 defaultContextWindow 回退,默认 262,144(config-catalog 原话:"A guess by construction"),本地模型实际服务几十 k 上下文时,这个数字会让 token 计量与 compaction(压缩)压力评估严重失真。同理 defaultMaxTokens 回退默认 32,768,"它只是给模型定容,本身不会成为每次请求的上限"。
  • 小模型的工具调用质量差异。agent 能力强依赖工具调用(tool call)的可靠性,社区的一致观察是:7B~14B 量级的模型在多工具、长系统提示词场景下的调用格式稳定性明显弱于旗舰模型,表现为漏参数、编造工具名或提前停止。官方文档未给出模型推荐,这一点以社区共识与你自己的实测为准,用 dsh 挂本地模型更适合隐私敏感的问答与轻任务,而不是重 agent 工作流。

vLLM 用户额外多两件顺手的工具:supportsThinkingTokenBudget(端点接受 thinking_token_budget 限制推理长度)与 chatTemplateKwargs(以 chat_template_kwargs 发送的参数,仅在两种 chat-template thinking 格式下被 pi-ai 读取)。若你的网关给 thinking 等级用了自己的词汇,reasoningEfforts 字典可以把每个等级的"线上拼写"改名,pi-ai README 示例 high: high 的结构,"每个键都是选择器提供的等级,其值是该等级对应的别名拼写"。

11. 多模型、默认模型与"纪元"

  • 配置好多个提供方后,模型选择器会列出所有已配置路由。选择语义有两句话(providers.zh.md 原话):

选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。

默认值的所有者是 ctx.agentDefaultModel 服务(docs/subsystems/core.zh.md):它独立于任何 Host 或传输层持有 ModelSelection(provider + model + 可选推理强度),Web UI、headless 等入口共享同一份状态。若已保存的默认值指向已删除的提供方,"输入框会显示选择模型,并在选择其他模型前阻止输入"------删路由之前想想还有会话指着它。

那"会话保留自身日志中的模型"是什么意思? 这就要请出本章最后一位主角:epoch(纪元)。dsh 的每个对话请求都是会话日志的纯函数,循环不从内存读配置,而是从日志重建请求。重建的锚点是 request/header 事件携带的 EpochHeader

js 复制代码
// 来自 docs/subsystems/session.zh.md(type-equiv 摘录)
interface EpochHeader {
  /** 调用配置(provider、model、推理强度与采样标量)。 */
  config: LlmCallConfig
  /** 由确切适配器(而非调用方提议)落实的配置字段标记。 */
  adapterDefaults?: LlmCallConfigAdapterDefaults
  /** 渲染后的系统提示词;无系统提示词的请求缺省。 */
  system?: string
  /** 已组装的工具 schema;无工具的请求缺省。 */
  tools?: ToolSchema[]
}
  • 快照的 reason 有四种,规则一句话说完(session.zh.md 原话):"带有 reason 'initial''resume' 的完整 request/header 快照记录每个 agent loop 实例的边界;请求变化时会追加 reason 为 'change' 的快照;未变的信封显式开启消息序列或跟随 surface 替换时,会追加 reason 为 'series' 的快照。"

  • 换言之:你在会话中途换了一次模型,信封(配置 + 系统提示词 + 工具清单)变了,日志里就会追加一条 reason: 'change' 的快照,从此这个会话的后续请求按新信封重建。上下文并没有丢(历史消息还在),但对提供方而言这是一个新的缓存域 :llm-deepseek README 原话"更换提供方或模型会选中不同的缓存域",KV cache 前缀复用从切换点之后重新开始。这就是"开新纪元"的成本与含义:对 dsh 是可重建性(每个请求都能从日志还原),对钱包是切换后第一批请求没有缓存折扣。

  • 画成时间线是这样的:

bash 复制代码
会话日志(request/header 快照流)
─► header{reason:'initial', model:qwen3:8b}          ← 会话第一次请求
─► header{reason:'series',  model:qwen3:8b}          ← 同一模型开启新消息序列
─► header{reason:'change',  model:deepseek-v4-pro,
                          startsSeries:true}          ← 你在会话中途换了模型:
─► header{reason:'series',  model:deepseek-v4-pro}   ←    新信封从这里接管
─► (后续 turn/step/重试沿用最新快照,不再追加)

两个实用推论。第一,想"用更强模型重做最后一步"时,原会话内切换是合理的,历史还在,只是切换点之后按新配置计费与缓存。第二,如果是整段任务 要换模型(比如从本地小模型迁到旗舰模型跑长任务),新开会话往往更划算:没有跨缓存域的断裂,也没有新旧 compat 断言混在一条日志里的心智负担。另外注意 reason: 'resume' 的存在意味着"恢复会话"本身也是一次信封登记,从日志里选出最新快照(foldRequestHeader(events))继续,而不是凭内存。

⚠️ 常见误区 :以为"会话内换模型"只是改个标签。它同时改变三件事:后续请求发往新路由(新凭据、新协议、新 compat 断言);EpochHeader 追加 change 快照;提供方侧缓存域作废重开。反过来,没发过请求的会话还没有任何快照,选中默认模型后直接按新配置开工,这也是为什么"删掉默认提供方"会让这些会话卡在"选择模型"上。

顺带一提失败侧的语义:每条路由可以带 retryPolicy(省略时 normal 模式、最多重试五次,由 dsh-llm-retry 执行);pi-ai 适配器把终止性失败区分为 QUOTA 与暂时性 RATE_LIMIT;"一次适配器调用就是一次提供方尝试",重试永远发生在 agent 层并打开新的持久轮次(docs/subsystems/llm-streaming.zh.md)。

12. 排错速查:providers.zh.md 排错节全解

  • 官方排错节的每一条都值得背下来,这里展开成"症状 → 根因 → 动作":

  • MISSING_CREDENTIAL:请求时凭据引用解析为空。动作:通过模型页存储提供方密钥,或提供被引用的环境变量。记住四层供值顺序与"空值即不存在"。

  • UNKNOWN_MODEL :请求的模型未在该路由配置。动作:"选择已配置的模型,或向自定义提供方添加缺失的模型。"手动填的模型名必须先出现在路由的 models 列表里。

  • 获取可用模型返回 401 :模型发现调用 GET /models。动作:检查密钥;端点不提供该端点就手动录入。

  • 密钥与地址都对,网关拒绝一切 :请求形状与 OpenAI 不同。动作:路由上设 compat.supportsDeveloperRole: falsecompat.maxTokensField: max_tokens

  • 只有推理模型失败 :系统提示词以 developer 角色发出被拒。动作:compat.supportsDeveloperRole: false

  • compat 开关因没有值被拒:冒号后留空。动作:给值,或删键以沿用 catalog。

  • 图片在发送前被拒 :模型未声明图片模态。动作:加 input: [text, image];pi-ai 路径下 DeepSeek 自身的 chat-completions 路由是纯文本且无法用配置改变。

  • 提供方拒绝带图片的请求 :声明了端点没有的能力。动作:从 inputdefaultInput 移除 image,然后开新会话------旧会话日志里的图片会反复出现在请求里。

动手实验

实验 1(主实验,约 10 分钟):接 Ollama 跑通一次对话

bash 复制代码
# 1. 启动本地服务并拉模型
ollama pull qwen3:8b
ollama list                      # 确认 qwen3:8b 在列

# 2. 验证 OpenAI 兼容层(这就是"获取可用模型"调用的端点)
curl http://127.0.0.1:11434/v1/models

# 3. 写入路由配置:编辑 $DSH_HOME/settings.yaml(Windows 默认 C:\Users\<你>\.dsh\settings.yaml)
#    内容即第 10 节的 yaml 示例(ollama-local 路由)

# 4. 带占位凭据启动
# PowerShell:
$env:OLLAMA_API_KEY = "ollama"; pnpm dsh web

预期:浏览器打开 http://127.0.0.1:3080,「设置 → 模型」出现 Ollama 本地 ;新建会话,模型选择器选 Qwen3 8B,发送"用一句话介绍你自己",能看到流式回复。

实验 2(约 3 分钟):验证凭据是"按请求解析"的

在实验 1 的会话里先发一条消息确认可用。然后另开一个终端 ,编辑 $DSH_HOME/.credentials.yaml(提供方管理的存储),把 OLLAMA_API_KEY 的值改成任意非空新值,保存后回到 Web UI 再发一条消息------不重启,新值立即生效(外部编辑会被存储观察者推送,凭据每次请求重新解析)。再把值清空保存,下一次请求应报 MISSING_CREDENTIAL。对照第 2 节的三条特性逐条验证。

实验 3(约 5 分钟,可选):亲手触发一次"断言不是检查"

ollama-local 路由的模型加一行 input: [text, image](保存,无需重启),在会话里拖一张本地图片发送。Ollama 的多数文本模型会直接报错或胡乱描述------提供方拒绝了 harness 忠实转发的请求。这证明 harness 不做模态检查,只按你的断言放行;把 input 改回 [text](或删掉该行)并开新会话再试,图片会在发送前被拒(回到漏声明的另一半语义)。

常见坑

症状 原因 解法
网关每个请求都 400,密钥/地址都对 请求形状与 OpenAI 不同(developer 角色 / max_completion_tokens 路由 compat.supportsDeveloperRole: false + compat.maxTokensField: max_tokensproviders.zh.md
只有推理模型失败 系统提示词以 role:"developer" 发出被拒 同上第一条开关
UNKNOWN_MODEL 请求的模型不在路由 models 列表 选择已配置模型,或补录模型 id
"获取可用模型" 401 密钥错,或端点无 GET /models 查密钥;手动录入模型
compat 开关被拒,报"没有值" YAML 冒号后留空 给值,或删键沿用 catalog
附加图片秒被拒(未发出) 模型未声明 image 模态 input: [text, image];pi-ai 下 DeepSeek 路由纯文本不可改
第三方/New API 模型不显示"推理等级" 模型未声明 reasoningEfforts(默认为"非推理模型") models[i] 下声明 reasoningEfforts(如 off: null / low: low / high: high / max: max
提供方拒绝带图请求,反复失败 声明了端点没有的图片能力;图片留在会话日志反复重发 移除 image 断言并开新会话
MISSING_CREDENTIAL apiKeyEnv 引用为空/未设 存密钥或设环境变量;注意空值即不存在
企业内网 TLS 报自签名证书错误 Node 默认只信内置 CA,不认企业自签根证书(社区常见,官方文档未涉及) 用企业根证书:设置 NODE_EXTRA_CA_CERTS 指向根证书链文件后重启 dsh;排查方向是先确认 curl 同报错即环境问题
公司代理后无法连官方 API 进程未继承代理环境变量(社区常见) 在启动 dsh 的同一 shell 设 HTTPS_PROXY/HTTP_PROXY;注意 llm-deepseek 用原生 fetch 直连,代理生效与否以实测为准
本地模型上下文行为怪异(回答被截断/计量失真) contextWindow 未设,落到 262,144 回退,与端点实际不符 手动设置与端点一致的 contextWindow
改了 Provider ID,历史会话对不上 Provider ID 是永久键,日志/凭据引用/默认值都按它存 不改 ID;重命名 = 加新提供方、删旧提供方

小结

  • dsh 的模型配置是二层模型:provider 是注册路由(端点 + 协议 + 凭据 + compat 断言的集合),model 是原样传给适配器的 id;目录仅供参考,不是请求白名单。
  • 自带两个孪生适配器:dsh-llm-deepseek 独占 deepseek-official 直连路由(Files API 图片管线),dsh-llm-pi-ai 服务目录提供方与任意自声明网关;路由名不冲突,可同时挂载。
  • 一切模型变更在下一次请求生效,无需重启;密钥只写在 $DSH_HOME/.credentials.yaml,settings 里只有 apiKeyEnv 引用,按请求重新解析(改密钥不用重启,改进程环境变量要重启)。
  • 自定义提供方的 Provider ID 小写且永久;"获取可用模型"调 OpenAI 兼容 GET /models,失败就手填。
  • 模态与 compat 都是"断言不是检查":harness 不探测端点,写错的能力由提供方在请求中途拒绝,漏写的能力在发送前被拒。
  • reasoningEffortsinput 同属"模型级能力断言":不声明就是"非推理模型",选择器不显示"推理等级";在 models[i] 下声明即可让第三方/New API 模型也显示(无需重启),默认档位由路由级 reasoning 字段控制。
  • compat 优先级链:模型级逐字段胜出路由级 → catalog → pi-ai 检测;空值键被拒绝;开关随协议而异;修网关 400 的第一动作是 supportsDeveloperRole: false + maxTokensField: max_tokens
  • 换模型 = 新的 EpochHeaderreason: 'change')+ 提供方缓存域重开;会话日志让每个请求可重建。
  • 本地模型靠 OpenAI 兼容路由接入,必须给占位凭据、手动对齐 contextWindow,并对小模型的工具调用质量降低预期。

参考资料

官方文档(仓库路径):

  • docs/user/guide/providers.zh.md------本章主干:DeepSeek 卡片、目录/自定义提供方、inputdefaultInputcompat、排错节
  • docs/subsystems/llm-streaming.zh.md------GenerateOptionsLlmAdapter 约定、LlmFailure、辅助调用 purpose
  • docs/subsystems/session.zh.md------request/header 事件与 EpochHeader(reason: initial/resume/change/series)
  • docs/subsystems/settings.zh.md------settings namespace 三层解析与 settings/updated
  • docs/subsystems/credentials.zh.md------凭据引用、按操作解析、env/file/project-env/user-env 四层
  • docs/subsystems/core.zh.md------ctx.agentDefaultModel 默认模型选择
  • docs/config-catalog.zh.md------dsh-llm-pi-aiPiAiProviderProfile / PiAiModelProfile / PiAiCompatProfile(全部字段真源)

源码与包参考:

  • packages/llm/llm/README.zh.md------dsh-llm 服务
  • packages/llm/llm-pi-ai/README.zh.md------多提供方适配器:profile 字段表、登录、发现、失败码、已知限制
  • packages/llm/llm-deepseek/README.zh.md------直连适配器:字段表、thinking、图片管线、失败码
  • packages/llm/llm-retry/------按路由 retryPolicy 的重试执行器
  • packages/llm/llm/src/types.ts------ModelModalityMap(text/image 词汇表)

外部资料:

相关推荐
Blockbuater_drug3 小时前
MCP Server 接入实战: 9种平台配置差异与凭证安全
claude·cursor·mcp·openclaw·hermes agent·dsh·agent 配置
缘友一世4 天前
DeepSeek Harness(dsh)从零到全栈【3】核心概念地图:一篇讲透DSH全部术语
dsh
风fffff5 天前
dsh-project-memory v0.3.0到v0.4.0:从项目记忆到开发工作流记忆的演进
ai·agent·插件·dsh·deepseek harness
缘友一世5 天前
DeepSeek Harness(dsh)从零到全栈【1】认识 DeepSeek Harness:Agent、Harness 与“一切皆插件“
dsh·deepseek agent
张忠琳6 天前
【deepseek-harness】Cordis 开源项目深度介绍
ai·agent·deepseek·harness·cordis·dsh
程序员三明治6 天前
【体验毛坯房】Deep Harness 入门教程
java·人工智能·后端·大模型·llm·deepseek·dsh
张忠琳8 天前
【deepseek-harness】Cordis 时空可组合性编程范式 — 三段式精读笔记(四)
ai·agent·deepseek·harness·cordis·dsh
张忠琳8 天前
【deepseek-harness】Cordis 时空可组合性编程范式 — 三段式精读笔记(五)
ai·agent·deepseek·harness·cordis·dsh
张忠琳9 天前
【deepseek-harness】Cordis 时空可组合性编程范式 — 三段式精读笔记(二)
ai·agent·deepseek·harness·cordis·dsh