第04章:模型配置与供应商体系
第三章用官方 DeepSeek 卡片把流程跑通了,但 dsh 的模型层远比一个 key 输入框复杂:它支持目录供应商、自定义 OpenAI 兼容端点、视觉模型、热更新,以及两个风格迥异的内置适配器。本章把这条体系讲透——每个配置键的实义、两条认证路径的区别、Provider ID 为什么永久不可改,以及常见的排障对照。
前置要求:已通过第三章跑通 Web UI 首次交互,知道 Settings → Models 入口与
$DSH_HOME的位置。
参考原文:本章内容对应仓库 providers.md 与两个适配器的 README(llm-deepseek、llm-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 的机制,分两层:
- UI/文件保存 → 下次请求用新值:适配器通过 thunk(惰性取值函数)每次操作重读配置,连接事实(端点、目录、请求默认值、空闲预算)在下一次请求生效。
- 已在飞行的流保持旧值:一个正在跑的请求沿用它启动时的配置事实,不被打断。
这带来一个实操结论:改配置是”无感”的——改完直接发请求即可,不存在”重启生效”的仪式。这也是 4.11 要讲的”无重启覆盖”的基础。
4.1.2 文件位置
$DSH_HOME 是 dsh 的 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 只接受 text 和 image,且只作用于写它的那个模型——所以同一个 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 defaultInput 与 modelOverrides 的优先级
关键理解: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]
两条硬规则:
- 除了模型自己的
input(空列表等于省略),每个列表都必须至少命名一种模态;未知模态无论写在哪都被拒绝。 - 这两个字段声明的是你对端点的断言,而非真的去验证端点。模型声明吃图但端点其实不吃,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 关于 models 与 modelOverrides
两者常被当成”同一件事的两种写法”,其实是两种截然不同的写意图(详见 4.11):
| 键 | 写意图 | 语义 |
|---|---|---|
models |
整体重定义 | 数组整体替换目录,列表里没写的模型都不再服务 |
modelOverrides |
逐项修正 | 只改 key 指定的那几个目录模型,其余 37 个照旧 |
一个目录路由写 models 列表,意味着”从此以后只服务这个列表里的模型”;写 modelOverrides 意味着”改这几个,别的别动”。
4.7.3 完整字段集
完整字段集比这张表更宽:displayName、compat(推理方言开关)、reasoning、thinkingBudgets、cacheRetention、transport、websocketConnectTimeoutMs、streamIdleTimeoutMs 等。以 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 是官方思考档位。值得记住两条规则:
off不会上线:适配器把off映射成thinking: {type: 'disabled'},且绝不把reasoning_effort: 'off'发到线上。一个不受支持的档位会在网络 I/O 之前报UNSUPPORTED_REASONING_EFFORT。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 声明一个非推理模型——这就是把目录模型的推理能力剥掉的写法。
models 与 modelOverrides 在 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 表里 models 与 modelOverrides 的语义分野——它们不是两个名字同一件事,而是”整体”与”逐项”两种截然不同的写意图。
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.yaml加input: [text, image];defaultInput是兜底非覆盖,目录模型收窄用modelOverrides。 - 配置键实义:路由名、
apiKeyEnv(引用非明文)、api、baseURL、models(整体替换)、modelOverrides(逐项改)、defaultContextWindow、defaultMaxTokens、defaultInput、headers、timeoutMs、retryPolicy。 - 两个适配器:llm-deepseek(
deepseek-official路由、默认 v4-flash/v4-pro、约 1M 上下文、reasoningEffort四档、可指 OpenAI 兼容网关、仅流式、稳定错误码)与 llm-pi-ai(多供应商字典、基于 pi-ai、reasoningEfforts档位声明、compat方言开关)。 - 选模型 = 新 session 默认值;已发请求的 session 锁定其 log 里的模型。
- 排障四类:
MISSING_CREDENTIAL、UNKNOWN_MODEL、发现 401、图片被拒/被 provider 拒。 llm-deepseek:/llm-pi-ai:段无重启覆盖;models数组整体替换不逐项合并(已知行为)。
下一步:第五章:CLI 与 Profile / Bundle 体系进入命令行侧与组合配置。