助手本体:会话引擎、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
+389
View File
@@ -0,0 +1,389 @@
"""会话引擎:身份 → 记忆 → LLM → TTS 的核心回路。
这一层不依赖 Pipecat、不依赖任何传输方式,只依赖四个接口
(LLMProvider / MemoryStore / IdentityProvider / LocalTTS)。
好处有两个:
1. **可以用文件音频端到端测试**,不需要麦克风、音箱、WebRTC。
2. 接 Pipecat 时只需写薄适配层;换传输方式(WebRTC / 本地声卡)不动这里。
一轮的完整流程:
用户音频
└→ STT 转写
└→ 身份裁决(决定用谁的记忆)
└→ 记忆检索(带超时,失败不阻塞)
└→ 拼 system(每轮重建,单条)
└→ LLM 流式
└→ 按句切分 → 逐句 TTS → 边合成边播
└→ 回复结束,异步写记忆(绑定创建时的身份)
延迟的关键在最后两步:**必须按句切分后立刻合成**。
VITS 是非自回归的,整段合成完才出声;只有切成短句才能提前开口。
"""
from __future__ import annotations
import asyncio
import logging
import time
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import AsyncIterator, Sequence
import numpy as np
from .config import AppConfig
from .identity.base import IdentityDecision, IdentityProvider
from .llm.base import LLMProvider, ToolCall
from .memory.base import (
ConfirmStatus, Memory, MemoryKind, MemoryStore, Scope, Turn,
)
from .prompts import build_system_prompt, trim_history
from .voice.stt import StreamingSTT
from .voice.tts import LocalTTS, split_sentences
log = logging.getLogger(__name__)
class EventKind(str, Enum):
IDENTITY = "identity" # 本轮的身份裁决结果
MEMORY_USED = "memory_used" # 检索到的记忆
TRANSCRIPT = "transcript" # STT 结果
RESPONSE_DELTA = "response_delta" # LLM 增量文本(未播,仅供显示)
SPEAK = "speak" # 一段可播放的音频
TOOL_CALL = "tool_call" # 模型请求调用工具
METRICS = "metrics" # 本轮计时
ERROR = "error"
@dataclass
class Event:
kind: EventKind
text: str = ""
audio: np.ndarray | None = None
sample_rate: int = 16000
identity: IdentityDecision | None = None
memories: list[Memory] = field(default_factory=list)
tool_call: ToolCall | None = None
metrics: "TurnMetrics | None" = None
error: str = ""
@dataclass
class TurnMetrics:
"""按方案 §10 要求记录耗时,便于定位瓶颈。"""
stt_ms: float = 0.0
identity_ms: float = 0.0
retrieve_ms: float = 0.0
llm_first_content_ms: float = 0.0
first_audio_ms: float = 0.0 # 从开始处理到第一段音频就绪
total_ms: float = 0.0
llm_backend: str = ""
memories_used: int = 0
reply_chars: int = 0
speaks: int = 0
def summary(self) -> str:
return (f"STT {self.stt_ms:.0f}ms | 身份 {self.identity_ms:.0f}ms | "
f"检索 {self.retrieve_ms:.0f}ms | LLM首token {self.llm_first_content_ms:.0f}ms | "
f"首音 {self.first_audio_ms:.0f}ms | 总 {self.total_ms:.0f}ms | "
f"{self.llm_backend} | 记忆{self.memories_used}条 | {self.speaks}段音频")
class ConversationEngine:
def __init__(
self,
cfg: AppConfig,
llm: LLMProvider,
memory: MemoryStore,
identity: IdentityProvider,
tts: LocalTTS,
stt: StreamingSTT | None = None,
):
self.cfg = cfg
self.llm = llm
self.memory = memory
self.identity = identity
self.tts = tts
self.stt = stt
#: 会话历史。切身份时会清空——方案 §8 要求切换身份
#: 清理上一人的私有检索上下文。
self._history: list[dict] = []
self._history_identity_version: int = -1
self._session_id: str = ""
self._current_turn: asyncio.Task | None = None
# ------------------------------------------------------------ 会话
async def start_session(self, session_id: str | None = None,
user_id: str | None = None) -> str:
sid = session_id or f"s_{uuid.uuid4().hex[:12]}"
if user_id:
self.identity.switch(user_id, reason="会话指定")
d = self.identity.current()
await self.memory.start_session(sid, d.user_id)
self._session_id = sid
self._history_identity_version = self._identity_version()
log.info("会话开始 %s,身份 %s", sid, d.user_id)
return sid
async def end_session(self) -> None:
if self._session_id:
await self.memory.end_session(self._session_id)
def _identity_version(self) -> int:
# ManualIdentityProvider 有 version;其他实现没有时退化为 0
return getattr(self.identity, "version", 0)
def _sync_identity_scope(self) -> None:
"""身份变了就丢弃历史。
不做这件事的后果:切到新用户后,模型仍看得到上一人的对话,
第一条回复就可能带出别人的信息。
"""
v = self._identity_version()
if v != self._history_identity_version:
if self._history:
log.info("身份变更,丢弃 %d 条历史消息", len(self._history))
self._history = []
self._history_identity_version = v
# ------------------------------------------------------------ 主回路
async def respond_to_text(self, user_text: str, *,
session_id: str | None = None) -> AsyncIterator[Event]:
"""处理一句用户输入,产出事件流。"""
t_start = time.perf_counter()
metrics = TurnMetrics()
sid = session_id or self._session_id or await self.start_session()
self._sync_identity_scope()
identity = self.identity.current()
metrics.identity_ms = (time.perf_counter() - t_start) * 1000
yield Event(kind=EventKind.IDENTITY, identity=identity)
if not user_text.strip():
yield Event(kind=EventKind.METRICS, metrics=metrics)
return
# ---- 记忆检索:带超时,失败不阻塞(方案 §8)
t0 = time.perf_counter()
memories = await self._retrieve_memories(identity, user_text)
metrics.retrieve_ms = (time.perf_counter() - t0) * 1000
metrics.memories_used = len(memories)
if memories:
yield Event(kind=EventKind.MEMORY_USED, memories=memories)
# ---- 拼 system(每轮重建,单条)
system = build_system_prompt(identity, memories)
history = trim_history(self._history,
max_turns=self.cfg.context.max_turns)
messages = [{"role": "system", "content": system}] + history + \
[{"role": "user", "content": user_text}]
# ---- LLM 流式 + 逐句 TTS
reply_parts: list[str] = []
buffer = ""
speaks = 0
async for ev in self._stream_and_speak(messages, metrics):
if ev.kind == EventKind.RESPONSE_DELTA:
reply_parts.append(ev.text)
buffer += ev.text
# 增量文本必须**原样透传**给调用方(显示、字幕、日志都要用)。
# 这里漏掉 yield 的后果是:音频正常出来了,但上层拿到的是空回复——
# 因为文本只进了内部累加器,从未离开这个生成器。
yield ev
# 攒到句末就送去合成——这是提前出声的唯一手段
segs = split_sentences(buffer)
if len(segs) > 1:
*ready, buffer = segs
for seg in ready:
async for speak_ev in self._speak(seg, metrics):
speaks += 1
yield speak_ev
else:
yield ev
# 收尾:把剩下的半句也合成掉
if buffer.strip():
async for speak_ev in self._speak(buffer, metrics):
speaks += 1
yield speak_ev
reply = "".join(reply_parts).strip()
metrics.reply_chars = len(reply)
metrics.speaks = speaks
metrics.total_ms = (time.perf_counter() - t_start) * 1000
# ---- 落历史
self._history.append({"role": "user", "content": user_text})
if reply:
self._history.append({"role": "assistant", "content": reply})
# ---- 异步写记忆:**绑定创建时的身份**,不读"当前用户"
if self.cfg.memory.auto_extract_enabled and reply:
self._spawn_memory_write(
user_text=user_text, reply=reply,
user_id=identity.user_id,
session_id=sid,
turn_id=f"t_{uuid.uuid4().hex[:10]}")
await self.memory.add_turn(Turn(
turn_id=f"t_{uuid.uuid4().hex[:10]}", session_id=sid,
user_id=identity.user_id, role="user", text=user_text))
yield Event(kind=EventKind.METRICS, metrics=metrics)
async def respond_to_audio(self, samples: np.ndarray, *,
session_id: str | None = None) -> AsyncIterator[Event]:
"""从音频走完整条链路(需要 STT)。"""
if self.stt is None:
yield Event(kind=EventKind.ERROR, error="未配置 STT,无法处理音频")
return
t0 = time.perf_counter()
stream = self.stt.new_stream()
stream.accept(samples)
text = stream.finish()
stt_ms = (time.perf_counter() - t0) * 1000
yield Event(kind=EventKind.TRANSCRIPT, text=text)
async for ev in self.respond_to_text(text, session_id=session_id):
if ev.kind == EventKind.METRICS and ev.metrics:
ev.metrics.stt_ms = stt_ms
yield ev
# ------------------------------------------------------------ 内部
async def _retrieve_memories(self, identity: IdentityDecision,
query: str) -> list[Memory]:
"""带超时的检索。超时/出错一律返回空,绝不让对话卡住。"""
if not identity.confirmed:
# 访客不做个性化检索(方案 §6:"未知身份只用访客临时上下文")
return []
try:
return await asyncio.wait_for(
self.memory.retrieve(
identity.user_id, query,
limit=self.cfg.context.max_memories,
scopes=[Scope.PRIVATE, Scope.SHARED]),
timeout=self.cfg.context.memory_timeout_s)
except asyncio.TimeoutError:
log.warning("记忆检索超时 %.2fs,本轮不带记忆继续",
self.cfg.context.memory_timeout_s)
return []
except Exception as e: # noqa: BLE001
log.warning("记忆检索失败(%s),本轮不带记忆继续", e)
return []
async def _stream_and_speak(self, messages: list[dict],
metrics: TurnMetrics) -> AsyncIterator[Event]:
"""只负责 LLM 流式,不做 TTS。TTS 由调用方按句驱动。"""
t0 = time.perf_counter()
try:
async for chunk in self.llm.stream(messages):
if chunk.kind.value == "content" and chunk.text:
if not metrics.llm_first_content_ms:
metrics.llm_first_content_ms = (time.perf_counter() - t0) * 1000
yield Event(kind=EventKind.RESPONSE_DELTA, text=chunk.text)
elif chunk.kind.value == "tool_call" and chunk.tool_name:
# 骨架阶段只上报,不执行。工具执行要配合
# 方案 §9 的"执行 ID + 状态确认 + 不盲目重试",
# 留到接米家时再做。
yield Event(kind=EventKind.TOOL_CALL, tool_call=ToolCall(
id=chunk.tool_id, name=chunk.tool_name,
arguments=chunk.tool_arguments))
except asyncio.CancelledError:
log.info("本轮被取消(打断)")
raise
except Exception as e: # noqa: BLE001
log.error("LLM 流失败:%s", e)
yield Event(kind=EventKind.ERROR, error=f"{type(e).__name__}: {e}")
metrics.llm_backend = getattr(self.llm, "last_backend", self.llm.name)
async def _speak(self, text: str, metrics: TurnMetrics) -> AsyncIterator[Event]:
"""合成一段并产出可播放音频。"""
text = text.strip()
if not text:
return
t0 = time.perf_counter()
try:
# TTS 是 CPU 密集的同步调用,放线程里跑,别阻塞事件循环
audio = await asyncio.to_thread(self.tts.synthesize, text)
except Exception as e: # noqa: BLE001
log.error("TTS 失败:%s", e)
yield Event(kind=EventKind.ERROR, error=f"TTS 失败:{e}")
return
if audio is None or len(audio) == 0:
return
if not metrics.first_audio_ms:
metrics.first_audio_ms = (time.perf_counter() - t0) * 1000
yield Event(kind=EventKind.SPEAK, text=text, audio=audio,
sample_rate=self.tts.sample_rate)
def _spawn_memory_write(self, *, user_text: str, reply: str,
user_id: str, session_id: str, turn_id: str) -> None:
"""起一个后台任务写记忆。
**身份是参数传进来的,不在这里读 self.identity.current()。**
方案 §8 明确要求:"异步任务绑定创建时的 user_id/session_id,
避免迟到结果写入新用户。" 如果这里去读"当前身份",
用户在这几百毫秒内切了身份,这条记忆就会写到别人名下。
"""
async def _write():
try:
for mem in extract_memories(user_text, reply):
mem.owner_user_id = user_id # 绑定创建时的身份
mem.source_session_id = session_id
mem.source_turn_id = turn_id
mem.confirm_status = ConfirmStatus(
self.cfg.memory.auto_extract_confirm_status)
await self.memory.remember(mem)
except Exception as e: # noqa: BLE001
log.warning("异步写记忆失败(不影响对话):%s", e)
task = asyncio.create_task(_write())
# 持有引用,避免被 GC 提前回收
if not hasattr(self, "_bg_tasks"):
self._bg_tasks: set[asyncio.Task] = set()
self._bg_tasks.add(task)
task.add_done_callback(self._bg_tasks.discard)
# ---------------------------------------------------------------- 记忆抽取
#: 显式"记住"的触发词。首版用规则,不额外调一次 LLM——
#: 那会多一次往返,而语音场景对延迟很敏感。
_REMEMBER_PATTERNS = (
("记住", ""), ("记一下", ""), ("别忘了", ""), ("帮我记", ""),
("我喜欢", "偏好"), ("我不喜欢", "偏好"), ("我习惯", "偏好"),
)
def extract_memories(user_text: str, reply: str) -> list[Memory]:
"""从一轮对话里抽取候选记忆。
首版只认显式表达。**抽取结果默认是 candidate,不参与检索**——
方案 §8:"模型推测不当作确认事实"。要变成 confirmed 需要用户确认
或走工具调用。
"""
out: list[Memory] = []
text = (user_text or "").strip()
for trigger, kind_hint in _REMEMBER_PATTERNS:
if trigger in text:
# 取触发词之后的正文
body = text.split(trigger, 1)[1].strip(" ::,,。.!!??")
if len(body) < 2:
continue
out.append(Memory(
kind=MemoryKind.PREFERENCE if kind_hint else MemoryKind.FACT,
key=kind_hint or "",
value=body[:200],
))
break
return out
__all__ = [
"ConversationEngine", "Event", "EventKind", "TurnMetrics", "extract_memories",
]