Files
ai-companion/p0/verify_local_llm.py
T
ylhan 35e107c290 助手本体:会话引擎、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 恢复
2026-09-15 11:05:09 +08:00

258 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)