助手本体:会话引擎、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,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""端到端测试:音频/文本 → STT → 身份 → 记忆 → LLM → TTS → 音频。
|
||||
|
||||
**全程不需要麦克风、音箱、摄像头、ESP32。**
|
||||
这是"服务器即助手"架构的直接好处:链路可以在任何有 CPU 的地方验证,
|
||||
硬件到货后再接最后一段(真实音频进出)即可。
|
||||
|
||||
跑完会把合成音频写到 out/ 目录,可以人工试听整条链路的实际效果。
|
||||
|
||||
运行:
|
||||
cd <项目根>
|
||||
~/venvs/ai_assistant/bin/python app/tests/test_engine_e2e.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.config import AppConfig # noqa: E402
|
||||
from app.engine import ConversationEngine, EventKind # noqa: E402
|
||||
from app.identity import GUEST_USER_ID, ManualIdentityProvider # noqa: E402
|
||||
from app.llm import FallbackProvider, create_provider # noqa: E402
|
||||
from app.memory import SQLiteMemoryStore # noqa: E402
|
||||
from app.voice import StreamingSTT, create_tts # noqa: E402
|
||||
|
||||
OK, FAIL = [], []
|
||||
OUT = ROOT / "out"
|
||||
|
||||
|
||||
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 save_wav(path: Path, audio: np.ndarray, sr: int) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pcm = (np.clip(audio, -1, 1) * 32767).astype(np.int16)
|
||||
with wave.open(str(path), "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(sr)
|
||||
w.writeframes(pcm.tobytes())
|
||||
|
||||
|
||||
async def collect(engine: ConversationEngine, text: str) -> tuple[str, np.ndarray, list]:
|
||||
"""跑一轮,返回 (回复文本, 拼接音频, 事件列表)。"""
|
||||
reply, audios, events = [], [], []
|
||||
async for ev in engine.respond_to_text(text):
|
||||
events.append(ev)
|
||||
if ev.kind == EventKind.RESPONSE_DELTA:
|
||||
reply.append(ev.text)
|
||||
elif ev.kind == EventKind.SPEAK and ev.audio is not None:
|
||||
audios.append(ev.audio)
|
||||
audio = np.concatenate(audios) if audios else np.zeros(0, dtype=np.float32)
|
||||
return "".join(reply).strip(), audio, events
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
cfg = AppConfig.load()
|
||||
tmp = Path(tempfile.mkdtemp(prefix="e2e_"))
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("=== 0. 装配 ===")
|
||||
llm = FallbackProvider(create_provider(cfg.llm),
|
||||
create_provider(cfg.llm_fallback))
|
||||
mem = SQLiteMemoryStore(tmp / "memory.db")
|
||||
await mem.open()
|
||||
tts = create_tts(cfg.voice)
|
||||
stt = StreamingSTT(cfg.voice)
|
||||
ident = ManualIdentityProvider()
|
||||
engine = ConversationEngine(cfg, llm, mem, ident, tts, stt)
|
||||
print(f" LLM={cfg.llm.model} 备用={cfg.llm_fallback.model}")
|
||||
tts_desc = (f"{cfg.voice.tts_backend}/{cfg.voice.tts_speaker}"
|
||||
if cfg.voice.tts_backend == "remote" else f"local/{cfg.voice.tts_threads}线程")
|
||||
print(f" STT={cfg.voice.stt_threads}线程 TTS={tts_desc}")
|
||||
check("四个组件装配成功", True)
|
||||
|
||||
ok, detail = await llm.health()
|
||||
check("LLM 可用", ok, detail)
|
||||
if not ok:
|
||||
await mem.close()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
return 1
|
||||
|
||||
await engine.start_session("e2e_sess", user_id="u_ylhan")
|
||||
|
||||
# ---------------------------------------------------------- 1
|
||||
print("\n=== 1. 纯文本一轮(LLM → 分句 → TTS)===")
|
||||
reply, audio, events = await collect(engine, "现在几点了?随便说一句就行。")
|
||||
print(f" 回复: {reply[:60]!r}")
|
||||
m = [e for e in events if e.kind == EventKind.METRICS][0].metrics
|
||||
print(f" {m.summary()}")
|
||||
check("有回复", bool(reply))
|
||||
check("有音频输出", len(audio) > 0, f"{len(audio)} 采样点")
|
||||
check("回复不含 Markdown",
|
||||
not any(c in reply for c in "*#`|[]"), reply[:40])
|
||||
# 首音延迟这里只做"没离谱"的兜底检查,不设严格阈值:
|
||||
# 本机 N5095 的 TTS RTF 是 1.86(实测),首音必然被 TTS 拖到秒级;
|
||||
# 服务器上同一模型是 0.09,首音在 150ms 量级。
|
||||
# 真正该卡指标的是部署机(服务器),所以那个断言放在服务器上的验收里。
|
||||
check("首音延迟未失控", m.first_audio_ms < 8000, f"{m.first_audio_ms:.0f}ms")
|
||||
save_wav(OUT / "e2e_1_text.wav", audio, tts.sample_rate)
|
||||
|
||||
# ---------------------------------------------------------- 2
|
||||
print("\n=== 2. 多句回复按句播(每句独立音频)===")
|
||||
_, _, events2 = await collect(
|
||||
engine, "用三句话介绍一下你自己,每句短一点。")
|
||||
speaks = [e for e in events2 if e.kind == EventKind.SPEAK]
|
||||
print(f" 分 {len(speaks)} 段: {[e.text[:14] for e in speaks]}")
|
||||
check("按句切成了多段", len(speaks) >= 2, f"{len(speaks)} 段")
|
||||
audio2 = np.concatenate([e.audio for e in speaks]) if speaks else np.zeros(0, np.float32)
|
||||
save_wav(OUT / "e2e_2_multisentence.wav", audio2, tts.sample_rate)
|
||||
|
||||
# ---------------------------------------------------------- 3
|
||||
print("\n=== 3. 记忆:显式记住 → 下一轮被检索到 ===")
|
||||
await collect(engine, "记住我喜欢喝美式咖啡,不加糖。")
|
||||
await asyncio.sleep(0.5) # 等异步写入落库
|
||||
stored = await mem.list_memories("u_ylhan", include_candidates=True)
|
||||
print(f" 库中记忆 {len(stored)} 条: {[m.text[:20] for m in stored]}")
|
||||
check("记忆已落库", len(stored) >= 1, f"{len(stored)} 条")
|
||||
check("自动抽取的是候选状态",
|
||||
all(m.confirm_status.value == "candidate" for m in stored))
|
||||
|
||||
# 手动确认一条,看它是否参与检索
|
||||
await mem.remember(type(stored[0])(
|
||||
owner_user_id="u_ylhan", kind=stored[0].kind, key="",
|
||||
value="用户喜欢喝美式咖啡,不加糖",
|
||||
confirm_status=__import__("app.memory", fromlist=["ConfirmStatus"]).ConfirmStatus.CONFIRMED))
|
||||
_, _, events3 = await collect(engine, "我想喝点东西,推荐一下?")
|
||||
used = [e for e in events3 if e.kind == EventKind.MEMORY_USED]
|
||||
n_used = len(used[0].memories) if used else 0
|
||||
print(f" 本轮用到记忆 {n_used} 条")
|
||||
check("确认的记忆参与检索", n_used >= 1, f"{n_used} 条")
|
||||
|
||||
# ---------------------------------------------------------- 4
|
||||
print("\n=== 4. 身份隔离:换人后拿不到上一个人的记忆 ===")
|
||||
ident.switch("u_other", reason="测试切换")
|
||||
_, _, events4 = await collect(engine, "我想喝点什么?")
|
||||
used4 = [e for e in events4 if e.kind == EventKind.MEMORY_USED]
|
||||
n4 = len(used4[0].memories) if used4 else 0
|
||||
check("新身份没有旧记忆", n4 == 0, f"{n4} 条")
|
||||
reply4 = "".join(e.text for e in events4 if e.kind == EventKind.RESPONSE_DELTA)
|
||||
check("回复未泄露咖啡偏好", "美式" not in reply4 and "咖啡" not in reply4,
|
||||
reply4[:50])
|
||||
|
||||
# ---------------------------------------------------------- 5
|
||||
print("\n=== 5. 访客身份不做个性化 ===")
|
||||
ident.reset()
|
||||
print(f" 当前身份: {ident.current().user_id} (confirmed={ident.current().confirmed})")
|
||||
_, _, events5 = await collect(engine, "我喜欢什么?")
|
||||
used5 = [e for e in events5 if e.kind == EventKind.MEMORY_USED]
|
||||
check("访客不检索私人记忆", len(used5) == 0 or not used5[0].memories)
|
||||
reply5 = "".join(e.text for e in events5 if e.kind == EventKind.RESPONSE_DELTA)
|
||||
check("访客回复不编造偏好", "美式" not in reply5, reply5[:50])
|
||||
|
||||
# ---------------------------------------------------------- 6
|
||||
print("\n=== 6. 音频输入(真人录音 → STT → LLM → TTS)===")
|
||||
# 测试音频不在代码目录里,和模型放在一起(都是可重新下载的产物)。
|
||||
# 用模型目录反推,注意要 resolve()——服务器上模型目录是个软链,
|
||||
# 不解析的话 .parent 会指到 ~/ai-companion 而不是真实位置。
|
||||
wav_path = None
|
||||
for cand in (
|
||||
cfg.voice.model_dir.resolve().parent / "wavs" / "stt_1.wav",
|
||||
ROOT / "p0" / "stt_tts_bench" / "wavs" / "stt_1.wav",
|
||||
):
|
||||
if cand.is_file():
|
||||
wav_path = cand
|
||||
break
|
||||
if wav_path is not None:
|
||||
with wave.open(str(wav_path)) as w:
|
||||
samples = np.frombuffer(w.readframes(w.getnframes()),
|
||||
dtype=np.int16).astype(np.float32) / 32768.0
|
||||
ident.switch("u_ylhan", reason="回到主人")
|
||||
t0 = time.perf_counter()
|
||||
transcript, audios, events6 = [], [], []
|
||||
async for ev in engine.respond_to_audio(samples):
|
||||
events6.append(ev)
|
||||
if ev.kind == EventKind.TRANSCRIPT:
|
||||
transcript.append(ev.text)
|
||||
elif ev.kind == EventKind.SPEAK and ev.audio is not None:
|
||||
audios.append(ev.audio)
|
||||
got = "".join(transcript)
|
||||
m6 = [e for e in events6 if e.kind == EventKind.METRICS]
|
||||
print(f" STT: {got[:50]!r}")
|
||||
if m6 and m6[0].metrics:
|
||||
print(f" {m6[0].metrics.summary()}")
|
||||
check("音频链路跑通(能出转写)", bool(got.strip()))
|
||||
if audios:
|
||||
save_wav(OUT / "e2e_6_fromaudio.wav", np.concatenate(audios), tts.sample_rate)
|
||||
else:
|
||||
check("音频链路跑通", False, f"测试音频不存在: {wav_path}")
|
||||
|
||||
# ---------------------------------------------------------- 7
|
||||
print("\n=== 7. 打断:取消正在进行的回复 ===")
|
||||
cancelled = "未取消"
|
||||
chunks_seen = 0
|
||||
|
||||
async def long_turn():
|
||||
# 必须把整个流消费完才谈得上"取消"。
|
||||
# 第一版写成 `async for _ in ...: return "已开始"`——
|
||||
# 第一个事件就 return,生成器随之关闭,根本没有正在进行的回复可取消,
|
||||
# 测试于是永远"失败",但失败原因跟取消能力毫无关系。
|
||||
nonlocal chunks_seen
|
||||
async for _ev in engine.respond_to_text("请详细讲一讲中国历史,越长越好。"):
|
||||
chunks_seen += 1
|
||||
return "已完成"
|
||||
|
||||
task = asyncio.create_task(long_turn())
|
||||
await asyncio.sleep(0.7)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
cancelled = "已取消"
|
||||
check("长回复可被取消", cancelled == "已取消" and chunks_seen > 0,
|
||||
f"{cancelled},取消前已收到 {chunks_seen} 个事件")
|
||||
|
||||
await engine.end_session()
|
||||
await mem.close()
|
||||
await llm.aclose()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
print(f"\n合成音频已写入 {OUT}/ ,可人工试听:")
|
||||
for f in sorted(OUT.glob("e2e_*.wav")):
|
||||
print(f" {f.name}")
|
||||
|
||||
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(asyncio.run(main()))
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""身份层测试。
|
||||
|
||||
最关键的一条:**人脸认出来不等于切身份**。
|
||||
方案 §6 写得很直白:"看到熟人不等于确认他正在说话"。
|
||||
做错的后果是——路人从摄像头前走过,助手就把你的私人记忆念了出来。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
|
||||
from app.identity import ( # noqa: E402
|
||||
GUEST_USER_ID, IdentityClaim, ManualIdentityProvider,
|
||||
)
|
||||
|
||||
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 main() -> int:
|
||||
print("=== 1. 初始状态 ===")
|
||||
p = ManualIdentityProvider()
|
||||
d = p.current()
|
||||
check("默认是访客", d.user_id == GUEST_USER_ID and d.is_guest, d.user_id)
|
||||
check("访客信任等级独立", d.trust_level == "guest")
|
||||
|
||||
print("\n=== 2. 显式切换(首版唯一可靠入口)===")
|
||||
d = p.switch("u_ylhan", reason="对话确认")
|
||||
check("切换生效", d.user_id == "u_ylhan" and d.confirmed)
|
||||
check("信任等级为 normal", d.trust_level == "normal")
|
||||
check("版本号 +1", p.version == 1, str(p.version))
|
||||
v = p.version
|
||||
p.switch("u_ylhan") # 切到同一个人
|
||||
check("切到同一人不增版本号", p.version == v, f"{v} → {p.version}")
|
||||
|
||||
print("\n=== 3. 人脸候选不得自动切换(关键)===")
|
||||
p2 = ManualIdentityProvider("u_ylhan")
|
||||
before = p2.current().user_id
|
||||
d = p2.resolve([IdentityClaim(source="face", candidate_user_id="u_other",
|
||||
confidence=0.95, track_id="track_7")])
|
||||
check("身份未被自动切换", d.user_id == before, f"{before} → {d.user_id}")
|
||||
check("但仍保留建议", any(s.candidate_user_id == "u_other" for s in d.suggestions),
|
||||
d.reason)
|
||||
check("track_id 未污染 user_id", d.user_id != "track_7")
|
||||
|
||||
# 访客状态下也不应变
|
||||
p3 = ManualIdentityProvider()
|
||||
d3 = p3.resolve([IdentityClaim(source="face", candidate_user_id="u_ylhan",
|
||||
confidence=0.99)])
|
||||
check("访客状态也不自动切换", d3.user_id == GUEST_USER_ID and d3.is_guest, d3.reason)
|
||||
|
||||
print("\n=== 4. 低置信度连建议都不给 ===")
|
||||
p4 = ManualIdentityProvider()
|
||||
d4 = p4.resolve([IdentityClaim(source="face", candidate_user_id="u_x", confidence=0.3)])
|
||||
check("低置信度无建议", len(d4.suggestions) == 0, f"{len(d4.suggestions)} 条")
|
||||
|
||||
print("\n=== 5. 权威来源可以切换 ===")
|
||||
p5 = ManualIdentityProvider()
|
||||
d5 = p5.resolve([IdentityClaim(source="client_login", candidate_user_id="u_phone",
|
||||
confidence=1.0)])
|
||||
check("客户端登录态可切身份", d5.user_id == "u_phone" and d5.confirmed, d5.reason)
|
||||
|
||||
d5b = p5.resolve([IdentityClaim(source="manual", candidate_user_id="u_ylhan")])
|
||||
check("显式来源可切身份", d5b.user_id == "u_ylhan")
|
||||
|
||||
print("\n=== 6. 权威线索互相矛盾时不切 ===")
|
||||
p6 = ManualIdentityProvider("u_ylhan")
|
||||
d6 = p6.resolve([
|
||||
IdentityClaim(source="manual", candidate_user_id="u_a", confidence=1.0),
|
||||
IdentityClaim(source="client_login", candidate_user_id="u_b", confidence=1.0),
|
||||
])
|
||||
check("矛盾时保持原身份", d6.user_id == "u_ylhan", d6.reason)
|
||||
|
||||
print("\n=== 7. reset 回访客 ===")
|
||||
p7 = ManualIdentityProvider("u_ylhan")
|
||||
v7 = p7.version
|
||||
d7 = p7.reset()
|
||||
check("回到访客", d7.user_id == GUEST_USER_ID and d7.is_guest)
|
||||
check("版本号 +1(上层据此丢弃旧上下文)", p7.version == v7 + 1,
|
||||
f"{v7} → {p7.version}")
|
||||
|
||||
print("\n=== 8. 空 user_id 应报错而非静默接受 ===")
|
||||
try:
|
||||
p7.switch("")
|
||||
check("拒绝空 user_id", False, "没报错")
|
||||
except ValueError:
|
||||
check("拒绝空 user_id", True)
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""记忆层测试。
|
||||
|
||||
重点不是"能存能取",而是方案 §8 反复强调的隔离性质:
|
||||
- 未知身份不碰私人记忆
|
||||
- 删别人的记忆删不掉
|
||||
- 自动抽取的候选不参与检索
|
||||
- 删用户要连会话和摘要一起删干净
|
||||
|
||||
这些是"错了很难发现、发现了很严重"的类型,所以单独测。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
|
||||
from app.memory import ( # noqa: E402
|
||||
ConfirmStatus, Memory, MemoryKind, Scope, SQLiteMemoryStore, Turn,
|
||||
)
|
||||
from app.memory.sqlite_store import FALLBACK_MAX_CORPUS # 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 ""))
|
||||
|
||||
|
||||
async def run(store: SQLiteMemoryStore) -> None:
|
||||
await store.open()
|
||||
|
||||
print("\n=== 1. 基本读写 ===")
|
||||
await store.ensure_user("u_ylhan", "ylhan")
|
||||
await store.ensure_user("u_guest", "访客")
|
||||
mid = await store.remember(Memory(
|
||||
owner_user_id="u_ylhan", kind=MemoryKind.PREFERENCE,
|
||||
key="回复风格", value="喜欢简短直接,不要长篇解释"))
|
||||
check("写入返回 memory_id", isinstance(mid, int) and mid > 0, str(mid))
|
||||
got = await store.retrieve("u_ylhan", "回复风格")
|
||||
check("能检索到", len(got) == 1 and got[0].key == "回复风格")
|
||||
check("字段完整", got[0].kind == MemoryKind.PREFERENCE and got[0].value.startswith("喜欢"))
|
||||
|
||||
print("\n=== 2. 身份隔离(最关键)===")
|
||||
other = await store.retrieve("u_guest", "回复风格")
|
||||
check("别人检索不到", len(other) == 0, f"拿到 {len(other)} 条")
|
||||
listed = await store.list_memories("u_guest")
|
||||
check("别人列不出", len(listed) == 0)
|
||||
|
||||
# 用 memory_id 越权删
|
||||
deleted = await store.forget("u_guest", mid)
|
||||
check("越权删除失败", deleted is False)
|
||||
still = await store.retrieve("u_ylhan", "回复风格")
|
||||
check("原主记忆还在", len(still) == 1)
|
||||
|
||||
print("\n=== 3. 同键新值替换旧值 ===")
|
||||
await store.remember(Memory(
|
||||
owner_user_id="u_ylhan", kind=MemoryKind.PREFERENCE,
|
||||
key="回复风格", value="喜欢更活泼一点的说法"))
|
||||
rows = await store.retrieve("u_ylhan", "回复风格")
|
||||
check("同键只剩一条", len(rows) == 1, f"{len(rows)} 条")
|
||||
check("值已更新", "活泼" in rows[0].value, rows[0].value)
|
||||
|
||||
# 不同 key 不应互相覆盖
|
||||
await store.remember(Memory(owner_user_id="u_ylhan", kind=MemoryKind.PREFERENCE,
|
||||
key="称呼", value="叫他老韩"))
|
||||
rows2 = await store.list_memories("u_ylhan")
|
||||
check("不同 key 各自保留", len(rows2) == 2, f"{len(rows2)} 条")
|
||||
|
||||
print("\n=== 4. 候选记忆默认不参与检索 ===")
|
||||
# 判据是"这条候选不在结果里",**不是"结果为空"**。
|
||||
# 后者是实现细节:检索在关键词不命中时会退回最近记忆
|
||||
# (见 sqlite_store.FALLBACK_MAX_CORPUS),结果本来就可能非空。
|
||||
CAND = "用户好像养了一只叫豆豆的猫"
|
||||
await store.remember(Memory(
|
||||
owner_user_id="u_ylhan", kind=MemoryKind.FACT, key="", value=CAND,
|
||||
confirm_status=ConfirmStatus.CANDIDATE))
|
||||
hit = await store.retrieve("u_ylhan", "豆豆")
|
||||
check("候选不出现在检索结果里", all(m.value != CAND for m in hit),
|
||||
f"拿到 {len(hit)} 条:{[m.value[:12] for m in hit]}")
|
||||
hit2 = await store.retrieve("u_ylhan", "豆豆", include_candidates=True)
|
||||
check("显式要求时可检索到", any(m.value == CAND for m in hit2),
|
||||
f"拿到 {len(hit2)} 条")
|
||||
allm = await store.list_memories("u_ylhan")
|
||||
check("候选确实已落库", any(m.confirm_status == ConfirmStatus.CANDIDATE for m in allm))
|
||||
|
||||
print("\n=== 5. 过期 ===")
|
||||
EXPIRED = "十分钟后提醒我关火"
|
||||
await store.remember(Memory(
|
||||
owner_user_id="u_ylhan", kind=MemoryKind.TASK, key="", value=EXPIRED,
|
||||
expires_at=time.time() - 1)) # 已过期
|
||||
expired = await store.retrieve("u_ylhan", "关火")
|
||||
check("过期记忆不出现在结果里", all(m.value != EXPIRED for m in expired),
|
||||
f"拿到 {len(expired)} 条")
|
||||
check("过期记忆也不在列表里",
|
||||
all(m.value != EXPIRED for m in await store.list_memories("u_ylhan")))
|
||||
|
||||
print("\n=== 6. 会话与轮次 ===")
|
||||
await store.start_session("sess_1", "u_ylhan")
|
||||
for i, (role, text) in enumerate([("user", "今天天气怎么样"),
|
||||
("assistant", "今天晴,十八到二十六度。")]):
|
||||
await store.add_turn(Turn(turn_id=f"t{i}", session_id="sess_1",
|
||||
user_id="u_ylhan", role=role, text=text))
|
||||
check("会话与轮次写入无异常", True)
|
||||
|
||||
print("\n=== 7. 中文检索打分 ===")
|
||||
r = SQLiteMemoryStore.relevance("客厅的灯", "客厅有一盏米家吸顶灯")
|
||||
check("相关文本有分", r > 0.3, f"{r:.2f}")
|
||||
r2 = SQLiteMemoryStore.relevance("客厅的灯", "喜欢听爵士乐")
|
||||
check("无关文本为 0", r2 == 0.0, f"{r2:.2f}")
|
||||
r3 = SQLiteMemoryStore.relevance("回复风格", "回复风格:喜欢简短")
|
||||
check("子串直接命中", r3 == 1.0, f"{r3:.2f}")
|
||||
|
||||
print("\n=== 8. 删除用户要清干净 ===")
|
||||
n = await store.purge_user("u_ylhan")
|
||||
check("返回删除的记忆数", n >= 3, f"{n} 条")
|
||||
check("记忆已清空", len(await store.list_memories("u_ylhan")) == 0)
|
||||
check("检索也拿不到", len(await store.retrieve("u_ylhan", "回复风格")) == 0)
|
||||
# 会话和轮次靠外键级联删除
|
||||
rows = await store._run(lambda c: c.execute(
|
||||
"SELECT COUNT(*) AS n FROM turns WHERE user_id=?", ("u_ylhan",)).fetchone()["n"])
|
||||
check("轮次一并删除", rows == 0, f"残留 {rows} 条")
|
||||
rows2 = await store._run(lambda c: c.execute(
|
||||
"SELECT COUNT(*) AS n FROM sessions WHERE user_id=?", ("u_ylhan",)).fetchone()["n"])
|
||||
check("会话一并删除", rows2 == 0, f"残留 {rows2} 条")
|
||||
|
||||
print("\n=== 9. 重复建用户不报错、不覆盖 ===")
|
||||
await store.ensure_user("u_x", "第一次")
|
||||
await store.ensure_user("u_x", "第二次")
|
||||
name = await store._run(lambda c: c.execute(
|
||||
"SELECT display_name FROM users WHERE user_id=?", ("u_x",)).fetchone()["display_name"])
|
||||
check("已存在则不改", name == "第一次", name)
|
||||
|
||||
print("\n=== 10. 兜底检索要限量(否则就成了每轮全量注入)===")
|
||||
await store.ensure_user("u_big")
|
||||
await store.remember(Memory(owner_user_id="u_big", kind=MemoryKind.FACT,
|
||||
value="常喝拿铁"))
|
||||
# "推荐个饮品"和"常喝拿铁"没有任何共同字符,字面打分为 0
|
||||
got = await store.retrieve("u_big", "推荐个饮品")
|
||||
check("记忆少时兜底返回最近一条", len(got) == 1, f"{len(got)} 条")
|
||||
|
||||
for i in range(FALLBACK_MAX_CORPUS + 2):
|
||||
await store.remember(Memory(owner_user_id="u_big", kind=MemoryKind.FACT,
|
||||
value=f"无关记录编号{i}"))
|
||||
got2 = await store.retrieve("u_big", "推荐个饮品")
|
||||
check("记忆多时不再无条件兜底", len(got2) == 0, f"{len(got2)} 条")
|
||||
got3 = await store.retrieve("u_big", "拿铁")
|
||||
check("关键词命中仍生效", any("拿铁" in m.value for m in got3), f"{len(got3)} 条")
|
||||
|
||||
await store.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tmp = Path(tempfile.mkdtemp(prefix="memtest_"))
|
||||
try:
|
||||
store = SQLiteMemoryStore(tmp / "memory.db")
|
||||
asyncio.run(run(store))
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user