在文章《Ultra X7 358H 笔记本电脑,寻找本地用于编码的最强大模型,实测 21.0 tok/s,上下文 256K》中, 本地部署 Qwen3.6-35B-A3B 之后,当时 AI 测试和我自己的简单测试,都把测试结果指向了一个值:20+ tok/s。
后来,在文章《Ultra X7 358H 笔记本电脑,本地三个大模型接入 DeepSeek Harness 桌面版的过程与方法》中, 我又把本地部署的大模型接入了 Deepseek Harness Desktop。
于是,我便用 Deepseek Harness Desktop + Qwen3.6-35B-A3B,准备让它来分析一个实际的博客项目,分析项目完毕,Deepseek Harness Desktop 显示的输出速度,才 13 tok/s!
这着实让我火热的心凉了一大截 :)
老办法,出了问题,有不理解的地方,问 AI。
下面是 AI(Deepseek Harness Desktop + Deepseek V4.1 Flash)对这个问题的回复:
上下文深度对生成速度的影响:实测说明
结论:23~25 tok/s 只在”浅上下文”下成立,它是最好情况而非常态。
在真实 agentic 会话里,每加深 1K 上下文约损失 0.39 tok/s(约 1.9%);
到 20~30K 深度 时解码速率降到 11~16 tok/s,界面显示(含预填与工具等待)更低至 5~7 折。这不是故障、不是配置错误、不是换页、也不是机器变热——代价落在逐 token 的解码阶段:
解码时每生成一个 token 都要对已累积的全部上下文做注意力,而预填是批量处理的、几乎不受深度影响。
同一批测试里预填速率始终稳定在 297~331 tok/s,与深度无关,这一条同时排除了换页和配置问题。⚠️ 但要避免一个想当然的解释:这个代价不能归因于”KV 搬多了”——
按实测有效带宽算账,KV 字节搬运只占额外耗时的约 1%(见 4.2 节)。
- 记录日期:2026-10-07
- 机器:Intel Core Ultra X7 358H / Intel Arc B390 iGPU(SYCL,无独显)/ 31.55 GiB 板载内存
- 模型:Qwen3.6-35B-A3B(UD-Q3_K_M,15.46 GiB,MoE 激活约 3B)
- 服务:
llama-server-ngl 99 -c 262144 -np 1 --cache-type-k q8_0 --cache-type-v q8_0 --jinja,端口 8081 - 触发本调查的现象:在 DSH 里用它分析项目,实测只有 13 tok/s,而此前验证值是 23 tok/s
问题
同一个模型、同一台机器、同一个服务,出现了两个相差近一倍的速度数字:
| 来源 | 数值 | 场景 |
|---|---|---|
| 此前受控测试 | 23~25 tok/s | 空上下文、单轮短问答 |
| 用户在 DSH 里的实测 | 13 tok/s | agentic 分析项目、多轮工具调用 |
需要回答三个问题:
- 13 是不是测量错误或被别的东西拖累?(比如 DSH 把预填时间算进去了)
- 23~25 是不是过于乐观的”最好情况”?
- 如果是深度造成的,代价有多大、能否避免?
分析过程
先排除服务与配置问题
/props 与 /slots 显示:n_ctx = 262144、is_processing = false、state 空闲,服务健康。
命令行参数与预期完全一致(-c 262144 -np 1、KV q8_0、--alias qwen36)。
→ 配置和内存分配都没问题,且 /props 的 n_ctx 证明 256K 窗口确实生效了。
从 DSH 会话日志里取真实数据
会话日志是 append-only 的多帧 zstd:
~\.dsh\sessions\--D-sites-bigfoxme--\session-7a02256b-...\session.v4.jsonl.zstd踩了一个坑:zstdDecompressSync 和流式 API 都只解出第一个 frame(80811 B 里只得到 199 B)。
解法是按 zstd 魔数 28 B5 2F FD 切帧后逐帧解压 → 79 帧 → 149 条记录。
日志里拿到了三个关键事实:
| 记录 | 内容 | 含义 |
|---|---|---|
request/context | {"contextWindow":262144} | 256K 声明已生效 |
request/header | maxTokens: 8192,route local-qwen36 | 走的就是本地路由 |
assistant/message ×18 | 每步 usage,cacheReadTokens 最高 26391 | 真实上下文深度 ≈ 26.4K |
另外两点值得记录:
- 18 条助手消息全部带 reasoning,而日志里
enable_thinking出现 0 次。 说明 pi-ai 适配器没有传递该参数的能力(它的采样字段只有 temperature/maxTokens/stop), DSH 里思考始终是开着的。不过该会话的推理都很短(42~480 字符),不是速度主因。 - 第 1 步撞上
503 Loading model并重试 3 次——模型还在加载就开始提问。
第一次算速率算错了
我先用 输出token ÷ 整步耗时 算,得到 2.26~11.42 tok/s。但这个指标是错的,因为记录顺序是:
step/start → assistant/message → tool/call → tool/result → step/endstep/end 出现在 工具执行之后,所以整步耗时里混进了读文件、跑命令的时间。必须先剔除。
意外发现:会话里已经存了逐 token 的时间戳
DSH 的流记录里保存了 token 级到达间隔:
{"type":"text-chunks","time0":1791517980564,"index":1, "dt":[60,62,63,64,61,66,62,62,61], "texts":["好的",",","让我","先","了解","项目","整体","结构","。","\n\n"]}dt 就是每个 token 的毫秒间隔(首个 407 ms 是首字延迟)。稳态约 60~63 ms/token = 16.4 tok/s——
这是最直接的证据,不需要任何推算。
同时按时间线把每步拆成三段:
| 阶段 | 平均耗时 |
|---|---|
预填(step/start → 首个 chunk) | 6.8 s |
| 模型期(首 chunk → 末 chunk) | 15.2 s |
工具执行 + 其他(末 chunk → step/end) | 4.5 s |
于是得到三个层次的数字,对应不同的问法:
| 口径 | 数值 | 说明 |
|---|---|---|
纯解码(逐 token dt) | 16.4 tok/s | 模型真实生成速度 |
| 模型期整体 | 11.93 tok/s | 含 block 切换(推理→正文→工具调用)的间隙 |
| 输出 ÷ 整步耗时 | 7.13 tok/s | 含工具执行与预填 |
→ 用户看到的 13 落在”模型期整体”这一档,是真实的、可解释的。
活体服务上的受控深度扫描
为把”深度”从其他因素里分离出来,在正在运行的服务上做扫描(不加载模型、不重启),
读服务端自己返回的 timings.predicted_per_second(权威解码速率):
- 请求体写入 JSON 文件后用
curl --data-binary @file发送(避免 PowerShell 吃掉引号) - 填充文本由同一句话重复构成,深度递增 → 后续请求能复用 KV 前缀缓存
- 每点生成 62~221 token,样本量足够
测试结果
深度曲线(同一服务、连续测量)
| 有效上下文 | 解码速率 | 每 token | 相对浅深度 |
|---|---|---|---|
| ~26 token | 21.00 tok/s | 47.6 ms | — |
| ~3.3K | 19.74 tok/s | 50.7 ms | −6% |
| ~6.6K | 19.31 tok/s | 51.8 ms | −8% |
| ~13.2K | 15.91 tok/s | 62.9 ms | −24% |
| ~17.3K | 14.24 tok/s | 70.2 ms | −32% |
| 21K(另测,400 token 样本) | 10.72 tok/s | 93.3 ms | −49% |
拟合出的经验关系(量级参考,非精确公式):
解码速率 ≈ 21.0 − 0.00039 × 上下文token数 (每 1K 深度约 −0.39 tok/s,−1.9%)关键对照:预填完全不受深度影响
同一个 21K 深度的请求里,两项指标对比:
| 阶段 | 21K 深度 | 浅深度 | 是否随深度退化 |
|---|---|---|---|
预填 prompt_per_second | 331.0 tok/s | 339 tok/s(llama-bench pp4096) | ❌ 基本不变 |
解码 predicted_per_second | 10.72 tok/s | 24.32 tok/s | ✅ 掉一半 |
整个扫描区间内预填稳定在 297~331 tok/s。这一条是整个调查里最有价值的证据, 因为它一次性排除了两个竞争解释:
- 排除换页:权重被换出页面会同时拖垮预填与解码,而预填纹丝不动。
- 排除配置/量化问题:两者用的是同一套参数、同一块 KV。
排除”机器变热/变慢”
我做了重活(21K 预填 + 多次长生成)之后立刻复测浅深度:
| 时刻 | 浅深度解码 |
|---|---|
| 重活之前 | 20.5 / 21.4 tok/s |
| 重活之后 | 24.32 tok/s(111 token 样本) |
随后用 bench_running_server.py 复测 | 23.17 tok/s(min 21.98 / max 24.36) |
浅深度稳定回到 23~24,所以不是热降频、也不是”跑久了变慢”。
顺带发现:前缀”形状”变化要付一次重预填
bench_running_server.py 的第一次请求首字延迟 17.29 s,第二次只有 0.23 s。
原因是从上一个 21K 填充前缀切换到新提示时,KV 缓存前缀不匹配、需要重新预填。
这解释了真实会话里 平均 6.8 s 的预填耗时:agent 每一步的提示都在变化, 一旦工具结果插在历史中段或发生压缩,前缀就分叉,重预填代价立刻出现。
交叉验证:另一个模型的独立测量
同一台机器上,dense 27B(Qwen3.8-27B)由另一次实验测得:
| 深度 | 生成速率 |
|---|---|
| d=0 | 5.90 tok/s |
| d=8192 | 5.03 tok/s(−15%) |
| d=16384 | 4.26 tok/s(−28%) |
两个不同架构的模型、两次独立测量,16~17K 深度处的衰减量级一致(−28% vs −32%), 说明这是通用现象而非某个模型的怪癖。
结论
两个数字都对,只是测的不是同一件事
| 数值 | 成立条件 |
|---|---|
| 23~25 tok/s | 空/浅上下文(<5K)、单轮短问答、无工具调用 —— 最好情况 |
| 13 tok/s | 26K 上下文、agentic 多轮、每步含预填与工具等待 —— 真实会话的常态 |
机制:代价定位在”逐 token 的注意力”,具体成因未确立
已经确定的(实测):代价只出现在解码阶段,预填几乎不受影响。 这一条排除了所有”与阶段无关”的解释——如果是权重被换出页面,预填会同步变慢,而它没有。
一个反直觉的算账(说明不能用”KV 搬运量”解释):
| 项 | 数值 |
|---|---|
| 浅深度每 token 耗时 | 41.1 ms |
| 权重读取 15.46 GiB ÷ 41.1 ms → 有效带宽 | ≈ 404 GB/s |
| 21K 深度每 token 需读的 KV | 21K × 10.6 KiB ≈ 222 MiB |
| 这些 KV 按 404 GB/s 搬运的理论耗时 | ≈ 0.6 ms |
| 21K 深度每 token 的实际额外耗时 | 52.2 ms |
KV 字节搬运只解释了额外耗时的约 1%。所以不能说”解码是内存带宽瓶颈”—— 同理,它也不是”上下文越长、要读的数据越多”这种简单模型。
因此更可能的机制(推断,未单独验证):长上下文下注意力算子本身的效率问题。
本模型的注意力配置是 2 个 KV 头、head_dim 256、且只有 10/40 层有注意力,
长序列下算术密度低、访存模式分散(KV 工作集远超 iGPU 末级缓存),
实际表现可能远低于算力上界。另外该服务的启动参数未显式指定 -fa
(flash attention 走 auto),是否影响长序列注意力效率也未验证。
这些只是候选解释,我没有单独验证。 本报告确证的是现象与其量级,不是内核层面的成因—— 这也意味着”换个后端/打开 flash attention 能不能改善”是一个尚未验证的可行方向,值得单独实验。
排除的解释
| 候选解释 | 判定 | 依据 |
|---|---|---|
| 换页 / 显存不足 | ❌ 排除 | 预填在 21K 深度仍是 331 tok/s |
| 机器变热 / 跑久变慢 | ❌ 排除 | 重活后浅深度回到 24.32 tok/s |
| 配置或量化错误 | ❌ 排除 | 参数与预期一致,KV 已是 q8_0 |
| DSH 测量算错 | ❌ 不成立 | 13 与逐 token dt(16.4)和模型期(11.93)自洽:显示值已经排除了预填与工具等待。若把工具时间也算进去只有 7.1 |
| 思考模式开着 | ⚠️ 次要 | 确实始终开着(适配器无法传 enable_thinking),但该会话推理仅 42~480 字符 |
| 模型加载未完成 | ⚠️ 仅第一步 | 第 1 步 503 Loading model 并重试 3 次,只影响首轮 |
| 上下文深度 | ✅ 主因 | 单一变量扫描:21.00 → 14.24 tok/s(0→17.3K) |
该期待多少
| 会话上下文 | 纯解码(服务端 predicted_per_second) | DSH 界面显示(≈模型期,已排除预填与工具) | 若自己算”输出÷整步”(含工具) |
|---|---|---|---|
| 空 ~ 5K | 20 ~ 24 tok/s | 16 ~ 19 | 10 ~ 14 |
| 10 ~ 15K | 15 ~ 17 tok/s | 12 ~ 13 | 7 ~ 9 |
| 20 ~ 30K | 11 ~ 16 tok/s | 9 ~ 13(该会话实测 13) | 6 ~ 8(该会话实测 7.13) |
换算系数来自那个会话的实测分解:纯解码 16.4 → 模型期 11.93(×0.73)→ 输出÷整步 7.13(×0.43)。 三个数字都对,问的是不同的东西——讨论速度时务必先说明用哪个口径。
一处诚实的不一致
曲线外推 26K 应得约 11 tok/s,而真实会话在 26K 处实测 16.4 tok/s(高约 1.5 倍)。 两者方向一致但绝对值有差距,原因未查明。可能因素:填充文本高度重复、 两次测量的机器状态不同、真实提示含工具 schema 结构不同。
→ 因此上面的曲线应作为量级参考,而不是精确公式。
能做什么
| 手段 | 有效性 | 说明 |
|---|---|---|
| 控制会话长度(新任务开新会话;长了就压缩) | ✅ 唯一真正有效 | 深度是主因,26K 会话比 5K 会话慢约一倍 |
| 换更省的 KV 量化 | ❌ 无意义 | 已经是 q8_0,且预填证明不是内存问题 |
| 调采样参数 / 换量化档位 | ❌ 无意义 | 与逐 token 的 KV 读取成本无关 |
| 关掉思考模式 | ⚠️ 有限 | DSH 侧无接口可传,且该会话推理本来就很短 |
把服务端 -c 从 262144 降到 65536 | ⚠️ 仅省内存 | 释放约 2 GiB KV(256K 是 2720 MiB,64K 只要 680 MiB),但不能提速解码;且必须同时下调配置里的 contextWindow,而压缩公式在 W < 约 73K 时的行为尚未验证 |
| 等模型加载完成再开始提问 | ✅ 避免首轮浪费 | 该会话第 1 步因此重试 3 次 |
方法学教训(可复用)
- 不要用”输出 token ÷ 整步耗时”衡量模型速度。 在 agent 框架里,这个区间包含工具执行。
要么用逐 token 间隔(
dt),要么用服务端的predicted_per_second。 - 小提示的
prompt_ms不能当预填速率。 15 个 token 的预填里,固定开销占大头 (实测 69.7 ms/token vs 19 个 token 时的 24.8 ms/token)。要测预填必须给足够长的提示。 - 读多帧 zstd 日志要按魔数切帧,
zstdDecompressSync与流式 API 都可能只解第一帧 (本次 80811 B 只解出 199 B)。 - 调用 llama-server 时把 JSON 写进文件再
curl --data-binary @file。 PowerShell 的--data-raw $body会吃掉 JSON 里的双引号,表现为服务端返回parse error at line 1, column 2('{m'),极易被误判成服务端故障。 - 区分”能加载”与”能跑得快”:
/props报告的n_ctx只说明窗口分配成功, 与解码速率无关。 RemoteDisconnected先查进程是否被重启(本次服务在 11:50:07 被换过 PID), 不要一上来就怀疑请求格式。
附:复现方法
深度扫描(不加载模型,对满载机器安全):
# 1) 生成不同深度的请求体(填充文本用同一句重复,便于前缀缓存复用)# 每份 body 只改填充长度,问题固定# 2) 依次发送,取服务端 timingscurl.exe -s -N -X POST http://127.0.0.1:8081/v1/chat/completions ` -H "Content-Type: application/json" --data-binary "@$env:TEMP\sw_16384.json"# 关注返回里的:prompt_per_second(预填)、predicted_per_second(解码)、cache_n(命中缓存)浅深度稳态(已在工作区,可直接用):
D:\AI_Tools\unsloth\run.cmd scripts\bench_running_server.py 8081 repeat 3读会话日志里的逐 token 间隔:按 zstd 魔数 28 B5 2F FD 切帧逐帧解压
~\.dsh\sessions\<工作区>\<会话id>\session.v4.jsonl.zstd,
然后取 assistant/message.data.stream 里 type == "text-chunks" 的 dt 数组。
附:数据来源与可信度
| 数据 | 来源 | 可信度 |
|---|---|---|
| 深度曲线(5 点)、填充/解码对比 | 活体服务 /v1/chat/completions 返回的 timings | 实测(可复现) |
| 21K 深度 10.72 tok/s | 同上,400 token 样本 | 实测 |
| 会话深度 26.4K、逐 token 60~63 ms | 用户会话日志的 usage.cacheReadTokens 与 dt 数组 | 实测(原始记录) |
| 浅深度 24.32 / 23.17 tok/s | 同一服务复测 + bench_running_server.py | 实测 |
| 预填在深度上不变(297~331 tok/s) | 同上 | 实测 |
| dense 27B 的 −28% | 本工作区既有实验(见 显存与内存实测报告.md、Qwen3.6-35B-A3B-编码部署报告.md) | 既有实测 |
| 代价只落在解码阶段、预填不受影响 | 实测对照(21K 深度:预填 331 / 解码 10.7) | 实测 |
| 具体成因(算子效率 / 缓存未命中 / 未开 FA) | 候选解释;已算账排除”KV 字节搬运”(仅占约 1%) | 推断,未单独验证 |
| 曲线外推与真实会话 1.5 倍差异的原因 | 未查明 | 未知 |
这篇文章是否对你有帮助?
发现错误或想要改进这篇文章?
在 GitHub 上编辑此页