DeepSeek Harness 架构分析与 LocalBrain 的对接
harness 这个词被中文翻译丢掉了一半意思;而它恰恰解释了 dsh 为什么能把 DeepSeek 自己换掉。
把本地模型接进 dsh 那天,我先卡在一个词上:harness。中文资料多半译成「框架」或「外壳」,可这两个词都解释不了它为什么把模型适配器、工具注册表、连 agent 循环本身都做成可替换的插件。
读完架构文档我才明白:它不是外壳,是挽具。下面分三段讲——harness 到底指什么、dsh 的架构长什么样、以及怎么用方寸智匣把模型调用接到本地。
▍🧭 harness 不是外壳,是挽具
先说这个词本身。harness 在英文里是套在牲口身上的那套挽具:缰绳、轭、拉套。它不提供动力,动力来自牲口;它的作用是把动力接到车上,并且让人能控制方向。
换成模型就很好懂了。模型本身只会生成下一个 token,它不知道你的文件在哪、不会自己去跑命令、也记不住上一轮说过什么。harness 干的就是挽具的活:替它保管上下文、替它调用工具、替它在文件和终端之间跑腿,直到一整件事办完。
值得说明的是,官方词汇表并没有给 harness 单独下定义——在 DeepSeek 的文档里,它就是这套框架的自称,README 第一句写的是「an open-source agent harness」。所以「harness 是什么」不该去查定义,该去看它把哪些活揽了下来。看架构文档就一目了然:会话日志、系统提示词组装、工具执行流水线、权限与沙箱、轮次调度——这些全是挽具,不是马。
这也解释了那句被反复引用的话为什么成立:模型可以换,挽具不用换。
// 动力在模型,方向盘在 harness。
▍🔌 架构核心:一切皆插件,连内核都没留
官方架构文档写得很直白:产品的每一部分都是插件,包括模型适配器、工具注册表、会话日志,以及 agent loop(智能体循环)本身,因此每一部分都可以从配置替换。
真正的分水岭是紧接着的下一句——不存在需要打补丁的特权内核。扩展 dsh 不是挤进内核,而是把插件挂到别的插件旁边;每一项注册都是可撤销的副作用,插件卸载时按原路收回。
底座叫 Cordis,由 dsh 以 vendor 方式引入。它只有五个概念,而每一个都在回答同一个问题:凭什么能换掉。
| 概念 | 它是什么 | 为什么这让它能换 |
|---|---|---|
| 插件 | 一个带 apply 函数的对象,或一个 Service 子类 | 最小替换单位 |
| 上下文 | 服务的容器,一个服务占据一个稳定的 ctx 键 | 按键查找,而不是导入具体实现 |
| inject | 声明依赖哪些服务,就绪后才启动 | 加载顺序由依赖推导,不用手排启动序列 |
| 类型化事件 | emit、waterfall、parallel、serial 四种分发 | 拦截与改写发生在事件上,不必改源码 |
| 可逆副作用 | 注册即副作用,reload 与 teardown 时撤销 | 卸载不留残渣 |
※ 概念与分发模式取自官方 Cordis 入门文档。
一个能被加载的插件只需要这么多——没有基类,没有注册中心,导出一个名字和一个 apply 就是全部:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello-plugin'
export function apply(ctx: Context) {
// 声明的依赖会在 apply 执行前就绪
console.log('[hello-plugin] plugin loaded!')
}「按键查找」这个约定,正是本地模型能接进来的物理基础:别的插件要的是 ctx.llm 这个位置,而不是某一个具体适配器。官方把这种可替换能力叫 seam(接缝),并规定一个 seam 必须同时凑齐三种角色——声明接口的 Service Definition、实现它的 Provider、使用它的 Consumer。seam 正是替换一个提供方就能改变整个产品的原因。
运行中的 dsh 因此是一棵叠出来的插件树:先按顺序叠 profile 列出的各个组合包,再叠 profile 自己的 cordis.patch.yml,然后是 home 级的那一份,最后是命令行 --patch 覆盖层。一条 patch 按 id 找到某个条目,替换掉它整个 config,或者插入一个新条目。
这条分工后面对接时会一直用到:settings.yaml 改的是插件的配置值,cordis.patch.yml 改的是插件树本身。
想知道自己机器上到底跑了什么,不必猜:
$ 打印本机实际启动的完整配置树
dsh --profile web --dump-config
$ 不启动,只看默认组合
dsh --profile web --dump-default-config它打印出的任何条目,都可以被你自己的 patch 替换——这是「一切皆插件」唯一需要亲手验证的地方。
和 VS Code 的差别也在这里。VS Code 的扩展挂在一个你动不了的编辑器内核旁边;dsh 把内核本身也摊成了配置树里的普通条目。前者让你扩展它,后者允许你替换它——包括替换掉 DeepSeek 自己。
// 能被 dump 出来的,就能被替换掉。
▍🎛 两组四选一:一组管进程,一组管视野
上手时最容易犯的错,是把两个「四选一」当成同一个设置。命令行里 dsh --profile 后面能跟四个名字,网页端会话框上方还有个下拉菜单也是四选一。入口决定挂载哪些组合包,preset 决定这一个会话向模型公开哪些工具和提示词段落。两者正交,可以自由组合。
先看入口。四个里只有三个真的会跑智能体:
| 入口 | 它挂载了什么 | 什么时候用它 |
|---|---|---|
| dsh web | base + web-app:HTTP 服务器、Web 运行时、浏览器客户端 | 日常交互,默认落在 127.0.0.1:3080 |
| dsh --profile headless 任务文本 | base + headless:不挂服务器、不挂浏览器客户端、不开监听端口 | 脚本与流水线,打印最终答案就退出 |
| dsh --profile 名称 | 你自己攒的组装,存在 $DSH_HOME/profiles/ 下 | 换掉整套外壳,比如装一个第三方 TUI |
| dsh plugin --profile 名称 … | 不跑智能体,是 pnpm 透传 | 给某个 profile 装、卸、升级插件 |
※ web 与 headless 首次使用会从随附模板自动初始化,其余名称必须先用 dsh plugin 创建。
headless 那一行对自动化最值钱:它的退出码是有语义的——最终原因是 completed 就退 0,否则退 1,成功时 stderr 一个字都不写,可以直接塞进 cron 和 CI,不必解析日志猜结果。而 dsh plugin 根本不是运行模式,它只是把参数原样转发给 pnpm,成功后只改磁盘上的清单;正在运行的 profile 仍保留启动那一刻的组合包集合,增删插件必须重启才生效,而普通 cordis.patch.yml 的改动是热重载的。
再看 preset。随附四个,界面中文名与目录 id 的对应关系如下:
| 界面名称 | 目录 id | 组装行数 | 与标准模式的差异 |
|---|---|---|---|
| 标准模式 | standard | 252 | 基准:文件编辑、Shell、检索、Skills、计划、目标、子代理、工作流 |
| PTC 模式 | code | 263 | standard 逐字不变,只多一行工具呈现配置 |
| 极简模式 | minimal | 88 | 只留持久 shell 与 str_replace_editor |
| 创造模式 | cordis | 263 | standard 之上加自省工具、创作 skill 与专用人设 |
※ 行数为各 preset 的 agent.cordis.yml 实际行数,取自官方仓库 apps/cli/config/agent-presets/ 下的四个目录。
PTC 模式改的是工具呈现方式,从 native 换成 code:模型只看得到 run_code 一个工具,外加一份自动生成的 SDK 提示词,然后用一段 TypeScript 程序把多步操作组合起来,本来要五次往返的序列变成一次。它需要宿主提供 codeRuntime,没有就在挂载时点名失败。创造模式则多了一套能读写自己所运行的那个运行时的工具——官方在文件头明写:它会拿模型写的 JavaScript 去对着活的运行时求值,这个 preset 上的会话应当视同 shell 访问权限。
还有一条产品级的锁:只有尚未产出任何内容的空白会话才能切换 preset。聊到一半换掉工具集,会在日志里留下新组装根本执行不了的工具调用,所以这道锁由网关在传输层直接拒绝。
// 入口决定进程怎么起,preset 决定模型看见什么。
▍🔗 用方寸智匣接管模型调用
开箱状态下,dsh 的默认智能体模型指向 deepseek-official,模型是 deepseek-v4-flash——也就是说,你不改配置,请求就走 DeepSeek 自家的按量计费入口。框架免费开源,模型可以替换,默认流量却先经过它自己的那扇门。

