dsh-stt-qwen — 本地千问语音识别器
给 DSH 的「语音输入」插件换一个识别器:不再用内置的 SenseVoice,而是把麦克风录音交给本机跑的 Qwen3-ASR-0.6B(CPU 推理),全程 127.0.0.1,录音不出网。
为什么是插件,而不是改官方包
官方 @deepseek-ai/dsh-experimental-speech-to-text 本身就是一个 Provider 注册表:
ctx.speechToText.register({
info: {
id, name, location: 'host-local' | 'cloud', languages,
// 面板用来提示「要占多少磁盘、大概等多久」;本包不替用户下载模型,所以不报 downloadSources
setupEstimate: { recommendedDiskBytes, minimumMinutes, maximumMinutes },
},
// 可选:语音面板靠它显示「准备好了没」,并允许用户手动催一次加载
preparation: { snapshot(), subscribe(listener), prepare(), cancel() },
transcribe({ audio, language }, signal) => { text, audioSeconds, inferenceSeconds },
})
官方 voice-input-bundle 只内置了一个 sensevoice-local,并在自己的 README 里写明:「额外 Provider 使用不同 id 注册到同一服务;云端识别需要显式增加 Provider 与凭据配置」。所以本包走的就是官方留的扩展点,不动官方一行代码。
面板上的「可用 / 加载中 / 不可用」是插件在报,不是猜的
官方内置的识别器恒报 ready,本包不能这么干:权重是 1.7 GB、首次加载要数秒到几十秒,
还可能「解释器/模型没放对」。所以本包实现了 preparation,phase 每次都从服务真实状态推导,
面板看到的就是真实状态:
| phase | 什么时候 | 面板应该显示 |
|---|---|---|
unprepared |
解释器 / 服务脚本 / 模型目录缺一个,且还没试过 prepare() |
不可用 + 缺什么(人话,带路径) |
loading |
正在起进程 / 加载权重(prepare() 或 startAlive() 进行中) |
加载中 |
ready |
服务在跑、/health 已通 |
可用 |
standby |
环境齐备但服务没加载(或常驻模式下正在退避重启,附下次重试秒数) | 未加载(按下麦克风会自动拉起) |
failed |
试过但失败(或常驻服务被判不可用) | 不可用 + 原因 |
prepare() 是「现在就把权重加载起来」,真的等到 /health 通才 resolve;cancel() 停掉服务。
另外两条与面板无关但同样重要的硬约束,写在代码里也写在这里:
- 不合格音频在调子进程之前就被拒(单声道 / PCM16 / 16 kHz,与
server/server.py:150的
decode_wav逐条一致)。省一次进程往返,错误消息直接指名道姓,而不是让服务端抛 500。 - 卸载顺序:先注销识别器,再停服务(
unregister → server.stop() → 路由与退出钩子)。
反过来的话,最后一刻进来的转写请求会打进一个正在退场的子进程。
链路:
浏览器麦克风 → ui-voice-input → api-speech-to-text(Remote) → ctx.speechToText
→ 【本包注册的 qwen3-asr-local】→ 127.0.0.1:随机端口 → qwen-asr(CPU) → 文本回填草稿框
目录:代码和运行时是分开放的
| 位置 | 内容 | 为什么 |
|---|---|---|
<插件目录> |
插件本体(index.js、server/server.py、测试) |
跟着你现有的本地插件目录走 |
<运行时目录> |
曾经是 .venv(Python 3.12 + torch CPU)+ models\(Qwen3-ASR-1.7B / 0.6B);2026-11-01 整体清空,现在只剩密钥与日志 |
必须纯 ASCII 路径,见下;重建看「复现安装」 |
> ⚠️ 坑:qwen-asr 依赖 nagisa(dynet 原生库),它用自己的窄字符 API 读包内模型文件。venv 一旦落在带中文的路径(例如 06-AI与项目 下面),import qwen_asr 会直接抛 RuntimeError: Could not read model from ...nagisa_v001.model。把 venv 和模型放到纯 ASCII 目录即可,代码本身留在中文目录没问题。
复现安装
# 1) 纯 ASCII 路径下的隔离环境
$rt = '<运行时目录>'
uv venv "$rt\.venv" --python 3.12
uv pip install --python "$rt\.venv\Scripts\python.exe" `
--index-url https://mirrors.aliyun.com/pypi/simple/ `
-r 'D:\Desktop\06-AI与项目\dsh-stt-qwen\server\requirements.txt'
# 2) 模型权重(ModelScope,1.75 GB)
uv tool run --from modelscope modelscope download `
--model Qwen/Qwen3-ASR-0.6B --local_dir "$rt\models\Qwen3-ASR-0.6B"
想换 1.7B(更准、更慢)就下 Qwen/Qwen3-ASR-1.7B,然后把 profile 里的 modelPath 指过去。
在 DSH 里启用
- profile 的
package.json加依赖:"dsh-stt-qwen": "link:D:/Desktop/06-AI与项目/dsh-stt-qwen" - profile 的
cordis.patch.yml加一条 insert(见本包cordis.patch.yml的写法),把speech-to-text的defaultProvider改成qwen3-asr-local(或保持 SenseVoice,在语音面板下拉里手动换成「Qwen3-ASR-0.6B(本地)」) - 在 profile 目录跑一次安装,然后重启 DSH:
$node = 'D:\DeepSeek Harness\resources\runtime\primary-runtime\dependencies\node\bin\node.exe'
$pnpm = 'D:\DeepSeek Harness\resources\runtime\pnpm\bin\pnpm.cjs'
& $node $pnpm install --dir '%USERPROFILE%\.dsh\profiles\desktop'
生命周期:与宿主同生共死
默认(keepAlive: true)本地识别进程就是 DSH 的「附属进程」:
| 时机 | 谁动手 | 怎么动 |
|---|---|---|
| DSH 启动 | 插件 apply() |
挂载即 startAlive() 拉起 python,后台加载模型(本机 1.7B 约 6 秒);失败按退避自动重试,不阻塞 DSH 启动 |
| DSH 运行中 | 服务端自己 | 权重常驻(idleTimeoutSeconds: 0)——随时听写都是热的,代价是内存一直占着(本机实测提交量约 6.7 GB);不会自己退出(keepAlive 把 idleExitAfterSeconds 压成 0) |
| 识别进程被杀 | 插件监督 | 几秒内发现意外退出,按 restartBackoffMs 退避重启(1s→2s→5s→…→60s,最多 6 次) |
| 插件卸载(HMR / 优雅退出) | 插件 disposer | POST /shutdown 请它自己退 → 关 stdin → 3 秒不退就按进程树强收 |
| 宿主进程退出 | process.on('exit') 钩子 |
killSync() 同步收掉子进程与它整棵子树(不依赖 await、不发 HTTP) |
| 宿主被强杀 / 崩溃 | 服务端两条看门狗 | stdin EOF(--watch-stdin 1)立刻退;父进程存活检查(--parent-pid <宿主 pid>,psutil 每 5 s 一次)兜住管道被继承的情况 |
三条保险是叠加的:钩子能跑就跑钩子,跑不了就靠管道和父进程检查 —— 任何一条成立都不会留下孤儿。
踩坑记录:venv 的 python 是两段式的。 Windows 上 <运行时目录>\.venv\Scripts\python.exe
只是个启动器,真正跑 server.py、加载模型、监听端口的是它拉起的基础解释器(os.getpid()
与 child_process 给的 pid 不是同一个)。实测杀掉启动器时真正解释器会跟着走(启动器把它放进了
job object),但插件不依赖这个细节:强收时用 taskkill /F /T(整棵树),并按服务端握手自报的
pid 再补一刀。复现实验:node test/venv-launcher-probe.mjs。
自查:
# 一眼看生命周期现状(running / childPid / interpreterPid / 重启次数 / 上次退出)
Invoke-RestMethod 'http://127.0.0.1:19387/plugin-api/dsh-stt-qwen/status' | ConvertTo-Json -Depth 6
# 真起进程、真杀进程的完整验证(21 项,会开关 6 次 python)
node test/lifecycle-test.mjs
# preparation 契约的离线自测(用替身服务端,不加载权重、不占内存,6 组断言)
node test/preparation-test.mjs
# 队列 / 长音频切分 / 模型下载(全部离线,不加载权重)
node test/queue-test.mjs ; node test/split-test.mjs ; node test/download-test.mjs
配置项
| 键 | 默认 | 说明 |
|---|---|---|
providerId |
qwen3-asr-local |
注册 id,也是 defaultProvider 要写的值 |
displayName |
Qwen3-ASR-0.6B(本地) |
下拉框里显示的名字 |
pythonPath |
包内 .venv |
用哪个解释器(放 ASCII 路径) |
serverScript |
包内 server/server.py |
转写服务脚本 |
modelPath |
包内 models/Qwen3-ASR-0.6B |
模型目录 |
dtype |
float32 |
float32 / bfloat16 / float16;CPU 上 bf16 不一定更快 |
device |
cpu |
有 N 卡时可以试 cuda:0 |
threads |
0(自动) |
实测 8 线程(物理核)够用,16 线程没有更快 |
quantize |
none |
none / int8。int8 走 torch.ao 动态量化:1.7B 上实测短音频 -28%、长音频 -37%,但常驻内存接近翻倍(fp32 权重和 qint8 副本同时存在);0.6B 上加 int8 反而慢 18%(小模型省下的算力不够补量化开销),所以小模型不要开。开 int8 会强制 dtype: float32 —— ONEDNN 的 qlinear_dynamic 只吃 float 输入,bf16 会直接报 data type of input should be float |
processPriority |
above |
服务进程优先级:normal / above / high。转写是短突发任务,above(ABOVE_NORMAL_PRIORITY_CLASS)能压掉「后台扫盘/索引时突然慢一倍」的毛刺 |
keepAlive |
true |
与宿主同生共死:挂载即拉起、宿主在就不自行退出、被第三方杀掉自动重启。设 false 回到旧的按需拉起 + 空闲自退 |
preload |
true |
挂载时就把模型拉起来,省掉第一次听写的冷启动(keepAlive: true 时必然为真) |
restartBackoffMs |
[1000,2000,5000,15000,30000,60000] |
意外退出后的重启退避(毫秒,最后一项是上限) |
restartMaxAttempts |
6 |
连续拉不起来多少次后放弃自动重启(放弃后下一次听写仍会再试一次) |
readyTimeoutMs |
600000 |
模型加载就绪的上限 |
idleTimeoutSeconds |
900 |
空闲多久先「卸权重」;0 = 权重常驻不回收(随时听写都是热的,代价是内存一直占着)。本机 profile 设成 0:实测 1.7B/bf16 提交量 6698 MB,工作集被 Windows 修剪到 653 MB |
idleAction |
release |
release = 只卸权重(进程留着,重载几秒);exit = 进程直接退出(重载十几秒)。只在 idleTimeoutSeconds > 0 时生效;idleTimeoutSeconds: 0 时整条空闲回收不启动,这一项是死配置 |
idleExitAfterSeconds |
1800 |
release 之后再空闲这么久就退出进程;0 = 永不退出。keepAlive: true 时这一项被强制成 0 —— 同生共死意味着进程不会自己走掉 |
maxConcurrent |
1 |
同时在跑的转写数。模型服务是单进程串行的,默认 1 就是它的真实吞吐 |
maxPending |
4 |
排队上限;满了直接拒绝(错误消息带上限),而不是默默堆着让人以为「按了没反应」 |
longAudioSplitSeconds |
0(关) |
>0 时:超过这个秒数的音频先在静音点切段、逐段识别再拼起来。见下节取舍 |
longAudioMinSegmentSeconds |
3 |
切段的最短段长,避免把一段话切得稀碎 |
modelDownloadBaseUrl |
空 | 模型清单基址(如 https://hf-mirror.com/Qwen/Qwen3-ASR-0.6B/resolve/main)。留空 = 不提供下载、也不广告 downloadSources |
modelDownloadFiles |
[] |
清单:相对路径字符串,或 { path, sha256 }。给了 sha256 就必须校验通过才落盘 |
modelDownloadTimeoutMs |
3600000 |
单文件下载超时 |
队列:并发与排队都封顶
语音面板是「人按下麦克风」的场景,所以两件事都设了上限,而且满了会说话:
maxConcurrent(默认 1):真正同时在跑的转写数。本插件的服务是单进程串行的,把它调大只会让请求都在服务端排队,不会更快。maxPending(默认 4):允许排队的条数。超了直接抛
转写队列已满(在跑 1 条、排队 4 条,上限 4 条);等当前几次识别结束后再试—— 面板会原样显示这句话。- 现状(在跑几条、排队几条)随时可看:
GET /plugin-api/dsh-stt-qwen/status的queue字段。
云端识别器不占队列:它只是一次 HTTPS 调用,不占本机的模型进程与内存;队列是给本地那台「单进程 + 几 GB 内存」的服务用的。
长音频:在静音点切段(默认关)
longAudioSplitSeconds 默认为 0,也就是整段送。这是有意的取舍:
- 整段送有上下文,通常更准,也是这个插件一直以来的行为;
- 切段是为了绕开「模型对超长音频明显变慢 / 吃内存」的情况;
- 打开后切点是算出来的:在每一段上限的「最后三分之一」里找最安静的 20 ms 窗口,尽量不剪在句子中间;找不到静音就按上限硬切。段间用空格拼接(不能用换行——结果会写进输入框,换行可能被当成「发送」)。
- 段与段是串行跑的,整次听写算队列里的一个任务。
# 超过 30 秒的音频自动切段(整段送更准,确认长音频有问题再打开)
longAudioSplitSeconds: 30
longAudioMinSegmentSeconds: 3
离线验证(合成音频,不加载模型、不占内存):
node test/queue-test.mjs # 并发/排队上限/出错不卡队列
node test/split-test.mjs # 切段数量、切点落在静音处、段段是合法 WAV、总时长守恒
模型下载(可选,清单驱动)
默认不下载任何东西(模型手工放置,见「复现安装」)。配置了清单之后,插件提供:
POST /plugin-api/dsh-stt-qwen/model/download 起一次下载(已在跑 → 409)
POST /plugin-api/dsh-stt-qwen/model/download/cancel 取消(保留 .part,下次续传)
GET /plugin-api/dsh-stt-qwen/status 里的 download 字段 = 每个文件的进度
行为:.part 增量写盘 → 带 Range 续传(服务端不认 Range 就重头写,不会拼出坏文件)→ 有 sha256 就校验、不符删除 .part → 校验通过才原子改名到模型目录。已存在且校验通过的文件直接跳过。
modelDownloadBaseUrl: https://hf-mirror.com/Qwen/Qwen3-ASR-0.6B/resolve/main
modelDownloadFiles:
- config.json
- { path: model.safetensors, sha256: "<64 位十六进制>" }
为什么是清单驱动,而不是内置一个官方地址:地址和校验值写死在插件里,上游一变就会变成「点了下载却装出坏模型」。基址由你给(国内建议 hf-mirror),插件只负责续传、校验、原子落盘;downloadSources 也只在配置了清单时才广告。
node test/download-test.mjs # 起一个支持 Range 的本地 HTTP 服务,真跑完整下载/续传/校验失败/跳过
云端模式(不占本机内存,一次 HTTPS 调用)
不想让本机吃 4 GB、也不想等 10 秒级冷启动时,注册一个云端识别器即可:收到完整 WAV → 一次 HTTPS 调用 → 拿文字。
不需要额外插件、不需要新前端:官方那个麦克风按钮本来就把整段录音交给 ctx.speechToText,云端 Provider 只是换了个 transcribe 实现。
三种接口形态(cloudApi)
cloudApi |
请求形态 | 音频怎么进去 | 典型厂商/模型 |
|---|---|---|---|
dashscope-chat |
POST {base}/chat/completions(OpenAI 兼容) |
base64 Data URL 内联在 input_audio.data |
阿里云百炼 qwen3-asr-flash |
dashscope-native |
POST {base}/services/aigc/multimodal-generation/generation |
base64 Data URL 内联在 content[].audio |
阿里云百炼 fun-asr-flash 这类 |
openai-transcriptions |
POST {base}/audio/transcriptions(multipart) |
直接上传 WAV 文件 | 硅基流动、火山、各类 OpenAI 兼容网关 |
三种形态都有本地假服务器测试:node test/cloud-mock.mjs(不需要真 key,验证请求体、鉴权头、响应解析)。
配置样例
- id: stt-qwen
name: dsh-stt-qwen
config:
localEnabled: false # 纯云端:本机完全不注册本地识别器
cloudEnabled: true
cloudProviderId: qwen3-asr-flash-cloud
cloudDisplayName: "Qwen3-ASR-Flash(云端)"
cloudApi: dashscope-chat
cloudBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1"
cloudModel: "qwen3-asr-flash"
cloudApiKeyEnv: DASHSCOPE_API_KEY # 只写引用名,配置里绝不放密钥
cloudLanguages: ["auto", "zh", "en", "yue", "ja", "ko"]
密钥从哪来(配置里永远只有引用名)
cloudApiKeyEnv 的解析顺序,三选一即可:
- DSH 凭据服务(
ctx.credentials.resolve)——官方各插件用的就是这个,密钥可写不可读; cloudApiKeyFile:把密钥单独放一个文件,插件读该路径;- 进程环境变量:
setx DASHSCOPE_API_KEY "sk-…"之后重启 DSH。
都取不到时报错会点名缺哪个引用,值永远不会被日志打印。
两个上限要记住
- DSH 侧:官方
api-speech-to-text默认单次录音上限 120 秒 / 4 MB(浏览器到点截断,超了直接拒),可在它那条配置里调; - 厂商侧:以
qwen3-asr-flash为例是 ≤10 MB / ≤5 分钟,且 base64 会膨胀 4/3(16 kHz 单声道 PCM16 = 32 KB/s),所以 Data URL 这条路的实际上限约 3.9 分钟 —— 与 DSH 的 120 秒上限相比很宽裕。
选型结论(2026-10-01 实查官方页,附来源)
只看「本机录好 WAV 直接上传」这个场景,能用的其实不多 —— 很多便宜接口都要求先有一个公网 URL:
| 厂商 / 模型 | 接口形态 | 音频入口 | 价格 | 评价 |
|---|---|---|---|---|
阿里云百炼 qwen3-asr-flash |
OpenAI 兼容 chat | base64 内联 ✅ | 0.00022 元/秒(≈0.79 元/小时),另送 10 小时/90 天 | 选它:形态最省事、价格可接受、模型就是本地那套的云端更大版本 |
阿里云百炼 fun-asr-flash |
原生 multimodal | base64 内联 ✅ | 0.00022 元/秒 | 同价备选,可带上下文提示(术语偏置),已一并挂上做 A/B |
阿里云百炼 paraformer-v2 |
异步 submit-poll | 必须公网 URL ❌ | 0.00008 元/秒(最便宜) | 便宜但本机用不了 |
硅基流动 FunAudioLLM/SenseVoiceSmall |
OpenAI 兼容 multipart | 文件直传 ✅ | 免费 | 便宜,但就是被嫌弃的 SenseVoice 那一族(还有下线公告冲突) |
硅基流动 Qwen/Qwen3-ASR-1.7B |
— | — | 定价页标免费 | 接口文档的 model 枚举里没有它,能否调用需实测 |
| 火山引擎 豆包 ASR | WebSocket 二进制分帧 / 异步要 URL | ❌ | 1~3.5 元/小时 | 形态不适配 |
| 腾讯云一句话识别 | HTTP + TC3 签名 | base64 ✅ | 3.2 元/千次,每月送 5000 次 | 单次 ≤60 秒,且要自己拼签名 |
| 百度短语音标准版 | HTTP + Bearer | base64 / RAW ✅ | 3.4 元/千次,送 5 万次 | 单次 ≤60 秒 |
智谱 GLM-ASR-2512 |
OpenAI 风格 multipart | 文件/base64 ✅ | ≈0.0002 元/秒 | 单次仅 ≤30 秒,太短 |
MiniMax asr-1.0 |
multipart | 文件直传 ✅ | 2.50 元/小时 | 接口省事,但比阿里贵约 3 倍 |
按「每天 5 分钟」估算:阿里约 2 元/月(前 10 小时免费≈前 4 个月零成本),腾讯/百度在免费额度内基本 0 元但要被 60 秒卡住。
来源:help.aliyun.com/zh/model-studio/qwen3-asr-flash、.../fun-asr-flash、.../paraformer-v2、api-docs.siliconflow.cn/docs/api/audio-transcriptions-post、siliconflow.cn/pricing、cloud.tencent.com/document/product/1093/35686、cloud.baidu.com/doc/SPEECH/s/Tldjm0i4c、platform.minimaxi.com/docs/pricing/overview.md、volcengine.com/docs/6561/1359370。
密钥放哪:推荐一个文件(改完即生效,不用重启)
# 一行,只放密钥本身,不要引号、不要换行以外的空格
Set-Content -Path '<运行时目录>\dashscope.key' -Value 'sk-xxxxxxxx' -Encoding ascii -NoNewline
插件按「DSH 凭据服务 → 密钥文件 → 环境变量」的顺序取;文件读不到会回落到环境变量而不是直接报错。
环境变量路线也行(setx DASHSCOPE_API_KEY "sk-…"),但那样必须重启 DSH 才读得到。
云端实测(2026-10-01,真 key、真音频、4.2 秒中文样本)
| 识别器 | HTTP 往返 | 结果 | 备注 |
|---|---|---|---|
qwen3-asr-flash |
0.59 s | 一字不差 ✅ | 默认;base64 内联,无需公网 URL |
fun-asr-flash |
0.28 s | 一字不差 ✅ | 备选;必须显式给 parameters.format=wav,返回结构也不同(output.output.sentence.text) |
| 本地千问 0.6B(对照) | 1.5 s 推理 + 3~13 s 加载 | ✅ | 要占 4.2 GB 内存,所以降级为兜底 |
云端这条路没有加载、没有空闲回收、不占本机内存和提交量 —— 两段式空闲策略只有本地识别器还用得着
(本机 2026-11-01 起只走云端:localEnabled: false,本地识别器与两段式空闲回收都不再启用)。
两个踩过的坑(都已修在代码里):
fun-asr-flash的原生 multimodal 形态:只给input.messages[].content[].audio会报format is empty,必须补parameters: { format: 'wav' };纯 base64(不带data:前缀)会 500(服务端拿它当远程路径去 wget)。- 403
Model access denied.不是代码问题:要么业务空间没开通该模型、要么 RAM 子账号缺权限、要么整个 key 没模型权限(连qwen-plus都被拒就属于后者)。用一枚好 key 和坏 key 对比 401/403,能一秒区分「key 无效」和「权限没开」。
复测(2026-11-01,真 key,本机 7800X3D)
删掉本地模型后的实测,走的是插件自己的代码路径(test/real-dashscope-test.mjs → apply() → provider.transcribe):
| 音频 | HTTP 往返(3 次) | 说明 |
|---|---|---|
| 4.20 s 中文 | 0.55 s / 0.79 s / 0.79 s | 与官方样本逐字一致 |
| 29.43 s 中文 | 1.02 s / 0.99 s | 约 29× 实时(test/cloud_probe.py) |
| 本地 1.7B 时代对照 | 3.07 s(8.88 s 音频)、2.71 s(14.28 s 音频) | 用户抱怨慢的那两次 |
本机 IPv6 会黑洞 21 秒(踩过,别再踩)
同一条请求,curl -4 稳定 0.13 s,curl -6 有一次 connect=0 / total=21.26 s ——
21 s 正好是 TCP SYN 重试放弃的时长。dashscope.aliyuncs.com 的 AAAA 记录确实存在
(2408:400a:3e:ef02:...),本机 IPv6 到它时通时不通:
- Python(
requests/ urllib3)不做 happy-eyeballs,取到第一个地址就死等 → 随机吃满 21~22 s。
test/cloud_probe.py因此默认把socket.getaddrinfo掐成只返回 A 记录,加--ipv6可复现旧行为; - Node 侧实测过
dns.setDefaultResultOrder('ipv4first')对 undici 的 fetch 没有改善
(test/dns-order-test.mjs);分层探针test/diag-node-net.mjs显示 dns/tcp/tls 各层都在 0.1 s 内,
真正走插件路径时也是 0.55~0.79 s,说明偶发而非稳定,暂不改插件代码; - 判据:把
curl -4与curl -6各打三次,只要-6出现connect=0+ 20 s 级 total,
就是这条坑,不是服务端慢。
实测(i5-12500H,16 线程,无独显)
> 这一节是旧机器 + float32 + 当时开启空闲释放时代的实测数据,留作对照。
> 本机(7800X3D / 31 GB / bf16 / idleTimeoutSeconds: 0)当前的口径见文末「当前部署状态」。
| 指标 | 数值 |
|---|---|
| 模型加载 | 13.3 ~ 13.5 s(脱机、文件已在缓存);DSH 启动时并发装载 41.6 s;首次冷读权重约 53 s |
| 4.2 s 中文音频推理 | 1.40 ~ 1.98 s(约 3× 实时) |
| 端到端(含拉起服务) | 15.5 s(其中 13.5 s 是加载模型) |
| 常驻内存(float32) | 3.6 GB(当时配置为空闲 900 s 自动释放) |
| 结果 | 甚至出现交易几乎停滞的情况。(与官方样本一致) |
dtype 别乱换:bfloat16 在这台机器上实测推理 7.5 s(float32 只要 1.5 s,慢 5 倍),
内存也没降到预期 —— Alder Lake 没有 BF16 加速,PyTorch 只能软化。继续用 float32。
(Zen 4 相反:7800X3D 上 bf16 比 float32 快一倍多,见「当前部署状态」。)
模型规模与 int8 量化:本机实测(7800X3D / 8 核 8 线程 / 无 CUDA / 31 GB)
> 本节与 test/bench_quant.py、test/compare_models.py、test/smoke_server.py 都需要本地模型目录存在。
> 2026-11-01 起本机已删模型、只走云端,重跑这些脚本前先用 modelscope 把 Qwen3-ASR-0.6B 下回来。
同一台机器、同一个 torch 2.14.1+cpu、threads=8、同一批音频
(test/asr_zh.wav 4.20 s、test/asr_zh_long.wav 29.43 s),脚本是 test/bench_quant.py
和 test/compare_models.py:
| 方案 | 4.2 s 音频 | 29.4 s 音频 | RSS 峰值 | 文本 |
|---|---|---|---|---|
1.7B bfloat16 |
1.352 s(RTF 0.322) | 6.806 s(0.231) | 4.6 GB | 基准 |
1.7B bfloat16 + quantize: int8 |
0.969 s(0.231) | 4.273 s(0.145) | 10.8 GB | 与基准一致 |
1.7B 同上,threads: 16 |
0.901 s | 4.331 s | 7.0 GB | 一致(8 核 8 线程,16 线程没有意义) |
0.6B bfloat16 |
0.599 s(0.143) | 3.342 s(0.114) | 2.2 GB | 与 1.7B 逐字一致(difflib 1.0) |
0.6B + quantize: int8 |
0.709 s(0.169) | 3.904 s(0.133) | 7.5 GB | 一致 |
结论:小模型比量化更划算。0.6B/bf16 比 1.7B/bf16 快 2.26×(短)/2.04×(长),
常驻内存直接砍半,而且两段测试音频的文本完全一致;1.7B 加 int8 虽然也能快 28%~37%,
但要把 10.8 GB 常驻内存一直占着。只有当你必须保留 1.7B 的稳健性、又不接受它的延迟时,
才值得打开 quantize: int8。
三条踩过的死路(别重走):
torch.ao.quantization.quantize_dynamic在bfloat16输入下第一层就炸:
RuntimeError: qlinear_dynamic (ONEDNN): data type of input should be float.—— 必须float32加载;torchao的Int8WeightOnlyConfig(0.18.0)在纯 CPU 上没有优化 kernel,实测慢 5 倍(6.47 s / 31.3 s);torchao的Int4WeightOnlyConfig直接ImportError: Requires mslk >= 1.0.0(本机没有)。
服务端回归:test/smoke_server.py 会拉起真服务、读握手、打 /health、POST 真音频、再 /shutdown,
用来确认 --quantize / --priority 没有破坏协议(要先重建 .venv 并下模型,2026-11-01 已都删):
<运行时目录>\.venv\Scripts\python.exe <插件目录>\test\smoke_server.py --quantize int8
云端探针 test/cloud_probe.py 只用标准库,随便一个 python 3.10+ 就能跑(本机可用
%USERPROFILE%\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\python\python.exe),
输出「HTTP 状态 + 往返秒数 + 文本」,是判断「key 通不通、到底几秒」的第一手段。
两段式空闲回收(机制说明;本机现已关闭)
> 本机 idleTimeoutSeconds: 0 时整条空闲回收不启动(watch_idle() 直接返回),
> 下面这套只在 idleTimeoutSeconds > 0 时生效;服务端级的回归测试见 test/release_cycle.py。
「可以用语音的时候才加载 + 停手就把内存还回去」拆成两段,因为只卸权重不够干净:
| 阶段 | 物理内存(任务管理器看到的) | 进程提交量 | 下次听写要等 |
|---|---|---|---|
| 刚加载完 | 4.2 GB | 5.5 GB | 0 |
| 停手 300 s 后(release) | ~10 MB(实测 4236 → 8 MB) | 5.5 GB 起 | ~3 s(实测 2.9~3.2 s,只需重新读权重) |
| 再停手 1800 s 后(exit) | 0 | 0 | 13~50 s(要重跑一遍 torch/transformers 导入) |
release 分支会显式调 EmptyWorkingSet:Windows 的堆对刚释放的大块只在空闲链表里留着,
不 trim 一次,工作集根本不会掉下来(第一版就是卡在这里:权重置空后 RSS 仍是 3.6 GB)。
一直 release 不退出会有个副作用:重载后提交量涨到约 8.5 GB 然后稳定(不是每轮增长,
实测三轮 8365 → 8425 → 8492 MB)。这台机提交限额 31.2 GB、平时已用约 20 GB,
余量不舒服,所以第二段把进程也退掉,彻底还清。
服务端为什么写得这么别扭(踩坑记录)
server.py 里有两条看起来多余、其实都是硬约束的规矩,改代码前务必看一眼:
- 模型必须在主线程、且在开始处理任何请求之前加载完。
把加载放进子线程会让import torch/scipy.linalg这类 C 扩展的 DLL 加载
在create_module里零 CPU 冻死。第一次踩的是「看门狗线程里import psutil」
与「加载线程里import torch」并发导入。 - 原生导入结束之前,不能有看门狗线程,尤其是读 stdin 的那个。
实验(test/watchdog-vs-import.mjs,各 2 次):
| 导入期间另一个线程在干什么 | 结果 |
|---|---|
阻塞在 sys.stdin.buffer.read(1)(管道) |
卡死 2/2 |
只是 time.sleep 循环(父进程存活检查) |
通过 2/2 |
| 没有任何其它线程 | 通过 2/2 |
即:一个线程挂在管道的同步读上,会让同进程主线程随后的 DLL 加载冻住。
所以 stdin 看门狗要等到导入结束再启动;就绪信号也因此改成 stdout 的第二行握手
({"port":…} → {"ready":…}),而不是加载期间轮询 /health。
已知限制
- 只支持完整录音转写:官方语音服务本身没有流式识别接口。
- CPU 推理意味着长录音会线性变慢(1 分钟音频大致 20 s 左右);追求长音频体验要上 GPU,或改用方案 B(DashScope 云端)。
- 0.6B 的精度是「够用」档;术语/人名密集的场合,1.7B 或云端 Qwen3-ASR-Flash 的
system词表偏置会明显更好。 - 第一次听写要不要等,取决于空闲策略:本机
preload: true+idleTimeoutSeconds: 0(权重常驻),
所以任何一次听写都不用等加载;只有把idleTimeoutSeconds设回正数(空闲卸权重)时,
「停手超过空闲阈值后的第一次」才要等重载(本机实测约 3 秒,本机 profile 不启用这套)。 - 服务只监听
127.0.0.1,并要求随机 token,其他本地进程无法直接调用。
排障
先看诊断接口(插件自己开的,本机回环):
Invoke-RestMethod 'http://127.0.0.1:19387/plugin-api/dsh-stt-qwen/status' | ConvertTo-Json -Depth 6
它会告诉你:插件有没有挂载、localEnabled/keepAlive/空闲策略、本地服务在不在跑(lifecycle.running、
childPid、interpreterPid、重启次数、上次退出的原因)、注册了哪些识别器(含模型与 baseUrl)、
以及密钥来源(引用名 / 文件路径 / 文件是否存在 / 环境变量是否设置),还有最近几次听写的结果与音量。
宿主日志的 stdout 捕获在启动早期不一定接得上,所以判断"装没装"以这个接口为准。
生命周期相关的三个自查:
node test/lifecycle-test.mjs # 21 项:挂载即拉起 / 转写 / 被杀自动重启 / 卸载即停 / 宿主强杀不残留 ×2 / 缺运行环境
node test/preparation-test.mjs # 6 组:preparation 阶段推进 / 音频准入 / 卸载顺序(离线,不占内存)
node test/venv-launcher-probe.mjs # 复现「venv python 是两段式」这件事
node test/watchdog-vs-import.mjs 2 # 看门狗线程 vs 原生导入(改服务端之前先看这个)
# 不经 DSH 直接验证链路(真起进程、真转写)
$node = 'D:\DeepSeek Harness\resources\runtime\primary-runtime\dependencies\node\bin\node.exe'
& $node '<插件目录>\test\plugin-dry-run.mjs'
# 三个复现/定位实验(怀疑又踩到导入卡死时用)
& $node 'D:\Desktop\06-AI与项目\dsh-stt-qwen\test\watchdog-vs-import.mjs' 2 # 看门狗线程 vs 导入
& $node 'D:\Desktop\06-AI与项目\dsh-stt-qwen\test\thread-import-race.mjs' 3 # 主线程/子线程导入
& $node 'D:\Desktop\06-AI与项目\dsh-stt-qwen\test\spawn-matrix.mjs' scipy.linalg # spawn 参数矩阵
# 手动起服务,看模型加载与转写原始返回(需要本地 venv + 模型目录;2026-11-01 已删,重建见「复现安装」)
& '<运行时目录>\.venv\Scripts\python.exe' `
'D:\Desktop\06-AI与项目\dsh-stt-qwen\server\server.py' `
--model '<运行时目录>\models\Qwen3-ASR-0.6B' --port 18121 `
--watch-stdin 0 --parent-pid -1
- 插件日志前缀是
[dsh-stt-qwen],写在 DSH 宿主日志里(\logs\harness-*.log)。 - 语音面板报错时,先看是不是
pythonPath/modelPath写错,或者模型目录用了中文路径。 - 进程卡住(CPU 不涨)时用 py-spy 抓栈最快:
<运行时目录>\.venv\Scripts\py-spy.exe dump --pid(需重建 venv)。
当前部署状态(2026-11-01)
- 默认走云端:
speech-to-text.defaultProvider = qwen3-asr-flash-cloud
(dashscope-chat+qwen3-asr-flash,一次 HTTPS 往返,实测 0.59 s)。
2026-11-01 用户决定弃用本地推理,localEnabled: false,本地模型已从磁盘删除;
改这条配置不需要重启 DSH:profile 被监听,插件会卸载重载并重新注册识别器; - 密钥:
cloudApiKeyFile = <运行时目录>\dashscope.key(一行密钥,读不到再回落到
DASHSCOPE_API_KEY)。2026-11-01 已写入并用真音频验证(test/real-dashscope-test.mjs往返 0.65 s);
插件每次转写都现读该文件(index.js:788resolveKey()),所以换 key 不用重启; - 本地侧现状:已整体清空(2026-11-01)。
<运行时目录>\.venv(1.7 GB)与
models\Qwen3-ASR-1.7B、models\Qwen3-ASR-0.6B(共 6.2 GB)全部删除,目录现在只剩
dashscope.key和几个历史日志(合计 93 KB)。回退本地要重建两步:按「复现安装」重建 venv
(uv venv+requirements.txt)、重新下模型(modelscope 约 1 分钟),再把localEnabled
改回 true、defaultProvider改回qwen3-asr-local; - 本地时代的实测数据(7800X3D / 8 核 8 线程 / 31 GB / 无 CUDA):1.7B/bf16 4.2 s 音频 1.35 s、
29.4 s 音频 6.81 s;0.6B/bf16 0.60 s / 3.34 s,文本逐字一致;float32比 bf16 慢一倍多。
这些数字只对 CPU 路径成立,硬件细节见下节「Windows 原生 ROCm」; - 云端识别器在册:
qwen3-asr-flash-cloud(dashscope-chat|qwen3-asr-flash|密钥引用
DASHSCOPE_API_KEY,实际走cloudApiKeyFile);fun-asr-flash那次实测更快(0.28 s),需要时
按「云端模式」那节加一条同形态的 provider 即可做 A/B。
Windows 原生 ROCm:这台机器其实能上显卡(2026-11-01 复核)
这条 README 以前写过「Windows 上 PyTorch 没有 ROCm」,这句是错的。本机
E:\ComfyUI_windows_portable_amd\python_embeded(Python 3.12.10)里装的是 AMD 官方
ROCm-on-Windows 的 PyTorch 预览轮子:
| 项 | 实测值 |
|---|---|
torch.__version__ |
2.16.0a0+rocm10.2.0a20261007(拆分包 amd_torch_device_gfx1100-...、amd_torchvision_device_gfx1100-...) |
torch.version.hip |
7.17.26401 |
torch.cuda.is_available() |
True(HIP 走 CUDA API 表面) |
| 设备 | AMD Radeon RX 7900 XT,capability (11, 0) = gfx1100 |
| bf16 4096×4096 matmul | 1.65 ms → 83.2 TFLOPS(预热 3 次、均值 20 次) |
安装入口(AMD 官方,stable 与 nightly 两条):
pip install --index-url https://stable.repo.amd.com/rocm/whl-next/ "torch[device-all]==2.13.0+rocm10.0.0"
pip install --index-url https://nightly.repo.amd.com/rocm/whl-next "torch[device-gfx1100]" --pre
把 Qwen3-ASR 搬上显卡时踩到的三件事(方向已放弃,留作备忘):
qwen_asr 0.0.6钉死transformers==4.57.6,而 ComfyUI 环境是transformers 5.17.0:
pip install qwen-asr会降级 transformers、弄坏 ComfyUI,只能pip install --target DIR+PYTHONPATH;python_embeded没有venv模块(ModuleNotFoundError: No module named 'venv'),建不了独立虚拟环境;- 直接用 transformers 5.17 自带的
Qwen3ASR*跑,会报
Qwen3ASREncoder expects padded_feature_length to be a multiple of n_window * 2 (100), but got 420:
模型目录的preprocessor_config.json里feature_extractor_type写的是WhisperFeatureExtractor,
AutoProcessor 于是装出一个不会做 mel 轴对齐的提取器。把它改成Qwen3ASRFeatureExtractor即可。
test/gpu_probe.py就是这条路线探针,能一路跑到generate之前。
卸载
- profile 的
cordis.patch.yml里删掉stt-qwen那条 insert,把defaultProvider改回sensevoice-local; - profile 的
package.json里删掉依赖并重跑一次pnpm install; - 想彻底清干净就删
<运行时目录>(现在只有 93 KB:一行dashscope.key+ 几个历史安装日志;
.venv与两个模型目录 2026-11-01 已删,删前那会儿是 7.9 GB)。