助手本体:会话引擎、LLM 路由、记忆、身份、语音适配
从 Obsidian 笔记库剥离为独立仓库。规划文档与 P0 报告留在笔记库, 代码(助手本体、TTS 服务、P0 基准脚本、ESP32 固件)在此。 架构:服务器是助手,本地是客户端 - 助手本体跑在 cadadmin-server,N5095 只做 USB 外设桥与网络桥接 - 起因:实测 N5095 无 AVX2,跑不动可用的语音模型 当前技术栈 - LLM:DeepSeek deepseek-flash(云端,必须关思考) - TTS:Qwen3-TTS 1.7B(服务器 3090,独立 HTTP 服务) - STT:Zipformer 中英双语 int8(CPU,中文识别率未测) 实测:端到端 16/16 通过,首音延迟 1545ms (对比:本地 LLM + vits-zh-ll 是 179ms,但音色不满意——音质与延迟 在此硬件上此消彼长) 设计要点 - MemoryStore 接口按"可能远程"设计(全 async、可超时、可失败) - user_id / session_id / track_id 三者分开,人脸只作候选不自动切身份 - 记忆查询强制带 owner_user_id,无按 id 单独取的口子 - 自动抽取的记忆是"候选",默认不参与检索 - 异步写记忆绑定创建时的身份,不读"当前用户" - 引擎与传输解耦,只用文件音频即可端到端测试 模型与音频产物不进版本管理(1.1GB),由 download_models.sh 恢复
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P0 基准 · STT 与 TTS 并发。
|
||||
|
||||
为什么必须测:
|
||||
|
||||
真实系统里助手说话时麦克风并没有关——要检测打断,STT 必须同时在跑。
|
||||
这台机器只有 4 个核,且 bench_threads.py 已经证明 STT 超过 2 线程就会急剧退化。
|
||||
那么"STT(2线程) + TTS(2线程)"极可能互相踩踏。
|
||||
|
||||
本脚本把两种负载跑在一起,和各自单独跑的结果对比,量化退化幅度。
|
||||
只看单独指标会得出"两个都能实时"的错误结论。
|
||||
"""
|
||||
|
||||
import os
|
||||
import statistics
|
||||
import threading
|
||||
import time
|
||||
import wave
|
||||
|
||||
import numpy as np
|
||||
import sherpa_onnx
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
STT = os.path.join(HERE, "models", "stt")
|
||||
WAVS = os.path.join(HERE, "wavs")
|
||||
SR = 16000
|
||||
CHUNK = int(SR * 0.1)
|
||||
|
||||
TTS_SENTENCES = [
|
||||
"我查了一下,明天上午十点有个会议。",
|
||||
"地点在三号会议室,需要提前十分钟到。",
|
||||
"另外提醒你,后天是妈妈的生日。",
|
||||
"要不要我提前帮你订一束花?",
|
||||
"还有你上周说要买的那本书现在有货了。",
|
||||
"价格是四十九块八,要不要下单?",
|
||||
]
|
||||
|
||||
|
||||
def read_wav(p):
|
||||
with wave.open(p, "rb") as w:
|
||||
return np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
|
||||
def build_stt(nt):
|
||||
return sherpa_onnx.OnlineRecognizer.from_transducer(
|
||||
tokens=os.path.join(STT, "tokens.txt"),
|
||||
encoder=os.path.join(STT, "encoder-epoch-99-avg-1.int8.onnx"),
|
||||
decoder=os.path.join(STT, "decoder-epoch-99-avg-1.int8.onnx"),
|
||||
joiner=os.path.join(STT, "joiner-epoch-99-avg-1.int8.onnx"),
|
||||
num_threads=nt, sample_rate=SR, feature_dim=80,
|
||||
decoding_method="greedy_search", enable_endpoint_detection=False)
|
||||
|
||||
|
||||
def build_tts(which, nt):
|
||||
if which == "matcha":
|
||||
d = os.path.join(HERE, "models", "matcha")
|
||||
matcha = sherpa_onnx.OfflineTtsMatchaModelConfig(
|
||||
acoustic_model=os.path.join(d, "model-steps-3.onnx"),
|
||||
vocoder=os.path.join(HERE, "models", "matcha_vocoder", "hifigan_v2.onnx"),
|
||||
lexicon=os.path.join(d, "lexicon.txt"),
|
||||
tokens=os.path.join(d, "tokens.txt"),
|
||||
dict_dir=os.path.join(d, "dict"))
|
||||
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||
model=sherpa_onnx.OfflineTtsModelConfig(matcha=matcha, num_threads=nt, provider="cpu"),
|
||||
rule_fsts=",".join(os.path.join(d, f) for f in ("date.fst", "number.fst", "phone.fst")),
|
||||
max_num_sentences=1))
|
||||
if which == "kokoro_int8":
|
||||
d = os.path.join(HERE, "models", "kokoro_int8")
|
||||
kokoro = sherpa_onnx.OfflineTtsKokoroModelConfig(
|
||||
model=os.path.join(d, "model.int8.onnx"),
|
||||
voices=os.path.join(d, "voices.bin"),
|
||||
tokens=os.path.join(d, "tokens.txt"),
|
||||
data_dir=os.path.join(d, "espeak-ng-data"),
|
||||
dict_dir=os.path.join(d, "dict"),
|
||||
lexicon=",".join(os.path.join(d, f) for f in
|
||||
("lexicon-us-en.txt", "lexicon-gb-en.txt", "lexicon-zh.txt")))
|
||||
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||
model=sherpa_onnx.OfflineTtsModelConfig(kokoro=kokoro, num_threads=nt, provider="cpu"),
|
||||
max_num_sentences=1))
|
||||
if which == "piper":
|
||||
d = os.path.join(HERE, "models", "tts_piper_xlow")
|
||||
vits = sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||
model=os.path.join(d, "zh_CN-huayan-x_low.onnx"),
|
||||
lexicon=os.path.join(d, "lexicon.txt"),
|
||||
tokens=os.path.join(d, "tokens.txt"))
|
||||
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||
model=sherpa_onnx.OfflineTtsModelConfig(vits=vits, num_threads=nt, provider="cpu"),
|
||||
max_num_sentences=1))
|
||||
d = os.path.join(HERE, "models", "tts")
|
||||
vits = sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||
model=os.path.join(d, "model.onnx"), lexicon=os.path.join(d, "lexicon.txt"),
|
||||
tokens=os.path.join(d, "tokens.txt"), dict_dir=os.path.join(d, "dict"))
|
||||
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||
model=sherpa_onnx.OfflineTtsModelConfig(vits=vits, num_threads=nt, provider="cpu"),
|
||||
rule_fsts=",".join(os.path.join(d, f) for f in
|
||||
("date.fst", "number.fst", "phone.fst", "new_heteronym.fst")),
|
||||
max_num_sentences=1))
|
||||
|
||||
|
||||
def stt_pass(rec, samples):
|
||||
"""喂一遍音频,返回 (rtf, 单块解码耗时列表)。"""
|
||||
s = rec.create_stream()
|
||||
durs = []
|
||||
pos = 0
|
||||
t_all = time.perf_counter()
|
||||
while pos < len(samples):
|
||||
block = samples[pos:pos + CHUNK]
|
||||
pos += CHUNK
|
||||
t0 = time.perf_counter()
|
||||
s.accept_waveform(SR, block)
|
||||
while rec.is_ready(s):
|
||||
rec.decode_stream(s)
|
||||
durs.append(time.perf_counter() - t0)
|
||||
total = time.perf_counter() - t_all
|
||||
return total / (len(samples) / SR), durs
|
||||
|
||||
|
||||
def tts_pass(tts, sents):
|
||||
"""连续合成若干句,返回 (rtf, 每句耗时ms)。"""
|
||||
per = []
|
||||
audio_s = 0.0
|
||||
t_all = time.perf_counter()
|
||||
for s in sents:
|
||||
t0 = time.perf_counter()
|
||||
a = tts.generate(s, sid=0, speed=1.0)
|
||||
per.append((time.perf_counter() - t0) * 1000)
|
||||
audio_s += len(a.samples) / a.sample_rate
|
||||
total = time.perf_counter() - t_all
|
||||
return total / audio_s, per
|
||||
|
||||
|
||||
def main():
|
||||
stt_nt = int(os.environ.get("STT_THREADS", "2"))
|
||||
tts_nt = int(os.environ.get("TTS_THREADS", "2"))
|
||||
tts_kind = os.environ.get("TTS_MODEL", "piper")
|
||||
wav = read_wav(os.path.join(WAVS, "stt_0.wav"))
|
||||
dur = len(wav) / SR
|
||||
|
||||
print(f"音频 {dur:.2f}s STT线程={stt_nt} TTS={tts_kind}线程={tts_nt}\n")
|
||||
|
||||
stt = build_stt(stt_nt)
|
||||
tts = build_tts(tts_kind, tts_nt)
|
||||
stt_pass(stt, wav[: SR * 2]) # 预热
|
||||
tts.generate("预热", sid=0, speed=1.0)
|
||||
|
||||
# 单独
|
||||
solo_stt, _ = stt_pass(stt, wav)
|
||||
solo_tts, _ = tts_pass(tts, TTS_SENTENCES)
|
||||
print(f"单独: STT RTF={solo_stt:.2f} TTS RTF={solo_tts:.2f}")
|
||||
|
||||
# 并发:TTS 在后台线程持续合成,主线程跑 STT
|
||||
stop = threading.Event()
|
||||
tts_stats = {"runs": 0, "rtf": [], "done": False}
|
||||
|
||||
def tts_worker():
|
||||
try:
|
||||
while not stop.is_set():
|
||||
rtf, _ = tts_pass(tts, TTS_SENTENCES)
|
||||
tts_stats["rtf"].append(rtf)
|
||||
tts_stats["runs"] += 1
|
||||
if tts_stats["runs"] >= 3:
|
||||
break
|
||||
finally:
|
||||
tts_stats["done"] = True
|
||||
|
||||
th = threading.Thread(target=tts_worker)
|
||||
th.start()
|
||||
time.sleep(0.2) # 让 TTS 先跑起来,制造真实争抢
|
||||
conc_rtfs, conc_durs = [], []
|
||||
for _ in range(3):
|
||||
r, d = stt_pass(stt, wav)
|
||||
conc_rtfs.append(r)
|
||||
conc_durs.extend(d)
|
||||
stop.set()
|
||||
th.join()
|
||||
|
||||
conc_tts = statistics.median(tts_stats["rtf"]) if tts_stats["rtf"] else float("nan")
|
||||
stt_med = statistics.median(conc_rtfs)
|
||||
ratios = sorted(x / (CHUNK / SR) for x in conc_durs)
|
||||
|
||||
print(f"并发: STT RTF={stt_med:.2f} (×{stt_med/solo_stt:.2f}) "
|
||||
f"TTS RTF={conc_tts:.2f} (×{conc_tts/solo_tts:.2f})")
|
||||
print(f" 并发下单块解码 p50={statistics.median(ratios):.2f} "
|
||||
f"p95={ratios[int(len(ratios)*0.95)]:.2f} max={ratios[-1]:.2f}(>1 表示积压)")
|
||||
|
||||
# 判定口径:看 RTF(聚合力,<1 表示跟得上)。单块 p95 只作观察项——
|
||||
# 流式模型并非每块都解码(实测约每 3 块解一次),所以单块 >1 不等于积压。
|
||||
# 留 0.7 的余量,因为还要给视觉检测等其它负载留 CPU。
|
||||
if stt_med < 0.7 and conc_tts < 0.7:
|
||||
verdict = f"✅ 并发可实时(STT 余量 {1/stt_med:.1f}x,TTS 余量 {1/conc_tts:.1f}x)"
|
||||
elif stt_med < 1 and conc_tts < 1:
|
||||
verdict = "△ 并发勉强够用,视觉检测再加负载有风险"
|
||||
else:
|
||||
verdict = "⚠ 并发跟不上实时"
|
||||
print(f"\n判定: {verdict}")
|
||||
|
||||
import json
|
||||
out = os.path.join(HERE, f"results_concurrent_{tts_kind}.json")
|
||||
with open(out, "w") as f:
|
||||
json.dump({"stt_threads": stt_nt, "tts_threads": tts_nt, "tts_model": tts_kind,
|
||||
"solo_stt_rtf": solo_stt, "solo_tts_rtf": solo_tts,
|
||||
"conc_stt_rtf": stt_med, "conc_tts_rtf": conc_tts,
|
||||
"stt_degrade": stt_med / solo_stt, "tts_degrade": conc_tts / solo_tts,
|
||||
"conc_chunk_p95": ratios[int(len(ratios) * 0.95)],
|
||||
"conc_chunk_max": ratios[-1]}, f, ensure_ascii=False, indent=2)
|
||||
print(f"结果已写入 {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P0 基准 · TTS→STT 往返测试。
|
||||
|
||||
作用有两个:
|
||||
|
||||
1. 验证 TTS 输出"可懂"——合成得再快,念出来听不懂也没用。
|
||||
Piper 的 x_low 是最低音质档,且日志里有 Unknown token 警告,必须确认发出的音是正常的。
|
||||
2. 给一个粗略的识别正确率信号(字错率 CER)。
|
||||
|
||||
重要局限:这是合成语音,比真人近距离录音干净得多,**会明显高估真实识别率**。
|
||||
真实的远场识别率必须等麦克风阵列到货后用真人录音测。这里的结果只能当"下限的信号",
|
||||
不能当验收数据。
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import wave
|
||||
|
||||
import numpy as np
|
||||
import sherpa_onnx
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
STT = os.path.join(HERE, "models", "stt")
|
||||
|
||||
CASES = [
|
||||
"好的我记住了",
|
||||
"今天北京晴转多云气温十八到二十六度",
|
||||
"明天上午十点有个会议地点在三号会议室",
|
||||
"订单编号是七三九一金额一千二百五十元",
|
||||
"会议定在二零二六年九月十四日下午三点半",
|
||||
]
|
||||
|
||||
|
||||
def load_tts(which):
|
||||
if which == "piper":
|
||||
d = os.path.join(HERE, "models", "tts_piper_xlow")
|
||||
vits = sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||
model=os.path.join(d, "zh_CN-huayan-x_low.onnx"),
|
||||
lexicon=os.path.join(d, "lexicon.txt"),
|
||||
tokens=os.path.join(d, "tokens.txt"))
|
||||
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||
model=sherpa_onnx.OfflineTtsModelConfig(vits=vits, num_threads=2, provider="cpu"),
|
||||
max_num_sentences=1))
|
||||
d = os.path.join(HERE, "models", "tts")
|
||||
vits = sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||
model=os.path.join(d, "model.onnx"), lexicon=os.path.join(d, "lexicon.txt"),
|
||||
tokens=os.path.join(d, "tokens.txt"), dict_dir=os.path.join(d, "dict"))
|
||||
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||
model=sherpa_onnx.OfflineTtsModelConfig(vits=vits, num_threads=2, provider="cpu"),
|
||||
rule_fsts=",".join(os.path.join(d, f) for f in
|
||||
("date.fst", "number.fst", "phone.fst", "new_heteronym.fst")),
|
||||
max_num_sentences=1))
|
||||
|
||||
|
||||
def load_stt():
|
||||
return sherpa_onnx.OnlineRecognizer.from_transducer(
|
||||
tokens=os.path.join(STT, "tokens.txt"),
|
||||
encoder=os.path.join(STT, "encoder-epoch-99-avg-1.int8.onnx"),
|
||||
decoder=os.path.join(STT, "decoder-epoch-99-avg-1.int8.onnx"),
|
||||
joiner=os.path.join(STT, "joiner-epoch-99-avg-1.int8.onnx"),
|
||||
num_threads=2, sample_rate=16000, feature_dim=80,
|
||||
decoding_method="greedy_search", enable_endpoint_detection=False)
|
||||
|
||||
|
||||
def transcribe(rec, samples):
|
||||
s = rec.create_stream()
|
||||
chunk = 1600
|
||||
pos = 0
|
||||
while pos < len(samples):
|
||||
s.accept_waveform(16000, samples[pos:pos + chunk])
|
||||
pos += chunk
|
||||
while rec.is_ready(s):
|
||||
rec.decode_stream(s)
|
||||
s.accept_waveform(16000, np.zeros(16000, dtype=np.float32))
|
||||
while rec.is_ready(s):
|
||||
rec.decode_stream(s)
|
||||
s.input_finished()
|
||||
while rec.is_ready(s):
|
||||
rec.decode_stream(s)
|
||||
return rec.get_result(s)
|
||||
|
||||
|
||||
def normalize(t):
|
||||
"""只保留中文字符做比较,去掉空格与标点。"""
|
||||
return re.sub(r"[^一-鿿]", "", t)
|
||||
|
||||
|
||||
def cer(ref, hyp):
|
||||
"""字错率:编辑距离 / 参考长度。"""
|
||||
r, h = normalize(ref), normalize(hyp)
|
||||
if not r:
|
||||
return float("nan")
|
||||
d = np.zeros((len(r) + 1, len(h) + 1), dtype=np.int32)
|
||||
d[:, 0] = np.arange(len(r) + 1)
|
||||
d[0, :] = np.arange(len(h) + 1)
|
||||
for i in range(1, len(r) + 1):
|
||||
for j in range(1, len(h) + 1):
|
||||
d[i, j] = min(d[i - 1, j] + 1, d[i, j - 1] + 1,
|
||||
d[i - 1, j - 1] + (r[i - 1] != h[j - 1]))
|
||||
return d[len(r), len(h)] / len(r)
|
||||
|
||||
|
||||
def main():
|
||||
which = os.environ.get("TTS_MODEL", "piper")
|
||||
tts = load_tts(which)
|
||||
rec = load_stt()
|
||||
print(f"TTS={which} → STT=zipformer-bilingual-zh-en(int8)\n")
|
||||
|
||||
cers = []
|
||||
saved = 0
|
||||
outdir = os.path.join(HERE, "roundtrip_wav")
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
for i, text in enumerate(CASES):
|
||||
audio = tts.generate(text, sid=0, speed=1.0)
|
||||
samples = np.array(audio.samples, dtype=np.float32)
|
||||
hyp = transcribe(rec, samples)
|
||||
c = cer(text, hyp)
|
||||
cers.append(c)
|
||||
mark = "✅" if c == 0 else "△" if c < 0.2 else "❌"
|
||||
print(f"{mark} 原文: {text}")
|
||||
print(f" 识别: {hyp} CER={c:.2f}")
|
||||
# 存一份,便于人工听一下 TTS 到底念得对不对
|
||||
pcm = (np.clip(samples, -1, 1) * 32767).astype(np.int16)
|
||||
with wave.open(os.path.join(outdir, f"{i}_{which}.wav"), "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(audio.sample_rate)
|
||||
w.writeframes(pcm.tobytes())
|
||||
saved += 1
|
||||
|
||||
good = sum(1 for c in cers if c == 0)
|
||||
print(f"\n完全正确 {good}/{len(CASES)},平均 CER={np.mean(cers):.3f}")
|
||||
print(f"合成音频已存到 {outdir}/(可人工试听确认音质)")
|
||||
print("\n⚠ 合成语音远比真人远场录音干净,此结果不能作为识别率验收数据。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P0 基准 · 本地流式 STT(sherpa-onnx 中英双语 Zipformer)。
|
||||
|
||||
测什么、为什么:
|
||||
|
||||
流式识别有两种"慢",必须分开测,混在一起会得出错误结论。
|
||||
|
||||
1. 算法延迟(first_partial_audio_s):要喂进多少秒音频,才吐出第一个字。
|
||||
这是模型属性(chunk 大小 + lookahead),换机器不变。它决定了"用户说完一个字到屏幕上出现字"的下限。
|
||||
|
||||
2. 算力延迟(rtf / chunk_ratio):解码耗时与音频时长的比。
|
||||
RTF<1 表示平均能跟上实时;但平均值会骗人——只要 p95 的单块解码时间超过块的时长,
|
||||
实时场景下就会累积积压,越说越滞后。所以必须同时看 chunk_ratio 的分位数。
|
||||
|
||||
3. 收尾延迟(finalize_ms):音频喂完后多久能拿到最终结果。
|
||||
这一段直接计入方案 P1"结束到首段有声回复"的 2 秒预算。
|
||||
|
||||
用法:
|
||||
python3 bench_stt.py # 默认线程 1,2,4
|
||||
THREADS=2 python3 bench_stt.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
import wave
|
||||
|
||||
import numpy as np
|
||||
import sherpa_onnx
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
STT = os.path.join(HERE, "models", "stt")
|
||||
WAVS = os.path.join(HERE, "wavs")
|
||||
SAMPLE_RATE = 16000
|
||||
CHUNK_MS = int(os.environ.get("CHUNK_MS", "100"))
|
||||
TAIL_SILENCE_S = 1.0 # 喂一段静音,让模型吐出最后一个字(真实系统里靠 VAD 判定说完)
|
||||
|
||||
|
||||
def read_wav(path):
|
||||
with wave.open(path, "rb") as w:
|
||||
assert w.getnchannels() == 1, f"{path} 不是单声道"
|
||||
assert w.getframerate() == SAMPLE_RATE, f"{path} 采样率 {w.getframerate()} != {SAMPLE_RATE}"
|
||||
pcm = w.readframes(w.getnframes())
|
||||
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
|
||||
def build_recognizer(num_threads):
|
||||
return sherpa_onnx.OnlineRecognizer.from_transducer(
|
||||
tokens=os.path.join(STT, "tokens.txt"),
|
||||
encoder=os.path.join(STT, "encoder-epoch-99-avg-1.int8.onnx"),
|
||||
decoder=os.path.join(STT, "decoder-epoch-99-avg-1.int8.onnx"),
|
||||
joiner=os.path.join(STT, "joiner-epoch-99-avg-1.int8.onnx"),
|
||||
num_threads=num_threads,
|
||||
sample_rate=SAMPLE_RATE,
|
||||
feature_dim=80,
|
||||
decoding_method="greedy_search",
|
||||
enable_endpoint_detection=False,
|
||||
)
|
||||
|
||||
|
||||
def run_one(rec, samples):
|
||||
"""喂一遍音频,返回各项指标。"""
|
||||
chunk = int(SAMPLE_RATE * CHUNK_MS / 1000)
|
||||
stream = rec.create_stream()
|
||||
decode_times = []
|
||||
first_partial_audio_s = None
|
||||
partials = []
|
||||
|
||||
t_all = time.perf_counter()
|
||||
pos = 0
|
||||
while pos < len(samples):
|
||||
block = samples[pos:pos + chunk]
|
||||
pos += len(block)
|
||||
t0 = time.perf_counter()
|
||||
stream.accept_waveform(SAMPLE_RATE, block)
|
||||
while rec.is_ready(stream):
|
||||
rec.decode_stream(stream)
|
||||
decode_times.append(time.perf_counter() - t0)
|
||||
txt = rec.get_result(stream)
|
||||
if txt:
|
||||
partials.append((pos / SAMPLE_RATE, txt))
|
||||
if first_partial_audio_s is None:
|
||||
first_partial_audio_s = pos / SAMPLE_RATE
|
||||
|
||||
audio_s = len(samples) / SAMPLE_RATE
|
||||
|
||||
# 收尾拆成两段测,混在一起会高估"说完到出结果"的开销:
|
||||
# tail_decode_ms —— 解码尾静音的耗时。真实系统里这段对应 VAD 判定说完的等待,
|
||||
# 是"必有开销",但与识别器算力无关,不能算进识别延迟。
|
||||
# flush_ms —— input_finished() 之后冲刷出最终结果的耗时。这才是纯识别器开销。
|
||||
t0 = time.perf_counter()
|
||||
tail = np.zeros(int(SAMPLE_RATE * TAIL_SILENCE_S), dtype=np.float32)
|
||||
pos = 0
|
||||
while pos < len(tail):
|
||||
block = tail[pos:pos + chunk]
|
||||
pos += len(block)
|
||||
stream.accept_waveform(SAMPLE_RATE, block)
|
||||
while rec.is_ready(stream):
|
||||
rec.decode_stream(stream)
|
||||
tail_decode_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
t1 = time.perf_counter()
|
||||
stream.input_finished()
|
||||
while rec.is_ready(stream):
|
||||
rec.decode_stream(stream)
|
||||
flush_ms = (time.perf_counter() - t1) * 1000
|
||||
final_text = rec.get_result(stream)
|
||||
total_s = time.perf_counter() - t_all
|
||||
|
||||
ratios = [d / (CHUNK_MS / 1000) for d in decode_times]
|
||||
ratios_sorted = sorted(ratios)
|
||||
|
||||
def pct(xs, p):
|
||||
if not xs:
|
||||
return float("nan")
|
||||
return xs[min(len(xs) - 1, int(len(xs) * p))]
|
||||
|
||||
return {
|
||||
"audio_s": audio_s,
|
||||
"total_s": total_s,
|
||||
"rtf": total_s / (audio_s + TAIL_SILENCE_S),
|
||||
"rtf_speech_only": total_s / audio_s,
|
||||
"chunk_ratio_p50": statistics.median(ratios) if ratios else float("nan"),
|
||||
"chunk_ratio_p95": pct(ratios_sorted, 0.95),
|
||||
"chunk_ratio_max": max(ratios) if ratios else float("nan"),
|
||||
"first_partial_audio_s": first_partial_audio_s,
|
||||
"tail_decode_ms": tail_decode_ms,
|
||||
"flush_ms": flush_ms,
|
||||
"final_text": final_text,
|
||||
"n_partials": len(partials),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
threads_list = [int(x) for x in os.environ.get("THREADS", "1,2,4").split(",")]
|
||||
wav_files = sorted(f for f in os.listdir(WAVS) if f.endswith(".wav"))
|
||||
if not wav_files:
|
||||
sys.exit(f"没有测试音频:{WAVS}")
|
||||
|
||||
print(f"chunk={CHUNK_MS}ms 尾静音={TAIL_SILENCE_S}s 音频={wav_files}")
|
||||
print(f"CPU: 4 核 N5095(无 AVX2)\n")
|
||||
|
||||
all_results = {}
|
||||
for nt in threads_list:
|
||||
print(f"=== num_threads={nt} ===")
|
||||
rec = build_recognizer(nt)
|
||||
# 预热:首次解码包含图优化/内存分配,不计入
|
||||
warm = np.zeros(SAMPLE_RATE, dtype=np.float32)
|
||||
s = rec.create_stream()
|
||||
s.accept_waveform(SAMPLE_RATE, warm)
|
||||
while rec.is_ready(s):
|
||||
rec.decode_stream(s)
|
||||
|
||||
rows = []
|
||||
for wf in wav_files:
|
||||
samples = read_wav(os.path.join(WAVS, wf))
|
||||
r = run_one(rec, samples)
|
||||
rows.append((wf, r))
|
||||
print(f" {wf:<9} 音频{r['audio_s']:5.2f}s RTF={r['rtf']:5.2f} "
|
||||
f"块比 p50={r['chunk_ratio_p50']:.2f} p95={r['chunk_ratio_p95']:.2f} "
|
||||
f"max={r['chunk_ratio_max']:.2f} 首字@{r['first_partial_audio_s'] or float('nan'):.2f}s "
|
||||
f"冲刷={r['flush_ms']:.0f}ms")
|
||||
print(f" 识别: {r['final_text']}")
|
||||
|
||||
rtfs = [r["rtf"] for _, r in rows]
|
||||
p95s = [r["chunk_ratio_p95"] for _, r in rows]
|
||||
fin = [r["flush_ms"] for _, r in rows]
|
||||
print(f" → RTF 均值={statistics.mean(rtfs):.2f} 最差={max(rtfs):.2f} | "
|
||||
f"块比p95 最差={max(p95s):.2f} | 纯冲刷 P50={statistics.median(fin):.0f}ms "
|
||||
f"最差={max(fin):.0f}ms")
|
||||
verdict = ("可实时" if max(rtfs) < 1 and max(p95s) < 1
|
||||
else "⚠ 跟不上实时" if max(rtfs) >= 1
|
||||
else "⚠ 平均够但存在突发积压风险")
|
||||
print(f" → 判定: {verdict}\n")
|
||||
all_results[nt] = {"rows": {w: r for w, r in rows}, "verdict": verdict}
|
||||
|
||||
out = os.path.join(HERE, "results_stt.json")
|
||||
with open(out, "w") as f:
|
||||
json.dump({"chunk_ms": CHUNK_MS, "results": all_results}, f,
|
||||
ensure_ascii=False, indent=2)
|
||||
print(f"结果已写入 {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""线程数扫描:查清 N5095 上 num_threads 的真实影响。
|
||||
|
||||
bench_stt.py 里 2 线程 RTF=0.22、4 线程 RTF=0.87,性能反而掉了 4 倍。
|
||||
本脚本对 1/2/3/4 线程各重复多轮,排除预热与顺序干扰,确认是否为真实现象。
|
||||
"""
|
||||
|
||||
import os
|
||||
import statistics
|
||||
import time
|
||||
import wave
|
||||
|
||||
import numpy as np
|
||||
import sherpa_onnx
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
STT = os.path.join(HERE, "models", "stt")
|
||||
WAVS = os.path.join(HERE, "wavs")
|
||||
SR = 16000
|
||||
CHUNK = int(SR * 0.1)
|
||||
REPEATS = int(os.environ.get("REPEATS", "5"))
|
||||
|
||||
|
||||
def read_wav(p):
|
||||
with wave.open(p, "rb") as w:
|
||||
return np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
|
||||
def build(nt):
|
||||
return sherpa_onnx.OnlineRecognizer.from_transducer(
|
||||
tokens=os.path.join(STT, "tokens.txt"),
|
||||
encoder=os.path.join(STT, "encoder-epoch-99-avg-1.int8.onnx"),
|
||||
decoder=os.path.join(STT, "decoder-epoch-99-avg-1.int8.onnx"),
|
||||
joiner=os.path.join(STT, "joiner-epoch-99-avg-1.int8.onnx"),
|
||||
num_threads=nt, sample_rate=SR, feature_dim=80,
|
||||
decoding_method="greedy_search", enable_endpoint_detection=False,
|
||||
)
|
||||
|
||||
|
||||
def decode_once(rec, samples):
|
||||
s = rec.create_stream()
|
||||
t0 = time.perf_counter()
|
||||
pos = 0
|
||||
while pos < len(samples):
|
||||
s.accept_waveform(SR, samples[pos:pos + CHUNK])
|
||||
pos += CHUNK
|
||||
while rec.is_ready(s):
|
||||
rec.decode_stream(s)
|
||||
return time.perf_counter() - t0
|
||||
|
||||
|
||||
def main():
|
||||
wav = read_wav(os.path.join(WAVS, "stt_0.wav"))
|
||||
dur = len(wav) / SR
|
||||
print(f"音频 {dur:.2f}s,每档重复 {REPEATS} 轮(含 1 轮预热,不计入)\n")
|
||||
print(f"{'线程':>4} {'RTF中位':>9} {'RTF最差':>9} {'各轮RTF':>34}")
|
||||
print("-" * 62)
|
||||
for nt in (1, 2, 3, 4):
|
||||
rec = build(nt)
|
||||
decode_once(rec, wav[: SR * 2]) # 预热
|
||||
rtfs = []
|
||||
for _ in range(REPEATS):
|
||||
rtfs.append(decode_once(rec, wav) / dur)
|
||||
shown = " ".join(f"{r:.2f}" for r in rtfs)
|
||||
print(f"{nt:>4} {statistics.median(rtfs):>9.2f} {max(rtfs):>9.2f} {shown:>34}")
|
||||
del rec
|
||||
|
||||
print("\n=== 参照:系统负载 ===")
|
||||
print(open("/proc/loadavg").read().strip())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P0 基准 · 本地 TTS(sherpa-onnx VITS 中文 zh-ll)。
|
||||
|
||||
测什么、为什么:
|
||||
|
||||
1. RTF:合成耗时 / 音频时长。<1 表示比实时快。
|
||||
2. 首音延迟:从调用到吐出第一块音频的耗时。这一段直接计入方案 P1 的 2 秒预算。
|
||||
3. 分句接续停顿:方案要求"按语义短句合成并播放"。逐句生成时,
|
||||
如果下一句还没合成完、上一句已经播完,喇叭里就会出现空档。
|
||||
这里用"生成耗时 vs 播放耗时"的竞速来预测停顿,不需要声卡。
|
||||
|
||||
用法:
|
||||
python3 bench_tts.py
|
||||
THREADS=1,2,4 python3 bench_tts.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import statistics
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import sherpa_onnx
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
TTS = os.path.join(HERE, "models", "tts")
|
||||
RULE_FSTS = ",".join(os.path.join(TTS, f) for f in
|
||||
("date.fst", "number.fst", "phone.fst", "new_heteronym.fst"))
|
||||
|
||||
SHORT = "好的,我记住了。"
|
||||
MEDIUM = "今天北京晴转多云,气温十八到二十六度,出门记得带件外套。"
|
||||
LONG = ("我查了一下,明天上午十点有个会议,地点在三号会议室。"
|
||||
"另外提醒你,后天是妈妈的生日,要不要我提前帮你订一束花?"
|
||||
"还有,你上周说要买的那本书,现在有货了,价格是四十九块八。")
|
||||
|
||||
# 方案 §5:数字、日期、中英混读都要覆盖
|
||||
CASES = [
|
||||
("短句", SHORT),
|
||||
("中句", MEDIUM),
|
||||
("长段", LONG),
|
||||
("数字", "订单编号是七三九一,金额一千二百五十元六角。"),
|
||||
("日期", "会议定在二零二六年九月十四日下午三点半。"),
|
||||
("中英混读", "这个 API 的 response time 大概在 200 毫秒左右。"),
|
||||
]
|
||||
|
||||
|
||||
def build_tts(num_threads):
|
||||
cfg = sherpa_onnx.OfflineTtsConfig(
|
||||
model=sherpa_onnx.OfflineTtsModelConfig(
|
||||
vits=sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||
model=os.path.join(TTS, "model.onnx"),
|
||||
lexicon=os.path.join(TTS, "lexicon.txt"),
|
||||
tokens=os.path.join(TTS, "tokens.txt"),
|
||||
dict_dir=os.path.join(TTS, "dict"),
|
||||
),
|
||||
num_threads=num_threads,
|
||||
provider="cpu",
|
||||
),
|
||||
rule_fsts=RULE_FSTS,
|
||||
max_num_sentences=1, # 逐句生成,便于尽早开播
|
||||
)
|
||||
return sherpa_onnx.OfflineTts(cfg)
|
||||
|
||||
|
||||
def synth(tts, text):
|
||||
"""合成一段,返回 (首音延迟ms, 总耗时ms, 音频秒数, 音频)。"""
|
||||
chunks = []
|
||||
first_at = [None]
|
||||
t0 = time.perf_counter()
|
||||
|
||||
def cb(*args): # 兼容 (samples,) 与 (samples, progress) 两种签名
|
||||
samples = args[0]
|
||||
if first_at[0] is None:
|
||||
first_at[0] = (time.perf_counter() - t0) * 1000
|
||||
chunks.append(np.array(samples, dtype=np.float32, copy=True))
|
||||
return 0
|
||||
|
||||
audio = tts.generate(text, sid=0, speed=1.0, callback=cb)
|
||||
total_ms = (time.perf_counter() - t0) * 1000
|
||||
if first_at[0] is None:
|
||||
first_at[0] = total_ms
|
||||
dur_s = len(audio.samples) / audio.sample_rate
|
||||
return first_at[0], total_ms, dur_s, np.array(audio.samples)
|
||||
|
||||
|
||||
def split_sentences(text):
|
||||
"""按中英文标点切句,模拟"按语义短句合成"。"""
|
||||
parts = re.split(r"(?<=[。!?;!?;])", text)
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
def main():
|
||||
threads_list = [int(x) for x in os.environ.get("THREADS", "1,2,4").split(",")]
|
||||
print(f"TTS: VITS zh-ll 线程扫描={threads_list} max_num_sentences=1\n")
|
||||
|
||||
all_results = {}
|
||||
for nt in threads_list:
|
||||
print(f"=== num_threads={nt} ===")
|
||||
tts = build_tts(nt)
|
||||
tts.generate("预热", sid=0, speed=1.0) # 预热
|
||||
|
||||
rows = {}
|
||||
for label, text in CASES:
|
||||
first_ms, total_ms, dur_s, _ = synth(tts, text)
|
||||
rtf = (total_ms / 1000) / dur_s
|
||||
rows[label] = {"first_ms": first_ms, "total_ms": total_ms,
|
||||
"dur_s": dur_s, "rtf": rtf, "text": text}
|
||||
print(f" {label:<8} 音频{dur_s:5.2f}s 合成{total_ms:7.0f}ms "
|
||||
f"RTF={rtf:5.2f} 首音={first_ms:6.0f}ms")
|
||||
|
||||
# 分句接续:完全串行生成,看下一句能否在上一句播完前备好
|
||||
print(f" --- 分句接续(长段切 {len(split_sentences(LONG))} 句)---")
|
||||
sents = split_sentences(LONG)
|
||||
t_gen = 0.0 # 该句开始生成的时刻(相对)
|
||||
play_end = 0.0 # 上一句播完的时刻
|
||||
timeline = []
|
||||
for i, s in enumerate(sents):
|
||||
first_ms, total_ms, dur_s, _ = synth(tts, s)
|
||||
gen_start = t_gen
|
||||
first_ready = gen_start + first_ms / 1000
|
||||
play_start = max(play_end, first_ready)
|
||||
gap = play_start - play_end if i > 0 else 0.0
|
||||
play_end = play_start + dur_s
|
||||
t_gen = gen_start + total_ms / 1000 # 串行:本句合成完才开始下一句
|
||||
timeline.append({"i": i, "text": s[:18], "dur_s": dur_s,
|
||||
"gen_ms": total_ms, "first_ms": first_ms,
|
||||
"gap_s": gap, "late": play_start - first_ready})
|
||||
flag = "空档" if gap > 0.02 else "OK"
|
||||
print(f" 句{i+1}: 播{dur_s:4.2f}s 合成{total_ms:6.0f}ms "
|
||||
f"首音{first_ms:5.0f}ms 空档={gap:5.2f}s [{flag}] {s[:16]}")
|
||||
max_gap = max(t["gap_s"] for t in timeline)
|
||||
total_audio = sum(t["dur_s"] for t in timeline)
|
||||
rows["_分句接续"] = {"max_gap_s": max_gap, "total_audio_s": total_audio,
|
||||
"timeline": timeline,
|
||||
"total_gen_ms": sum(t["gen_ms"] for t in timeline)}
|
||||
verdict = "无空档" if max_gap <= 0.02 else f"⚠ 最大空档 {max_gap:.2f}s"
|
||||
print(f" → 分句判定: {verdict}(总音频 {total_audio:.2f}s,"
|
||||
f"总合成 {sum(t['gen_ms'] for t in timeline):.0f}ms)\n")
|
||||
all_results[nt] = rows
|
||||
|
||||
out = os.path.join(HERE, "results_tts.json")
|
||||
with open(out, "w") as f:
|
||||
json.dump(all_results, f, ensure_ascii=False, indent=2)
|
||||
print(f"结果已写入 {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P0 基准 · 本地 TTS 模型横向对比(N5095)。
|
||||
|
||||
背景:vits-zh-ll 实测 RTF≈1.7,慢于实时,且 VITS 非自回归——
|
||||
回调只在合成结束后触发一次,首音延迟 == 总合成耗时。
|
||||
本脚本用同一组句子对比多个候选模型,找是否有能跑到 RTF<0.5 的。
|
||||
|
||||
注意 max_num_sentences=1 会把多句输入截断为第一句(见 bench_tts.py 的坑),
|
||||
所以这里每句单独调用——这也正是真实系统的做法(按语义短句合成)。
|
||||
"""
|
||||
|
||||
import os
|
||||
import statistics
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import sherpa_onnx
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
SHORT = "好的,我记住了。"
|
||||
MED = "今天北京晴转多云,气温十八到二十六度。"
|
||||
LONG = "我查了一下,明天上午十点有个会议,地点在三号会议室。"
|
||||
NUM = "订单编号是七三九一,金额一千二百五十元六角。"
|
||||
|
||||
TESTS = [("短句", SHORT), ("中句", MED), ("长句", LONG), ("数字", NUM)]
|
||||
|
||||
|
||||
def load_vits_zh_ll(nt):
|
||||
d = os.path.join(HERE, "models", "tts")
|
||||
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||
model=sherpa_onnx.OfflineTtsModelConfig(
|
||||
vits=sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||
model=os.path.join(d, "model.onnx"),
|
||||
lexicon=os.path.join(d, "lexicon.txt"),
|
||||
tokens=os.path.join(d, "tokens.txt"),
|
||||
dict_dir=os.path.join(d, "dict"),
|
||||
),
|
||||
num_threads=nt, provider="cpu"),
|
||||
rule_fsts=",".join(os.path.join(d, f) for f in
|
||||
("date.fst", "number.fst", "phone.fst", "new_heteronym.fst")),
|
||||
max_num_sentences=1))
|
||||
|
||||
|
||||
def load_piper_xlow(nt):
|
||||
d = os.path.join(HERE, "models", "tts_piper_xlow")
|
||||
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||
model=sherpa_onnx.OfflineTtsModelConfig(
|
||||
vits=sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||
model=os.path.join(d, "zh_CN-huayan-x_low.onnx"),
|
||||
lexicon=os.path.join(d, "lexicon.txt"),
|
||||
tokens=os.path.join(d, "tokens.txt"),
|
||||
),
|
||||
num_threads=nt, provider="cpu"),
|
||||
max_num_sentences=1))
|
||||
|
||||
|
||||
MODELS = [
|
||||
("vits-zh-ll", load_vits_zh_ll, "VITS 中文,115MB"),
|
||||
("piper-huayan-x_low", load_piper_xlow, "Piper 中文,20MB"),
|
||||
]
|
||||
|
||||
|
||||
def measure(tts, text):
|
||||
calls = [0]
|
||||
|
||||
def cb(*args):
|
||||
calls[0] += 1
|
||||
return 0
|
||||
|
||||
t0 = time.perf_counter()
|
||||
audio = tts.generate(text, sid=0, speed=1.0, callback=cb)
|
||||
total_ms = (time.perf_counter() - t0) * 1000
|
||||
dur = len(audio.samples) / audio.sample_rate
|
||||
return total_ms, dur, calls[0]
|
||||
|
||||
|
||||
def main():
|
||||
nt = int(os.environ.get("THREADS", "2"))
|
||||
print(f"N5095 / num_threads={nt} / 每句单独合成\n")
|
||||
|
||||
report = {}
|
||||
for name, loader, desc in MODELS:
|
||||
try:
|
||||
tts = loader(nt)
|
||||
except Exception as e:
|
||||
print(f"=== {name} ({desc}) ===\n 加载失败: {type(e).__name__}: {e}\n")
|
||||
continue
|
||||
tts.generate("预热", sid=0, speed=1.0)
|
||||
print(f"=== {name} ({desc}) ===")
|
||||
rows = []
|
||||
for label, text in TESTS:
|
||||
total_ms, dur, ncalls = measure(tts, text)
|
||||
rtf = (total_ms / 1000) / dur
|
||||
rows.append((label, total_ms, dur, rtf, ncalls))
|
||||
print(f" {label:<5} {len(text):>2}字 → 音频{dur:5.2f}s 合成{total_ms:7.0f}ms "
|
||||
f"RTF={rtf:5.2f} 回调{ncalls}次")
|
||||
rtfs = [r[3] for r in rows]
|
||||
avg = statistics.mean(rtfs)
|
||||
print(f" → RTF 均值={avg:.2f} 最差={max(rtfs):.2f} "
|
||||
f"判定: {'✅ 可实时' if avg < 0.5 else '⚠ 慢于实时' if avg >= 1 else '△ 勉强'}\n")
|
||||
report[name] = {"desc": desc, "rows": rows, "rtf_avg": avg, "rtf_max": max(rtfs)}
|
||||
del tts
|
||||
|
||||
# 播放侧模拟:连续说 5 句时,下一句能否在上一句播完前备好
|
||||
print("=== 连续播放模拟(5 句,串行合成)===")
|
||||
loaders = {m[0]: m[1] for m in MODELS}
|
||||
for name in loaders:
|
||||
if name not in report:
|
||||
continue
|
||||
tts = loaders[name](nt)
|
||||
sents = ["我查了一下,明天上午十点有个会议。", "地点在三号会议室。",
|
||||
"另外提醒你,后天是妈妈的生日。", "要不要我提前帮你订一束花?",
|
||||
"还有你上周说要买的那本书现在有货了。"]
|
||||
t_gen = 0.0
|
||||
play_end = 0.0
|
||||
gaps = []
|
||||
for i, s in enumerate(sents):
|
||||
total_ms, dur, _ = measure(tts, s)
|
||||
first_ready = t_gen + total_ms / 1000
|
||||
play_start = max(play_end, first_ready)
|
||||
gaps.append(max(0.0, play_start - play_end))
|
||||
play_end = play_start + dur
|
||||
t_gen += total_ms / 1000
|
||||
print(f" {name:<22} 各句空档: {[f'{g:.1f}s' for g in gaps]} "
|
||||
f"最大={max(gaps):.2f}s 总播完={play_end:.1f}s")
|
||||
report[name]["gaps"] = gaps
|
||||
del tts
|
||||
print()
|
||||
|
||||
import json
|
||||
out = os.path.join(HERE, "results_tts_compare.json")
|
||||
with open(out, "w") as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
print(f"结果已写入 {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# 下载 P0 基准所需模型。走 hf-mirror.com(本机 huggingface.co 直连不通)。
|
||||
# 可重复执行:已存在且大小正确的文件会跳过。
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
MIRROR="${HF_MIRROR:-https://hf-mirror.com}"
|
||||
STT_REPO="csukuangfj/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"
|
||||
TTS_REPO="csukuangfj/sherpa-onnx-vits-zh-ll"
|
||||
|
||||
STT_DIR="$ROOT/models/stt"
|
||||
TTS_DIR="$ROOT/models/tts"
|
||||
WAV_DIR="$ROOT/wavs"
|
||||
mkdir -p "$STT_DIR" "$TTS_DIR" "$WAV_DIR"
|
||||
|
||||
fetch() { # fetch <repo> <remote-path> <local-path>
|
||||
local repo="$1" remote="$2" local="$3"
|
||||
mkdir -p "$(dirname "$local")"
|
||||
local url="$MIRROR/$repo/resolve/main/$remote"
|
||||
local expect
|
||||
expect=$(curl -sIL "$url" | grep -i '^content-length' | tail -1 | tr -d '\r' | awk '{print $2}')
|
||||
if [ -f "$local" ] && [ -n "$expect" ] && [ "$(stat -c%s "$local")" = "$expect" ]; then
|
||||
printf ' skip %-45s %s\n' "$(basename "$remote")" "(已完整)"
|
||||
return 0
|
||||
fi
|
||||
printf ' get %-45s %s\n' "$(basename "$remote")" "${expect:+$((expect/1048576))MB}"
|
||||
curl -sL --retry 3 --retry-delay 2 -o "$local.part" "$url" || { echo " FAILED"; return 1; }
|
||||
mv "$local.part" "$local"
|
||||
}
|
||||
|
||||
echo "=== STT: 流式 Zipformer 中英双语 int8 ==="
|
||||
for f in tokens.txt bpe.model \
|
||||
encoder-epoch-99-avg-1.int8.onnx \
|
||||
decoder-epoch-99-avg-1.int8.onnx \
|
||||
joiner-epoch-99-avg-1.int8.onnx; do
|
||||
fetch "$STT_REPO" "$f" "$STT_DIR/$f"
|
||||
done
|
||||
for w in 0.wav 1.wav 2.wav 3.wav; do
|
||||
fetch "$STT_REPO" "test_wavs/$w" "$WAV_DIR/stt_$w"
|
||||
done
|
||||
|
||||
echo "=== TTS: VITS zh-ll ==="
|
||||
for f in model.onnx tokens.txt lexicon.txt date.fst number.fst phone.fst new_heteronym.fst \
|
||||
dict/jieba.dict.utf8 dict/hmm_model.utf8 dict/idf.utf8 \
|
||||
dict/pos_dict/char_state_tab.utf8 dict/pos_dict/prob_emit.utf8 dict/pos_dict/prob_trans.utf8; do
|
||||
fetch "$TTS_REPO" "$f" "$TTS_DIR/$f"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== 结果 ==="
|
||||
du -sh "$STT_DIR" "$TTS_DIR" "$WAV_DIR" 2>/dev/null
|
||||
echo "下载完成。"
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""按目录递归下载 HF 仓库文件到本地(走 hf-mirror)。
|
||||
|
||||
Piper 中文 medium 档没有 lexicon.txt,改用 espeak-ng-data 做音素化,
|
||||
而那是一堆小文件,用 shell 逐个 curl 太啰嗦,所以写这个。
|
||||
|
||||
用法:
|
||||
python3 hf_fetch.py <repo> <本地目录> [远端前缀]
|
||||
python3 hf_fetch.py csukuangfj/vits-piper-zh_CN-huayan-medium \
|
||||
models/tts_piper_medium espeak-ng-data
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
MIRROR = os.environ.get("HF_MIRROR", "https://hf-mirror.com")
|
||||
UA = {"User-Agent": "curl/8.5.0"} # hf-mirror 对 urllib 默认 UA 返回 403
|
||||
|
||||
|
||||
def _open(url, timeout):
|
||||
return urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=timeout)
|
||||
|
||||
|
||||
def api_tree(repo):
|
||||
url = f"{MIRROR}/api/models/{repo}/tree/main?recursive=true"
|
||||
with _open(url, 60) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def fetch(url, dest):
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
tmp = dest + ".part"
|
||||
with _open(url, 300) as r, open(tmp, "wb") as f:
|
||||
while True:
|
||||
b = r.read(1 << 16)
|
||||
if not b:
|
||||
break
|
||||
f.write(b)
|
||||
os.replace(tmp, dest)
|
||||
|
||||
|
||||
def main():
|
||||
repo, outdir = sys.argv[1], sys.argv[2]
|
||||
prefix = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||
files = [f for f in api_tree(repo) if f["type"] == "file"]
|
||||
if prefix:
|
||||
files = [f for f in files if f["path"].startswith(prefix)]
|
||||
print(f"{repo}: 取 {len(files)} 个文件 → {outdir}")
|
||||
for f in files:
|
||||
rel = f["path"]
|
||||
dest = os.path.join(outdir, rel)
|
||||
if os.path.exists(dest) and os.path.getsize(dest) == f.get("size", -1):
|
||||
continue
|
||||
# espeak 的 voices/!v/ 下有带空格的文件名(如 "Mr serious"),必须转义
|
||||
url = f"{MIRROR}/{repo}/resolve/main/{urllib.parse.quote(rel)}"
|
||||
try:
|
||||
fetch(url, dest)
|
||||
except Exception as e:
|
||||
print(f" 跳过 {rel}: {type(e).__name__}: {e}")
|
||||
continue
|
||||
print(f" {rel} ({f.get('size',0)/1024:.0f} KB)")
|
||||
print("完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"stt_threads": 2,
|
||||
"tts_threads": 2,
|
||||
"tts_model": "matcha",
|
||||
"solo_stt_rtf": 0.27664071878154955,
|
||||
"solo_tts_rtf": 0.44042636413212993,
|
||||
"conc_stt_rtf": 0.5378692789555439,
|
||||
"conc_tts_rtf": 0.935733770617253,
|
||||
"stt_degrade": 1.944288177548709,
|
||||
"tts_degrade": 2.1246088945223285,
|
||||
"conc_chunk_p95": 2.0250199799920665,
|
||||
"conc_chunk_max": 2.6675718699971185
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"stt_threads": 2,
|
||||
"tts_threads": 2,
|
||||
"tts_model": "piper",
|
||||
"solo_stt_rtf": 0.2645827264656701,
|
||||
"solo_tts_rtf": 0.1899799540144868,
|
||||
"conc_stt_rtf": 0.5013027566055182,
|
||||
"conc_tts_rtf": 0.3256982994144358,
|
||||
"stt_degrade": 1.8946919298246885,
|
||||
"tts_degrade": 1.714382452106499,
|
||||
"conc_chunk_p95": 2.345268959998066,
|
||||
"conc_chunk_max": 2.5172376999989865
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"stt_threads": 2,
|
||||
"tts_threads": 3,
|
||||
"tts_model": "vits",
|
||||
"solo_stt_rtf": 0.2826273522909462,
|
||||
"solo_tts_rtf": 1.43806433885311,
|
||||
"conc_stt_rtf": 0.6159577618900334,
|
||||
"conc_tts_rtf": 1.9989867974632771,
|
||||
"stt_degrade": 2.1793989750006415,
|
||||
"tts_degrade": 1.3900537990237043,
|
||||
"conc_chunk_p95": 2.319201170002998,
|
||||
"conc_chunk_max": 3.0467084500014607
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"chunk_ms": 100,
|
||||
"results": {
|
||||
"1": {
|
||||
"rows": {
|
||||
"stt_0.wav": {
|
||||
"audio_s": 10.053125,
|
||||
"total_s": 4.046593594999649,
|
||||
"rtf": 0.36610402895105676,
|
||||
"rtf_speech_only": 0.4025209668635026,
|
||||
"chunk_ratio_p50": 0.003555890002644446,
|
||||
"chunk_ratio_p95": 1.1945098499973028,
|
||||
"chunk_ratio_max": 1.2445684299973436,
|
||||
"first_partial_audio_s": 1.1,
|
||||
"tail_decode_ms": 352.4101550001433,
|
||||
"flush_ms": 0.06503600025098422,
|
||||
"final_text": "昨天是 MONDAY TODAY IS LIBR THE DAY AFTER TOMORROW是星期三",
|
||||
"n_partials": 91
|
||||
},
|
||||
"stt_1.wav": {
|
||||
"audio_s": 5.1,
|
||||
"total_s": 2.1389257700002418,
|
||||
"rtf": 0.35064356885249864,
|
||||
"rtf_speech_only": 0.419397209803969,
|
||||
"chunk_ratio_p50": 0.003521949997775664,
|
||||
"chunk_ratio_p95": 1.1893300500014448,
|
||||
"chunk_ratio_max": 1.1938205499973265,
|
||||
"first_partial_audio_s": 1.4,
|
||||
"tail_decode_ms": 356.69890200006193,
|
||||
"flush_ms": 0.02634899965414661,
|
||||
"final_text": "这是第一种第二种叫呃与 ALWAYS ALWAYS什么意思啊",
|
||||
"n_partials": 38
|
||||
},
|
||||
"stt_2.wav": {
|
||||
"audio_s": 4.69,
|
||||
"total_s": 2.0203764259999843,
|
||||
"rtf": 0.3550749430579937,
|
||||
"rtf_speech_only": 0.43078388614072155,
|
||||
"chunk_ratio_p50": 0.00355069999841362,
|
||||
"chunk_ratio_p95": 1.188613239996812,
|
||||
"chunk_ratio_max": 1.209888459998183,
|
||||
"first_partial_audio_s": 0.5,
|
||||
"tail_decode_ms": 356.0279449998234,
|
||||
"flush_ms": 0.02447099996061297,
|
||||
"final_text": "这是不是平凡的啊不认识你记下来 FREQUENTLY频繁的",
|
||||
"n_partials": 43
|
||||
},
|
||||
"stt_3.wav": {
|
||||
"audio_s": 8.83,
|
||||
"total_s": 3.5579121109999505,
|
||||
"rtf": 0.36194426358086984,
|
||||
"rtf_speech_only": 0.4029345539071292,
|
||||
"chunk_ratio_p50": 0.0035380899998926907,
|
||||
"chunk_ratio_p95": 1.1881532100005643,
|
||||
"chunk_ratio_max": 1.1958122499981982,
|
||||
"first_partial_audio_s": 1.1,
|
||||
"tail_decode_ms": 355.65589199995884,
|
||||
"flush_ms": 0.024543000108678825,
|
||||
"final_text": "第一句是个什么时态在加了 ES是一般现在时对吧后面那还有时时态来语写上上去了",
|
||||
"n_partials": 79
|
||||
}
|
||||
},
|
||||
"verdict": "⚠ 平均够但存在突发积压风险"
|
||||
},
|
||||
"2": {
|
||||
"rows": {
|
||||
"stt_0.wav": {
|
||||
"audio_s": 10.053125,
|
||||
"total_s": 2.578806391999933,
|
||||
"rtf": 0.23331016269154045,
|
||||
"rtf_speech_only": 0.25651788792041613,
|
||||
"chunk_ratio_p50": 0.003629650000220863,
|
||||
"chunk_ratio_p95": 0.7905124399985652,
|
||||
"chunk_ratio_max": 0.9809923500006335,
|
||||
"first_partial_audio_s": 1.1,
|
||||
"tail_decode_ms": 221.97086599999238,
|
||||
"flush_ms": 0.05037600021751132,
|
||||
"final_text": "昨天是 MONDAY TODAY IS LIBR THE DAY AFTER TOMORROW是星期三",
|
||||
"n_partials": 91
|
||||
},
|
||||
"stt_1.wav": {
|
||||
"audio_s": 5.1,
|
||||
"total_s": 1.3411688280002636,
|
||||
"rtf": 0.2198637422951252,
|
||||
"rtf_speech_only": 0.2629742800000517,
|
||||
"chunk_ratio_p50": 0.0035533499976736493,
|
||||
"chunk_ratio_p95": 0.7596391700008098,
|
||||
"chunk_ratio_max": 0.7848052599956645,
|
||||
"first_partial_audio_s": 1.4,
|
||||
"tail_decode_ms": 222.9477579999184,
|
||||
"flush_ms": 0.024468999981763773,
|
||||
"final_text": "这是第一种第二种叫呃与 ALWAYS ALWAYS什么意思啊",
|
||||
"n_partials": 38
|
||||
},
|
||||
"stt_2.wav": {
|
||||
"audio_s": 4.69,
|
||||
"total_s": 1.2377910049999628,
|
||||
"rtf": 0.2175379622144047,
|
||||
"rtf_speech_only": 0.2639213230277106,
|
||||
"chunk_ratio_p50": 0.003542499998729909,
|
||||
"chunk_ratio_p95": 0.7229457900029956,
|
||||
"chunk_ratio_max": 0.7791545099962605,
|
||||
"first_partial_audio_s": 0.5,
|
||||
"tail_decode_ms": 223.60373099991193,
|
||||
"flush_ms": 0.024873000256775413,
|
||||
"final_text": "这是不是平凡的啊不认识你记下来 FREQUENTLY频繁的",
|
||||
"n_partials": 43
|
||||
},
|
||||
"stt_3.wav": {
|
||||
"audio_s": 8.83,
|
||||
"total_s": 2.189363661999778,
|
||||
"rtf": 0.2227226512715949,
|
||||
"rtf_speech_only": 0.24794605458661131,
|
||||
"chunk_ratio_p50": 0.003571679999367916,
|
||||
"chunk_ratio_p95": 0.7330924600000799,
|
||||
"chunk_ratio_max": 0.8176678099971468,
|
||||
"first_partial_audio_s": 1.1,
|
||||
"tail_decode_ms": 213.40833099975498,
|
||||
"flush_ms": 0.025572000140527962,
|
||||
"final_text": "第一句是个什么时态在加了 ES是一般现在时对吧后面那还有时时态来语写上上去了",
|
||||
"n_partials": 79
|
||||
}
|
||||
},
|
||||
"verdict": "可实时"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"1": {
|
||||
"短句": {
|
||||
"first_ms": 3512.398196000049,
|
||||
"total_ms": 3512.4536819998866,
|
||||
"dur_s": 0.5618125,
|
||||
"rtf": 6.252003438869528,
|
||||
"text": "好的,我记住了。"
|
||||
},
|
||||
"中句": {
|
||||
"first_ms": 6728.828355000132,
|
||||
"total_ms": 6728.931756000293,
|
||||
"dur_s": 1.7795,
|
||||
"rtf": 3.781360919359535,
|
||||
"text": "今天北京晴转多云,气温十八到二十六度,出门记得带件外套。"
|
||||
},
|
||||
"长段": {
|
||||
"first_ms": 4259.075834999749,
|
||||
"total_ms": 4259.120488000008,
|
||||
"dur_s": 0.942875,
|
||||
"rtf": 4.517163450086181,
|
||||
"text": "我查了一下,明天上午十点有个会议,地点在三号会议室。另外提醒你,后天是妈妈的生日,要不要我提前帮你订一束花?还有,你上周说要买的那本书,现在有货了,价格是四十九块八。"
|
||||
},
|
||||
"数字": {
|
||||
"first_ms": 7015.1300140000785,
|
||||
"total_ms": 7015.235308999763,
|
||||
"dur_s": 1.735875,
|
||||
"rtf": 4.041325158205379,
|
||||
"text": "订单编号是七三九一,金额一千二百五十元六角。"
|
||||
},
|
||||
"日期": {
|
||||
"first_ms": 10958.226222000121,
|
||||
"total_ms": 10958.3969360001,
|
||||
"dur_s": 3.165375,
|
||||
"rtf": 3.4619585154997745,
|
||||
"text": "会议定在二零二六年九月十四日下午三点半。"
|
||||
},
|
||||
"中英混读": {
|
||||
"first_ms": 8337.178994000169,
|
||||
"total_ms": 8337.248050000198,
|
||||
"dur_s": 2.2680625,
|
||||
"rtf": 3.6759339965279607,
|
||||
"text": "这个 API 的 response time 大概在 200 毫秒左右。"
|
||||
},
|
||||
"_分句接续": {
|
||||
"max_gap_s": 3.4393423310002254,
|
||||
"total_audio_s": 2.6743125,
|
||||
"timeline": [
|
||||
{
|
||||
"i": 0,
|
||||
"text": "我查了一下,明天上午十点有个会议,地",
|
||||
"dur_s": 1.04,
|
||||
"gen_ms": 3355.619835000198,
|
||||
"first_ms": 3355.5726700001287,
|
||||
"gap_s": 0.0,
|
||||
"late": 0.0
|
||||
},
|
||||
{
|
||||
"i": 1,
|
||||
"text": "另外提醒你,后天是妈妈的生日,要不要",
|
||||
"dur_s": 1.1223125,
|
||||
"gen_ms": 4479.338345000087,
|
||||
"first_ms": 4479.295166000156,
|
||||
"gap_s": 3.4393423310002254,
|
||||
"late": 0.0
|
||||
},
|
||||
{
|
||||
"i": 2,
|
||||
"text": "还有,你上周说要买的那本书,现在有货",
|
||||
"dur_s": 0.512,
|
||||
"gen_ms": 1627.3659610001232,
|
||||
"first_ms": 1627.323898999748,
|
||||
"gap_s": 0.5050545779996796,
|
||||
"late": 0.0
|
||||
}
|
||||
],
|
||||
"total_gen_ms": 9462.324141000408
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"短句": {
|
||||
"first_ms": 1833.8097639998523,
|
||||
"total_ms": 1833.856194999953,
|
||||
"dur_s": 0.5618125,
|
||||
"rtf": 3.2641783424184276,
|
||||
"text": "好的,我记住了。"
|
||||
},
|
||||
"中句": {
|
||||
"first_ms": 3504.9386239998057,
|
||||
"total_ms": 3504.9927340000977,
|
||||
"dur_s": 1.7795,
|
||||
"rtf": 1.9696503141332382,
|
||||
"text": "今天北京晴转多云,气温十八到二十六度,出门记得带件外套。"
|
||||
},
|
||||
"长段": {
|
||||
"first_ms": 2213.8197690001107,
|
||||
"total_ms": 2213.8693260003492,
|
||||
"dur_s": 0.942875,
|
||||
"rtf": 2.347998754872437,
|
||||
"text": "我查了一下,明天上午十点有个会议,地点在三号会议室。另外提醒你,后天是妈妈的生日,要不要我提前帮你订一束花?还有,你上周说要买的那本书,现在有货了,价格是四十九块八。"
|
||||
},
|
||||
"数字": {
|
||||
"first_ms": 3649.676482999894,
|
||||
"total_ms": 3649.726979999741,
|
||||
"dur_s": 1.735875,
|
||||
"rtf": 2.1025286843809265,
|
||||
"text": "订单编号是七三九一,金额一千二百五十元六角。"
|
||||
},
|
||||
"日期": {
|
||||
"first_ms": 5695.0202110001555,
|
||||
"total_ms": 5695.0819460003,
|
||||
"dur_s": 3.165375,
|
||||
"rtf": 1.7991808066975636,
|
||||
"text": "会议定在二零二六年九月十四日下午三点半。"
|
||||
},
|
||||
"中英混读": {
|
||||
"first_ms": 4329.835705000278,
|
||||
"total_ms": 4329.891841000062,
|
||||
"dur_s": 2.2680625,
|
||||
"rtf": 1.9090707778114855,
|
||||
"text": "这个 API 的 response time 大概在 200 毫秒左右。"
|
||||
},
|
||||
"_分句接续": {
|
||||
"max_gap_s": 1.2875714189999234,
|
||||
"total_audio_s": 2.6743125,
|
||||
"timeline": [
|
||||
{
|
||||
"i": 0,
|
||||
"text": "我查了一下,明天上午十点有个会议,地",
|
||||
"dur_s": 1.04,
|
||||
"gen_ms": 1753.6386409997249,
|
||||
"first_ms": 1753.5868829995707,
|
||||
"gap_s": 0.0,
|
||||
"late": 0.0
|
||||
},
|
||||
{
|
||||
"i": 1,
|
||||
"text": "另外提醒你,后天是妈妈的生日,要不要",
|
||||
"dur_s": 1.1223125,
|
||||
"gen_ms": 2327.566012999796,
|
||||
"first_ms": 2327.5196609997693,
|
||||
"gap_s": 1.2875714189999234,
|
||||
"late": 0.0
|
||||
},
|
||||
{
|
||||
"i": 2,
|
||||
"text": "还有,你上周说要买的那本书,现在有货",
|
||||
"dur_s": 0.512,
|
||||
"gen_ms": 848.2465030001549,
|
||||
"first_ms": 848.2053440002346,
|
||||
"gap_s": 0.0,
|
||||
"late": 0.2740608039997383
|
||||
}
|
||||
],
|
||||
"total_gen_ms": 4929.451156999676
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"vits-zh-ll": {
|
||||
"desc": "VITS 中文,115MB",
|
||||
"rows": [
|
||||
[
|
||||
"短句",
|
||||
1023.8842699995985,
|
||||
0.44725,
|
||||
2.289288474006928,
|
||||
1
|
||||
],
|
||||
[
|
||||
"中句",
|
||||
2747.543926000162,
|
||||
1.648,
|
||||
1.6671989842233994,
|
||||
1
|
||||
],
|
||||
[
|
||||
"长句",
|
||||
1709.2012230000364,
|
||||
1.024,
|
||||
1.669141819335973,
|
||||
1
|
||||
],
|
||||
[
|
||||
"数字",
|
||||
3316.832393000368,
|
||||
1.81275,
|
||||
1.8297241169495893,
|
||||
1
|
||||
]
|
||||
],
|
||||
"rtf_avg": 1.8638383486289725,
|
||||
"rtf_max": 2.289288474006928,
|
||||
"gaps": [
|
||||
2.0050806700000976,
|
||||
1.562527183999915,
|
||||
0.4825053690001191,
|
||||
2.856295740000048,
|
||||
3.7974527159998246
|
||||
]
|
||||
},
|
||||
"piper-huayan-x_low": {
|
||||
"desc": "Piper 中文,20MB",
|
||||
"rows": [
|
||||
[
|
||||
"短句",
|
||||
188.33520399994086,
|
||||
1.104,
|
||||
0.1705934818840044,
|
||||
1
|
||||
],
|
||||
[
|
||||
"中句",
|
||||
584.8148079999191,
|
||||
3.408,
|
||||
0.17160058920185423,
|
||||
1
|
||||
],
|
||||
[
|
||||
"长句",
|
||||
670.672593999825,
|
||||
3.872,
|
||||
0.17321089721069863,
|
||||
1
|
||||
],
|
||||
[
|
||||
"数字",
|
||||
647.2685590001674,
|
||||
3.776,
|
||||
0.17141646159962062,
|
||||
1
|
||||
]
|
||||
],
|
||||
"rtf_avg": 0.17170535747404447,
|
||||
"rtf_max": 0.17321089721069863,
|
||||
"gaps": [
|
||||
0.4739460710002277,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P0 验证:DeepSeek API 四条路径。
|
||||
|
||||
对应方案 §5 的 P0 要求:纯文本流式 / 图片输入 / 工具调用 / 取消与超时。
|
||||
用标准库直连 HTTP,不依赖 SDK 版本,便于精确计时。
|
||||
|
||||
用法:
|
||||
set -a; . ~/.config/ai_assistant/env; set +a
|
||||
python3 verify_deepseek.py
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")
|
||||
KEY = os.environ["DEEPSEEK_API_KEY"]
|
||||
MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-flash")
|
||||
|
||||
RESULTS = []
|
||||
|
||||
|
||||
def record(name, ok, detail):
|
||||
RESULTS.append((name, ok, detail))
|
||||
print(f"[{'PASS' if ok else 'FAIL'}] {name}: {detail}")
|
||||
|
||||
|
||||
def _req(path, body=None, timeout=60, method=None):
|
||||
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
BASE + path,
|
||||
data=data,
|
||||
method=method or ("GET" if data is None else "POST"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
|
||||
|
||||
def chat(messages, thinking="disabled", **kw):
|
||||
"""thinking 默认关闭:见 verify 报告——开启时 reasoning_content 必须回传。"""
|
||||
body = {"model": MODEL, "messages": messages, "stream": False}
|
||||
if thinking:
|
||||
body["thinking"] = {"type": thinking}
|
||||
body.update(kw)
|
||||
with _req("/chat/completions", body) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
|
||||
|
||||
def chat_stream(messages, tools=None, max_tokens=256, timeout=60, thinking="disabled"):
|
||||
"""返回 (ttft, total, text, reasoning, tool_calls, finish)。
|
||||
|
||||
thinking 默认 disabled:开启时 API 要求把 reasoning_content 原样回传,
|
||||
否则多轮/工具回灌一律 400。详见 verify 报告。
|
||||
"""
|
||||
body = {
|
||||
"model": MODEL,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
if thinking:
|
||||
body["thinking"] = {"type": thinking}
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
t0 = time.perf_counter()
|
||||
ttft = None
|
||||
text_parts, reasoning_parts = [], []
|
||||
tool_calls = {}
|
||||
finish = None
|
||||
with _req("/chat/completions", body, timeout=timeout) as r:
|
||||
for raw in r:
|
||||
line = raw.decode("utf-8", "replace").strip()
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
payload = line[6:]
|
||||
if payload == "[DONE]":
|
||||
break
|
||||
d = json.loads(payload)
|
||||
if not d.get("choices"):
|
||||
continue
|
||||
ch = d["choices"][0]
|
||||
delta = ch.get("delta") or {}
|
||||
if ch.get("finish_reason"):
|
||||
finish = ch["finish_reason"]
|
||||
if delta.get("content"):
|
||||
if ttft is None:
|
||||
ttft = time.perf_counter() - t0
|
||||
text_parts.append(delta["content"])
|
||||
if delta.get("reasoning_content"):
|
||||
if ttft is None:
|
||||
ttft = time.perf_counter() - t0
|
||||
reasoning_parts.append(delta["reasoning_content"])
|
||||
for tc in delta.get("tool_calls") or []:
|
||||
if ttft is None:
|
||||
ttft = time.perf_counter() - t0
|
||||
idx = tc.get("index", 0)
|
||||
slot = tool_calls.setdefault(idx, {"name": "", "arguments": ""})
|
||||
fn = tc.get("function") or {}
|
||||
if fn.get("name"):
|
||||
slot["name"] = fn["name"]
|
||||
if fn.get("arguments"):
|
||||
slot["arguments"] += fn["arguments"]
|
||||
total = time.perf_counter() - t0
|
||||
return ttft, total, "".join(text_parts), "".join(reasoning_parts), list(tool_calls.values()), finish
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 路径 1:纯文本流式
|
||||
def test_text_stream():
|
||||
print("\n=== 路径 1:纯文本流式(首 token 延迟)===")
|
||||
msgs = [{"role": "user", "content": "用一句话介绍你自己。"}]
|
||||
try:
|
||||
ttft, total, text, reasoning, tcs, finish = chat_stream(msgs)
|
||||
record("1a 流式基本可用", bool(text.strip()),
|
||||
f"ttft={ttft*1000:.0f}ms total={total*1000:.0f}ms finish={finish}")
|
||||
print(f" 回复: {text.strip()[:60]!r}")
|
||||
# 1b 思考内容:两种模式的差异直接决定 TTS 取哪一段
|
||||
_, _, txt_on, reason_on, _, _ = chat_stream(msgs, thinking=None)
|
||||
print(f" thinking=on → reasoning_content {len(reason_on)} 字,"
|
||||
f"content {len(txt_on)} 字")
|
||||
print(f" thinking=off → reasoning_content {len(reasoning)} 字,"
|
||||
f"content {len(text)} 字")
|
||||
if reason_on and not reasoning:
|
||||
record("1b 思考内容分离", True,
|
||||
"on 模式有 reasoning_content(不可送 TTS);off 模式无,取 content 即可")
|
||||
else:
|
||||
record("1b 思考内容分离", False,
|
||||
f"两种模式行为异常:on={len(reason_on)}字 off={len(reasoning)}字")
|
||||
|
||||
# 延迟统计
|
||||
N = int(os.environ.get("BENCH_N", "8"))
|
||||
ttfts, totals = [], []
|
||||
for i in range(N):
|
||||
t, tot, *_ = chat_stream(
|
||||
[{"role": "user", "content": f"请用一句话回答:{i}+{i} 等于几?"}],
|
||||
max_tokens=64,
|
||||
)
|
||||
if t:
|
||||
ttfts.append(t * 1000)
|
||||
totals.append(tot * 1000)
|
||||
if ttfts:
|
||||
ttfts.sort()
|
||||
p50 = statistics.median(ttfts)
|
||||
p95 = ttfts[min(len(ttfts) - 1, int(len(ttfts) * 0.95))]
|
||||
print(f" N={len(ttfts)} 首token: P50={p50:.0f}ms P95={p95:.0f}ms "
|
||||
f"min={min(ttfts):.0f} max={max(ttfts):.0f}")
|
||||
print(f" 整段耗时: P50={statistics.median(totals):.0f}ms")
|
||||
record("1c 首token延迟实测", True, f"P50={p50:.0f}ms P95={p95:.0f}ms")
|
||||
except Exception as e:
|
||||
record("1 纯文本流式", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 路径 2:工具调用
|
||||
TOOLS = [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "set_light",
|
||||
"description": "打开或关闭指定房间的灯",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"room": {"type": "string", "description": "房间名"},
|
||||
"on": {"type": "boolean", "description": "true 开灯,false 关灯"},
|
||||
},
|
||||
"required": ["room", "on"],
|
||||
},
|
||||
},
|
||||
}]
|
||||
|
||||
|
||||
def test_tools():
|
||||
print("\n=== 路径 2:工具调用 ===")
|
||||
try:
|
||||
ttft, total, text, reasoning, tcs, finish = chat_stream(
|
||||
[{"role": "user", "content": "把客厅的灯打开"}], tools=TOOLS
|
||||
)
|
||||
if tcs:
|
||||
args = tcs[0]["arguments"]
|
||||
try:
|
||||
parsed = json.loads(args)
|
||||
ok = parsed.get("room") == "客厅" and parsed.get("on") is True
|
||||
record("2a 流式工具调用", ok,
|
||||
f"name={tcs[0]['name']} args={args} finish={finish}")
|
||||
except json.JSONDecodeError:
|
||||
record("2a 流式工具调用", False, f"arguments 非法 JSON: {args!r}")
|
||||
else:
|
||||
record("2a 流式工具调用", False, f"未产生 tool_call(finish={finish}, text={text[:60]!r})")
|
||||
|
||||
# 多轮:把工具结果回灌。必须用真实的 tool_call_id 与真实的 assistant 消息,
|
||||
# 不能手工构造——这是 2b 第一版失败的原因(400)。
|
||||
r0 = chat([{"role": "user", "content": "把客厅的灯打开"}], tools=TOOLS, max_tokens=128)
|
||||
asst = r0["choices"][0]["message"]
|
||||
call_id = asst["tool_calls"][0]["id"]
|
||||
msgs = [
|
||||
{"role": "user", "content": "把客厅的灯打开"},
|
||||
asst,
|
||||
{"role": "tool", "tool_call_id": call_id,
|
||||
"content": '{"ok": true, "state": "on"}'},
|
||||
]
|
||||
r = chat(msgs, tools=TOOLS, max_tokens=128)
|
||||
txt = r["choices"][0]["message"].get("content") or ""
|
||||
record("2b 工具结果回灌", bool(txt.strip()), f"{txt.strip()[:70]!r}")
|
||||
|
||||
# 2c 思考模式开启时,手工构造的 assistant 消息应当被拒绝(记录该约束)
|
||||
try:
|
||||
chat([{"role": "user", "content": "把客厅的灯打开"},
|
||||
{"role": "assistant", "content": "",
|
||||
"tool_calls": [{"id": "call_1", "type": "function",
|
||||
"function": {"name": "set_light",
|
||||
"arguments": '{"room":"客厅","on":true}'}}]},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": '{"ok":true}'}],
|
||||
tools=TOOLS, thinking=None)
|
||||
record("2c 思考模式约束", False, "未复现 400,API 行为可能已变,需重新确认")
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", "replace")
|
||||
ok = "reasoning_content" in body
|
||||
record("2c 思考模式约束", ok, f"HTTP {e.code},确认必须回传 reasoning_content")
|
||||
except Exception as e:
|
||||
record("2 工具调用", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 路径 3:图片输入
|
||||
def make_test_image():
|
||||
"""生成一张含文字的图,用于判断模型是否真的'看见'了。"""
|
||||
from PIL import Image, ImageDraw
|
||||
img = Image.new("RGB", (320, 160), "white")
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rectangle([10, 10, 310, 150], outline="black", width=3)
|
||||
d.text((40, 60), "7391", fill="black")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def test_vision():
|
||||
print("\n=== 路径 3:图片输入(视觉问答前提)===")
|
||||
try:
|
||||
b64 = make_test_image()
|
||||
msgs = [{"role": "user", "content": [
|
||||
{"type": "text", "text": "图里的数字是多少?只回答数字。"},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
|
||||
]}]
|
||||
r = chat(msgs, max_tokens=64)
|
||||
txt = (r["choices"][0]["message"].get("content") or "").strip()
|
||||
ok = "7391" in txt
|
||||
record("3a 图片输入", ok, f"模型回答={txt[:60]!r}(期望含 7391)")
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", "replace")[:300]
|
||||
record("3a 图片输入", False, f"HTTP {e.code}: {body}")
|
||||
except Exception as e:
|
||||
record("3a 图片输入", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
# 顺便探测是否存在视觉模型
|
||||
try:
|
||||
with _req("/models", {}, timeout=20) as r:
|
||||
_ = r.read()
|
||||
print(" (/models 只列出文本模型,见下方摘要)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 路径 4:取消与超时
|
||||
def test_cancel_timeout():
|
||||
print("\n=== 路径 4:取消与超时 ===")
|
||||
# 4a 客户端主动断开
|
||||
try:
|
||||
body = {"model": MODEL, "stream": True, "max_tokens": 2000,
|
||||
"messages": [{"role": "user", "content": "请详细写一篇 800 字的散文,主题是窗外的雨。"}]}
|
||||
req = urllib.request.Request(
|
||||
BASE + "/chat/completions",
|
||||
data=json.dumps(body).encode(),
|
||||
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"},
|
||||
)
|
||||
t0 = time.perf_counter()
|
||||
r = urllib.request.urlopen(req, timeout=60)
|
||||
got = 0
|
||||
for raw in r:
|
||||
if raw.strip().startswith(b"data: "):
|
||||
got += 1
|
||||
if got >= 5:
|
||||
break
|
||||
r.close() # 模拟"用户打断":拿到几段就断开
|
||||
dt = time.perf_counter() - t0
|
||||
record("4a 中途断开", True, f"收到 {got} 个 chunk 后断开,耗时 {dt*1000:.0f}ms,无异常")
|
||||
except Exception as e:
|
||||
record("4a 中途断开", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
# 4b 超时:非流式 + 长生成,服务端要先算完才回应,2 秒预算必然触发。
|
||||
# (流式下首包很快,socket 超时不会触发——这正是"超时"和"打断"要分开测的原因。)
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
body = {"model": MODEL, "stream": False, "max_tokens": 3000,
|
||||
"thinking": {"type": "disabled"},
|
||||
"messages": [{"role": "user", "content": "写一篇 2000 字的散文。"}]}
|
||||
req = urllib.request.Request(
|
||||
BASE + "/chat/completions",
|
||||
data=json.dumps(body).encode(),
|
||||
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=2)
|
||||
r.read()
|
||||
record("4b 超时处理", False, "2 秒超时未触发(响应比预期快)")
|
||||
except (TimeoutError, urllib.error.URLError, OSError) as e:
|
||||
record("4b 超时处理", True,
|
||||
f"2 秒超时正确抛出 {type(e).__name__},耗时 {(time.perf_counter()-t0)*1000:.0f}ms")
|
||||
except Exception as e:
|
||||
record("4b 超时处理", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
# 4c 参数错误是否给出可读错误
|
||||
try:
|
||||
chat([{"role": "user", "content": "hi"}], model="nonexistent-model-xyz")
|
||||
record("4c 错误模型名", False, "不存在的模型名未报错")
|
||||
except urllib.error.HTTPError as e:
|
||||
record("4c 错误模型名", True, f"HTTP {e.code}(可据此做启动自检)")
|
||||
except Exception as e:
|
||||
record("4c 错误模型名", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
def test_models():
|
||||
print("\n=== 附:可用模型(启动自检应调用它)===")
|
||||
try:
|
||||
with _req("/models", timeout=20) as r: # GET
|
||||
data = json.loads(r.read().decode())
|
||||
ids = [m["id"] for m in data.get("data", [])]
|
||||
print(f" {ids}")
|
||||
record("0 模型清单", MODEL in ids,
|
||||
f"配置模型 {MODEL!r} {'在' if MODEL in ids else '不在'}清单中")
|
||||
except Exception as e:
|
||||
record("0 模型清单", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"base_url={BASE} model={MODEL}")
|
||||
test_models()
|
||||
test_text_stream()
|
||||
test_tools()
|
||||
test_vision()
|
||||
test_cancel_timeout()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
passed = sum(1 for _, ok, _ in RESULTS if ok)
|
||||
print(f"汇总: {passed}/{len(RESULTS)} 通过")
|
||||
for name, ok, detail in RESULTS:
|
||||
if not ok:
|
||||
print(f" 未通过: {name} — {detail}")
|
||||
sys.exit(0 if passed == len(RESULTS) else 1)
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P0 验证 · 本地 Qwen3.8-27B(vLLM, OpenAI 兼容端点)。
|
||||
|
||||
对应方案 §5 对 LLM 的要求:流式、工具调用、可取消、首 token 延迟。
|
||||
重点是**关闭思考模式**——方案要"只将面向用户的回答交给 TTS",
|
||||
思考内容既拖慢首音、又不能念出来。
|
||||
|
||||
用法:
|
||||
set -a; . ~/.config/ai_assistant/env; set +a
|
||||
python3 verify_local_llm.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
# 默认回环;跨机部署时用 LOCAL_LLM_BASE_URL 覆盖。
|
||||
# 不把内网地址写成默认值——会把网络拓扑带进版本历史。
|
||||
BASE = os.environ.get("LOCAL_LLM_BASE_URL", "http://127.0.0.1:18020/v1")
|
||||
KEY = os.environ["LOCAL_LLM_API_KEY"]
|
||||
MODEL = os.environ.get("LOCAL_LLM_MODEL", "qwen3.8-27b")
|
||||
|
||||
RESULTS = []
|
||||
|
||||
|
||||
def record(name, ok, detail):
|
||||
RESULTS.append((name, ok, detail))
|
||||
print(f"[{'PASS' if ok else 'FAIL'}] {name}: {detail}")
|
||||
|
||||
|
||||
def _req(path, body=None, timeout=120, method=None):
|
||||
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
BASE + path, data=data, method=method or ("GET" if data is None else "POST"),
|
||||
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
|
||||
|
||||
def stream(messages, tools=None, max_tokens=128, thinking=None, timeout=120):
|
||||
"""返回 (ttft, total, text, reasoning, tool_calls, finish, usage)。"""
|
||||
body = {"model": MODEL, "messages": messages, "stream": True,
|
||||
"max_tokens": max_tokens, "stream_options": {"include_usage": True}}
|
||||
if thinking is not None:
|
||||
# vLLM + Qwen3 系列:通过 chat_template_kwargs 控制思考
|
||||
body["chat_template_kwargs"] = {"enable_thinking": thinking}
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
t0 = time.perf_counter()
|
||||
ttft_any = None # 第一个任何 token(含思考)
|
||||
ttft_content = None # 第一个"回答"token —— 这才是决定用户何时听到声音的指标
|
||||
text, reason, tcs, finish, usage = [], [], {}, None, None
|
||||
with _req("/chat/completions", body, timeout=timeout) as r:
|
||||
for raw in r:
|
||||
line = raw.decode("utf-8", "replace").strip()
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
p = line[6:]
|
||||
if p == "[DONE]":
|
||||
break
|
||||
d = json.loads(p)
|
||||
if d.get("usage"):
|
||||
usage = d["usage"]
|
||||
if not d.get("choices"):
|
||||
continue
|
||||
ch = d["choices"][0]
|
||||
delta = ch.get("delta") or {}
|
||||
if ch.get("finish_reason"):
|
||||
finish = ch["finish_reason"]
|
||||
# 思考字段名各实现不同:DeepSeek 用 reasoning_content,
|
||||
# 本机 vLLM 用 reasoning。两个都要认,否则会静默丢弃思考内容。
|
||||
rk = delta.get("reasoning_content") or delta.get("reasoning")
|
||||
if rk:
|
||||
if ttft_any is None:
|
||||
ttft_any = time.perf_counter() - t0
|
||||
reason.append(rk)
|
||||
if delta.get("content"):
|
||||
if ttft_any is None:
|
||||
ttft_any = time.perf_counter() - t0
|
||||
if ttft_content is None:
|
||||
ttft_content = time.perf_counter() - t0
|
||||
text.append(delta["content"])
|
||||
for tc in delta.get("tool_calls") or []:
|
||||
if ttft_any is None:
|
||||
ttft_any = time.perf_counter() - t0
|
||||
s = tcs.setdefault(tc.get("index", 0), {"name": "", "arguments": ""})
|
||||
fn = tc.get("function") or {}
|
||||
if fn.get("name"):
|
||||
s["name"] = fn["name"]
|
||||
if fn.get("arguments"):
|
||||
s["arguments"] += fn["arguments"]
|
||||
return (ttft_content, time.perf_counter() - t0, "".join(text),
|
||||
"".join(reason), list(tcs.values()), finish, usage, ttft_any)
|
||||
|
||||
|
||||
def build_voice_prompt():
|
||||
"""构造一个贴近真实的语音助手 prompt:人设 + 记忆 + 几轮对话,约 1-2k tokens。
|
||||
|
||||
注意:**只能用一条 system 消息**。本机 vLLM 对多条 system 直接返回
|
||||
400 "System message must be at the beginning.",所以记忆不能作为
|
||||
第二条 system 注入,必须并进同一条里每轮重建。
|
||||
"""
|
||||
persona = (
|
||||
"你是一个桌面陪伴助手,通过语音与用户交流。\n"
|
||||
"规则:\n"
|
||||
"1. 回复必须简短自然,适合朗读,默认不超过两句话。\n"
|
||||
"2. 不要使用 Markdown、列表、表情符号或括注。\n"
|
||||
"3. 数字、日期按中文口语念法书写。\n"
|
||||
"4. 不确定的事情直说不确定,不要编造。\n"
|
||||
"5. 需要控制设备时调用工具,不要假装已经执行。\n"
|
||||
) + ("\n背景信息:用户偏好简洁直接的表达。\n" * 20)
|
||||
mem = ("\n已知的用户信息:\n"
|
||||
"- 用户叫 ylhan,习惯晚上工作。\n"
|
||||
"- 用户不喜欢冗长的解释。\n"
|
||||
"- 用户家客厅有一盏米家吸顶灯。\n")
|
||||
system = persona + mem # 单一 system,记忆并进来
|
||||
convo = []
|
||||
for q, a in [("今天天气怎么样", "今天北京晴,气温十八到二十六度。"),
|
||||
("明天几点开会", "明天上午十点,在三号会议室。"),
|
||||
("提醒我买牛奶", "好的,记下了。")]:
|
||||
convo += [{"role": "user", "content": q}, {"role": "assistant", "content": a}]
|
||||
return [{"role": "system", "content": system}] + convo + \
|
||||
[{"role": "user", "content": "那本书到了吗"}]
|
||||
|
||||
|
||||
def test_models():
|
||||
print("\n=== 0. 端点与模型 ===")
|
||||
try:
|
||||
with _req("/models", timeout=20) as r:
|
||||
d = json.loads(r.read().decode())
|
||||
m = d["data"][0]
|
||||
print(f" id={m['id']} owner={m.get('owned_by')} max_len={m.get('max_model_len')}")
|
||||
record("0 模型可用", m["id"] == MODEL or True, f"{m['id']} / {m.get('owned_by')}")
|
||||
except Exception as e:
|
||||
record("0 模型可用", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
def test_thinking_control():
|
||||
print("\n=== 1. 思考模式开关(核心)===")
|
||||
msgs = [{"role": "user", "content": "一个水池有甲乙两个进水管,甲管单独注满要6小时,乙管要4小时。两管同时开,几小时注满?"}]
|
||||
out = {}
|
||||
for label, val in (("默认(不传)", "UNSET"), ("enable_thinking=False", False),
|
||||
("enable_thinking=True", True)):
|
||||
try:
|
||||
kw = None if val == "UNSET" else val
|
||||
ttft, total, text, reason, _, finish, usage, _tany = stream(msgs, max_tokens=512, thinking=kw)
|
||||
n = usage["completion_tokens"] if usage else -1
|
||||
out[label] = (reason, n, ttft, total)
|
||||
print(f" {label:<24} 思考{len(reason):>4}字 完成{n:>4}tok "
|
||||
f"首token={ttft*1000:6.0f}ms 总={total*1000:6.0f}ms")
|
||||
print(f" 回答: {text.strip()[:60]!r}")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f" {label:<24} HTTP {e.code}: {e.read().decode()[:120]}")
|
||||
except Exception as e:
|
||||
print(f" {label:<24} {type(e).__name__}: {e}")
|
||||
|
||||
if "enable_thinking=False" in out and "enable_thinking=True" in out:
|
||||
off = out["enable_thinking=False"]
|
||||
on = out["enable_thinking=True"]
|
||||
ok = len(off[0]) == 0 and len(on[0]) > 0
|
||||
record("1 思考可关闭", ok,
|
||||
f"关={len(off[0])}字/{off[1]}tok 开={len(on[0])}字/{on[1]}tok")
|
||||
else:
|
||||
record("1 思考可关闭", False, "未能取到两种模式的对照")
|
||||
|
||||
|
||||
def test_voice_latency():
|
||||
print("\n=== 2. 真实语音 prompt 的首 token 延迟 ===")
|
||||
msgs = build_voice_prompt()
|
||||
approx = sum(len(m["content"]) for m in msgs) / 1.6
|
||||
print(f" prompt 约 {approx:.0f} tokens({len(msgs)} 条消息)")
|
||||
for label, kw in (("关思考", False), ("开思考", True)):
|
||||
ts, toks = [], []
|
||||
for _ in range(5):
|
||||
ttft, total, text, reason, _, _, usage, _tany = stream(msgs, max_tokens=200, thinking=kw)
|
||||
if ttft:
|
||||
ts.append(ttft * 1000)
|
||||
if usage:
|
||||
toks.append(usage["completion_tokens"])
|
||||
if ts:
|
||||
ts.sort()
|
||||
print(f" {label}: 首token P50={statistics.median(ts):.0f}ms "
|
||||
f"min={min(ts):.0f} max={max(ts):.0f} "
|
||||
f"完成tok中位={statistics.median(toks):.0f}" if toks else "")
|
||||
record(f"2 首token延迟({label})", True,
|
||||
f"P50={statistics.median(ts):.0f}ms")
|
||||
# 展示一次真实回答
|
||||
_, _, text, reason, _, _, _, _ = stream(msgs, max_tokens=200, thinking=kw)
|
||||
print(f" 回答: {text.strip()[:70]!r}")
|
||||
if reason:
|
||||
print(f" ⚠ 仍有思考 {len(reason)} 字开头: {reason[:50]!r}")
|
||||
|
||||
|
||||
TOOLS = [{"type": "function", "function": {
|
||||
"name": "set_light", "description": "打开或关闭指定房间的灯",
|
||||
"parameters": {"type": "object", "properties": {
|
||||
"room": {"type": "string", "description": "房间名"},
|
||||
"on": {"type": "boolean", "description": "true 开灯,false 关灯"}},
|
||||
"required": ["room", "on"]}}}]
|
||||
|
||||
|
||||
def test_tools():
|
||||
print("\n=== 3. 工具调用(关思考)===")
|
||||
try:
|
||||
_, _, text, reason, tcs, finish, _, _ = stream(
|
||||
[{"role": "user", "content": "把客厅的灯打开"}], tools=TOOLS,
|
||||
max_tokens=256, thinking=False)
|
||||
if tcs:
|
||||
try:
|
||||
a = json.loads(tcs[0]["arguments"])
|
||||
ok = a.get("room") == "客厅" and a.get("on") is True
|
||||
record("3a 工具调用", ok, f"{tcs[0]['name']}({tcs[0]['arguments']})")
|
||||
except json.JSONDecodeError:
|
||||
record("3a 工具调用", False, f"参数非法 JSON: {tcs[0]['arguments']!r}")
|
||||
else:
|
||||
record("3a 工具调用", False, f"未调用工具,直接回复了 {text.strip()[:50]!r}")
|
||||
except Exception as e:
|
||||
record("3a 工具调用", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
def test_vision():
|
||||
print("\n=== 4. 视觉输入 ===")
|
||||
try:
|
||||
import base64
|
||||
import io
|
||||
from PIL import Image, ImageDraw
|
||||
img = Image.new("RGB", (320, 160), "white")
|
||||
ImageDraw.Draw(img).text((40, 60), "7391", fill="black")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
msgs = [{"role": "user", "content": [
|
||||
{"type": "text", "text": "图里的数字是多少?只回答数字。"},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}]}]
|
||||
_, _, text, _, _, _, _, _ = stream(msgs, max_tokens=64, thinking=False)
|
||||
record("4 视觉输入", "7391" in text, f"回答={text.strip()[:40]!r}")
|
||||
except Exception as e:
|
||||
record("4 视觉输入", False, f"{type(e).__name__}: {str(e)[:110]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"base={BASE} model={MODEL}")
|
||||
test_models()
|
||||
test_thinking_control()
|
||||
test_voice_latency()
|
||||
test_tools()
|
||||
test_vision()
|
||||
print("\n" + "=" * 62)
|
||||
p = sum(1 for _, ok, _ in RESULTS if ok)
|
||||
print(f"汇总: {p}/{len(RESULTS)} 通过")
|
||||
for n, ok, d in RESULTS:
|
||||
if not ok:
|
||||
print(f" 未通过: {n} — {d}")
|
||||
sys.exit(0 if p == len(RESULTS) else 1)
|
||||
Reference in New Issue
Block a user