按 P1-Pipecat Flows Agent化与记忆改造交接.md 的 Phase 3 完成。 **语音路径保持 legacy**,文字路径可由 AI_AGENT_RUNTIME=flows 切换。 服务器实测通过工具、姓名、记忆三项验收,随后按文档要求改回 legacy。 新增 app/agent/flows_runtime.py - FlowsBridge:LLM 文本帧 → RTVI 消息 + 分句 TTS,产出与 legacy 的 _run_turn 一致(bot-llm-started → 逐 token bot-llm-text → stopped → bot-transcription / bot-output) - UserTextRecorder:截下用户原话供策略层判定语义等级 - build_flows_parts / make_flow_manager:拆成两段,解决 "FlowManager 要 worker、worker 要等管线" 的先后依赖 - 节点只用 task_messages,**不用 role_message**(避免两条 system 消息) - respond_immediately=False(不自动开口) - handler 用闭包捕获 ctx_factory,不用模块级全局(并发会话会串身份) 新增 app/agent/llm_service.py - 固化 Phase 1 spike 的选型:复用 DeepSeekLLMService,显式覆盖模型名 (Pipecat 默认的 deepseek-v4-flash 不存在) 配置 app/config.py - 新增 AgentConfig + AI_AGENT_RUNTIME,**默认 legacy** 改造 services/bot_server.py - EngineProcessor 加 owns_text:flows 模式下语音仍归它,文字帧往下传 - runtime_deps() 暴露 memory/identity/session/tts,两条路径共用同一份资源 (避免各建一份 SQLite 连接或各加载一次 TTS) - **_run_task 改用 PipelineRunner**:直接 task.run() 时 runner 没挂上, Flows 的工具执行会失败("runner is not set; call attach() first"), 异常被转成给 LLM 的通用错误串,默认日志看不到。这正是 Phase 1 spike 记录的坑 2,集成时又踩了一次。 新增 app/tests/test_flows_runtime.py(22 项,不依赖网络) 新增 deploy.sh - **不用 --delete**、一次只同步一个目录、显式排除清单。 起因见下。 客户端 client/test_client.py 加 --text 模式(RTVI send-text) - 信封字段实测校正两次:**是 id 不是 msg_id**,**data.content 不是 text**; 写错会被服务端以 "Invalid RTVI transport message" 拒掉且客户端无感知 - 等待条件改为"等到非静音音频",不能数音频块——WebRTC 空闲时也在推静音 服务器实测(2026-09-16) - legacy 文字:11.2s 音频 / 25 个有声段 - flows 文字:9.3s 音频 / 18 个有声段,首音约 2.8s - flows 记忆闭环:只说"记住我不吃香菜" → 工具写入 confirmed 记忆 → 数据库确认 #4 [confirmed] preference 饮食偏好=不吃香菜 - 称呼"林"从档案恢复 已知问题(留给 Phase 4/7) - Pipecat 报 "Both system_instruction and an initial system message in context are set" 警告。来源未查明(我只用了 task_messages)。 切回本地 vLLM 时可能 400,因为该后端拒绝多条 system 消息。 - Flows 路径暂不暴露 web_search(交接文档要求分阶段,搜索仍走 legacy)
263 lines
9.8 KiB
Python
263 lines
9.8 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 AgentConfig:
|
||
"""Agent 运行时的选择。
|
||
|
||
Phase 3 起有两条路径,**默认必须是 legacy**——交接文档要求
|
||
"flows 默认值只有在线上验收后才能从 legacy 改为 flows"。
|
||
"""
|
||
|
||
#: "legacy" = ConversationEngine 手写工具循环(当前线上行为)
|
||
#: "flows" = Pipecat Flows 驱动文字路径(Phase 3 新路径)
|
||
runtime: str = field(default_factory=lambda: _env("AI_AGENT_RUNTIME", "legacy"))
|
||
|
||
@property
|
||
def use_flows(self) -> bool:
|
||
return self.runtime.strip().lower() == "flows"
|
||
|
||
|
||
@dataclass
|
||
class AppConfig:
|
||
# 单用户原型的稳定内部档案 ID。它只用于数据隔离,绝不是用户可见姓名。
|
||
default_user_id: str = field(
|
||
default_factory=lambda: _env("AI_DEFAULT_USER_ID", "u_ylhan"))
|
||
#: 主 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)
|
||
agent: AgentConfig = field(default_factory=AgentConfig)
|
||
|
||
@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",
|
||
]
|