← 返回文章列表

把 MiniMax-M3 装进 DeepSeek Harness:一个完整的过程报告

摘要

DeepSeek Harness(DSH)是一个把\\\"模型 + 工具 + 会话 + GUI\\\"做成可插拔件(cordis plugin)的桌面 / Web Agent 运行时。本次任务是给它接入一个新模型——MiniMax-M3——作为除官方 DeepSeek-V4 之外的第二条 provider 路线。我把全过程跑下来,把\\\"DSH 怎么把一个外部模型纳入它自己的体系\\\"这个机制讲清楚。

把 MiniMax-M3 装进 DeepSeek Harness:一个完整的过程报告 — 题图

写在前面

DeepSeek Harness(DSH)是一个把"模型 + 工具 + 会话 + GUI"做成可插拔件(cordis plugin)的桌面 / Web Agent 运行时。本次任务是给它接入一个新模型——MiniMax-M3——作为除官方 DeepSeek-V4 之外的第二条 provider 路线。我把全过程跑下来,把"DSH 怎么把一个外部模型纳入它自己的体系"这个机制讲清楚。

把 MiniMax-M3 装进 DeepSeek Harness:一个完整的过程报告 — 段落图

整篇文章对应三个实际产出物:

  • ~/.dsh/settings.yaml —— 用户侧配置
  • ~/.dsh/.credentials.yaml —— 用户侧密钥
  • 两个 vendor 补丁(@earendil-works/pi-aiopenai-completions.js@deepseek-ai/dsh-llm-pi-ailib/index.js)—— 给 pi-ai 加 minimax 方言 + 给 dsh-llm-pi-ai schema 加白名单

以及 6 个验证脚本,全部跑通:

  • verify.mjs(文本 + reasoning seam)
  • gui.mjs(真实 GUI 路径 + 截图)
  • verify-image.mjs(图片输入多模态)
  • verify-tool-call.mjs(工具调用两轮 loop)
  • verify-runtime.mjs + mock-server.mjs(cache / abort / timeout / headers)

1. DSH 的插件模型(先讲清,再讲改造)

DSH 的运行时是一个 cordis 容器。每一个能力都是一个独立的 npm 包,遵循 cordis 的 plugin 约定:

@deepseek-ai/dsh-llm  → LLM runtime seam(ctx.llm.stream / listModels / resolveModelInfo)
@deepseek-ai/dsh-llm-deepseek  → 原生 DeepSeek 适配器,注册路由 `deepseek-official`
@deepseek-ai/dsh-llm-pi-ai  → 通用多 provider 适配器,封装了 @earendil-works/pi-ai
@deepseek-ai/dsh-credentials  → 凭据引用解析(ctx.credentials.resolve(ref))
@deepseek-ai/dsh-credentials-local → 从 ~/.dsh/.credentials.yaml 读
@deepseek-ai/dsh-settings  → settings seam(settings.mutate / namespace 注册)
@deepseek-ai/dsh-settings-file  → 从 ~/.dsh/settings.yaml 读
@deepseek-ai/dsh-attachment  → attachment seam(ctx.attachments.saveImage / readImage)
@deepseek-ai/dsh-attachment-local  → 文件持久化 attachment store

路由(route)是 DSH 给 LLM provider 取的名字。新增一条 provider 路线 = 在 settings 里加一个 providers.<key> profile,schema 校验通过即注册成功;删除 = 清空 profile 即撤销。两件事由不同插件各司其职dsh-llm-pi-ai 注册 models.streamSimple 的实现细节,dsh-credentials-local 决定密钥从哪个文件来。运行时(dsh-llm)不直接读 API key,而是每次 stream 时调 ctx.credentials.resolve(apiKeyEnv),按"请求解析"的方式取密钥。这样密钥零持久化漂移到进程环境,也不会泄漏到 LLM request 的任何字段之外。

