提示 黑粉科技 · 本地部署 / 免费白嫖 / 自制软件
DeepSeek Harness 架构分析与 LocalBrain 的对接
harness 这个词被中文翻译丢掉了一半意思;而它恰恰解释了 dsh 为什么能把 DeepSeek 自己换掉。
提示 2026-08-24·黑粉科技
1、解除局域网限制、不启动
dsh,使用这一行:
把本地模型接进 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 官方云端模型的实际表现,不能拿本地链路的成功去替它背书。
**▍✅ 如何给局域网共享使用 **
下面假设你的 dsh 安装目录是:
~/dsh
如果安装在其他目录,只需要修改命令开头的:
cd ~/dsh
整个流程分为三步:
-
解除
dsh对0.0.0.0的启动限制。 -
修复局域网 HTTP 环境下的
crypto.randomUUID报错。 -
启动局域网服务。
前两步只需要首次配置时执行一次,以后日常使用只执行第三步。
第一步:解除 0.0.0.0 局域网监听限制
全新安装的 dsh 虽然底层服务器支持监听 0.0.0.0,但启动程序会主动拒绝,并显示:
error: --host 0.0.0.0 is intentionally not supported yet for safety
执行下面这一整行解除限制:
cd ~/dsh && DSH_STARTUP_FILE="./node_modules/@deepseek-ai/dsh-web-app/lib/startup.js" && cp -n "$DSH_STARTUP_FILE" "$DSH_STARTUP_FILE.bak" && sed -i '' '/if (options\.host === "0\.0\.0\.0") program\.error/d' "$DSH_STARTUP_FILE" && echo "已解除 dsh 的 0.0.0.0 访问限制"
正常情况下会显示:
已解除 dsh 的 0.0.0.0 访问限制
这条命令会:
-
找到
dsh web的启动文件。 -
将原始文件备份为
startup.js.bak。 -
删除禁止
-host 0.0.0.0的安全检查。 -
保留端口检查和其他配置不变。
这条命令可以重复执行。如果限制已经删除,再次执行不会重复修改。
第二步:修复 crypto.randomUUID is not a function
通过 Mac 本机地址访问时:
http://127.0.0.1:3080/
浏览器通常会提供:
crypto.randomUUID()
但是,局域网设备通过普通 HTTP 地址访问时:
http://192.168.x.x:3080/
部分浏览器会把页面视为非安全上下文,因此不提供 crypto.randomUUID()。
这会导致发送消息、调用接口或打开文件选择器时出现:
crypto.randomUUID is not a function
执行下面这一整行,为浏览器客户端代码加入兼容处理:
cd ~/dsh && node -e 'const fs=require("fs"),p=require("path");const root="node_modules/@deepseek-ai",from="crypto.randomUUID()",replacement="(typeof crypto.randomUUID === \"function\" ? crypto.randomUUID.call(crypto) : \"10000000-1000-4000-8000-100000000000\".replace(/[018]/g, c => (c ^ crypto.getRandomValues(new Uint8Array(1)).at(0) & 15 >> c / 4).toString(16)))";let files=0,calls=0;function walk(dir){for(const entry of fs.readdirSync(dir,{withFileTypes:true})){const file=p.join(dir,entry.name);if(entry.isDirectory())walk(file);else if(entry.isFile()&&entry.name==="client.js"){const source=fs.readFileSync(file,"utf8");const count=source.split(from).length-1;if(count){const backup=file+".before-lan-uuid-fix";if(!fs.existsSync(backup))fs.copyFileSync(file,backup);fs.writeFileSync(file,source.split(from).join(replacement));files++;calls+=count;}}}}walk(root);console.log("局域网 UUID 修复完成:"+files+" 个客户端文件,共 "+calls+" 处");'
正常情况下会显示类似:
局域网 UUID 修复完成:3 个客户端文件,共 4 处
具体数字可能因为 dsh 版本不同而略有差异。
这条命令会:
-
只搜索浏览器端的
client.js文件。 -
找到其中的
crypto.randomUUID()调用。 -
修改前创建
.before-lan-uuid-fix备份。 -
浏览器支持
crypto.randomUUID()时继续使用原生实现。 -
浏览器不支持时,使用
crypto.getRandomValues()生成兼容 UUID。 -
不修改普通服务端 JavaScript 文件。
这条命令也可以重复执行。
如果再次执行时显示:
局域网 UUID 修复完成:0 个客户端文件,共 0 处
说明对应代码已经修复,不是失败。
第三步:启动 dsh 局域网服务
以后每次使用时,执行下面这一整行:
cd ~/dsh && LAN_IP=$(ifconfig | awk '/^[a-z0-9]+:/{i=$1; sub(":","",i)} $1=="inet" && $2 !~ /^(127\.|169\.254\.|198\.18\.)/ && i !~ /^(lo|utun|awdl|llw|bridge)/ {print $2; exit}') && test -n "$LAN_IP" && echo "局域网访问地址:http://$LAN_IP:3080/" && ./node_modules/.bin/dsh web --host 0.0.0.0 --port 3080 --trusted-host "$LAN_IP" --no-open
这条命令会自动:
-
进入
~/dsh。 -
查找 Mac 当前真实的局域网 IP。
-
跳过
127.0.0.1本机地址。 -
跳过
169.254.x.x无效自动分配地址。 -
跳过 Clash 常见的
198.18.x.x虚拟地址。 -
跳过
utun、awdl等虚拟网络接口。 -
把真实局域网 IP 加入
-trusted-host。 -
监听所有网络接口的
3080端口。 -
显示其他设备应该访问的网址。
正常情况下会看到类似:
局域网访问地址:http://192.168.3.61:3080/
dsh web: http://127.0.0.1:3080
192.168.3.61 是示例。请以你的终端实际输出为准。
如果 dsh 自己另外显示:
LAN: http://198.18.0.1:3080
这是因为 Clash、Surge 或其他代理软件创建了虚拟网卡。不要使用 198.18.0.1,应使用命令前面主动输出的真实地址,例如:
http://192.168.3.61:3080/
第四步:在其他设备上访问
确保手机、平板或其他电脑和 Mac 连接到同一个路由器。
在其他设备的浏览器中打开终端显示的地址,例如:
http://192.168.3.61:3080/
不要使用:
http://127.0.0.1:3080/
因为其他设备上的 127.0.0.1 指向的是其他设备自己,不是运行 dsh 的 Mac。
也不要使用:
http://198.18.0.1:3080/
因为它通常是代理软件的虚拟地址。
浏览器显示旧页面或旧错误怎么办
第一次修改完成后,浏览器可能仍然缓存旧版 JavaScript。
可以在网址后添加一个版本参数:
http://192.168.3.61:3080/?v=1
如果以后再次遇到缓存问题,把数字改大:
http://192.168.3.61:3080/?v=2
电脑浏览器可以强制刷新:
macOS:Command + Shift + R
Windows/Linux:Ctrl + Shift + R
手机浏览器可以关闭原标签页,然后重新打开带版本参数的地址。
如何停止 dsh
回到运行 dsh 的终端,按:
Control + C
停止后,局域网内的其他设备将无法继续访问。
更新或重新安装后的处理
以上修改位于:
~/dsh/node_modules/
如果以后执行:
npm install
或者升级、重新安装 dsh,修改可能会被覆盖。
升级后重新执行:
第一步:解除 0.0.0.0 限制
第二步:修复 crypto.randomUUID
第三步:启动局域网服务
平时没有升级时,只需要执行第三步。
日常使用流程总结
首次配置:
执行第一步
执行第二步
执行第三步
以后启动:
只执行第三步
停止服务:
在运行窗口按 Control + C
升级或重新安装后:
重新执行第一步和第二步
然后执行第三步
⚠️ 安全提醒
dsh web 不是普通静态网页,它可能具有读取文件、调用工具和执行本机命令的能力。
请务必遵守:
-
只在可信的家庭或办公局域网中使用。
-
不要在机场、酒店、咖啡店等公共 Wi-Fi 中开放。
-
不要在路由器上把
3080端口映射到公网。 -
不要通过公网 IP、DDNS 或内网穿透直接暴露。
-
局域网里存在不可信设备时不要启动。
-
使用结束后按
Control + C停止服务。
最后回到那个词。**harness 之所以译成「外壳」会丢掉意思,是因为外壳暗示里面还有个不能动的核;而 dsh 连 agent 循环都摊成了配置树里的普通条目。**把模型换成本机的那一刻,你换掉的不是它的一个选项,而是它默认替你做的那个决定。
$ 框架与文档
https://github.com/deepseek-ai/deepseek-harness
方寸智匣下载页(一键写入模型路由与六个本地 MCP):
https://github.com/HackerChi-Hub/localbrain-releases/releases
提示 黑粉科技 · 本地AI / 白嫖指南 / 我做的工具 / 新品速递 所有方案都先在自己的 M5 Pro 上跑通才写 · 视频在 B站同名