☰
从零开发一个 DeepSeek Harness 微信插件:我用 8 个 bug 换来的 Cordis 插件开发指南
2026/10/1 8:23:28 网站建设 项目流程

项目地址:https://github.com/ranshaodexiao/dsh-wechat-ilink
npm:dsh-wechat-ilink(dsh plugin --profile desktop add dsh-wechat-ilink一条命令安装)


⚠️ 先看这里:本文对应的版本

DSH 迭代非常快,插件 API 随时可能变。本文所有结论都基于下面这组版本实测,版本不同可能失效。

组件本文实测版本说明
DeepSeek Harness0.2.0-rc.2核心,dsh/dsh-agent-loop/dsh-llm/dsh-session同版本
Cordis4.0.4插件系统
schemastery3.18.4配置校验(可选依赖)
Node.js24.x插件要求 ≥ 22.13.0
Electron44.0.0Desktop 外壳
本插件dsh-wechat-ilink@0.6.5

怎么查你自己的版本

# 插件声明的兼容范围dsh--version# 或者直接看已安装的 DSH 包版本node-e"console.log(require('@deepseek-ai/dsh/package.json').version)"

本插件的package.json里声明了:

"dsh":{"engines":{"dsh":">=0.2.0-rc.2"}}

⚠️注意:这个字段不会被 DSH 强制检查—— 它只是声明,不满足也不会拦你(详见 1.2 节)。
所以升级 DSH 后请务必看启动自检的日志(见 4.4 节),那是唯一能告诉你插件是否还活着的信号。

版本敏感度分级

文中标注了每个结论的版本敏感度:

标记含义
🔴高直接依赖 DSH 内部实现(源码行号、事件名、字段名),升级后必须重新验证
🟡中依赖公开 API 或稳定约定,一般不会变,但值得留意
🟢低通用工程实践(长轮询、日志、去重),与版本无关

前言:为什么写这篇

我想在微信里直接用DeepSeek Harness(以下简称 DSH)。

不是"把消息转发给某个 API"那种,而是真的在微信里跟 DSH 的会话对话—— 它能读写文件、跑命令、用工具,回复发回微信。

做完之后回头看,真正难的不是微信协议,而是DSH 插件的契约。我踩了 8 个 bug,其中 5 个在离线测试全绿的情况下被真实 DSH 打出来。

这篇文章把这些坑都写出来。如果你要开发 DeepSeek Harness 插件,这些坑你大概率也会踩。


一、先搞清楚 DSH 插件是什么

DSH 用的是Cordis插件系统。一个插件就是一个 JS 模块,导出四个东西:

exportconstname='wechat-clawbot'// 插件名(日志标签)exportconstinject=['agents','sessions']// 依赖的服务exportconstConfig=Schema.object({...})// 配置校验exportfunctionapply(ctx,config){...}// 入口

打包成bundle:package.json里声明dsh.bundle.patch,指向一个cordis.patch.yml。

🟡中—— 插件契约本身稳定(Cordis 4.0.4 实测),但inject的服务名会随 DSH 增加/改名。

1.1 第一个坑:inject是硬门槛

Cordis 的ctx是Proxy。你没在inject里声明的服务,属性访问会直接抛错:

Error: cannot get property "sessionQuery" without inject

这不是"返回 undefined",是抛异常。

我一开始只想读ctx.agents,代码里顺手用了ctx.sessionQuery,直接炸。

正确做法:

// 必需的服务 → 写进 injectexportconstinject=['agents','sessions','sessionQuery']// 真正可选的服务 → 用 ctx.get(),它返回 undefined 而不抛错constattachments=ctx.get('attachments')if(attachments){/* ... */}

我封装了一个readOptionalService(ctx, name)处理这个,兼容 Cordis 的 Proxy 和普通对象(测试里用)。

1.2 第二个坑:peerDependencies会静默跳过整个 bundle

🔴高—— 静默失败,最难排查的一类问题。

bundle 的dsh.engines.dsh字段不会被强制检查。但如果你声明了@deepseek-ai/dsh-*的peerDependencies,不满足时会静默跳过整个 bundle—— 没有报错,插件就是不加载。

所以我的插件故意不声明任何dsh-*peer:

{"peerDependencies":{"@deepseek-ai/cordis":"~4.0.4","@deepseek-ai/schemastery":"~3.18.4"},"peerDependenciesMeta":{"@deepseek-ai/cordis":{"optional":true},"@deepseek-ai/schemastery":{"optional":true}}}

两个都标 optional—— 因为我把它们做成了可降级依赖(不装 schemastery 就用内置校验器)。

