Zum Hauptinhalt springen
L

dsh-local-trim

leonlee114514/dsh-local-trim

One-step local model onboarding plus a light harness for DSH. On load it probes loopback and private-network inference endpoints (llama.cpp, Ollama, vLLM, LM Studio), reads each model's context window and output budget from the backend, and registers the provider through settings, so a local model becomes selectable without hand-writing config. It then trims only that provider: masks the tool list through tools.restrict, drops named system-prompt sections and memory contexts, swaps the injected instruction block for a short one while preserving its first 80 and last 96 characters, and truncates tool-schema descriptions without touching structure or required fields. Cloud providers are returned as the same assembly object, untouched. The injected block is re-read from disk on every assembly, so editing the file takes effect on the next request with no reload. The panel in the plugin detail page is a live operations view: it shows whether the takeover is active and by which hook, how many characters the last assembly was trimmed from and to, and which providers are online (probed concurrently, with a real one-shot completion reporting latency and reply text per provider). If a local backend is not running, the panel offers a one-click launch: it probes the port first (never starting a duplicate), then starts the matching server (ollama serve, or a custom command you supply) detached and polls until the endpoint answers, clamping the wait to 3-600 seconds, reaping the child it just spawned if it never answers, and naming the paths it searched when it cannot find the binary. A stop button kills backends this plugin started (SIGTERM, then SIGKILL) and refuses to touch a server somebody else launched. When a model has just been downloaded and no backend exists yet, the panel can also scan the machine for .gguf files with hard caps (300 models, 20000 directory entries, Downloads only one level deep), start llama-server on the chosen one (skipping the .mmproj. vision projection and reusing a server that already answers), register the provider under the model id the backend reports, and run one real completion to prove it works. Before spawning anything it compares the model file size against available memory and refuses a model that cannot fit (or that exceeds 80% of the budget) until you confirm, switching to --no-mmap in that case; the honest limit is that this reads OS-visible RAM only, not GPU-side memory.

Installation

dsh plugin --profile web add github:leonlee114514/dsh-local-trim

README

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

适合改代码调试。

# 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 带三样东西,少一样都可能拿不到回答:

    1. 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 不认。)
    2. 模型上的 reasoningEfforts: { off: null, low: low } —— 上面那个分支的前提 是 model.reasoning === true,而它由这个字段推出来。
    3. 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 都覆盖了:

  1. 工具面:未命中路由时 release(agent) 撤销该 agent 的全部限制(restrict 调用次数: 0)。
  2. 提示词面:trimAssembly 未命中时直接 return assembly —— 同一个对象引用, 不新建、不原地改。
  3. 注入面:接管后的 assembleWithLocalTrim 里 provider 判定不命中就直接返回下游结果。
  4. 唯一的全局副作用是重新定义了 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),点一键配置并试跑, 一条链走完三件事:

  1. 先做资源预检:算的是 显存装不下的那部分(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,不假装通过也不假装危险。
  2. 用这个 gguf 起 llama-server(自动找 ~/.workbuddy/local-models/bin/ 下最新的 build, 参数带 --n-gpu-layers auto --fit on --jinja --cache-type-k/v q8_0);端口上已经有服务 在应答就跳过拉起,不重复起;
  3. 等 GET /v1/models 真应答,以它报的模型 id 为准(不是文件名);
  4. 复用「导入」的注册逻辑写进 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 可省)→ 探测 → 下拉里选一个模型 → 导入并试跑。导入做四件事:

  1. GET /v1/models(不通用时退 /api/tags)拿到模型列表;
  2. 按端口猜后端名,生成 provider id(local-llamacpp-qwen3-4b 这种),也可以自己填;
  3. 写进 llm-pi-ai 的 providers,模型选好后在「模型」选择器里立刻能选到;
  4. 立刻发一条真实请求,把端到端耗时、回复内容、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 的端口去找后端并起起来,然后轮询等它应答:

端口后端怎么起
11434Ollama找到 ollama(.exe) 后 serve,并带上 OLLAMA_HOST=127.0.0.1:11434
8090llama.cpp先找 llama-server,再按 modelRoots() 的顺序捡现成的 gguf,参数自己拼
8000vLLM要 --model(HF 目录或仓库 id),插件不猜 —— 高级配置里填完整命令
1234LM Studiolms server start --port 1234(不需要先指定模型,模型是运行时按请求加载的)

