一次 DeepSeek Harness 接入 OpenCode Go 的排障复盘:从 400 报错到「模型没有视觉」

环境: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.yaml 的 llm-pi-ai.providers 段里。每个 provider 的路由键(下文的 opencode2)是配置字典的 key,模型列表是它的 models 数组。

因为 Harness 内置的 opencode-go catalog 里没有 deepseek-v4.1-flash(三个 DeepSeek 条目分别是 deepseek-v4-flash、deepseek-v4-flash-vision-exp、deepseek-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 clients 和 known 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.yaml 由 dsh-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.yml 和 settings.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_]+:" 之类只取字段名,或者干脆打码。如果已经发生了,记得轮换密钥。

DeepSeek Harness Desk:更轻松地在桌面上使用 DeepSeek Harness

DeepSeek Harness Desk 应用图标

DeepSeek Harness Desk:更轻松地在桌面上使用 DeepSeek Harness

如果你喜欢 DeepSeek Harness 的工作方式,却不想每次都打开终端、配置运行环境,DeepSeek Harness Desk 提供了一个更顺手的选择:把 Harness 放进一个简洁的桌面窗口里,并补上菜单栏/系统托盘、通知和运行状态管理等日常使用体验。

Continue reading DeepSeek Harness Desk:更轻松地在桌面上使用 DeepSeek Harness

Nacos 2.5.2 在 macOS M1 上的内存下限实测:500MB 降不动

Nacos 2.5.2 在 macOS M1 上的内存下限实测:500MB 降不动

Nacos 2.5.2 standalone 模式默认只设了堆 -Xms64m -Xmx128m,Metaspace 和 DirectMemory 完全没上限,实际 RSS 在 500MB 左右。想压更低,试了一圈,结论是:500MB 基本是硬下限。

踩过的坑

试过把堆降到 96m,运行 70 秒后 OOM 退出,没有 heapdump,静默挂掉。试过把线程栈压到 192k,JDK 11 在 macOS arm64 上直接拒绝启动——最低 208k。试过 Metaspace 上限设 128m,启动到 Derby 加载阶段就挂,因为 Nacos 光启动就要 140-150MB 的类元数据。

用 NMT 拆解 RSS 构成

开启 -XX:NativeMemoryTracking=summary 后用 jcmd VM.native_memory summary 逐项看,500MB 的去向很清楚:

区域 占用 说明
Metaspace 93MB 16351 个类,Nacos 体积决定的硬需求
Java Heap 123MB Xmx 128m 几乎满,96m 运行时不够
线程栈 67MB 227 个线程,macOS 强制 208k/线程下限
Symbol 表 19MB 类加载的副产品,不可调
Code Cache 28MB JIT 编译代码
Direct Memory ~70MB gRPC/Netty 堆外内存
JVM 自身 + 共享库 ~50MB

几乎全是 native 内存,JVM 参数设上限只能防止失控,降不了实际占用。

最终配置

startup.sh 第 95 行 standalone 分支改为:

JAVA_OPT="${JAVA_OPT} ${CUSTOM_NACOS_MEMORY:- -Xms64m -Xmx128m -Xmn32m -XX:MetaspaceSize=64m -XX:MaxMetaspaceSize=192m -XX:MaxDirectMemorySize=96m -XX:+UseSerialGC -Xss256k -XX:ReservedCodeCacheSize=64m}"

每一项的作用:-XX:+UseSerialGC 替掉 G1GC 省 GC 元数据;-Xss256k 从默认 1m 砍到 1/4;MaxMetaspaceSize=192m 和 MaxDirectMemorySize=96m 封顶防失控;ReservedCodeCacheSize=64m 从默认 240m 降到 64m。

实测 RSS 仍在 500MB 左右,但这套参数封住了所有可膨胀的 native 内存区域,长期运行不会因为 Metaspace 或 DirectMemory 无上限而持续增长。

还想往下压的三条路(超出 JVM 调参范畴)

  1. startup.sh -m standalone -f naming 只跑服务发现,跳过配置中心,实测 RSS 降到 485MB,省 50MB,代价是 config 不可用。
  2. 换 JDK 17,metaspace 管理更高效,预计省 30-50MB。
  3. 外接 MySQL 替代内嵌 Derby,省掉 Raft/嵌入式存储的堆内开销。

底线:Nacos 这种 Spring Boot 重应用,光 JVM 自身结构 + 类元数据 + gRPC 堆外就要 400MB+,这是物理约束。想突破 500MB 得从减少加载模块入手,不是调参能解决的。


另一台电脑复刻步骤

1. 备份原文件

cp nacos/bin/startup.sh nacos/bin/startup.sh.bak

2. 改 standalone 分支

编辑 nacos/bin/startup.sh,找到第 95 行(standalone 分支):

# 原始(只有堆)
JAVA_OPT="${JAVA_OPT} ${CUSTOM_NACOS_MEMORY:- -Xms64m -Xmx128m -Xmn32m}"

# 改为
JAVA_OPT="${JAVA_OPT} ${CUSTOM_NACOS_MEMORY:- -Xms64m -Xmx128m -Xmn32m -XX:MetaspaceSize=64m -XX:MaxMetaspaceSize=192m -XX:MaxDirectMemorySize=96m -XX:+UseSerialGC -Xss256k -XX:ReservedCodeCacheSize=64m}"

只改 standalone 那行,集群分支(第 101 行)不动。

3. 重启验证

