Files
ai-companion/p0/verify_deepseek.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

356 lines
14 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 验证: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)