Files
ai-companion/app/tests/test_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

230 lines
9.3 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
"""LLM 抽象层测试。
重点验证实测踩过的三个坑是否真的被吸收:
1. 思考字段名差异(reasoning / reasoning_content)
2. 关闭思考的方式差异
3. 多条 system 消息归一化
运行:
~/venvs/ai_assistant/bin/python -m app.tests.test_llm
或
cd <项目根> && ~/venvs/ai_assistant/bin/python app/tests/test_llm.py
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from app.config import AppConfig # noqa: E402
from app.llm import ( # noqa: E402
ChunkKind, DeepSeekProvider, FallbackProvider, LocalVLLMProvider, create_provider,
)
from app.llm.base import LLMProvider # noqa: E402
OK, FAIL = [], []
def check(name: str, cond: bool, detail: str = "") -> None:
(OK if cond else FAIL).append(name)
print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
# ---------------------------------------------------------------- 纯本地逻辑(无需网络)
def test_normalize_messages() -> None:
print("\n=== 1. 消息归一化(多条 system → 一条,置于开头)===")
msgs = [
{"role": "system", "content": "你是助手。"},
{"role": "user", "content": "你好"},
{"role": "system", "content": "记忆:用户叫 ylhan。"},
]
out = LLMProvider.normalize_messages(msgs)
check("只剩一条 system", sum(1 for m in out if m["role"] == "system") == 1,
f"实际 {sum(1 for m in out if m['role']=='system')} 条")
check("system 在开头", out[0]["role"] == "system")
check("两条 system 内容都保留",
"你是助手。" in out[0]["content"] and "ylhan" in out[0]["content"])
check("非 system 消息顺序不变",
[m["role"] for m in out[1:]] == ["user"])
# 无 system 的情况不应凭空造一条
out2 = LLMProvider.normalize_messages([{"role": "user", "content": "hi"}])
check("无 system 时不新增", len(out2) == 1 and out2[0]["role"] == "user")
# 空 system 应被丢弃
out3 = LLMProvider.normalize_messages(
[{"role": "system", "content": " "}, {"role": "user", "content": "hi"}])
check("空白 system 被丢弃", len(out3) == 1)
def test_request_bodies() -> None:
print("\n=== 2. 各后端的请求体(关思考方式不同)===")
cfg_local = AppConfig.load().llm
cfg_ds = AppConfig.load().llm_fallback
cfg_local.thinking = False
cfg_ds.thinking = False
b_local = LocalVLLMProvider(cfg_local)._build_body(
[{"role": "user", "content": "hi"}], tools=None, max_tokens=100)
check("本机用 chat_template_kwargs",
b_local.get("chat_template_kwargs") == {"enable_thinking": False},
str(b_local.get("chat_template_kwargs")))
b_ds = DeepSeekProvider(cfg_ds)._build_body(
[{"role": "user", "content": "hi"}], tools=None, max_tokens=100)
check("DeepSeek 用 thinking.type",
b_ds.get("thinking") == {"type": "disabled"},
str(b_ds.get("thinking")))
check("两者模型名不同", b_local["model"] != b_ds["model"],
f"{b_local['model']} vs {b_ds['model']}")
def test_sse_parsing() -> None:
print("\n=== 3. SSE 解析:两种思考字段名都认 ===")
class FakeResp:
def __init__(self, lines): self.content = self._gen(lines)
async def _gen(self, lines):
for l in lines:
yield (l + "\n").encode()
async def run(lines):
return [c async for c in LLMProvider._iter_sse(FakeResp(lines))]
async def main():
# reasoning_content(DeepSeek 写法)
c1 = await run([
'data: {"choices":[{"delta":{"reasoning_content":"想一想"}}]}',
'data: {"choices":[{"delta":{"content":"你好"}}]}',
'data: [DONE]',
])
check("认 reasoning_content",
any(c.kind == ChunkKind.REASONING and c.text == "想一想" for c in c1))
check("content 正常", any(c.kind == ChunkKind.CONTENT and c.text == "你好" for c in c1))
# reasoning(本机 vLLM 写法)
c2 = await run([
'data: {"choices":[{"delta":{"reasoning":"想一下"}}]}',
'data: {"choices":[{"delta":{"content":"在的"}}]}',
'data: [DONE]',
])
check("认 reasoning(本机 vLLM 字段)",
any(c.kind == ChunkKind.REASONING and c.text == "想一下" for c in c2))
# 工具调用分片累积
c3 = await run([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"set_light","arguments":"{\\"ro"}}]}}]}',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"om\\":\\"客厅\\"}"}}]}}]}',
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}',
])
tc = [c for c in c3 if c.kind == ChunkKind.TOOL_CALL]
check("工具调用参数分片拼接", tc and tc[-1].tool_arguments == '{"room":"客厅"}',
tc[-1].tool_arguments if tc else "无")
check("工具名保留", tc and tc[-1].tool_name == "set_light")
# 坏帧不应崩溃
c4 = await run(['data: {坏json', 'data: {"choices":[{"delta":{"content":"ok"}}]}', 'data: [DONE]'])
check("坏帧被跳过而非崩溃",
any(c.kind == ChunkKind.CONTENT and c.text == "ok" for c in c4))
asyncio.run(main())
# ---------------------------------------------------------------- 需要网络
async def test_live() -> None:
cfg = AppConfig.load()
provider = create_provider(cfg.llm)
print(f"\n=== 4. 实连本机 vLLM({cfg.llm.model} @ {cfg.llm.base_url})===")
ok, detail = await provider.health()
check("健康检查", ok, detail)
if not ok:
await provider.aclose()
return
msgs = [
{"role": "system", "content":
"你是桌面陪伴助手,回复必须简短自然、适合朗读,不超过两句话,"
"不要用 Markdown 或表情符号。"},
{"role": "user", "content": "今天天气怎么样"},
{"role": "assistant", "content": "今天北京晴,十八到二十六度。"},
{"role": "user", "content": "那明天呢"},
]
res = await provider.complete(msgs, max_tokens=120)
print(f" 回答: {res.content.strip()[:70]!r}")
print(f" 首token(回答)={res.first_content_ms:.0f}ms 总={res.total_ms:.0f}ms")
print(f" 思考={len(res.reasoning)}字 完成tok={res.usage.get('completion_tokens') if res.usage else '?'}")
check("有回答内容", bool(res.content.strip()))
check("关思考后无思考内容", len(res.reasoning) == 0, f"{len(res.reasoning)} 字")
check("首token在 1 秒内", (res.first_content_ms or 9e9) < 1000,
f"{res.first_content_ms:.0f}ms")
check("回复足够短", len(res.content) < 100, f"{len(res.content)} 字")
# 工具调用
tools = [{"type": "function", "function": {
"name": "set_light", "description": "开关指定房间的灯",
"parameters": {"type": "object", "properties": {
"room": {"type": "string"}, "on": {"type": "boolean"}},
"required": ["room", "on"]}}}]
res2 = await provider.complete([{"role": "user", "content": "把客厅的灯打开"}],
tools=tools, max_tokens=128)
check("工具调用正确", res2.has_tool_calls and
res2.tool_calls[0].parsed_arguments().get("room") == "客厅",
f"{res2.tool_calls[0].name if res2.tool_calls else '无'}")
# 取消:模拟打断
async def cancel_midway():
async for _ch in provider.stream(
[{"role": "user", "content": "请详细讲一下中国历史"}], max_tokens=800):
return "cancelled"
return "finished"
got = await asyncio.wait_for(cancel_midway(), timeout=30)
check("可中途取消(打断)", got == "cancelled", got)
await provider.aclose()
async def test_fallback_logic() -> None:
print("\n=== 5. 降级逻辑(不发真实请求)===")
cfg = AppConfig.load()
class BoomProvider(LocalVLLMProvider):
def __init__(self, c, err): super().__init__(c); self._err = err
async def stream(self, *a, **kw):
raise self._err
yield # noqa: 使其成为生成器
fallback = DeepSeekProvider(cfg.llm_fallback)
# 连接类错误应降级
from app.llm.base import LLMError
p = FallbackProvider(BoomProvider(cfg.llm, ConnectionError("连不上")), fallback)
check("连接错误判为可降级", p._should_fallback(ConnectionError("x")))
check("超时判为可降级", p._should_fallback(TimeoutError("x")))
check("503 判为可降级", p._should_fallback(LLMError("local HTTP 503: busy")))
check("400 判为不可降级", not p._should_fallback(LLMError("local HTTP 400: bad")))
check("401 判为不可降级", not p._should_fallback(LLMError("local HTTP 401: auth")))
def main() -> int:
test_normalize_messages()
test_request_bodies()
test_sse_parsing()
asyncio.run(test_live())
asyncio.run(test_fallback_logic())
print("\n" + "=" * 62)
print(f"通过 {len(OK)} / {len(OK) + len(FAIL)}")
for n in FAIL:
print(f" 未通过: {n}")
return 0 if not FAIL else 1
if __name__ == "__main__":
sys.exit(main())