1.3 第三个坑:Config校验必须同步

🔴高—— 依赖 Cordis / Standard Schema 的内部调用方式。

resolveConfig内部调的是 Standard Schema 的~standard.validate(),必须是同步的。

我第一版写了个async validate,直接挂。


二、微信侧:走官方 iLink Bot API

不要去 hook 个人微信协议 —— 封号、不稳定、也不道德。

微信 ClawBot 背后是腾讯的iLink Bot API,有公开的协议行为可参考(@tencent-weixin/openclaw-weixin,MIT)。

核心就几个端点:

端点作用
get_bot_qrcode申请登录二维码
get_qrcode_status轮询扫码状态
getupdates长轮询收消息
sendmessage发消息
sendtyping“正在输入”
getuploadurl媒体上传

请求头:

iLink-App-Id: bot iLink-App-ClientVersion: <版本号> AuthorizationType: 1 Authorization: <token> X-WECHAT-UIN: <base64(随机 uint32 的十进制字符串)>

几个实战要点:

  • errcode -14= 登录态失效,需要重新扫码。插件要能自动暂停轮询、清空游标
  • 媒体是 AES-128-ECB + PKCS7 加密的,aes_key有两种线上编码(base64 的原始 16 字节 / base64 的 32 字符 hex 串),两种都要处理
  • 回复必须带context_token—— 这是会话路由锚点,只随入站消息下发。所以无法主动推送,只能"收到消息才回复"

三、真正的硬骨头:把微信接到 DSH 会话上

这是整个项目 80% 的 bug 来源。

🔴高(全节)—— 本节全部依赖 DSH agent 的内部契约。
下面每个 API 名、事件名、字段名都在DSH0.2.0-rc.2上实测。
升级 DSH 后这一节必须重新验证,尤其 3.2 和 3.3。

3.1 创建 / 恢复 agent

DSH 的 agent API(0.2.0-rc.2):

ctx.agents.get(id)// 拿活着的 agentctx.agents.create(options)// 新建ctx.agents.resume({resumeSessionId})// 恢复已持久化的会话

坑:固定会话在重启后已持久化,不能再create,会抛SessionAlreadyExistsError。

⚠️ 这个异常来自@deepseek-ai/dsh-session-persistence。我按名字判断而不是instanceof
—— 因为插件是link:装的,instanceof在模块实例不一致时会失效。

正确逻辑:

constexisting=ctx.agents.get(sessionId)if(existing)returnexistingif(awaitsessionExists(sessionId)){returnawaitctx.agents.resume({resumeSessionId:sessionId})}returnawaitctx.agents.create(options)

3.2 坑中之坑:{{model}}和{{cwd}}

这是最难的 bug,也是所有"空回复"问题的根源。

DSH 的系统提示词里引用了三个变量,persona 模板含有{{model}}、{{cwd}}:

You are a coding agent powered by the {{model}} model. ... ... {{cwd}} ...

而它们的值来自agent 对象(dsh-agent-loop/lib/index.js,0.2.0-rc.2 的 1564-1566 行):

ctx.systemPrompt.variable("provider",(context)=>context.agent?.options.provider);ctx.systemPrompt.variable("model",(context)=>context.agent?.options.model);ctx.systemPrompt.variable("cwd",(context)=>context.agent?.session.header.cwd);

任何一个没值,提示词组装直接抛错—— 而这个错发生在任何模型请求之前。

症状:回合立刻结束、零模型调用、回复为空。日志里只有:

prompt variable "{{model}}" has no value for this assembly

修复:

constoptions={agentOptions:{provider,model},// ← 提供 {{model}} / {{provider}}meta:{cwd:resolveCwd()},// ← 提供 {{cwd}}}

{{cwd}}特别阴险:session.header.cwd在会话创建时就固定,resume无法补。所以修复前创建的会话是永久损坏的 —— 只能删掉重建。

而且 DSH 会把没有 cwd 的会话放在一个叫_no-cwd的目录下。这是我发现真相的关键线索:

~/.dsh/sessions/_no-cwd/wechat-clawbot ← 目录名本身就是铁证

排查技巧:turn/end事件的reason.kind === 'error'后面就是 DSH 的原始原因。而会话日志是多帧 zstd(每次 flush 一个 frame),Node 的zstdDecompressSync只读第一帧 ——我一开始就栽在这里,以为事件没落盘。

🟡中——_no-cwd目录名和多帧 zstd 是观测手段,即使 DSH 改了实现,"去看持久化事件而不是猜"这个方法依然成立。

3.3 回合边界:不能用whenIdle()

🔴高—— 这一节的结论完全建立在对 agent 循环内部实现的观测上。

我想等 agent 跑完一个回合,然后取最终文本。第一版用了agent.whenIdle()——在空闲 agent 上立刻返回,拿到空结果。

第二版改成"等任意turn/end"—— 又错了。因为 agent 循环有个特性(dsh-agent-loop,0.2.0-rc.2):

// 回合在 driver 唤醒时打开,claim 为空时立刻关闭if(phase.step===0&&decision.messages.length===0){turnEnds={kind:'completed'};returnfalse}

也就是说会有一个不携带消息、不调模型的空回合,它的turn/end会抢先满足我的等待条件。

正确做法:等持久的边界事件 ——

  1. 先等agent/inbox/spliced事件里出现我们那条消息的 id(证明它被接收了)
  2. 再等那之后的turn/end
// 等消息被 admitconstmessageId=message.id// 然后等这个回合的 turn/end

然后从事件流里提取助手文本(取最后一个非空 content)。

💡这条规则即使 DSH 改了内部实现也大概率适用:不要依赖"agent 空闲了"这种
瞬时状态,要依赖持久化事件证明"我的消息被接收了"和"这个回合结束了"。
前者是观察,后者是契约。

3.4createUserMessage拿不到怎么办

🟡中—— 这是"打包环境"问题,不是 API 问题。

DSH 的@deepseek-ai/dsh-llm打包在 Electron 的app.asar里。而插件是以link:装进 profile 的 ——裸 import 会解析到插件自己的node_modules,找不到。

我的做法:优先用真实工厂(能解析到就用),否则用行为等价的本地实现。

关键是本地实现要逐字段对齐真实工厂(0.2.0-rc.2 的实现就是下面这三步):

functioncreateUserMessageLocal(input){returndeepFreeze(structuredClone({...input,id:randomUUID(),}))}

⚠️ 如果你要抄这个 fallback,务必对着当前版本的dsh-llm源码核对字段
—— 字段一旦增加,本地实现就会静默地少传东西。


四、长期运行的稳定性

4.1 长轮询不能饿死事件循环

🟢低—— 通用 Node.js 问题,与 DSH 版本无关。

getupdates是长轮询(几十秒)。如果在一个紧循环里跑,会饿死事件循环。

每轮迭代让出一次宏任务:

awaitnewPromise((resolve)=>setTimeout(resolve,0))

我的实现里还挂了AbortSignal,让停止时能立刻退出等待,而不是干等一个定时器。

4.2 去重和退避

🟢低

  • 按message_id去重(网络重试会发重复消息)
  • 失败退避(我用的 2s → 30s,连续失败 3 次后升到 30s)
  • 游标持久化,重启后接着收

4.3 日志必须落文件

🟢低(做法)+ 🟡 中(路径)

插件跑在 DSH 进程内部,它的 stderr 你在外面看不到。

所以我把所有运行记录写到一个持久化文件:

%DSH_HOME%\clawbot\channel.log

带 token 脱敏、2 MiB 上限、超限裁剪到 512 KiB。没有这个日志,线上问题只能靠猜—— 我前 3 个 bug 全靠它才定位到。

4.4 启动自检 —— 版本升级的第一道防线

🟡中(做法)+ 🔴 高(价值)

DSH 更新频繁,插件很容易被上游改动打挂。

我加了个启动自检:启动后跑一个合成回合,走真实的provider/model/cwd 提示词组装和真实模型调用,把结果写进日志。

SELFTEST PASS ms=4202 events=15 replyChars=146 SELFTEST FAIL (turn error) ... reason={"kind":"error",...}

用独立的<sessionId>-selftest会话,不影响真实对话。成本是每次启动一次模型调用,但它是 DSH 升级后最早能发现问题的信号。

💡如果你在做 DSH 插件,强烈建议也加一个。
我所有 8 个 bug 里,有 5 个是在提示词组装环节炸的 —— 而自检恰好完整覆盖了那个环节。
DSH 升级后你不需要记得去验证任何东西,看日志里那一行就够了。


五、两个"看起来能用其实没用"的教训

5.1 自检报假 PASS

第一版自检的逻辑是"没抛异常就算成功"。结果它报:

SELFTEST PASS ... replyChars=0

回复长度是 0 却报 PASS。这是我"验证"最典型的失败模式:判据本身是错的。

现在:空回复一律 FAIL,并打印turn/end的原始 reason。

5.2 离线测试全绿 ≠ 能用

这是整个项目最重要的一课。

我有 138 个离线测试(假 iLink 服务器、假 DSH 上下文),全绿。但它们证明不了插件在真实 DSH 上能跑。

8 个 bug 里有 5 个是在测试全绿的情况下被真实 DSH 打出来的:

bug离线测试为什么没发现
inject缺失假 ctx 是普通对象,不抛错
{{model}}无值假 agent 不做提示词组装
{{cwd}}无值同上
回合边界假 agent 没有真实循环的空回合特性
blocked未识别假 agent 不实现归档门

根本原因:测试替身比真实实现宽松。你写的假对象,只会实现你以为需要的东西。

出路是用真实的 DSH 代码做验证—— 我从app.asar里解出 DSH 的全部包,写了一批验证脚本,直接对着真实实现跑:

// 用真实 Cordisconst{Context}=awaitimport('.../cordis/lib/index.js')// 用真实 Sessionconstsession=Session.create(id,undefined,{header:{cwd:...}})

⚠️ 注意Session.create的签名是(id, seed, header, ...)——
header是第三个参数,不是第二个。我第一版写成(id, { header }),直接报
seed.entries is not a function。这类签名细节必须看源码,不能猜。


六、配得上"生产可用"的几件事

6.1/list/use/back:从微信远程接管另一个会话

🟡中—— 用的都是公开 API(agents.get/followup),设计思路与版本无关。

默认微信消息进插件自己的会话。但你可能正在 DSH 里干一个活,想在外面用手机看进度、下指令。

ctx.agents.get(sessionId)能拿到活着的 agent,对它followup()就能把微信消息注入那个会话:

/list 列出正在运行的会话 /use 1 接管第 1 个 现在到哪了? ← 这条进那个会话 /back 退回微信自己的会话

设计要点:

  • 只拦截精确的命令词。/model、/new、/hello /use 1这些原样转发给 agent,不误吞
  • 不打断正在跑的活——followup()进的是"下一个回合"队列
  • /back不销毁别人的会话—— 只销毁插件自己创建的 agent
  • 默认只列正在运行的—— 一屏已停止的旧会话只是噪音

6.2 归档的会话会被拒绝执行

🔴高—— 完整依赖archived-session-gate这个内部插件的行为。

DSH 有个archived-session-gate(dsh-api-session-controller,0.2.0-rc.2):

ctx.on('agent/pre-step',(payload,next)=>underArchivedSession(ctx,payload.agent)?Promise.resolve({kind:'reject'}):next())

归档的会话里任何步骤都被拒绝,回合以blocked结束,不调模型、无输出。

我踩这个坑是因为:/list第一版把所有会话都列出来(一堆噪音),我顺手把自己那个会话归档了 ——然后整个通道就死了,而且症状是"回复为空",看不出原因。

现在插件会提前检测归档状态并给出明确提示,同时/list过滤掉归档/子代理/插件自己的会话。

💡通用教训:turn/end的reason.kind不止error和completed。
DSH 里至少有completed/aborted/blocked/error/max-tokens/interrupted/forked。
只判断"有没有报错"会漏掉blocked这类静默失败。我漏了一个版本才补上。


七、发布:GitHub 只放源码,npm 放编译产物

🟢低(做法)+ 🔴 高(一处依赖)

这是我认为最值得抄的一个实践:

渠道内容
GitHub只有源码(.gitignore排除lib/)
npm编译后的正式版(tarball 含lib/)
{"scripts":{"prepare":"tsc -p tsconfig.json","prepublishOnly":"npm run verify"}}
  • prepare:npm install和npm publish前自动编译
  • prepublishOnly:发布前跑完整测试 ——构建或测试挂了就发不出去

好处:两个渠道不可能"不一致",因为 npm 的产物就是从这份源码编译出来的。

而且用户安装体验完全不同:

dsh plugin--profile desktop add dsh-wechat-ilink

一条命令搞定。因为 tarball 里已经带着lib/,不需要任何构建步骤。

如果从 GitHub 直接装,pnpm 默认禁止依赖运行构建脚本,会卡在prepare上,需要手工加allowBuilds配置。这就是为什么用户该走 npm。

额外发现:dsh plugin add会自动注册 bundle

🔴高—— 依赖dsh-plugin-manager的内部行为,但这条最值得验证。

我原以为用户还要手工编辑dsh.profile.bundles。读了官方 CLI 源码后发现不用(dsh-plugin-manager/lib/types/operations.js,0.2.0-rc.2 的第 503 行):

elseif(options.activateNewBundles!==false){awaitreconcile(before,dir,context.installAnchor,options);}

dsh plugin add会自动把包名写进dsh.profile.bundles。我实测确认了:

dsh plugin --profile test add dsh-wechat-ilink → bundles: ["@deepseek-ai/dsh-base", "dsh-wechat-ilink"] ← 自动写入

教训:别猜工具怎么工作,去读它的源码。我差点把一个不存在的手工步骤写进文档。

⚠️ 这个自动注册是当前版本的行为。如果将来变了,症状是"包装上了但插件不生效"
—— 那时候检查dsh.profile.bundles里有没有你的包名。


八、清单:开发 DSH 插件要注意的

Cordis 侧

  • inject里列全必需服务;可选服务用ctx.get()
  • 不要声明@deepseek-ai/dsh-*的 peer(会静默跳过 bundle)
  • Config校验必须同步
  • 用ctx.effect()绑定后台任务的生命周期

DSH agent 侧(🔴 高风险区,升级必查)

  • create/resume要处理SessionAlreadyExistsError(按错误名判断,别用instanceof)
  • 必须提供agentOptions(provider + model)和meta.cwd
  • meta.cwd一旦漏传,会话永久损坏(header.cwd创建后不可改)
  • 回合边界等agent/inbox/spliced+turn/end,不要用whenIdle()
  • 识别turn/end的全部reason.kind:至少completed/error/blocked
  • 注意archived-session-gate:归档会话会被拒绝执行
  • Session.create的签名是(id, seed, header, ...),别把 header 传成第二个参数

运维侧

  • 日志落持久化文件(进程内 stderr 看不到)
  • 加 token 脱敏
  • 加启动自检(跑真实回合)←DSH 升级后最重要的一道防线
  • 长轮询每轮让出事件循环

验证侧

  • 离线测试全绿不代表能用—— 用真实 DSH 代码做验证
  • 检查项别硬编码常量(包名、路径),从源头读取
  • 验证脚本自己也会错 —— 它报 PASS 不等于真的验证过

升级 DSH 后

  • 看启动自检日志:SELFTEST PASS还是FAIL
  • 若 FAIL,reason里有 DSH 的原始报错
  • 检查dsh.profile.bundles里插件名还在不在
  • 重跑一遍本文 3.2 / 3.3 / 6.2 三节对应的行为

九、最后的复盘

这个项目从"能装上"到"真的能用",一共8 个 bug,其中最后 3 个本质是同一个错误:

我创建 agent 时只传了最少字段,然后一个个撞上 DSH 期望的输入。

如果一开始就把agentLoop.create的契约读清楚,后面三个都不会发生。

还有两次误判病根(“空回合抢跑”、完成判据选错),都是我拿着不确定的理解直接动手改,结果写了个"修复"却没修到点子上。两次都是靠真实数据推翻的—— 先是channel.log,然后是那个多帧 zstd 的会话文件(我第一次解压只读了第一帧,误以为事件没落盘)。

真正让项目往前走的一直是同一个动作:看数据,别猜。

几条我认为最值钱的经验:

  1. 测试替身比真实实现宽松—— 你写的假对象只实现你以为需要的东西。5 个 bug 因此漏网。
  2. 判据本身也可能是错的—— 自检报replyChars=0却 PASS,就是判据错了。
  3. 工具的源码是最好的文档——dsh plugin add会自动注册 bundle,这件事写在operations.js第 503 行,一个grep就能看到。
  4. 打算绕过去的那一步,后面往往藏着真问题—— 我因为没装 git,写了个"模拟 git 行为"的检查脚本;装上真实 git 后立刻发现两个问题(CRLF 规范化、CLI 缺 shebang,后者会让 Linux 用户直接跑不起来)。

最后关于版本:DSH 还在0.2.0-rc阶段,API 会变。本文标 🔴 的地方请务必自己重新验证一遍 —— 但 🟢 那些工程实践(长轮询让出、日志落盘、持久化事件边界、启动自检)无论版本怎么变都成立,那才是这篇文章能长期有效的部分。


附:项目信息

  • GitHub:https://github.com/ranshaodexiao/dsh-wechat-ilink
  • npm:https://www.npmjs.com/package/dsh-wechat-ilink
  • 安装:dsh plugin --profile desktop add dsh-wechat-ilink
  • 适配 DSH 版本:0.2.0-rc.2(Cordis4.0.4、Node ≥ 22.13.0)
  • 规模:源码 16 个文件 / 3904 行,测试 7 个文件 / 2318 行 / 138 个用例
  • 协议:腾讯官方 iLink Bot API(MIT 参考实现@tencent-weixin/openclaw-weixin)
  • 不做:企业微信、公众号、小程序、个人号协议 hook、公网穿透

功能:微信 ↔ DSH 会话双向对话(文字 + 图片)、微信远程接管 DSH 里正在干活的会话、启动自检、持久化日志。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询