更重要的是,DSH 的 web bundle 用 typert registry + client bundle 装载插件,客户端 plugins 通过 window.__DSH_BOOT__ 这个内联 JSON 数组声明依赖注入关系(inject: ['@deepseek-ai/cordis-connection', '…']),typert 客户端 runner 按依赖顺序加载。所以 web 端不需要重新构建就能热加载新插件——只要 settings.yaml 改动,hot-reload 就会把新 provider 推到客户端。

2. profile 的最小完备集

dsh-llm-pi-aiPiAiProviderProfile schema(z.object)要求 hand-declared route 必须三件齐全:

字段 必须 含义
api 协议标识,MiniMax 是 OpenAI 兼容 → openai-completions
baseURL 端点,https://api.minimaxi.com/v1
models 非空模型列表,至少一个 id
apiKeyEnv 推荐 凭据引用名,配合 dsh-credentials-localMINIMAX_CN_API_KEY 解析
compat 可选 thinking 格式、是否支持 reasoning_effort 等协议方言开关
displayName 可选 选择器里看到的名字

把 MiniMax-M3 装进 DeepSeek Harness:一个完整的过程报告 — 段落图

最初用户在 ~/.dsh/settings.yaml 里只写了 apiKeyEnv: MINIMAX_CN_API_KEY 这一行。三件必填字段(api / baseURL / models)全部缺,assertServiceableinstallSettingsSection 注册时就会整个 namespace 直接拒绝——settings.mutate 返回 settings-rejected,settings seam 保留该 namespace 的"最后可用值",所以错误是静默路由缺席,而不是显式崩溃。这是 DSH 一个很值钱的设计:写错的 profile 不会让整个进程挂掉,只会让该 route 不存在;用户重新编辑保存即可恢复。

补全后:

llm-pi-ai:
  providers:
  minimax-cn:
  apiKeyEnv: MINIMAX_CN_API_KEY
  displayName: MiniMax (CN)
  api: openai-completions
  baseURL: https://api.minimaxi.com/v1
  models:
  - id: MiniMax-M3
  name: MiniMax-M3
  contextWindow: 1000000  # 1M 上下文,原生多模态
  maxTokens: 65536
  input: [text, image]

agent-default-model 同时改:

agent-default-model:
  provider: minimax-cn
  model: MiniMax-M3
  reasoningEffort: high

~/.dsh/.credentials.yamlMINIMAX_CN_API_KEY: sk-cp-…

3. thinking 折叠成 reasoning 块:方言补丁

跑通第一波后我立即发现 M3 的输出里有 <think>… 标签混在正文里。这是因为:

  1. pi-ai 的 openai-completions 默认按 URL 推测 thinkingFormat——api.minimaxi.com 不在已知列表里,落到 "openai" fallback,发的是 OpenAI 的 reasoning_effort 字段;
  2. MiniMax 不认 reasoning_effort,静默忽略;
  3. 但 M3 默认开启 thinking(省略时为 adaptive),输出 content 里直接内联 <think>…

直接发 reasoning_effort 没用;测试 MiniMax 各种参数后发现:

  • thinking.type: "disabled" ✅ 关闭思考
  • thinking.type: "adaptive" ✅ 默认开启
  • thinking.type: "enabled" ❌ 报错 "invalid thinking.type"
  • reasoning_split: true把思考拆到 reasoning_content 字段

也就是说 MiniMax 需要的 wire 形状,pi-ai 的现有 thinkingFormat 字典里没有任何一个匹配。最干净的修法是在 pi-ai 的 openai-completions.js 里加一个 minimax 方言:

else if (compat.thinkingFormat === "minimax" && model.reasoning) {
  // MiniMax-M3 dialect: reasoning content is split into `reasoning_content`
  // when `reasoning_split: true` is sent, so the harness can render it as
  // a thinking block instead of inlining it into `content`. The `Off`
  // effort closes the model's adaptive thinking entirely.
  params.reasoning_split = true;
  if (options?.reasoningEffort && model.thinkingLevelMap?.off !== null) {
  params.thinking = { type: "adaptive" };
  }
  else if (model.thinkingLevelMap?.off !== null) {
  params.thinking = { type: "disabled" };
  }
}

