dsh-local-trim
leonlee114514/dsh-local-trim
给小本地模型的一步到位接入 + 轻量 harness。装载时自动探测回环与私有网段上的推理端点(llama.cpp、Ollama、vLLM、LM Studio),从后端读出每个模型的窗口与输出预算,再经 settings 注册 provider —— 不用手写配置就能在模型选择器里选到本地模型。随后只对这一个 provider 裁剪:用 tools.restrict 收缩工具面、按段名丢弃系统提示词段落与记忆 context、把注入的指令块换成一页纸同时保留其首 80 与末 96 字符、截断工具 schema 的 description 而不动结构与 required。云端 provider 原样返回同一个 assembly 对象,不做任何改动。注入正文每次装配都重新读盘,改文件下一次请求就生效,不用重载。插件详情页的面板是一块实时运行视图:显示接管是否生效、走的是哪个钩子,最近一次装配的字符进→出与省下的比例,以及各 provider 的在线灯(多路并发点灯,每个本地 provider 还能发一次真实的最小请求,把延迟与回复原文报出来)。本地后端没起来时,面板能一键拉起:先探测端口(已在应答就不重复启动),再按端口拉起对应后端(ollama serve,或你自己填的启动命令),进程独立于宿主存活,随后轮询到端口应答为止,等待上限夹在 3–600 秒,超时会把刚起的那个进程回收掉不留孤儿,找不到可执行文件时会把搜过的路径写清楚。面板上还有停止按钮,只肯停本插件自己起的后端(先 SIGTERM,1.5 秒不退再 SIGKILL),别人的服务一概不动。模型刚下好、后端还不存在时,面板能扫描本机 .gguf(跳过 .mmproj. 视觉投影、按大小降序,并带 300 个模型 / 20000 个目录项 / Downloads 只扫一层这三道上限),选一个就用它拉起 llama-server(端口上已有服务就复用),按后端自报的模型 id 注册 provider,再发一条真实请求证明能用。真正起之前还会拿模型体积比可用内存:装不下、或超过可用预算 80% 时先不替你起,要你确认,并在这种情况下自动改用 --no-mmap;这条预检只读到操作系统可见的内存,不含 GPU 侧显存,这一点在 README 里写清楚了。
安装
dsh plugin --profile web add github:leonlee114514/dsh-local-trimREADME
dsh-local-trim
给本地小模型一套轻量 harness:让 llama.cpp / Ollama / vLLM / LM Studio 这类本地端点也能在 DeepSeek Harness 里顺畅干活,而云端 provider 一个字节都不动。
它做什么:把工具面、系统提示词、工具 schema 三个面同时收窄,并把注入的整份系统提示词
换成一页纸。本地模型工具调用能力实测未退化(ops_bash / ops_glob / ops_read_files
两步串行都正确)。
这里不给具体的请求体数值。绝对值由你自己的 harness 决定 —— 装了多少插件、注册了多少工具、 系统提示词有哪几段,每个人的基线不同,裁剪空间也就不同。想知道自己省了多少, 用文末「验证裁剪真的生效了」那段,在装与不装之间各抓一次请求来对比。
为什么需要它
DSH 默认面向云端大模型:一个装满插件的 profile 打开就是几十上百项工具,加一整份长篇系统
提示词。本地模型常见窗口是 16K–64K,装得多一点就 CONTEXT_WINDOW_EXCEEDED;就算窗口够,
小模型被上百个工具描述和几万字规则围着,调用正确率也会掉。
具体多大取决于你装了什么 —— 这正是下面要做按 provider 裁剪的原因。
DSH 没有「按模型裁工具」的开关:
dsh-tools的 Config 只有mode/maxParallel;- agent-preset 只能声明插件,不列 ≠ 不注册;
dsh-purge也没有工具过滤字段。
唯一原语是 tools.restrict({ allow }),它把限制追加到调用者所在 scope 的层,只影响该 agent
及其子 agent —— 正好满足「只给本地模型精简」。本插件就是把这个原语和三个提示词层面的
裁剪组合起来。
四层裁剪
| 层 | 做什么 | 手段 |
|---|---|---|
| 1. 工具面 | 只留本地真用得上的工具 | tools.restrict({ allow }),allow = 实际可见名 ∩ 白名单 |
| 2. 提示词面 | 丢掉本地用不到的大段 | system-prompt/assemble 里按段名过滤 sections / contexts |
| 3. 注入面 | 整份系统提示词换成一页纸 | 替换 dsh-purge 注入段正文,保留头 80 / 尾 96 字符 |
| 4. schema 面 | 截断工具描述 | 递归截 description,type / properties / required 一字不动 |
第 3 层有个关键细节:宿主 dsh-agent-loop 在渲染完 prompt 后会检查
globalThis.__dshPurgeHungPrompt() 返回的原文的前 80 字符和后 96 字符是否都还在渲染结果里;
只要缺一个,就把整份原文再前置一遍。所以替换注入时必须原样保留这两段取样,否则省下的
token 会被立刻加回去。
安装
方式一:dsh plugin add(推荐,v0.1.1 起)
包已声明 dsh.bundle,装完自动生效,默认开启自动识别(routes: []):
dsh plugin add github:Leonlee114514/dsh-local-trim
不想要自动识别就改自己 profile 的 cordis.patch.yml,把 local-tool-trim 的 routes 写死:
- id: local-tool-trim
config:
routes: [my-local-provider] # 只裁这一个 provider
方式二:本地源码 + link:
适合改代码调试。
# 1) 进你的 DSH profile 目录
cd ~/.dsh/profiles/desktop # Windows: %USERPROFILE%\.dsh\profiles\desktop
# 2) 作为本地依赖登记(用 link: 指向源码目录,改完即时生效)
# 路径必须是相对 profile 目录的正确层数,写错会得到一个指向空目录的 junction
pnpm add link:../../../path/to/dsh-local-trim
# 3) 在 cordis.patch.yml 末尾 insert 插件(见下方配置)
踩过的坑:手工把插件拷进
node_modules/而不写进package.json,任何一次pnpm install都会把它当无主残留隔离删除(日志在.dsh-market/log.ndjson)。一定要用pnpm add link:...或写进dependencies。
一步到位(v0.2.0 起)
不想手写 llm-pi-ai 的 baseURL / 窗口 / 模型列表:插件启动时会自己探测本地端点,
探到能连上的就把 provider 写进你的 cordis.patch.yml(持久化,重启还在),
新 provider 立刻可选用,不需要重启 DSH。
- insert:
- id: local-tool-trim
name: dsh-local-trim
inject: [ tools, systemPrompt ]
config:
provision: true # 或写成对象,见下
provision 三态:
| 写法 | 行为 |
|---|---|
false | 完全不探测,插件只做裁剪 |
true(或不写) | 按顺序探 8090(llama.cpp) → 11434(Ollama) → 8000(vLLM) → 1234(LM Studio),命中第一个可用的 |
{ endpoint: 'http://host:port/v1' } | 只探这一个地址 |
{ endpoints: [...], provider: 'my-local', overwrite: true } | 自定义列表 / 名字 / 是否覆盖同名配置 |
行为约定:
-
已有可用的本地 provider 就完全不写入 —— 你手写的
local-qwen不会被覆盖。 -
provider id 按端口推断:
8090 → local-llamacpp、11434 → local-ollama、8000 → local-vllm、1234 → local-lmstudio;可用provider:指定。 -
窗口 / 输出长度从后端的
/v1/models的meta.n_ctx、meta.n_predict读; 读不到就退回保守值。 -
自动生成的 profile 带三样东西,少一样都可能拿不到回答:
compat.thinkingFormat: 'chat-template'+compat.chatTemplateKwargs.enable_thinking: false—— 本地 chat template 默认开思考模式,max_tokens会全花在reasoning_content上、content返回空串。但 pi-ai 只在thinkingFormat是chat-template一类的分支里 才把chatTemplateKwargs翻成请求体的chat_template_kwargs;默认的openai分支不翻,写了也发不出去。(qwen分支发的是顶层enable_thinking,llama.cpp 不认。)- 模型上的
reasoningEfforts: { off: null, low: low }—— 上面那个分支的前提 是model.reasoning === true,而它由这个字段推出来。 apiKeyEnv—— 本地端点不校验 key,但 provider 没有 credential 声明时 pi-ai 不带Authorization头,llama.cpp 会静默返回空 completion (不是 401,非常难查)。默认指向DSH_LOCAL_LLM_API_KEY,值为空也行。
不需要关思考就在
provision里写chatTemplateKwargs: null。 -
探测与写入失败都不会影响裁剪,把原因写进日志(默认
~/.dsh/local-llm/local-trim.log,可用DSH_LOCAL_TRIM_LOG改位置)。
插件热重载期间写配置会被 HMR 事务拒绝(
HMR transactions cannot be nested)。 这时插件会记下待办,等下一个真实请求(agent/created)再写 —— 不需要你做什么。 冷启动(装完插件重启 DSH)时第一次就成功。
最小配置
- insert:
- id: local-tool-trim
name: dsh-local-trim
inject: [ tools, systemPrompt ]
config:
enabled: true
routes: [] # 空数组 = 自动识别本地端点
routes 三态:
| 写法 | 含义 |
|---|---|
| 不写 | 早期默认:只认 local-qwen 这一个名字(建议显式写 [] 或写具体名字) |
[] | 自动识别:provider 的 baseURL 指向回环/私有网段,或 provider 名带 local/ollama/vllm/lmstudio 等标记 |
[my-provider, ollama] | 只认写出来的名字(精确匹配,my-provider-turbo 不会命中) |
自动识别的 baseURL 特征:127.0.0.1 / localhost / 0.0.0.0 / [::1] /
host.docker.internal / 10.x / 192.168.x / 172.16–31.x。
四种本地后端的接入
先把模型挂进 DSH 的 llm-pi-ai provider,本插件负责裁剪。四种后端都走 OpenAI 兼容接口,
差别只在 baseURL 和要不要 API key。
llama.cpp(llama-server)
llama-server -m model.gguf --mmproj mmproj.gguf \
--host 127.0.0.1 --port 8090 --alias my-local \
--jinja -c 65536 --n-predict 8192 \
--cache-type-k q8_0 --cache-type-v q8_0
# cordis.patch.yml
- id: llm-pi-ai
config:
providers:
my-local: # provider id 自己定,带 local 标记会被自动识别
displayName: 本地 llama.cpp
apiKeyEnv: DSH_LOCAL_LLM_API_KEY # 见上文第 3 点:缺了会静默返回空串
api: openai-completions
baseURL: http://127.0.0.1:8090/v1
defaultContextWindow: 65536 # 必须和 -c 一致
defaultMaxTokens: 8192 # 必须和 --n-predict 一致
timeoutMs: 1800000
streamIdleTimeoutMs: 600000
compat:
supportsUsageInStreaming: true
maxTokensField: max_tokens
thinkingFormat: chat-template # 见上文第 1 点:不写这一行,下面白写
chatTemplateKwargs:
enable_thinking: false
models:
- id: my-local
reasoningEfforts: { off: null, low: low } # 见上文第 2 点
contextWindow: 65536
maxTokens: 8192
input: [ text ]
reasoningEfforts: false
defaultContextWindow 只在 provider 重新解析时生效 —— 改完必须重启 DSH 实例,
HMR 不覆盖 llm-pi-ai。
Ollama
ollama serve # 默认 127.0.0.1:11434
ollama pull qwen2.5-coder:7b
providers:
ollama:
displayName: Ollama
api: openai-completions
baseURL: http://localhost:11434/v1
apiKeyEnv: OLLAMA_API_KEY # 随便填个值,Ollama 不校验
defaultContextWindow: 32768
defaultMaxTokens: 4096
Ollama 的 num_ctx 由模型 Modelfile 决定,得和这里的 defaultContextWindow 对齐;
ollama show <model> 能看到实际值。provider 名带 ollama 前缀,自动识别直接命中。
vLLM
vllm serve Qwen/Qwen2.5-Coder-7B-Instruct \
--host 0.0.0.0 --port 8000 \
--max-model-len 32768
providers:
vllm:
displayName: vLLM
api: openai-completions
baseURL: http://192.168.1.9:8000/v1 # 局域网机器也自动识别(192.168.x)
apiKeyEnv: VLLM_API_KEY
defaultContextWindow: 32768
defaultMaxTokens: 4096
compat:
maxTokensField: max_tokens
LM Studio
GUI 里打开 Developer → Start Server(默认 127.0.0.1:1234),Context Length 在
模型加载设置里改。
providers:
lmstudio:
displayName: LM Studio
api: openai-completions
baseURL: http://127.0.0.1:1234/v1
apiKeyEnv: LMSTUDIO_API_KEY
defaultContextWindow: 16384
defaultMaxTokens: 4096
完整配置
- insert:
- id: local-tool-trim
name: dsh-local-trim
inject: [ tools, systemPrompt ]
config:
enabled: true
routes: []
promptTrim: true
provision: true # 探测本地端点并自动写 provider;false 关闭
# 本地模型用不到的段。名字取自各插件的 systemPrompt.section({ name })。
# 这份清单对应的是「装了很多插件」的 profile:你装了哪几个插件,就删哪几个插件
# 产出的段,别整套照抄。名字对不上的段会被静默忽略,不会报错。
dropSections:
- tool:mindmap
- free-search:engines
- dsh-purge:anchor
- dshpg-capabilities
- mnemon:routing
- plugin:dsh-imagegen
- ui:deliverable-file-references
- app:web-surface
- tool:goal
- tool:ralph
- tool:workflow
- tool:session-query
- tool:pty
- dsh-excel-chat:interaction
- dsh-ops:repository-tooling:mode-standard
- dsh-ops:repository-tooling:mode-ptc
- dsh-ops:repository-tooling:mode-cordis
- harness:source
dropContexts:
- mnemon:memory-view
- mnemon:runtime-memory
# 白名单同时写规范名和 dsh-ops 的 ops_* 名:不同 profile 装的工具包不一样,
# 有的注册 bash/read/glob/grep,有的注册 ops_bash/ops_read_files/…,
# 两种名字都列上,这份配置就能同时用在两种 profile 上。
keep:
- bash
- pwsh
- ops_bash
- read
- ops_read_files
- ops_inspect_local_file
- write
- edit
- glob
- ops_glob
- grep
- ops_grep
- working_directory
- job_output
- job_list
- job_kill
- todo_write
- present
- read_image
# 描述截断长度。再往下会把 "Omit or leave empty when using" 这类半句留下,
# 反而降低调用正确率 —— 半句话比长话更容易误导模型调用。
# 设 0 表示该面不截断(保留原文)。
toolDescLimit: 140
toolParamDescLimit: 90
换成自己的轻量注入
三层优先级:config.injectText > ~/.dsh/local-llm/light-inject.md > 内置默认。
写个文件就行:
mkdir -p ~/.dsh/local-llm
cat > ~/.dsh/local-llm/light-inject.md <<'EOF'
[SYSTEM]
你是本地运行的编码助手。工具集已裁剪,上下文有限。
- 直接执行,不要反问,缺参数用占位符
- 中文回复,代码原文
EOF
内置默认(DEFAULT_INJECT)是一页纸的版本:身份 + 5 条行为准则 + 工具速查 + 输出格式。
正文多长不影响正确性,只影响省下的量 —— 想更省就写更短,但别把工具用法删到模型认不出。
云端隔离
三重保证,test/autodetect.mjs 和 test/selfcheck.mjs 都覆盖了:
- 工具面:未命中路由时
release(agent)撤销该 agent 的全部限制(restrict 调用次数: 0)。 - 提示词面:
trimAssembly未命中时直接return assembly—— 同一个对象引用, 不新建、不原地改。 - 注入面:接管后的
assembleWithLocalTrim里 provider 判定不命中就直接返回下游结果。 - 唯一的全局副作用是重新定义了
prompt.assemble的访问器,但它的行为对非本地 provider 完全等价于原函数。
实测:同一个会话里交替切云端 provider 和本地 provider,云端返回正常、本地工具调用正常, 两边的工具面互不影响。
控制面板(v0.3.0 起,v0.4.0 改版)
插件在 DSH Web 的插件详情页里挂了一块面板:左侧栏进「插件」,列表里点开
dsh-local-trim,详情页底部就是它。面板直接读写这份 profile 的
cordis.patch.yml 里 local-tool-trim.config 那一节,改完点保存即生效,不用重启。
v0.4.0 起版面按「先看状态,再动手」重排,从上到下五块:
① 状态带 —— 一行徽章,每枚带颜色灯:
| 徽章 | 含义 |
|---|---|
| 接管 prompt.assemble | 生效方式(叠在 dsh-purge 外层 / 叠在既有访问器外层 / 接管原生),失败时直接把原因写在这里 |
| 注入正文 | 一页纸注入有多少字符、来源是 config / light-inject.md / 内置 |
| 一步到位 | 自动注册 provider 的结果 |
| 提示词裁剪 / schema 瘦身 | 两层各自的开关状态 |
| 设置可写 | 宿主有没有把本插件登记成可写设置条目 |
② 裁剪收益条 —— 最近一次装配的进→出字符数、省了百分之几、段数变化、命中的 provider 和时间。走云端 provider 时这里会显示「未裁剪」,那是正常的:云端一个字节都不动。
③ 模型 provider 表 —— 列出 settings 里所有 provider,本地的一个个点灯
(GET /v1/models,15 秒内复用结果,多路并发不串行),显示在线耗时或错误原文;每个本地
provider 后面有 试跑 按钮,发一条「只回一个字:好」的真实请求,回来显示毫秒数与回复内容。
拉起 按钮会在这台机器上找后端并起一个脱离宿主的进程;如果那个端口上确实有本插件
拉起的进程,还会多一个 停止 按钮(SIGTERM → 赖着不走再 SIGKILL)。
别人的 ollama serve 不会被这里点掉 —— 服务端只肯停自己 spawn 过、且登记在内存表里的 pid。
④ 本机模型 · 一键配置(v0.3.9 起,v0.4.0 加资源预检,v0.4.1 改判据)—— 上一块要求你先有端点,可刚下完模型的 时候服务还不存在。这一块把顺序倒过来:扫描本机 → 选一个 gguf → 一键配置并试跑。
点「扫描本机模型」后插件会去这几个地方找 .gguf(每层最多下探两级,跳过
.mmproj. 视觉投影,按文件大小降序,大的排前面):
~/.workbuddy/local-models/files ← WorkBuddy 下载目录,命中率最高
~/AI/models
~/models
~/.lmstudio/models
%LOCALAPPDATA%/LM-Studio/models
~/Downloads
选中一个模型后填端口(默认 8090)和上下文(默认 65536),点一键配置并试跑, 一条链走完三件事:
- 先做资源预检:算的是 显存装不下的那部分(
sizeBytes − gpuBytes)比内存预算 (max(totalmem − 2GB, freemem))。为什么不拿 gguf 全长去比内存 —— gguf 走 mmap 读, 落在可回收的 file-backed page cache 里,不是常驻匿名内存。这台机器实测就说明了这点: 16.9GB 的 gguf 跑着,进程 WorkingSet 才 285–691MB,显存 14289/14848MiB 打满, 系统还剩 2.2GB 可用,任务管理器不爆、不换页。- 显存 + 内存都装不下 →
risky,唯一的硬拦。那不是慢,是必然加载失败; - 显存外的部分超过内存预算但走 mmap →
warn,照起。只是开头一段明显慢 (专家按需从盘里换进来); - 同样装不下但用了
--load-mode none/mlock(不走 mmap,要真驻留 RAM)→risky, 这时候才可能换页卡死; - 拿不到体积 →
unknown,不假装通过也不假装危险。
- 显存 + 内存都装不下 →
- 用这个 gguf 起
llama-server(自动找~/.workbuddy/local-models/bin/下最新的 build, 参数带--n-gpu-layers auto --fit on --jinja --cache-type-k/v q8_0);端口上已经有服务 在应答就跳过拉起,不重复起; - 等
GET /v1/models真应答,以它报的模型 id 为准(不是文件名); - 复用「导入」的注册逻辑写进
llm-pi-ai,然后发一条真实请求试跑。
选完模型后「保留思考模式」旁边还有一个 加载方式 下拉(--load-mode),默认留空:
| 值 | 含义 | 什么时候选 |
|---|---|---|
| 留空 | 交给 llama.cpp 自己决定(auto) | 默认。它会挑最合适的方式 |
mmap | 分页读盘,权重落在可回收的 page cache | 内存不宽裕时最稳 |
none | 全量读进匿名内存 | 内存富余、不想让盘参与 |
mlock | 锁页,不许换出 | 要求时序稳定、且内存确定够 |
mmap+mlock | 分页读 + 锁页 | 少数场景,一般不用 |
注意 --no-mmap 这个参数 b11012 不认(error: invalid argument),所以插件从不拼它。
失败时面板会说卡在哪一步:precheck(资源预检拦住)/ launch(起不来,带查过的路径)/
probe(起来了但没报模型)/ register(写 settings 被拒)。拉起后端口超时不应答的话,
插件会把刚起的进程回收掉,不留孤儿。别自己猜。
别把 MoE 的冷启动当成卡死。 27.2B MoE(8/192 专家激活)在这台机器上实测: 前 1000–2000 token 是预热期(20 → 28 → 36 tok/s),之后恒定 76 tok/s (prose 75.5 / 计数 76.2 / 代码 71.9),prefill 3215 token 跑到 674 tok/s, 预热完 IO Read 归零。所以「17GB 就该卡」是错的判断 —— 判断依据是显存装不装得下, 不是文件大小。
v0.4.0 曾拿 gguf 全长比内存,把这台机器判成
risky并自动加--no-mmap; 而 b11012 根本不认这个参数(error: invalid argument: --no-mmap), 等于一点「一键配置」必然起不来。v0.4.1 全部改成--load-mode auto|none|mmap|mlock|mmap+mlock。
⑤ 导入本地模型 —— 填端点(例如 http://127.0.0.1:8090,/v1 可省)→ 探测 →
下拉里选一个模型 → 导入并试跑。导入做四件事:
GET /v1/models(不通用时退/api/tags)拿到模型列表;- 按端口猜后端名,生成 provider id(
local-llamacpp-qwen3-4b这种),也可以自己填; - 写进
llm-pi-ai的providers,模型选好后在「模型」选择器里立刻能选到; - 立刻发一条真实请求,把端到端耗时、回复内容、token 数(或后端原文错误)贴在面板上。
试跑失败不会回收配置 —— 配置已经落地,把后端拉起或改好端点后点「试跑」即可,不用重新导入。
默认会关掉 chat template 的思考模式(enable_thinking: false):本地小模型开了思考会把
max_tokens 全花在 reasoning 上、content 直接空字符串。需要思考的模型勾上「保留思考模式」。
原来那些字段(开关、阈值、三个名单、注入正文、一步到位)都在最下面折叠的 高级配置 里,位置变了,能力没变。
⑥ 一键拉起(v0.3.4 起,v0.3.7 会自己捡模型,v0.4.0 能停,v0.4.1 认 --load-mode) —— provider 表里每个本地 provider 后面都多一个 拉起 按钮。点它就按这个 provider 的端口去找后端并起起来,然后轮询等它应答:
| 端口 | 后端 | 怎么起 |
|---|---|---|
| 11434 | Ollama | 找到 ollama(.exe) 后 serve,并带上 OLLAMA_HOST=127.0.0.1:11434 |
| 8090 | llama.cpp | 先找 llama-server,再按 modelRoots() 的顺序捡现成的 gguf,参数自己拼 |
| 8000 | vLLM | 要 --model(HF 目录或仓库 id),插件不猜 —— 高级配置里填完整命令 |
| 1234 | LM Studio | lms server start --port 1234(不需要先指定模型,模型是运行时按请求加载的) |
llama.cpp 这一条是 v0.3.7 的重点:模型已经下好了就别让人再下一次 20GB。插件按这个顺序找
llama-server(Windows 补.exe):LLAMA_SERVER_BIN→~/.workbuddy/local-models/bin/<build>/(WorkBuddy 的布局,取最新 build)→~/AI/llama.cpp/(Windows)/ Homebrew 与/usr/local/bin(macOS、Linux) → PATH;- gguf:按
modelRoots()的顺序找(DSH_LOCAL_MODELS_DIR→~/models→~/llama.cpp/models→~/.cache/llama.cpp→~/.lmstudio/models→<dataDir>/LM-Studio/models→~/.workbuddy/local-models/files→~/Downloads),第一个含.gguf的目录胜出; 同一目录里优先local_model.gguf,跳过.mmproj.(那是视觉投影); 要指定自己的目录用DSH_LOCAL_MODELS_DIR或配置项modelDirs; - 拼参数(新版 llama.cpp):
-m <gguf> --host 127.0.0.1 --port 8090 --alias <provider 的模型 id> --n-gpu-layers auto --fit on --parallel 1 --jinja --cache-type-k q8_0 --cache-type-v q8_0 -c 65536 --n-predict 8192。老版本(没有--fit)会自动换一套:--n-gpu-layers 99, 不下发 KV 量化,见「参数能力靠--help探测」一节。
这套参数是按 32GB 统一内存实测出来的:权重 17GB 上卡,q8_0 KV 单 slot 才压得住 64K 上下文。 两个组件都找到才自动拼;缺模型文件才回「需要模型文件才能起」,缺可执行文件才回「本机没找到」, 错误里带着查过的路径。
顺序是:先探测,端口本来就在应答就直接告诉你「没有重复拉起」;没应答才找可执行文件
(OLLAMA_BIN 环境变量 → 平台默认安装位置:Windows ~/AI/ollama/ollama.exe、
%LOCALAPPDATA%\Programs\Ollama\ollama.exe、C:\Program Files\Ollama\ollama.exe;
macOS /Applications/Ollama.app/...、Homebrew /opt/homebrew/bin;Linux /usr/local/bin、
/usr/bin、/snap/bin → PATH),拉起后按 拉起后等端口(秒) 轮询
/v1/models,成功后顺手把点灯缓存清掉。
高级配置里的 一键拉起命令 留空就走上面的自动逻辑;填了(例如
C:\Users\you\AI\ollama\ollama.exe serve --verbose)就原样执行,优先于自动探测。
起进程用 detached + stdio:ignore,不挂在宿主进程的管道上,所以关掉 DSH 不会把它带走,
要开机自启得自己挂(Windows 计划任务或启动项)。
停止(v0.4.0 起):进程起来了就得能关。provider 那一行如果在「本插件起过的端口」表里,
就会多一个红色 停止 按钮,点它走 POST /api/dsh-local-trim/stop → SIGTERM,
1.5 秒内不退再补 SIGKILL,还活着就如实报失败(不谎报成功)。
这张表只存在宿主内存里:重启 DSH 之后表就空了,此时那些进程要自己关
(taskkill /PID <pid> /F,pid 在面板「已拉起」提示和启动日志里都有)。
拉起后等端口的上限是 拉起后等端口(秒),默认 20 秒,可设范围 3–600 秒 —— 超出会被夹回去。
17GB 权重冷加载实测 11.6 秒,接近默认上限;更慢的机器记得调大。
超时不会留孤儿:插件会把刚 spawn 的那个 pid 回收掉再报错。
其他按钮:探测本地端点(依次打 8090 / 11434 / 8000 / 1234)、看日志
(读 ~/.dsh/local-llm/local-trim.log 尾部)、重新检测(强制刷新点灯结果)。
右上角显示当前 revision;这份配置在别处被改过时,保存会提示冲突并自动重新读取。
宿主里跑的还是旧代码时(只有 describe / save / probe / log 四条路由,没有 status),
面板不会整块报错:它自动退回 describe 视图,并在顶部提示「重启一次 DSH 就能看到点灯、
收益条和模型导入」。插件是 JS 模块,装在宿主进程里,改完源码要让宿主重新 apply 一次。
面板走的是插件自建的 HTTP bridge,前缀 /api/dsh-local-trim,十二个端点
describe / save / probe / log / status / check / import / test / launch /
stop / models / adopt,全部只接受 POST,且只放行本机来源
(sec-fetch-site: cross-site 直接 403,Origin 必须与 Host 同源)。
重活路由(除 describe / save / log 之外)带两层节流:同一个路由上一次还没跑完时
直接回「等它结束再来一次」,跑完之后 800ms 内再来也被挡(bridgeThrottleMs 可调,设 0 关掉
间隔限制、只留并发去重)。前端连点不会在宿主机上堆叠起一堆 llama-server。
保存报「被 home patch 覆盖」怎么办
如果这个插件的配置整段写在 profile 的 cordis.patch.yml 里(很多人是手写进去的),
settings.update 会要求「合成后的 config 与要写的完全一致」,而那段 patch 里还有
routes / dropSections 这些面板不负责的键 —— 于是整段写入被拒,面板会提示
这份配置整段来自 profile 的 cordis.patch.yml(home patch 层),面板不能整段覆盖。
两条路:① 直接改那个文件里 local-tool-trim.config 的对应键;② 把 patch 里这个插件的
config: 段删掉(只留 id / name / inject),重启后配置归 settings 管,面板就能改。
面板的只读视图(状态带、收益条、点灯、试跑、拉起)在两种情况下都能用。
面板不出现怎么办
先看日志末行是「控制面板 UI 已进入 client 条目表」还是「需要完整重启一次 DSH」。
宿主的 dsh-client-modules 会把每个包第一次扫描的结果缓存起来(pkgMeta),
插件热重载不会清这个缓存 —— 所以如果先装插件、后补 dsh.client 声明,只重载插件
不够,必须完整重启一次 DSH。插件自己会在装载时清掉本包那一项并触发重扫,正常情况下
一次重载就够;日志里说还需要重启时,重启即可。
启动自检
插件装载时往 ~/.dsh/local-llm/local-trim.log 写 10 项检查。下面是形态示例,数字取自
某一份具体配置(你的会不一样,<…> 处随配置变化):
✓ tools 服务 可 restrict
✓ systemPrompt 服务 可用
✓ dsh-purge 已装(本插件在其外层收尾,无需改它源码)
✓ assemble 接管 accessor+purge-slot
✓ 轻量注入正文 <字符数>,来源 ~/.dsh/local-llm/light-inject.md
✓ 工具白名单 <keep 项数> 项
✓ 裁剪段落 <dropSections 项数> 段 + <dropContexts 项数> 个 context
✓ 生效路由 自动识别(baseURL 指向回环/私有网段,或 provider 名带 local/ollama 等标记)
✓ schema 瘦身 description 140 / 参数 90
✓ 一步到位 <探测结果,如「已有配置好的 local-llamacpp」>
控制面板 bridge 已挂载:/api/dsh-local-trim/{describe,save,probe,log}
控制面板 UI 已进入 client 条目表(刷新页面即可在插件详情页看到)
就绪:仅对 本地端点(自动识别) 生效,其它 provider 原样放行
想关掉:config.selfCheck: false。
验证裁剪真的生效了
llama.cpp 加 --log-prompts-dir 会把每次请求落盘:
llama-server ... --log-prompts-dir ~/.dsh/local-llm/prompts
cd ~/.dsh/local-llm/prompts
f=$(ls -t *.txt | head -1)
echo "SIZE=$(stat -c%s $f)"
for k in "LOCAL LIGHT" "PRIME DIRECTIVE" "Mindmap editing"; do
printf "%-20s %s\n" "$k:" "$(grep -c "$k" $f)"
done
期望:LOCAL LIGHT: 1、PRIME DIRECTIVE: 0、Mindmap editing: 0。SIZE 跟你自己的
配置有关,不看绝对值,看的是「注入换成了轻量版、那些大段不再出现」。
如果 PRIME DIRECTIVE: 1,说明结果被某一层重写覆盖了。两步排查:
- 看
local-trim.log里最新的assemble 接管行。理想值是accessor+purge-slot; 出现already意味着外层是别的实例留下的包装器(见坑位 6)。 - 在配置里临时打开
debugWrap: true,重载后再发一次请求,日志会多出成对的包装器进入: N 段 / X 字符与包装器返回: M 段 / Y 字符。如果「返回」的体积没有变小, 是trimAssembly判定这条请求不属于本地 provider(看routes/ baseURL 是否被识别); 如果「进入」的体积本身就很小而请求很大,说明别的层在我们之后又灌了内容进来。
七个踩过的坑
-
tools.restrict({ allow })只认全局工具名。 scoped 注册名(如dsh-ops的ops_glob) 混进 allow 会让整批校验失败,一条限制都装不上。本插件从错误消息里把被拒的名字抠出来 记住,下一轮跳过 —— 比依赖view()私有字段跨版本安全。 -
cordis 的 waterfall
next()不收参数。 下游拿到的是最初那份args,所以裁剪必须在await next()之后对返回值做。 -
别和 dsh-purge 的 400ms 心跳抢
slot.rewrite。 它每 400ms 用bindSlot覆盖一次, 反向重挂永远输在时序上。正确做法是接管prompt.assemble访问器并在 getter 上转发Symbol.for('dsh-purge.assembleGuard'),让它只刷自己的 slot、不重装访问器覆盖你。 -
注入段必须
interpolate: false,并且保留头 80 / 尾 96 字符。 否则renderPrompt会用变量改写正文,破坏宿主取样判定,整份原文立刻被重新前置。 -
llm-pi-ai改defaultContextWindow要重启实例。 HMR 不覆盖 provider 解析, 改了不重启的话 bili / 代理层拿到的还是旧窗口。 -
热重载会留下上一实例的包装器,不能看到标记就跳过。 重载后旧实例的
prompt.assemble包装器还在最外层,但它的ctx已失效 —— provider 判断失效 → 它自己 原样放行,而它内层仍会调dsh-purge把整份原文灌回去。所以接管时不能用全局布尔标记 判断「已经装过」,要带一个 每次apply都不同的实例 token;发现外层是别人的包装器时 叠加一层自己的,而不是放弃。进程重启后旧包装器自然消失,叠加的层数也就回到一。 -
.volatile()的配置字段不是值,是活引用。 加上volatile()之后宿主传进apply(ctx, config)的每个字段都是{ get(): T },config.enabled !== false会永远为真 (对象 !== false),裁剪开关直接失效。必须统一走一个resolveConfig(config)逐键.get(), 而且不能一次性解构成常量 —— 面板改了要能立刻生效,每次用时现读。
跨平台(v0.4.2 起)
v0.4.1 及以前这个插件只在本机(Windows ARM64 + 一张 N 卡 + WorkBuddy 的 llama.cpp 布局)
上验证过,代码里到处是 C:/Users/<USER>/... 这种硬编码。换台机器大概率表现为
「扫不到模型 / 找不到二进制 / 一拉起就报参数错」。v0.4.2 把这些都换成了可推导的规则。
支持矩阵
CI 在 ubuntu-latest(Node 20/22/24)、windows-latest、macos-latest 上各跑一遍,
所以这张表不是声明,是每次提交都在验的东西。
| 能力 | Windows | macOS | Linux |
|---|---|---|---|
| 用户目录 / 数据目录 | %USERPROFILE% / %LOCALAPPDATA% | ~ / ~/Library/Application Support | ~ / $XDG_DATA_HOME |
| 找 ollama | 用户目录安装 + Program Files + PATH | .app 包内 + Homebrew + PATH | /usr/local/bin、/usr/bin、/snap/bin + PATH |
| 找 llama-server | WorkBuddy build + 用户目录 + PATH(补 .exe) | Homebrew + /usr/local/bin + PATH | 同 macOS |
| 找 vLLM / LM Studio | LM-Studio/lms.exe、Program Files + PATH | .app 包内 + PATH | /usr/local/bin + PATH |
| 扫 gguf | ~/models、~/AI/models、~/Downloads… | ~/models、~/llama.cpp/models、~/.cache/llama.cpp… | 同 macOS,另加 LM Studio 数据目录 |
| 显存探测 | nvidia-smi(在 system32,靠 PATH) | nvidia-smi / rocm-smi / sysctl hw.memsize | nvidia-smi / rocm-smi |
| 拉起 / 停止进程 | 支持(spawn + signal 0 探测) | 支持 | 支持 |
认自己的模型目录
内建目录覆盖三平台的常规位置(完整清单用面板上的「扫描本机模型」看,接口返回的 dirs
就是实际扫过的路径)。模型不在常规位置时,两条路:
-
环境变量(优先级最高,排在最前):
# bash / zsh export DSH_LOCAL_MODELS_DIR=/mnt/weights/gguf# PowerShell(用户级,永久) [Environment]::SetEnvironmentVariable('DSH_LOCAL_MODELS_DIR', 'D:\models', 'User') -
配置项
modelDirs(数组,支持~),和上面那个环境变量同时存在时都会扫:modelDirs: - ~/gguf - /data/weights
认自己的后端二进制
四个后端各有一个覆盖变量,指定后直接排到候选表最前,不再猜:
| 变量 | 作用 |
|---|---|
OLLAMA_BIN | ollama 可执行文件的完整路径 |
LLAMA_SERVER_BIN | llama-server 的完整路径(自编译 / 非标准位置) |
VLLM_BIN | vllm 的完整路径(conda / venv 里的那个) |
LMSTUDIO_BIN | lms CLI 的完整路径 |
参数能力靠 --help 探测,不靠版本号猜
llama.cpp 的参数是逐版演进的:--load-mode 和 --fit 是近几版才有的,老版本没有;
反过来 --no-mmap 在新版本里已经被 --load-mode 取代(传进去直接
error: invalid argument)。所以插件在拼命令行之前会先跑一次
llama-server --help,把输出里出现的 --flag 全抠出来,只拼它真认的那几个:
- 探到了 → 只留白名单里的;被裁掉的参数名会出现在
planLaunch返回的dropped数组里, 面板会告诉你「你的 llama.cpp 不认--fit,已跳过」。 短参数(-c/-m/-t)也一并收 ——-c是--ctx-size的短写,只认长参数的话-c 65536会被判成不支持而整条裁掉,上下文悄悄退回引擎默认值(本机就这么丢过一次)。 - 探不出来(命令不存在、超时、输出为空)→ 不裁剪,按旧行为全给。宁可让引擎自己报错, 也不要因为探测失败把好参数丢掉。
- 结果按二进制路径缓存,同一个
llama-server只跑一次--help。
flag 在、取值不一定在。 --n-gpu-layers auto 的 auto 是和 --fit 同一代加的;
老引擎只认数字。所以拼参数时按 --fit 有无分两套:
| 能力 | 有 --fit(新引擎) | 无 --fit(老引擎) |
|---|---|---|
--n-gpu-layers | auto | 99 |
--cache-type-k/v | q8_0 | 不下发(老版本类型表窄) |
--fit on | 下发 | 不下发 |
llamaCppAutoArgs 的返回值里带 modernEngine,面板日志能看出走的是哪一套。
显存探测的三条路
detectGpuInfo() 依次尝试,谁先答上算谁:
- NVIDIA —
nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits, 三个平台通用(Windows 上它在system32,PATH 里有)。 - AMD —
rocm-smi --showmeminfo vram,认VRAM Total Memory (B): 17163091968和VRAM Total Memory (B): 24.0 GiB两种写法。 - Apple Silicon — 没有独立 VRAM,统一内存。取
sysctl -n hw.memsize的四分之一 作为「GPU 侧可用」的粗估,并标unified: true(面板上会显示这是统一内存,不是真显存)。 - 都没有(Intel 核显 / 无独显 / 容器里)→ 返回 0,预检退化成「不算显存」,不报错。
统一内存要特殊处理。 Apple Silicon 的「显存」和内存是同一个池子,
hostBytes = 模型 − 显存 会凭空多出余量(24GB 的 Mac 装 16GB 模型会被算成只占 10GB)。
所以 detectGpuInfo() 对 Apple Silicon 返回 unified: true,预检见到它就按
sizeBytes 全额比 totalMemBytes,上限也只算一次。AMD 核显走的是独立 VRAM 路径,
不受这条影响。
预留内存可配
预检给系统留多少内存是 reserveMb(默认 2048 MiB)。桌面 Windows + 宿主 + 浏览器差不多
要这么多;Linux 服务器可以调到 1024,内存紧张的机器调大,0 表示不预留(只跟 free 比)。
reserveMb: 1024
已知边界
- 只做本进程内的裁剪,不碰模型权重,不改任何第三方插件源码。
dsh-purge未安装时也能工作(接管原生prompt.assemble),只是没有头 80 / 尾 96 的 保留逻辑(没有那层兜底就不需要)。- 段名来自各插件的
systemPrompt.section({ name }),插件升级改段名后要在dropSections里同步。 - llama.cpp 侧的调参只到「一键拉起时给一组能跑的默认值」(
--n-gpu-layers auto --fit on --cache-type-k/v q8_0);要换量化、换 KV cache 精度、限并发,改高级配置里的启动命令,插件不替你优化推理性能。 - 资源预检是估算,不是保证。
checkModelFit用os.freemem()/os.totalmem()、 模型文件体积、以及探测到的显存总量做减法:hostBytes = sizeBytes − gpuBytes。 显存探测按厂商分三条路(见「跨平台」一节)。这几个数都不精确(本机实测:32GB 机器 Windows 只报 16.37GB 可见,其中 GPU 拿走 14.5GB;freemem还把 page cache 算成已占用)。 所以它只会拦住明显起不来的,不会替你算出真实占用。拿不到显存时退化成「不算显存」, 不报错。判ok也不代表不会慢。 - 面板只能停自己起的进程。
stop路由走一张内存表(launched),表里只有本插件spawn过的 pid。宿重重启后这张表就空了,此时手动起的后端要自己关。
开发
npm run verify # = check + test,8 个文件语法 + 7 套测试
npm run check # 只跑 node --check(lib/*.js + client.js + test/platform-sim.mjs)
npm test # 只跑测试(unit + portable + host)
npm run test:portable # 只跑跨平台候选表推演(linux / darwin / win32 各一遍)
npm run test:sim # 假装自己是 linux / darwin,把整套测试再跑一遍
node test/selfcheck.mjs # 两个场景:装了 / 没装 dsh-purge
node test/autodetect.mjs # 四个本地后端 + 三个云端 provider 的路由判定
node test/provision.mjs # 探测 → 写 settings 的一步到位
node test/panel.mjs # 面板视图模型 + 导入流程(离线,不起浏览器)
node test/panel-host.mjs # 真跑 apply(),假 settings / 假后端把 12 条 bridge 路由全打一遍
node test/launcher.mjs # 一键拉起 + 扫本机 gguf + 资源预检 + 停进程(不真起进程)
node test/portable.mjs # 路径推导 / 显存探测 / --help 能力探测(假 platform + 假 exec)
node test/platform-sim.mjs linux # 假装自己是 Linux,验候选表不会吐 Windows 路径
node test/platform-sim.mjs darwin # 同上,macOS
node test/platform-sim.mjs win32 # 同上,Windows(本机就是,但断言的是「全 .exe」)
node test/run-sim.mjs # 上面那三套 + launcher / panel-host,按 linux + darwin 各一遍
node test/stranger.mjs # 抹掉本机所有有利条件(无 GPU / 无模型 / 空 PATH)
test/stranger.mjs 是「别人第一次装」的等价物:清空 PATH、抹掉显卡、家目录里不放模型,
然后要求每一步都优雅退化 —— 显存读不到就是 0(不是 NaN),--help 探不到就不裁剪(不是全丢),
没有 gguf 就回 no-model 并列目录。本机什么都有,这些分支永远走不到。
test/run-sim.mjs 把同一招用到整套测试上:它用 child_process 起子进程并显式传
DSH_SIM_PLATFORM,而不是写 DSH_SIM_PLATFORM=linux node … —— 那种前缀语法只在
POSIX shell 成立,Windows 的 npm script 会直接报「不是内部或外部命令」。这一步存在的理由
很具体:test/portable.mjs 里曾断言 endsWith("lms.exe"),macOS runner 上
exeName() 不加后缀就红,而本机是 Windows,永远踩不到。
test/platform-sim.mjs 靠 Object.defineProperty(process, "platform", …) 在 import
之前改平台 —— lib/launcher.js 顶部的 const WIN = process.platform === 'win32' 是
import 时求值的,改掉之后整个模块都会按目标平台展开。
os.homedir() 也能骗,但有坑:真实 Windows 上它只读 USERPROFILE(删掉反而退回
真实家目录,HOME 根本不看),Linux/macOS 才读 HOME。一开始只改 HOME,模拟 darwin
时家目录仍是 C:/Users/<你>,候选表里混进一堆 C:/... —— 而那正是要抓的东西,等于
这套测试白跑。现在 test/fake-platform.mjs 把两个变量都指到 /sim/nobody,并且有三条
用例专门断言「家目录里没有本机真实路径」。剩下骗不到的只有 process.env.PATH、真实文件
系统和 nvidia-smi;把这三样也删掉的是 test/stranger.mjs。
test/panel.mjs 只测纯逻辑:lib/status.js(接管文案、收益计算、provider 判定与点灯计数)
和 lib/import-model.js(入参校验、provider id 生成、profile 拼装、试跑请求与四类失败分支,
全部用假 fetch)。
test/panel-host.mjs 是宿主侧集成:用一份内存 settings、一个假 webServer、一个假
llama.cpp(/v1/models + /v1/chat/completions)跑真的 apply(ctx),让插件自己注册路由,
然后逐条断言 status / check / import / test / launch / stop / models / adopt 的返回 ——
包括同源守卫 403、同名不覆盖、端点连不上、模型不在列表里、provider 不存在、
资源预检拦截、以及「只肯停自己起的进程」这几种路径。它不需要真起 DSH。
这份用例显式传
bridgeThrottleMs: 0:它是在几毫秒内连着打同一批重活路由的, 默认 800ms 间隔会把它们全挡掉。节流本身由最后几组用例单独验。
test/launcher.mjs 覆盖一键拉起的纯逻辑:端口解析、命令行切分(认双引号)、
whichOnPath 补扩展名、四种后端的计划生成、找不到可执行文件与「需要模型参数」两条错误路径、
自定义命令优先、runDetached 的 detached/stdio/env 传参,以及 waitForEndpoint 的
命中与超时。spawnImpl 与 probe 全部注入,CI 上不会留下后台进程。
v0.3.9 起还覆盖 scanLocalModels(跳过 mmproj、按大小降序、目录下探)与「自选端口 +
指定 gguf」的拼装(端口和 alias 都得按用户填的走)。v0.4.0 补上了
mergeEnv(Windows 上 PATH / Path 大小写去重)、envPathValue、checkModelFit
(五档结论 + 显存外的 hostBytes + 拿不到体积时的 unknown)、--load-mode 白名单(normalizeLoadMode / LOAD_MODES)、detectGpuBytes、stopProcess
(SIGTERM → SIGKILL → 报失败,幂等)、isProcessAlive(EPERM 算活着),
以及扫描的三道上限(maxModels / maxEntries / Downloads 只扫一层)与 truncated 标记。
adopt 的 sizeBytes / gpuBytes / totalMemBytes 可以从入参覆盖(非正数或省略就回落真实探测)。
CI 的 runner 上没有 nvidia-smi、内存也只有 15.6GB,不覆盖的话「能跑的机器不该被判 risky」
这条断言在 CI 上永远测不到 —— 它会一路走 risky 分支。生产路径不吃这三个参数。
本地不要为了验证去真起 17GB 的模型 —— planLaunch 已经把命令和参数摆出来了,看一眼就够;
真要端到端跑,挑个空闲时段。别把冷启动当成卡死:27.2B MoE 在这里实测预热
1000–2000 token 后稳定 76 tok/s(见上文「一键配置」下的说明)。
lib/config.js 顶层 import z from '@deepseek-ai/schemastery' —— 运行时由 DSH 宿主提供
(peerDependencies,optional,不会再给装插件的人拉第二份副本),跑测试前需要
npm ci(它同时也写在 devDependencies 里)。CI 就是这么做的。
scripts/restore-bash.ps1 修 pnpm 把 @dsh-ops/bash-win32-x64 当不匹配平台剪掉的问题
(arm64 Windows 上会遇到):重新 pack 该可选依赖并放回 node_modules/@dsh-ops/。
License
MIT