要把这扇门改到本机,落点是两份文件,而它们各管一半、规则也不通用。
第一份是 settings.yaml,改的是模型路由。方寸智匣写进去的是这样一段(凭据已脱敏):
llm-pi-ai:
providers:
localbrain:
displayName: LocalBrain (本地 MLX)
api: openai-completions
baseURL: http://127.0.0.1:11434/v1
headers:
Authorization: <占位凭据>
compat:
supportsDeveloperRole: false
maxTokensField: max_tokens
defaultInput: [text, image]
models:
- id: /绝对路径/到/你的模型目录
name: Qwen3.8-27B-...-GGUF
input: [text, image]
contextWindow: 81920
agent-default-model:
provider: localbrain
model: /绝对路径/到/你的模型目录这段配置里有四处是踩过坑才补上的,少一处都会让接口在真正调用时才翻车:
| 字段 | 不写会怎样 |
|---|---|
| compat.supportsDeveloperRole: false | 系统提示词以 developer 角色发出,很多本地网关直接拒绝,表现为「只有推理模型失败」 |
| compat.maxTokensField: max_tokens | 输出上限写作 max_completion_tokens,只认 max_tokens 的服务端拒绝每一个请求 |
| input / defaultInput 含 image | 图片在发送前就被拒绝,并点名不支持的模型 |
| contextWindow | 沿用适配器默认的 1,000,000,压力敏感插件会按一百万来估算,长对话可能整段进不了模型 |
※ 图片模态是对端点的断言而不是检查:多写 image 同样有代价——图片会先进入持久化历史,再被 provider 拒绝,而且必须另开新会话才能收场。
还有两处容易忽略。没有真实密钥的本地服务,也仍然要放一个占位凭据,否则请求以 MISSING_CREDENTIAL 失败;而 model 的 id 会被原样当成协议里的 model 字符串传给端点,所以这里可以直接填本地模型目录的绝对路径。
第二份是 cordis.patch.yml,改的是插件树本身——六个本地 MCP 就挂在这里。官方明确警告不要覆盖现有 patch,因为里面可能还有无关的用户配置;方寸智匣的处理办法是把自己写的部分用一对注释包起来:
# >>> LocalBrain managed block — 由 LocalBrain「一键写入」维护,请勿手工编辑块内内容
- insert:
- id: 'localbrain-whisper'
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: 'localbrain-whisper'
transport: stdio
command: '/…/LocalBrain/runtime/mlx-env/bin/python3'
args: ['-m', 'localbrain_mcps.whisper']
toolCallTimeoutMs: 600000
# …tts / image / video / webminer / docfactory 同构
# <<< LocalBrain managed block成对标记解决的正是官方那条警告:它只重写自己那一段,块外的用户配置原样保留。
这段里同样有两个不能省的细节。toolCallTimeoutMs 必须显式加大:MCP 工具调用默认只有 60000 毫秒,而本地语音、图像、视频任务经常来不及,这里写的是 600000。另外每个服务实例的名字必须满足 [A-Za-z0-9_-]{1,32},因为模型最终看到的工具名是「mcp 双下划线 服务名 双下划线 工具名」拼出来的。
改完不用重启:settings.yaml 会被监听并热更新,写回时还会创建兄弟锁文件,锁的获取上限是 2 秒。锁文件即使看起来很旧也不能随手删——文件年龄无法证明写入进程已经死掉。
▍📊 接上之后,模型到底看见了什么
配置写完不等于接通。下面这组数字取自我这台机器上四次真实会话的日志(dsh 0.1.1-rc.2,六个 LocalBrain MCP 全部在线),标准与极简两列是实测,另两列按同一套配置推算。

