环境:DeepSeek Harness Desk 0.3.37(运行时 dsh 0.1.5-rc.2,内置 Node 24.19.0),底层 LLM 库 @earendil-works/pi-ai 0.85.1,接入 OpenCode Go(Console Go)网关。
一天之内踩了两个坑:先是所有请求返回 400 MissingSessionID,修好之后又发现自定义接入的 deepseek-v4.1-flash 不能看图。两个问题的根因都不在报错指向的地方。这篇把排查路径、验证手法和最终配置完整记录下来。


背景

OpenCode Go 是 OpenCode 提供的订阅制网关,端点统一在 https://opencode.ai/zen/go/v1/,按模型分三类协议:/chat/completions(DeepSeek、GLM、Kimi 等)、/messages(MiniMax、Qwen)、/responses(Grok、GPT 等)。

在 Harness 里,模型不由内置列表决定,而是写在用户配置 ~/.dsh/settings.yamlllm-pi-ai.providers 段里。每个 provider 的路由键(下文的 opencode2)是配置字典的 key,模型列表是它的 models 数组。

因为 Harness 内置的 opencode-go catalog 里没有 deepseek-v4.1-flash(三个 DeepSeek 条目分别是 deepseek-v4-flashdeepseek-v4-flash-vision-expdeepseek-v4-pro),所以我们用一个自定义路由键 opencode2 手动声明它。

这个「自定义路由键」的决定,是后面两个问题共同的伏笔。


问题一:400 MissingSessionID

现象

对话请求全部失败:

{
  "type": "MissingSessionID",
  "message": "Error from provider (Console Go): Request is missing x-opencode-session and cannot be routed efficiently."
}

定位

先去查官方文档,得到一个关键线索:OpenCode 把使用方分成 validated clientsknown problematic 两类,而 DeepSeek Harness 被明确列在后者,备注是「session info missing on some model paths」——也就是说这是已知缺陷,不是我们配置错了。

