DeepSeek 开源了自己的 Agent 框架 dsh,我给它写了个 A 股插件并发到了 npm
8 月 13 日,DeepSeek 官方开源了 DeepSeek Harness (CLI 名 dsh),MIT 协议,内核是 Cordis,设计口号是「一切皆插件」------模型、工具、技能、Web UI,全是可插拔模块。
我花了一天多,给它写了一个 A 股信息查询插件 dsh-astock-research,已经发到 npm。这篇把插件是怎么写出来的完整拆开讲:文件结构、工具注册、技能随包走、以及我踩到的几个坑。
源码:github.com/tiantianlao... (Gitee 镜像 gitee.com/skingway/ds... )
一、结论先行:一个 dsh 插件最少要几个文件
bash
dsh-astock-research/
├── package.json # 声明 dsh.bundle
├── index.js # 插件本体:注册工具 + 注册 skill
├── skill.md # 技能说明书(给模型看的规则)
├── cordis.patch.yml # bundle 层:profile 引入本包时应用
├── README.md
└── LICENSE
真正干活的只有两个:index.js 和 skill.md。零外部依赖,原生 fetch + node:fs 就够。
package.json 里关键的是最后那段 dsh 字段:
json
{
"name": "dsh-astock-research",
"version": "0.1.1",
"type": "module",
"main": "index.js",
"files": ["index.js", "skill.md", "cordis.patch.yml", "README.md"],
"keywords": ["dsh-plugin", "deepseek-harness", "astock"],
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
}
}
dsh.bundle.patch 指向的那个 YAML,就是 dsh 认这个包是「插件包」的凭据。
cordis.patch.yml 本身极简,作用是告诉 profile「把这个插件 insert 进来」:
yaml
- insert:
- id: astock-research
name: dsh-astock-research
name 走 Node 模块解析,所以装完包直接按包名引用就行,不用写路径。
二、index.js:注册工具
插件是标准 ESM,导出 name / inject / apply 三样:
js
export const name = 'astock-research'
export const inject = ['tools', 'skills']
export function apply(ctx, config = {}) {
const BASE = (config.baseUrl || 'https://quant.tybbtech.com/api').replace(/\/+$/, '')
// ... 注册工具
}
inject 声明依赖哪些服务,apply 里就能拿到 ctx.tools / ctx.skills。config 是用户在 profile 里给这个插件传的配置,写默认值兜底即可。
注册一个工具长这样:
js
const OUT = {
schema: { type: 'object' },
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
}
const reg = (def) => ctx.tools.register({ output: OUT, ...def })
reg({
name: 'stock_search',
description: '按名称或代码搜索A股股票, 返回代码/名称/市场。想查一只股票的任何信息前, 先用这个把名称转成6位代码。港股/美股/未上市标的返回空列表 (仅覆盖沪深及北交所A股)。',
parameters: {
type: 'object',
properties: {
keyword: { type: 'string', description: '股票名称或代码, 如 "600519"' },
},
required: ['keyword'],
additionalProperties: false,
},
async execute(args) {
const d = await api(`/search?keyword=${encodeURIComponent(args.keyword)}&limit=10`)
return { results: d.results || [] }
},
})
三点值得说:
1)output.render 决定工具结果怎么进上下文。 我这里统一 JSON.stringify 成一段 text。既然结果是要进上下文的,就得在 execute 里先瘦身再返回,别把接口返回的整坨 JSON 直接倒进去。比如财报只留最近 8 期趋势、因子目录只留三个字段:
js
// stock_financials:趋势只留最近 8 期
return { code: d.code, name: d.name, industry: d.industry, latest: d.latest,
trend: (d.trend || []).slice(-8) }
// factor_catalog:每条只留 id / 白话名 / 分类
return { atoms: (d.atoms || []).map((a) => ({
id: a.id, name: a.layman_name || a.professional_name, cat: a.category_label })) }
2)description 不是注释,是给模型看的说明书。 这一条我认为是写 Agent 工具最关键的心法。比如 factor_catalog 这个工具,我在 description 里明确写了它不能做什么:
js
description: '列出交易信号因子目录 (id + 白话名 + 分类), 用于给用户科普"有哪些技术信号"。'
+ '注意: dsh 这里只能科普信号含义, 不能扫描"哪些股票触发了某信号 / 触发后历史胜率如何"'
+ '------那是策略反找, 属于 AIHEY 交易推演助手的功能。'
把能力边界写进 description,模型就不会硬着头皮编一个它做不到的结果出来。
3)参数校验放在 execute 里早退。 比如六位代码:
js
const CODE_RE = /^\d{6}$/
if (!CODE_RE.test(args.code)) throw new Error('code 须为6位数字, 先用 stock_search')
抛出的 Error message 模型是看得见的,所以报错文案要写成"可以走通的下一步",而不是单纯的失败通知。同理,我把网络层的异常也全部翻译成了人话:
js
} catch (e) {
const timedOut = e?.name === 'TimeoutError' || e?.name === 'AbortError'
throw new Error(timedOut
? 'A股数据服务响应超时 (20秒)。稍等片刻重试即可; 若反复超时, 服务可能在维护。'
: 'A股数据服务暂时连不上。请先检查本机网络; 网络正常仍失败的话, 服务可能在维护, 稍后重试即可。')
}
还有一个细节:查财报遇到 404(新股或未覆盖),我不当错误抛,而是正常返回一段说明------
js
if (e?.status === 404) {
return { code: args.code, note: '暂无这只股票的财报档案 (可能是新股或数据尚未覆盖), 可改查公告或画像。' }
}
因为「这只票没有财报档案」是业务上的正常结果,报成工具错误会让模型误以为系统坏了,然后开始瞎重试。
三、让规则随插件一起走:内联注册 skill
工具解决「能拿到什么数据」,但**「拿到之后该怎么讲」**得另外管。dsh 的 skill 机制正好干这个。
最省事的做法不是实现一个完整的 SkillProvider,而是运行时内联注册:
js
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
try {
const here = dirname(fileURLToPath(import.meta.url))
const skillBody = readFileSync(join(here, 'skill.md'), 'utf8')
ctx.skills.register({
name: 'stock-research', // 必须 kebab-case
description: SKILL_DESCRIPTION, // 决定模型什么时候激活它
content: skillBody,
source: 'bundled',
})
} catch (e) {
ctx.logger?.warn?.('skill 注册失败 (%s), 工具仍可用', e?.message || e)
}
要点:
- 用
import.meta.url定位同目录 ,不要用process.cwd()。用户从哪个目录启动 dsh 是不确定的,cwd 一定会踩空。 validateRuntimeSkill只强校验name(kebab-case)/description/ invocation,source传'bundled'即可。- 失败要降级,不要抛。skill 注册不上,工具还能用;直接抛异常整个插件就废了。
skill.md 里我写了三件事:合规红线(只陈述事实、不荐股、每条带免责声明)、数据源路由、引导规则。
数据源路由这块顺带说个方法论上的选择。dsh 本体自带联网搜索,那么什么时候查数据库、什么时候联网?我没有写关键词清单,而是给了一条判断原则:
先问自己:这个问题要的是一个确定的数字/事实 ,还是一段需要综合多源的叙事/解释 ? 要确定数字 → 走结构化数据工具;要叙事解释 → 走联网;两者都要时各取一半,但必须分清每部分来源。
清单一定会漏,原则不会。这条我踩过好几次坑之后才想明白。
四、装上去跑起来
sh
npm install -g @deepseek-ai/dsh pnpm # dsh 本体 + pnpm(装插件要用)
dsh plugin --profile web add dsh-astock-research
dsh web
第二条会把包写进 profile 的 package.json 的 dsh.profile.bundles,并 link 进 node_modules:

