助手本体:会话引擎、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:
+217
@@ -0,0 +1,217 @@
|
||||
"""配置加载。
|
||||
|
||||
原则(来自方案 §10):
|
||||
- 密钥保存在本机配置,不写日志,不进版本管理
|
||||
- 设备标识用稳定名字,不用会变的序号
|
||||
- 依赖版本固定,板卡/固件/通道/引脚都记录
|
||||
|
||||
密钥文件是 ~/.config/ai_assistant/env(权限 600),格式是 shell 变量。
|
||||
本模块只读它,不写。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
ENV_FILE = Path.home() / ".config" / "ai_assistant" / "env"
|
||||
|
||||
# 本项目根目录(app/ 的上一级)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
_lock = threading.Lock()
|
||||
_loaded = False
|
||||
|
||||
|
||||
def load_env(path: Path | None = None) -> dict[str, str]:
|
||||
"""读取密钥文件到 os.environ(已存在的变量不覆盖)。
|
||||
|
||||
只做极简解析:KEY=VALUE,忽略空行和 # 注释。
|
||||
不用 os.system/source,避免把 shell 引进来。
|
||||
"""
|
||||
global _loaded
|
||||
p = path or ENV_FILE
|
||||
with _lock:
|
||||
if _loaded:
|
||||
return dict(os.environ)
|
||||
if p.is_file():
|
||||
for line in p.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, _, v = line.partition("=")
|
||||
k, v = k.strip(), v.strip().strip('"').strip("'")
|
||||
os.environ.setdefault(k, v)
|
||||
_loaded = True
|
||||
return dict(os.environ)
|
||||
|
||||
|
||||
def _env(key: str, default: str = "") -> str:
|
||||
load_env()
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
def _env_int(key: str, default: int) -> int:
|
||||
try:
|
||||
return int(_env(key, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _env_float(key: str, default: float) -> float:
|
||||
try:
|
||||
return float(_env(key, str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- LLM
|
||||
|
||||
@dataclass
|
||||
class LLMConfig:
|
||||
"""一个 LLM 后端的配置。
|
||||
|
||||
thinking 的处理方式各后端不同,所以由后端自己解释这个开关:
|
||||
- 本机 vLLM: chat_template_kwargs={"enable_thinking": false}
|
||||
- DeepSeek: thinking={"type": "disabled"}
|
||||
两者都实测有效,见 P0-本地LLM验证报告.md 与 P0-DeepSeek验证报告.md。
|
||||
"""
|
||||
name: str
|
||||
base_url: str
|
||||
api_key: str
|
||||
model: str
|
||||
thinking: bool = False # 语音场景默认关思考
|
||||
max_tokens: int = 200 # 语音回复要短,方案要求"默认简短自然回复"
|
||||
timeout_s: float = 30.0
|
||||
first_token_timeout_s: float = 8.0
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.base_url and self.api_key and self.model)
|
||||
|
||||
|
||||
def local_llm_config() -> LLMConfig:
|
||||
"""本机/内网 LLM(vLLM 等)。
|
||||
|
||||
默认走回环:服务与助手同机时 127.0.0.1 最省一跳。
|
||||
部署到别的机器时必须用 LOCAL_LLM_BASE_URL 显式覆盖——
|
||||
**不要把具体的内网地址写进默认值**,那会把网络拓扑带进版本历史,
|
||||
而且换环境就得改代码。
|
||||
"""
|
||||
return LLMConfig(
|
||||
name="local",
|
||||
base_url=_env("LOCAL_LLM_BASE_URL", "http://127.0.0.1:18020/v1"),
|
||||
api_key=_env("LOCAL_LLM_API_KEY"),
|
||||
model=_env("LOCAL_LLM_MODEL", "qwen3.8-27b"),
|
||||
)
|
||||
|
||||
|
||||
def deepseek_config() -> LLMConfig:
|
||||
return LLMConfig(
|
||||
name="deepseek",
|
||||
base_url=_env("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1"),
|
||||
api_key=_env("DEEPSEEK_API_KEY"),
|
||||
model=_env("DEEPSEEK_MODEL", "deepseek-flash"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 上下文
|
||||
|
||||
@dataclass
|
||||
class ContextConfig:
|
||||
"""上下文预算。
|
||||
|
||||
实测:真实语音 prompt(人设+记忆+3轮对话)约 406 tokens,
|
||||
本机 LLM 首 token 367ms。上下文越长,首 token 越慢——直接伤"回应及时"。
|
||||
所以这里设的是硬上限,超出就丢最旧的轮次,不是软建议。
|
||||
"""
|
||||
max_prompt_tokens: int = 4096 # 总上限
|
||||
max_turns: int = 12 # 最多保留的对话轮次
|
||||
max_memories: int = 5 # 单轮最多注入的记忆条数
|
||||
memory_timeout_s: float = 0.3 # 检索超时,方案要求"记忆失败不阻塞对话"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 语音
|
||||
|
||||
def _default_model_dir() -> Path:
|
||||
"""模型目录。可用环境变量 AI_MODEL_DIR 覆盖。
|
||||
|
||||
为什么要有这个覆盖:开发在 N5095,运行在服务器,两边模型放的
|
||||
位置不一样(服务器上模型在 ~/ai_companion_bench/models,
|
||||
不在代码目录里)。没有覆盖的话,部署就得改代码——
|
||||
而"改代码才能部署"是部署流程里最容易出错的一环。
|
||||
"""
|
||||
override = _env("AI_MODEL_DIR")
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return PROJECT_ROOT / "p0" / "stt_tts_bench" / "models"
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoiceConfig:
|
||||
"""语音输入输出。
|
||||
|
||||
线程数是实测出来的,不能按"核多给多"猜:
|
||||
- N5095(4核,无AVX2):STT 超过 2 线程急剧退化,4线程慢 3.9 倍
|
||||
- 服务器(i9-14900K,有AVX2):同样配置下 RTF 0.03
|
||||
所以线程数按部署机器配置,不写死。服务器上可以给足。
|
||||
"""
|
||||
model_dir: Path = field(default_factory=_default_model_dir)
|
||||
sample_rate: int = 16000
|
||||
stt_threads: int = field(default_factory=lambda: _env_int("AI_STT_THREADS", 2))
|
||||
tts_threads: int = field(default_factory=lambda: _env_int("AI_TTS_THREADS", 4))
|
||||
|
||||
# ---- TTS 后端选择 ----
|
||||
# "local" = sherpa-onnx vits-zh-ll:RTF 0.09,很快,但音色用户不满意
|
||||
# "remote" = Qwen3-TTS 独立服务:RTF 0.55–0.94,音质是另一档,用户试听选定
|
||||
tts_backend: str = field(default_factory=lambda: _env("AI_TTS_BACKEND", "remote"))
|
||||
tts_service_url: str = field(
|
||||
default_factory=lambda: _env("TTS_SERVICE_URL", "http://127.0.0.1:18040"))
|
||||
# 用户试听选定的组合(out/tts_qwen/instruct_7849.wav)
|
||||
tts_speaker: str = field(default_factory=lambda: _env("AI_TTS_SPEAKER", "Serena"))
|
||||
tts_instructions: str = field(
|
||||
default_factory=lambda: _env("AI_TTS_INSTRUCTIONS", "用轻快活泼的语气说"))
|
||||
tts_language: str = field(default_factory=lambda: _env("AI_TTS_LANGUAGE", "Chinese"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 记忆
|
||||
|
||||
@dataclass
|
||||
class MemoryConfig:
|
||||
db_path: Path = field(default_factory=lambda: Path.home() / ".local" / "share" / "ai_assistant" / "memory.db")
|
||||
# 自动抽取的记忆默认是"候选",不参与检索;只有用户显式确认的才生效。
|
||||
# 对应方案 §8:"模型推测不当作确认事实"
|
||||
auto_extract_enabled: bool = True
|
||||
auto_extract_confirm_status: str = "candidate"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 汇总
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
#: 主 LLM 由 AI_LLM_PRIMARY 选择:
|
||||
#: "local" = 本机 vLLM(首 token 192ms,但占 21.5GB 显存)
|
||||
#: "deepseek" = 云端(首 token 786ms,网络往返约 700ms)
|
||||
#: 2026-09-15 起用 deepseek:本地 27B 已停,GPU 让给 Qwen3-TTS。
|
||||
#: 这是个可逆的权衡——音质提升换首音延迟增加约 600ms。
|
||||
llm: LLMConfig = field(default_factory=local_llm_config)
|
||||
llm_fallback: LLMConfig = field(default_factory=deepseek_config)
|
||||
context: ContextConfig = field(default_factory=ContextConfig)
|
||||
voice: VoiceConfig = field(default_factory=VoiceConfig)
|
||||
memory: MemoryConfig = field(default_factory=MemoryConfig)
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> "AppConfig":
|
||||
load_env()
|
||||
primary = _env("AI_LLM_PRIMARY", "local").strip().lower()
|
||||
if primary == "deepseek":
|
||||
return cls(llm=deepseek_config(), llm_fallback=local_llm_config())
|
||||
return cls()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AppConfig", "LLMConfig", "ContextConfig", "VoiceConfig", "MemoryConfig",
|
||||
"load_env", "local_llm_config", "deepseek_config", "PROJECT_ROOT", "ENV_FILE",
|
||||
]
|
||||
Reference in New Issue
Block a user