加在 deepseek 分支之后。同时给 dsh-llm-pi-aiSUPPORTED_THINKING_FORMATS 白名单加 'minimax'。settings.yaml 加 compat.thinkingFormat: minimax,模型声明 reasoningEffortsmodel.reasoning === true(否则分支不触发)。

DSH 这里有个细节值得一记:模型上声明 reasoningEffortsoff: 不带值表示"提供 Off 档、选中时什么也不发"——dsh-llm-pi-ai 的 pi-ai thinkingFormat 用这套档位字典做 thinkingLevelMap.off !== null 判断,从而决定发 disabled 还是 adaptive。这跟 deepseek 方言的 off 行为完全错位:deepseek 的 off 是发送 thinking: {type: "disabled"}(MiniMax 支持 ✓),但 deepseek 的 on 档发送 thinking: {type: "enabled"}(MiniMax 报错 ✗)。所以 minimax 方言必须永远不发 enabled,档位映射到 adaptive / disabled 二元——所以"minimal / low / medium / high"四个非 off 档位之间实际行为差异由 MiniMax 服务端决定,profile 只是声明"这些档位可用"。

把 MiniMax-M3 装进 DeepSeek Harness:一个完整的过程报告 — 段落图

4. 调用链:从用户输入到 MiniMax API

把整个流程压平画下来:

GUI composer text input
  │
  ▼  typert WebSocket RPC
  ▼
dclient → dapigateway → dsession → dapi
  │
  ▼  加载 dsh-llm/dsh-llm-pi-ai provider route
ctx.llm.stream({ provider:'minimax-cn', model:'MiniMax-M3', system, messages, tools, maxTokens, reasoningEffort, sessionId, signal })
  │
  ├─▶ ctx.credentials.resolve('MINIMAX_CN_API_KEY')
  │  → LocalCredentialProvider 从 ~/.dsh/.credentials.yaml 取 sk-cp-…
  │
  ├─▶ profileOptions(profile, reasoning, apiKey)
  │  → 把 reasoning 转 pi-ai 通用 reasoning 字段
  │
  ├─▶ snapshot.models.streamSimple(model, ctx, options)
  │  → pi-ai 内置 openai-completions.streamSimple
  │  ├─▶ getCompat(model) → { thinkingFormat:'minimax', … }
  │  ├─▶ buildParams(model, ctx, options, compat)
  │  │  ├─ prompt_cache_key 不发(URL 不是 openai.com)
  │  │  ├─ messages = convertMessages(...)(图片转 image_url base64 data URL)
  │  │  └─ thinkingFormat==='minimax' 分支:
  │  │  params.reasoning_split = true
  │  │  params.thinking = { type: 'adaptive' }
  │  │  → params 准备完毕
  │  ├─▶ fetch 'https://api.minimaxi.com/v1/chat/completions'
  │  │  Headers:  Authorization: Bearer sk-cp-…
  │  │  user-agent: deepseek-harness/0.1.0-rc.5 (+…)
  │  │  content-type: application/json
  │  │  ← 没有 x-deepseek-harness-*(pi-ai 路由不自动加)
  │  ├─▶ 流式 SSE → 解析 chunks
  │  │  choice.delta.reasoning_content → thinking block
  │  │  choice.delta.content  → text block
  │  │  finish_reason:'stop' / 'tool_calls'
  │  │  usage.{prompt_tokens, completion_tokens, cached_tokens}
  │  └─▶ toStreamChunks(events, contextWindow)
  │  → 翻译成 dsh-llm 协议:
  │  block-start (blockType: 'reasoning' | 'text' | 'tool-call')
  │  reasoning-delta / text-delta / tool-call-delta
  │  block-end
  │  usage
  │  finish {kind: 'stop' | 'tool-calls' | 'max-tokens' | 'aborted' | 'error'}
  │
  └─▶ idleWatchdog(upstream, streamIdleTimeoutMs)
  → 每 5 分钟(默认)未读 chunk 就 TIMEOUT