sh nacos/bin/shutdown.sh
sh nacos/bin/startup.sh -m standalone
# 等 1 分钟,确认 HTTP 200
curl -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8848/nacos/

4. 如果另一台不是 M1 而是 Intel Mac

参数完全一样,但线程栈下限不同,Intel 上 -Xss256k 可以更低。如果还想压,试 -Xss208k,不过省不了多少。

5. 如果另一台是 Linux/Windows

-Xss256k 可能可以降到更激进(某些 Linux 平台最低 160k),但也省不了几 MB,不值得折腾稳定性。

macOS chronod 占用 100% CPU 的一次排查记录

macOS chronod 占用 100% CPU 的排查与修复

现象:活动监视器中 chronod 长时间占用 100% 以上 CPU,重启系统后仍然存在。

进程路径:

/System/Library/PrivateFrameworks/ChronoCore.framework/Support/chronod

chronod 主要和 macOS 小组件、通知中心、App Intents、时间线数据同步有关。日志中如果反复出现这些关键词,通常说明它卡在小组件同步或缓存处理上:

com.apple.chrono
NotificationCenter
ReplicatorServices
remoteArchives
widget-relevance-cache

可先尝试关闭 iPhone 小组件:

系统设置 -> 桌面与程序坞 -> 小组件 -> 使用 iPhone 小组件

然后重启相关进程:

killall chronod 2>/dev/null
killall NotificationCenter 2>/dev/null

如果仍然高占用,清理 chronod 缓存和数据库,让系统自动重建:

mkdir -p ~/Desktop/chronod-backup

killall chronod 2>/dev/null
killall NotificationCenter 2>/dev/null

mv "$HOME/Library/Caches/com.apple.chrono" \
   "$HOME/Desktop/chronod-backup/com.apple.chrono-$(date +%Y%m%d-%H%M%S)"

mv "$HOME/Library/Group Containers/group.com.apple.chronod/chronod" \
   "$HOME/Desktop/chronod-backup/chronod-$(date +%Y%m%d-%H%M%S)"

killall chronod 2>/dev/null
killall NotificationCenter 2>/dev/null

如果执行时提示 Operation not permitted,需要先给终端完整磁盘访问权限:

系统设置 -> 隐私与安全性 -> 完全磁盘访问权限

给 Terminal 或 iTerm 授权后重新执行。

这个操作会重建小组件和通知中心相关缓存,一般不会影响应用数据。部分小组件可能需要重新加载或重新添加。

macOS 透明压缩工具 applesauce 安装与使用指南

前言

在 Windows 上,我们可以通过 NTFS 压缩功能让文件“原地变小”而不改变使用方式。macOS 虽然底层文件系统(APFS/HFS+)也支持透明压缩,但系统并没有提供图形界面开关。

applesauce 是一个命令行工具,可以调用 macOS 原生压缩能力,实现对文件的透明压缩——压缩后的文件仍在原位置,双击正常打开,但磁盘占用空间显著减小。

Continue reading macOS 透明压缩工具 applesauce 安装与使用指南

DeepSeek Sidebar – 一键在 Chrome 侧边栏打开 DeepSeek

一键在 Chrome 侧边栏打开 DeepSeek,支持页面缩放并记忆缩放比例。

功能

  • 一键打开 — 点击扩展图标即可在侧边栏加载 DeepSeek
  • 自由缩放 — 工具栏按钮或 Ctrl/Cmd +/-/0 快捷键,30%-200% 范围调节
  • 记忆缩放 — 自动保存缩放比例,下次打开立即恢复
  • 简洁工具栏 — 深色主题,不干扰对话体验

安装

从 Chrome Web Store 安装

https://chromewebstore.google.com/detail/deepseek-sidebar/gakblhcadiegnapiajgolefjhhmicobi

开发者模式加载

  1. 克隆本仓库
    git clone https://github.com/misswell/deepseek-sidebar.git
  2. 打开 chrome://extensions
  3. 开启右上角「开发者模式」
  4. 点击「加载已解压的扩展程序」,选择项目目录

文件结构

├── manifest.json       # MV3 扩展清单
├── background.js       # Service Worker,处理图标点击
├── sidepanel.html      # 侧边栏页面
├── sidepanel.js        # 缩放控制逻辑
├── rules.json          # 移除 X-Frame-Options 响应头
├── privacy-policy.html # 隐私政策
└── icons/              # 扩展图标
    ├── icon16.png
    ├── icon48.png
    └── icon128.png

权限说明

权限 用途
sidePanel 在 Chrome 侧边栏中展示 DeepSeek 页面
activeTab 点击扩展图标时获取当前标签页以打开侧边栏
storage 本地保存用户的缩放比例设置
declarativeNetRequest 移除 DeepSeek 的 X-Frame-Options 响应头,使其可在侧边栏中加载
host_permissions 访问 chat.deepseek.com 以实现上述头部修改

隐私

本扩展不收集任何用户数据。详见 隐私政策。

License

MIT

Yarn 安装完成后存在超时提示处理

Yarn 安装完成后存在超时提示

success Already up-to-date.
✨  Done in 0.11s.
info There appears to be trouble with your network connection. Retrying...
info There appears to be trouble with your network connection. Retrying...

问题 :Yarn 的版本检查机制
Yarn 每次运行时可能会尝试连接网络检查自身版本或发送统计数据,即使不需要下载包。

解决方案:禁用版本检查和统计

# 禁用 Yarn 的版本检查
yarn config set disable-self-update-check true

# 禁用匿名统计数据上报
yarn config set analytics false