助手本体:会话引擎、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:
ylhan
2026-09-15 11:05:09 +08:00
commit 35e107c290
46 changed files with 5976 additions and 0 deletions
+210
View File
@@ -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()
+138
View File
@@ -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()
+187
View File
@@ -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()
+73
View File
@@ -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()
+149
View File
@@ -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()
+139
View File
@@ -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()
+52
View File
@@ -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 "下载完成。"
+69
View File
@@ -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
}
+127
View File
@@ -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": "可实时"
}
}
}
+158
View File
@@ -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
}
}
}
+86
View File
@@ -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
]
}
}
+355
View File
@@ -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)
+257
View File
@@ -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)