关键设计点:

  1. 请求解析而非加载解析ctx.credentials.resolve('MINIMAX_CN_API_KEY') 在每次 stream() 调用时执行,密钥不持久化进 env,也不进 LLM request body 或 logs。
  2. pi-ai 处理流、dsh-llm 不感知协议toStreamChunks 把 pi-ai 内部事件统一翻译成 harness 的 chunk 协议(text-delta / reasoning-delta / tool-call-delta / usage / finish),下游 agent loop 只看翻译后的协议,pi-ai 升级或换协议时下游无需改动。
  3. catalog 路由 vs hand-declared 路由:pi-ai catalog 路由(OpenAI、Anthropic、Z.AI 等)直接复用 pi-ai 自带 catalog 的端点/协议/catalog,profile 只覆写鉴权和命名;hand-declared 路由(minimax-cn)profile 必须自填 api/baseURL/models。
  4. 错误码稳定:provider 返回 401/403 → LlmError('AUTH'),上下文溢出 → LlmError('CONTEXT_WINDOW_EXCEEDED'),完成流但无 content → LlmError('EMPTY_RESPONSE')。上层 UI 看到的是稳定码,不被 MiniMax 错误文本细节绑死。
  5. idleWatchdog:5 分钟未读 chunk 触发 LlmError('TIMEOUT');上游 abort 触发 LlmError('ABORTED')。实测 250ms 阈值能在 274ms 命中,ABORTED 在 0 text chunks 时立即 finish {kind:'aborted', failure:{code:'ABORTED'}}。

5. 实测数据(6 个脚本的真实跑通结果)

验证项 关键证据
verify.mjs — 文本 + reasoning blocks=[reasoning (342 字符), text (65 字符)]; usage cacheRead=198 tokens; responseId 真实有效; replayState.api=openai-completions
gui.mjs — 真实 DSH GUI 模型选择器显示 "MiniMax-M3 High";新会话发 prompt 后渲染完整 thinking 块 + 干净正文;状态栏 1 轮·1 步·LLM 2.5s·145 tok/s·缓存命中 1%·输入 21.9K tok·输出 71 tok;0 INVALID_CREDENTIAL/NO_ADAPTER/HTTP_* 错误
verify-image.mjs — 图片输入 saveImage → AttachmentRef,readImage round-trip 132 字节一致;MiniMax 看到 64×64 RGB 图像,识别形状("矩形");usage cacheRead 222 tokens;reasoning + text 双 block 分离
verify-tool-call.mjs — 工具调用 Turn 1 决定调用 get_weather,args={"city":"深圳"},finish reason=tool-calls;Turn 2 喂回 tool result,M3 输出结构化总结(25°C/晴朗/微风)+ "工具验证通过";cache hit 520 tokens
verify-runtime.mjs #A — KV cache 两轮 turn1/2 finish=stop,system prompt 1069 字符完整保留
verify-runtime.mjs #A.2 — REAL MiniMax cache turn1 cacheRead=128 → turn2 cacheRead=384(cache 复用显著增长,system+历史 prefix 命中)
verify-runtime.mjs #B — abort 50ms 触发 abort → finish {kind:'aborted', failure:{code:'ABORTED'}}; 0 text chunks
verify-runtime.mjs #C — timeout streamIdleTimeoutMs=250 → 274ms 内 TIMEOUT fire, failureCode=TIMEOUT
verify-runtime.mjs #D — headers user-agent: deepseek-harness/0.1.0-rc.5 (+github URL) + Authorization Bearer ✓; x-deepseek-harness-user-id / -session-id(pi-ai 路由不自动加,这是与 dsh-llm-deepseek 的设计差异)