启动 dsh web(不带 --patch)就是干净启动,用 dump-config 可以确认 bundle 层已经在了。
跑起来是这个样子------工具调用链在界面上是摊开的,哪一步调了什么一目了然:

模型拿到结构化公告之后,按 skill.md 里的规则逐条讲成人话,末尾自动附免责声明:

问到超出插件能力边界的东西(比如「回测怎么做」),它会科普流程、并按引导规则给出去处,而不是硬编一个结果:

五、几个坑
1)本地调试用 --patch 时,Windows 路径必须写成 file:///D:/... URL。 直接写 D:\path\plugin.js 会被 ESM 当成协议解析,报 d: 协议不支持。这个错误信息完全看不出是路径问题,我卡了挺久。
2)dsh plugin 命令内部转发 pnpm。 只装了 npm 的机器上这条会失败,所以我把 pnpm 一起写进了第一条安装命令。
3)不要用 github: 协议分发。 一开始我图省事,让用户 dsh plugin add github:xxx/yyy。结果 github: 协议直连 codeload.github.com,中国大陆用户基本装不上。后来老老实实发了 npm 包,npm 源和淘宝镜像国内都可达,问题消失。
4)README 里的截图用相对路径。 相对路径在 GitHub 和 Gitee 都能正常渲染,但 npm 包页面不显示。反过来,用 raw.githubusercontent.com 绝对路径的话,npm 页面能显示但大陆用户打不开。我选了前者。
5)npm 账号用 passkey 做 2FA 的话,npm publish 必须在真交互终端里跑。 passkey 不产生 6 位码,--otp= 参数没有意义,非交互 shell 只会报 EOTP。
六、发布之后:让别人找得到
dsh 的插件生态起得很快------已经有多个自动聚合站,规则是只要 GitHub 仓库挂上 dsh-plugin topic 就会被收录,等于一次上架多个「应用商店」,Web UI 里的插件市场也从这里取。所以发完 npm 记得回 GitHub 挂 topic。
精选榜 awesome-dsh-plugin 的 CI 有硬门槛:仓库创建满 1 天 + 提交数 ≥ 10 。我是提 PR 之前翻它的 CI 配置才发现的------当时仓库只有 1 个 commit,提上去必被机器人打回。所以先把该做的事做掉攒够真实提交(英文 README、CHANGELOG、报错友好化、跨平台说明),再去提。PR 格式也有讲究:只加一个 YAML 描述文件,然后跑它的脚本重新生成 README,绝对不要手改 README。
最后
完整源码在这里,MIT 协议,欢迎抄走改成你自己领域的插件------把 api() 换成你自己的接口,skill.md 换成你自己的规则,骨架是通用的:
- GitHub:github.com/tiantianlao...
- Gitee:gitee.com/skingway/ds...
插件本身只做「单点、当下」的查询与解读。需要跑全市场历史逐日数据的部分(策略回测、信号反找、历史模拟盘)我放在了自己的服务里,插件通过一个工具给出入口------这个拆分方式对「本地插件 + 远端重计算」这类场景应该有参考价值。
数据均来自公开渠道,仅供研究参考,不构成投资建议。