| 项目 | 标准模式 | PTC 模式 | 极简模式 | 创造模式 |
|---|---|---|---|---|
| 协议里的工具条目数 | 42 | 1 | 19 | 49 |
| 其中 preset 层贡献 | 25 | 25 | 2 | 32 |
| 其中 LocalBrain 的 MCP 工具 | 17 | 17 | 17 | 17 |
| 系统提示词长度 | 6267 至 6355 字符 | 更长,多一段 SDK 章节 | 46 字符 | 约 7600 字符 |
※ 六个 MCP 服务实际公布 17 个工具。标准与极简取自会话日志的 request/header 事件;PTC 与创造按同一套配置推算,没有实跑。
这张表里有两处最容易读反。
第一处:极简模式并不会把你的工具砍到只剩两个。它砍掉的只是 preset 那一层;MCP 客户端是挂在宿主平面的 cordis.patch.yml 上的,所以那 17 个工具在极简模式里一个不少,42 减到 19 而不是减到 2。实测里它还真的用了——一次标着 minimal 的会话,照样调了 1 次 bash、2 次网页搜索和 4 次网页抓取,全部来自 MCP。它真正砍掉的是提示词:6267 字符对 46 字符。
第二处:PTC 模式不是把 41 个工具删掉了,而是把它们从协议的工具数组搬进了系统提示词的 SDK 章节。能力一个没少,程序里照样调得到全部可见工具,包括那 17 个 MCP 的;省的是往返次数,不是单次请求的体积。
还有两件只有跑起来才撞得到的事。其一,宿主平面自带的 web_search 和 MCP 那个同名搜索工具会同时在列,实测模型七比一偏向后者;名字近似的重复工具不会报错,只会让模型每次自己挑一个。其二是稳定性:一个 step 上连撞五次重试才收场,失败码是 TRANSPORT,也就是连接被掐断;走的是默认重试策略——最多 5 次,退避 500 到 10000 毫秒、抖动 0.1,且只对空响应、限流、服务端错误、超时和传输失败这五类重试。
接本地模型时,你多半不是败在配置写错,而是败在连接不稳。
▍✅ 怎么选,以及这套方案的边界
两组四选一各管一半,照下面这张表对号入座:
| 你想干的事 | 选哪个入口 | 选哪个 preset |
|---|---|---|
| 日常写代码、要看得见过程 | dsh web | 标准模式 |
| 塞进 cron 或 CI,靠退出码判断成败 | dsh --profile headless | 标准或极简 |
| 想砍掉六千多字符的提示词开销 | 任意 | 极简模式 |
| 多步操作压成一次模型往返 | 任意 | PTC 模式,前提是有 TypeScript 运行时 |
| 让它读写自己所在的运行时 | dsh web | 创造模式,当 shell 权限对待 |
※ 入口与 preset 互不影响,可以自由组合。
边界必须说清楚。这套框架仍是 developer preview,官方用大写警告会出现兼容性破坏;上面的实测来自单机单次,PTC 与创造两列没有实跑;这里也没有验证 DeepSeek 官方云端模型的实际表现,不能拿本地链路的成功去替它背书。
最后回到那个词。harness 之所以译成「外壳」会丢掉意思,是因为外壳暗示里面还有个不能动的核;而 dsh 连 agent 循环都摊成了配置树里的普通条目。把模型换成本机的那一刻,你换掉的不是它的一个选项,而是它默认替你做的那个决定。
$ 框架与文档
https://github.com/deepseek-ai/deepseek-harness
方寸智匣下载页(一键写入模型路由与六个本地 MCP):
https://github.com/HackerChi-Hub/localbrain-releases/releases