6. 几个有值的细节

toPiAssistant 必须带 source。我在第一版 verify-tool-call.mjs 里手写 assistant message 没带 source: {kind:'model', provider, model},结果 dsh-llm-pi-ai 的 toPiAssistant 访问 source.kind 抛 "Cannot read properties of undefined",被 LlmRuntime 误包成 finish {kind:'error', code:'UNKNOWN'}。正确写法是用 createAssistantMessage({content, source}) helper 或显式声明 source。这是 dsh-llm-pi-ai 的小坑。

thinkingLevelMap.off !== null 的隐式 bug。deepseek 方言里用这个判断"off 档是否存在",但当 off 字段完全缺失时 thinkingLevelMap.off === undefinedundefined !== null 是 true,条件仍然通过。我的 minimax 分支复制了同款判断,也"宽容地"接受 off 字段缺失。如果未来要严格化,建议改成 in model.thinkingLevelMap && model.thinkingLevelMap.off !== null

pi-ai 的 prompt_cache_key 默认不发。pi-ai 的 openai-completions 只在 baseUrl.includes("api.openai.com")cacheRetention=='long' 时设置 prompt_cache_key。MiniMax 的 api.minimaxi.com 不在白名单,所以 dsh-llm-pi-ai 不会主动发 prompt_cache_key。MiniMax 服务端按 messages 内容 hash 自动 cache,cache_read tokens 在 usage 里仍然报。实测 turn1 cacheRead=128、turn2 cacheRead=384,cache 复用确实生效。

把 MiniMax-M3 装进 DeepSeek Harness:一个完整的过程报告 — 段落图

x-deepseek-harness-* 是 deepseek 路由专属dsh-llm-deepseek 给每次请求加 x-deepseek-harness-user-idx-deepseek-harness-session-iddsh-llm-pi-ai 只发通用 user-agent。如果上游按这些 header 做请求归因或区分,minimax-cn 路由需要单独加 headers。

patched vendor 的脆弱性。两个 vendor 改动(pi-ai 的 minimax 分支、dsh-llm-pi-ai 的白名单)都会被 pnpm install 覆盖。短期够用;长期方案两条路:① 给 upstream pi-ai 提 PR 加 minimax 方言;② 在 dsh-llm-pi-aiapply() 加 pre-apply 钩子,monkey patch openAICompletionsApi 而不动 vendor。

7. 总结:DSH 把"接入一个新模型"做成了什么

它做成了三件事

  1. 配置文件settings.yaml 一个 providers.<key> 块),
  2. 密钥引用.credentials.yaml 一行),
  3. 可选的协议方言补丁(如果新模型的 wire 格式不在 pi-ai 字典里)。

不再需要改 web 端、不再需要改 CLI、不再需要改 UI。web 端通过 window.__DSH_BOOT__ 的插件图自动加载所有 client-side 客户端(typert),settings 的 hot-reload 直接把新 provider 推到前端选择器;CLI / 各 agent loop / 各 subagent 全部通过 ctx.llm.stream 与 LLM seam 解耦;protocol translation 在 dsh-llm-pi-ai 的 toStreamChunks 里统一处理。

这套插件架构让"接入 MiniMax"这件事的用户感知面改动最小化:用户只看到一个新 provider 出现在 Models 页和会话模型选择器里。底层有 4 个 vendor 文件改动、2 个用户配置改动、6 个验证脚本——但这些都不影响 DSH 其他任何代码路径。这是 cordis plugin architecture + settings hot-reload + 协议转译三件套共同给出的工程结果。

把 MiniMax-M3 装进 DeepSeek Harness:一个完整的过程报告 — 段落图

以上,既然看到这里了,如果觉得不错,随手点个赞、在看、转发三连吧,如果想第一时间收到推送,也可以给我个星标⭐~ 谢谢你看我的文章,我们,下次再见。