znlgis 博客

GIS开发与技术分享 — GDAL · GeoServer · PostGIS · QGIS · OpenLayers · Cesium · FreeCAD · NPOI

第04章:模型配置与供应商体系

第三章用官方 DeepSeek 卡片把流程跑通了,但 dsh 的模型层远比一个 key 输入框复杂:它支持目录供应商、自定义 OpenAI 兼容端点、视觉模型、热更新,以及两个风格迥异的内置适配器。本章把这条体系讲透——每个配置键的实义、两条认证路径的区别、Provider ID 为什么永久不可改,以及常见的排障对照。

前置要求:已通过第三章跑通 Web UI 首次交互,知道 Settings → Models 入口与 $DSH_HOME 的位置。

参考原文:本章内容对应仓库 providers.md 与两个适配器的 README(llm-deepseekllm-pi-ai)。字段名与默认值以 master 为准。

4.1 配置入口:Web UI 与底层文件

模型配置有两个入口,一个面向人,一个面向文件:

入口 位置 作用
Web UI Settings → Models 填 key、加供应商、选模型,图形化操作
底层文件 $DSH_HOME/settings.yaml 所有非密钥配置的落盘位置
底层文件 $DSH_HOME/.credentials.yaml 密钥的落盘位置(第三章讲过,write-only)

Web UI 的每一次保存,最终都写成这两个 YAML 文件;反过来,直接编辑 settings.yaml 也能改配置。二者之间没有谁是”唯一真相”——文件是底,UI 是其上的编辑面。

4.1.1 热重载的两个层次

官方指南明确”模型变更在下一次请求生效,无需重启 server”。这句话背后是 settings seam 的机制,分两层:

  1. UI/文件保存 → 下次请求用新值:适配器通过 thunk(惰性取值函数)每次操作重读配置,连接事实(端点、目录、请求默认值、空闲预算)在下一次请求生效。
  2. 已在飞行的流保持旧值:一个正在跑的请求沿用它启动时的配置事实,不被打断。

这带来一个实操结论:改配置是”无感”的——改完直接发请求即可,不存在”重启生效”的仪式。这也是 4.11 要讲的”无重启覆盖”的基础。

4.1.2 文件位置

$DSH_HOMEdsh 的 home 目录(未显式设置时默认 ~/.dsh)。两个 YAML 文件都放在这里,与项目目录无关——这意味着配置是全局的,workspace 是局部的:模型/凭据在所有项目间共享,workspace 每次会话另选。

4.2 官方 DeepSeek 卡片

Settings → Models 页面最上方是 DeepSeek 官方卡片。它只暴露一个字段:API key。填进去保存即可,目录里的端点、协议、模型列表都已内置,不需要你补任何东西。

再次强调第三章 3.8 的存储规则,它在所有供应商上一致:

  • 密钥是 write-only:保存后页面只回显脱敏描述符,永不回显字面密钥;
  • 密钥存 $DSH_HOME/.credentials.yaml
  • settings.yaml 里只保留对该凭据的引用,不写密钥本体。

所以官方卡片背后其实是”内置适配器 + 一条凭据引用”,它和自定义供应商走的是同一套底层机制,只是把细节藏起来了。这张卡片对应的是 4.8 要讲的 llm-deepseek 适配器,其默认凭据引用是环境变量 DEEPSEEK_API_KEY

4.3 目录供应商:两条认证路径

Add provider 打开供应商目录,选择 Anthropic、OpenAI 等。这里有一条关键区分——不是所有供应商都靠填 API key

类型 供应商 认证方式
API key 型 Anthropic、OpenAI 直接填 API key
原生认证型 Bedrock AWS 凭据 + region
原生认证型 Vertex ADC(应用默认凭据)+ project
原生认证型 Azure provider 环境 + api-version
原生认证型 Codex OAuth

对于 API key 型,填 key 保存即可,已安装的目录自动提供端点、协议与模型列表。

对于原生认证型,官方指南的原话是:只填 API-key 字段不能配置它们(filling only the API-key field does not configure them)。因为它们不走 Authorization: Bearer 这条通用路径,而是各自的原生机制:

供应商 原生认证细节
Bedrock 用 SigV4 签名,凭据来自 AWS 配置 + 指定 region
Vertex 需要 project、location,凭据来自应用默认凭据(ADC,如 gcloud
Azure 需要 provider 环境变量 + api-version
Codex 走 OAuth 流程

这些凭据来源不在 UI 的 key 框里,而来自你主机上的环境(AWS 配置、gcloud ADC 等)。你在这些供应商的卡片里填的 key 不会被用上。

为什么这么分?这是 llm-pi-ai 适配器 supportedProtocols() 刻意收窄的结果:一个 route 只有能”用 key + 端点 + headers 完整描述”时才进目录的通用表单。Bedrock 这类需要额外签名参数的原生协议被挡在通用表单之外,只能走各自 provider 的原生通道(详见 4.8)。

4.4 自定义 OpenAI 兼容端点

对公司网关、自托管服务、或目录里没有的供应商,用 Add a custom provider。表单要求:

字段 要求
Provider ID 小写(lowercase),永久不可改
显示名(display name) 可改
base URL 端点地址
API protocol 协议
凭据(credential) key 或引用
模型 至少一个 model

4.4.1 Provider ID 为什么永久不可改

这是本章最重要的一个概念。Provider ID 一旦定下就不能改,官方给出的原因是:请求、已保存的 session、模型默认值、凭据引用都依赖它

展开说,Provider ID 是”路由名”(route name),它出现在至少四处,且每处都是持久化的引用:

依赖点 具体表现 ID 改了会怎样
请求 GenerateOptions.provider 用它选中路由 旧请求指向不存在的路由
已保存的 session session log 里记录的就是这个路由名 历史 session 对不上号
模型默认值 默认模型引用某 provider 下的 model 引用悬空,composer 显示 Select model 并阻止输入
凭据引用 apiKeyEnv 等引用锚定在路由上 凭据解析链断裂

所以官方给的改名姿势是:新增一个 provider,再删掉旧的。显示名、base URL、协议、凭据、模型列表都是可编辑的,只有 Provider ID 是锚点。

# 一个典型的自定义端点,路由名 my-gateway 就是 Provider ID
llm-pi-ai:
  providers:
    my-gateway:
      apiKeyEnv: GATEWAY_API_KEY
      api: openai-completions
      baseURL: https://gateway.example/v1
      models:
        - id: legacy-chat

这也解释了为什么表单让你一次性定好 Provider ID 并锁定它——它不是一个”显示标签”,而是遍布持久化数据的结构键。

4.5 模型发现:Fetch available models

自定义 provider 表单里有个 Model catalog 区,点 Fetch available models 会用表单当前显示的 base URL 与凭据去查询端点,返回候选模型列表。注意三个语义细节:

细节 说明
查询端点 OpenAI 兼容的 GET /models,没有此端点的网关查不到
草稿语义 选中候选只更新草稿,供应商要等点保存才真正落盘
目录供应商 不走网络请求,直接用已安装目录里的模型列表

所以”发现模型”是便利功能,不是唯一途径。对不实现 GET /models 的端点(很多自托管网关),直接在表单里手填 model id 即可。这也意味着:模型目录的最终真相是 settings.yaml,不是一次网络探测的结果——发现只是把候选填进草稿,你仍然要保存。

4.6 视觉模型与 input 配置

一个容易踩坑的点:手填的模型默认是纯文本。原因很朴素——没有谁能问端点”你接受哪些模态”,所以对任何手填模型,harness 保守地假设它只吃文本。

后果是:往这样的模型上附图片,会在发送前就被拒绝,并点名是哪个模型拒绝的。要让一个自定义 provider 上的视觉模型接受图片,表单里没有对应字段,需要在 $DSH_HOME/settings.yaml 里给模型加一行 input

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 只接受 textimage,且只作用于写它的那个模型——所以同一个 route 可以同时服务纯文本模型和视觉模型。

如果所有手填模型都吃图,就设一次 route 级兜底,而不是每个模型写一遍:

llm-pi-ai:
  providers:
    vision-gateway:
      apiKeyEnv: GATEWAY_API_KEY
      api: openai-completions
      baseURL: https://vision.example/v1
      defaultInput: [text, image]
      models:
        - id: first-model
        - id: second-model

4.6.1 defaultInputmodelOverrides 的优先级

关键理解:defaultInput兜底(fallback)而非覆盖(override),默认值 [text]。模态的解析顺序是:

模型自己的 input  →  目录对该模型的记录  →  route 的 defaultInput(默认 [text])
配置 角色 行为
模型的 input 该模型自己的声明 最优先
目录模型自带记录 已安装目录对该模型的记录 中间层
route 的 defaultInput 兜底 只在目录没有描述该模型时回答

一个微妙处:在目录供应商上,defaultInput 只回答目录没描述的模型,所以它永远不会把一个目录里本来就带图的模型削成纯文本。要收窄某个目录模型(比如不想让它吃图),因为目录供应商没有 models 列表可写,得用 modelOverrides,以 model id 为键:

llm-pi-ai:
  providers:
    anthropic:
      modelOverrides:
        claude-sonnet-4-5:
          input: [text]

两条硬规则:

  1. 除了模型自己的 input(空列表等于省略),每个列表都必须至少命名一种模态;未知模态无论写在哪都被拒绝。
  2. 这两个字段声明的是你对端点的断言,而非真的去验证端点。模型声明吃图但端点其实不吃,harness 抓不到——要等 provider 在请求时拒绝。

“省略或空列表”语义容易搞混,理清一下:模型自己的 input 省略或写空列表,意思相同——”未声明”,于是解析继续往下走到目录记录或 defaultInput。而 route 的 defaultInput 不允许为空,因为它下面没有别的兜底了。

4.7 配置键实义

把上一节散落的键收拢成一个完整清单。下列键属于 llm-pi-ai 的 provider profile(自定义端点和目录覆盖通用):

实义
路由名(providers: 下的键) Provider ID,选中路由的名字,见 4.4
apiKeyEnv 凭据引用,每次请求按它解析;本文件不落密钥。省略则路由不认证
api 协议,如 openai-completions。只在目录无法提供协议时需要
baseURL 该路由所有模型的端点;目录路由省略则沿用各模型的端点
models 模型列表,整体替换目录,而非追加
modelOverrides 以 model id 为键,只改目录中个别模型,其余保留
defaultContextWindow 目录/条目都无尺寸时的上下文兜底(pi-ai 默认 262,144)
defaultMaxTokens 无输出上限时的兜底(pi-ai 默认 32,768)
defaultInput 模态兜底,默认 [text],见 4.6
headers 额外请求头;harness 的应用署名头优先于同名配置头
timeoutMs 传输超时
retryPolicy 重试策略;省略用有界默认值

4.7.1 关于 apiKeyEnv 与凭据

apiKeyEnv 不是把 key 写死在这里,而是一个引用名——真正取值时按它到凭据层(.credentials.yaml)或环境变量里去找。所以 settings.yaml 里永远看不到密钥明文。省略 apiKeyEnv 会让路由处于”未认证”状态:对目录路由这意味着回退到 pi-ai 的原生环境发现;对 OpenAI 兼容端点,本地无 key 服务器需要一个占位凭据(或 headers 里的 Authorization 项)才能跑。

4.7.2 关于 modelsmodelOverrides

两者常被当成”同一件事的两种写法”,其实是两种截然不同的写意图(详见 4.11):

写意图 语义
models 整体重定义 数组整体替换目录,列表里没写的模型都不再服务
modelOverrides 逐项修正 只改 key 指定的那几个目录模型,其余 37 个照旧

一个目录路由写 models 列表,意味着”从此以后只服务这个列表里的模型”;写 modelOverrides 意味着”改这几个,别的别动”。

4.7.3 完整字段集

完整字段集比这张表更宽:displayNamecompat(推理方言开关)、reasoningthinkingBudgetscacheRetentiontransportwebsocketConnectTimeoutMsstreamIdleTimeoutMs 等。以 config-catalog 为准。llm-deepseek 适配器的键另有一套,见 4.8。

4.8 两个内置适配器:llm-deepseek 与 llm-pi-ai

dsh 的 LLM seam 上挂着两个功能重叠、实现迥异的适配器。理解它们的定位,很多配置问题就迎刃而解。

4.8.1 llm-deepseek:官方 DeepSeek 专用

包名 @deepseek-ai/dsh-llm-deepseek,直接用 fetch + SSE 翻译官方线格式。它拥有 deepseek-official 路由——刻意与 pi-ai 目录里的 deepseek 区分,让一次组合可以并排挂两条 DeepSeek 路径。

- id: llm-deepseek
  name: '@deepseek-ai/dsh-llm-deepseek'
  config:
    apiKeyEnv: DEEPSEEK_API_KEY          # 默认;每次请求经凭据层解析
    baseURL: https://api.deepseek.com     # 省略时走 $DEEPSEEK_BASE_URL 再走公共 API
    thinking: enabled                    # 可选;provider 默认 enabled
    reasoningEffort: high                # off | low | high | max;省略默认 high
    maxTokens: 256000                    # 每次请求的输出上限,这是默认值
    streamIdleTimeoutMs: 300000          # 流空闲超时,默认五分钟
    retryPolicy:
      mode: always
      backoff:
        initialDelayMs: 500
        maxDelayMs: 10000
        jitterRatio: 0.1
    defaultContextWindow: 1000000        # 上下文兜底,默认 1M
    models:
      - id: deepseek-v4-flash
        name: DeepSeek-V4-Flash
      - id: private-reasoner
        description: Company-hosted reasoning model
        contextWindow: 512000

要点:

特性 说明
默认目录 省略 models 时暴露 deepseek-v4-flash(DeepSeek-V4-Flash)与 deepseek-v4-pro(DeepSeek-V4-Pro),各约 1,000,000 token 上下文
models 语义 显式列表整体替换默认值;models: [] 暴露空目录
reasoningEffort off / low / high / max 四档;off 序列化为 thinking.type: disabled 且省略 reasoning_effort,其余档序列化为官方顶层 reasoning_effort
thinking: disabled 部署级锁,只暴露 off,请求级强行开思考会在网络 I/O 前失败
baseURL 可指向 OpenAI 兼容网关(模型 id 原样透传,换 DeepSeek 模型无需生命周期级注册)
流式 仅流式stream_options.include_usage 恒开

推理档位与 reasoning_content 回传

reasoningEffort 是官方思考档位。值得记住两条规则:

  1. off 不会上线:适配器把 off 映射成 thinking: {type: 'disabled'},且绝不把 reasoning_effort: 'off' 发到线上。一个不受支持的档位会在网络 I/O 之前报 UNSUPPORTED_REASONING_EFFORT
  2. reasoning_content 回传规则:带工具调用的 assistant 轮必须把 reasoning_content 序列化回历史(thinking 模式下 API 要求如此),不带工具调用的轮则丢弃以省 token。这是官方线格式的硬约束,适配器替你处理了。

错误码一览

非 2xx 响应会抛稳定的 LlmError 错误码,这里列主要的:

错误码 触发
AUTH 401/403
QUOTA 余额/额度/积分耗尽
RATE_LIMIT 其他 429
CONTEXT_WINDOW_EXCEEDED 400 且识别为上下文溢出
INVALID_REQUEST 其他 400
SERVER 5xx
TRANSPORT 预响应传输失败(DNS、拒连、TLS、代理),点名端点
TIMEOUT 流空闲超时
STREAM_CLOSED [DONE] 就结束
MALFORMED_RESPONSE 坏 JSON
EMPTY_RESPONSE 完成但无内容块,默认策略会重试

这些码比裸 HTTP 状态更有语义,是排障时首先要看的东西。

4.8.2 llm-pi-ai:通用多供应商

包名 @deepseek-ai/dsh-llm-pi-ai,基于 @earendil-works/pi-ai。一个插件实例持有以路由为键的 provider profile 字典

- id: llm
  name: '@deepseek-ai/dsh-llm-pi-ai'
  config:
    providers:
      # 目录路由:端点、协议、模型全来自 pi-ai
      openai:
        apiKeyEnv: OPENAI_API_KEY
        baseURL: https://proxy.example.com:8443
      # 目录路由:目录收窄到单个模型并修正其容量
      anthropic:
        apiKeyEnv: ANTHROPIC_API_KEY
        models:
          - id: claude-sonnet-4-5
            contextWindow: 200000
      # 手写路由:pi-ai 不内置这个 key,profile 提供完整定义
      acme-gateway:
        displayName: Acme Gateway
        apiKeyEnv: ACME_GATEWAY_API_KEY
        api: openai-completions
        baseURL: https://gateway.acme.example/v1
        models:
          - id: acme-large
            name: Acme Large
            contextWindow: 65536
            maxTokens: 4096
特性 说明
目录路由 命名一个 pi-ai 内置 provider 时,端点/协议/模型目录作默认,逐字段覆盖
手写路由 pi-ai 不内置该 key 时,profile 完整定义 provider——新增网关/自托管/目录更新的供应商是配置而非改代码
休眠态 providers 为空或省略时挂载为休眠:零路由,等 llm-pi-ai: settings 段供 profile 时再注册
models vs modelOverrides models 整体替换目录;modelOverrides 只改目录中个别模型,其余保留
每模型推理档位 reasoningEfforts:key 是选择器展示的档位,value 是上线的拼写,如 max: ultra 给自有词汇的网关改名
compat thinkingFormat / supportsReasoningEffort 等推理方言开关;URL 无法识别的私有 DeepSeek 方言网关用 compat.thinkingFormat: deepseek 纠正
supportedProtocols() 刻意比 pi-ai 全集窄:只含”key + 端点 + headers 能完整描述”的协议。这正是 4.3 原生认证供应商被挡在通用表单外的原因

推理档位声明

reasoningEfforts 声明一个模型可选哪些思考档位:key 是选择器展示的档位(来自 pi-ai 的档位集 off/minimal/low/medium/high/xhigh/max),value 是发到线上的拼写。所以 high: high 是原样透传,而 max: ultra 是给一个自有词汇的网关改名。没声明的档位不提供;false 声明一个非推理模型——这就是把目录模型的推理能力剥掉的写法。

modelsmodelOverrides 在 pi-ai 里的完整语义

一个目录路由写 models 列表,等于”从此只服务列表里的模型”:列表里每个条目未写的字段默认从目录同名模型继承,所以把目录收窄到两个模型、修正一个容量、加一个新模型都是一行编辑——但一旦写 models路由还要服务的每个模型都必须出现在列表里(哪怕一个只有 id 的条目)。

modelOverrides 则没有这个成本:每个 key 是一个目录模型 id,value 是 models 条目同款字段(id 放在 key 里),其余目录照常服务——”改一个模型,其余三十七个不动”就是三行编辑。

两者差异一句话:llm-deepseek 是”一个供应商的深度专用实现”,llm-pi-ai 是”一堆供应商的广度通用实现”。同一 provider 路由被两个适配器同时注册会抛 DUPLICATE_ADAPTER,所以别让两条配置争抢同一个名字。

4.9 默认模型语义

模型选择器里”选模型”这件事,语义比看起来重:

场景 行为
选择模型 同时把它设为新 session 的默认模型
已发过请求的 session 保留自己 log 里记录的模型,不跟随默认值
默认模型指向已删除的 provider composer 显示 Select model 并阻止输入,直到另选模型

所以要分清楚”新 session 默认值”和”既有 session 的锁定模型”是两个不同的东西。后者之所以能锁定,是因为模型名写进了 session 自己的 log(这正是第三章 3.10 埋的伏笔,详见第十章)。一个 session 一旦动起来,就按它 log 里的模型走,你改默认值不影响它。

4.10 排障对照表

症状 原因 处理
MISSING_CREDENTIAL 路由没有可用 key 通过 Models 页存 key,或提供所引用的环境变量
UNKNOWN_MODEL 选中的 model 不在路由目录里 选一个已配置模型,或把缺失模型加进自定义 provider
模型发现返回 401 key 错误 查 key;发现走 OpenAI 兼容 GET /models,无此端点的端点手填模型
图片发送前被拒 模型声明无图模态 给自定义 provider 的模型加 input: [text, image];DeepSeek 官方 chat-completions 路由纯文本,不可配置
provider 拒绝携带图片的请求 模型声明了端点实际不吃的图 从授予它的列表(模型 input 或 route defaultInput)移除 image,然后开新 session

最后一条值得强调:错误信息里”图片留在 session log”是理解 dsh 的一个窗口——session 是有持久状态的,某些失败无法在旧 session 里原地修复,必须新开。附带图片的消息一旦进入 session log,旧 session 会反复重发同一个注定失败的请求,直到 session 离开它。

4.11 无重启覆盖与 models 整体替换

两个适配器都把 settings 命名空间注册到了 settings seam 上,因此 settings.yaml 里写同名段可以无重启覆盖适配器配置:

  • llm-deepseek: 段覆盖 DeepSeek 适配器任何字段,下次请求生效;
  • llm-pi-ai: 段与组合里的 base 配置按 provider 逐项合并——可以加路由、改某个字段、把路由指向另一个代理。

为什么 llm-pi-ai 能”逐项合并”而 llm-deepseek 是”整体覆盖”?因为 llm-pi-ai 的 providers字典,settings 层可以按 provider 键合并;llm-deepseek 的配置是平铺字段,逐字段覆盖即可。两者都在”无重启生效”这一点上一致。

但有一条已知行为要记住:

models 数组是整体替换,不做逐项合并。

原因在于 settings 层的合并是逐字段的,而数组是一个字段。想”改目录里一个模型”用 modelOverrides(逐模型),想”整体重定义目录”用 models(整体替换)。这是 llm-deepseek README 明确列在 Known Limitations 里的设计权衡,不是 bug:逐项合并需要一个 keyed 结构,当前数组形态做不到。

这解释了 4.7 表里 modelsmodelOverrides 的语义分野——它们不是两个名字同一件事,而是”整体”与”逐项”两种截然不同的写意图。

4.12 本章小结

  • 配置入口是 Web UI(Settings → Models)与底层 $DSH_HOME/settings.yaml + .credentials.yaml 的映射,热重载让改动下次请求生效;配置是全局的,workspace 是局部的。
  • 官方 DeepSeek 卡片只填 API key;key 是 write-only,只回显脱敏描述符。
  • 目录供应商分两条认证路径:Anthropic/OpenAI 填 key;Bedrock/Vertex/Azure/Codex 走原生认证(AWS 凭据 + region、ADC project、api-version、OAuth),只填 key 无效。
  • 自定义 OpenAI 兼容端点要 Provider ID(小写、永久不可改)、base URL、协议、凭据、至少一个模型;Provider ID 因请求/session/默认模型/凭据引用四处依赖而不可改,改名 = 新增 + 删除。
  • Fetch available models 调 GET /models,无此端点则手填;手填模型默认纯文本。
  • 视觉模型在 settings.yamlinput: [text, image]defaultInput 是兜底非覆盖,目录模型收窄用 modelOverrides
  • 配置键实义:路由名、apiKeyEnv(引用非明文)、apibaseURLmodels(整体替换)、modelOverrides(逐项改)、defaultContextWindowdefaultMaxTokensdefaultInputheaderstimeoutMsretryPolicy
  • 两个适配器:llm-deepseek(deepseek-official 路由、默认 v4-flash/v4-pro、约 1M 上下文、reasoningEffort 四档、可指 OpenAI 兼容网关、仅流式、稳定错误码)与 llm-pi-ai(多供应商字典、基于 pi-ai、reasoningEfforts 档位声明、compat 方言开关)。
  • 选模型 = 新 session 默认值;已发请求的 session 锁定其 log 里的模型。
  • 排障四类:MISSING_CREDENTIALUNKNOWN_MODEL、发现 401、图片被拒/被 provider 拒。
  • llm-deepseek: / llm-pi-ai: 段无重启覆盖;models 数组整体替换不逐项合并(已知行为)。

下一步:第五章:CLI 与 Profile / Bundle 体系进入命令行侧与组合配置。