跳转至内容
  • 版块
  • 最新
  • 标签
  • 热门
  • 用户
  • 群组
皮肤
  • 浅色
  • Brite
  • Cerulean
  • Cosmo
  • Flatly
  • Journal
  • Litera
  • Lumen
  • Lux
  • Materia
  • Minty
  • Morph
  • Pulse
  • Sandstone
  • Simplex
  • Sketchy
  • Spacelab
  • United
  • Yeti
  • Zephyr
  • 深色
  • Cyborg
  • Darkly
  • Quartz
  • Slate
  • Solar
  • Superhero
  • Vapor

  • 默认(LCZ-Blue)
  • 不使用皮肤
  • LCZ-Green
  • LCZ-Blue
  • LCZ-Black
折叠
品牌标识

抡锤者

首页 版块 标签 硬件 AI 广场
lybhb8L

lybhb8

@lybhb8
取消关注 关注
关于
帖子
3
主题
3
分享
0
群组
0
粉丝
0
关注
0

帖子

最新 最佳 有争议的

  • Deepseek-Harness 单卡(7900XTX)运行Qwen3.8-27B 完整文档-3
    lybhb8L lybhb8
    AI硬件 7900xtx dsharness qwen-27b

    6. 性能测试

    6.1 测试脚本

    存放到 ~/llama-bin/bench.py:

    #!/usr/bin/env python3
    import time, json, urllib.request, concurrent.futures, statistics
    
    API = "http://localhost:8090/v1/chat/completions"
    MODEL = "qwen3.8-27b"
    
    def chat(prompt, max_tokens, temp=0):
        payload = {"model": MODEL, "messages": [{"role": "user", "content": prompt}],
                   "max_tokens": max_tokens, "temperature": temp, "stream": True}
        req = urllib.request.Request(API, data=json.dumps(payload).encode(),
                                      headers={"Content-Type": "application/json"})
        start = time.time(); first = None; tok = 0
        try:
            with urllib.request.urlopen(req) as r:
                for line in r:
                    line = line.decode().strip()
                    if line.startswith("data: "):
                        d = line[6:]
                        if d == "[DONE]": break
                        try:
                            o = json.loads(d)
                            c = o.get("choices", [{}])[0].get("delta", {}).get("content")
                            if c:
                                if first is None: first = time.time()
                                tok += 1
                        except: pass
        except: pass
        total = time.time() - start
        ttft = first - start if first else 0
        return ttft, total, tok
    
    def sec(t):
        print(f"\n{chr(61)*64}\n  {t}\n{chr(61)*64}")
    
    # ① 吞吐量
    sec("① 吞吐量测试")
    tr = []
    for label, prompt, mx in [
        ("短输出 100", "用一句话介绍人工智能。", 100),
        ("中输出 500", "请写一段关于春天的短文,约200字。", 500),
        ("长输出 1000", "请写一篇关于AI发展历程的文章,尽量详细。", 1000),
        ("超长 2000", "请详细解释量子计算原理,写3000字文章。", 2000),
    ]:
        ttft, total, tok = chat(prompt, mx)
        gt = total - ttft
        tps = tok / gt if gt > 0 else 0
        tr.append({"l": label, "ttft": ttft, "tps": tps})
        print(f"  {label:<12} TTFT:{ttft*1000:>6.0f}ms {gt:>6.2f}s {tok:>5}tok {tps:>6.1f}tok/s")
    print(f"  平均: {statistics.mean(r['tps'] for r in tr):.1f} tok/s")
    
    # ② 并发
    sec("② 并发测试")
    for n in [2, 4, 8]:
        def w(i): return chat(f"{i}+{i}等于多少?", 50)
        s = time.time()
        with concurrent.futures.ThreadPoolExecutor(max_workers=n) as p:
            results = [f.result() for f in [p.submit(w, i) for i in range(n)]]
        wall = time.time() - s
        total_tok = sum(r[2] for r in results)
        print(f"  并发={n} 耗时:{wall:.2f}s tokens:{total_tok} 吞吐:{total_tok/wall:.1f}tok/s")
    
    # ③ 稳定性
    sec("⑥ 稳定性测试 (5次)")
    st = []
    for i in range(5):
        ttft, total, tok = chat("用一句话介绍机器学习。", 100)
        gt = total - ttft
        tps = tok / gt if gt > 0 else 0
        st.append({"ttft": ttft, "tps": tps})
        print(f"  第{i+1}次: TTFT:{ttft*1000:.0f}ms {tok}tok {tps:.1f}tok/s")
    print(f"  TTFT: min={min(r['ttft'] for r in st)*1000:.0f} max={max(r['ttft'] for r in st)*1000:.0f}")
    print(f"  速度: min={min(r['tps'] for r in st):.1f} max={max(r['tps'] for r in st):.1f} avg={statistics.mean(r['tps'] for r in st):.1f}")
    

    6.2 实测结果

    吞吐量:

    输出长度 TTFT 生成耗时 Tokens 速度
    短 (100) 5132ms* 0.58s 32 55.2 tok/s
    中 (500) 619ms 2.62s 147 56.2 tok/s
    长 (1000) 623ms 17.35s 1000 57.7 tok/s
    超长 (2000) 660ms 34.68s 1839 53.0 tok/s

    *首次含冷启动加载,后续 TTFT 稳定 ~600ms。平均 55.5 tok/s

    并发:

    并发数 总耗时 总Tokens 吞吐
    2 1.49s 7 4.7 tok/s
    4 2.64s 19 7.2 tok/s
    8 4.39s 46 10.5 tok/s

    并发下 TTFT 线性增长(串行处理,--parallel 1)

    温度影响:

    温度 TTFT 速度
    0 333ms 53.4 tok/s
    0.5 451ms 43.0 tok/s
    1.0 450ms 46.2 tok/s

    长上下文:

    上下文 TTFT 速度
    短 (~200字) 622ms 46.4 tok/s
    长 (~1600字) 1439ms 55.1 tok/s

    稳定性 (5次相同请求):

    指标 最小 最大 平均
    TTFT 153ms 624ms 305ms
    速度 48.7 52.7 51.8 tok/s

    速度波动 <8%,非常稳定

    口径说明:上表测于未启用 k4v 时期,且为纯文本负载。启用 k4v 后的解码表现见 §6.3。
    两组数字不可直接比较——负载类型不同(散文 vs 逐字重现)。


    6.3 投机解码专项(k4v)

    llama.cpp 的投机解码分支支持多个投机器同时启用,--spec-type 吃逗号清单。
    在原有 draft-mtp(模型内建 MTP 头自投机)之上叠加 ngram-map-k4v(从已生成上下文做
    n-gram 查表,命中一次吐最多 48 个 draft token),二者互补:n-gram 命中时是"猜一大段",
    没命中就退回 MTP,所以不会拖慢其他内容。

    --spec-type ngram-map-k4v,draft-mtp \
    --spec-draft-n-max 3 \
    --spec-ngram-map-k4v-size-n 32 \
    --spec-ngram-map-k4v-size-m 48 \
    --spec-ngram-map-k4v-min-hits 1
    

    零 VRAM 成本、零重编,已在 llama.sh 中默认开启(K4V=0 可关)。

    实测结果

    方法:temp=0、每项 2 轮取中位数、读 timings.predicted_per_second。
    两配置 n_gen 完全一致(407/365/500/271/630),故无「输出长度不同造成的假信号」。

    工作负载 仅 draft-mtp +k4v (n=32) 变化 接受率变化
    code_refactor 86.1 112.6 +30.7% 1.00 → 0.96
    repeat_synth 86.0 122.4 +42.3% 1.00 → 0.99
    template_batch 47.2 46.7 −1.1% 0.37 → 0.37
    reasoning 72.9 72.1 −1.1% 0.79 → 0.79
    prose 47.2 46.7 −1.1% 0.38 → 0.38

    负项均落在 ±1.1%,与同配置重跑的噪音水平一致。经 systemd 重启后复验:112.3 / 122.7。

    两个反直觉点

    1. 接受率下降不是退化。 code_refactor 接受率 1.00 → 0.96 看着像变差,实则
      草稿 token 从 306 涨到 400、总吞吐 86 → 112。接受率必须与 drafted 一起看,
      孤立看会误判(参见 §9.6 记录的同类教训)。
    2. 收益来自「逐字重现」,不是「格式相似」。
    你的任务长这样 k4v 收益
    让模型把整份文件/文章原样吐回、只改几行(改代码、翻译校对、逐条批注) +30~60%,最值钱
    大量重复的结构化输出(JSON/YAML/SRT/表格批次) 中等
    逐条照抄原文再加判定(规格检查、RAG 引述) 小幅或持平
    格式固定但内容全新(套模板产新数据) 持平(size-n 取 12 时倒扣)
    纯创作、思考链、对话 无感(也不会变慢)

    对 dsh 而言第一行才是主场:agent 改文件时经常要把整个文件回吐一遍,这就是 +30.7% 那档。

    为什么 size-n 必须是 32

    size-n 决定「要比对多长的前文才算命中」。拉长 = 更严格 = 少开火但更准。

    size-n code_refactor template_batch 字幕重排类 合成重复
    关闭 86.1 47.2 106.1 86.0
    12 166.4 68.4 ❌ −5% 95.8 ❌ −10% 178.6
    24 169.1 70.2 100.9 ❌ —
    32 157.0 71.1 105.4 237.0
    40 136.0 71.9 104.8 236.9

    (上表为来源帖在 RTX 4080S / 7900 XTX 上的扫描值,用于说明趋势,本机仅实测 n=32 一档。)

    size-n 太小 → n-gram 一直开火、一直猜错,白做的 draft 计算比不开启还多。
    典型反例是"文字照抄、时间轴改掉"的字幕重排:n-gram 看到前文就把旧时间戳一起预测出来,
    每到时间轴就撞墙,draft 992 个只接受 510。格式重复但内容要换的任务,n-gram 是负资产。

    不要动 min-hits

    来源帖试过 --spec-ngram-map-k4v-min-hits 2(要求 n-gram 出现两次才采用),
    结果各项 draft 计数与完全关闭 k4v 逐项相同——等于直接关掉功能。保持 1。

    不要设 p_min,n_max 保持 3

    n_max=5 + p_min=0.4 在来源帖实测中「重复内容看起来更快,但推理 −22%、散文 −37%」:
    draft 猜长了接受率上不去(散文只有 0.39),每步都在做白工。n_max 越大,
    可预测内容越快、不可预测内容越慢——纯创作偏多用 3,结构化输出偏多可试 4~5。

    来源:https://lcz.me/topic/1398(7900 XTX / Vulkan 实测报告,附 RTX 交叉验证)


    7. 日常运维

    7.1 常用命令

    # 启动模型(自动带 shim;有 systemd 单元且无覆盖变量时自动走 systemctl)
    ~/llama-bin/llama.sh 38
    
    # systemd 托管时的启动/重启(推荐;走 llama.sh 38 --fg,会继承脚本内全部参数)
    systemctl --user start  qwen38
    systemctl --user restart qwen38
    systemctl --user stop   qwen38     # 手动停,Restart=on-failure 不会拉回
    
    # 查看状态
    ~/llama-bin/llama.sh status
    
    # 停止
    ~/llama-bin/llama.sh stop
    
    # 切换模型
    ~/llama-bin/llama.sh 36 --use
    
    # 临时覆盖参数(单次生效)
    K4V=0 ~/llama-bin/llama.sh 38          # 关掉 ngram-k4v,只留 MTP
    NMAX=2 CRAM=0 ~/llama-bin/llama.sh 38   # 关掉 KV 驻内存
    CTX=65536 ~/llama-bin/llama.sh 38       # 加大窗口前先读 §9.8:逼近上限会掉进 GTT
    
    # shim 单独管理
    ~/llama-bin/shim.sh status
    ~/llama-bin/shim.sh restart
    
    # 查看日志
    tail -f /tmp/qwen38-vulkan.log    # llama.cpp
    tail -f /tmp/dsh-llm-shim.log     # shim
    journalctl --user -u qwen38 -f     # systemd 下 llama.cpp 的日志
    

    改 llama.sh 后不需要动 systemd 单元 —— 单元的 ExecStart 是 llama.sh 38 --fg,
    会自动继承脚本里的新参数。systemctl --user restart qwen38 即可生效。

    7.2 健康检查

    # llama.cpp
    curl -s http://127.0.0.1:8080/health | python3 -m json.tool
    
    # shim
    curl -s http://127.0.0.1:8090/health | python3 -m json.tool
    
    # 模型列表
    curl -s http://127.0.0.1:8090/v1/models | python3 -m json.tool
    
    # 快速测试
    curl -s http://127.0.0.1:8090/v1/chat/completions \
      -H "Content-Type: application/json" \
      -d '{"model":"qwen3.8-27b","messages":[{"role":"user","content":"你好"}],"max_tokens":50}'
    

    8. 故障排查

    8.1 常见问题

    症状 原因 解决
    shim: UPSTREAM_UNREACHABLE llama.cpp 未启动 ~/llama-bin/llama.sh 38
    CONTEXT_OVERFLOW 上下文超限 /compact 压缩或新开会话
    工具调用参数错乱 绕过 shim 直连 llama.cpp 确保 baseURL 指向 :8090
    模型跑飞 (32k token) llama.cpp 解析器 bug 必须经 shim,不能直连
    端口冲突 8080/8081/8090 被占用 改 P38_PORT/SHIM_PORT 环境变量
    显存不足 OOM 两模型同时运行 只能跑一个,先 stop 再启另一个
    MTP 头被忽略 --spec-type 未生效 检查日志 grep "nextn.*ignoring"

    8.2 日志位置

    服务 日志文件
    llama.cpp 3.8 /tmp/qwen38-vulkan.log
    llama.cpp 3.6 /tmp/qwen36-vulkan.log
    shim /tmp/dsh-llm-shim.log
    DSH ~/.dsh/logs/

    8.3 完全重置

    # 停止所有服务
    ~/llama-bin/llama.sh stop
    ~/llama-bin/shim.sh stop
    
    # 验证端口已释放
    ss -tlnp | grep -E "8080|8081|8090"
    
    # 重新启动
    ~/llama-bin/llama.sh 38
    

    9. 诊断与实战记录

    9.1 直连失败的具体表现与根因

    直连 llama.cpp 的 OpenAI 端点时:

    assistant/message: content: []   stopReason: "length"
    usage: output 32768 token   耗时 7.7 分钟   → 0 可用输出
    

    根因不在模型,而在 llama.cpp b11223 自带的 tool-call 解析器(该 build 报 chat_format: peg-native)。用 /apply-template + /completion 绕过解析器取模型原始输出:

    <tool_call> <function=bash> <parameter=command> ls -1 /home/hnz/桌面 | wc -l </parameter>
    <parameter=description> Count entries in Desktop directory </parameter> </function> </tool_call>
    

    49 token、格式完全正确、正常 EOS。但同一个请求走 /v1/chat/completions(带 tools)时,解析结果是:

    {"command": "ls -1 /home/hnz/桌面 | wc -l </parameter> <parameter=description> ... </tool_call>\n<tool_call>..."}
    

    即第二个参数起的整段被吞进第一个参数的值里;而且这种失败会诱发模型把同一个 <tool_call> 重复几百遍直到打满 max_tokens。模型自带模板明确允许参数值跨行("that can span multiple lines"),解析器却处理不了。

    对照实验(同一份真实 DSH 请求,24 个工具):

    路径 结果
    直连 :8081 0/10 成功,每次跑飞约 30s / 满 max_tokens
    经 shim 10/10 成功,每次约 1s / 49~51 token

    采样侧只能缓解(--dry-multiplier 0.8 能让它不再无限重复,但参数照样被解析坏),换 build 可能有效但会丢掉这套 MTP 调优(69 tok/s),所以选了中间层方案。

    ⚠ 后续复核推翻了这组对照(2026-09-29,见 §1.2 与 §9.7):当前配置下直连可正常完成
    工具调用,0/10 复现不出来。下文保留为历史记录,不代表当前实际行为。

    该故障的现场记录已归档:本目录 local_model.sh.txt
    (模型输出的原始抓取,含 4058 个零宽字符 U+200B 残留的重复 <tool_call> 标签,
    bash -n 直接语法错误 —— 正是「重复到打满 max_tokens」的产物)。

    对照另一个模型:qwen3.8-27b(2026-09-29 实测,当时 n_ctx 还是 262144;现已改为 131072,见 §9.8)经 shim 的干净工具调用 5/5、每次 1~2 秒;纯文本回复在流式与非流式下都与直连逐字节一致(temperature 0 + 固定 seed 对齐)。3.6/3.8 两个实例的严格模式提示双向验证通过。

    9.2 验证记录

    1. 解析单测 node ~/llama-bin/shim-parser-test.mjs → 7/7(含 <bash> 漏写 function= 的容错)。
    2. 真实请求重放 10/10 干净 tool_calls,每次 1s。
    3. headless 端到端(dsh --profile headless --patch dsh-shim-overlay.yml "…"):
      • 单步任务:tool-call bash → "3" → 最终回答 → turn/end: completed
      • 多步任务:一轮里并行发出 3 个 bash + 1 个 grep,再补一次 bash,最终答案
        (9 个文件 / 1 个子目录 / SHIM_PORT 默认 8090)全部正确。
      • 缓存命中正常(cacheRead 4928 / 5402 token)。
    4. 失败路径:上游不可用时返回 HTTP 502 + 明确错误信息;/health 只反映 shim 自身。
    5. k4v 启用后的复验(2026-09-29):经 systemd llama.sh 38 --fg 启动后,
      投机解码基准复现手工启动的数值(112.3 / 122.7),dsh 经 shim 端到端冒烟正常。
    6. 默认模型切换:dsh-shim-overlay.yml 的默认模型已由 qwen3.6-27b 改为 qwen3.8-27b,
      与 llama.sh 默认保持一致。改前若 3.6 未启动,shim 会返回
      502 UPSTREAM_UNREACHABLE 并提示当前实际在跑哪个实例。

    9.3 修复记录(都是 shim / 脚本侧的问题,DSH 走流式不受影响)

    症状 原因 修法
    非流式纯文本回复只剩最后 ~11 个字符 防标签跨 chunk 的 sent 指针在非流式路径也前进,但那条路并不发送 只在真正流式时推进 sent
    工具调用之前的正文被丢掉 流式分支没把保留尾巴里的正文发出去 发 tool_calls 前先补发 prefixText
    shim.sh stop 误杀无关进程(连调用它的 shell 都被杀过) pkill -f "dsh-llm-shim.mjs" 按文件名裸匹配,vim 该文件 也会中招 启动写 PID 文件,停止时按 PID + 只认 argv[0] 含 node 的进程
    llama.sh status 偶尔显示「停止」而模型其实在跑 pgrep 在某些环境下看不到别的进程 状态行加端口健康检查兜底

    9.4 上下文限制与超限处理

    • 本地模型的上下文比云端小得多,切模型前要看一眼历史长度。qwen3.8-27b 的
      n_ctx 现在是 131072(不再是 262144,见 §9.8),maxTokens: 32768,
      所以留给 prompt 的预算约 9.8 万 token——本地会话比想象的短得多。
      把一个已经很长的云端会话直接切到本地,llama.cpp 会回 400 exceed_context_size_error。正确做法:
      • 另开一个新会话用本地模型(最稳);或
      • 先在当前会话 /compact 压缩,再 /model 切到本地;
      • 确实需要更长窗口,见 §9.8 关于 CTX 与显存的取舍,不要盲目调大。
        现在这种情况会明确报 CONTEXT_OVERFLOW 并给出 token 数,不会再静默无响应。
        实测 DSH 侧呈现为 dsh: INVALID_REQUEST: 400: 本地模型上下文不足:请求 N token,而模型 ctx 只有 131072。请在 DSH 里 /compact 压缩会话,或另开一个新会话。,
        并且不再重试 5 次(INVALID_REQUEST 不在 llm-retry 的重试类别里,立刻失败并给出原因)。
    • ⚠ 超限之后的"压缩死循环"(2026-09-29 实测):会话一旦超过本地窗口,DSH 会自动
      触发压缩,但压缩请求本身也要把整段历史发给同一个模型,于是照样超限:
      13:08:09  provider=local model=qwen3.8-27b ctx=262144   ← 当时的配置,现为 131072
      13:08:11  compaction/start
      13:08:13  compaction/end  error: 400 请求 330277 token > ctx 262144
      13:08:15  turn/end  error: 400 CONTEXT_OVERFLOW(请求 329927 token)
      
      而 /compact 不接受参数、用当前选中的模型,所以在本地模型上手动压缩同样会失败。
      正确顺序(二选一):
      1. 另开新会话再选本地模型(已验证:provider=local/qwen3.8-27b,2 次工具调用、
        turn/end: completed,prompt 仅约 5.5k token);
      2. 想保留本会话:先 /model 切回 deepseek-flash → 发一条消息让切换生效 → /compact
        (云端 100 万窗口才压得动)→ 确认压缩成功后,再 /model 切回本地。
        注意切换生效前触发的压缩仍会用旧模型(实测踩过),所以要先发一条消息。

    9.5 日常使用注意

    • 一条命令就够:llama.sh 36|38 在模型就绪后会自动 shim.sh start(幂等,已在跑就跳过);
      llama.sh stop 与 llama.sh 36 --use 会连带停掉 shim。llama.sh status 也会打印 shim 状态行。
      (改动前已备份为 ~/llama-bin/llama.sh.bak-*、shim.sh.bak-*。)
    • 单独控制 shim:~/llama-bin/shim.sh start|stop|restart|status。
      升级过 dsh-llm-shim.mjs 之后要 shim.sh restart 才会生效(下次 llama.sh 36|38 起模型也会自动重启它)。
    • 端口占用兜底:若 :8090 已被别的实例服务(例如别的终端拉起的),shim.sh start 会跳过启动
      而不是抢端口;llama.sh status / shim.sh status 也会把这种情况算作“运行”。
    • 模型要对上:选 3.6 就得跑 llama.sh 36,选 3.8 就得跑 llama.sh 38(两个实例互斥)。
      对不上时 shim 会直接报错并说明现在跑的是哪个、该用哪条命令,不会拿另一个模型顶替。
      llama.sh 36 --use 切换模型时 shim 不用重启(它按模型名转发)。
    • shim 没启动时本地路由会 502;此时 GUI 里 /model 切回 deepseek-flash 即可。
    • 本会话里曾临时拉起过一个 shim 实例;llama.sh stop / shim.sh stop 都能把它停掉,
      之后由 llama.sh 正常接管。
    • 日志:/tmp/dsh-llm-shim.log(SHIM_VERBOSE=1 时记录每次解析结果与重试)。

    9.6 治本方向(上游修复)

    1. 换/升级 llama.cpp build —— 该 build 的 PEG 工具解析器是根因;上游修好后 shim 可以直接摘掉。
    2. 若换 build:本方案与 MTP 调优无关,shim 不依赖 --spec-type draft-mtp。
    3. 上报点:模板文档明示参数值可跨行,而 PEG 解析器处理不了跨行值,且失败会诱发重复生成。
    4. 另外值得反馈 DSH 上游:从大窗口模型(deepseek 100 万)切到小窗口模型(本地 26.2 万)时,
      自动压缩会用切换前的模型调度、且压缩请求本身可能超过目标窗口 —— 于是"越超限越压不动"。
      建议改成按目标模型窗口判断、并允许指定压缩用的模型。

    9.7 补充复核:shim A/B 与多轮稳定性

    2026-09-29 在当时配置(Qwen3.8-27B @262144、k4v 开启)下重做 A/B,
    两份 overlay 仅 baseURL 端口不同。

    A/B 结果

    路径 结果
    经 shim :8090 66 个文件 / ubuntu-ai ✓
    直连 :8080 66 个文件 / ubuntu-ai ✓(并多解释了 find -type f 的口径)

    coerce() 类型强转专项

    shim 的 coerceCall() 只在模型把数字/布尔发成带引号字符串时才触发修补
    (dsh-llm-shim.mjs 中 coerce():number→Number()、布尔映射 true/yes/1↔false/no/0、
    array/object→JSON.parse(),且仅处理 typeof v === "string")。

    为最大化触发机会,构造 7 字段全部 required 的工具
    (string / integer / integer / boolean / number / array / object),逼模型逐个输出:

    路径 样本 类型全对 字符串化
    直连 :8080 12 12/12 0
    经 shim :8090 6 6/6 0

    直连返回 {"task_name":"backup","delay_ms":1500,"repeat":3,"enabled":true,"threshold":0.75,"tags":["system","disk"],"opts":{"mode":"full"}}
    —— 全是原生 JSON 类型,18 次采样零次需要 coerce 修补。

    这不能证明 coerce 永远无用。 它是"模型偶尔把数字发成字符串"的兜底,
    而本次采样温度固定 0.3、提示固定,输出高度一致,未触发该边缘情况。
    真实长会话的输出分布更杂。建议保留 shim、切直连观察几天,再决定是否下线。

    多轮对话不存在「越聊越慢」

    社区方案建议加稳定性四件套
    --no-kv-unified --parallel 1 --ctx-checkpoints 2 --cache-ram 4096 来修多轮掉速。
    本机 4 轮实测(cache_prompt:true)显示该问题不存在:

    轮次 cache_n(复用前缀) prompt_n(本轮新算) decode t/s
    1 0 67 64.1
    2 366 60 66.4
    3 725 49 70.9
    4 1073 47 64.8

    第 2 轮起每轮只重算 47~67 个 token,decode 无衰减(甚至微升)。
    原因:本机一直是 --parallel 1,单槽下不存在 kv_unified 跨槽共享导致的状态失效。
    那套参数「免费」的前提是解决一个本机没有的问题,故不加。

    复现命令

    DSH=~/.npm/_npx/1e7f6d9597241db0/node_modules/.bin/dsh
    # 两份 overlay 仅 baseURL 端口不同(:8080 / :8090),其余完全一致
    $DSH --profile headless --patch ~/llama-bin/dsh-shim-overlay.yml \
         headless "统计 ~/llama-bin 下的文件数并读出 /etc/hostname"
    
    # 类型强转压测脚本原为 /tmp/opencode/coerce-test.py(临时目录,已随重启丢失)
    # 需要复现时按 §9.7 的 7 字段 schema 描述重写即可
    

    9.8 CTX 从 256K 降到 128K 的根因(Vulkan GTT 回落)

    2026-09-30 发现 llama.sh 38 慢到不可用(decode 0.49 tok/s),根因不是 shim、不是投机解码,
    而是**-c 262144 把权重从显存挤进了 GTT**。

    机制:模型权重 16.4GB + 256K 的 KV cache ≈ 26.3GB,超出 25.75GB 显存在 DEVICE_LOCAL 上
    能分配到的额度,vkAllocateMemory 失败后 Vulkan 后端静默改用 GTT(系统内存)映射。
    服务照常起来、/health 报 ok、n_ctx 也如实报 262144 —— 但每个 token 都要走 PCIe。

    指标 -c 262144 -c 131072
    vis_vram_used 567 MiB 权重完整驻留
    gtt_used 23490 MiB ~2.5 GB(KV)
    decode 0.49 tok/s 正常(数十 tok/s)
    prefill 30 t/s 400+ t/s

    日志里的特征行:print_timing: ... 0.49 tokens per second(decode 阶段)、
    prompt processing ... 30.08 tokens per second(prefill 阶段)。

    "多分配的 KV 不花钱"是错的。 只有在 KV 仍能装进 DEVICE_LOCAL 时这句话才成立;
    一旦溢出到 GTT,代价是每个 token 都走系统内存带宽。换句话说,判断依据是"装不装得下",
    不是"填不填得满"
    。

    显存扫描(-cram -1 下,KV 在系统内存,只算权重+运行时开销):

    ctx 显存占用 备注
    32K ~17.4 GB 安全
    128K ~21.2 GB 当前默认,留 ~4.5 GB 余量
    256K ~26.3 GB 爆,触发 GTT 回落

    下界 64K:Hermes Agent 硬性要求 context >= 64000,低于此直接拒绝连接。

    顺带加了两个参数:

    • -cram -1:KV cache 显式放系统内存,不与权重抢 DEVICE_LOCAL。
      对混合架构(本模型仅 16 层真注意力)代价很小,却能给权重留出余量。
    • P38_CTX 默认 131072:CTX=65536 ./llama.sh 38 可临时覆盖,
      但每次加大都必须重测 llama.sh status 的显存占用,逼近 ~24GB 时立刻退回。

    P36_CTX 仍是 196608,因为 Qwen3.6 的实测配置一直如此;若 3.6 也出现 0.49 tok/s,
    按同样逻辑降级即可。

    教训:llama.cpp 的性能参数(-c / -ngl / -ub)互相耦合,改任何一个都要
    看最终显存落点(vis_vram_used / gtt_used),不能只看单个参数的账面数字。
    相关实测记录见 ~/llama-bin/OPTIMIZATION.md。


    10. 附录:文件清单

    文件 用途
    ~/llama-bin/llama.sh 模型服务统一入口(36/38 启停、切换、状态;k4v 参数见 §6.3)
    ~/llama-bin/OPTIMIZATION.md 推理性能优化完整记录(显存账、ubatch、prefill 曲线、decode 溯源、各外部方案复核)
    ~/llama-bin/dsh-llm-shim.mjs shim 本体
    ~/llama-bin/shim.sh start / stop / restart / status(风格与 llama.sh 一致)
    ~/llama-bin/shim-parser-test.mjs 解析逻辑离线单测(7 项,改动后跑一下)
    ~/llama-bin/dsh-shim-overlay.yml headless 端到端验证用的 --patch 覆盖层(默认 qwen3.8-27b)
    ~/llama-bin/dsh-direct-overlay.yml 同上的直连版(:8080,无 shim),供 A/B 用
    ~/llama-bin/bench.py 性能测试脚本(第 6 章)
    ~/.config/qwen38/api.env 服务端密钥(独立文件,不进脚本、不进 shell 环境)
    ~/.config/systemd/user/qwen38.service systemd 单元,ExecStart=llama.sh 38 --fg(改 llama.sh 会自动继承)
    ~/llama-bin/llama.sh.bak-* / shim.sh.bak-* 改动前的脚本备份
    ~/文档/7900_dsh/README.md 本文档目录的维护说明与归档清单
    ~/文档/7900_dsh/local_model.sh.txt §9.1「无限重复 tool_call」故障的现场抓取(非脚本,不可执行)
    目录 内容
    --- ---
    ~/models/ Qwen3.8-27B-UD-Q4_K_M.gguf、Qwen3.6-27B-Q4_K_M-mtp.gguf、mmproj-model-bf16.gguf
    ~/.dsh/profiles/web/cordis.patch.yml DSH provider 路由(local → :8090/v1)
    /tmp/qwen38-vulkan.log / /tmp/qwen36-vulkan.log llama.cpp 日志
    /tmp/dsh-llm-shim.log shim 日志(SHIM_VERBOSE=1 时记录每次解析结果与重试)
    ~/.dsh/logs/ DSH 日志

  • Deepseek-Harness 单卡(7900XTX)运行Qwen3.8-27B 完整文档-2
    lybhb8L lybhb8
    AI硬件 7900xtx dsharness qwen-27b

    4. 开发工具调用 Shim

    4.1 核心代码 dsh-llm-shim.mjs

    单文件、无第三方依赖,存放到 ~/llama-bin/dsh-llm-shim.mjs:

    #!/usr/bin/env node
    /**
     * dsh-llm-shim —— 放在 DSH 与 llama.cpp 之间的 OpenAI 兼容工具调用 shim
     *
     * 背景:llama.cpp b11223(Vulkan + MTP)自带的 tool-call 解析器(chat_format:
     * peg-native)解析不了 Qwen3.x 的 XML 工具调用:第二个参数起的整段输出会被
     * 吞进第一个参数的值里,并且这种失败会诱发模型无限重复 <tool_call>,直到
     * max_tokens(实测 32768 token / 7.7 分钟、零可用输出)。
     *
     * 但模型本身的输出是正确的,所以这一层这样做:
     *   1. 把 tools 原样保留在请求里(模板仍会把工具定义写进 prompt),
     *      但把 tool_choice 改成 "none" —— 于是 llama.cpp 不做任何工具语法约束,
     *      模型的原始 XML 会完整出现在 message.content 里;
     *   2. 这一层自己把 XML 解析成标准 OpenAI tool_calls;
     *   3. 一旦拿到一个完整的 <tool_call> 且后面又开始重复,立刻掐断上游流,
     *      顺手解决“跑飞 32k token”的问题。
     *
     * 用法:
     *   node dsh-llm-shim.mjs                 # 监听 127.0.0.1:8090
     *                                           # qwen3.8-* → :8080,其余 → :8081(一一对应,不替换)
     *   SHIM_PORT=8091 node dsh-llm-shim.mjs
     *   SHIM_UPSTREAM=http://127.0.0.1:8080 node dsh-llm-shim.mjs   # 固定上游
     * 环境变量:SHIM_UPSTREAM / SHIM_UPSTREAM_36 / SHIM_UPSTREAM_38 / SHIM_PORT / SHIM_HOST /
     *          SHIM_VERBOSE / SHIM_ALLOW_FALLBACK / SHIM_TOOL_REGION_CAP / SHIM_MAX_TOOL_CALLS
     */
    
    import http from "node:http";
    
    const UPSTREAM = (process.env.SHIM_UPSTREAM ?? "").replace(/\/+$/, "");
    const PORT = Number(process.env.SHIM_PORT ?? 8090);
    const HOST = process.env.SHIM_HOST ?? "127.0.0.1";
    const VERBOSE = process.env.SHIM_VERBOSE === "1";
    
    /**
     * llama.sh 的 36/38 两个实例互斥,同一时间只有一个在跑,所以按请求里的模型名选上游。
     * 默认**不静默回退**:选 3.8 却在跑 3.6 时,宁可报错也不要拿另一个模型冒充
     * (两者 ctx 不同,静默替换会让人误判在测哪个模型)。要旧的「谁在跑就用谁」行为
     * 就设 `SHIM_ALLOW_FALLBACK=1`。
     */
    const ROUTES = [
      { pattern: /3\.8/i, url: (process.env.SHIM_UPSTREAM_38 ?? "http://127.0.0.1:8080").replace(/\/+$/, ""),
        label: "Qwen3.8-27B", key: "38" },
      { pattern: /./, url: (process.env.SHIM_UPSTREAM_36 ?? "http://127.0.0.1:8081").replace(/\/+$/, ""),
        label: "Qwen3.6-27B", key: "36" },
    ];
    const ALLOW_FALLBACK = process.env.SHIM_ALLOW_FALLBACK === "1";
    
    /** @returns 该请求要试的上游列表(默认严格模式只有一个)。 */
    function upstreamsFor(model) {
      if (UPSTREAM) return [UPSTREAM];
      const picked = (ROUTES.find((r) => r.pattern.test(String(model ?? ""))) ?? ROUTES[0]).url;
      if (!ALLOW_FALLBACK) return [picked];
      return [...new Set([picked, ...ROUTES.map((r) => r.url)])];
    }
    
    /** 某个上游是否健康;用来把「现在到底哪个实例在跑」写进报错里。 */
    async function isHealthy(url, signal) {
      if (url === undefined) return false;
      try {
        return (await fetch(`${url}/health`, { signal })).ok;
      } catch {
        return false;
      }
    }
    
    const routeLabel = (url) => {
      const hit = ROUTES.find((r) => r.url === url);
      return hit === undefined ? url : `:${new URL(url).port}(${hit.label})`;
    };
    
    /** 一段工具调用最多累计多少字符就认定上游在跑飞。 */
    const TOOL_REGION_CAP = Number(process.env.SHIM_TOOL_REGION_CAP ?? 4000);
    /** 最多接受几个工具调用(正常一轮并行调用不会太多)。 */
    const MAX_TOOL_CALLS = Number(process.env.SHIM_MAX_TOOL_CALLS ?? 4);
    /** 纯文本流水的保留量,避免把跨界出现的 "<tool_call>" 前半截发出去。 */
    const HOLDBACK = "<tool_call>".length - 1;
    /** 工具调用区解析失败时,整轮重试几次(模型下一轮通常就写对了)。 */
    const MAX_PARSE_ATTEMPTS = Number(process.env.SHIM_PARSE_ATTEMPTS ?? 3);
    
    const log = (...a) => console.log(new Date().toISOString(), ...a);
    
    // ── XML 工具调用解析 ────────────────────────────────────────────────────────
    
    /** 去掉值尾部残留的闭合标签(模型偶尔漏写某个闭合标签)。 */
    function cutValue(v) {
      const i = v.search(/<\/parameter>|<\/function>|<\/tool_call>/);
      if (i >= 0) v = v.slice(0, i);
      return v.trim();
    }
    
    /** 解析单个 <tool_call> 的内容。支持 <function=..><parameter=..> 与 JSON 两种形态。 */
    function parseBlock(inner, knownNames) {
      const trimmed = inner.trim();
      // 形态二:<tool_call>{"name":"bash","arguments":{...}}</tool_call>
      if (trimmed.startsWith("{")) {
        try {
          const o = JSON.parse(trimmed);
          const name = o.name ?? o.tool ?? o.function?.name;
          let args = o.arguments ?? o.parameters ?? o.tool_input ?? {};
          if (typeof args === "string") args = JSON.parse(args);
          if (typeof name === "string" && args && typeof args === "object") return { name, args };
        } catch { /* 落到 XML 形态 */ }
      }
      // 形态一:<function=bash>…</function>
      let name;
      let body;
      const fm = /<function\s*=\s*([^>\s>]+)\s*>/.exec(trimmed);
      if (fm) {
        name = fm[1];
        body = trimmed.slice(fm.index + fm[0].length);
      } else {
        // 容错:模型偶尔漏写 function=,直接写成 <bash> … </function>
        for (const candidate of knownNames ?? []) {
          const m = new RegExp(`<${candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*>`).exec(trimmed);
          if (m) {
            name = candidate;
            body = trimmed.slice(m.index + m[0].length);
            break;
          }
        }
        if (name === undefined) return null;
      }
      const parts = body.split(/<parameter\s*=\s*([^>\s]+)\s*>/);
      const args = {};
      for (let i = 1; i < parts.length; i += 2) {
        const key = parts[i];
        const value = cutValue(parts[i + 1] ?? "");
        args[key] = value;
      }
      // 有名字但一个参数都没解析出来时,多半是格式坏掉了,交给上层决定是否重试。
      return Object.keys(args).length > 0 ? { name, args } : null;
    }
    
    /** 从累计文本里挤出所有已闭合的 <tool_call> 块。 */
    function scanToolCalls(text, knownNames) {
      const calls = [];
      const re = /<tool_call>([\s\S]*?)<\/tool_call>/g;
      let m;
      while ((m = re.exec(text))) {
        const parsed = parseBlock(m[1], knownNames);
        if (parsed) calls.push(parsed);
      }
      return calls;
    }
    
    const sameCall = (a, b) => a.name === b.name && JSON.stringify(a.args) === JSON.stringify(b.args);
    
    /** 去掉模型跑飞时重复生成的相同调用。 */
    function dedupe(calls) {
      const out = [];
      for (const c of calls) if (!out.some((o) => sameCall(o, c))) out.push(c);
      return out;
    }
    
    // ── 按 JSON Schema 把字符串值还原成正确类型 ────────────────────────────────
    // XML 里所有参数都是字符串,但 DSH 的工具参数有 integer/boolean/array 等。
    
    function schemaByName(tools) {
      const map = new Map();
      for (const t of tools ?? []) {
        const fn = t?.function ?? t;
        if (fn?.name) map.set(fn.name, fn.parameters ?? fn.input_schema ?? null);
      }
      return map;
    }
    
    function typeOf(schema) {
      if (!schema || typeof schema !== "object") return undefined;
      if (typeof schema.type === "string") return schema.type;
      const cands = [];
      for (const key of ["anyOf", "oneOf", "allOf"]) {
        for (const s of schema[key] ?? []) if (s?.type && s.type !== "null") cands.push(s.type);
      }
      return cands.length === 1 ? cands[0] : undefined;
    }
    
    function coerce(value, schema) {
      switch (typeOf(schema)) {
        case "integer":
        case "number": {
          const n = Number(value);
          return Number.isFinite(n) ? n : value;
        }
        case "boolean": {
          const s = value.toLowerCase();
          if (["true", "yes", "1"].includes(s)) return true;
          if (["false", "no", "0"].includes(s)) return false;
          return value;
        }
        case "array":
        case "object": {
          try { return JSON.parse(value); } catch { return value; }
        }
        default:
          return value;
      }
    }
    
    function coerceCall(call, schemas) {
      const params = schemas.get(call.name)?.properties;
      if (!params) return call;
      const args = {};
      for (const [k, v] of Object.entries(call.args)) {
        args[k] = typeof v === "string" ? coerce(v, params[k]) : v;
      }
      return { name: call.name, args };
    }
    
    const newCallId = () => `call_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
    
    // ── HTTP ────────────────────────────────────────────────────────────────────
    
    function readBody(req) {
      return new Promise((resolve, reject) => {
        const chunks = [];
        req.on("data", (c) => chunks.push(c));
        req.on("end", () => resolve(Buffer.concat(chunks)));
        req.on("error", reject);
      });
    }
    
    const sse = (res, obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
    
    /**
     * 连上第一个能用的本地上游。一旦开始收流就不再换。
     *
     * 4xx 是「请求本身有问题」,换实例没有意义 —— 直接把上游的状态码和正文原样交给
     * 客户端(否则真实原因会被 "unreachable" 掩盖)。只有连不上或 5xx 才换下一个。
     *
     * @param body - 已经改写成 tool_choice:"none" 的上游请求体。
     * @param model - 客户端请求的模型名,决定先试哪个实例。
     * @param signal - 客户端断开信号。
     * @returns `{ response }` 成功;失败为 `{ status, payload, detail }`。
     */
    async function connectUpstream(body, model, signal) {
      const init = {
        method: "POST",
        headers: { "content-type": "application/json", accept: "text/event-stream" },
        body: JSON.stringify(body),
        signal,
      };
      let failure = { status: 502, payload: { error: { message: "shim: no local llama.cpp upstream reachable",
        code: "NO_UPSTREAM" } }, detail: "none tried" };
      for (const base of upstreamsFor(model)) {
        let response;
        try {
          response = await fetch(`${base}/v1/chat/completions`, init);
        } catch (error) {
          if (signal?.aborted) throw error;
          // 严格模式:把这个模型对应的实例没起来、而另一个在跑这件事说清楚,
          // 而不是偷偷拿另一个模型回答。
          const others = ROUTES.filter((r) => r.url !== base);
          const wanted = ROUTES.find((r) => r.url === base);
          let hint = "";
          for (const other of others) {
            if (await isHealthy(other.url, signal)) {
              hint = `现在运行的是 ${routeLabel(other.url)};请在 GUI 的 /model 里选它,` +
                `或用 ~/llama-bin/llama.sh ${wanted?.key ?? "36|38"} 启动 ${routeLabel(base)}。`;
              break;
            }
          }
          if (hint === "") hint = "本地模型没启动?先用 ~/llama-bin/llama.sh 36|38 启动。";
          failure = { status: 502, payload: { error: {
            message: `shim: ${routeLabel(base)}未启动(${base} 连不上)。${hint}`,
            code: "UPSTREAM_UNREACHABLE", detail: error?.message ?? String(error) } },
            detail: `${base} unreachable: ${error?.message ?? error}` };
          log(failure.detail);
          continue;
        }
        if (response.ok && response.body) {
          if (VERBOSE) log(`→ ${base}`);
          return { response };
        }
        const text = await response.text().catch(() => "");
        let payload;
        try { payload = JSON.parse(text); } catch { payload = { error: { message: text.slice(0, 500) } }; }
        // 上下文超限是最常见的 4xx,给一句能直接照做的提示。
        const type = payload?.error?.type;
        if (type === "exceed_context_size_error" || /exceed.*context/i.test(payload?.error?.message ?? "")) {
          payload = { error: { message:
            `本地模型上下文不足:请求 ${payload.error.n_prompt_tokens ?? "?"} token,` +
            `而模型 ctx 只有 ${payload.error.n_ctx ?? "?"}。请在 DSH 里 /compact 压缩会话,或另开一个新会话。`,
            code: "CONTEXT_OVERFLOW", upstream: payload.error } };
        }
        failure = { status: response.status, payload, detail: `${base} answered ${response.status}: ${text.slice(0, 200)}` };
        log(failure.detail);
        if (response.status < 500) break;   // 4xx:请求本身的问题,换实例也没用
      }
      log(`upstream failed (${failure.detail})`);
      return failure;
    }
    
    /**
     * 处理一次 /v1/chat/completions。
     *
     * @param body - 客户端请求体。
     * @param res - 客户端响应。
     * @param signal - 客户端断开时中止上游。
     */
    async function handleCompletions(body, res, signal) {
      // 与 OpenAI 语义一致:只有显式 stream:true 才是流式(DSH 每次都会显式带上)。
      const wantsStream = body.stream === true;
      const schemas = schemaByName(body.tools);
      const knownNames = [...schemas.keys()];
      const hasTools = (body.tools?.length ?? 0) > 0;
    
      // 关键:工具定义留在 prompt 里,但关掉 llama.cpp 的工具语法约束。
      const upstreamBody = {
        ...body,
        ...(hasTools ? { tool_choice: "none" } : {}),
        stream: true,
        stream_options: { include_usage: true },
      };
    
      const model = body.model ?? "local";
      const id = `chatcmpl-shim-${Math.random().toString(36).slice(2, 12)}`;
      const created = Math.floor(Date.now() / 1000);
    
      // SSE 头必须等上游真的接受请求之后再发:否则上游报错时状态码已经锁成 200,
      // 只能把错误正文塞进流里,客户端会看到 "Stream ended without finish_reason",
      // 真实原因(例如上下文超限)被完全掩盖。
      let streamHeadersSent = false;
      const ensureStreamHeaders = () => {
        if (!wantsStream || streamHeadersSent) return;
        res.writeHead(200, {
          "content-type": "text/event-stream; charset=utf-8",
          "cache-control": "no-cache",
          connection: "keep-alive",
        });
        streamHeadersSent = true;
      };
    
      const emitContent = (chunk) => {
        if (wantsStream) sse(res, { id, object: "chat.completion.chunk", created, model,
          choices: [{ index: 0, delta: { content: chunk }, finish_reason: null }] });
      };
      const emitToolCalls = (calls, prefixText, usage) => {
        const payload = calls.map((c, index) => ({
          index, id: newCallId(), type: "function",
          function: { name: c.name, arguments: JSON.stringify(c.args) },
        }));
        if (VERBOSE) log(`→ tool_calls ${calls.map((c) => `${c.name}(${JSON.stringify(c.args).slice(0, 80)})`).join(", ")}`);
        if (wantsStream) {
          // 工具调用之前的正文也要发出去(它可能还压在保留尾巴里),别丢内容。
          if (prefixText) emitContent(prefixText);
          sse(res, { id, object: "chat.completion.chunk", created, model,
            choices: [{ index: 0, delta: { tool_calls: payload }, finish_reason: null }] });
          sse(res, { id, object: "chat.completion.chunk", created, model,
            choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
            ...(usage ? { usage } : {}) });
          res.write("data: [DONE]\n\n");
          res.end();
        } else {
          res.writeHead(200, { "content-type": "application/json" });
          res.end(JSON.stringify({
            id, object: "chat.completion", created, model,
            choices: [{ index: 0, message: { role: "assistant", content: prefixText || null,
              tool_calls: payload }, finish_reason: "tool_calls" }],
            ...(usage ? { usage } : {}),
          }));
        }
      };
      /** @param tail - 还没转发出去的正文字(流式时 sent 之后的剩余部分)。 */
      const emitPlain = (tail, finish, usage) => {
        if (wantsStream) {
          if (tail) emitContent(tail);
          sse(res, { id, object: "chat.completion.chunk", created, model,
            choices: [{ index: 0, delta: {}, finish_reason: finish ?? "stop" }],
            ...(usage ? { usage } : {}) });
          res.write("data: [DONE]\n\n");
          res.end();
        } else {
          res.writeHead(200, { "content-type": "application/json" });
          res.end(JSON.stringify({
            id, object: "chat.completion", created, model,
            choices: [{ index: 0, message: { role: "assistant", content: tail },
              finish_reason: finish ?? "stop" }],
            ...(usage ? { usage } : {}),
          }));
        }
      };
    
      /**
       * 收完一次上游流:边收边把普通正文转发给客户端,遇到 <tool_call> 就转入解析。
       * @returns 本次尝试的结果,供上层决定要不要重试。
       */
      const consume = async (upstream) => {
        const upstreamAbort = new AbortController();
        signal.addEventListener("abort", () => upstreamAbort.abort(), { once: true });
        const decoder = new TextDecoder();
        let buffer = "";
        let text = "";
        let sent = 0;
        let toolMode = false;
        let toolText = "";
        let usage = null;
        let upstreamFinish = null;
        let aborted = false;
        try {
          for await (const chunk of upstream.body) {
            buffer += decoder.decode(chunk, { stream: true });
            let nl;
            while ((nl = buffer.indexOf("\n")) >= 0) {
              const line = buffer.slice(0, nl).trim();
              buffer = buffer.slice(nl + 1);
              if (!line.startsWith("data:")) continue;
              const payload = line.slice(5).trim();
              if (payload === "[DONE]") continue;
              let parsed;
              try { parsed = JSON.parse(payload); } catch { continue; }
              if (parsed.usage) usage = parsed.usage;
              const choice = parsed.choices?.[0];
              if (!choice) continue;
              if (choice.finish_reason) upstreamFinish = choice.finish_reason;
              const piece = typeof choice.delta?.content === "string" ? choice.delta.content : "";
              if (!piece) continue;
    
              if (!toolMode) {
                text += piece;
                const at = text.indexOf("<tool_call>");
                if (at < 0) {
                  // 普通正文:留一点尾巴防止 "<tool_call>" 跨 chunk,其余立即转发。
                  // 保留只对真正的流式有意义 —— 非流式一次性发送时不能推进 sent,
                  // 否则最后 emitPlain 只会吐出被"保留"的那几个字符。
                  if (wantsStream) {
                    const safe = Math.max(sent, text.length - HOLDBACK);
                    if (safe > sent) { emitContent(text.slice(sent, safe)); sent = safe; }
                  }
                  continue;
                }
                if (at > sent) { emitContent(text.slice(sent, at)); sent = at; }
                toolMode = true;
                toolText = text.slice(at);
                text = text.slice(0, at);
              } else {
                toolText += piece;
              }
    
              if (toolMode) {
                const found = scanToolCalls(toolText, knownNames);
                if (found.length >= MAX_TOOL_CALLS || toolText.length >= TOOL_REGION_CAP) {
                  aborted = true;
                  upstreamAbort.abort();
                  break;
                }
              }
            }
            if (aborted) break;
          }
        } catch (error) {
          if (!aborted && error?.name !== "AbortError") log(`upstream stream error: ${error?.message ?? error}`);
        } finally {
          upstreamAbort.abort();
        }
        const calls = toolMode
          ? dedupe(scanToolCalls(toolText, knownNames)).map((c) => coerceCall(c, schemas))
          : [];
        return { text, sent, toolMode, toolText, calls, usage, upstreamFinish, aborted };
      };
    
      for (let attempt = 1; attempt <= MAX_PARSE_ATTEMPTS; attempt++) {
        const connected = await connectUpstream(upstreamBody, body.model, signal);
        if (connected.response === undefined) {
          // 上游拒绝了这次请求(或全连不上):此时还没发过 SSE 头,可以如实回状态码和原因。
          const { status, payload } = connected;
          if (!res.headersSent) {
            res.writeHead(status, { "content-type": "application/json" });
            res.end(JSON.stringify(payload));
          } else if (!res.writableEnded) {
            // 上一轮已经发过流(解析失败重试的情形):只能把错误写进流里。
            sse(res, payload);
            res.write("data: [DONE]\n\n");
            res.end();
          }
          return;
        }
    
        ensureStreamHeaders();
        const r = await consume(connected.response);
    
        if (r.calls.length > 0) {
          if (r.aborted && VERBOSE) log(`upstream cut early after ${r.toolText.length} chars (runaway guard)`);
          emitToolCalls(r.calls, r.text, r.usage);
          return;
        }
    
        // 看到了 <tool_call> 却没解析出调用、而且还没往客户端发过正文 → 重试一次,
        // 模型下一轮通常就写对了(实测常见形态:<bash> 漏写 function=)。
        if (r.toolMode && r.sent === 0 && attempt < MAX_PARSE_ATTEMPTS && !signal.aborted) {
          log(`attempt ${attempt}: unparsable tool region (${r.toolText.length} chars), retrying`);
          continue;
        }
    
        if (r.toolMode) {
          log(`tool region had no parsable call; falling back to text (${r.toolText.length} chars): ` +
            r.toolText.replace(/\s+/g, " ").slice(0, 200));
          emitPlain(r.text.slice(r.sent) + r.toolText, r.upstreamFinish === "length" ? "length" : "stop", r.usage);
          return;
        }
        emitPlain(r.text.slice(r.sent), r.upstreamFinish === "length" ? "length" : "stop", r.usage);
        return;
      }
    }
    
    const server = http.createServer(async (req, res) => {
      const url = req.url ?? "/";
      if (req.method === "GET" && (url.startsWith("/health") || url.startsWith("/v1/models"))) {
        // /health 只有本层活着就返回 ok(上游没起来时 DSH 不该因此认为 shim 挂了)。
        if (url.startsWith("/health")) {
          res.writeHead(200, { "content-type": "application/json" });
          res.end(JSON.stringify({ status: "ok", upstreams: upstreamsFor(""),
            fallback: ALLOW_FALLBACK ? "on" : "off (strict: 选哪个模型就只打哪个实例)" }));
          return;
        }
        for (const base of upstreamsFor("")) {
          const upstream = await fetch(`${base}${url}`).catch(() => null);
          if (upstream?.ok) {
            const text = await upstream.text();
            res.writeHead(200, { "content-type": "application/json" });
            res.end(text);
            return;
          }
        }
        res.writeHead(502, { "content-type": "application/json" });
        res.end(JSON.stringify({ error: { message: "shim: upstream unreachable" } }));
        return;
      }
      if (req.method !== "POST" || !url.startsWith("/v1/chat/completions")) {
        res.writeHead(404, { "content-type": "application/json" });
        res.end(JSON.stringify({ error: { message: `shim: no route for ${req.method} ${url}` } }));
        return;
      }
      const controller = new AbortController();
      res.on("close", () => controller.abort());
      try {
        const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
        await handleCompletions(body, res, controller.signal);
      } catch (error) {
        log(`request failed: ${error?.stack ?? error}`);
        if (!res.headersSent) res.writeHead(500, { "content-type": "application/json" });
        if (!res.writableEnded) res.end(JSON.stringify({ error: { message: `shim: ${error?.message ?? error}` } }));
      }
    });
    
    server.listen(PORT, HOST, () => {
      log(`dsh-llm-shim listening on http://${HOST}:${PORT}  →  upstream ${upstreamsFor("").join(" , ")}`);
      log(`tool-call parsing: client-side (tool_choice forced to "none"; cap ${TOOL_REGION_CAP} chars, max ${MAX_TOOL_CALLS} calls)`);
    });
    
    // 便于离线单测解析逻辑(直接运行时这些导出无副作用)。
    export { parseBlock, scanToolCalls, dedupe, coerce };
    

    4.2 管理脚本 shim.sh

    存放到 ~/llama-bin/shim.sh:

    #!/bin/bash
    # 本地模型工具调用 shim 管理 —— 配合 dsh-llm-shim.mjs
    #
    #   ./shim.sh start     启动(已在跑则提示)
    #   ./shim.sh stop      停止
    #   ./shim.sh restart
    #   ./shim.sh status    shim 与两个 llama 实例的状态
    #
    # 为什么需要它:llama.cpp b11223(Vulkan + MTP)自带的 tool-call 解析器解析不了
    # Qwen3.x 的 XML 工具调用(第二个参数起的整段被吞进第一个参数),并会诱发模型
    # 无限重复 <tool_call> 直到 max_tokens。模型本身输出是对的,所以这一层让
    # llama.cpp 不做工具语法约束,由 shim 自己把 XML 解析成标准 OpenAI tool_calls。
    # 实测:直连 0/10 成功、每次跑飞 ~30s;走 shim 10/10 成功、每次 ~1s。
    set -uo pipefail
    
    SHIM_DIR="${SHIM_DIR:-$HOME/llama-bin}"
    SHIM_JS="$SHIM_DIR/dsh-llm-shim.mjs"
    PORT="${SHIM_PORT:-8090}"
    LOG="${SHIM_LOG:-/tmp/dsh-llm-shim.log}"
    PIDFILE="${SHIM_PIDFILE:-$SHIM_DIR/.dsh-llm-shim.pid}"
    NODE="${NODE:-$(command -v node || echo "$HOME/.nvm/versions/node/v24.21.0/bin/node")}"
    
    # 只认 argv[0] 是 node、且命令行含脚本绝对路径的进程。
    # 绝不裸 pgrep 文件名 —— 否则 `pkill -f dsh-llm-shim.mjs` 会连
    # `vim dsh-llm-shim.mjs`、甚至调用者自己的 shell 一起杀掉(踩过一次)。
    _running() {
      local pid argv0
      if [ -r "$PIDFILE" ]; then
        pid=$(cat "$PIDFILE" 2>/dev/null)
        if [ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ] && \
           tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null | grep -qF -- "$SHIM_JS"; then
          echo "$pid"; return 0
        fi
      fi
      local found=""
      for pid in $(pgrep -f -- "$SHIM_JS" 2>/dev/null); do
        [ "$pid" = "$$" ] && continue
        [ "$pid" = "${PPID:-0}" ] && continue
        argv0=$(tr '\0' '\n' <"/proc/$pid/cmdline" 2>/dev/null | head -1)
        case "$argv0" in *node*) found="$found $pid" ;; esac
      done
      [ -n "$found" ] || return 1        # 没找到要明确失败,否则调用方会误判“已在运行”
      echo $found
    }
    _healthy() { curl -fsS --max-time 2 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; }
    
    case "${1:-status}" in
      start)
        if _running >/dev/null; then
          echo "shim 已在运行 (pid $(_running | tr '\n' ' '))"; exit 0
        fi
        # 进程看不到、但端口已经有人在服务(例如由别的终端/会话拉起)时别再抢端口
        if _healthy; then
          echo "shim 端口 :$PORT 已有实例在服务,跳过启动"; exit 0
        fi
        [ -f "$SHIM_JS" ] || { echo "缺少 $SHIM_JS" >&2; exit 1; }
        [ -x "$NODE" ] || { echo "找不到 node: $NODE" >&2; exit 1; }
        SHIM_PORT="$PORT" SHIM_VERBOSE=1 setsid nohup "$NODE" "$SHIM_JS" </dev/null >"$LOG" 2>&1 &
        echo $! >"$PIDFILE"
        disown
        printf '启动中'
        for _ in $(seq 1 20); do _healthy && break; sleep 0.5; printf '.'; done
        echo
        if _healthy; then
          echo "shim 就绪: http://127.0.0.1:$PORT   log: $LOG"
        else
          echo "启动失败,检查 $LOG" >&2; exit 1
        fi ;;
      stop)
        if _running >/dev/null; then
          _pids=$(_running)
          kill $_pids 2>/dev/null
          for _ in $(seq 1 20); do _running >/dev/null || break; sleep 0.5; done
          kill -9 $_pids 2>/dev/null
          rm -f "$PIDFILE"
          echo "shim 已停止"
        elif _healthy; then
          echo "shim 进程在本终端不可见,但 :$PORT 仍在服务(换个终端 pkill -f dsh-llm-shim.mjs)" >&2
        else
          echo "shim 未在运行"
        fi ;;
      restart) "$0" stop; sleep 1; exec "$0" start ;;
      status)
        if _running >/dev/null; then
          printf 'shim      : 运行中 (pid %s) ' "$(_running | tr '\n' ' ')"
          _healthy && echo "健康" || echo "端口 $PORT 无响应"
        else
          echo "shim      : 停止"
        fi
        for spec in "llama 3.6 :8081" "llama 3.8 :8080"; do
          set -- $spec; p="${3#:}"
          printf '%-13s: ' "$1 $2"
          curl -fsS --max-time 3 "http://127.0.0.1:$p/health" 2>/dev/null | grep -q ok && echo 运行中 || echo 未运行
        done ;;
      *) sed -n '2,8p' "$0"; exit 1 ;;
    esac
    

    4.3 Shim 工作原理详解

    请求流程:

    1. DSH 发送 OpenAI 格式请求(含 tools 定义)到 :8090
    2. shim 保留 tools 但把 tool_choice 改为 none
    3. 转发到 llama.cpp,模型输出原始 XML 工具调用
    4. shim 边收流边检测 tool_call 标签
    5. 解析 XML 为 {name, args},按 JSON Schema 还原类型
    6. 封装为标准 OpenAI tool_calls 返回给 DSH

    防跑飞机制:

    • TOOL_REGION_CAP = 4000 字符:超过则判定跑飞,掐断流
    • MAX_TOOL_CALLS = 4:最多接受 4 个并行工具调用
    • 重复检测:相同 name+args 的调用自动去重
    • 解析失败重试:最多 3 次(模型下一轮通常写对)

    环境变量:

    变量 默认值 说明
    SHIM_PORT 8090 监听端口
    SHIM_UPSTREAM 自动路由 固定上游地址
    SHIM_UPSTREAM_38 http://127.0.0.1:8080 Qwen3.8 上游
    SHIM_UPSTREAM_36 http://127.0.0.1:8081 Qwen3.6 上游
    SHIM_VERBOSE 0 设为 1 输出调试日志
    SHIM_ALLOW_FALLBACK 0 设为 1 允许跨模型回退
    SHIM_TOOL_REGION_CAP 4000 工具调用区最大字符数
    SHIM_MAX_TOOL_CALLS 4 最大并行工具调用数

    5. 配置 DSH 接入

    5.1 配置方式

    真实文件有两份,内容一致、用途不同:

    文件 用途
    ~/.dsh/profiles/web/cordis.patch.yml 实际生效的 DSH Web profile 路由层
    ~/llama-bin/dsh-shim-overlay.yml headless 端到端验证用的 --patch 覆盖层(下方代码块即此文件)
    ~/llama-bin/dsh-direct-overlay.yml 同上的直连版(:8080,无 shim),供 A/B 用
    # headless 端到端验证:DSH → shim(8090) → llama.cpp(8080)
    # 默认模型 qwen3.8-27b(:8080,与 llama.sh 默认一致)。
    # 3.6(:8081)默认停用,需先 ./llama.sh 36 才会起;否则 shim 会返回 502 UPSTREAM_UNREACHABLE。
    - id: llm-pi-ai
      name: "@deepseek-ai/dsh-llm-pi-ai"
      config:
        providers:
          local:
            displayName: 本地 llama.cpp
            api: openai-completions
            baseURL: http://127.0.0.1:8090/v1
            headers:
              authorization: Bearer local-no-auth
            compat:
              supportsStore: false
              supportsDeveloperRole: false
              supportsReasoningEffort: false
              maxTokensField: max_tokens
              supportsStrictMode: false
              supportsLongCacheRetention: false
            models:
              - id: qwen3.8-27b
                name: Qwen3.8-27B (本地 :8080)
                contextWindow: 131072
                maxTokens: 32768
              - id: qwen3.6-27b
                name: Qwen3.6-27B (本地 :8081,需手动启动)
                contextWindow: 196608
                maxTokens: 32768
    - id: agent-default-model
      name: "@deepseek-ai/dsh-agent-default-model"
      config:
        provider: local
        model: qwen3.8-27b
    

    5.2 配置说明

    字段 值 说明
    api openai-completions OpenAI 兼容 API
    baseURL http://127.0.0.1:8090/v1 指向 shim
    headers.authorization Bearer local-no-auth 占位密钥(服务端未开认证,但 pi-ai 缺了会拒发)
    compat.maxTokensField max_tokens llama.cpp 用 max_tokens
    contextWindow (3.8) 131072 必须等于 llama.sh 的 P38_CTX。声明大了 DSH 不会提前压缩,直到服务端报 CONTEXT_OVERFLOW
    maxTokens (3.8) 32768 输出上限须给 prompt 留余量:3.8 的窗口 128K,留 ~96K 给 prompt
    contextWindow (3.6) 196608 对应 P36_CTX;换 3.6 前先确认 llama.sh 36 起来了

    contextWindow / maxTokens 三处必须对齐:llama.sh 的 P38_CTX → overlay → 运行中的
    /v1/models 返回的 n_ctx。对不上时以服务端为准,改配置后 systemctl --user restart qwen38。

    5.3 设置默认模型

    - id: agent-default-model
      name: "@deepseek-ai/dsh-agent-default-model"
      config:
        provider: local
        model: qwen3.8-27b
    


  • Deepseek-Harness 单卡(7900XTX)运行Qwen3.8-27B 完整文档-1
    lybhb8L lybhb8
    AI硬件 7900xtx dsharness qwen-27b

    将本地 llama.cpp 推理服务接入 DeepSeek Harness (DSH) 的全过程:环境准备、服务部署、shim 开发、DSH 配置、性能测试、日常运维与根因诊断。

    本文档合并自《DSH 接入本地模型完整教程》与《本地模型接入 DSH —— 诊断与解决方案》。

    实测环境:Ubuntu 22.04 · Intel CPU · 62GB RAM · AMD 7900 XTX (Vulkan, 25.75GB VRAM) · llama.cpp b11223 (Vulkan + MTP + ngram-k4v) · Node.js 24 · Qwen3.8-27B / Qwen3.6-27B (Q4_K_M)

    实测日期:2026-09-29(含同日 shim A/B 复核与 k4v 投机解码实测)


    目录

    1. 架构概览
    2. 环境准备
    3. 部署 llama.cpp 推理服务
    4. 开发工具调用 Shim
    5. 配置 DSH 接入
    6. 性能测试
    7. 日常运维
    8. 故障排查
    9. 诊断与实战记录
    10. 附录:文件清单

    本次更新(2026-09-30)

    • §9.8(重写)— CTX 从 256K 降到 128K 的根因:vkAllocateMemory 装不下 DEVICE_LOCAL,
      Vulkan 把权重退回 GTT,decode 掉到 0.49 tok/s。llama.sh 加 -cram -1。
      下界 64K(Hermes Agent 硬性要求 context >= 64000),上界 128K(实测 ~21.2GB/25.75GB 留余量)。
    • §1.1 / §3.1 / §3.2 / §5.1 / §9.4 — 3.8 的 ctx 全面改为 131072;overlay 的
      contextWindow: 131072 / maxTokens: 32768(原先声明 262144,是超限的直接来源)。
    • §3.1 内嵌代码块与 ~/llama-bin/llama.sh 逐行同步(含 systemd 自动检测、_USER_OVERRIDES 机制);
      §5.1 与 ~/llama-bin/dsh-shim-overlay.yml 同步。
    • 修正 §9.7 的锚点死链(#97-补充复核shim-ab-与多轮稳定性)。

    上次更新(2026-09-29)

    • §1.2 / §9.1 / §9.7 — shim 必要性复核:直连 :8080 现可正常完成工具调用,
      原「直连 0/10」复现不出来;coerce() 在 18 次强制采样中零次触发。结论改为
      「shim 仍默认启用,但不应再被当作不可替代的必需品」。
    • §6.3(新增)— 投机解码 k4v 实测:code_refactor +30.7%、合成重复 +42.3%,
      其余负载零退步。已默认启用,size-n 必须 32。
    • §3.1 / §5.1 — 内嵌代码块已与 ~/llama-bin/ 下真实文件重新同步。
    • §5.1 / §9.2 — overlay 默认模型由 3.6 改为 3.8。

    1. 架构概览

    1.1 三层架构

    
    DSH (Agent 框架)
      │  OpenAI 兼容请求 (含 tools 定义)
      ▼
    dsh-llm-shim (:8090, Node.js)
      │  tool_choice 改为 none,转发纯文本流
      ▼
    llama.cpp llama-server (Vulkan + MTP)
      :8080  Qwen3.8-27B  (ctx=131072)
      :8081  Qwen3.6-27B  (ctx=196608)  ← 两实例互斥
    

    1.2 为什么需要 Shim

    llama.cpp b11223 早期实测中自带的 tool-call 解析器(peg-native)解析不了 Qwen3.x 的 XML 工具调用:第二个参数起的整段被吞进第一个参数,并诱发模型无限重复 tool_call 标签直到 max_tokens(实测 32768 token / 7.7 分钟,零可用输出)。

    shim 的策略:

    1. 把 tool_choice 强制改为 none,llama.cpp 不做工具语法约束
    2. 模型原始 XML 完整出现在 message.content 里
    3. shim 自行把 XML 解析成标准 OpenAI tool_calls
    4. 检测到重复 tool_call 时立即掐断上游流
    方案 成功率 单次耗时
    直连 llama.cpp(2026-09-29 首次) 0/10 ~30s(跑飞)
    经 shim(同期) 10/10 ~1s

    ⚠ 2026-09-29 复核:直连已可用,shim 的必要性需重新评估

    同日按当前配置重新做 A/B,「直连 0/10」复现不出来:

    场景 结果
    简单工具直连 :8080 10/10,无跑飞
    DSH 真实复杂 schema(多工具)直连 2/2
    上述 + thinking 开启 2/2
    dsh headless 端到端(仅换 baseURL 端口) 66 文件 / ubuntu-ai,与经 shim 完全一致
    参数类型强制压测(7 字段全 required,含 number/boolean/array/object) 直连 12/12 类型全对、0 次字符串化;经 shim 6/6

    原结论只有三种可能:① build 或依赖已变;② 模型行为随采样/上下文漂移;③ 原基准本身有问题。
    本轮未能定位到确切原因。因此 shim 仍默认启用,但不应再被当作不可替代的必需品。

    另见 §9.7 对 coerce() 类型强转分支的专项测试。
    下线前的判断标准:切直连后连续跑真实 dsh 会话,观察是否出现工具参数校验失败。


    2. 环境准备

    2.1 硬件要求

    组件 最低要求 实测配置
    CPU 8 核 x86_64 Intel 多核
    RAM 32 GB 62 GB
    GPU Vulkan 1.3 AMD 25.75 GB VRAM
    磁盘 30 GB 可用 NVMe SSD

    显存估算:Qwen3.8-27B Q4_K_M 文本约 23.72 GB,加视觉 mmproj 约 24.9 GB。两个 27B 模型不能同时运行。

    2.2 软件依赖

    依赖 版本 用途
    Node.js >= 18 运行 shim(无第三方依赖)
    llama.cpp b11223 Vulkan 推理引擎
    Python >= 3.8 基准测试
    Vulkan 驱动 AMDGPU-Pro / Mesa GPU 加速

    2.3 模型文件

    
    ls ~/models/
    # Qwen3.8-27B-UD-Q4_K_M.gguf    (16.4GB)
    # Qwen3.6-27B-Q4_K_M-mtp.gguf
    # mmproj-model-bf16.gguf         (视觉投影,可选)
    

    3. 部署 llama.cpp 推理服务

    3.1 管理脚本 llama.sh

    存放到 ~/llama-bin/llama.sh:

    
    #!/bin/bash
    # 本地 LLM 服务统一入口 — llama.cpp Vulkan + MTP
    #
    #   ./llama.sh 38            启动 Qwen3.8-27B (:8080)(默认走 setsid 手动;systemd 托管时请用 systemctl --user start qwen38)
    #   ./llama.sh 36            启动 Qwen3.6-27B (:8081)(顺带拉起 :8090 shim)
    #   ./llama.sh stop          停止全部(38 自动走 systemctl --user stop qwen38,不会被自动拉回)
    #   ./llama.sh status        查看状态
    #   ./llama.sh 36 --use      停掉当前,换成 3.6
    #   ./llama.sh 38 --vision   带视觉启动(加载期开关,需重启)
    #   ./llama.sh 38 --fg       前台运行(给 systemd 用)
    #
    # 覆盖: CTX=196608 NMAX=2 UB=512 K4V=0 K4V_N=32 API_KEY=xxx MMPROJ=path LOG=path ./llama.sh 38
    # 注意: 下面初始化 CTX/NMAX 前必须先抓取环境值,否则 CTX="" 会让 ${CTX:-默认} 失效
    ENV_CTX="${CTX:-}"; ENV_NMAX="${NMAX:-}"; ENV_MMPROJ="${MMPROJ:-}"; ENV_EXTRA="${EXTRA:-}"
    set -uo pipefail
    
    BIN_DIR="${BIN_DIR:-$HOME/llama-bin/vulkan/llama-b11223}"
    BIN="$BIN_DIR/llama-server"
    SHIM="${SHIM:-$HOME/llama-bin/shim.sh}"   # 工具调用中间层(DSH 本地模型用)
    VRAM_DEV=/sys/class/drm/card0/device
    
    # 密钥:独立文件,不进脚本、不进 shell 环境
    ENV_FILE="${ENV_FILE:-$HOME/.config/qwen38/api.env}"
    [ -r "$ENV_FILE" ] && { set -a; . "$ENV_FILE"; set +a; }
    
    # profile: 别名|模型文件|ctx|nmax
    # 均为 qwen35 混合架构: 65 层但 full_attention_interval=4 → 仅 16 层真注意力,
    # 其余为 SSM 状态(常数大小, 与 ctx 无关). 训练上限均为 262144.
    #
    # 2026-09-30: CTX 从 262144 降到 131072。原注释说"多分配的 KV 不花钱"是错的——
    # 24GB 卡上 256K 会把模型+KV 顶到 ~26.3GB/24.5GB,vkAllocateMemory 装不下
    # DEVICE_LOCAL,Vulkan 后端把全部权重退回 GTT(系统内存)映射:实测
    # vis_vram_used=567MiB / gtt_used=23490MiB,decode 掉到 0.49 tok/s、prefill 30 t/s。
    # 「只有实际填满多少影响 decode」也不成立——溢出到 GTT 后每个 token 都要走 PCIe。
    #
    # 下界 64K:Hermes Agent 硬性要求 context >= 64000,低于此直接拒绝连接。
    # 上界实测:32K→17.4GB,128K→~21.2GB,256K→~26.3GB(爆)。128K 是留余量的最大值。
    # 需要更大窗口用 ENV_CTX 覆盖,但别超 ~160K。
    P38_ALIAS="qwen3.8-27b"; P38_MODEL="Qwen3.8-27B-UD-Q4_K_M.gguf";   P38_CTX=131072; P38_NMAX=3
    P36_ALIAS="qwen3.6-27b"; P36_MODEL="Qwen3.6-27B-Q4_K_M-mtp.gguf"; P36_CTX=196608; P36_NMAX=3
    P38_PORT="${P38_PORT:-8080}"
    P36_PORT="${P36_PORT:-8081}"
    MMPROJ_FULL="$HOME/models/mmproj-model-bf16.gguf"   # 仅 3.8 有效
    
    # 记下「用户自己设的」调优变量。必须在这里取:下面第 45 行起会把 CTX/NMAX 清空
    # 并给 UB/HOST/LOG/K4V 赋默认值,等到启动路径上再判断就分不清是用户设的还是默认了。
    _TUNABLE="CTX ENV_CTX UB B NMAX ENV_NMAX K4V K4V_N K4V_M K4V_HITS CRAM EXTRA ENV_EXTRA TEMP TOPK CKV CV LOG HOST MMPROJ"
    _USER_OVERRIDES=""
    for _v in $_TUNABLE; do [ -n "${!_v-}" ] && _USER_OVERRIDES="$_USER_OVERRIDES $_v"; done
    
    KEY=""; ALIAS=""; MODEL=""; CTX=""; NMAX=""; PORT=""; NAME=""
    case "${1:-}" in
        38|qwen38) KEY=38; NAME="Qwen3.8-27B"; ALIAS=$P38_ALIAS; MODEL=$P38_MODEL; CTX="${ENV_CTX:-$P38_CTX}"; NMAX="${ENV_NMAX:-$P38_NMAX}"; PORT=$P38_PORT ;;
        36|qwen36) KEY=36; NAME="Qwen3.6-27B"; ALIAS=$P36_ALIAS; MODEL=$P36_MODEL; CTX="${ENV_CTX:-$P36_CTX}"; NMAX="${ENV_NMAX:-$P36_NMAX}"; PORT=$P36_PORT ;;
    esac
    
    MODEL_PATH=""; [ -n "$MODEL" ] && MODEL_PATH="$HOME/models/$MODEL"
    API_KEY="${API_KEY:-${QWEN_API_KEY:-}}"
    HOST="${HOST:-0.0.0.0}"
    
    # 只匹配本模型的进程;用 alias 定位,避免 pkill -f "llama-server -m"
    # 连带杀掉另一个模型的实例
    _running() { pgrep -f -- "--alias $1" 2>/dev/null; }
    # 有 systemd 用户服务托管的模型(38 → qwen38.service),stop 必须走 systemctl:
    # 否则 pkill 造成的"非正常退出"会被 Restart=on-failure 在 20s 后拉回来
    _svc_of() { case "$1" in "$P38_ALIAS") echo qwen38.service ;; esac; }
    # 注意 kind 必须是 shell 变量;写成 awk 的 -v 变量时 shell 展开不到
    _vram() { local kind="$1"; awk '{printf "%.2f", $1/1e9}' "$VRAM_DEV/mem_info_vram_$kind" 2>/dev/null; }
    
    stop() {
        local n=0 a svc
        for a in "$P38_ALIAS" "$P36_ALIAS"; do
            if _running "$a" >/dev/null; then
                svc=$(_svc_of "$a")
                if [ -n "$svc" ] && systemctl --user is-active --quiet "$svc" 2>/dev/null; then
                    # systemd 接管中:受控停止,避免被 Restart 拉回;等进程真正退出
                    systemctl --user stop "$svc" 2>/dev/null
                    for _ in $(seq 1 45); do _running "$a" >/dev/null || break; sleep 1; done
                else
                    pkill -f -- "--alias $a" 2>/dev/null
                    for _ in $(seq 1 45); do _running "$a" >/dev/null || break; sleep 1; done
                    pkill -9 -f -- "--alias $a" 2>/dev/null
                fi
                n=$((n+1))
            fi
        done
        [ "$n" -gt 0 ] && echo "已停止 $n 个实例" || echo "未在运行"
        # 模型都停了,工具调用中间层留着也没用;交给 shim.sh 用 PID 精确停,
        # 避免裸 pgrep/pkill 文件名误杀其它提到该文件的进程。
        if [ -x "$SHIM" ]; then
            _out=$("$SHIM" stop 2>&1)
            case "$_out" in *已停止*) echo "$_out" ;; esac
        fi
    }
    
    status() {
        printf '%-10s %-7s %-6s %-11s %s\n' 模型 端口 状态 显存 模型文件
        for spec in "Qwen3.8-27B|$P38_ALIAS|$P38_PORT|$P38_MODEL" "Qwen3.6-27B|$P36_ALIAS|$P36_PORT|$P36_MODEL"; do
            IFS='|' read -r name a p m <<<"$spec"
            if _running "$a" >/dev/null; then
                printf '%-10s %-7s %-6s %-11s %s\n' "$name" ":$p" "运行" "$(_vram used) GB" "$m"
            elif curl -fsS --max-time 2 "http://127.0.0.1:$p/health" >/dev/null 2>&1; then
                # 进程看不见但端口在服务(例如从别的命名空间/终端起来的)也算运行
                printf '%-10s %-7s %-6s %-11s %s\n' "$name" ":$p" "运行" "-" "$m"
            else
                printf '%-10s %-7s %-6s %-11s %s\n' "$name" ":$p" "停止" "-" "$m"
            fi
        done
        # 以端口健康为准:shim 可能由别的终端/会话拉起,进程看不到但确实在服务
        if curl -fsS --max-time 2 "http://127.0.0.1:8090/health" >/dev/null 2>&1; then
            _shim_state=运行; else _shim_state=停止; fi
        printf '%-10s %-7s %-6s %-11s %s\n' "shim" ":8090" "$_shim_state" "-" "dsh-llm-shim.mjs"
        echo "总显存: $(_vram used) / $(_vram total) GB"
    }
    
    if [ -z "$KEY" ]; then
        # 裸命令(函数已定义,可安全调用):stop/status,其余按 status 兜底
        case "${1:-}" in
            stop) stop; exit 0 ;;
            status) status; exit 0 ;;
            *) status; exit 0 ;;
        esac
    fi
    
    MMPROJ=""
    for a in "$@"; do
        case "$a" in
            --vision) MMPROJ="$MMPROJ_FULL" ;;
            --stop) stop; exit 0 ;;
            --status) status; exit 0 ;;
            --restart|--use)
                for a2 in "$@"; do
                    case "$a2" in 36|qwen36) NEXT=36 ;; esac
                done
                stop; sleep 2
                case "${NEXT:-}" in 36) exec "$0" 36 --vision ;; *) exec "$0" 38 ;; esac ;;
        esac
    done
    
    [ -f "$MODEL_PATH" ] || { echo "模型缺失: $MODEL_PATH" >&2; exit 1; }
    [ -x "$BIN" ] || { echo "二进制缺失: $BIN" >&2; exit 1; }
    
    if _running "$ALIAS" >/dev/null; then
        echo "$NAME 已在 :$PORT 运行。要重启先 stop,或用 ./llama.sh $KEY --use" >&2
        exit 1
    fi
    # 另一个模型占着显存就停掉,否则必然 OOM
    if _running "$P38_ALIAS" >/dev/null || _running "$P36_ALIAS" >/dev/null; then
        echo "提示: 另一模型正在运行,将先停止(两者不能共存于 25.75GB 显存)"
        stop; sleep 2
    fi
    [ -n "$MMPROJ" ] && [ ! -f "$MMPROJ" ] && { echo "mmproj 缺失,关闭视觉: $MMPROJ" >&2; MMPROJ=""; }
    # UB 默认 512:llama-bench 上 pp32768 有 399→414 t/s(+3.7%) 的提升,
    # 但服务端实测 decode 63.7→59.3 t/s(−7%)、显存 23.64→24.83 GB(96%),
    # 且触发 "failed to fit params to free device memory"(-ngl 999 写死无法自动收缩)。
    # prefill 的收益抵不上 decode 的损失,故保持 512。保留变量以便复测。
    UB="${UB:-512}"; B="${B:-$((UB * 4))}"
    # 实测: ub2048 纯文本 @256K 投影 22924 MiB, 视觉 mmproj 再 +1.19GB → 约 24.6GB / 25.75GB 极限
    if [ -n "$MMPROJ" ] && [ "$CTX" -gt 196608 ] && [ "$UB" -gt 512 ]; then
        echo "⚠ ctx=$CTX + 视觉 + ub=$UB 会逼近 25.75GB 上限;建议 CTX=196608 或 UB=512" >&2
    fi
    
    # 投机解码: MTP 自投机 + n-gram 查表叠加(k4v)。
    # 3.8 实测(temp=0, 每项 2 轮取中位数, n_gen 两配置完全一致故无输出长度混淆):
    #   工作负载              draft-mtp   +k4v(n=32)     变化
    #   code_refactor             86.1        112.6     +30.7%  ← dsh 改文件典型负载
    #   repeat_synth              86.0        122.4     +42.3%
    #   template_batch            47.2         46.7      -1.1%  (噪音内)
    #   reasoning                 72.9         72.1      -1.1%  (噪音内)
    #   prose                     47.2         46.7      -1.1%  (噪音内)
    # 零 VRAM 成本, 零重编。n=32 是关键: n=12 在「格式重复但内容要换」的任务上倒扣
    # 5~10%(n-gram 一直开火猜错时间轴), n=32 把所有负项收进 ±1.3% 噪音内。
    # 原理: k4v 从已生成上下文做 n-gram 查表, 命中一次吐最多 48 token; 没命中退回 MTP。
    # 来源: https://lcz.me/topic/1398 §二/§三(该文在 7900XTX Vulkan 上交叉验证过)
    K4V="${K4V:-1}"
    SPEC_TYPE="draft-mtp"; K4V_ARGS=()
    if [ "$K4V" = "1" ]; then
        SPEC_TYPE="ngram-map-k4v,draft-mtp"
        K4V_ARGS=( --spec-ngram-map-k4v-size-n "${K4V_N:-32}"
                   --spec-ngram-map-k4v-size-m "${K4V_M:-48}"
                   --spec-ngram-map-k4v-min-hits "${K4V_HITS:-1}" )
    fi
    
    ARGS=( -m "$MODEL_PATH" -c "$CTX" -ngl 999 -fa 1
           -ub "$UB" -b "$B" --reasoning off --parallel 1
           -cram "${CRAM:--1}"
           --host "$HOST" --port "$PORT" --alias "$ALIAS"
           --spec-type "$SPEC_TYPE" --spec-draft-n-max "$NMAX" "${K4V_ARGS[@]}" )
    # KV 精度与采样:草稿与目标共享 context,V cache 量化误差会压低接受率。
    # 3.6 实测(reps=3, ctx196608, n-max4): 0.7/20 → 67.33 tok/s 接受0.544
    #                                      0.3/5  → 68.70 tok/s 接受0.560
    # 3.6 最佳: n-max3 + 0.3/5 → 69.54 tok/s 接受0.657 (V=q4_0 最优; q8_0/f16 反而更差更慢)
    # 3.8 保持 0.7/20 —— 其 83.30 tok/s 是在该采样下实测的
    if [ "$KEY" = "36" ]; then
        ARGS+=( -ctk "${CKV:-q8_0}" -ctv "${CV:-q4_0}"
                --temp "${TEMP:-0.3}" --top-k "${TOPK:-5}" )
    else
        ARGS+=( -ctk "${CKV:-q4_0}" -ctv "${CV:-q4_0}"
                --temp "${TEMP:-0.7}" --top-k "${TOPK:-20}" )
    fi
    # EXTRA 透传调优参数,例如 EXTRA="--spec-draft-p-min 0.2"
    [ -n "$ENV_EXTRA" ] && ARGS+=( $ENV_EXTRA )
    [ -n "$MMPROJ" ] && ARGS+=(--mmproj "$MMPROJ")
    # 关闭服务端认证: 配合 opencode 里 apiKey="" 使用,不传 --api-key
    LOG="${LOG:-/tmp/qwen${KEY}-vulkan.log}"
    
    echo "model  : $NAME  ($MODEL)"
    echo "config : ctx=$CTX  n-max=$NMAX  ub=$UB/b=$B  视觉=$([ -n "$MMPROJ" ] && echo 开 || echo 关)  认证=无"
    echo "listen : $HOST:$PORT   log: $LOG"
    
    for a in "$@"; do
        if [ "$a" = "--fg" ]; then
            # systemd 用的就是 --fg:exec 之后脚本就结束了,所以 shim 必须在这里起,
            # 否则「起模型就自动带 shim」在 --fg 下不成立。start 是幂等的。
            [ -x "$SHIM" ] && "$SHIM" start
            exec "$BIN" "${ARGS[@]}"
        fi
    done
    
    # 有 systemd 单元托管时一律交给 systemctl:手动 setsid 起的进程不受
    # Restart=on-failure 保护,还会占住端口让单元以 exit-code 1 反复 crash-loop
    # (2026-09-30 22:10 就是这么把 qwen38 顶掉的)。
    # systemctl 不继承当前 shell 的环境,所以存在调优变量或 --vision 时退回直起,
    # 否则 `CTX=163840 ./llama.sh 38` 会被静默吞掉。
    VIA=direct
    _SVC=$(_svc_of "$ALIAS")
    if [ -n "$_SVC" ] && systemctl --user cat "$_SVC" >/dev/null 2>&1; then
        if [ -n "$_USER_OVERRIDES" ] || [ -n "$MMPROJ" ]; then
            echo "⚠ 有覆盖[${_USER_OVERRIDES:- --vision}] systemd 不继承 → 直起(无 Restart 保护)" >&2
        else
            VIA=systemd
        fi
    fi
    
    PROBE=127.0.0.1; [ "$HOST" = "0.0.0.0" ] || PROBE="$HOST"
    _fail_hint() { [ "$VIA" = systemd ] && echo "journalctl --user -u $_SVC -n 50" || echo "$LOG"; }
    
    if [ "$VIA" = systemd ]; then
        echo "→ systemctl --user start $_SVC"
        systemctl --user start "$_SVC"
    else
        setsid nohup "$BIN" "${ARGS[@]}" </dev/null >"$LOG" 2>&1 & disown
    fi
    
    printf 'loading'
    for _ in $(seq 1 90); do
        sleep 4
        curl -fsS --max-time 3 "http://$PROBE:$PORT/health" 2>/dev/null | grep -q '"ok"' && break
        _running "$ALIAS" >/dev/null || { echo; echo "启动失败,看: $(_fail_hint)" >&2; exit 1; }
        printf '.'
    done
    
    if curl -fsS --max-time 3 "http://$PROBE:$PORT/health" 2>/dev/null | grep -q '"ok"'; then
        echo; echo "就绪"
        awk -v u="$(_vram used)" -v t="$(_vram total)" \
            'BEGIN{printf "显存  : %s / %s GB (%.0f%%)\n", u, t, t?100*u/t:0}'
        LAN=$(ip route get 1.1.1.1 2>/dev/null | grep -oE 'src [0-9.]+' | cut -d' ' -f2)
        [ -n "$LAN" ] && echo "局域网: http://$LAN:$PORT/v1   (模型名 $ALIAS)"
        [ "$VIA" = direct ] && { grep -q "nextn.*ignoring" "$LOG" 2>/dev/null && \
            echo "⚠ 日志显示 MTP 头被忽略,检查 --spec-type 是否生效" >&2; }
        # 工具调用中间层:llama.cpp b11223 自带的 tool-call 解析器解析不了 Qwen3.x 的 XML
        # 工具调用(参数被吞 + 诱发无限重复),改由 shim 代为解析。详见
        # ~/桌面/本地模型接入DSH-诊断.md。shim 已在跑时 start 是幂等的。
        if [ -x "$SHIM" ]; then
            "$SHIM" start
        else
            echo "⚠ 缺少 $SHIM,DSH 本地模型会拿不到工具调用" >&2
        fi
    else
        echo; echo "超时,看 $(_fail_hint)" >&2; exit 1
    fi
    

    3.2 使用说明

    cd ~/llama-bin
    
    ./llama.sh 38          # 启动 Qwen3.8-27B (:8080)
    ./llama.sh 36          # 启动 Qwen3.6-27B (:8081)
    ./llama.sh status      # 查看状态
    ./llama.sh stop        # 停止所有
    ./llama.sh 36 --use   # 切换到 3.6(先停当前再起 3.6)
    ./llama.sh 38 --vision # 带视觉启动
    ./llama.sh 38 --fg     # 前台运行(systemd 用)
    

    关键参数说明:

    • -ngl 999 -fa 1:全部层 offload 到 GPU
    • -c 131072:默认 ctx 128K,不是训练上限 262144。256K 会把模型+KV 顶到 ~26.3GB/25.75GB,
      Vulkan 装不下 DEVICE_LOCAL 而把权重退回 GTT(系统内存)映射,decode 掉到 0.49 tok/s。
      下界 64K(Hermes Agent 硬性要求 context >= 64000)。
      需要更大窗口用 CTX=65536 ./llama.sh 38 覆盖,但每次加大都要重新核对显存(详见 §9.8)
    • -cram -1:KV cache 放内存而非显存。25.75GB 卡上省下的正是权重能留在 DEVICE_LOCAL 的那点余量
    • --spec-type ngram-map-k4v,draft-mtp --spec-draft-n-max 3:投机解码。ngram-map-k4v 不可省略,详见 §6.3
    • --spec-ngram-map-k4v-size-n 32:n-gram 查表长度,必须 32。取 12 会在「格式重复但内容要换」的任务上倒扣 5~10%
    • -ctk q4_0 -ctv q4_0:KV cache 量化(只换容量不换速度)
    • --parallel 1:单并发(本地推理推荐)
    • --reasoning off:关闭推理模式(Qwen3.x)
    • systemd 自动检测:有 qwen38.service 单元时,无覆盖变量的启动自动走 systemctl --user start;
      有 CTX/UB/K4V 等覆盖或 --vision 时退回直起(systemd 不继承 shell 环境),并打印警告

    常见误用:./llama.sh use 36 不是有效命令。切换模型用 ./llama.sh 36 --use,
    或直接 ./llama.sh stop && ./llama.sh 36。在 32GB 以外的显存预算下两个 27B 无法共存,
    脚本会自动先停另一个。


  • 登录

  • 登录或注册以进行搜索。
  • 第一个帖子
    最后一个帖子
0
  • 版块
  • 最新
  • 标签
  • 热门
  • 用户
  • 群组