242 lines
9.0 KiB
Python
242 lines
9.0 KiB
Python
"""配置加载。
|
||
|
||
原则(来自方案 §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
|
||
|
||
|
||
def _env_bool(key: str, default: bool) -> bool:
|
||
value = _env(key, "true" if default else "false").strip().lower()
|
||
return value in {"1", "true", "yes", "on"}
|
||
|
||
|
||
# ---------------------------------------------------------------- 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 # 检索超时,方案要求"记忆失败不阻塞对话"
|
||
|
||
|
||
# ---------------------------------------------------------------- 联网搜索
|
||
|
||
@dataclass
|
||
class SearchConfig:
|
||
"""SearXNG 搜索后端。
|
||
|
||
默认关闭,避免开发机没有 SearXNG 时改变普通对话行为;部署机显式设置
|
||
SEARCH_ENABLED=true。服务只需暴露在助手所在机器的回环地址。
|
||
"""
|
||
|
||
enabled: bool = field(default_factory=lambda: _env_bool("SEARCH_ENABLED", False))
|
||
base_url: str = field(
|
||
default_factory=lambda: _env("SEARXNG_BASE_URL", "http://127.0.0.1:18080"))
|
||
timeout_s: float = field(default_factory=lambda: _env_float("SEARCH_TIMEOUT_S", 6.0))
|
||
max_results: int = field(default_factory=lambda: _env_int("SEARCH_MAX_RESULTS", 5))
|
||
language: str = field(default_factory=lambda: _env("SEARCH_LANGUAGE", "zh-CN"))
|
||
|
||
|
||
# ---------------------------------------------------------------- 语音
|
||
|
||
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)
|
||
search: SearchConfig = field(default_factory=SearchConfig)
|
||
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", "SearchConfig", "VoiceConfig", "MemoryConfig",
|
||
"load_env", "local_llm_config", "deepseek_config", "PROJECT_ROOT", "ENV_FILE",
|
||
]
|