社区讨论帖(deepseek-ai/deepseek-harness#5495)把机制讲得更清楚:网关从某日起要求每个推理请求带一个稳定的 x-opencode-session 头,用于把同一会话的请求路由到同一后端,以保持 prompt cache 热着。而 Harness 的请求链路

dsh-agent-loop → dsh-llm → dsh-llm-pi-ai

直接调用 pi-ai 的 streamSimple,绕过了上游 pi-coding-agent 里负责合并归因头的 mergeProviderAttributionHeaders,于是任何会话身份都没能到达 adapter,自然也没进请求头。

根因

讨论帖里列了 6 种解法,按口碑排序,社区插件 dsh-opencode-session 是其中最干净的一档:它监听 Harness 的 llm/stream 事件,在匹配的 provider 上把一个进程内 AsyncLocalStorage 存起来,然后 patch 全局 fetch,在请求真正出网时把头合并进去,值复用 Harness 本来就带着的会话 ID。

检查本地发现插件其实已经装好了——但我们还是 400。原因出在插件的默认配置上:

// 插件源码
const DEFAULT_PROVIDERS = ['opencode', 'opencode-go']

插件默认只对路由键 opencode / opencode-go 注入头,而我们的路由键是自定义的 opencode2,根本不在匹配范围内。装了等于没装。

修复

Harness 的插件树由 Cordis 加载器按 patch 层组合,每层是一个 YAML 数组。用户层在 ~/.dsh/profiles/web/cordis.patch.yml。我们在这一层按 id 定向覆盖插件那一行的配置,把路由键补进去:

- id: opencode-go-session-header
  config:
    providers:
      - opencode
      - opencode-go
      - opencode2     # 我们自定义的路由键
    mode: session-id
    debug: false

这里有个容易踩的坑:读加载器源码(applyEntryPatches)后发现,id 定向 patch 的合并语义是

for (const [key, value] of Object.entries(overrides)) {
  target[key] = value   // 整字段替换,不是深合并
}

所以 config整块替换的。如果只写 providers,原本的 mode / debug 会连同整块 config 一起消失。必须写全。

怎么确认真的生效

这次没有靠「改完重启试试」,而是顺着数据流一路验证:

  1. 组合语义:用运行时自己的 composeEntries(而非自己写个 YAML 合并)跑一遍「插件 bundle 层 + 我们的用户层」,确认最终行的 providers 里确实有 opencode2
  2. 真实加载器:跑 dsh --dump-config --profile web,输出里能看到加载器自己标注的 # ... patched by /Users/.../cordis.patch.yml,并且 providers 已含 opencode2
  3. 端到端:插件开了 debugFile,改动落盘后该文件出现了 36 条记录,全部是 provider: opencode2 的真实调用,每条都带上了 x-opencode-session,值就是会话 ID。

顺带确认了一件省事的事:cordis.patch.yml 是被 Cordis HMR 热监听的(watchUserPatches),文件一改,加载器会重组整棵 patch 栈并事务性重新应用——不需要重启服务


问题二:自定义模型「没有视觉」

现象

deepseek-v4.1-flash 接进来了,但传图片时模型看不到内容,附件也像是不可用。

定位

Harness 判断一个模型能不能看图,靠配置里的 input 字段(PiAiModality,取值 text / image)。而模态的解析有三层优先级:

模型条目的 input  →  该路由的 defaultInput  →  兜底默认

关键在于:第一层和第二层的「继承」只对 pi-ai 内置 catalog 里的 provider 生效。我们的路由键 opencode2 不是 catalog provider,模型条目又没有声明 input,于是直接落到第三层兜底 defaultInput: [text]——视觉能力就这么被关掉了。

实测最能说明问题。我们用一个模块探针(后面细说)对同一份真实配置做前后对比:

声明 input 前:deepseek-v4.1-flash | input: ["text"]
声明 input 后:deepseek-v4.1-flash | input: ["text","image"]

关键实验:它其实能看图

动手改配置之前,先要回答一个前提问题:这个模型到底支不支持图片? 因为如果网关本身不支持,声明 input: image 只会让模型在拿到图之后中途报错,比看不到图更糟。

官方文档说 deepseek-v4.1-flash 是纯文本,四个 DeepSeek 模型里只有 deepseek-v4-flash-vision-exp 处理图片。但文档可能滞后,于是直接实测。

第一次测试是错的。我发了一张纯色图问「什么颜色」,三个模型(含公认的纯文本模型)全都答对了 "Red"。这说明这个测试没有区分度——纯色图靠猜都能中,所以结论无效。

于是设计了一个猜不中的对照实验:在白色背景上画一个具体的数字(用点阵字体渲染),问「图里是什么数字」,并加一组不带图的对照组。

结果非常明确:

deepseek-v4.1-flash  带图 → "7"
  reasoning_content: "The image shows a pixelated digit that looks like a 7.
                       The top horizontal bar and the diagonal descending
                       stroke are characteristic of the digit 7."

换一个数字 4 再测一次,同样答对,推理过程还准确描述了「左侧竖笔画、横笔画」的结构。

结论:网关实际上是支持视觉的,官方文档过时了。 问题纯粹在 Harness 侧的配置声明。

修复

~/.dsh/settings.yaml 的模型条目下补上模态声明:

    opencode2:
      displayName: Opencode
      apiKeyEnv: OPENCODE2_API_KEY
      api: openai-completions
      baseURL: https://opencode.ai/zen/go/v1
      models:
        - id: deepseek-v4.1-flash
          name: deepseek-v4.1-flash
          input:            # 新增
            - text          # 新增
            - image         # 新增

改完后用 dsh-llm-pi-ai 导出的真实 Config schema 校验通过;再用适配器内部的 resolveRouteModels 对真实 settings 做物化,确认输出是 ["text","image"]

settings.yamldsh-settings-file 用 chokidar 监听(watch 默认开启,源码注释写明「external edits hot-publish through the seam」),所以同样是热生效,不用重启


方法论:这次真正起作用的东西

复盘下来,两个 bug 本身都不复杂,但有几点思路值得单独拎出来。

1. 报错的位置和根因的位置常常不是同一层

400 是网关抛的,但根因在客户端的请求构造路径;「没有视觉」表现为功能缺失,但根因是一行配置声明。如果盯着报错的地方找,会一直在错误的方向上打转。

2. 用运行时自己的工具当真相,别靠推测

这次能快速收敛,很大程度是因为没有靠猜:

  • 验证 patch 组合,用的是运行时自己导出的 composeEntries,不是自己写的近似实现;
  • 验证配置合法性,用的是包真实导出的 schema,不是照文档手搓一个;
  • 验证模型能力,用的是适配器内部的 resolveRouteModels,直接看物化结果。

自己复刻一套逻辑去验证另一套逻辑,只会验证你的复刻。

内部函数没导出时,有个干净的办法:把模块复制到临时目录(软链运行时的 node_modules),在副本末尾追加一行导出,然后 import 这个副本。既拿到了内部函数,又不碰原始安装目录。

3. 测试要设计成「猜不中」的

这是本次最有价值的一条教训。第一版视觉测试(纯色图)看起来通过了,但没有任何信息量——因为对照组(公认的纯文本模型)也「通过」了。

有效的测试必须满足:如果是阴性结果,它不可能碰巧通过。改成「读一个具体的数字」之后,纯文本模型无法靠猜满足条件,结论才立得住。

任何时候测试全绿都值得再问一句:这个测试有没有可能因为太简单而全绿?

4. 文档会过时,端点不会

官方文档白纸黑字说那是纯文本模型。实测证明它支持视觉。文档描述的是某一时刻的状态,而网关在持续迭代。涉及「某个端点到底行不行」这类事实问题时,一次实测胜过十页文档。

5. 热重载是省事,但也让验证变难

cordis.patch.ymlsettings.yaml 都支持热更新,省去了重启。但反过来,没有重启这个明确的分界点,就需要更主动地去确认「改动真的被读进去了」。这次用的办法是:

  • 看插件 debugFile 有没有新记录;
  • --dump-config 看加载器的实际组合结果;
  • 必要时用 lsof 检查进程持有的文件句柄 inode——如果磁盘上文件的 inode 和进程打开的 inode 不一致(原子写会换 inode),说明进程的缓存副本可能还是旧的,需要触发一次变更事件让它重读。

6. 分清「能力声明」和「能力探测」

input 字段的本质是声明,不是探测。源码注释把取舍说得很清楚:没有接口能问网关「你支持哪些模态」,所以只能靠配置声明。而两种猜错的代价不对等——

  • 声明少了(漏报):图片在附加前就被拒绝,报错并指明是哪个模型,可控;
  • 声明多了(虚报):请求带着图发出去,被 provider 在中途拒绝,而此时消息已经落库,会话会卡在重复发送一个不可能成功的请求上。

所以正确处理是:对确实验证过支持视觉的模型精确声明,而不是图省事给整个 provider 加宽泛的 defaultInput


附录:两个最终配置

改动只涉及两个文件,都在用户目录下,没有碰 App 安装目录。

~/.dsh/profiles/web/cordis.patch.yml(修 400)

# 给 dsh-opencode-session 插件补上自定义路由键 opencode2。
# 注意:id 定向 patch 整块替换 config,mode/debug 必须重写。
- id: opencode-go-session-header
  config:
    providers:
      - opencode
      - opencode-go
      - opencode2
    mode: session-id
    debug: false

~/.dsh/settings.yaml(修视觉)

llm-pi-ai:
  providers:
    opencode2:
      displayName: Opencode
      apiKeyEnv: OPENCODE2_API_KEY
      api: openai-completions
      baseURL: https://opencode.ai/zen/go/v1
      models:
        - id: deepseek-v4.1-flash
          name: deepseek-v4.1-flash
          input:
            - text
            - image

补充提醒

  • 会话头插件是过渡方案。上游正在 pi-ai 层面做归一化(earendil-works/pi#9326)。等 Harness 内置了会话头,可以 dsh plugin --profile web remove dsh-opencode-session 卸掉插件并清空 patch 文件。
  • Harness 升级后重新检查这两处opencode2 这种自定义路由键依赖配置里显式声明,内置 catalog 一旦收录了 deepseek-v4.1-flash,就可以改用 catalog 默认值、省掉模态声明。
  • 调试时不要把密钥打到终端grep -r "KEY" 配置文件 这类命令很容易把密钥整行带进终端历史或日志里。排查认证问题时用 grep -oE "^[A-Z_]+:" 之类只取字段名,或者干脆打码。如果已经发生了,记得轮换密钥。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注