助手本体:会话引擎、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
+16
View File
@@ -0,0 +1,16 @@
"""记忆层。
对外只暴露 `MemoryStore` 接口和 `SQLiteMemoryStore` 实现。
调用方依赖接口,不依赖 SQLite——将来多设备共享记忆时要搬到服务器,
只换实现,调用方一行不改(前提是调用方按 async + 可能失败 来写)。
"""
from .base import (
ConfirmStatus, Memory, MemoryKind, MemoryStore, Scope, Turn,
)
from .sqlite_store import SCHEMA, SQLiteMemoryStore
__all__ = [
"MemoryStore", "SQLiteMemoryStore", "Memory", "Turn",
"Scope", "ConfirmStatus", "MemoryKind", "SCHEMA",
]
+185
View File
@@ -0,0 +1,185 @@
"""记忆抽象层。
设计原则(方案 §8):
1. **接口按"可能远程"设计**。SQLite 现在跑在本地,但将来多设备共享记忆
时要搬到服务器。所以所有操作都是 async、可能超时、可能失败——
写成同步直连本地文件的话,搬家要重写所有调用方。
现在付这个成本几乎为零,将来付就很贵。
2. **查询和写入都强制带 owner_user_id**,没有"查全部"的口子。
未知身份只给访客临时上下文,不落库。
3. **模型推测不当作确认事实**。自动抽取的记忆是 candidate,默认不参与检索;
只有用户显式"记住"或工具调用写入的才是 confirmed。
4. **异步任务绑定创建时的身份**。写入任务在创建时捕获 user_id/session_id,
绝不读"当前用户"——否则用户切了身份,上一轮迟到的写入会串到新人头上。
5. 视觉 track_id、会话 session_id、永久 user_id **三者分开**,不互相冒充。
"""
from __future__ import annotations
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Sequence
class Scope(str, Enum):
"""记忆的作用域。群体会话默认只用 shared。"""
PRIVATE = "private"
SHARED = "shared"
class ConfirmStatus(str, Enum):
"""确认状态。区分"用户明确说的"和"模型猜的"。"""
CONFIRMED = "confirmed" # 用户显式记住 / 工具调用写入
CANDIDATE = "candidate" # 回复后自动抽取,默认不参与检索
class MemoryKind(str, Enum):
PREFERENCE = "preference" # 明确偏好:结构化键值
FACT = "fact" # 事实
TASK = "task" # 临时事项,常带过期时间
@dataclass
class Memory:
memory_id: int | None = None
owner_user_id: str = ""
scope: Scope = Scope.PRIVATE
kind: MemoryKind = MemoryKind.FACT
key: str = ""
value: str = ""
confirm_status: ConfirmStatus = ConfirmStatus.CONFIRMED
source_session_id: str | None = None
source_turn_id: str | None = None
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
expires_at: float | None = None
@property
def text(self) -> str:
"""用于检索和注入的文本形式。"""
return f"{self.key}:{self.value}" if self.key else self.value
def is_expired(self, now: float | None = None) -> bool:
if self.expires_at is None:
return False
return (now or time.time()) > self.expires_at
@dataclass
class Turn:
"""一轮对话。用于溯源,不默认长期保存逐字稿。"""
turn_id: str
session_id: str
user_id: str
role: str # "user" | "assistant"
text: str
created_at: float = field(default_factory=time.time)
class MemoryStore(ABC):
"""记忆存储。
所有方法都可能抛异常——调用方必须按"记忆失败不阻塞对话"处理
(方案 §8)。超时由调用方用 asyncio.wait_for 控制。
"""
# ---------------------------------------------------------- 生命周期
@abstractmethod
async def open(self) -> None: ...
@abstractmethod
async def close(self) -> None: ...
# ---------------------------------------------------------- 用户与会话
@abstractmethod
async def ensure_user(self, user_id: str, display_name: str = "",
trust_level: str = "normal") -> None:
"""建档。已存在则不动。身份认证与信任等级分开(方案 §6)。"""
@abstractmethod
async def start_session(self, session_id: str, user_id: str) -> None: ...
@abstractmethod
async def end_session(self, session_id: str) -> None: ...
@abstractmethod
async def add_turn(self, turn: Turn) -> None: ...
# ---------------------------------------------------------- 记忆读写
@abstractmethod
async def remember(self, mem: Memory) -> int:
"""写入一条记忆,返回 memory_id。
同 owner + 同 kind + 同 key 的 confirmed 记忆,新值替换旧值
(方案 §8:"明确偏好…新值替换旧值")。
"""
@abstractmethod
async def forget(self, owner_user_id: str, memory_id: int) -> bool:
"""删除。必须带 owner_user_id——不能靠 memory_id 单独删,
否则猜到一个 id 就能删别人的记忆。"""
@abstractmethod
async def retrieve(self, owner_user_id: str, query: str, *,
limit: int = 5,
include_candidates: bool = False,
scopes: Sequence[Scope] | None = None) -> list[Memory]:
"""按相关性检索。
强制按 owner_user_id 过滤。unknown 身份应传一个访客 id,
而不是绕过这个方法。
"""
@abstractmethod
async def list_memories(self, owner_user_id: str, *,
include_candidates: bool = True) -> list[Memory]:
"""列出全部,供"查看/删除"入口使用(方案 §8)。"""
@abstractmethod
async def purge_user(self, owner_user_id: str) -> int:
"""彻底删除该用户的一切:记忆、会话、轮次。
对应方案 §8:"删除同时覆盖会话上下文、摘要和未来索引"。"""
# ---------------------------------------------------------- 检索打分
@staticmethod
def relevance(query: str, text: str) -> float:
"""中文字符二元组重合度打分。
为什么不用 FTS5:SQLite 默认分词器对中文是按整串处理的,
一句话会变成一个 token,检索不出来;trigram 分词器可用但要额外
编译选项。在"记忆条数不多"这个实际规模下,在 Python 里按二元组
打分更可控,也不依赖 SQLite 的编译配置。
将来真的需要向量检索时,换掉这个打分函数和 retrieve 的实现即可,
接口不动。
"""
q, t = query.strip(), text.strip()
if not q or not t:
return 0.0
if q in t:
return 1.0
qb = {q[i:i + 2] for i in range(len(q) - 1)} or {q}
tb = {t[i:i + 2] for i in range(len(t) - 1)} or {t}
if not qb:
return 0.0
hit = len(qb & tb)
if hit == 0:
return 0.0
# 以查询覆盖率为准:记忆里多出来的字不该拉低分数
return hit / len(qb)
__all__ = [
"MemoryStore", "Memory", "Turn", "Scope", "ConfirmStatus", "MemoryKind",
]
+334
View File
@@ -0,0 +1,334 @@
"""SQLite 记忆存储。
几个刻意的选择:
* **列名用 mem_key / mem_value**,不用 key / value——后者在 SQL 里
容易和关键字撞,每个语句都得加引号,早晚漏一处。
* **WAL 模式**:读写不互相阻塞,将来多进程访问同一份库也不容易锁死。
* **外键级联删除**:删用户时记忆/会话/轮次一起走。方案 §8 要求
"删除同时覆盖会话上下文、摘要和未来索引",靠数据库约束比靠代码记得住。
* **所有 SQL 操作走 asyncio.to_thread**。本地 SQLite 其实微秒级,
但接口承诺了 async,就得真的不阻塞事件循环——否则将来换成远程
实现时,调用方那些"同步假设"会全部暴露成 bug。
* **每条查询都带 owner_user_id**。没有"按 memory_id 单独取"的口子:
猜到 id 就能读别人的记忆,是这类系统最容易犯的错。
"""
from __future__ import annotations
import asyncio
import sqlite3
import threading
import time
from pathlib import Path
from typing import Sequence
from .base import ConfirmStatus, Memory, MemoryKind, MemoryStore, Scope, Turn
#: 关键词全不命中时,允许"退回最近记忆"的记忆总量上限。
#: 超过这个数就退回严格匹配——否则每轮都会把记忆全塞进 prompt,
#: 既不再是"按需检索",也会持续推高首 token 延迟。
FALLBACK_MAX_CORPUS = 10
SCHEMA = """
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL DEFAULT '',
trust_level TEXT NOT NULL DEFAULT 'normal',
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
started_at REAL NOT NULL,
ended_at REAL
);
CREATE TABLE IF NOT EXISTS turns (
turn_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
role TEXT NOT NULL,
text TEXT NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS memories (
memory_id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
scope TEXT NOT NULL DEFAULT 'private',
kind TEXT NOT NULL DEFAULT 'fact',
mem_key TEXT NOT NULL DEFAULT '',
mem_value TEXT NOT NULL,
confirm_status TEXT NOT NULL DEFAULT 'confirmed',
source_session_id TEXT,
source_turn_id TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
expires_at REAL
);
CREATE INDEX IF NOT EXISTS idx_mem_owner ON memories(owner_user_id, confirm_status);
CREATE INDEX IF NOT EXISTS idx_mem_key ON memories(owner_user_id, kind, mem_key);
CREATE INDEX IF NOT EXISTS idx_turns_sess ON turns(session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_sess_user ON sessions(user_id);
"""
def _ensure_user_row(c: sqlite3.Connection, user_id: str,
display_name: str = "") -> None:
"""保证 users 里有这一行。
为什么放在存储层而不是让调用方自己 ensure:
memories / turns / sessions 都有指向 users 的外键,
「被引用的 user 必须存在」是这个 schema 的不变量。
让每个调用点各自记得先建档,早晚漏一处——而且漏了之后
报的是 "FOREIGN KEY constraint failed",跟业务逻辑对不上号,
排查起来很绕。由存储层自己兜住,调用方就不需要知道这个约束。
实测踩过:会话中途切到一个未建档的 user_id,
第一轮写 turns 就炸在这里。
"""
c.execute("INSERT INTO users(user_id, display_name, created_at) VALUES(?,?,?) "
"ON CONFLICT(user_id) DO NOTHING", (user_id, display_name, time.time()))
def _row_to_memory(row: sqlite3.Row) -> Memory:
return Memory(
memory_id=row["memory_id"],
owner_user_id=row["owner_user_id"],
scope=Scope(row["scope"]),
kind=MemoryKind(row["kind"]),
key=row["mem_key"],
value=row["mem_value"],
confirm_status=ConfirmStatus(row["confirm_status"]),
source_session_id=row["source_session_id"],
source_turn_id=row["source_turn_id"],
created_at=row["created_at"],
updated_at=row["updated_at"],
expires_at=row["expires_at"],
)
class SQLiteMemoryStore(MemoryStore):
def __init__(self, db_path: Path | str):
self.db_path = Path(db_path)
self._conn: sqlite3.Connection | None = None
self._lock = threading.Lock()
# ---------------------------------------------------------- 内部
def _db(self) -> sqlite3.Connection:
if self._conn is None:
raise RuntimeError("MemoryStore 未 open()")
return self._conn
def _exec(self, fn):
"""在受控线程里跑同步 SQL,串行化以免共用连接出问题。"""
with self._lock:
return fn(self._db())
async def _run(self, fn):
return await asyncio.to_thread(self._exec, fn)
# ---------------------------------------------------------- 生命周期
async def open(self) -> None:
def _open() -> sqlite3.Connection:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA)
conn.commit()
return conn
# 此处还没有并发使用者,不需要持锁跨 await
self._conn = await asyncio.to_thread(_open)
async def close(self) -> None:
if self._conn is None:
return
def _close(c: sqlite3.Connection):
c.commit()
c.close()
self._conn = None
await self._run(_close)
# ---------------------------------------------------------- 用户与会话
async def ensure_user(self, user_id: str, display_name: str = "",
trust_level: str = "normal") -> None:
def op(c: sqlite3.Connection):
c.execute(
"INSERT INTO users(user_id, display_name, trust_level, created_at) "
"VALUES(?,?,?,?) ON CONFLICT(user_id) DO NOTHING",
(user_id, display_name, trust_level, time.time()))
c.commit()
await self._run(op)
async def start_session(self, session_id: str, user_id: str) -> None:
def op(c: sqlite3.Connection):
c.execute("INSERT INTO users(user_id, created_at) VALUES(?,?) "
"ON CONFLICT(user_id) DO NOTHING", (user_id, time.time()))
c.execute("INSERT INTO sessions(session_id, user_id, started_at) "
"VALUES(?,?,?) ON CONFLICT(session_id) DO NOTHING",
(session_id, user_id, time.time()))
c.commit()
await self._run(op)
async def end_session(self, session_id: str) -> None:
def op(c: sqlite3.Connection):
c.execute("UPDATE sessions SET ended_at=? WHERE session_id=?",
(time.time(), session_id))
c.commit()
await self._run(op)
async def add_turn(self, turn: Turn) -> None:
def op(c: sqlite3.Connection):
_ensure_user_row(c, turn.user_id)
c.execute("INSERT INTO turns(turn_id, session_id, user_id, role, text, created_at) "
"VALUES(?,?,?,?,?,?) ON CONFLICT(turn_id) DO NOTHING",
(turn.turn_id, turn.session_id, turn.user_id, turn.role,
turn.text, turn.created_at))
c.commit()
await self._run(op)
# ---------------------------------------------------------- 记忆读写
async def remember(self, mem: Memory) -> int:
now = time.time()
def op(c: sqlite3.Connection):
_ensure_user_row(c, mem.owner_user_id)
# 已确认的同类同键记忆:新值替换旧值(方案 §8)
if mem.confirm_status == ConfirmStatus.CONFIRMED and mem.key:
row = c.execute(
"SELECT memory_id FROM memories WHERE owner_user_id=? AND kind=? "
"AND mem_key=? AND confirm_status=?",
(mem.owner_user_id, mem.kind.value, mem.key,
ConfirmStatus.CONFIRMED.value)).fetchone()
if row:
c.execute(
"UPDATE memories SET mem_value=?, scope=?, updated_at=?, "
"expires_at=?, source_session_id=?, source_turn_id=? "
"WHERE memory_id=? AND owner_user_id=?",
(mem.value, mem.scope.value, now, mem.expires_at,
mem.source_session_id, mem.source_turn_id,
row["memory_id"], mem.owner_user_id))
c.commit()
return row["memory_id"]
cur = c.execute(
"INSERT INTO memories(owner_user_id, scope, kind, mem_key, mem_value, "
"confirm_status, source_session_id, source_turn_id, created_at, "
"updated_at, expires_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(mem.owner_user_id, mem.scope.value, mem.kind.value, mem.key,
mem.value, mem.confirm_status.value, mem.source_session_id,
mem.source_turn_id, mem.created_at or now, now, mem.expires_at))
c.commit()
return int(cur.lastrowid)
return await self._run(op)
async def forget(self, owner_user_id: str, memory_id: int) -> bool:
def op(c: sqlite3.Connection):
# 必须同时匹配 owner——不能只凭 memory_id 删
cur = c.execute("DELETE FROM memories WHERE memory_id=? AND owner_user_id=?",
(memory_id, owner_user_id))
c.commit()
return cur.rowcount > 0
return await self._run(op)
async def retrieve(self, owner_user_id: str, query: str, *,
limit: int = 5,
include_candidates: bool = False,
scopes: Sequence[Scope] | None = None) -> list[Memory]:
allowed = [s.value for s in (scopes or [Scope.PRIVATE, Scope.SHARED])]
statuses = [ConfirmStatus.CONFIRMED.value]
if include_candidates:
statuses.append(ConfirmStatus.CANDIDATE.value)
placeholders = ",".join("?" * len(allowed))
status_ph = ",".join("?" * len(statuses))
def op(c: sqlite3.Connection):
rows = c.execute(
f"SELECT * FROM memories WHERE owner_user_id=? "
f"AND scope IN ({placeholders}) AND confirm_status IN ({status_ph})",
(owner_user_id, *allowed, *statuses)).fetchall()
now = time.time()
scored = []
for r in rows:
m = _row_to_memory(r)
if m.is_expired(now):
continue
score = self.relevance(query, m.text) if query.strip() else 0.0
scored.append((score, m.updated_at, m))
hits = [x for x in scored if x[0] > 0]
if not hits and 0 < len(scored) <= FALLBACK_MAX_CORPUS:
# 关键词一条都没命中时,退回"最近更新的若干条"。
#
# 理由是实测出来的:查"我想喝点什么"匹配不上"喜欢喝美式咖啡"——
# 两句共享"喝"字,但没有共同的二元组,按字面打分是 0。
# 中文口语短句和记忆文本之间"意思相关但用词不同"极其常见,
# 纯字面匹配漏得厉害。
#
# 但兜底必须**限量**:不加限制就变成"每轮无条件注入记忆",
# 那就不是方案 §8 说的"按需检索"了,而且记忆一多会持续
# 拉长 prompt、推高首 token 延迟。
#
# 所以只在记忆总量很小时兜底——这个阶段"全给"和"按需给"
# 结果差不多,而漏给的代价(助手像不认识你)更大。
# 超过阈值就退回严格匹配,接受漏召。
#
# 这是权宜之计,不是终局:真正的解法是向量检索
# (方案 §8 把它列在"真实使用中检索不足再评估")。
# 到那时换掉打分和这段逻辑即可,接口不动。
hits = scored
hits.sort(key=lambda x: (x[0], x[1]), reverse=True)
return [m for _, _, m in hits[:limit]]
return await self._run(op)
async def list_memories(self, owner_user_id: str, *,
include_candidates: bool = True) -> list[Memory]:
statuses = [ConfirmStatus.CONFIRMED.value]
if include_candidates:
statuses.append(ConfirmStatus.CANDIDATE.value)
status_ph = ",".join("?" * len(statuses))
def op(c: sqlite3.Connection):
rows = c.execute(
f"SELECT * FROM memories WHERE owner_user_id=? "
f"AND confirm_status IN ({status_ph}) ORDER BY updated_at DESC",
(owner_user_id, *statuses)).fetchall()
now = time.time()
return [m for m in (_row_to_memory(r) for r in rows) if not m.is_expired(now)]
return await self._run(op)
async def purge_user(self, owner_user_id: str) -> int:
def op(c: sqlite3.Connection):
cur = c.execute("DELETE FROM memories WHERE owner_user_id=?", (owner_user_id,))
n = cur.rowcount
c.execute("DELETE FROM turns WHERE user_id=?", (owner_user_id,))
c.execute("DELETE FROM sessions WHERE user_id=?", (owner_user_id,))
# users 放最后:外键级联会把残留一并带走
c.execute("DELETE FROM users WHERE user_id=?", (owner_user_id,))
c.commit()
return n
return await self._run(op)
__all__ = ["SQLiteMemoryStore", "SCHEMA"]