Files
ai-companion/app/config.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

218 lines
8.0 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.
"""配置加载。
原则(来自方案 §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",
]