llama.cpp 这一条是 v0.3.7 的重点:模型已经下好了就别让人再下一次 20GB。插件按这个顺序找

  1. 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;
  2. 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;
  3. 拼参数(新版 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,说明结果被某一层重写覆盖了。两步排查:

  1. 看 local-trim.log 里最新的 assemble 接管 行。理想值是 accessor+purge-slot; 出现 already 意味着外层是别的实例留下的包装器(见坑位 6)。
  2. 在配置里临时打开 debugWrap: true,重载后再发一次请求,日志会多出成对的 包装器进入: N 段 / X 字符 与 包装器返回: M 段 / Y 字符。如果「返回」的体积没有变小, 是 trimAssembly 判定这条请求不属于本地 provider(看 routes / baseURL 是否被识别); 如果「进入」的体积本身就很小而请求很大,说明别的层在我们之后又灌了内容进来。

七个踩过的坑

  1. tools.restrict({ allow }) 只认全局工具名。 scoped 注册名(如 dsh-ops 的 ops_glob) 混进 allow 会让整批校验失败,一条限制都装不上。本插件从错误消息里把被拒的名字抠出来 记住,下一轮跳过 —— 比依赖 view() 私有字段跨版本安全。

  2. cordis 的 waterfall next() 不收参数。 下游拿到的是最初那份 args,所以裁剪必须在 await next() 之后对返回值做。

  3. 别和 dsh-purge 的 400ms 心跳抢 slot.rewrite。 它每 400ms 用 bindSlot 覆盖一次, 反向重挂永远输在时序上。正确做法是接管 prompt.assemble 访问器并在 getter 上转发 Symbol.for('dsh-purge.assembleGuard'),让它只刷自己的 slot、不重装访问器覆盖你。

  4. 注入段必须 interpolate: false,并且保留头 80 / 尾 96 字符。 否则 renderPrompt 会用变量改写正文,破坏宿主取样判定,整份原文立刻被重新前置。

  5. llm-pi-ai 改 defaultContextWindow 要重启实例。 HMR 不覆盖 provider 解析, 改了不重启的话 bili / 代理层拿到的还是旧窗口。

  6. 热重载会留下上一实例的包装器,不能看到标记就跳过。 重载后旧实例的 prompt.assemble 包装器还在最外层,但它的 ctx 已失效 —— provider 判断失效 → 它自己 原样放行,而它内层仍会调 dsh-purge 把整份原文灌回去。所以接管时不能用全局布尔标记 判断「已经装过」,要带一个 每次 apply 都不同的实例 token;发现外层是别人的包装器时 叠加一层自己的,而不是放弃。进程重启后旧包装器自然消失,叠加的层数也就回到一。

  7. .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 上各跑一遍, 所以这张表不是声明,是每次提交都在验的东西。

能力WindowsmacOSLinux
用户目录 / 数据目录%USERPROFILE% / %LOCALAPPDATA%~ / ~/Library/Application Support~ / $XDG_DATA_HOME
找 ollama用户目录安装 + Program Files + PATH.app 包内 + Homebrew + PATH/usr/local/bin、/usr/bin、/snap/bin + PATH
找 llama-serverWorkBuddy build + 用户目录 + PATH(补 .exe)Homebrew + /usr/local/bin + PATH同 macOS
找 vLLM / LM StudioLM-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.memsizenvidia-smi / rocm-smi
拉起 / 停止进程支持(spawn + signal 0 探测)支持支持

认自己的模型目录

内建目录覆盖三平台的常规位置(完整清单用面板上的「扫描本机模型」看,接口返回的 dirs 就是实际扫过的路径)。模型不在常规位置时,两条路:

  1. 环境变量(优先级最高,排在最前):

    # bash / zsh
    export DSH_LOCAL_MODELS_DIR=/mnt/weights/gguf
    
    # PowerShell(用户级,永久)
    [Environment]::SetEnvironmentVariable('DSH_LOCAL_MODELS_DIR', 'D:\models', 'User')
    
  2. 配置项 modelDirs(数组,支持 ~),和上面那个环境变量同时存在时都会扫:

    modelDirs:
      - ~/gguf
      - /data/weights
    

认自己的后端二进制

四个后端各有一个覆盖变量,指定后直接排到候选表最前,不再猜:

变量作用
OLLAMA_BINollama 可执行文件的完整路径
LLAMA_SERVER_BINllama-server 的完整路径(自编译 / 非标准位置)
VLLM_BINvllm 的完整路径(conda / venv 里的那个)
LMSTUDIO_BINlms 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-layersauto99
--cache-type-k/vq8_0不下发(老版本类型表窄)
--fit on下发不下发

llamaCppAutoArgs 的返回值里带 modernEngine,面板日志能看出走的是哪一套。

显存探测的三条路

detectGpuInfo() 依次尝试,谁先答上算谁:

  1. NVIDIA — nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits, 三个平台通用(Windows 上它在 system32,PATH 里有)。
  2. AMD — rocm-smi --showmeminfo vram,认 VRAM Total Memory (B): 17163091968 和 VRAM Total Memory (B): 24.0 GiB 两种写法。
  3. Apple Silicon — 没有独立 VRAM,统一内存。取 sysctl -n hw.memsize 的四分之一 作为「GPU 侧可用」的粗估,并标 unified: true(面板上会显示这是统一内存,不是真显存)。
  4. 都没有(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

Ähnliche Plugins