助手本体:会话引擎、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:
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# 模型与音频产物:体积大、可重新获取,不进版本管理。
|
||||||
|
# 重新下载:p0/stt_tts_bench/download_models.sh
|
||||||
|
|
||||||
|
p0/stt_tts_bench/models/
|
||||||
|
p0/stt_tts_bench/wavs/
|
||||||
|
p0/stt_tts_bench/roundtrip_wav/
|
||||||
|
p0/stt_tts_bench/listening_test/
|
||||||
|
p0/stt_tts_bench/listening_ab/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# 配置与密钥:本机配置在 ~/.config/ai_assistant/env,永不出现在仓库里
|
||||||
|
.env
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# 编辑器
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# AI 桌面陪伴助手 · 代码说明
|
||||||
|
|
||||||
|
**架构一句话**:服务器是助手,本地是客户端。
|
||||||
|
助手本体(会话引擎、记忆、身份、STT/TTS 调度)跑在 cadadmin-server;
|
||||||
|
N5095 只做 USB 外设接入和网络桥接。理由与实测见 `P0-语音基准报告.md`。
|
||||||
|
|
||||||
|
运行方式与配置见 `部署说明.md`;总体方案见 `AI桌面陪伴助手方案.md`。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
app/ 助手本体(部署到服务器)
|
||||||
|
├── config.py 配置。全部可用环境变量覆盖——部署时不改代码
|
||||||
|
├── prompts.py 提示词构造。单条 system 每轮重建
|
||||||
|
├── engine.py 会话引擎:身份 → 记忆 → LLM → TTS 的核心回路
|
||||||
|
├── llm/ LLM 抽象与后端
|
||||||
|
│ ├── base.py 接口 + SSE 解析 + 消息归一化
|
||||||
|
│ ├── local_vllm.py 本机 vLLM(Qwen3.8-27B,已停用)
|
||||||
|
│ ├── deepseek.py DeepSeek 云 API(当前主用)
|
||||||
|
│ └── __init__.py 工厂 + 降级路由
|
||||||
|
├── memory/ 记忆
|
||||||
|
│ ├── base.py 接口。**按"可能远程"设计**——全 async、可超时、可失败
|
||||||
|
│ └── sqlite_store.py SQLite 实现
|
||||||
|
├── identity/ 身份
|
||||||
|
│ ├── base.py 接口。人脸只提供候选,**不自动切身份**
|
||||||
|
│ └── manual.py 首版实现:显式切换 + 访客兜底
|
||||||
|
├── voice/ 语音
|
||||||
|
│ ├── stt.py sherpa-onnx 流式 Zipformer(CPU)
|
||||||
|
│ ├── tts.py 本地 vits-zh-ll(sherpa-onnx)
|
||||||
|
│ └── remote_tts.py Qwen3-TTS 远端服务适配(当前主用)
|
||||||
|
└── tests/ 4 个测试,共 88 项断言,全部无需硬件
|
||||||
|
|
||||||
|
services/
|
||||||
|
└── tts_server.py Qwen3-TTS 服务(跑在 ~/tts-qwen,独立 venv)
|
||||||
|
|
||||||
|
p0/ P0 阶段的验证与基准脚本(保留用于复现)
|
||||||
|
├── verify_deepseek.py DeepSeek 四条路径
|
||||||
|
├── verify_local_llm.py 本机 Qwen3.8-27B
|
||||||
|
└── stt_tts_bench/ STT/TTS 基准、模型下载脚本、线程扫描
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 服务器上(完整)
|
||||||
|
cd ~/ai-companion
|
||||||
|
.venv/bin/python app/tests/test_llm.py # 27 项
|
||||||
|
.venv/bin/python app/tests/test_memory.py # 28 项
|
||||||
|
.venv/bin/python app/tests/test_identity.py # 17 项
|
||||||
|
.venv/bin/python app/tests/test_engine_e2e.py # 16 项,端到端
|
||||||
|
```
|
||||||
|
|
||||||
|
端到端测试**不需要麦克风、音箱、摄像头、ESP32**——
|
||||||
|
这是"服务器即助手"架构的直接好处:链路可以在任何有 CPU 的地方验证,
|
||||||
|
硬件到货后再接最后一段。跑完会把合成音频写到 `out/`,可以人工试听。
|
||||||
|
|
||||||
|
## 设计上几个刻意的选择
|
||||||
|
|
||||||
|
**接口按最坏情况设计。** `MemoryStore` 全是 async、可超时、可失败,
|
||||||
|
虽然现在背后是本地 SQLite(微秒级)。因为将来多设备共享记忆时要搬到服务器,
|
||||||
|
如果现在写成同步直连本地文件,那时要重写所有调用方。
|
||||||
|
|
||||||
|
**身份、会话、视觉追踪三者分开。** `user_id`(永久身份)、`session_id`(一次会话)、
|
||||||
|
`track_id`(画面里的一个目标)互不冒充。人脸认出来**不等于**确认对方在说话——
|
||||||
|
所以人脸只产生"建议",不自动切换身份。做错的后果是路人经过就把你的私人记忆念出来。
|
||||||
|
|
||||||
|
**记忆查询强制带 `owner_user_id`。** 没有"按 memory_id 单独取"的口子:
|
||||||
|
猜到 id 就能读别人的记忆,是这类系统最容易犯的错。删除同理,必须同时匹配 owner。
|
||||||
|
|
||||||
|
**自动抽取的记忆是"候选",默认不参与检索。** 只有用户显式"记住"或工具调用
|
||||||
|
写入的才是"已确认"。对应方案 §8 的"模型推测不当作确认事实"。
|
||||||
|
|
||||||
|
**异步任务绑定创建时的身份。** 回复后写记忆的任务在创建时捕获 `user_id`,
|
||||||
|
不读"当前用户"——否则用户在这几百毫秒内切了身份,这条记忆会写到别人名下。
|
||||||
|
|
||||||
|
**引擎与传输解耦。** 不依赖 Pipecat、不依赖任何传输方式,只依赖四个接口。
|
||||||
|
好处:可以用文件音频端到端测试;接 WebRTC 时只需写薄适配层。
|
||||||
|
|
||||||
|
## 踩过的坑
|
||||||
|
|
||||||
|
全部写在对应文件的注释里,这里只列索引,详见 `部署说明.md` §已知的坑:
|
||||||
|
|
||||||
|
- 本机 vLLM 只接受一条 system 消息(多条 400)
|
||||||
|
- 思考字段名 vLLM 用 `reasoning`、DeepSeek 用 `reasoning_content`
|
||||||
|
- DeepSeek 默认开思考,多轮/工具回灌会 400;且思考会吃掉 `max_tokens`
|
||||||
|
- `max_num_sentences=1` 静默截断多句输入
|
||||||
|
- VITS 非自回归,首音 = 整句合成耗时
|
||||||
|
- Qwen3-TTS 短句 RTF 反而最差(固定开销约 1 秒)
|
||||||
|
- qwen-tts 与 vllm-omni 依赖互斥,必须分 venv
|
||||||
|
- STT 与 TTS 的线程扩展方向相反
|
||||||
+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",
|
||||||
|
]
|
||||||
+389
@@ -0,0 +1,389 @@
|
|||||||
|
"""会话引擎:身份 → 记忆 → LLM → TTS 的核心回路。
|
||||||
|
|
||||||
|
这一层不依赖 Pipecat、不依赖任何传输方式,只依赖四个接口
|
||||||
|
(LLMProvider / MemoryStore / IdentityProvider / LocalTTS)。
|
||||||
|
好处有两个:
|
||||||
|
|
||||||
|
1. **可以用文件音频端到端测试**,不需要麦克风、音箱、WebRTC。
|
||||||
|
2. 接 Pipecat 时只需写薄适配层;换传输方式(WebRTC / 本地声卡)不动这里。
|
||||||
|
|
||||||
|
一轮的完整流程:
|
||||||
|
|
||||||
|
用户音频
|
||||||
|
└→ STT 转写
|
||||||
|
└→ 身份裁决(决定用谁的记忆)
|
||||||
|
└→ 记忆检索(带超时,失败不阻塞)
|
||||||
|
└→ 拼 system(每轮重建,单条)
|
||||||
|
└→ LLM 流式
|
||||||
|
└→ 按句切分 → 逐句 TTS → 边合成边播
|
||||||
|
└→ 回复结束,异步写记忆(绑定创建时的身份)
|
||||||
|
|
||||||
|
延迟的关键在最后两步:**必须按句切分后立刻合成**。
|
||||||
|
VITS 是非自回归的,整段合成完才出声;只有切成短句才能提前开口。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import AsyncIterator, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .config import AppConfig
|
||||||
|
from .identity.base import IdentityDecision, IdentityProvider
|
||||||
|
from .llm.base import LLMProvider, ToolCall
|
||||||
|
from .memory.base import (
|
||||||
|
ConfirmStatus, Memory, MemoryKind, MemoryStore, Scope, Turn,
|
||||||
|
)
|
||||||
|
from .prompts import build_system_prompt, trim_history
|
||||||
|
from .voice.stt import StreamingSTT
|
||||||
|
from .voice.tts import LocalTTS, split_sentences
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EventKind(str, Enum):
|
||||||
|
IDENTITY = "identity" # 本轮的身份裁决结果
|
||||||
|
MEMORY_USED = "memory_used" # 检索到的记忆
|
||||||
|
TRANSCRIPT = "transcript" # STT 结果
|
||||||
|
RESPONSE_DELTA = "response_delta" # LLM 增量文本(未播,仅供显示)
|
||||||
|
SPEAK = "speak" # 一段可播放的音频
|
||||||
|
TOOL_CALL = "tool_call" # 模型请求调用工具
|
||||||
|
METRICS = "metrics" # 本轮计时
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Event:
|
||||||
|
kind: EventKind
|
||||||
|
text: str = ""
|
||||||
|
audio: np.ndarray | None = None
|
||||||
|
sample_rate: int = 16000
|
||||||
|
identity: IdentityDecision | None = None
|
||||||
|
memories: list[Memory] = field(default_factory=list)
|
||||||
|
tool_call: ToolCall | None = None
|
||||||
|
metrics: "TurnMetrics | None" = None
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TurnMetrics:
|
||||||
|
"""按方案 §10 要求记录耗时,便于定位瓶颈。"""
|
||||||
|
stt_ms: float = 0.0
|
||||||
|
identity_ms: float = 0.0
|
||||||
|
retrieve_ms: float = 0.0
|
||||||
|
llm_first_content_ms: float = 0.0
|
||||||
|
first_audio_ms: float = 0.0 # 从开始处理到第一段音频就绪
|
||||||
|
total_ms: float = 0.0
|
||||||
|
llm_backend: str = ""
|
||||||
|
memories_used: int = 0
|
||||||
|
reply_chars: int = 0
|
||||||
|
speaks: int = 0
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
return (f"STT {self.stt_ms:.0f}ms | 身份 {self.identity_ms:.0f}ms | "
|
||||||
|
f"检索 {self.retrieve_ms:.0f}ms | LLM首token {self.llm_first_content_ms:.0f}ms | "
|
||||||
|
f"首音 {self.first_audio_ms:.0f}ms | 总 {self.total_ms:.0f}ms | "
|
||||||
|
f"{self.llm_backend} | 记忆{self.memories_used}条 | {self.speaks}段音频")
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationEngine:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cfg: AppConfig,
|
||||||
|
llm: LLMProvider,
|
||||||
|
memory: MemoryStore,
|
||||||
|
identity: IdentityProvider,
|
||||||
|
tts: LocalTTS,
|
||||||
|
stt: StreamingSTT | None = None,
|
||||||
|
):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.llm = llm
|
||||||
|
self.memory = memory
|
||||||
|
self.identity = identity
|
||||||
|
self.tts = tts
|
||||||
|
self.stt = stt
|
||||||
|
#: 会话历史。切身份时会清空——方案 §8 要求切换身份
|
||||||
|
#: 清理上一人的私有检索上下文。
|
||||||
|
self._history: list[dict] = []
|
||||||
|
self._history_identity_version: int = -1
|
||||||
|
self._session_id: str = ""
|
||||||
|
self._current_turn: asyncio.Task | None = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 会话
|
||||||
|
|
||||||
|
async def start_session(self, session_id: str | None = None,
|
||||||
|
user_id: str | None = None) -> str:
|
||||||
|
sid = session_id or f"s_{uuid.uuid4().hex[:12]}"
|
||||||
|
if user_id:
|
||||||
|
self.identity.switch(user_id, reason="会话指定")
|
||||||
|
d = self.identity.current()
|
||||||
|
await self.memory.start_session(sid, d.user_id)
|
||||||
|
self._session_id = sid
|
||||||
|
self._history_identity_version = self._identity_version()
|
||||||
|
log.info("会话开始 %s,身份 %s", sid, d.user_id)
|
||||||
|
return sid
|
||||||
|
|
||||||
|
async def end_session(self) -> None:
|
||||||
|
if self._session_id:
|
||||||
|
await self.memory.end_session(self._session_id)
|
||||||
|
|
||||||
|
def _identity_version(self) -> int:
|
||||||
|
# ManualIdentityProvider 有 version;其他实现没有时退化为 0
|
||||||
|
return getattr(self.identity, "version", 0)
|
||||||
|
|
||||||
|
def _sync_identity_scope(self) -> None:
|
||||||
|
"""身份变了就丢弃历史。
|
||||||
|
|
||||||
|
不做这件事的后果:切到新用户后,模型仍看得到上一人的对话,
|
||||||
|
第一条回复就可能带出别人的信息。
|
||||||
|
"""
|
||||||
|
v = self._identity_version()
|
||||||
|
if v != self._history_identity_version:
|
||||||
|
if self._history:
|
||||||
|
log.info("身份变更,丢弃 %d 条历史消息", len(self._history))
|
||||||
|
self._history = []
|
||||||
|
self._history_identity_version = v
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 主回路
|
||||||
|
|
||||||
|
async def respond_to_text(self, user_text: str, *,
|
||||||
|
session_id: str | None = None) -> AsyncIterator[Event]:
|
||||||
|
"""处理一句用户输入,产出事件流。"""
|
||||||
|
t_start = time.perf_counter()
|
||||||
|
metrics = TurnMetrics()
|
||||||
|
sid = session_id or self._session_id or await self.start_session()
|
||||||
|
|
||||||
|
self._sync_identity_scope()
|
||||||
|
identity = self.identity.current()
|
||||||
|
metrics.identity_ms = (time.perf_counter() - t_start) * 1000
|
||||||
|
yield Event(kind=EventKind.IDENTITY, identity=identity)
|
||||||
|
|
||||||
|
if not user_text.strip():
|
||||||
|
yield Event(kind=EventKind.METRICS, metrics=metrics)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ---- 记忆检索:带超时,失败不阻塞(方案 §8)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
memories = await self._retrieve_memories(identity, user_text)
|
||||||
|
metrics.retrieve_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
metrics.memories_used = len(memories)
|
||||||
|
if memories:
|
||||||
|
yield Event(kind=EventKind.MEMORY_USED, memories=memories)
|
||||||
|
|
||||||
|
# ---- 拼 system(每轮重建,单条)
|
||||||
|
system = build_system_prompt(identity, memories)
|
||||||
|
history = trim_history(self._history,
|
||||||
|
max_turns=self.cfg.context.max_turns)
|
||||||
|
messages = [{"role": "system", "content": system}] + history + \
|
||||||
|
[{"role": "user", "content": user_text}]
|
||||||
|
|
||||||
|
# ---- LLM 流式 + 逐句 TTS
|
||||||
|
reply_parts: list[str] = []
|
||||||
|
buffer = ""
|
||||||
|
speaks = 0
|
||||||
|
async for ev in self._stream_and_speak(messages, metrics):
|
||||||
|
if ev.kind == EventKind.RESPONSE_DELTA:
|
||||||
|
reply_parts.append(ev.text)
|
||||||
|
buffer += ev.text
|
||||||
|
# 增量文本必须**原样透传**给调用方(显示、字幕、日志都要用)。
|
||||||
|
# 这里漏掉 yield 的后果是:音频正常出来了,但上层拿到的是空回复——
|
||||||
|
# 因为文本只进了内部累加器,从未离开这个生成器。
|
||||||
|
yield ev
|
||||||
|
# 攒到句末就送去合成——这是提前出声的唯一手段
|
||||||
|
segs = split_sentences(buffer)
|
||||||
|
if len(segs) > 1:
|
||||||
|
*ready, buffer = segs
|
||||||
|
for seg in ready:
|
||||||
|
async for speak_ev in self._speak(seg, metrics):
|
||||||
|
speaks += 1
|
||||||
|
yield speak_ev
|
||||||
|
else:
|
||||||
|
yield ev
|
||||||
|
|
||||||
|
# 收尾:把剩下的半句也合成掉
|
||||||
|
if buffer.strip():
|
||||||
|
async for speak_ev in self._speak(buffer, metrics):
|
||||||
|
speaks += 1
|
||||||
|
yield speak_ev
|
||||||
|
|
||||||
|
reply = "".join(reply_parts).strip()
|
||||||
|
metrics.reply_chars = len(reply)
|
||||||
|
metrics.speaks = speaks
|
||||||
|
metrics.total_ms = (time.perf_counter() - t_start) * 1000
|
||||||
|
|
||||||
|
# ---- 落历史
|
||||||
|
self._history.append({"role": "user", "content": user_text})
|
||||||
|
if reply:
|
||||||
|
self._history.append({"role": "assistant", "content": reply})
|
||||||
|
|
||||||
|
# ---- 异步写记忆:**绑定创建时的身份**,不读"当前用户"
|
||||||
|
if self.cfg.memory.auto_extract_enabled and reply:
|
||||||
|
self._spawn_memory_write(
|
||||||
|
user_text=user_text, reply=reply,
|
||||||
|
user_id=identity.user_id,
|
||||||
|
session_id=sid,
|
||||||
|
turn_id=f"t_{uuid.uuid4().hex[:10]}")
|
||||||
|
|
||||||
|
await self.memory.add_turn(Turn(
|
||||||
|
turn_id=f"t_{uuid.uuid4().hex[:10]}", session_id=sid,
|
||||||
|
user_id=identity.user_id, role="user", text=user_text))
|
||||||
|
|
||||||
|
yield Event(kind=EventKind.METRICS, metrics=metrics)
|
||||||
|
|
||||||
|
async def respond_to_audio(self, samples: np.ndarray, *,
|
||||||
|
session_id: str | None = None) -> AsyncIterator[Event]:
|
||||||
|
"""从音频走完整条链路(需要 STT)。"""
|
||||||
|
if self.stt is None:
|
||||||
|
yield Event(kind=EventKind.ERROR, error="未配置 STT,无法处理音频")
|
||||||
|
return
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
stream = self.stt.new_stream()
|
||||||
|
stream.accept(samples)
|
||||||
|
text = stream.finish()
|
||||||
|
stt_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
yield Event(kind=EventKind.TRANSCRIPT, text=text)
|
||||||
|
async for ev in self.respond_to_text(text, session_id=session_id):
|
||||||
|
if ev.kind == EventKind.METRICS and ev.metrics:
|
||||||
|
ev.metrics.stt_ms = stt_ms
|
||||||
|
yield ev
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 内部
|
||||||
|
|
||||||
|
async def _retrieve_memories(self, identity: IdentityDecision,
|
||||||
|
query: str) -> list[Memory]:
|
||||||
|
"""带超时的检索。超时/出错一律返回空,绝不让对话卡住。"""
|
||||||
|
if not identity.confirmed:
|
||||||
|
# 访客不做个性化检索(方案 §6:"未知身份只用访客临时上下文")
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
self.memory.retrieve(
|
||||||
|
identity.user_id, query,
|
||||||
|
limit=self.cfg.context.max_memories,
|
||||||
|
scopes=[Scope.PRIVATE, Scope.SHARED]),
|
||||||
|
timeout=self.cfg.context.memory_timeout_s)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.warning("记忆检索超时 %.2fs,本轮不带记忆继续",
|
||||||
|
self.cfg.context.memory_timeout_s)
|
||||||
|
return []
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.warning("记忆检索失败(%s),本轮不带记忆继续", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _stream_and_speak(self, messages: list[dict],
|
||||||
|
metrics: TurnMetrics) -> AsyncIterator[Event]:
|
||||||
|
"""只负责 LLM 流式,不做 TTS。TTS 由调用方按句驱动。"""
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
async for chunk in self.llm.stream(messages):
|
||||||
|
if chunk.kind.value == "content" and chunk.text:
|
||||||
|
if not metrics.llm_first_content_ms:
|
||||||
|
metrics.llm_first_content_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
yield Event(kind=EventKind.RESPONSE_DELTA, text=chunk.text)
|
||||||
|
elif chunk.kind.value == "tool_call" and chunk.tool_name:
|
||||||
|
# 骨架阶段只上报,不执行。工具执行要配合
|
||||||
|
# 方案 §9 的"执行 ID + 状态确认 + 不盲目重试",
|
||||||
|
# 留到接米家时再做。
|
||||||
|
yield Event(kind=EventKind.TOOL_CALL, tool_call=ToolCall(
|
||||||
|
id=chunk.tool_id, name=chunk.tool_name,
|
||||||
|
arguments=chunk.tool_arguments))
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
log.info("本轮被取消(打断)")
|
||||||
|
raise
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.error("LLM 流失败:%s", e)
|
||||||
|
yield Event(kind=EventKind.ERROR, error=f"{type(e).__name__}: {e}")
|
||||||
|
metrics.llm_backend = getattr(self.llm, "last_backend", self.llm.name)
|
||||||
|
|
||||||
|
async def _speak(self, text: str, metrics: TurnMetrics) -> AsyncIterator[Event]:
|
||||||
|
"""合成一段并产出可播放音频。"""
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
# TTS 是 CPU 密集的同步调用,放线程里跑,别阻塞事件循环
|
||||||
|
audio = await asyncio.to_thread(self.tts.synthesize, text)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.error("TTS 失败:%s", e)
|
||||||
|
yield Event(kind=EventKind.ERROR, error=f"TTS 失败:{e}")
|
||||||
|
return
|
||||||
|
if audio is None or len(audio) == 0:
|
||||||
|
return
|
||||||
|
if not metrics.first_audio_ms:
|
||||||
|
metrics.first_audio_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
yield Event(kind=EventKind.SPEAK, text=text, audio=audio,
|
||||||
|
sample_rate=self.tts.sample_rate)
|
||||||
|
|
||||||
|
def _spawn_memory_write(self, *, user_text: str, reply: str,
|
||||||
|
user_id: str, session_id: str, turn_id: str) -> None:
|
||||||
|
"""起一个后台任务写记忆。
|
||||||
|
|
||||||
|
**身份是参数传进来的,不在这里读 self.identity.current()。**
|
||||||
|
方案 §8 明确要求:"异步任务绑定创建时的 user_id/session_id,
|
||||||
|
避免迟到结果写入新用户。" 如果这里去读"当前身份",
|
||||||
|
用户在这几百毫秒内切了身份,这条记忆就会写到别人名下。
|
||||||
|
"""
|
||||||
|
async def _write():
|
||||||
|
try:
|
||||||
|
for mem in extract_memories(user_text, reply):
|
||||||
|
mem.owner_user_id = user_id # 绑定创建时的身份
|
||||||
|
mem.source_session_id = session_id
|
||||||
|
mem.source_turn_id = turn_id
|
||||||
|
mem.confirm_status = ConfirmStatus(
|
||||||
|
self.cfg.memory.auto_extract_confirm_status)
|
||||||
|
await self.memory.remember(mem)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.warning("异步写记忆失败(不影响对话):%s", e)
|
||||||
|
|
||||||
|
task = asyncio.create_task(_write())
|
||||||
|
# 持有引用,避免被 GC 提前回收
|
||||||
|
if not hasattr(self, "_bg_tasks"):
|
||||||
|
self._bg_tasks: set[asyncio.Task] = set()
|
||||||
|
self._bg_tasks.add(task)
|
||||||
|
task.add_done_callback(self._bg_tasks.discard)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 记忆抽取
|
||||||
|
|
||||||
|
#: 显式"记住"的触发词。首版用规则,不额外调一次 LLM——
|
||||||
|
#: 那会多一次往返,而语音场景对延迟很敏感。
|
||||||
|
_REMEMBER_PATTERNS = (
|
||||||
|
("记住", ""), ("记一下", ""), ("别忘了", ""), ("帮我记", ""),
|
||||||
|
("我喜欢", "偏好"), ("我不喜欢", "偏好"), ("我习惯", "偏好"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_memories(user_text: str, reply: str) -> list[Memory]:
|
||||||
|
"""从一轮对话里抽取候选记忆。
|
||||||
|
|
||||||
|
首版只认显式表达。**抽取结果默认是 candidate,不参与检索**——
|
||||||
|
方案 §8:"模型推测不当作确认事实"。要变成 confirmed 需要用户确认
|
||||||
|
或走工具调用。
|
||||||
|
"""
|
||||||
|
out: list[Memory] = []
|
||||||
|
text = (user_text or "").strip()
|
||||||
|
for trigger, kind_hint in _REMEMBER_PATTERNS:
|
||||||
|
if trigger in text:
|
||||||
|
# 取触发词之后的正文
|
||||||
|
body = text.split(trigger, 1)[1].strip(" ::,,。.!!??")
|
||||||
|
if len(body) < 2:
|
||||||
|
continue
|
||||||
|
out.append(Memory(
|
||||||
|
kind=MemoryKind.PREFERENCE if kind_hint else MemoryKind.FACT,
|
||||||
|
key=kind_hint or "",
|
||||||
|
value=body[:200],
|
||||||
|
))
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ConversationEngine", "Event", "EventKind", "TurnMetrics", "extract_memories",
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""身份层。
|
||||||
|
|
||||||
|
对外暴露 `IdentityProvider` 接口与首版 `ManualIdentityProvider` 实现。
|
||||||
|
将来接人脸/声纹只需新增实现,记忆表结构与调用方都不动
|
||||||
|
(这正是方案 §6 要求"预留 IdentityProvider 接口"的原因)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
GUEST_USER_ID, TRUST_GUEST, TRUST_NORMAL,
|
||||||
|
IdentityClaim, IdentityDecision, IdentityProvider,
|
||||||
|
)
|
||||||
|
from .manual import SUGGEST_MIN_CONFIDENCE, ManualIdentityProvider
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"IdentityProvider", "ManualIdentityProvider",
|
||||||
|
"IdentityClaim", "IdentityDecision",
|
||||||
|
"GUEST_USER_ID", "TRUST_NORMAL", "TRUST_GUEST", "SUGGEST_MIN_CONFIDENCE",
|
||||||
|
]
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""身份抽象层。
|
||||||
|
|
||||||
|
方案 §6 的核心约束,逐条落进接口:
|
||||||
|
|
||||||
|
* "首版可靠身份入口:手动/对话确认切换到已建档的 user_id;
|
||||||
|
无法确认时使用 unknown/guest。"
|
||||||
|
|
||||||
|
* **"看到熟人不等于确认他正在说话"** —— 人脸只提供候选,
|
||||||
|
不自动切换身份。这条最容易做错:摄像头看到谁就切谁的记忆,
|
||||||
|
结果是"路人经过就把你的私人记忆念出来"。
|
||||||
|
|
||||||
|
* "显式报名字用于个性化档案选择,**不是身份认证**,不能据此解锁敏感信息"
|
||||||
|
—— 所以 trust_level 与 user_id 分开存,将来加认证时不用改偏好存储。
|
||||||
|
|
||||||
|
* "预留 IdentityProvider 接口:输入时间戳、track_id、候选身份、置信度与来源;
|
||||||
|
输出确认的 user_id 或 unknown。后续声纹模块可接入此接口,不改记忆表结构。"
|
||||||
|
|
||||||
|
* "视觉 track_id、会话 session_id、永久 user_id 分开" ——
|
||||||
|
所以 IdentityClaim 里 track_id 是线索,不是身份。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
#: 无法确认身份时使用的固定 id。永远不落私人记忆。
|
||||||
|
GUEST_USER_ID = "guest"
|
||||||
|
|
||||||
|
#: 信任等级。与 user_id 分开,将来加认证不改这里。
|
||||||
|
TRUST_NORMAL = "normal"
|
||||||
|
TRUST_GUEST = "guest"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IdentityClaim:
|
||||||
|
"""一条身份线索。
|
||||||
|
|
||||||
|
source 取值(首版只用 manual,其余留给后续):
|
||||||
|
"manual" 用户显式切换/确认——**唯一可单独决定身份的来源**
|
||||||
|
"face" 人脸识别候选(本地特征,需用户主动录入)
|
||||||
|
"client_login" 客户端登录态(手机 App / 浏览器)
|
||||||
|
"voiceprint" 声纹(后续阶段)
|
||||||
|
"""
|
||||||
|
source: str
|
||||||
|
candidate_user_id: str | None = None
|
||||||
|
confidence: float = 0.0
|
||||||
|
track_id: str | None = None
|
||||||
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
#: 只有这些来源可以单独把身份定下来。人脸不在其中——
|
||||||
|
#: 方案明确要求人脸仅作候选、经确认后才启用对应记忆。
|
||||||
|
AUTHORITATIVE_SOURCES = ("manual", "client_login")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_authoritative(self) -> bool:
|
||||||
|
return self.source in self.AUTHORITATIVE_SOURCES
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IdentityDecision:
|
||||||
|
user_id: str
|
||||||
|
trust_level: str
|
||||||
|
confirmed: bool # True = 经确认;False = 默认访客
|
||||||
|
reason: str
|
||||||
|
#: 未被采纳的候选,保留下来供上层提示"是否要切换成 X"
|
||||||
|
suggestions: list[IdentityClaim] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_guest(self) -> bool:
|
||||||
|
return not self.confirmed
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityProvider(ABC):
|
||||||
|
"""身份裁决者。
|
||||||
|
|
||||||
|
实现要点:**宁可拒判,不要猜**。判错的代价是把 A 的私人记忆
|
||||||
|
用在 B 身上,比"这次不认识"严重得多。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def current(self) -> IdentityDecision:
|
||||||
|
"""当前身份。任何时候都必须能回答。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def switch(self, user_id: str, *, display_name: str = "",
|
||||||
|
reason: str = "显式切换") -> IdentityDecision:
|
||||||
|
"""显式切换身份——首版唯一可靠的入口。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def resolve(self, claims: Sequence[IdentityClaim]) -> IdentityDecision:
|
||||||
|
"""根据一组线索裁决身份。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def reset(self, reason: str = "回到待机") -> IdentityDecision:
|
||||||
|
"""回到访客身份,并清掉上一人的身份状态。
|
||||||
|
|
||||||
|
方案 §8 要求"切换身份清理上一人的私有检索上下文"——
|
||||||
|
调用方在收到身份变化后必须重新构造 system 消息(含记忆),
|
||||||
|
不能沿用上一轮的。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"IdentityProvider", "IdentityClaim", "IdentityDecision",
|
||||||
|
"GUEST_USER_ID", "TRUST_NORMAL", "TRUST_GUEST",
|
||||||
|
]
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""首版身份实现:显式切换 + 访客兜底。
|
||||||
|
|
||||||
|
对应方案 §6:"首版可靠身份入口:手动/对话确认切换到已建档的 user_id;
|
||||||
|
无法确认时使用 unknown/guest。"
|
||||||
|
|
||||||
|
行为规则:
|
||||||
|
|
||||||
|
1. 显式切换(`switch`)立即生效,这是唯一能单独定身份的方式。
|
||||||
|
2. 人脸/声纹线索只生成**建议**,不自动改身份。上层可以选择性地
|
||||||
|
问一句"要切换成 X 吗",但不能替用户决定。
|
||||||
|
3. 没有任何线索时是访客,且访客不能落私人记忆(由记忆层用
|
||||||
|
owner_user_id 隔离保证)。
|
||||||
|
4. 切换身份返回一个"版本号",上层据此判断要不要丢弃上一人的上下文。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from .base import (
|
||||||
|
GUEST_USER_ID, TRUST_GUEST, TRUST_NORMAL,
|
||||||
|
IdentityClaim, IdentityDecision, IdentityProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: 人脸候选被采纳为"建议"的最低置信度。
|
||||||
|
#: 低于这个值连建议都不给——减少无意义的打扰。
|
||||||
|
SUGGEST_MIN_CONFIDENCE = 0.6
|
||||||
|
|
||||||
|
|
||||||
|
class ManualIdentityProvider(IdentityProvider):
|
||||||
|
def __init__(self, initial_user: str | None = None):
|
||||||
|
self._decision = IdentityDecision(
|
||||||
|
user_id=initial_user or GUEST_USER_ID,
|
||||||
|
trust_level=TRUST_NORMAL if initial_user else TRUST_GUEST,
|
||||||
|
confirmed=bool(initial_user),
|
||||||
|
reason="初始身份" if initial_user else "初始为访客",
|
||||||
|
)
|
||||||
|
self._version = 0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 查询
|
||||||
|
|
||||||
|
def current(self) -> IdentityDecision:
|
||||||
|
return self._decision
|
||||||
|
|
||||||
|
@property
|
||||||
|
def version(self) -> int:
|
||||||
|
"""身份版本号。每次真正变更时 +1。
|
||||||
|
|
||||||
|
上层用它判断"上一轮的私有检索上下文是否过期"——
|
||||||
|
方案 §8 要求切换身份时清理上一人的上下文。
|
||||||
|
"""
|
||||||
|
return self._version
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 变更
|
||||||
|
|
||||||
|
def switch(self, user_id: str, *, display_name: str = "",
|
||||||
|
reason: str = "显式切换") -> IdentityDecision:
|
||||||
|
if not user_id:
|
||||||
|
raise ValueError("user_id 不能为空")
|
||||||
|
prev = self._decision.user_id
|
||||||
|
self._decision = IdentityDecision(
|
||||||
|
user_id=user_id,
|
||||||
|
trust_level=TRUST_NORMAL,
|
||||||
|
confirmed=True,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
if prev != user_id:
|
||||||
|
self._version += 1
|
||||||
|
log.info("身份切换 %s → %s(%s)", prev, user_id, reason)
|
||||||
|
return self._decision
|
||||||
|
|
||||||
|
def reset(self, reason: str = "回到待机") -> IdentityDecision:
|
||||||
|
if self._decision.user_id != GUEST_USER_ID:
|
||||||
|
self._version += 1
|
||||||
|
self._decision = IdentityDecision(
|
||||||
|
user_id=GUEST_USER_ID,
|
||||||
|
trust_level=TRUST_GUEST,
|
||||||
|
confirmed=False,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
return self._decision
|
||||||
|
|
||||||
|
def resolve(self, claims: Sequence[IdentityClaim]) -> IdentityDecision:
|
||||||
|
"""裁决。
|
||||||
|
|
||||||
|
顺序很重要:先看有没有权威来源(显式切换 / 客户端登录态),
|
||||||
|
有就采用;人脸之类的只作为建议附在后面。
|
||||||
|
"""
|
||||||
|
authoritative = [c for c in claims if c.is_authoritative and c.candidate_user_id]
|
||||||
|
suggestions = [
|
||||||
|
c for c in claims
|
||||||
|
if not c.is_authoritative
|
||||||
|
and c.candidate_user_id
|
||||||
|
and c.confidence >= SUGGEST_MIN_CONFIDENCE
|
||||||
|
]
|
||||||
|
|
||||||
|
if authoritative:
|
||||||
|
# 同一批里有多个权威来源且互相矛盾——宁可不切,报出来让人处理
|
||||||
|
ids = {c.candidate_user_id for c in authoritative}
|
||||||
|
if len(ids) > 1:
|
||||||
|
log.warning("权威身份线索互相矛盾 %s,保持当前身份", ids)
|
||||||
|
d = self._decision
|
||||||
|
d.reason = f"线索矛盾 {ids},保持 {d.user_id}"
|
||||||
|
d.suggestions = suggestions
|
||||||
|
return d
|
||||||
|
c = max(authoritative, key=lambda x: x.confidence)
|
||||||
|
assert c.candidate_user_id is not None
|
||||||
|
self.switch(c.candidate_user_id, reason=f"来源 {c.source}")
|
||||||
|
else:
|
||||||
|
# 没有权威来源:即便人脸认出来了也不切,只给建议
|
||||||
|
self._decision.suggestions = suggestions
|
||||||
|
if suggestions:
|
||||||
|
self._decision.reason = (
|
||||||
|
f"仅有人脸候选 {suggestions[0].candidate_user_id}"
|
||||||
|
f"(置信度 {suggestions[0].confidence:.2f}),未自动切换"
|
||||||
|
)
|
||||||
|
return self._decision
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ManualIdentityProvider", "SUGGEST_MIN_CONFIDENCE"]
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""LLM 后端选择与降级。
|
||||||
|
|
||||||
|
设计要点:**降级只在"还没吐出任何内容"时发生。**
|
||||||
|
|
||||||
|
一旦已经向调用方 yield 过 chunk,再切后端就会把两段不同来源的文本接在一起,
|
||||||
|
产出一句读不通的回复。那种情况下宁可让本轮失败——方案 §5 要求
|
||||||
|
"网络失败给简短提示,不反复播旧回复",而不是拼接残句。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
provider = create_provider(AppConfig.load().llm)
|
||||||
|
router = FallbackProvider(primary, fallback)
|
||||||
|
async for chunk in router.stream(messages): ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..config import LLMConfig
|
||||||
|
from .base import Chunk, ChunkKind, LLMError, LLMProvider, LLMResult, ToolCall
|
||||||
|
from .deepseek import DeepSeekProvider
|
||||||
|
from .local_vllm import LocalVLLMProvider
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_REGISTRY = {
|
||||||
|
"local": LocalVLLMProvider,
|
||||||
|
"deepseek": DeepSeekProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_provider(cfg: LLMConfig) -> LLMProvider:
|
||||||
|
cls = _REGISTRY.get(cfg.name)
|
||||||
|
if cls is None:
|
||||||
|
raise ValueError(f"未知的 LLM 后端 {cfg.name!r},可选:{sorted(_REGISTRY)}")
|
||||||
|
return cls(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
class FallbackProvider(LLMProvider):
|
||||||
|
"""主后端失败时切备用后端。
|
||||||
|
|
||||||
|
只对**连接层失败**降级(超时、拒连、5xx、模型不可用),
|
||||||
|
不对 4xx 这类"请求本身有问题"降级——那种情况换个后端也一样失败,
|
||||||
|
降级只会掩盖真正的 bug。
|
||||||
|
"""
|
||||||
|
|
||||||
|
supports_vision = True # 只要备用支持就算支持,实际由 _choose 时判断
|
||||||
|
|
||||||
|
def __init__(self, primary: LLMProvider, fallback: LLMProvider | None = None):
|
||||||
|
self.primary = primary
|
||||||
|
self.fallback = fallback
|
||||||
|
self.cfg = primary.cfg
|
||||||
|
self.name = f"{primary.name}+{fallback.name if fallback else 'none'}"
|
||||||
|
self.last_backend: str = primary.name
|
||||||
|
self._session = None
|
||||||
|
self._owns_session = False
|
||||||
|
|
||||||
|
def _build_body(self, *a, **kw): # 实际不会走到,路由在 stream 里
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self.primary.aclose()
|
||||||
|
if self.fallback:
|
||||||
|
await self.fallback.aclose()
|
||||||
|
|
||||||
|
async def stream(self, messages, *, tools=None, max_tokens=None):
|
||||||
|
emitted = False
|
||||||
|
try:
|
||||||
|
async for ch in self.primary.stream(messages, tools=tools, max_tokens=max_tokens):
|
||||||
|
emitted = True
|
||||||
|
self.last_backend = self.primary.name
|
||||||
|
yield ch
|
||||||
|
return
|
||||||
|
except Exception as e: # noqa: BLE001 — 要按类型决定是否降级
|
||||||
|
if emitted:
|
||||||
|
log.error("主后端 %s 在已输出后失败,不再降级:%s", self.primary.name, e)
|
||||||
|
raise
|
||||||
|
if self.fallback is None:
|
||||||
|
raise
|
||||||
|
if not self._should_fallback(e):
|
||||||
|
log.error("主后端 %s 失败且不适合降级:%s", self.primary.name, e)
|
||||||
|
raise
|
||||||
|
log.warning("主后端 %s 失败(%s),切备用 %s",
|
||||||
|
self.primary.name, type(e).__name__, self.fallback.name)
|
||||||
|
|
||||||
|
async for ch in self.fallback.stream(messages, tools=tools, max_tokens=max_tokens):
|
||||||
|
self.last_backend = self.fallback.name
|
||||||
|
yield ch
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _should_fallback(err: Exception) -> bool:
|
||||||
|
"""连接/超时/服务端错误才降级;请求本身的问题不降级。"""
|
||||||
|
if isinstance(err, LLMError):
|
||||||
|
# LLMError 里带的是 HTTP 状态码,只有 5xx / 429 值得重试到别的后端
|
||||||
|
msg = str(err)
|
||||||
|
for code in ("500", "502", "503", "504", "429"):
|
||||||
|
if f"HTTP {code}" in msg:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
# 超时、连接失败、DNS 等
|
||||||
|
return isinstance(err, (TimeoutError, ConnectionError, OSError))
|
||||||
|
|
||||||
|
async def health(self) -> tuple[bool, str]:
|
||||||
|
ok, detail = await self.primary.health()
|
||||||
|
if ok:
|
||||||
|
return True, f"主后端 {self.primary.name}: {detail}"
|
||||||
|
if self.fallback:
|
||||||
|
ok2, d2 = await self.fallback.health()
|
||||||
|
return ok2, f"主后端不可用({detail});备用 {self.fallback.name}: {d2}"
|
||||||
|
return False, detail
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LLMProvider", "LLMError", "Chunk", "ChunkKind", "LLMResult", "ToolCall",
|
||||||
|
"LocalVLLMProvider", "DeepSeekProvider", "FallbackProvider", "create_provider",
|
||||||
|
]
|
||||||
+308
@@ -0,0 +1,308 @@
|
|||||||
|
"""LLM 抽象层。
|
||||||
|
|
||||||
|
为什么需要这层:本机 vLLM 和 DeepSeek 虽然都是 OpenAI 兼容协议,
|
||||||
|
但有三处实测差异,写死任何一个都会出问题:
|
||||||
|
|
||||||
|
1. 思考内容的字段名
|
||||||
|
本机 vLLM : delta.reasoning
|
||||||
|
DeepSeek : delta.reasoning_content
|
||||||
|
只认一个会**静默丢弃**思考内容——不报错,只是看不见。
|
||||||
|
|
||||||
|
2. 关闭思考的方式
|
||||||
|
本机 vLLM : chat_template_kwargs={"enable_thinking": false}
|
||||||
|
DeepSeek : thinking={"type": "disabled"}
|
||||||
|
|
||||||
|
3. system 消息条数
|
||||||
|
本机 vLLM : 只允许一条,且必须在开头,多条直接 400
|
||||||
|
DeepSeek : 宽松
|
||||||
|
所以统一由本层归一化成单条 system。
|
||||||
|
|
||||||
|
以上均来自实测,见 P0-本地LLM验证报告.md / P0-DeepSeek验证报告.md。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, AsyncIterator, Sequence
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from ..config import LLMConfig
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ChunkKind(str, Enum):
|
||||||
|
REASONING = "reasoning"
|
||||||
|
CONTENT = "content"
|
||||||
|
TOOL_CALL = "tool_call"
|
||||||
|
DONE = "done"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Chunk:
|
||||||
|
kind: ChunkKind
|
||||||
|
text: str = ""
|
||||||
|
tool_index: int = 0
|
||||||
|
tool_id: str = ""
|
||||||
|
tool_name: str = ""
|
||||||
|
tool_arguments: str = ""
|
||||||
|
finish_reason: str | None = None
|
||||||
|
usage: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolCall:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
arguments: str
|
||||||
|
|
||||||
|
def parsed_arguments(self) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return json.loads(self.arguments or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LLMResult:
|
||||||
|
content: str = ""
|
||||||
|
reasoning: str = ""
|
||||||
|
tool_calls: list[ToolCall] = field(default_factory=list)
|
||||||
|
finish_reason: str | None = None
|
||||||
|
usage: dict[str, Any] | None = None
|
||||||
|
# 计时:首 token 是"思考"还是"回答"要分开,否则会误判延迟。
|
||||||
|
# 实测开思考时 138ms 就出首块,但那是思考内容,用户要听的回答还没开始。
|
||||||
|
first_reasoning_ms: float | None = None
|
||||||
|
first_content_ms: float | None = None
|
||||||
|
total_ms: float = 0.0
|
||||||
|
backend: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_tool_calls(self) -> bool:
|
||||||
|
return bool(self.tool_calls)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProvider(ABC):
|
||||||
|
"""一个 LLM 后端。
|
||||||
|
|
||||||
|
子类只需实现 `_build_body`(怎么关思考、怎么放参数);
|
||||||
|
消息归一化、SSE 解析、计时、取消都在基类做,保证各后端行为一致。
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: 是否支持图片输入。本机 vLLM 实例实测不支持("At most 0 image(s)")。
|
||||||
|
supports_vision: bool = False
|
||||||
|
|
||||||
|
def __init__(self, cfg: LLMConfig, session: aiohttp.ClientSession | None = None):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.name = cfg.name
|
||||||
|
self._session = session
|
||||||
|
self._owns_session = session is None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 供子类覆写
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _build_body(self, messages: list[dict], *, tools: Sequence[dict] | None,
|
||||||
|
max_tokens: int) -> dict:
|
||||||
|
"""构造请求体。各后端的思考开关差异在这里处理。"""
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {self.cfg.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def endpoint(self) -> str:
|
||||||
|
return self.cfg.base_url.rstrip("/") + "/chat/completions"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 消息归一化
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_messages(messages: Sequence[dict]) -> list[dict]:
|
||||||
|
"""把多条 system 合并成一条并置于开头。
|
||||||
|
|
||||||
|
本机 vLLM 对多条 system 直接 400(实测 "System message must be at the
|
||||||
|
beginning.")。而记忆注入天然想用第二条 system,所以在这里统一抹平。
|
||||||
|
|
||||||
|
顺带这也满足方案 §8 的要求:"注入的记忆每轮替换,不是追加"——
|
||||||
|
调用方每轮重新构造 system 内容即可。
|
||||||
|
"""
|
||||||
|
systems, rest = [], []
|
||||||
|
for m in messages:
|
||||||
|
if m.get("role") == "system":
|
||||||
|
c = m.get("content")
|
||||||
|
if isinstance(c, str) and c.strip():
|
||||||
|
systems.append(c.strip())
|
||||||
|
else:
|
||||||
|
rest.append(m)
|
||||||
|
out = []
|
||||||
|
if systems:
|
||||||
|
out.append({"role": "system", "content": "\n\n".join(systems)})
|
||||||
|
out.extend(rest)
|
||||||
|
return out
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 流式请求
|
||||||
|
|
||||||
|
async def _ensure_session(self) -> aiohttp.ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = aiohttp.ClientSession()
|
||||||
|
self._owns_session = True
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
if self._owns_session and self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
async def stream(
|
||||||
|
self,
|
||||||
|
messages: Sequence[dict],
|
||||||
|
*,
|
||||||
|
tools: Sequence[dict] | None = None,
|
||||||
|
max_tokens: int | None = None,
|
||||||
|
) -> AsyncIterator[Chunk]:
|
||||||
|
"""流式产出。调用方取消(CancelledError)时连接会随之关闭。
|
||||||
|
|
||||||
|
这就是方案 §5 要求的"打断时取消旧回复生成"的底层能力。
|
||||||
|
"""
|
||||||
|
body = self._build_body(
|
||||||
|
self.normalize_messages(messages),
|
||||||
|
tools=tools,
|
||||||
|
max_tokens=max_tokens or self.cfg.max_tokens,
|
||||||
|
)
|
||||||
|
session = await self._ensure_session()
|
||||||
|
timeout = aiohttp.ClientTimeout(
|
||||||
|
total=self.cfg.timeout_s,
|
||||||
|
sock_connect=5.0,
|
||||||
|
sock_read=self.cfg.first_token_timeout_s,
|
||||||
|
)
|
||||||
|
async with session.post(self.endpoint, json=body,
|
||||||
|
headers=self._headers(), timeout=timeout) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
detail = (await resp.text())[:400]
|
||||||
|
raise LLMError(f"{self.name} HTTP {resp.status}: {detail}")
|
||||||
|
async for chunk in self._iter_sse(resp):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _iter_sse(resp: aiohttp.ClientResponse) -> AsyncIterator[Chunk]:
|
||||||
|
"""解析 SSE。字段名差异在这里吸收。"""
|
||||||
|
# 工具调用的参数是分片到达的,按 index 累积
|
||||||
|
tool_acc: dict[int, dict] = {}
|
||||||
|
async for raw in resp.content:
|
||||||
|
line = raw.decode("utf-8", "replace").strip()
|
||||||
|
if not line or not line.startswith("data:"):
|
||||||
|
continue
|
||||||
|
payload = line[5:].strip()
|
||||||
|
if payload == "[DONE]":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
data = json.loads(payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
usage = data.get("usage")
|
||||||
|
choices = data.get("choices") or []
|
||||||
|
if not choices:
|
||||||
|
if usage:
|
||||||
|
yield Chunk(kind=ChunkKind.DONE, usage=usage)
|
||||||
|
continue
|
||||||
|
|
||||||
|
choice = choices[0]
|
||||||
|
delta = choice.get("delta") or {}
|
||||||
|
finish = choice.get("finish_reason")
|
||||||
|
|
||||||
|
# 两个字段名都认。少认一个就会静默丢思考内容。
|
||||||
|
reasoning = delta.get("reasoning_content") or delta.get("reasoning")
|
||||||
|
if reasoning:
|
||||||
|
yield Chunk(kind=ChunkKind.REASONING, text=reasoning)
|
||||||
|
|
||||||
|
content = delta.get("content")
|
||||||
|
if content:
|
||||||
|
yield Chunk(kind=ChunkKind.CONTENT, text=content)
|
||||||
|
|
||||||
|
for tc in delta.get("tool_calls") or []:
|
||||||
|
idx = tc.get("index", 0)
|
||||||
|
slot = tool_acc.setdefault(idx, {"id": "", "name": "", "arguments": ""})
|
||||||
|
if tc.get("id"):
|
||||||
|
slot["id"] = tc["id"]
|
||||||
|
fn = tc.get("function") or {}
|
||||||
|
if fn.get("name"):
|
||||||
|
slot["name"] = fn["name"]
|
||||||
|
if fn.get("arguments"):
|
||||||
|
slot["arguments"] += fn["arguments"]
|
||||||
|
yield Chunk(kind=ChunkKind.TOOL_CALL, tool_index=idx,
|
||||||
|
tool_id=slot["id"], tool_name=slot["name"],
|
||||||
|
tool_arguments=slot["arguments"])
|
||||||
|
|
||||||
|
if finish:
|
||||||
|
yield Chunk(kind=ChunkKind.DONE, finish_reason=finish, usage=usage)
|
||||||
|
# 注意:**不能在这里 return**。开了 stream_options.include_usage 后,
|
||||||
|
# usage 是在 finish_reason 之后的**单独一帧**里到达的
|
||||||
|
# (该帧 choices 为空)。提前返回会把 usage 丢掉。
|
||||||
|
# 循环由 [DONE] 或流结束自然终止。
|
||||||
|
|
||||||
|
async def complete(
|
||||||
|
self,
|
||||||
|
messages: Sequence[dict],
|
||||||
|
*,
|
||||||
|
tools: Sequence[dict] | None = None,
|
||||||
|
max_tokens: int | None = None,
|
||||||
|
) -> LLMResult:
|
||||||
|
"""收完整个流,返回结构化结果。"""
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
res = LLMResult(backend=self.name)
|
||||||
|
parts: list[str] = []
|
||||||
|
reason: list[str] = []
|
||||||
|
acc: dict[int, dict] = {}
|
||||||
|
async for ch in self.stream(messages, tools=tools, max_tokens=max_tokens):
|
||||||
|
if ch.kind == ChunkKind.CONTENT:
|
||||||
|
if res.first_content_ms is None:
|
||||||
|
res.first_content_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
parts.append(ch.text)
|
||||||
|
elif ch.kind == ChunkKind.REASONING:
|
||||||
|
if res.first_reasoning_ms is None:
|
||||||
|
res.first_reasoning_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
reason.append(ch.text)
|
||||||
|
elif ch.kind == ChunkKind.TOOL_CALL:
|
||||||
|
acc[ch.tool_index] = {"id": ch.tool_id, "name": ch.tool_name,
|
||||||
|
"arguments": ch.tool_arguments}
|
||||||
|
elif ch.kind == ChunkKind.DONE:
|
||||||
|
res.finish_reason = ch.finish_reason
|
||||||
|
if ch.usage:
|
||||||
|
res.usage = ch.usage
|
||||||
|
res.content = "".join(parts)
|
||||||
|
res.reasoning = "".join(reason)
|
||||||
|
res.tool_calls = [ToolCall(**v) for _, v in sorted(acc.items())]
|
||||||
|
res.total_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def health(self) -> tuple[bool, str]:
|
||||||
|
"""启动自检:确认模型名有效。方案 §10 要求记录实际版本。"""
|
||||||
|
try:
|
||||||
|
session = await self._ensure_session()
|
||||||
|
url = self.cfg.base_url.rstrip("/") + "/models"
|
||||||
|
async with session.get(url, headers=self._headers(),
|
||||||
|
timeout=aiohttp.ClientTimeout(total=10)) as r:
|
||||||
|
if r.status != 200:
|
||||||
|
return False, f"HTTP {r.status}"
|
||||||
|
data = await r.json()
|
||||||
|
ids = [m.get("id") for m in data.get("data", [])]
|
||||||
|
if self.cfg.model in ids:
|
||||||
|
return True, f"{self.cfg.model} 可用(清单 {len(ids)} 个)"
|
||||||
|
return False, f"模型 {self.cfg.model!r} 不在清单 {ids} 中"
|
||||||
|
except Exception as e: # noqa: BLE001 — 自检不该因异常类型漏判
|
||||||
|
return False, f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
class LLMError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["LLMProvider", "LLMError", "Chunk", "ChunkKind", "ToolCall", "LLMResult"]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""DeepSeek 云 API(备用后端)。
|
||||||
|
|
||||||
|
实测数据(P0-DeepSeek验证报告.md):
|
||||||
|
首 token P50 786ms / P95 1100ms(关思考,N=8)
|
||||||
|
视觉可用——正确读出图中数字
|
||||||
|
模型清单只有 deepseek-flash 与 deepseek-v4-pro
|
||||||
|
|
||||||
|
约束(重要,踩过):
|
||||||
|
默认开启思考模式,此时 API 要求把上一轮 assistant 消息里的
|
||||||
|
`reasoning_content` 原样回传,否则任何多轮/工具回灌一律 400:
|
||||||
|
"The `reasoning_content` in the thinking mode must be passed back to the API."
|
||||||
|
手工构造 assistant 消息(content=None / content="" / 不带 tools)三种写法
|
||||||
|
实测全部 400,只有回传服务端原始对象才行。
|
||||||
|
所以会话主路径必须显式关闭思考。
|
||||||
|
|
||||||
|
另:本服务思考字段名是 reasoning_content(本机 vLLM 是 reasoning),
|
||||||
|
基类两个都认。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from .base import LLMProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DeepSeekProvider(LLMProvider):
|
||||||
|
supports_vision = True
|
||||||
|
|
||||||
|
def _build_body(self, messages: list[dict], *, tools: Sequence[dict] | None,
|
||||||
|
max_tokens: int) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"model": self.cfg.model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": True,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"stream_options": {"include_usage": True},
|
||||||
|
}
|
||||||
|
# 显式声明思考开关。不传会走默认(开),多轮与工具回灌会 400。
|
||||||
|
body["thinking"] = {"type": "enabled" if self.cfg.thinking else "disabled"}
|
||||||
|
if tools:
|
||||||
|
body["tools"] = list(tools)
|
||||||
|
body["tool_choice"] = "auto"
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["DeepSeekProvider"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""本机 Qwen3.8-27B(vLLM,OpenAI 兼容)。
|
||||||
|
|
||||||
|
实测数据(P0-本地LLM验证报告.md):
|
||||||
|
关思考 首 token 367ms / 完成 10 tokens
|
||||||
|
开思考 首 token 1390ms / 完成 187 tokens,且**回答被 max_tokens 截断**
|
||||||
|
(实测 200 预算下只吐出"我不"两个字)
|
||||||
|
|
||||||
|
开思考不只是慢——思考 token 会占用 max_tokens 预算。
|
||||||
|
语音场景 max_tokens 本来就要设小,所以这不是"慢一点"的取舍,是"说不出话"。
|
||||||
|
默认关闭。
|
||||||
|
|
||||||
|
约束:只接受一条 system 消息,多条返回 400。已在基类归一化。
|
||||||
|
视觉:本实例不支持("At most 0 image(s) may be provided"),
|
||||||
|
模型目录里没有视觉塔。视觉要走 llama.cpp + mmproj 那条线。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from .base import LLMProvider
|
||||||
|
|
||||||
|
|
||||||
|
class LocalVLLMProvider(LLMProvider):
|
||||||
|
supports_vision = False
|
||||||
|
|
||||||
|
def _build_body(self, messages: list[dict], *, tools: Sequence[dict] | None,
|
||||||
|
max_tokens: int) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"model": self.cfg.model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": True,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"stream_options": {"include_usage": True},
|
||||||
|
}
|
||||||
|
# 关闭思考。这是本机部署实测唯一有效的方式
|
||||||
|
# (服务器上的 ctx_test.sh 也用同一写法)。
|
||||||
|
# 注意:即使关掉,响应里仍可能出现空的 reasoning 字段,属正常。
|
||||||
|
body["chat_template_kwargs"] = {"enable_thinking": self.cfg.thinking}
|
||||||
|
if tools:
|
||||||
|
body["tools"] = list(tools)
|
||||||
|
body["tool_choice"] = "auto"
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["LocalVLLMProvider"]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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"]
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
"""提示词构造。
|
||||||
|
|
||||||
|
关键约束(实测,P0-本地LLM验证报告.md §四):
|
||||||
|
|
||||||
|
本机 vLLM **只接受一条 system 消息**,多条返回
|
||||||
|
400 "System message must be at the beginning."
|
||||||
|
|
||||||
|
所以记忆不能作为"第二条 system"注入,必须**并进同一条里、每轮重建**。
|
||||||
|
|
||||||
|
这其实比追加式更干净,也正好满足方案 §8 的要求:
|
||||||
|
"切换身份清理上一人的私有检索上下文"、"注入的记忆每轮替换,不是追加"——
|
||||||
|
每轮从零拼出来的 system 天然不会有上一人的残留。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from .identity.base import IdentityDecision
|
||||||
|
from .memory.base import Memory
|
||||||
|
|
||||||
|
#: 语音助手的回复风格。方案 §5:"默认简短自然回复"。
|
||||||
|
#: 这几条不是"风格偏好",是**功能性的**——放出去的文本会直接进 TTS,
|
||||||
|
#: 带 Markdown 的话会被逐字念出来("井号井号 第一步")。
|
||||||
|
PERSONA_RULES = """\
|
||||||
|
你是一个桌面陪伴助手,通过语音和用户交流。用户听到的是合成语音,不是文字。
|
||||||
|
|
||||||
|
必须遵守:
|
||||||
|
1. 回复简短自然,适合朗读,默认一到两句话。不要长篇解释。
|
||||||
|
2. 绝对不要使用 Markdown、星号、井号、列表、表格、表情符号或括号注释。
|
||||||
|
3. 数字、日期、金额按中文口语写法,例如"一千二百元""下午三点半"。
|
||||||
|
4. 不确定的事直说不确定,不要编造。没做过的事不要说已经做了。
|
||||||
|
5. 需要操作设备时调用工具。不要假装已经执行,也不要说"正在为您打开"。
|
||||||
|
6. 用户打断时立刻停止,不要坚持把上一句说完。"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_prompt(
|
||||||
|
identity: IdentityDecision,
|
||||||
|
memories: Sequence[Memory] = (),
|
||||||
|
*,
|
||||||
|
extra: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""拼出本轮唯一的 system 消息。
|
||||||
|
|
||||||
|
每轮调用一次,不要缓存后复用——身份或记忆一变,缓存就会串。
|
||||||
|
"""
|
||||||
|
parts = [PERSONA_RULES]
|
||||||
|
|
||||||
|
if identity.confirmed:
|
||||||
|
parts.append(f"\n当前用户:{identity.user_id}(已确认身份)。")
|
||||||
|
else:
|
||||||
|
# 方案 §6:未知身份只用访客上下文,不做个性化
|
||||||
|
parts.append("\n当前用户身份未确认,按访客对待。"
|
||||||
|
"不要提及任何私人信息,也不要假定你知道对方是谁。")
|
||||||
|
|
||||||
|
if memories:
|
||||||
|
lines = ["\n已知的用户信息(供参考,不要逐条复述):"]
|
||||||
|
for m in memories:
|
||||||
|
lines.append(f"- {m.text}")
|
||||||
|
parts.append("\n".join(lines))
|
||||||
|
|
||||||
|
if identity.suggestions and not identity.confirmed:
|
||||||
|
# 有人脸候选但不自动切换。让助手可以自然地问一句,
|
||||||
|
# 而不是自作主张切换档案(方案 §6:"看到熟人不等于确认他正在说话")。
|
||||||
|
names = "、".join(
|
||||||
|
s.candidate_user_id for s in identity.suggestions[:2])
|
||||||
|
parts.append(
|
||||||
|
f"\n注意:摄像头可能认出了 {names},但这不代表对方正在说话。"
|
||||||
|
f"不要据此切换身份或提及该用户的私人信息。")
|
||||||
|
|
||||||
|
if extra:
|
||||||
|
parts.append("\n" + extra.strip())
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def trim_history(
|
||||||
|
history: Sequence[dict],
|
||||||
|
*,
|
||||||
|
max_turns: int,
|
||||||
|
max_chars: int = 4000,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""裁掉过旧的对话轮次。
|
||||||
|
|
||||||
|
方案 §8 要求"对话摘要限长、按需检索",不默认永久保存逐字稿。
|
||||||
|
这里做最简单的一档:保留最近 max_turns 轮,再按字符数兜底。
|
||||||
|
上下文越短,本地 LLM 的首 token 越短——这不是省资源,是直接买延迟。
|
||||||
|
"""
|
||||||
|
msgs = list(history)
|
||||||
|
# 一轮 = 一问一答,所以保留的消息数是轮数的两倍
|
||||||
|
keep = max_turns * 2
|
||||||
|
if len(msgs) > keep:
|
||||||
|
msgs = msgs[-keep:]
|
||||||
|
|
||||||
|
# 按字符数从旧到新裁,保证裁完仍然从 user 消息开始
|
||||||
|
while msgs and sum(len(m.get("content") or "") for m in msgs) > max_chars:
|
||||||
|
msgs.pop(0)
|
||||||
|
while msgs and msgs[0].get("role") != "user":
|
||||||
|
msgs.pop(0)
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["build_system_prompt", "trim_history", "PERSONA_RULES"]
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""端到端测试:音频/文本 → STT → 身份 → 记忆 → LLM → TTS → 音频。
|
||||||
|
|
||||||
|
**全程不需要麦克风、音箱、摄像头、ESP32。**
|
||||||
|
这是"服务器即助手"架构的直接好处:链路可以在任何有 CPU 的地方验证,
|
||||||
|
硬件到货后再接最后一段(真实音频进出)即可。
|
||||||
|
|
||||||
|
跑完会把合成音频写到 out/ 目录,可以人工试听整条链路的实际效果。
|
||||||
|
|
||||||
|
运行:
|
||||||
|
cd <项目根>
|
||||||
|
~/venvs/ai_assistant/bin/python app/tests/test_engine_e2e.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from app.config import AppConfig # noqa: E402
|
||||||
|
from app.engine import ConversationEngine, EventKind # noqa: E402
|
||||||
|
from app.identity import GUEST_USER_ID, ManualIdentityProvider # noqa: E402
|
||||||
|
from app.llm import FallbackProvider, create_provider # noqa: E402
|
||||||
|
from app.memory import SQLiteMemoryStore # noqa: E402
|
||||||
|
from app.voice import StreamingSTT, create_tts # noqa: E402
|
||||||
|
|
||||||
|
OK, FAIL = [], []
|
||||||
|
OUT = ROOT / "out"
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, cond: bool, detail: str = "") -> None:
|
||||||
|
(OK if cond else FAIL).append(name)
|
||||||
|
print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
|
||||||
|
|
||||||
|
|
||||||
|
def save_wav(path: Path, audio: np.ndarray, sr: int) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pcm = (np.clip(audio, -1, 1) * 32767).astype(np.int16)
|
||||||
|
with wave.open(str(path), "wb") as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(sr)
|
||||||
|
w.writeframes(pcm.tobytes())
|
||||||
|
|
||||||
|
|
||||||
|
async def collect(engine: ConversationEngine, text: str) -> tuple[str, np.ndarray, list]:
|
||||||
|
"""跑一轮,返回 (回复文本, 拼接音频, 事件列表)。"""
|
||||||
|
reply, audios, events = [], [], []
|
||||||
|
async for ev in engine.respond_to_text(text):
|
||||||
|
events.append(ev)
|
||||||
|
if ev.kind == EventKind.RESPONSE_DELTA:
|
||||||
|
reply.append(ev.text)
|
||||||
|
elif ev.kind == EventKind.SPEAK and ev.audio is not None:
|
||||||
|
audios.append(ev.audio)
|
||||||
|
audio = np.concatenate(audios) if audios else np.zeros(0, dtype=np.float32)
|
||||||
|
return "".join(reply).strip(), audio, events
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
cfg = AppConfig.load()
|
||||||
|
tmp = Path(tempfile.mkdtemp(prefix="e2e_"))
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print("=== 0. 装配 ===")
|
||||||
|
llm = FallbackProvider(create_provider(cfg.llm),
|
||||||
|
create_provider(cfg.llm_fallback))
|
||||||
|
mem = SQLiteMemoryStore(tmp / "memory.db")
|
||||||
|
await mem.open()
|
||||||
|
tts = create_tts(cfg.voice)
|
||||||
|
stt = StreamingSTT(cfg.voice)
|
||||||
|
ident = ManualIdentityProvider()
|
||||||
|
engine = ConversationEngine(cfg, llm, mem, ident, tts, stt)
|
||||||
|
print(f" LLM={cfg.llm.model} 备用={cfg.llm_fallback.model}")
|
||||||
|
tts_desc = (f"{cfg.voice.tts_backend}/{cfg.voice.tts_speaker}"
|
||||||
|
if cfg.voice.tts_backend == "remote" else f"local/{cfg.voice.tts_threads}线程")
|
||||||
|
print(f" STT={cfg.voice.stt_threads}线程 TTS={tts_desc}")
|
||||||
|
check("四个组件装配成功", True)
|
||||||
|
|
||||||
|
ok, detail = await llm.health()
|
||||||
|
check("LLM 可用", ok, detail)
|
||||||
|
if not ok:
|
||||||
|
await mem.close()
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
await engine.start_session("e2e_sess", user_id="u_ylhan")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 1
|
||||||
|
print("\n=== 1. 纯文本一轮(LLM → 分句 → TTS)===")
|
||||||
|
reply, audio, events = await collect(engine, "现在几点了?随便说一句就行。")
|
||||||
|
print(f" 回复: {reply[:60]!r}")
|
||||||
|
m = [e for e in events if e.kind == EventKind.METRICS][0].metrics
|
||||||
|
print(f" {m.summary()}")
|
||||||
|
check("有回复", bool(reply))
|
||||||
|
check("有音频输出", len(audio) > 0, f"{len(audio)} 采样点")
|
||||||
|
check("回复不含 Markdown",
|
||||||
|
not any(c in reply for c in "*#`|[]"), reply[:40])
|
||||||
|
# 首音延迟这里只做"没离谱"的兜底检查,不设严格阈值:
|
||||||
|
# 本机 N5095 的 TTS RTF 是 1.86(实测),首音必然被 TTS 拖到秒级;
|
||||||
|
# 服务器上同一模型是 0.09,首音在 150ms 量级。
|
||||||
|
# 真正该卡指标的是部署机(服务器),所以那个断言放在服务器上的验收里。
|
||||||
|
check("首音延迟未失控", m.first_audio_ms < 8000, f"{m.first_audio_ms:.0f}ms")
|
||||||
|
save_wav(OUT / "e2e_1_text.wav", audio, tts.sample_rate)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 2
|
||||||
|
print("\n=== 2. 多句回复按句播(每句独立音频)===")
|
||||||
|
_, _, events2 = await collect(
|
||||||
|
engine, "用三句话介绍一下你自己,每句短一点。")
|
||||||
|
speaks = [e for e in events2 if e.kind == EventKind.SPEAK]
|
||||||
|
print(f" 分 {len(speaks)} 段: {[e.text[:14] for e in speaks]}")
|
||||||
|
check("按句切成了多段", len(speaks) >= 2, f"{len(speaks)} 段")
|
||||||
|
audio2 = np.concatenate([e.audio for e in speaks]) if speaks else np.zeros(0, np.float32)
|
||||||
|
save_wav(OUT / "e2e_2_multisentence.wav", audio2, tts.sample_rate)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 3
|
||||||
|
print("\n=== 3. 记忆:显式记住 → 下一轮被检索到 ===")
|
||||||
|
await collect(engine, "记住我喜欢喝美式咖啡,不加糖。")
|
||||||
|
await asyncio.sleep(0.5) # 等异步写入落库
|
||||||
|
stored = await mem.list_memories("u_ylhan", include_candidates=True)
|
||||||
|
print(f" 库中记忆 {len(stored)} 条: {[m.text[:20] for m in stored]}")
|
||||||
|
check("记忆已落库", len(stored) >= 1, f"{len(stored)} 条")
|
||||||
|
check("自动抽取的是候选状态",
|
||||||
|
all(m.confirm_status.value == "candidate" for m in stored))
|
||||||
|
|
||||||
|
# 手动确认一条,看它是否参与检索
|
||||||
|
await mem.remember(type(stored[0])(
|
||||||
|
owner_user_id="u_ylhan", kind=stored[0].kind, key="",
|
||||||
|
value="用户喜欢喝美式咖啡,不加糖",
|
||||||
|
confirm_status=__import__("app.memory", fromlist=["ConfirmStatus"]).ConfirmStatus.CONFIRMED))
|
||||||
|
_, _, events3 = await collect(engine, "我想喝点东西,推荐一下?")
|
||||||
|
used = [e for e in events3 if e.kind == EventKind.MEMORY_USED]
|
||||||
|
n_used = len(used[0].memories) if used else 0
|
||||||
|
print(f" 本轮用到记忆 {n_used} 条")
|
||||||
|
check("确认的记忆参与检索", n_used >= 1, f"{n_used} 条")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 4
|
||||||
|
print("\n=== 4. 身份隔离:换人后拿不到上一个人的记忆 ===")
|
||||||
|
ident.switch("u_other", reason="测试切换")
|
||||||
|
_, _, events4 = await collect(engine, "我想喝点什么?")
|
||||||
|
used4 = [e for e in events4 if e.kind == EventKind.MEMORY_USED]
|
||||||
|
n4 = len(used4[0].memories) if used4 else 0
|
||||||
|
check("新身份没有旧记忆", n4 == 0, f"{n4} 条")
|
||||||
|
reply4 = "".join(e.text for e in events4 if e.kind == EventKind.RESPONSE_DELTA)
|
||||||
|
check("回复未泄露咖啡偏好", "美式" not in reply4 and "咖啡" not in reply4,
|
||||||
|
reply4[:50])
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 5
|
||||||
|
print("\n=== 5. 访客身份不做个性化 ===")
|
||||||
|
ident.reset()
|
||||||
|
print(f" 当前身份: {ident.current().user_id} (confirmed={ident.current().confirmed})")
|
||||||
|
_, _, events5 = await collect(engine, "我喜欢什么?")
|
||||||
|
used5 = [e for e in events5 if e.kind == EventKind.MEMORY_USED]
|
||||||
|
check("访客不检索私人记忆", len(used5) == 0 or not used5[0].memories)
|
||||||
|
reply5 = "".join(e.text for e in events5 if e.kind == EventKind.RESPONSE_DELTA)
|
||||||
|
check("访客回复不编造偏好", "美式" not in reply5, reply5[:50])
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 6
|
||||||
|
print("\n=== 6. 音频输入(真人录音 → STT → LLM → TTS)===")
|
||||||
|
# 测试音频不在代码目录里,和模型放在一起(都是可重新下载的产物)。
|
||||||
|
# 用模型目录反推,注意要 resolve()——服务器上模型目录是个软链,
|
||||||
|
# 不解析的话 .parent 会指到 ~/ai-companion 而不是真实位置。
|
||||||
|
wav_path = None
|
||||||
|
for cand in (
|
||||||
|
cfg.voice.model_dir.resolve().parent / "wavs" / "stt_1.wav",
|
||||||
|
ROOT / "p0" / "stt_tts_bench" / "wavs" / "stt_1.wav",
|
||||||
|
):
|
||||||
|
if cand.is_file():
|
||||||
|
wav_path = cand
|
||||||
|
break
|
||||||
|
if wav_path is not None:
|
||||||
|
with wave.open(str(wav_path)) as w:
|
||||||
|
samples = np.frombuffer(w.readframes(w.getnframes()),
|
||||||
|
dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
ident.switch("u_ylhan", reason="回到主人")
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
transcript, audios, events6 = [], [], []
|
||||||
|
async for ev in engine.respond_to_audio(samples):
|
||||||
|
events6.append(ev)
|
||||||
|
if ev.kind == EventKind.TRANSCRIPT:
|
||||||
|
transcript.append(ev.text)
|
||||||
|
elif ev.kind == EventKind.SPEAK and ev.audio is not None:
|
||||||
|
audios.append(ev.audio)
|
||||||
|
got = "".join(transcript)
|
||||||
|
m6 = [e for e in events6 if e.kind == EventKind.METRICS]
|
||||||
|
print(f" STT: {got[:50]!r}")
|
||||||
|
if m6 and m6[0].metrics:
|
||||||
|
print(f" {m6[0].metrics.summary()}")
|
||||||
|
check("音频链路跑通(能出转写)", bool(got.strip()))
|
||||||
|
if audios:
|
||||||
|
save_wav(OUT / "e2e_6_fromaudio.wav", np.concatenate(audios), tts.sample_rate)
|
||||||
|
else:
|
||||||
|
check("音频链路跑通", False, f"测试音频不存在: {wav_path}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 7
|
||||||
|
print("\n=== 7. 打断:取消正在进行的回复 ===")
|
||||||
|
cancelled = "未取消"
|
||||||
|
chunks_seen = 0
|
||||||
|
|
||||||
|
async def long_turn():
|
||||||
|
# 必须把整个流消费完才谈得上"取消"。
|
||||||
|
# 第一版写成 `async for _ in ...: return "已开始"`——
|
||||||
|
# 第一个事件就 return,生成器随之关闭,根本没有正在进行的回复可取消,
|
||||||
|
# 测试于是永远"失败",但失败原因跟取消能力毫无关系。
|
||||||
|
nonlocal chunks_seen
|
||||||
|
async for _ev in engine.respond_to_text("请详细讲一讲中国历史,越长越好。"):
|
||||||
|
chunks_seen += 1
|
||||||
|
return "已完成"
|
||||||
|
|
||||||
|
task = asyncio.create_task(long_turn())
|
||||||
|
await asyncio.sleep(0.7)
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancelled = "已取消"
|
||||||
|
check("长回复可被取消", cancelled == "已取消" and chunks_seen > 0,
|
||||||
|
f"{cancelled},取消前已收到 {chunks_seen} 个事件")
|
||||||
|
|
||||||
|
await engine.end_session()
|
||||||
|
await mem.close()
|
||||||
|
await llm.aclose()
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
print(f"\n合成音频已写入 {OUT}/ ,可人工试听:")
|
||||||
|
for f in sorted(OUT.glob("e2e_*.wav")):
|
||||||
|
print(f" {f.name}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 62)
|
||||||
|
print(f"通过 {len(OK)} / {len(OK) + len(FAIL)}")
|
||||||
|
for n in FAIL:
|
||||||
|
print(f" 未通过: {n}")
|
||||||
|
return 0 if not FAIL else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(asyncio.run(main()))
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""身份层测试。
|
||||||
|
|
||||||
|
最关键的一条:**人脸认出来不等于切身份**。
|
||||||
|
方案 §6 写得很直白:"看到熟人不等于确认他正在说话"。
|
||||||
|
做错的后果是——路人从摄像头前走过,助手就把你的私人记忆念了出来。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||||
|
|
||||||
|
from app.identity import ( # noqa: E402
|
||||||
|
GUEST_USER_ID, IdentityClaim, ManualIdentityProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
OK, FAIL = [], []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, cond: bool, detail: str = "") -> None:
|
||||||
|
(OK if cond else FAIL).append(name)
|
||||||
|
print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=== 1. 初始状态 ===")
|
||||||
|
p = ManualIdentityProvider()
|
||||||
|
d = p.current()
|
||||||
|
check("默认是访客", d.user_id == GUEST_USER_ID and d.is_guest, d.user_id)
|
||||||
|
check("访客信任等级独立", d.trust_level == "guest")
|
||||||
|
|
||||||
|
print("\n=== 2. 显式切换(首版唯一可靠入口)===")
|
||||||
|
d = p.switch("u_ylhan", reason="对话确认")
|
||||||
|
check("切换生效", d.user_id == "u_ylhan" and d.confirmed)
|
||||||
|
check("信任等级为 normal", d.trust_level == "normal")
|
||||||
|
check("版本号 +1", p.version == 1, str(p.version))
|
||||||
|
v = p.version
|
||||||
|
p.switch("u_ylhan") # 切到同一个人
|
||||||
|
check("切到同一人不增版本号", p.version == v, f"{v} → {p.version}")
|
||||||
|
|
||||||
|
print("\n=== 3. 人脸候选不得自动切换(关键)===")
|
||||||
|
p2 = ManualIdentityProvider("u_ylhan")
|
||||||
|
before = p2.current().user_id
|
||||||
|
d = p2.resolve([IdentityClaim(source="face", candidate_user_id="u_other",
|
||||||
|
confidence=0.95, track_id="track_7")])
|
||||||
|
check("身份未被自动切换", d.user_id == before, f"{before} → {d.user_id}")
|
||||||
|
check("但仍保留建议", any(s.candidate_user_id == "u_other" for s in d.suggestions),
|
||||||
|
d.reason)
|
||||||
|
check("track_id 未污染 user_id", d.user_id != "track_7")
|
||||||
|
|
||||||
|
# 访客状态下也不应变
|
||||||
|
p3 = ManualIdentityProvider()
|
||||||
|
d3 = p3.resolve([IdentityClaim(source="face", candidate_user_id="u_ylhan",
|
||||||
|
confidence=0.99)])
|
||||||
|
check("访客状态也不自动切换", d3.user_id == GUEST_USER_ID and d3.is_guest, d3.reason)
|
||||||
|
|
||||||
|
print("\n=== 4. 低置信度连建议都不给 ===")
|
||||||
|
p4 = ManualIdentityProvider()
|
||||||
|
d4 = p4.resolve([IdentityClaim(source="face", candidate_user_id="u_x", confidence=0.3)])
|
||||||
|
check("低置信度无建议", len(d4.suggestions) == 0, f"{len(d4.suggestions)} 条")
|
||||||
|
|
||||||
|
print("\n=== 5. 权威来源可以切换 ===")
|
||||||
|
p5 = ManualIdentityProvider()
|
||||||
|
d5 = p5.resolve([IdentityClaim(source="client_login", candidate_user_id="u_phone",
|
||||||
|
confidence=1.0)])
|
||||||
|
check("客户端登录态可切身份", d5.user_id == "u_phone" and d5.confirmed, d5.reason)
|
||||||
|
|
||||||
|
d5b = p5.resolve([IdentityClaim(source="manual", candidate_user_id="u_ylhan")])
|
||||||
|
check("显式来源可切身份", d5b.user_id == "u_ylhan")
|
||||||
|
|
||||||
|
print("\n=== 6. 权威线索互相矛盾时不切 ===")
|
||||||
|
p6 = ManualIdentityProvider("u_ylhan")
|
||||||
|
d6 = p6.resolve([
|
||||||
|
IdentityClaim(source="manual", candidate_user_id="u_a", confidence=1.0),
|
||||||
|
IdentityClaim(source="client_login", candidate_user_id="u_b", confidence=1.0),
|
||||||
|
])
|
||||||
|
check("矛盾时保持原身份", d6.user_id == "u_ylhan", d6.reason)
|
||||||
|
|
||||||
|
print("\n=== 7. reset 回访客 ===")
|
||||||
|
p7 = ManualIdentityProvider("u_ylhan")
|
||||||
|
v7 = p7.version
|
||||||
|
d7 = p7.reset()
|
||||||
|
check("回到访客", d7.user_id == GUEST_USER_ID and d7.is_guest)
|
||||||
|
check("版本号 +1(上层据此丢弃旧上下文)", p7.version == v7 + 1,
|
||||||
|
f"{v7} → {p7.version}")
|
||||||
|
|
||||||
|
print("\n=== 8. 空 user_id 应报错而非静默接受 ===")
|
||||||
|
try:
|
||||||
|
p7.switch("")
|
||||||
|
check("拒绝空 user_id", False, "没报错")
|
||||||
|
except ValueError:
|
||||||
|
check("拒绝空 user_id", True)
|
||||||
|
|
||||||
|
print("\n" + "=" * 62)
|
||||||
|
print(f"通过 {len(OK)} / {len(OK) + len(FAIL)}")
|
||||||
|
for n in FAIL:
|
||||||
|
print(f" 未通过: {n}")
|
||||||
|
return 0 if not FAIL else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""LLM 抽象层测试。
|
||||||
|
|
||||||
|
重点验证实测踩过的三个坑是否真的被吸收:
|
||||||
|
1. 思考字段名差异(reasoning / reasoning_content)
|
||||||
|
2. 关闭思考的方式差异
|
||||||
|
3. 多条 system 消息归一化
|
||||||
|
|
||||||
|
运行:
|
||||||
|
~/venvs/ai_assistant/bin/python -m app.tests.test_llm
|
||||||
|
或
|
||||||
|
cd <项目根> && ~/venvs/ai_assistant/bin/python app/tests/test_llm.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||||
|
|
||||||
|
from app.config import AppConfig # noqa: E402
|
||||||
|
from app.llm import ( # noqa: E402
|
||||||
|
ChunkKind, DeepSeekProvider, FallbackProvider, LocalVLLMProvider, create_provider,
|
||||||
|
)
|
||||||
|
from app.llm.base import LLMProvider # noqa: E402
|
||||||
|
|
||||||
|
OK, FAIL = [], []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, cond: bool, detail: str = "") -> None:
|
||||||
|
(OK if cond else FAIL).append(name)
|
||||||
|
print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 纯本地逻辑(无需网络)
|
||||||
|
|
||||||
|
def test_normalize_messages() -> None:
|
||||||
|
print("\n=== 1. 消息归一化(多条 system → 一条,置于开头)===")
|
||||||
|
msgs = [
|
||||||
|
{"role": "system", "content": "你是助手。"},
|
||||||
|
{"role": "user", "content": "你好"},
|
||||||
|
{"role": "system", "content": "记忆:用户叫 ylhan。"},
|
||||||
|
]
|
||||||
|
out = LLMProvider.normalize_messages(msgs)
|
||||||
|
check("只剩一条 system", sum(1 for m in out if m["role"] == "system") == 1,
|
||||||
|
f"实际 {sum(1 for m in out if m['role']=='system')} 条")
|
||||||
|
check("system 在开头", out[0]["role"] == "system")
|
||||||
|
check("两条 system 内容都保留",
|
||||||
|
"你是助手。" in out[0]["content"] and "ylhan" in out[0]["content"])
|
||||||
|
check("非 system 消息顺序不变",
|
||||||
|
[m["role"] for m in out[1:]] == ["user"])
|
||||||
|
|
||||||
|
# 无 system 的情况不应凭空造一条
|
||||||
|
out2 = LLMProvider.normalize_messages([{"role": "user", "content": "hi"}])
|
||||||
|
check("无 system 时不新增", len(out2) == 1 and out2[0]["role"] == "user")
|
||||||
|
|
||||||
|
# 空 system 应被丢弃
|
||||||
|
out3 = LLMProvider.normalize_messages(
|
||||||
|
[{"role": "system", "content": " "}, {"role": "user", "content": "hi"}])
|
||||||
|
check("空白 system 被丢弃", len(out3) == 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_bodies() -> None:
|
||||||
|
print("\n=== 2. 各后端的请求体(关思考方式不同)===")
|
||||||
|
cfg_local = AppConfig.load().llm
|
||||||
|
cfg_ds = AppConfig.load().llm_fallback
|
||||||
|
cfg_local.thinking = False
|
||||||
|
cfg_ds.thinking = False
|
||||||
|
|
||||||
|
b_local = LocalVLLMProvider(cfg_local)._build_body(
|
||||||
|
[{"role": "user", "content": "hi"}], tools=None, max_tokens=100)
|
||||||
|
check("本机用 chat_template_kwargs",
|
||||||
|
b_local.get("chat_template_kwargs") == {"enable_thinking": False},
|
||||||
|
str(b_local.get("chat_template_kwargs")))
|
||||||
|
|
||||||
|
b_ds = DeepSeekProvider(cfg_ds)._build_body(
|
||||||
|
[{"role": "user", "content": "hi"}], tools=None, max_tokens=100)
|
||||||
|
check("DeepSeek 用 thinking.type",
|
||||||
|
b_ds.get("thinking") == {"type": "disabled"},
|
||||||
|
str(b_ds.get("thinking")))
|
||||||
|
|
||||||
|
check("两者模型名不同", b_local["model"] != b_ds["model"],
|
||||||
|
f"{b_local['model']} vs {b_ds['model']}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sse_parsing() -> None:
|
||||||
|
print("\n=== 3. SSE 解析:两种思考字段名都认 ===")
|
||||||
|
|
||||||
|
class FakeResp:
|
||||||
|
def __init__(self, lines): self.content = self._gen(lines)
|
||||||
|
async def _gen(self, lines):
|
||||||
|
for l in lines:
|
||||||
|
yield (l + "\n").encode()
|
||||||
|
|
||||||
|
async def run(lines):
|
||||||
|
return [c async for c in LLMProvider._iter_sse(FakeResp(lines))]
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# reasoning_content(DeepSeek 写法)
|
||||||
|
c1 = await run([
|
||||||
|
'data: {"choices":[{"delta":{"reasoning_content":"想一想"}}]}',
|
||||||
|
'data: {"choices":[{"delta":{"content":"你好"}}]}',
|
||||||
|
'data: [DONE]',
|
||||||
|
])
|
||||||
|
check("认 reasoning_content",
|
||||||
|
any(c.kind == ChunkKind.REASONING and c.text == "想一想" for c in c1))
|
||||||
|
check("content 正常", any(c.kind == ChunkKind.CONTENT and c.text == "你好" for c in c1))
|
||||||
|
|
||||||
|
# reasoning(本机 vLLM 写法)
|
||||||
|
c2 = await run([
|
||||||
|
'data: {"choices":[{"delta":{"reasoning":"想一下"}}]}',
|
||||||
|
'data: {"choices":[{"delta":{"content":"在的"}}]}',
|
||||||
|
'data: [DONE]',
|
||||||
|
])
|
||||||
|
check("认 reasoning(本机 vLLM 字段)",
|
||||||
|
any(c.kind == ChunkKind.REASONING and c.text == "想一下" for c in c2))
|
||||||
|
|
||||||
|
# 工具调用分片累积
|
||||||
|
c3 = await run([
|
||||||
|
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"set_light","arguments":"{\\"ro"}}]}}]}',
|
||||||
|
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"om\\":\\"客厅\\"}"}}]}}]}',
|
||||||
|
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}',
|
||||||
|
])
|
||||||
|
tc = [c for c in c3 if c.kind == ChunkKind.TOOL_CALL]
|
||||||
|
check("工具调用参数分片拼接", tc and tc[-1].tool_arguments == '{"room":"客厅"}',
|
||||||
|
tc[-1].tool_arguments if tc else "无")
|
||||||
|
check("工具名保留", tc and tc[-1].tool_name == "set_light")
|
||||||
|
|
||||||
|
# 坏帧不应崩溃
|
||||||
|
c4 = await run(['data: {坏json', 'data: {"choices":[{"delta":{"content":"ok"}}]}', 'data: [DONE]'])
|
||||||
|
check("坏帧被跳过而非崩溃",
|
||||||
|
any(c.kind == ChunkKind.CONTENT and c.text == "ok" for c in c4))
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 需要网络
|
||||||
|
|
||||||
|
async def test_live() -> None:
|
||||||
|
cfg = AppConfig.load()
|
||||||
|
provider = create_provider(cfg.llm)
|
||||||
|
print(f"\n=== 4. 实连本机 vLLM({cfg.llm.model} @ {cfg.llm.base_url})===")
|
||||||
|
ok, detail = await provider.health()
|
||||||
|
check("健康检查", ok, detail)
|
||||||
|
if not ok:
|
||||||
|
await provider.aclose()
|
||||||
|
return
|
||||||
|
|
||||||
|
msgs = [
|
||||||
|
{"role": "system", "content":
|
||||||
|
"你是桌面陪伴助手,回复必须简短自然、适合朗读,不超过两句话,"
|
||||||
|
"不要用 Markdown 或表情符号。"},
|
||||||
|
{"role": "user", "content": "今天天气怎么样"},
|
||||||
|
{"role": "assistant", "content": "今天北京晴,十八到二十六度。"},
|
||||||
|
{"role": "user", "content": "那明天呢"},
|
||||||
|
]
|
||||||
|
res = await provider.complete(msgs, max_tokens=120)
|
||||||
|
print(f" 回答: {res.content.strip()[:70]!r}")
|
||||||
|
print(f" 首token(回答)={res.first_content_ms:.0f}ms 总={res.total_ms:.0f}ms")
|
||||||
|
print(f" 思考={len(res.reasoning)}字 完成tok={res.usage.get('completion_tokens') if res.usage else '?'}")
|
||||||
|
check("有回答内容", bool(res.content.strip()))
|
||||||
|
check("关思考后无思考内容", len(res.reasoning) == 0, f"{len(res.reasoning)} 字")
|
||||||
|
check("首token在 1 秒内", (res.first_content_ms or 9e9) < 1000,
|
||||||
|
f"{res.first_content_ms:.0f}ms")
|
||||||
|
check("回复足够短", len(res.content) < 100, f"{len(res.content)} 字")
|
||||||
|
|
||||||
|
# 工具调用
|
||||||
|
tools = [{"type": "function", "function": {
|
||||||
|
"name": "set_light", "description": "开关指定房间的灯",
|
||||||
|
"parameters": {"type": "object", "properties": {
|
||||||
|
"room": {"type": "string"}, "on": {"type": "boolean"}},
|
||||||
|
"required": ["room", "on"]}}}]
|
||||||
|
res2 = await provider.complete([{"role": "user", "content": "把客厅的灯打开"}],
|
||||||
|
tools=tools, max_tokens=128)
|
||||||
|
check("工具调用正确", res2.has_tool_calls and
|
||||||
|
res2.tool_calls[0].parsed_arguments().get("room") == "客厅",
|
||||||
|
f"{res2.tool_calls[0].name if res2.tool_calls else '无'}")
|
||||||
|
|
||||||
|
# 取消:模拟打断
|
||||||
|
async def cancel_midway():
|
||||||
|
async for _ch in provider.stream(
|
||||||
|
[{"role": "user", "content": "请详细讲一下中国历史"}], max_tokens=800):
|
||||||
|
return "cancelled"
|
||||||
|
return "finished"
|
||||||
|
|
||||||
|
got = await asyncio.wait_for(cancel_midway(), timeout=30)
|
||||||
|
check("可中途取消(打断)", got == "cancelled", got)
|
||||||
|
await provider.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fallback_logic() -> None:
|
||||||
|
print("\n=== 5. 降级逻辑(不发真实请求)===")
|
||||||
|
cfg = AppConfig.load()
|
||||||
|
|
||||||
|
class BoomProvider(LocalVLLMProvider):
|
||||||
|
def __init__(self, c, err): super().__init__(c); self._err = err
|
||||||
|
async def stream(self, *a, **kw):
|
||||||
|
raise self._err
|
||||||
|
yield # noqa: 使其成为生成器
|
||||||
|
|
||||||
|
fallback = DeepSeekProvider(cfg.llm_fallback)
|
||||||
|
# 连接类错误应降级
|
||||||
|
from app.llm.base import LLMError
|
||||||
|
p = FallbackProvider(BoomProvider(cfg.llm, ConnectionError("连不上")), fallback)
|
||||||
|
check("连接错误判为可降级", p._should_fallback(ConnectionError("x")))
|
||||||
|
check("超时判为可降级", p._should_fallback(TimeoutError("x")))
|
||||||
|
check("503 判为可降级", p._should_fallback(LLMError("local HTTP 503: busy")))
|
||||||
|
check("400 判为不可降级", not p._should_fallback(LLMError("local HTTP 400: bad")))
|
||||||
|
check("401 判为不可降级", not p._should_fallback(LLMError("local HTTP 401: auth")))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
test_normalize_messages()
|
||||||
|
test_request_bodies()
|
||||||
|
test_sse_parsing()
|
||||||
|
asyncio.run(test_live())
|
||||||
|
asyncio.run(test_fallback_logic())
|
||||||
|
|
||||||
|
print("\n" + "=" * 62)
|
||||||
|
print(f"通过 {len(OK)} / {len(OK) + len(FAIL)}")
|
||||||
|
for n in FAIL:
|
||||||
|
print(f" 未通过: {n}")
|
||||||
|
return 0 if not FAIL else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""记忆层测试。
|
||||||
|
|
||||||
|
重点不是"能存能取",而是方案 §8 反复强调的隔离性质:
|
||||||
|
- 未知身份不碰私人记忆
|
||||||
|
- 删别人的记忆删不掉
|
||||||
|
- 自动抽取的候选不参与检索
|
||||||
|
- 删用户要连会话和摘要一起删干净
|
||||||
|
|
||||||
|
这些是"错了很难发现、发现了很严重"的类型,所以单独测。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||||
|
|
||||||
|
from app.memory import ( # noqa: E402
|
||||||
|
ConfirmStatus, Memory, MemoryKind, Scope, SQLiteMemoryStore, Turn,
|
||||||
|
)
|
||||||
|
from app.memory.sqlite_store import FALLBACK_MAX_CORPUS # noqa: E402
|
||||||
|
|
||||||
|
OK, FAIL = [], []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, cond: bool, detail: str = "") -> None:
|
||||||
|
(OK if cond else FAIL).append(name)
|
||||||
|
print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
|
||||||
|
|
||||||
|
|
||||||
|
async def run(store: SQLiteMemoryStore) -> None:
|
||||||
|
await store.open()
|
||||||
|
|
||||||
|
print("\n=== 1. 基本读写 ===")
|
||||||
|
await store.ensure_user("u_ylhan", "ylhan")
|
||||||
|
await store.ensure_user("u_guest", "访客")
|
||||||
|
mid = await store.remember(Memory(
|
||||||
|
owner_user_id="u_ylhan", kind=MemoryKind.PREFERENCE,
|
||||||
|
key="回复风格", value="喜欢简短直接,不要长篇解释"))
|
||||||
|
check("写入返回 memory_id", isinstance(mid, int) and mid > 0, str(mid))
|
||||||
|
got = await store.retrieve("u_ylhan", "回复风格")
|
||||||
|
check("能检索到", len(got) == 1 and got[0].key == "回复风格")
|
||||||
|
check("字段完整", got[0].kind == MemoryKind.PREFERENCE and got[0].value.startswith("喜欢"))
|
||||||
|
|
||||||
|
print("\n=== 2. 身份隔离(最关键)===")
|
||||||
|
other = await store.retrieve("u_guest", "回复风格")
|
||||||
|
check("别人检索不到", len(other) == 0, f"拿到 {len(other)} 条")
|
||||||
|
listed = await store.list_memories("u_guest")
|
||||||
|
check("别人列不出", len(listed) == 0)
|
||||||
|
|
||||||
|
# 用 memory_id 越权删
|
||||||
|
deleted = await store.forget("u_guest", mid)
|
||||||
|
check("越权删除失败", deleted is False)
|
||||||
|
still = await store.retrieve("u_ylhan", "回复风格")
|
||||||
|
check("原主记忆还在", len(still) == 1)
|
||||||
|
|
||||||
|
print("\n=== 3. 同键新值替换旧值 ===")
|
||||||
|
await store.remember(Memory(
|
||||||
|
owner_user_id="u_ylhan", kind=MemoryKind.PREFERENCE,
|
||||||
|
key="回复风格", value="喜欢更活泼一点的说法"))
|
||||||
|
rows = await store.retrieve("u_ylhan", "回复风格")
|
||||||
|
check("同键只剩一条", len(rows) == 1, f"{len(rows)} 条")
|
||||||
|
check("值已更新", "活泼" in rows[0].value, rows[0].value)
|
||||||
|
|
||||||
|
# 不同 key 不应互相覆盖
|
||||||
|
await store.remember(Memory(owner_user_id="u_ylhan", kind=MemoryKind.PREFERENCE,
|
||||||
|
key="称呼", value="叫他老韩"))
|
||||||
|
rows2 = await store.list_memories("u_ylhan")
|
||||||
|
check("不同 key 各自保留", len(rows2) == 2, f"{len(rows2)} 条")
|
||||||
|
|
||||||
|
print("\n=== 4. 候选记忆默认不参与检索 ===")
|
||||||
|
# 判据是"这条候选不在结果里",**不是"结果为空"**。
|
||||||
|
# 后者是实现细节:检索在关键词不命中时会退回最近记忆
|
||||||
|
# (见 sqlite_store.FALLBACK_MAX_CORPUS),结果本来就可能非空。
|
||||||
|
CAND = "用户好像养了一只叫豆豆的猫"
|
||||||
|
await store.remember(Memory(
|
||||||
|
owner_user_id="u_ylhan", kind=MemoryKind.FACT, key="", value=CAND,
|
||||||
|
confirm_status=ConfirmStatus.CANDIDATE))
|
||||||
|
hit = await store.retrieve("u_ylhan", "豆豆")
|
||||||
|
check("候选不出现在检索结果里", all(m.value != CAND for m in hit),
|
||||||
|
f"拿到 {len(hit)} 条:{[m.value[:12] for m in hit]}")
|
||||||
|
hit2 = await store.retrieve("u_ylhan", "豆豆", include_candidates=True)
|
||||||
|
check("显式要求时可检索到", any(m.value == CAND for m in hit2),
|
||||||
|
f"拿到 {len(hit2)} 条")
|
||||||
|
allm = await store.list_memories("u_ylhan")
|
||||||
|
check("候选确实已落库", any(m.confirm_status == ConfirmStatus.CANDIDATE for m in allm))
|
||||||
|
|
||||||
|
print("\n=== 5. 过期 ===")
|
||||||
|
EXPIRED = "十分钟后提醒我关火"
|
||||||
|
await store.remember(Memory(
|
||||||
|
owner_user_id="u_ylhan", kind=MemoryKind.TASK, key="", value=EXPIRED,
|
||||||
|
expires_at=time.time() - 1)) # 已过期
|
||||||
|
expired = await store.retrieve("u_ylhan", "关火")
|
||||||
|
check("过期记忆不出现在结果里", all(m.value != EXPIRED for m in expired),
|
||||||
|
f"拿到 {len(expired)} 条")
|
||||||
|
check("过期记忆也不在列表里",
|
||||||
|
all(m.value != EXPIRED for m in await store.list_memories("u_ylhan")))
|
||||||
|
|
||||||
|
print("\n=== 6. 会话与轮次 ===")
|
||||||
|
await store.start_session("sess_1", "u_ylhan")
|
||||||
|
for i, (role, text) in enumerate([("user", "今天天气怎么样"),
|
||||||
|
("assistant", "今天晴,十八到二十六度。")]):
|
||||||
|
await store.add_turn(Turn(turn_id=f"t{i}", session_id="sess_1",
|
||||||
|
user_id="u_ylhan", role=role, text=text))
|
||||||
|
check("会话与轮次写入无异常", True)
|
||||||
|
|
||||||
|
print("\n=== 7. 中文检索打分 ===")
|
||||||
|
r = SQLiteMemoryStore.relevance("客厅的灯", "客厅有一盏米家吸顶灯")
|
||||||
|
check("相关文本有分", r > 0.3, f"{r:.2f}")
|
||||||
|
r2 = SQLiteMemoryStore.relevance("客厅的灯", "喜欢听爵士乐")
|
||||||
|
check("无关文本为 0", r2 == 0.0, f"{r2:.2f}")
|
||||||
|
r3 = SQLiteMemoryStore.relevance("回复风格", "回复风格:喜欢简短")
|
||||||
|
check("子串直接命中", r3 == 1.0, f"{r3:.2f}")
|
||||||
|
|
||||||
|
print("\n=== 8. 删除用户要清干净 ===")
|
||||||
|
n = await store.purge_user("u_ylhan")
|
||||||
|
check("返回删除的记忆数", n >= 3, f"{n} 条")
|
||||||
|
check("记忆已清空", len(await store.list_memories("u_ylhan")) == 0)
|
||||||
|
check("检索也拿不到", len(await store.retrieve("u_ylhan", "回复风格")) == 0)
|
||||||
|
# 会话和轮次靠外键级联删除
|
||||||
|
rows = await store._run(lambda c: c.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM turns WHERE user_id=?", ("u_ylhan",)).fetchone()["n"])
|
||||||
|
check("轮次一并删除", rows == 0, f"残留 {rows} 条")
|
||||||
|
rows2 = await store._run(lambda c: c.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM sessions WHERE user_id=?", ("u_ylhan",)).fetchone()["n"])
|
||||||
|
check("会话一并删除", rows2 == 0, f"残留 {rows2} 条")
|
||||||
|
|
||||||
|
print("\n=== 9. 重复建用户不报错、不覆盖 ===")
|
||||||
|
await store.ensure_user("u_x", "第一次")
|
||||||
|
await store.ensure_user("u_x", "第二次")
|
||||||
|
name = await store._run(lambda c: c.execute(
|
||||||
|
"SELECT display_name FROM users WHERE user_id=?", ("u_x",)).fetchone()["display_name"])
|
||||||
|
check("已存在则不改", name == "第一次", name)
|
||||||
|
|
||||||
|
print("\n=== 10. 兜底检索要限量(否则就成了每轮全量注入)===")
|
||||||
|
await store.ensure_user("u_big")
|
||||||
|
await store.remember(Memory(owner_user_id="u_big", kind=MemoryKind.FACT,
|
||||||
|
value="常喝拿铁"))
|
||||||
|
# "推荐个饮品"和"常喝拿铁"没有任何共同字符,字面打分为 0
|
||||||
|
got = await store.retrieve("u_big", "推荐个饮品")
|
||||||
|
check("记忆少时兜底返回最近一条", len(got) == 1, f"{len(got)} 条")
|
||||||
|
|
||||||
|
for i in range(FALLBACK_MAX_CORPUS + 2):
|
||||||
|
await store.remember(Memory(owner_user_id="u_big", kind=MemoryKind.FACT,
|
||||||
|
value=f"无关记录编号{i}"))
|
||||||
|
got2 = await store.retrieve("u_big", "推荐个饮品")
|
||||||
|
check("记忆多时不再无条件兜底", len(got2) == 0, f"{len(got2)} 条")
|
||||||
|
got3 = await store.retrieve("u_big", "拿铁")
|
||||||
|
check("关键词命中仍生效", any("拿铁" in m.value for m in got3), f"{len(got3)} 条")
|
||||||
|
|
||||||
|
await store.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
tmp = Path(tempfile.mkdtemp(prefix="memtest_"))
|
||||||
|
try:
|
||||||
|
store = SQLiteMemoryStore(tmp / "memory.db")
|
||||||
|
asyncio.run(run(store))
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
print("\n" + "=" * 62)
|
||||||
|
print(f"通过 {len(OK)} / {len(OK) + len(FAIL)}")
|
||||||
|
for n in FAIL:
|
||||||
|
print(f" 未通过: {n}")
|
||||||
|
return 0 if not FAIL else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""语音层:STT / TTS。
|
||||||
|
|
||||||
|
TTS 有两个后端,接口一致(synthesize / synthesize_sentences),
|
||||||
|
切换只需改配置 AI_TTS_BACKEND:
|
||||||
|
|
||||||
|
local — sherpa-onnx vits-zh-ll,RTF 0.09,很快但音色用户不满意
|
||||||
|
remote — Qwen3-TTS 独立服务,RTF 0.55–0.94,音质是另一档
|
||||||
|
|
||||||
|
为什么 remote 要走 HTTP 而不是进程内:qwen-tts 与助手/vllm-omni 的依赖互斥
|
||||||
|
(transformers 4.57 vs 5.14、hf_hub 0.36 vs 1.31),同 venv 会互相破坏。
|
||||||
|
|
||||||
|
模型文件不随代码走(体积大、可重新下载),由 VoiceConfig.model_dir 指向。
|
||||||
|
缺失时构造对象直接报错并提示下载脚本,不静默降级——静默降级会让
|
||||||
|
"没声音"变成一个很难查的问题。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .stt import STTError, STTStream, StreamingSTT
|
||||||
|
from .tts import MAX_CHARS_PER_SEGMENT, LocalTTS, TTSError, split_sentences
|
||||||
|
from .remote_tts import RemoteTTS, RemoteTTSError
|
||||||
|
|
||||||
|
|
||||||
|
def create_tts(cfg):
|
||||||
|
"""按配置造 TTS。返回对象统一提供 synthesize / synthesize_sentences。"""
|
||||||
|
if cfg.tts_backend == "remote":
|
||||||
|
return RemoteTTS(cfg.tts_service_url, voice=cfg.tts_speaker,
|
||||||
|
instructions=cfg.tts_instructions,
|
||||||
|
language=cfg.tts_language)
|
||||||
|
return LocalTTS(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"StreamingSTT", "STTStream", "STTError",
|
||||||
|
"LocalTTS", "TTSError", "split_sentences", "MAX_CHARS_PER_SEGMENT",
|
||||||
|
"RemoteTTS", "RemoteTTSError", "create_tts",
|
||||||
|
]
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""远端 TTS 适配器(HTTP 调 Qwen3-TTS 服务)。
|
||||||
|
|
||||||
|
**为什么是 HTTP 而不是进程内调用**:qwen-tts 和 vllm-omni/助手的依赖互斥
|
||||||
|
(transformers 4.57 vs 5.14、hf_hub 0.36 vs 1.31),装一个 venv 里会互相破坏,
|
||||||
|
实测把 vllm-omni 搞崩过。所以 TTS 跑在独立进程,用 HTTP 通信。
|
||||||
|
|
||||||
|
接口和 `LocalTTS` 保持一致(同步的 synthesize / synthesize_sentences),
|
||||||
|
这样引擎那边一行不用改——切换本地 VITS 和远端 Qwen3-TTS 只是换一个类。
|
||||||
|
|
||||||
|
实测延迟(RTX 3090,Serena 音色,非流式):
|
||||||
|
|
||||||
|
文本长度 音频 合成耗时 RTF
|
||||||
|
8 字 1.52s 1429ms 0.94 ← 短句最差
|
||||||
|
19 字 3.36s 1881ms 0.56
|
||||||
|
26 字 4.64s 2527ms 0.55
|
||||||
|
|
||||||
|
**注意短句 RTF 反而最差**:每次调用有约 1 秒的固定开销,
|
||||||
|
与文本长度无关(talker 模型自回归解码的启动成本 + 声码器解码)。
|
||||||
|
所以"让模型先说个短句"并不能降低首音延迟——反而比中长句更不划算。
|
||||||
|
|
||||||
|
流式可以绕开这个固定开销,但 qwen-tts 的封装把 codes→音频 的解码放在
|
||||||
|
全部 codes 生成完之后,拿不到增量。要走流式需要接管底层解码循环。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import wave
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .tts import split_sentences # 复用分句逻辑
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteTTSError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteTTS:
|
||||||
|
"""通过 HTTP 调用远端 TTS 服务。接口与 LocalTTS 一致。"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, *, voice: str = "Serena",
|
||||||
|
instructions: str = "用轻快活泼的语气说",
|
||||||
|
language: str = "Chinese", timeout_s: float = 60.0):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.voice = voice
|
||||||
|
self.instructions = instructions
|
||||||
|
self.language = language
|
||||||
|
self.timeout_s = timeout_s
|
||||||
|
self.sample_rate = 24000 # Qwen3-TTS 输出 24kHz,稍后按需重采样
|
||||||
|
self._checked = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 健康检查
|
||||||
|
|
||||||
|
def health(self) -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"{self.base_url}/health",
|
||||||
|
timeout=10) as r:
|
||||||
|
d = json.loads(r.read().decode())
|
||||||
|
if d.get("model_loaded"):
|
||||||
|
return True, f"模型已加载,默认音色 {d.get('default_voice')}"
|
||||||
|
return False, f"服务在但模型未加载:{d.get('status')}"
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return False, f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
def list_voices(self) -> dict:
|
||||||
|
with urllib.request.urlopen(f"{self.base_url}/v1/voices", timeout=10) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 合成
|
||||||
|
|
||||||
|
def synthesize(self, text: str, *, instructions: str | None = None) -> np.ndarray:
|
||||||
|
"""合成一段文本,返回 float32 单声道。"""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return np.zeros(0, dtype=np.float32)
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"input": text,
|
||||||
|
"voice": self.voice,
|
||||||
|
"instructions": instructions if instructions is not None else self.instructions,
|
||||||
|
"language": self.language,
|
||||||
|
"response_format": "wav",
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{self.base_url}/v1/audio/speech",
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=self.timeout_s) as r:
|
||||||
|
wav_bytes = r.read()
|
||||||
|
sr = int(r.headers.get("X-Sample-Rate", self.sample_rate))
|
||||||
|
gen_ms = r.headers.get("X-Gen-Ms", "?")
|
||||||
|
rtf = r.headers.get("X-RTF", "?")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
detail = e.read().decode("utf-8", "replace")[:200]
|
||||||
|
raise RemoteTTSError(f"TTS 服务 HTTP {e.code}: {detail}") from e
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
raise RemoteTTSError(f"TTS 服务不可达:{type(e).__name__}: {e}") from e
|
||||||
|
|
||||||
|
self.sample_rate = sr
|
||||||
|
log.debug("合成 %d 字:服务端 %sms(RTF %s),往返 %.0fms",
|
||||||
|
len(text), gen_ms, rtf, (time.perf_counter() - t0) * 1000)
|
||||||
|
|
||||||
|
with wave.open(io.BytesIO(wav_bytes), "rb") as w:
|
||||||
|
n = w.getnframes()
|
||||||
|
ch = w.getnchannels()
|
||||||
|
pcm = w.readframes(n)
|
||||||
|
audio = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
if ch > 1: # 服务端是单声道,防御一下
|
||||||
|
audio = audio.reshape(-1, ch).mean(axis=1)
|
||||||
|
return audio
|
||||||
|
|
||||||
|
def synthesize_sentences(self, text: str) -> Iterator[tuple[str, np.ndarray]]:
|
||||||
|
"""按句合成,逐句返回。调用方拿到第一句就能开播。"""
|
||||||
|
for seg in split_sentences(text):
|
||||||
|
yield seg, self.synthesize(seg)
|
||||||
|
|
||||||
|
def synthesize_all(self, text: str) -> np.ndarray:
|
||||||
|
chunks = [a for _, a in self.synthesize_sentences(text) if len(a)]
|
||||||
|
if not chunks:
|
||||||
|
return np.zeros(0, dtype=np.float32)
|
||||||
|
return np.concatenate(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["RemoteTTS", "RemoteTTSError"]
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""本地流式 STT(sherpa-onnx Zipformer 中英双语)。
|
||||||
|
|
||||||
|
实测(P0-语音基准报告.md):
|
||||||
|
|
||||||
|
N5095(4核,无 AVX2) RTF 0.22 @2线程 / 0.86 @4线程
|
||||||
|
服务器(i9-14900K) RTF 0.03
|
||||||
|
|
||||||
|
**线程数是实测出来的,不能按"核多给多"猜。** N5095 上 4 线程比 2 线程
|
||||||
|
慢 3.9 倍(小算子多,每算子一次线程同步,线程越多同步开销越大)。
|
||||||
|
所以线程数由配置决定,不写死。
|
||||||
|
|
||||||
|
另外两条实测结论写进了代码:
|
||||||
|
|
||||||
|
* **冲刷耗时 ≈ 0**:流式解码过程中已持续输出,`input_finished()` 后
|
||||||
|
立即拿到最终结果。"说完到出结果"的开销在 VAD 判定,不在识别器。
|
||||||
|
* **并非每块都解码**:约每 3 块解一次(模型 chunk 约 300ms)。
|
||||||
|
所以单块耗时偶尔超过块长是正常的,判断能否实时要看 RTF 而不是单块。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import sherpa_onnx
|
||||||
|
|
||||||
|
from ..config import VoiceConfig
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class STTError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingSTT:
|
||||||
|
"""流式识别器。线程安全地创建多条流(多客户端场景)。"""
|
||||||
|
|
||||||
|
def __init__(self, cfg: VoiceConfig):
|
||||||
|
self.cfg = cfg
|
||||||
|
d = Path(cfg.model_dir) / "stt"
|
||||||
|
files = {
|
||||||
|
"tokens": d / "tokens.txt",
|
||||||
|
"encoder": d / "encoder-epoch-99-avg-1.int8.onnx",
|
||||||
|
"decoder": d / "decoder-epoch-99-avg-1.int8.onnx",
|
||||||
|
"joiner": d / "joiner-epoch-99-avg-1.int8.onnx",
|
||||||
|
}
|
||||||
|
missing = [k for k, p in files.items() if not p.is_file()]
|
||||||
|
if missing:
|
||||||
|
raise STTError(
|
||||||
|
f"STT 模型文件缺失:{missing}(目录 {d})。"
|
||||||
|
f"先跑 p0/stt_tts_bench/download_models.sh")
|
||||||
|
|
||||||
|
self._rec = sherpa_onnx.OnlineRecognizer.from_transducer(
|
||||||
|
tokens=str(files["tokens"]),
|
||||||
|
encoder=str(files["encoder"]),
|
||||||
|
decoder=str(files["decoder"]),
|
||||||
|
joiner=str(files["joiner"]),
|
||||||
|
num_threads=cfg.stt_threads,
|
||||||
|
sample_rate=cfg.sample_rate,
|
||||||
|
feature_dim=80,
|
||||||
|
decoding_method="greedy_search",
|
||||||
|
# 端点检测留给上层的 VAD 处理:方案 §5 要求
|
||||||
|
# "语音活动检测、说完判断、唤醒是不同环节",不合并。
|
||||||
|
enable_endpoint_detection=False,
|
||||||
|
)
|
||||||
|
log.info("STT 就绪:%d 线程,采样率 %d", cfg.stt_threads, cfg.sample_rate)
|
||||||
|
|
||||||
|
def new_stream(self) -> "STTStream":
|
||||||
|
return STTStream(self, self._rec.create_stream())
|
||||||
|
|
||||||
|
|
||||||
|
class STTStream:
|
||||||
|
"""一条识别流。"""
|
||||||
|
|
||||||
|
def __init__(self, owner: StreamingSTT, stream):
|
||||||
|
self._owner = owner
|
||||||
|
self._s = stream
|
||||||
|
self._sr = owner.cfg.sample_rate
|
||||||
|
self._consumed = 0
|
||||||
|
|
||||||
|
def accept(self, samples: np.ndarray) -> None:
|
||||||
|
"""喂入 float32 单声道音频(取值 [-1,1])。"""
|
||||||
|
if samples.dtype != np.float32:
|
||||||
|
samples = samples.astype(np.float32)
|
||||||
|
self._s.accept_waveform(self._sr, samples)
|
||||||
|
self._consumed += len(samples)
|
||||||
|
rec = self._owner._rec
|
||||||
|
while rec.is_ready(self._s):
|
||||||
|
rec.decode_stream(self._s)
|
||||||
|
|
||||||
|
def accept_pcm16(self, pcm: bytes) -> None:
|
||||||
|
"""喂入 16bit PCM 字节流(麦克风常见格式)。"""
|
||||||
|
self.accept(np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def result(self) -> str:
|
||||||
|
"""当前结果。流式过程中会不断变长,说完后即为最终结果。"""
|
||||||
|
return self._owner._rec.get_result(self._s)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def consumed_seconds(self) -> float:
|
||||||
|
"""已喂入的音频时长。用于判断"喂多少音频才出第一个字"(算法延迟)。"""
|
||||||
|
return self._consumed / self._sr
|
||||||
|
|
||||||
|
def finish(self) -> str:
|
||||||
|
"""结束输入并冲刷。
|
||||||
|
|
||||||
|
实测冲刷耗时 ≈ 0ms——流式解码已持续输出,这里不额外花钱。
|
||||||
|
"""
|
||||||
|
rec = self._owner._rec
|
||||||
|
self._s.input_finished()
|
||||||
|
while rec.is_ready(self._s):
|
||||||
|
rec.decode_stream(self._s)
|
||||||
|
return rec.get_result(self._s)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""丢弃当前流,重新开始(打断后重新听)。"""
|
||||||
|
self._owner._rec.reset(self._s)
|
||||||
|
self._consumed = 0
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["StreamingSTT", "STTStream", "STTError"]
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""本地 TTS(sherpa-onnx VITS 中文)。
|
||||||
|
|
||||||
|
选型依据(P0-语音基准报告.md §六之二):
|
||||||
|
|
||||||
|
vits-zh-ll 服务器 RTF 0.09 / N5095 RTF 1.86 ← 用户试听选定,音质最好
|
||||||
|
piper-huayan-med 服务器 0.36 / N5095 0.32 (音质次之)
|
||||||
|
kokoro 1.96 以上,排除
|
||||||
|
|
||||||
|
**两个必须写进代码的实测结论:**
|
||||||
|
|
||||||
|
1. **VITS 是非自回归的,没有"边合成边播"。**
|
||||||
|
实测回调只在合成结束后触发一次,所以首音延迟 == 整句合成耗时。
|
||||||
|
提前出声的唯一手段是**按语义短句逐句合成**——把长回复切成短句,
|
||||||
|
第一句合成完就能播。
|
||||||
|
|
||||||
|
2. **`max_num_sentences=1` 会静默截断多句输入。**
|
||||||
|
传含多个句号的文本只会合成第一句,**不报错**。
|
||||||
|
第一版测"长段"时只出 0.94 秒音频,差点当成正常结果。
|
||||||
|
所以这里必须先分句,逐句送。
|
||||||
|
|
||||||
|
另外按方案 §5 要求,输出是 16kHz 单声道 float32;调用方负责重采样。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import sherpa_onnx
|
||||||
|
|
||||||
|
from ..config import VoiceConfig
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: 句末标点。中英文都要,方案 §5 要求覆盖中英混读。
|
||||||
|
_SENT_END = "。!?;!?;"
|
||||||
|
#: 从句标点,长句再切一刀,避免单句合成太久
|
||||||
|
_CLAUSE = ",,、::"
|
||||||
|
|
||||||
|
#: 单句最大字符数。超过就按从句标点再切——
|
||||||
|
#: 句子越长,首音延迟越大(首音 == 整句合成耗时)。
|
||||||
|
MAX_CHARS_PER_SEGMENT = 30
|
||||||
|
|
||||||
|
|
||||||
|
class TTSError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def split_sentences(text: str, *, max_chars: int = MAX_CHARS_PER_SEGMENT) -> list[str]:
|
||||||
|
"""按语义切分,返回可逐句合成的片段。
|
||||||
|
|
||||||
|
先按句末标点切,过长的再按从句标点切,仍过长就硬切。
|
||||||
|
标点保留在片段末尾(TTS 需要它来判断语调)。
|
||||||
|
|
||||||
|
这个函数存在的原因是 `max_num_sentences=1` 会静默丢内容——
|
||||||
|
不自己切,就会"念到一半没了"。
|
||||||
|
"""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
|
||||||
|
parts = re.split(rf"(?<=[{_SENT_END}])", text)
|
||||||
|
out: list[str] = []
|
||||||
|
for p in parts:
|
||||||
|
p = p.strip()
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
if len(p) <= max_chars:
|
||||||
|
out.append(p)
|
||||||
|
continue
|
||||||
|
# 过长:按从句标点再切
|
||||||
|
subs = re.split(rf"(?<=[{_CLAUSE}])", p)
|
||||||
|
buf = ""
|
||||||
|
for s in subs:
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
if len(buf) + len(s) <= max_chars:
|
||||||
|
buf += s
|
||||||
|
else:
|
||||||
|
if buf:
|
||||||
|
out.append(buf)
|
||||||
|
# 单从句仍然过长,硬切
|
||||||
|
while len(s) > max_chars:
|
||||||
|
out.append(s[:max_chars])
|
||||||
|
s = s[max_chars:]
|
||||||
|
buf = s
|
||||||
|
if buf:
|
||||||
|
out.append(buf)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class LocalTTS:
|
||||||
|
def __init__(self, cfg: VoiceConfig):
|
||||||
|
self.cfg = cfg
|
||||||
|
d = Path(cfg.model_dir) / "tts"
|
||||||
|
root = Path(cfg.model_dir)
|
||||||
|
files = {
|
||||||
|
"model": d / "model.onnx",
|
||||||
|
"lexicon": d / "lexicon.txt",
|
||||||
|
"tokens": d / "tokens.txt",
|
||||||
|
}
|
||||||
|
missing = [k for k, p in files.items() if not p.is_file()]
|
||||||
|
if missing:
|
||||||
|
raise TTSError(
|
||||||
|
f"TTS 模型文件缺失:{missing}(目录 {d})。"
|
||||||
|
f"先跑 p0/stt_tts_bench/download_models.sh")
|
||||||
|
|
||||||
|
rule_fsts = ",".join(
|
||||||
|
str(d / f) for f in ("date.fst", "number.fst", "phone.fst", "new_heteronym.fst")
|
||||||
|
if (d / f).is_file())
|
||||||
|
|
||||||
|
self._tts = sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||||
|
model=sherpa_onnx.OfflineTtsModelConfig(
|
||||||
|
vits=sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||||
|
model=str(files["model"]),
|
||||||
|
lexicon=str(files["lexicon"]),
|
||||||
|
tokens=str(files["tokens"]),
|
||||||
|
dict_dir=str(d / "dict"),
|
||||||
|
),
|
||||||
|
num_threads=cfg.tts_threads,
|
||||||
|
provider="cpu",
|
||||||
|
),
|
||||||
|
rule_fsts=rule_fsts,
|
||||||
|
# 逐句生成:这是唯一能提前出声的手段(见模块文档)
|
||||||
|
max_num_sentences=1,
|
||||||
|
))
|
||||||
|
self.sample_rate = self._tts.sample_rate
|
||||||
|
log.info("TTS 就绪:%d 线程,输出 %d Hz", cfg.tts_threads, self.sample_rate)
|
||||||
|
|
||||||
|
def synthesize(self, text: str) -> np.ndarray:
|
||||||
|
"""合成一段文本(内部已确保是单句)。"""
|
||||||
|
if not text.strip():
|
||||||
|
return np.zeros(0, dtype=np.float32)
|
||||||
|
audio = self._tts.generate(text, sid=0, speed=1.0)
|
||||||
|
return np.asarray(audio.samples, dtype=np.float32)
|
||||||
|
|
||||||
|
def synthesize_sentences(self, text: str) -> Iterator[tuple[str, np.ndarray]]:
|
||||||
|
"""按句合成,逐句 yield (句子, 音频)。
|
||||||
|
|
||||||
|
调用方拿到第一句就能开播,不必等整段——
|
||||||
|
这正是 VITS 非自回归下唯一的低延迟路径。
|
||||||
|
"""
|
||||||
|
for seg in split_sentences(text):
|
||||||
|
yield seg, self.synthesize(seg)
|
||||||
|
|
||||||
|
def synthesize_all(self, text: str) -> np.ndarray:
|
||||||
|
"""合成整段并拼接。用于离线/测试,不适合实时播放。"""
|
||||||
|
chunks = [a for _, a in self.synthesize_sentences(text) if len(a)]
|
||||||
|
if not chunks:
|
||||||
|
return np.zeros(0, dtype=np.float32)
|
||||||
|
return np.concatenate(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["LocalTTS", "TTSError", "split_sentences", "MAX_CHARS_PER_SEGMENT"]
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// ESP32-S3 + ILI9341 240x320 display test.
|
||||||
|
// Install Adafruit ILI9341, Adafruit GFX Library and their dependencies.
|
||||||
|
// Display: SCK=12, SDI/MOSI=11, CS=10, DC=9, RESET=8, GND=GND.
|
||||||
|
// Touch: T_CLK=12, T_DIN=11, T_DO=13, T_CS=7, T_IRQ=6.
|
||||||
|
// Install XPT2046_Touchscreen by Paul Stoffregen as well.
|
||||||
|
// Display SDO and SD card are not connected. Backlight is externally powered.
|
||||||
|
#include <SPI.h>
|
||||||
|
#include <Adafruit_GFX.h>
|
||||||
|
#include <Adafruit_ILI9341.h>
|
||||||
|
#include <XPT2046_Touchscreen.h>
|
||||||
|
|
||||||
|
constexpr int TFT_SCK = 12;
|
||||||
|
constexpr int TFT_MOSI = 11;
|
||||||
|
constexpr int TFT_CS = 10;
|
||||||
|
constexpr int TFT_DC = 9;
|
||||||
|
constexpr int TFT_RST = 8;
|
||||||
|
constexpr int TOUCH_MISO = 13;
|
||||||
|
constexpr int TOUCH_CS = 7;
|
||||||
|
constexpr int TOUCH_IRQ = 6;
|
||||||
|
|
||||||
|
Adafruit_ILI9341 tft(&SPI, TFT_DC, TFT_CS, TFT_RST);
|
||||||
|
// Poll first to test SPI independently of the IRQ wire.
|
||||||
|
XPT2046_Touchscreen touch(TOUCH_CS);
|
||||||
|
bool wasPressed = false;
|
||||||
|
bool alternateColor = false;
|
||||||
|
uint32_t touchCount = 0;
|
||||||
|
uint32_t lastReport = 0;
|
||||||
|
|
||||||
|
void drawFace(uint16_t color);
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(300);
|
||||||
|
pinMode(TFT_CS, OUTPUT);
|
||||||
|
digitalWrite(TFT_CS, HIGH);
|
||||||
|
pinMode(TOUCH_CS, OUTPUT);
|
||||||
|
digitalWrite(TOUCH_CS, HIGH);
|
||||||
|
pinMode(TOUCH_IRQ, INPUT_PULLUP);
|
||||||
|
SPI.begin(TFT_SCK, TOUCH_MISO, TFT_MOSI, TFT_CS);
|
||||||
|
tft.begin(10000000); // Start conservatively at 10 MHz for jumper wires.
|
||||||
|
tft.setRotation(1); // Landscape: 320 x 240. Use 3 if upside down.
|
||||||
|
touch.begin(SPI);
|
||||||
|
touch.setRotation(1);
|
||||||
|
|
||||||
|
const uint16_t colors[] = {ILI9341_RED, ILI9341_GREEN, ILI9341_BLUE};
|
||||||
|
for (uint16_t color : colors) {
|
||||||
|
tft.fillScreen(color);
|
||||||
|
delay(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
tft.fillScreen(ILI9341_NAVY);
|
||||||
|
drawFace(ILI9341_YELLOW);
|
||||||
|
Serial.println("Touch test ready. Raw coordinates are not calibrated pixels.");
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawFace(uint16_t color) {
|
||||||
|
const int cx = tft.width() / 2;
|
||||||
|
const int cy = 105;
|
||||||
|
tft.fillCircle(cx, cy, 82, color);
|
||||||
|
tft.fillCircle(cx - 28, cy - 22, 10, ILI9341_BLACK);
|
||||||
|
tft.fillCircle(cx + 28, cy - 22, 10, ILI9341_BLACK);
|
||||||
|
|
||||||
|
// Parabolic smile: the center is lower than the corners.
|
||||||
|
for (int x = -42; x <= 42; ++x) {
|
||||||
|
int y = cy + 43 - (x * x) / 65;
|
||||||
|
tft.fillCircle(cx + x, y, 3, ILI9341_BLACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
tft.fillRect(0, 199, tft.width(), 41, ILI9341_NAVY);
|
||||||
|
tft.setTextColor(ILI9341_WHITE, ILI9341_NAVY);
|
||||||
|
tft.setTextSize(2);
|
||||||
|
tft.setCursor(16, 207);
|
||||||
|
tft.print("Touches: ");
|
||||||
|
tft.print(touchCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
bool pressed = touch.touched();
|
||||||
|
if (pressed) {
|
||||||
|
TS_Point point = touch.getPoint();
|
||||||
|
if (!wasPressed) {
|
||||||
|
++touchCount;
|
||||||
|
alternateColor = !alternateColor;
|
||||||
|
drawFace(alternateColor ? ILI9341_CYAN : ILI9341_YELLOW);
|
||||||
|
}
|
||||||
|
if (millis() - lastReport >= 150) {
|
||||||
|
lastReport = millis();
|
||||||
|
Serial.printf("raw x=%d y=%d pressure=%d IRQ=%d\n",
|
||||||
|
point.x, point.y, point.z, digitalRead(TOUCH_IRQ));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wasPressed = pressed;
|
||||||
|
delay(20);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// ESP32-S3 N16R8 + ILI9341 + XPT2046-compatible touch.
|
||||||
|
// Libraries: Adafruit ILI9341, Adafruit GFX Library, XPT2046_Touchscreen.
|
||||||
|
// LCD: SCK=12 MOSI=11 CS=10 DC=9 RESET=8. LCD SDO disconnected.
|
||||||
|
// Touch: T_CLK=12 T_DIN=11 T_DO=13 T_CS=7 T_IRQ=6 (polled).
|
||||||
|
// SD disconnected. Keep the existing working power/backlight connections.
|
||||||
|
// No startup calibration. Adjust touch mapping constants below if needed.
|
||||||
|
#include <SPI.h>
|
||||||
|
#include <Adafruit_GFX.h>
|
||||||
|
#include <Adafruit_ILI9341.h>
|
||||||
|
#include <XPT2046_Touchscreen.h>
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
Adafruit_ILI9341 tft(&SPI, 9, 10, 8);
|
||||||
|
XPT2046_Touchscreen touch(7);
|
||||||
|
// Small off-screen canvas avoids visible erase/redraw flashes.
|
||||||
|
GFXcanvas16 eyes(280, 112);
|
||||||
|
// Approximate raw range after touch.setRotation(1), not calibrated pixels.
|
||||||
|
constexpr int TOUCH_MIN_X = 200, TOUCH_MAX_X = 3900;
|
||||||
|
constexpr int TOUCH_MIN_Y = 200, TOUCH_MAX_Y = 3900;
|
||||||
|
constexpr bool TOUCH_SWAP_XY = false;
|
||||||
|
constexpr bool TOUCH_FLIP_X = true, TOUCH_FLIP_Y = true;
|
||||||
|
constexpr uint32_t IDLE_AFTER_MS = 15000;
|
||||||
|
uint32_t idleBlinkAt = 0, idleLookAt = 0;
|
||||||
|
bool idle = false, idleLookingAway = false;
|
||||||
|
float gazeX = 0, gazeY = 0, targetX = 0, targetY = 0;
|
||||||
|
uint32_t lastTouch = 0, blinkStart = 0, lastFrame = 0, lastDown = 0;
|
||||||
|
bool looking = false, blinking = false, contact = false;
|
||||||
|
int previousX = 999, previousY = 999, previousOpen = -1;
|
||||||
|
|
||||||
|
void renderEyes(int dx, int dy, int opening) {
|
||||||
|
eyes.fillScreen(ILI9341_BLACK);
|
||||||
|
for (int i = 0; i < 2; ++i) {
|
||||||
|
int x = i == 0 ? 72 : 208;
|
||||||
|
eyes.fillRoundRect(x-49, 12, 98, 88, 30, ILI9341_WHITE);
|
||||||
|
eyes.fillCircle(x+dx, 56+dy, 25, ILI9341_CYAN);
|
||||||
|
eyes.fillCircle(x+dx, 56+dy, 14, ILI9341_BLACK);
|
||||||
|
eyes.fillCircle(x+dx-7, 49+dy, 5, ILI9341_WHITE);
|
||||||
|
int cover = (100-opening)*44/100;
|
||||||
|
if (cover > 0) {
|
||||||
|
eyes.fillRect(x-50, 12, 100, cover, ILI9341_BLACK);
|
||||||
|
eyes.fillRect(x-50, 100-cover, 100, cover, ILI9341_BLACK);
|
||||||
|
}
|
||||||
|
if (opening < 7) eyes.fillRoundRect(x-39, 54, 78, 4, 2, ILI9341_CYAN);
|
||||||
|
}
|
||||||
|
tft.drawRGBBitmap(20, 54, eyes.getBuffer(), 280, 112);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
pinMode(10, OUTPUT); digitalWrite(10, HIGH);
|
||||||
|
pinMode(7, OUTPUT); digitalWrite(7, HIGH);
|
||||||
|
pinMode(6, INPUT_PULLUP);
|
||||||
|
SPI.begin(12, 13, 11, 10);
|
||||||
|
tft.begin(10000000);
|
||||||
|
tft.setRotation(1);
|
||||||
|
touch.begin(SPI);
|
||||||
|
touch.setRotation(1);
|
||||||
|
if (!eyes.getBuffer()) {
|
||||||
|
tft.fillScreen(ILI9341_RED);
|
||||||
|
Serial.println("Eye canvas allocation failed.");
|
||||||
|
while (true) delay(1000);
|
||||||
|
}
|
||||||
|
randomSeed(micros());
|
||||||
|
tft.fillScreen(ILI9341_BLACK);
|
||||||
|
tft.setTextColor(ILI9341_CYAN);
|
||||||
|
tft.setTextSize(2);
|
||||||
|
tft.setCursor(46, 207);
|
||||||
|
tft.print("Touch to look!");
|
||||||
|
lastFrame = lastTouch = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
uint32_t now = millis();
|
||||||
|
if (touch.touched()) {
|
||||||
|
TS_Point p = touch.getPoint();
|
||||||
|
float rawX = TOUCH_SWAP_XY ? p.y : p.x;
|
||||||
|
float rawY = TOUCH_SWAP_XY ? p.x : p.y;
|
||||||
|
float px = constrain((rawX-TOUCH_MIN_X)*319.0f/(TOUCH_MAX_X-TOUCH_MIN_X), 0.0f, 319.0f);
|
||||||
|
if (TOUCH_FLIP_X) px = 319.0f-px;
|
||||||
|
float py = constrain((rawY-TOUCH_MIN_Y)*239.0f/(TOUCH_MAX_Y-TOUCH_MIN_Y), 0.0f, 239.0f);
|
||||||
|
if (TOUCH_FLIP_Y) py = 239.0f-py;
|
||||||
|
idle = false;
|
||||||
|
idleLookingAway = false;
|
||||||
|
if (!contact) { blinking = true; blinkStart = now; }
|
||||||
|
contact = true;
|
||||||
|
lastDown = lastTouch = now;
|
||||||
|
looking = true;
|
||||||
|
targetX = (px-159.5f)/159.5f*20.0f;
|
||||||
|
targetY = (py-119.5f)/119.5f*14.0f;
|
||||||
|
} else if (contact && now-lastDown > 100) {
|
||||||
|
contact = false; // Short release debounce prevents repeated blinks.
|
||||||
|
}
|
||||||
|
if (looking && now-lastTouch >= 3000) {
|
||||||
|
looking = false;
|
||||||
|
targetX = targetY = 0;
|
||||||
|
}
|
||||||
|
// "Unattended" currently means no touch, not camera-based presence detection.
|
||||||
|
if (!contact && !looking && now-lastTouch >= IDLE_AFTER_MS) {
|
||||||
|
if (!idle) {
|
||||||
|
idle = true;
|
||||||
|
idleLookingAway = false;
|
||||||
|
idleBlinkAt = now + random(400, 1600);
|
||||||
|
idleLookAt = now + random(900, 2200);
|
||||||
|
}
|
||||||
|
if (!blinking && int32_t(now-idleBlinkAt) >= 0) {
|
||||||
|
blinking = true;
|
||||||
|
blinkStart = now;
|
||||||
|
idleBlinkAt = now + random(2500, 6500);
|
||||||
|
}
|
||||||
|
if (int32_t(now-idleLookAt) >= 0) {
|
||||||
|
idleLookingAway = !idleLookingAway;
|
||||||
|
if (idleLookingAway) {
|
||||||
|
targetX = random(0, 2) ? random(9, 21) : -random(9, 21);
|
||||||
|
targetY = random(-12, 13);
|
||||||
|
idleLookAt = now + random(800, 2000);
|
||||||
|
} else {
|
||||||
|
targetX = targetY = 0;
|
||||||
|
idleLookAt = now + random(1800, 4500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (now-lastFrame < 33) { delay(2); return; }
|
||||||
|
float dt = (now-lastFrame)/1000.0f;
|
||||||
|
lastFrame = now;
|
||||||
|
float blend = 1.0f-expf(-12.0f*dt);
|
||||||
|
gazeX += (targetX-gazeX)*blend;
|
||||||
|
gazeY += (targetY-gazeY)*blend;
|
||||||
|
int opening = 100;
|
||||||
|
if (blinking) {
|
||||||
|
uint32_t elapsed = now-blinkStart;
|
||||||
|
if (elapsed < 100) opening = 100-int(elapsed);
|
||||||
|
else if (elapsed < 170) opening = 0;
|
||||||
|
else if (elapsed < 330) opening = int((elapsed-170)*100/160);
|
||||||
|
else blinking = false;
|
||||||
|
}
|
||||||
|
int dx = lroundf(gazeX), dy = lroundf(gazeY);
|
||||||
|
if (dx != previousX || dy != previousY || opening != previousOpen) {
|
||||||
|
renderEyes(dx, dy, opening);
|
||||||
|
previousX = dx; previousY = dy; previousOpen = opening;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""P0 基准 · STT 与 TTS 并发。
|
||||||
|
|
||||||
|
为什么必须测:
|
||||||
|
|
||||||
|
真实系统里助手说话时麦克风并没有关——要检测打断,STT 必须同时在跑。
|
||||||
|
这台机器只有 4 个核,且 bench_threads.py 已经证明 STT 超过 2 线程就会急剧退化。
|
||||||
|
那么"STT(2线程) + TTS(2线程)"极可能互相踩踏。
|
||||||
|
|
||||||
|
本脚本把两种负载跑在一起,和各自单独跑的结果对比,量化退化幅度。
|
||||||
|
只看单独指标会得出"两个都能实时"的错误结论。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import statistics
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import sherpa_onnx
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
STT = os.path.join(HERE, "models", "stt")
|
||||||
|
WAVS = os.path.join(HERE, "wavs")
|
||||||
|
SR = 16000
|
||||||
|
CHUNK = int(SR * 0.1)
|
||||||
|
|
||||||
|
TTS_SENTENCES = [
|
||||||
|
"我查了一下,明天上午十点有个会议。",
|
||||||
|
"地点在三号会议室,需要提前十分钟到。",
|
||||||
|
"另外提醒你,后天是妈妈的生日。",
|
||||||
|
"要不要我提前帮你订一束花?",
|
||||||
|
"还有你上周说要买的那本书现在有货了。",
|
||||||
|
"价格是四十九块八,要不要下单?",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def read_wav(p):
|
||||||
|
with wave.open(p, "rb") as w:
|
||||||
|
return np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
|
||||||
|
|
||||||
|
def build_stt(nt):
|
||||||
|
return sherpa_onnx.OnlineRecognizer.from_transducer(
|
||||||
|
tokens=os.path.join(STT, "tokens.txt"),
|
||||||
|
encoder=os.path.join(STT, "encoder-epoch-99-avg-1.int8.onnx"),
|
||||||
|
decoder=os.path.join(STT, "decoder-epoch-99-avg-1.int8.onnx"),
|
||||||
|
joiner=os.path.join(STT, "joiner-epoch-99-avg-1.int8.onnx"),
|
||||||
|
num_threads=nt, sample_rate=SR, feature_dim=80,
|
||||||
|
decoding_method="greedy_search", enable_endpoint_detection=False)
|
||||||
|
|
||||||
|
|
||||||
|
def build_tts(which, nt):
|
||||||
|
if which == "matcha":
|
||||||
|
d = os.path.join(HERE, "models", "matcha")
|
||||||
|
matcha = sherpa_onnx.OfflineTtsMatchaModelConfig(
|
||||||
|
acoustic_model=os.path.join(d, "model-steps-3.onnx"),
|
||||||
|
vocoder=os.path.join(HERE, "models", "matcha_vocoder", "hifigan_v2.onnx"),
|
||||||
|
lexicon=os.path.join(d, "lexicon.txt"),
|
||||||
|
tokens=os.path.join(d, "tokens.txt"),
|
||||||
|
dict_dir=os.path.join(d, "dict"))
|
||||||
|
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||||
|
model=sherpa_onnx.OfflineTtsModelConfig(matcha=matcha, num_threads=nt, provider="cpu"),
|
||||||
|
rule_fsts=",".join(os.path.join(d, f) for f in ("date.fst", "number.fst", "phone.fst")),
|
||||||
|
max_num_sentences=1))
|
||||||
|
if which == "kokoro_int8":
|
||||||
|
d = os.path.join(HERE, "models", "kokoro_int8")
|
||||||
|
kokoro = sherpa_onnx.OfflineTtsKokoroModelConfig(
|
||||||
|
model=os.path.join(d, "model.int8.onnx"),
|
||||||
|
voices=os.path.join(d, "voices.bin"),
|
||||||
|
tokens=os.path.join(d, "tokens.txt"),
|
||||||
|
data_dir=os.path.join(d, "espeak-ng-data"),
|
||||||
|
dict_dir=os.path.join(d, "dict"),
|
||||||
|
lexicon=",".join(os.path.join(d, f) for f in
|
||||||
|
("lexicon-us-en.txt", "lexicon-gb-en.txt", "lexicon-zh.txt")))
|
||||||
|
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||||
|
model=sherpa_onnx.OfflineTtsModelConfig(kokoro=kokoro, num_threads=nt, provider="cpu"),
|
||||||
|
max_num_sentences=1))
|
||||||
|
if which == "piper":
|
||||||
|
d = os.path.join(HERE, "models", "tts_piper_xlow")
|
||||||
|
vits = sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||||
|
model=os.path.join(d, "zh_CN-huayan-x_low.onnx"),
|
||||||
|
lexicon=os.path.join(d, "lexicon.txt"),
|
||||||
|
tokens=os.path.join(d, "tokens.txt"))
|
||||||
|
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||||
|
model=sherpa_onnx.OfflineTtsModelConfig(vits=vits, num_threads=nt, provider="cpu"),
|
||||||
|
max_num_sentences=1))
|
||||||
|
d = os.path.join(HERE, "models", "tts")
|
||||||
|
vits = sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||||
|
model=os.path.join(d, "model.onnx"), lexicon=os.path.join(d, "lexicon.txt"),
|
||||||
|
tokens=os.path.join(d, "tokens.txt"), dict_dir=os.path.join(d, "dict"))
|
||||||
|
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||||
|
model=sherpa_onnx.OfflineTtsModelConfig(vits=vits, num_threads=nt, provider="cpu"),
|
||||||
|
rule_fsts=",".join(os.path.join(d, f) for f in
|
||||||
|
("date.fst", "number.fst", "phone.fst", "new_heteronym.fst")),
|
||||||
|
max_num_sentences=1))
|
||||||
|
|
||||||
|
|
||||||
|
def stt_pass(rec, samples):
|
||||||
|
"""喂一遍音频,返回 (rtf, 单块解码耗时列表)。"""
|
||||||
|
s = rec.create_stream()
|
||||||
|
durs = []
|
||||||
|
pos = 0
|
||||||
|
t_all = time.perf_counter()
|
||||||
|
while pos < len(samples):
|
||||||
|
block = samples[pos:pos + CHUNK]
|
||||||
|
pos += CHUNK
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
s.accept_waveform(SR, block)
|
||||||
|
while rec.is_ready(s):
|
||||||
|
rec.decode_stream(s)
|
||||||
|
durs.append(time.perf_counter() - t0)
|
||||||
|
total = time.perf_counter() - t_all
|
||||||
|
return total / (len(samples) / SR), durs
|
||||||
|
|
||||||
|
|
||||||
|
def tts_pass(tts, sents):
|
||||||
|
"""连续合成若干句,返回 (rtf, 每句耗时ms)。"""
|
||||||
|
per = []
|
||||||
|
audio_s = 0.0
|
||||||
|
t_all = time.perf_counter()
|
||||||
|
for s in sents:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
a = tts.generate(s, sid=0, speed=1.0)
|
||||||
|
per.append((time.perf_counter() - t0) * 1000)
|
||||||
|
audio_s += len(a.samples) / a.sample_rate
|
||||||
|
total = time.perf_counter() - t_all
|
||||||
|
return total / audio_s, per
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
stt_nt = int(os.environ.get("STT_THREADS", "2"))
|
||||||
|
tts_nt = int(os.environ.get("TTS_THREADS", "2"))
|
||||||
|
tts_kind = os.environ.get("TTS_MODEL", "piper")
|
||||||
|
wav = read_wav(os.path.join(WAVS, "stt_0.wav"))
|
||||||
|
dur = len(wav) / SR
|
||||||
|
|
||||||
|
print(f"音频 {dur:.2f}s STT线程={stt_nt} TTS={tts_kind}线程={tts_nt}\n")
|
||||||
|
|
||||||
|
stt = build_stt(stt_nt)
|
||||||
|
tts = build_tts(tts_kind, tts_nt)
|
||||||
|
stt_pass(stt, wav[: SR * 2]) # 预热
|
||||||
|
tts.generate("预热", sid=0, speed=1.0)
|
||||||
|
|
||||||
|
# 单独
|
||||||
|
solo_stt, _ = stt_pass(stt, wav)
|
||||||
|
solo_tts, _ = tts_pass(tts, TTS_SENTENCES)
|
||||||
|
print(f"单独: STT RTF={solo_stt:.2f} TTS RTF={solo_tts:.2f}")
|
||||||
|
|
||||||
|
# 并发:TTS 在后台线程持续合成,主线程跑 STT
|
||||||
|
stop = threading.Event()
|
||||||
|
tts_stats = {"runs": 0, "rtf": [], "done": False}
|
||||||
|
|
||||||
|
def tts_worker():
|
||||||
|
try:
|
||||||
|
while not stop.is_set():
|
||||||
|
rtf, _ = tts_pass(tts, TTS_SENTENCES)
|
||||||
|
tts_stats["rtf"].append(rtf)
|
||||||
|
tts_stats["runs"] += 1
|
||||||
|
if tts_stats["runs"] >= 3:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
tts_stats["done"] = True
|
||||||
|
|
||||||
|
th = threading.Thread(target=tts_worker)
|
||||||
|
th.start()
|
||||||
|
time.sleep(0.2) # 让 TTS 先跑起来,制造真实争抢
|
||||||
|
conc_rtfs, conc_durs = [], []
|
||||||
|
for _ in range(3):
|
||||||
|
r, d = stt_pass(stt, wav)
|
||||||
|
conc_rtfs.append(r)
|
||||||
|
conc_durs.extend(d)
|
||||||
|
stop.set()
|
||||||
|
th.join()
|
||||||
|
|
||||||
|
conc_tts = statistics.median(tts_stats["rtf"]) if tts_stats["rtf"] else float("nan")
|
||||||
|
stt_med = statistics.median(conc_rtfs)
|
||||||
|
ratios = sorted(x / (CHUNK / SR) for x in conc_durs)
|
||||||
|
|
||||||
|
print(f"并发: STT RTF={stt_med:.2f} (×{stt_med/solo_stt:.2f}) "
|
||||||
|
f"TTS RTF={conc_tts:.2f} (×{conc_tts/solo_tts:.2f})")
|
||||||
|
print(f" 并发下单块解码 p50={statistics.median(ratios):.2f} "
|
||||||
|
f"p95={ratios[int(len(ratios)*0.95)]:.2f} max={ratios[-1]:.2f}(>1 表示积压)")
|
||||||
|
|
||||||
|
# 判定口径:看 RTF(聚合力,<1 表示跟得上)。单块 p95 只作观察项——
|
||||||
|
# 流式模型并非每块都解码(实测约每 3 块解一次),所以单块 >1 不等于积压。
|
||||||
|
# 留 0.7 的余量,因为还要给视觉检测等其它负载留 CPU。
|
||||||
|
if stt_med < 0.7 and conc_tts < 0.7:
|
||||||
|
verdict = f"✅ 并发可实时(STT 余量 {1/stt_med:.1f}x,TTS 余量 {1/conc_tts:.1f}x)"
|
||||||
|
elif stt_med < 1 and conc_tts < 1:
|
||||||
|
verdict = "△ 并发勉强够用,视觉检测再加负载有风险"
|
||||||
|
else:
|
||||||
|
verdict = "⚠ 并发跟不上实时"
|
||||||
|
print(f"\n判定: {verdict}")
|
||||||
|
|
||||||
|
import json
|
||||||
|
out = os.path.join(HERE, f"results_concurrent_{tts_kind}.json")
|
||||||
|
with open(out, "w") as f:
|
||||||
|
json.dump({"stt_threads": stt_nt, "tts_threads": tts_nt, "tts_model": tts_kind,
|
||||||
|
"solo_stt_rtf": solo_stt, "solo_tts_rtf": solo_tts,
|
||||||
|
"conc_stt_rtf": stt_med, "conc_tts_rtf": conc_tts,
|
||||||
|
"stt_degrade": stt_med / solo_stt, "tts_degrade": conc_tts / solo_tts,
|
||||||
|
"conc_chunk_p95": ratios[int(len(ratios) * 0.95)],
|
||||||
|
"conc_chunk_max": ratios[-1]}, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f"结果已写入 {out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""P0 基准 · TTS→STT 往返测试。
|
||||||
|
|
||||||
|
作用有两个:
|
||||||
|
|
||||||
|
1. 验证 TTS 输出"可懂"——合成得再快,念出来听不懂也没用。
|
||||||
|
Piper 的 x_low 是最低音质档,且日志里有 Unknown token 警告,必须确认发出的音是正常的。
|
||||||
|
2. 给一个粗略的识别正确率信号(字错率 CER)。
|
||||||
|
|
||||||
|
重要局限:这是合成语音,比真人近距离录音干净得多,**会明显高估真实识别率**。
|
||||||
|
真实的远场识别率必须等麦克风阵列到货后用真人录音测。这里的结果只能当"下限的信号",
|
||||||
|
不能当验收数据。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import wave
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import sherpa_onnx
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
STT = os.path.join(HERE, "models", "stt")
|
||||||
|
|
||||||
|
CASES = [
|
||||||
|
"好的我记住了",
|
||||||
|
"今天北京晴转多云气温十八到二十六度",
|
||||||
|
"明天上午十点有个会议地点在三号会议室",
|
||||||
|
"订单编号是七三九一金额一千二百五十元",
|
||||||
|
"会议定在二零二六年九月十四日下午三点半",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_tts(which):
|
||||||
|
if which == "piper":
|
||||||
|
d = os.path.join(HERE, "models", "tts_piper_xlow")
|
||||||
|
vits = sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||||
|
model=os.path.join(d, "zh_CN-huayan-x_low.onnx"),
|
||||||
|
lexicon=os.path.join(d, "lexicon.txt"),
|
||||||
|
tokens=os.path.join(d, "tokens.txt"))
|
||||||
|
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||||
|
model=sherpa_onnx.OfflineTtsModelConfig(vits=vits, num_threads=2, provider="cpu"),
|
||||||
|
max_num_sentences=1))
|
||||||
|
d = os.path.join(HERE, "models", "tts")
|
||||||
|
vits = sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||||
|
model=os.path.join(d, "model.onnx"), lexicon=os.path.join(d, "lexicon.txt"),
|
||||||
|
tokens=os.path.join(d, "tokens.txt"), dict_dir=os.path.join(d, "dict"))
|
||||||
|
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||||
|
model=sherpa_onnx.OfflineTtsModelConfig(vits=vits, num_threads=2, provider="cpu"),
|
||||||
|
rule_fsts=",".join(os.path.join(d, f) for f in
|
||||||
|
("date.fst", "number.fst", "phone.fst", "new_heteronym.fst")),
|
||||||
|
max_num_sentences=1))
|
||||||
|
|
||||||
|
|
||||||
|
def load_stt():
|
||||||
|
return sherpa_onnx.OnlineRecognizer.from_transducer(
|
||||||
|
tokens=os.path.join(STT, "tokens.txt"),
|
||||||
|
encoder=os.path.join(STT, "encoder-epoch-99-avg-1.int8.onnx"),
|
||||||
|
decoder=os.path.join(STT, "decoder-epoch-99-avg-1.int8.onnx"),
|
||||||
|
joiner=os.path.join(STT, "joiner-epoch-99-avg-1.int8.onnx"),
|
||||||
|
num_threads=2, sample_rate=16000, feature_dim=80,
|
||||||
|
decoding_method="greedy_search", enable_endpoint_detection=False)
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe(rec, samples):
|
||||||
|
s = rec.create_stream()
|
||||||
|
chunk = 1600
|
||||||
|
pos = 0
|
||||||
|
while pos < len(samples):
|
||||||
|
s.accept_waveform(16000, samples[pos:pos + chunk])
|
||||||
|
pos += chunk
|
||||||
|
while rec.is_ready(s):
|
||||||
|
rec.decode_stream(s)
|
||||||
|
s.accept_waveform(16000, np.zeros(16000, dtype=np.float32))
|
||||||
|
while rec.is_ready(s):
|
||||||
|
rec.decode_stream(s)
|
||||||
|
s.input_finished()
|
||||||
|
while rec.is_ready(s):
|
||||||
|
rec.decode_stream(s)
|
||||||
|
return rec.get_result(s)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(t):
|
||||||
|
"""只保留中文字符做比较,去掉空格与标点。"""
|
||||||
|
return re.sub(r"[^一-鿿]", "", t)
|
||||||
|
|
||||||
|
|
||||||
|
def cer(ref, hyp):
|
||||||
|
"""字错率:编辑距离 / 参考长度。"""
|
||||||
|
r, h = normalize(ref), normalize(hyp)
|
||||||
|
if not r:
|
||||||
|
return float("nan")
|
||||||
|
d = np.zeros((len(r) + 1, len(h) + 1), dtype=np.int32)
|
||||||
|
d[:, 0] = np.arange(len(r) + 1)
|
||||||
|
d[0, :] = np.arange(len(h) + 1)
|
||||||
|
for i in range(1, len(r) + 1):
|
||||||
|
for j in range(1, len(h) + 1):
|
||||||
|
d[i, j] = min(d[i - 1, j] + 1, d[i, j - 1] + 1,
|
||||||
|
d[i - 1, j - 1] + (r[i - 1] != h[j - 1]))
|
||||||
|
return d[len(r), len(h)] / len(r)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
which = os.environ.get("TTS_MODEL", "piper")
|
||||||
|
tts = load_tts(which)
|
||||||
|
rec = load_stt()
|
||||||
|
print(f"TTS={which} → STT=zipformer-bilingual-zh-en(int8)\n")
|
||||||
|
|
||||||
|
cers = []
|
||||||
|
saved = 0
|
||||||
|
outdir = os.path.join(HERE, "roundtrip_wav")
|
||||||
|
os.makedirs(outdir, exist_ok=True)
|
||||||
|
for i, text in enumerate(CASES):
|
||||||
|
audio = tts.generate(text, sid=0, speed=1.0)
|
||||||
|
samples = np.array(audio.samples, dtype=np.float32)
|
||||||
|
hyp = transcribe(rec, samples)
|
||||||
|
c = cer(text, hyp)
|
||||||
|
cers.append(c)
|
||||||
|
mark = "✅" if c == 0 else "△" if c < 0.2 else "❌"
|
||||||
|
print(f"{mark} 原文: {text}")
|
||||||
|
print(f" 识别: {hyp} CER={c:.2f}")
|
||||||
|
# 存一份,便于人工听一下 TTS 到底念得对不对
|
||||||
|
pcm = (np.clip(samples, -1, 1) * 32767).astype(np.int16)
|
||||||
|
with wave.open(os.path.join(outdir, f"{i}_{which}.wav"), "wb") as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(audio.sample_rate)
|
||||||
|
w.writeframes(pcm.tobytes())
|
||||||
|
saved += 1
|
||||||
|
|
||||||
|
good = sum(1 for c in cers if c == 0)
|
||||||
|
print(f"\n完全正确 {good}/{len(CASES)},平均 CER={np.mean(cers):.3f}")
|
||||||
|
print(f"合成音频已存到 {outdir}/(可人工试听确认音质)")
|
||||||
|
print("\n⚠ 合成语音远比真人远场录音干净,此结果不能作为识别率验收数据。")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""P0 基准 · 本地流式 STT(sherpa-onnx 中英双语 Zipformer)。
|
||||||
|
|
||||||
|
测什么、为什么:
|
||||||
|
|
||||||
|
流式识别有两种"慢",必须分开测,混在一起会得出错误结论。
|
||||||
|
|
||||||
|
1. 算法延迟(first_partial_audio_s):要喂进多少秒音频,才吐出第一个字。
|
||||||
|
这是模型属性(chunk 大小 + lookahead),换机器不变。它决定了"用户说完一个字到屏幕上出现字"的下限。
|
||||||
|
|
||||||
|
2. 算力延迟(rtf / chunk_ratio):解码耗时与音频时长的比。
|
||||||
|
RTF<1 表示平均能跟上实时;但平均值会骗人——只要 p95 的单块解码时间超过块的时长,
|
||||||
|
实时场景下就会累积积压,越说越滞后。所以必须同时看 chunk_ratio 的分位数。
|
||||||
|
|
||||||
|
3. 收尾延迟(finalize_ms):音频喂完后多久能拿到最终结果。
|
||||||
|
这一段直接计入方案 P1"结束到首段有声回复"的 2 秒预算。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 bench_stt.py # 默认线程 1,2,4
|
||||||
|
THREADS=2 python3 bench_stt.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import sherpa_onnx
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
STT = os.path.join(HERE, "models", "stt")
|
||||||
|
WAVS = os.path.join(HERE, "wavs")
|
||||||
|
SAMPLE_RATE = 16000
|
||||||
|
CHUNK_MS = int(os.environ.get("CHUNK_MS", "100"))
|
||||||
|
TAIL_SILENCE_S = 1.0 # 喂一段静音,让模型吐出最后一个字(真实系统里靠 VAD 判定说完)
|
||||||
|
|
||||||
|
|
||||||
|
def read_wav(path):
|
||||||
|
with wave.open(path, "rb") as w:
|
||||||
|
assert w.getnchannels() == 1, f"{path} 不是单声道"
|
||||||
|
assert w.getframerate() == SAMPLE_RATE, f"{path} 采样率 {w.getframerate()} != {SAMPLE_RATE}"
|
||||||
|
pcm = w.readframes(w.getnframes())
|
||||||
|
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
|
||||||
|
|
||||||
|
def build_recognizer(num_threads):
|
||||||
|
return sherpa_onnx.OnlineRecognizer.from_transducer(
|
||||||
|
tokens=os.path.join(STT, "tokens.txt"),
|
||||||
|
encoder=os.path.join(STT, "encoder-epoch-99-avg-1.int8.onnx"),
|
||||||
|
decoder=os.path.join(STT, "decoder-epoch-99-avg-1.int8.onnx"),
|
||||||
|
joiner=os.path.join(STT, "joiner-epoch-99-avg-1.int8.onnx"),
|
||||||
|
num_threads=num_threads,
|
||||||
|
sample_rate=SAMPLE_RATE,
|
||||||
|
feature_dim=80,
|
||||||
|
decoding_method="greedy_search",
|
||||||
|
enable_endpoint_detection=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_one(rec, samples):
|
||||||
|
"""喂一遍音频,返回各项指标。"""
|
||||||
|
chunk = int(SAMPLE_RATE * CHUNK_MS / 1000)
|
||||||
|
stream = rec.create_stream()
|
||||||
|
decode_times = []
|
||||||
|
first_partial_audio_s = None
|
||||||
|
partials = []
|
||||||
|
|
||||||
|
t_all = time.perf_counter()
|
||||||
|
pos = 0
|
||||||
|
while pos < len(samples):
|
||||||
|
block = samples[pos:pos + chunk]
|
||||||
|
pos += len(block)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
stream.accept_waveform(SAMPLE_RATE, block)
|
||||||
|
while rec.is_ready(stream):
|
||||||
|
rec.decode_stream(stream)
|
||||||
|
decode_times.append(time.perf_counter() - t0)
|
||||||
|
txt = rec.get_result(stream)
|
||||||
|
if txt:
|
||||||
|
partials.append((pos / SAMPLE_RATE, txt))
|
||||||
|
if first_partial_audio_s is None:
|
||||||
|
first_partial_audio_s = pos / SAMPLE_RATE
|
||||||
|
|
||||||
|
audio_s = len(samples) / SAMPLE_RATE
|
||||||
|
|
||||||
|
# 收尾拆成两段测,混在一起会高估"说完到出结果"的开销:
|
||||||
|
# tail_decode_ms —— 解码尾静音的耗时。真实系统里这段对应 VAD 判定说完的等待,
|
||||||
|
# 是"必有开销",但与识别器算力无关,不能算进识别延迟。
|
||||||
|
# flush_ms —— input_finished() 之后冲刷出最终结果的耗时。这才是纯识别器开销。
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
tail = np.zeros(int(SAMPLE_RATE * TAIL_SILENCE_S), dtype=np.float32)
|
||||||
|
pos = 0
|
||||||
|
while pos < len(tail):
|
||||||
|
block = tail[pos:pos + chunk]
|
||||||
|
pos += len(block)
|
||||||
|
stream.accept_waveform(SAMPLE_RATE, block)
|
||||||
|
while rec.is_ready(stream):
|
||||||
|
rec.decode_stream(stream)
|
||||||
|
tail_decode_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
|
||||||
|
t1 = time.perf_counter()
|
||||||
|
stream.input_finished()
|
||||||
|
while rec.is_ready(stream):
|
||||||
|
rec.decode_stream(stream)
|
||||||
|
flush_ms = (time.perf_counter() - t1) * 1000
|
||||||
|
final_text = rec.get_result(stream)
|
||||||
|
total_s = time.perf_counter() - t_all
|
||||||
|
|
||||||
|
ratios = [d / (CHUNK_MS / 1000) for d in decode_times]
|
||||||
|
ratios_sorted = sorted(ratios)
|
||||||
|
|
||||||
|
def pct(xs, p):
|
||||||
|
if not xs:
|
||||||
|
return float("nan")
|
||||||
|
return xs[min(len(xs) - 1, int(len(xs) * p))]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"audio_s": audio_s,
|
||||||
|
"total_s": total_s,
|
||||||
|
"rtf": total_s / (audio_s + TAIL_SILENCE_S),
|
||||||
|
"rtf_speech_only": total_s / audio_s,
|
||||||
|
"chunk_ratio_p50": statistics.median(ratios) if ratios else float("nan"),
|
||||||
|
"chunk_ratio_p95": pct(ratios_sorted, 0.95),
|
||||||
|
"chunk_ratio_max": max(ratios) if ratios else float("nan"),
|
||||||
|
"first_partial_audio_s": first_partial_audio_s,
|
||||||
|
"tail_decode_ms": tail_decode_ms,
|
||||||
|
"flush_ms": flush_ms,
|
||||||
|
"final_text": final_text,
|
||||||
|
"n_partials": len(partials),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
threads_list = [int(x) for x in os.environ.get("THREADS", "1,2,4").split(",")]
|
||||||
|
wav_files = sorted(f for f in os.listdir(WAVS) if f.endswith(".wav"))
|
||||||
|
if not wav_files:
|
||||||
|
sys.exit(f"没有测试音频:{WAVS}")
|
||||||
|
|
||||||
|
print(f"chunk={CHUNK_MS}ms 尾静音={TAIL_SILENCE_S}s 音频={wav_files}")
|
||||||
|
print(f"CPU: 4 核 N5095(无 AVX2)\n")
|
||||||
|
|
||||||
|
all_results = {}
|
||||||
|
for nt in threads_list:
|
||||||
|
print(f"=== num_threads={nt} ===")
|
||||||
|
rec = build_recognizer(nt)
|
||||||
|
# 预热:首次解码包含图优化/内存分配,不计入
|
||||||
|
warm = np.zeros(SAMPLE_RATE, dtype=np.float32)
|
||||||
|
s = rec.create_stream()
|
||||||
|
s.accept_waveform(SAMPLE_RATE, warm)
|
||||||
|
while rec.is_ready(s):
|
||||||
|
rec.decode_stream(s)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for wf in wav_files:
|
||||||
|
samples = read_wav(os.path.join(WAVS, wf))
|
||||||
|
r = run_one(rec, samples)
|
||||||
|
rows.append((wf, r))
|
||||||
|
print(f" {wf:<9} 音频{r['audio_s']:5.2f}s RTF={r['rtf']:5.2f} "
|
||||||
|
f"块比 p50={r['chunk_ratio_p50']:.2f} p95={r['chunk_ratio_p95']:.2f} "
|
||||||
|
f"max={r['chunk_ratio_max']:.2f} 首字@{r['first_partial_audio_s'] or float('nan'):.2f}s "
|
||||||
|
f"冲刷={r['flush_ms']:.0f}ms")
|
||||||
|
print(f" 识别: {r['final_text']}")
|
||||||
|
|
||||||
|
rtfs = [r["rtf"] for _, r in rows]
|
||||||
|
p95s = [r["chunk_ratio_p95"] for _, r in rows]
|
||||||
|
fin = [r["flush_ms"] for _, r in rows]
|
||||||
|
print(f" → RTF 均值={statistics.mean(rtfs):.2f} 最差={max(rtfs):.2f} | "
|
||||||
|
f"块比p95 最差={max(p95s):.2f} | 纯冲刷 P50={statistics.median(fin):.0f}ms "
|
||||||
|
f"最差={max(fin):.0f}ms")
|
||||||
|
verdict = ("可实时" if max(rtfs) < 1 and max(p95s) < 1
|
||||||
|
else "⚠ 跟不上实时" if max(rtfs) >= 1
|
||||||
|
else "⚠ 平均够但存在突发积压风险")
|
||||||
|
print(f" → 判定: {verdict}\n")
|
||||||
|
all_results[nt] = {"rows": {w: r for w, r in rows}, "verdict": verdict}
|
||||||
|
|
||||||
|
out = os.path.join(HERE, "results_stt.json")
|
||||||
|
with open(out, "w") as f:
|
||||||
|
json.dump({"chunk_ms": CHUNK_MS, "results": all_results}, f,
|
||||||
|
ensure_ascii=False, indent=2)
|
||||||
|
print(f"结果已写入 {out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""线程数扫描:查清 N5095 上 num_threads 的真实影响。
|
||||||
|
|
||||||
|
bench_stt.py 里 2 线程 RTF=0.22、4 线程 RTF=0.87,性能反而掉了 4 倍。
|
||||||
|
本脚本对 1/2/3/4 线程各重复多轮,排除预热与顺序干扰,确认是否为真实现象。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import statistics
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import sherpa_onnx
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
STT = os.path.join(HERE, "models", "stt")
|
||||||
|
WAVS = os.path.join(HERE, "wavs")
|
||||||
|
SR = 16000
|
||||||
|
CHUNK = int(SR * 0.1)
|
||||||
|
REPEATS = int(os.environ.get("REPEATS", "5"))
|
||||||
|
|
||||||
|
|
||||||
|
def read_wav(p):
|
||||||
|
with wave.open(p, "rb") as w:
|
||||||
|
return np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
|
||||||
|
|
||||||
|
def build(nt):
|
||||||
|
return sherpa_onnx.OnlineRecognizer.from_transducer(
|
||||||
|
tokens=os.path.join(STT, "tokens.txt"),
|
||||||
|
encoder=os.path.join(STT, "encoder-epoch-99-avg-1.int8.onnx"),
|
||||||
|
decoder=os.path.join(STT, "decoder-epoch-99-avg-1.int8.onnx"),
|
||||||
|
joiner=os.path.join(STT, "joiner-epoch-99-avg-1.int8.onnx"),
|
||||||
|
num_threads=nt, sample_rate=SR, feature_dim=80,
|
||||||
|
decoding_method="greedy_search", enable_endpoint_detection=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_once(rec, samples):
|
||||||
|
s = rec.create_stream()
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
pos = 0
|
||||||
|
while pos < len(samples):
|
||||||
|
s.accept_waveform(SR, samples[pos:pos + CHUNK])
|
||||||
|
pos += CHUNK
|
||||||
|
while rec.is_ready(s):
|
||||||
|
rec.decode_stream(s)
|
||||||
|
return time.perf_counter() - t0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
wav = read_wav(os.path.join(WAVS, "stt_0.wav"))
|
||||||
|
dur = len(wav) / SR
|
||||||
|
print(f"音频 {dur:.2f}s,每档重复 {REPEATS} 轮(含 1 轮预热,不计入)\n")
|
||||||
|
print(f"{'线程':>4} {'RTF中位':>9} {'RTF最差':>9} {'各轮RTF':>34}")
|
||||||
|
print("-" * 62)
|
||||||
|
for nt in (1, 2, 3, 4):
|
||||||
|
rec = build(nt)
|
||||||
|
decode_once(rec, wav[: SR * 2]) # 预热
|
||||||
|
rtfs = []
|
||||||
|
for _ in range(REPEATS):
|
||||||
|
rtfs.append(decode_once(rec, wav) / dur)
|
||||||
|
shown = " ".join(f"{r:.2f}" for r in rtfs)
|
||||||
|
print(f"{nt:>4} {statistics.median(rtfs):>9.2f} {max(rtfs):>9.2f} {shown:>34}")
|
||||||
|
del rec
|
||||||
|
|
||||||
|
print("\n=== 参照:系统负载 ===")
|
||||||
|
print(open("/proc/loadavg").read().strip())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""P0 基准 · 本地 TTS(sherpa-onnx VITS 中文 zh-ll)。
|
||||||
|
|
||||||
|
测什么、为什么:
|
||||||
|
|
||||||
|
1. RTF:合成耗时 / 音频时长。<1 表示比实时快。
|
||||||
|
2. 首音延迟:从调用到吐出第一块音频的耗时。这一段直接计入方案 P1 的 2 秒预算。
|
||||||
|
3. 分句接续停顿:方案要求"按语义短句合成并播放"。逐句生成时,
|
||||||
|
如果下一句还没合成完、上一句已经播完,喇叭里就会出现空档。
|
||||||
|
这里用"生成耗时 vs 播放耗时"的竞速来预测停顿,不需要声卡。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 bench_tts.py
|
||||||
|
THREADS=1,2,4 python3 bench_tts.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import statistics
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import sherpa_onnx
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
TTS = os.path.join(HERE, "models", "tts")
|
||||||
|
RULE_FSTS = ",".join(os.path.join(TTS, f) for f in
|
||||||
|
("date.fst", "number.fst", "phone.fst", "new_heteronym.fst"))
|
||||||
|
|
||||||
|
SHORT = "好的,我记住了。"
|
||||||
|
MEDIUM = "今天北京晴转多云,气温十八到二十六度,出门记得带件外套。"
|
||||||
|
LONG = ("我查了一下,明天上午十点有个会议,地点在三号会议室。"
|
||||||
|
"另外提醒你,后天是妈妈的生日,要不要我提前帮你订一束花?"
|
||||||
|
"还有,你上周说要买的那本书,现在有货了,价格是四十九块八。")
|
||||||
|
|
||||||
|
# 方案 §5:数字、日期、中英混读都要覆盖
|
||||||
|
CASES = [
|
||||||
|
("短句", SHORT),
|
||||||
|
("中句", MEDIUM),
|
||||||
|
("长段", LONG),
|
||||||
|
("数字", "订单编号是七三九一,金额一千二百五十元六角。"),
|
||||||
|
("日期", "会议定在二零二六年九月十四日下午三点半。"),
|
||||||
|
("中英混读", "这个 API 的 response time 大概在 200 毫秒左右。"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_tts(num_threads):
|
||||||
|
cfg = sherpa_onnx.OfflineTtsConfig(
|
||||||
|
model=sherpa_onnx.OfflineTtsModelConfig(
|
||||||
|
vits=sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||||
|
model=os.path.join(TTS, "model.onnx"),
|
||||||
|
lexicon=os.path.join(TTS, "lexicon.txt"),
|
||||||
|
tokens=os.path.join(TTS, "tokens.txt"),
|
||||||
|
dict_dir=os.path.join(TTS, "dict"),
|
||||||
|
),
|
||||||
|
num_threads=num_threads,
|
||||||
|
provider="cpu",
|
||||||
|
),
|
||||||
|
rule_fsts=RULE_FSTS,
|
||||||
|
max_num_sentences=1, # 逐句生成,便于尽早开播
|
||||||
|
)
|
||||||
|
return sherpa_onnx.OfflineTts(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def synth(tts, text):
|
||||||
|
"""合成一段,返回 (首音延迟ms, 总耗时ms, 音频秒数, 音频)。"""
|
||||||
|
chunks = []
|
||||||
|
first_at = [None]
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
|
||||||
|
def cb(*args): # 兼容 (samples,) 与 (samples, progress) 两种签名
|
||||||
|
samples = args[0]
|
||||||
|
if first_at[0] is None:
|
||||||
|
first_at[0] = (time.perf_counter() - t0) * 1000
|
||||||
|
chunks.append(np.array(samples, dtype=np.float32, copy=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
audio = tts.generate(text, sid=0, speed=1.0, callback=cb)
|
||||||
|
total_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
if first_at[0] is None:
|
||||||
|
first_at[0] = total_ms
|
||||||
|
dur_s = len(audio.samples) / audio.sample_rate
|
||||||
|
return first_at[0], total_ms, dur_s, np.array(audio.samples)
|
||||||
|
|
||||||
|
|
||||||
|
def split_sentences(text):
|
||||||
|
"""按中英文标点切句,模拟"按语义短句合成"。"""
|
||||||
|
parts = re.split(r"(?<=[。!?;!?;])", text)
|
||||||
|
return [p.strip() for p in parts if p.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
threads_list = [int(x) for x in os.environ.get("THREADS", "1,2,4").split(",")]
|
||||||
|
print(f"TTS: VITS zh-ll 线程扫描={threads_list} max_num_sentences=1\n")
|
||||||
|
|
||||||
|
all_results = {}
|
||||||
|
for nt in threads_list:
|
||||||
|
print(f"=== num_threads={nt} ===")
|
||||||
|
tts = build_tts(nt)
|
||||||
|
tts.generate("预热", sid=0, speed=1.0) # 预热
|
||||||
|
|
||||||
|
rows = {}
|
||||||
|
for label, text in CASES:
|
||||||
|
first_ms, total_ms, dur_s, _ = synth(tts, text)
|
||||||
|
rtf = (total_ms / 1000) / dur_s
|
||||||
|
rows[label] = {"first_ms": first_ms, "total_ms": total_ms,
|
||||||
|
"dur_s": dur_s, "rtf": rtf, "text": text}
|
||||||
|
print(f" {label:<8} 音频{dur_s:5.2f}s 合成{total_ms:7.0f}ms "
|
||||||
|
f"RTF={rtf:5.2f} 首音={first_ms:6.0f}ms")
|
||||||
|
|
||||||
|
# 分句接续:完全串行生成,看下一句能否在上一句播完前备好
|
||||||
|
print(f" --- 分句接续(长段切 {len(split_sentences(LONG))} 句)---")
|
||||||
|
sents = split_sentences(LONG)
|
||||||
|
t_gen = 0.0 # 该句开始生成的时刻(相对)
|
||||||
|
play_end = 0.0 # 上一句播完的时刻
|
||||||
|
timeline = []
|
||||||
|
for i, s in enumerate(sents):
|
||||||
|
first_ms, total_ms, dur_s, _ = synth(tts, s)
|
||||||
|
gen_start = t_gen
|
||||||
|
first_ready = gen_start + first_ms / 1000
|
||||||
|
play_start = max(play_end, first_ready)
|
||||||
|
gap = play_start - play_end if i > 0 else 0.0
|
||||||
|
play_end = play_start + dur_s
|
||||||
|
t_gen = gen_start + total_ms / 1000 # 串行:本句合成完才开始下一句
|
||||||
|
timeline.append({"i": i, "text": s[:18], "dur_s": dur_s,
|
||||||
|
"gen_ms": total_ms, "first_ms": first_ms,
|
||||||
|
"gap_s": gap, "late": play_start - first_ready})
|
||||||
|
flag = "空档" if gap > 0.02 else "OK"
|
||||||
|
print(f" 句{i+1}: 播{dur_s:4.2f}s 合成{total_ms:6.0f}ms "
|
||||||
|
f"首音{first_ms:5.0f}ms 空档={gap:5.2f}s [{flag}] {s[:16]}")
|
||||||
|
max_gap = max(t["gap_s"] for t in timeline)
|
||||||
|
total_audio = sum(t["dur_s"] for t in timeline)
|
||||||
|
rows["_分句接续"] = {"max_gap_s": max_gap, "total_audio_s": total_audio,
|
||||||
|
"timeline": timeline,
|
||||||
|
"total_gen_ms": sum(t["gen_ms"] for t in timeline)}
|
||||||
|
verdict = "无空档" if max_gap <= 0.02 else f"⚠ 最大空档 {max_gap:.2f}s"
|
||||||
|
print(f" → 分句判定: {verdict}(总音频 {total_audio:.2f}s,"
|
||||||
|
f"总合成 {sum(t['gen_ms'] for t in timeline):.0f}ms)\n")
|
||||||
|
all_results[nt] = rows
|
||||||
|
|
||||||
|
out = os.path.join(HERE, "results_tts.json")
|
||||||
|
with open(out, "w") as f:
|
||||||
|
json.dump(all_results, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f"结果已写入 {out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""P0 基准 · 本地 TTS 模型横向对比(N5095)。
|
||||||
|
|
||||||
|
背景:vits-zh-ll 实测 RTF≈1.7,慢于实时,且 VITS 非自回归——
|
||||||
|
回调只在合成结束后触发一次,首音延迟 == 总合成耗时。
|
||||||
|
本脚本用同一组句子对比多个候选模型,找是否有能跑到 RTF<0.5 的。
|
||||||
|
|
||||||
|
注意 max_num_sentences=1 会把多句输入截断为第一句(见 bench_tts.py 的坑),
|
||||||
|
所以这里每句单独调用——这也正是真实系统的做法(按语义短句合成)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import statistics
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import sherpa_onnx
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
SHORT = "好的,我记住了。"
|
||||||
|
MED = "今天北京晴转多云,气温十八到二十六度。"
|
||||||
|
LONG = "我查了一下,明天上午十点有个会议,地点在三号会议室。"
|
||||||
|
NUM = "订单编号是七三九一,金额一千二百五十元六角。"
|
||||||
|
|
||||||
|
TESTS = [("短句", SHORT), ("中句", MED), ("长句", LONG), ("数字", NUM)]
|
||||||
|
|
||||||
|
|
||||||
|
def load_vits_zh_ll(nt):
|
||||||
|
d = os.path.join(HERE, "models", "tts")
|
||||||
|
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||||
|
model=sherpa_onnx.OfflineTtsModelConfig(
|
||||||
|
vits=sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||||
|
model=os.path.join(d, "model.onnx"),
|
||||||
|
lexicon=os.path.join(d, "lexicon.txt"),
|
||||||
|
tokens=os.path.join(d, "tokens.txt"),
|
||||||
|
dict_dir=os.path.join(d, "dict"),
|
||||||
|
),
|
||||||
|
num_threads=nt, provider="cpu"),
|
||||||
|
rule_fsts=",".join(os.path.join(d, f) for f in
|
||||||
|
("date.fst", "number.fst", "phone.fst", "new_heteronym.fst")),
|
||||||
|
max_num_sentences=1))
|
||||||
|
|
||||||
|
|
||||||
|
def load_piper_xlow(nt):
|
||||||
|
d = os.path.join(HERE, "models", "tts_piper_xlow")
|
||||||
|
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||||
|
model=sherpa_onnx.OfflineTtsModelConfig(
|
||||||
|
vits=sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||||
|
model=os.path.join(d, "zh_CN-huayan-x_low.onnx"),
|
||||||
|
lexicon=os.path.join(d, "lexicon.txt"),
|
||||||
|
tokens=os.path.join(d, "tokens.txt"),
|
||||||
|
),
|
||||||
|
num_threads=nt, provider="cpu"),
|
||||||
|
max_num_sentences=1))
|
||||||
|
|
||||||
|
|
||||||
|
MODELS = [
|
||||||
|
("vits-zh-ll", load_vits_zh_ll, "VITS 中文,115MB"),
|
||||||
|
("piper-huayan-x_low", load_piper_xlow, "Piper 中文,20MB"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def measure(tts, text):
|
||||||
|
calls = [0]
|
||||||
|
|
||||||
|
def cb(*args):
|
||||||
|
calls[0] += 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
audio = tts.generate(text, sid=0, speed=1.0, callback=cb)
|
||||||
|
total_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
dur = len(audio.samples) / audio.sample_rate
|
||||||
|
return total_ms, dur, calls[0]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
nt = int(os.environ.get("THREADS", "2"))
|
||||||
|
print(f"N5095 / num_threads={nt} / 每句单独合成\n")
|
||||||
|
|
||||||
|
report = {}
|
||||||
|
for name, loader, desc in MODELS:
|
||||||
|
try:
|
||||||
|
tts = loader(nt)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"=== {name} ({desc}) ===\n 加载失败: {type(e).__name__}: {e}\n")
|
||||||
|
continue
|
||||||
|
tts.generate("预热", sid=0, speed=1.0)
|
||||||
|
print(f"=== {name} ({desc}) ===")
|
||||||
|
rows = []
|
||||||
|
for label, text in TESTS:
|
||||||
|
total_ms, dur, ncalls = measure(tts, text)
|
||||||
|
rtf = (total_ms / 1000) / dur
|
||||||
|
rows.append((label, total_ms, dur, rtf, ncalls))
|
||||||
|
print(f" {label:<5} {len(text):>2}字 → 音频{dur:5.2f}s 合成{total_ms:7.0f}ms "
|
||||||
|
f"RTF={rtf:5.2f} 回调{ncalls}次")
|
||||||
|
rtfs = [r[3] for r in rows]
|
||||||
|
avg = statistics.mean(rtfs)
|
||||||
|
print(f" → RTF 均值={avg:.2f} 最差={max(rtfs):.2f} "
|
||||||
|
f"判定: {'✅ 可实时' if avg < 0.5 else '⚠ 慢于实时' if avg >= 1 else '△ 勉强'}\n")
|
||||||
|
report[name] = {"desc": desc, "rows": rows, "rtf_avg": avg, "rtf_max": max(rtfs)}
|
||||||
|
del tts
|
||||||
|
|
||||||
|
# 播放侧模拟:连续说 5 句时,下一句能否在上一句播完前备好
|
||||||
|
print("=== 连续播放模拟(5 句,串行合成)===")
|
||||||
|
loaders = {m[0]: m[1] for m in MODELS}
|
||||||
|
for name in loaders:
|
||||||
|
if name not in report:
|
||||||
|
continue
|
||||||
|
tts = loaders[name](nt)
|
||||||
|
sents = ["我查了一下,明天上午十点有个会议。", "地点在三号会议室。",
|
||||||
|
"另外提醒你,后天是妈妈的生日。", "要不要我提前帮你订一束花?",
|
||||||
|
"还有你上周说要买的那本书现在有货了。"]
|
||||||
|
t_gen = 0.0
|
||||||
|
play_end = 0.0
|
||||||
|
gaps = []
|
||||||
|
for i, s in enumerate(sents):
|
||||||
|
total_ms, dur, _ = measure(tts, s)
|
||||||
|
first_ready = t_gen + total_ms / 1000
|
||||||
|
play_start = max(play_end, first_ready)
|
||||||
|
gaps.append(max(0.0, play_start - play_end))
|
||||||
|
play_end = play_start + dur
|
||||||
|
t_gen += total_ms / 1000
|
||||||
|
print(f" {name:<22} 各句空档: {[f'{g:.1f}s' for g in gaps]} "
|
||||||
|
f"最大={max(gaps):.2f}s 总播完={play_end:.1f}s")
|
||||||
|
report[name]["gaps"] = gaps
|
||||||
|
del tts
|
||||||
|
print()
|
||||||
|
|
||||||
|
import json
|
||||||
|
out = os.path.join(HERE, "results_tts_compare.json")
|
||||||
|
with open(out, "w") as f:
|
||||||
|
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f"结果已写入 {out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 下载 P0 基准所需模型。走 hf-mirror.com(本机 huggingface.co 直连不通)。
|
||||||
|
# 可重复执行:已存在且大小正确的文件会跳过。
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
MIRROR="${HF_MIRROR:-https://hf-mirror.com}"
|
||||||
|
STT_REPO="csukuangfj/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"
|
||||||
|
TTS_REPO="csukuangfj/sherpa-onnx-vits-zh-ll"
|
||||||
|
|
||||||
|
STT_DIR="$ROOT/models/stt"
|
||||||
|
TTS_DIR="$ROOT/models/tts"
|
||||||
|
WAV_DIR="$ROOT/wavs"
|
||||||
|
mkdir -p "$STT_DIR" "$TTS_DIR" "$WAV_DIR"
|
||||||
|
|
||||||
|
fetch() { # fetch <repo> <remote-path> <local-path>
|
||||||
|
local repo="$1" remote="$2" local="$3"
|
||||||
|
mkdir -p "$(dirname "$local")"
|
||||||
|
local url="$MIRROR/$repo/resolve/main/$remote"
|
||||||
|
local expect
|
||||||
|
expect=$(curl -sIL "$url" | grep -i '^content-length' | tail -1 | tr -d '\r' | awk '{print $2}')
|
||||||
|
if [ -f "$local" ] && [ -n "$expect" ] && [ "$(stat -c%s "$local")" = "$expect" ]; then
|
||||||
|
printf ' skip %-45s %s\n' "$(basename "$remote")" "(已完整)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf ' get %-45s %s\n' "$(basename "$remote")" "${expect:+$((expect/1048576))MB}"
|
||||||
|
curl -sL --retry 3 --retry-delay 2 -o "$local.part" "$url" || { echo " FAILED"; return 1; }
|
||||||
|
mv "$local.part" "$local"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== STT: 流式 Zipformer 中英双语 int8 ==="
|
||||||
|
for f in tokens.txt bpe.model \
|
||||||
|
encoder-epoch-99-avg-1.int8.onnx \
|
||||||
|
decoder-epoch-99-avg-1.int8.onnx \
|
||||||
|
joiner-epoch-99-avg-1.int8.onnx; do
|
||||||
|
fetch "$STT_REPO" "$f" "$STT_DIR/$f"
|
||||||
|
done
|
||||||
|
for w in 0.wav 1.wav 2.wav 3.wav; do
|
||||||
|
fetch "$STT_REPO" "test_wavs/$w" "$WAV_DIR/stt_$w"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "=== TTS: VITS zh-ll ==="
|
||||||
|
for f in model.onnx tokens.txt lexicon.txt date.fst number.fst phone.fst new_heteronym.fst \
|
||||||
|
dict/jieba.dict.utf8 dict/hmm_model.utf8 dict/idf.utf8 \
|
||||||
|
dict/pos_dict/char_state_tab.utf8 dict/pos_dict/prob_emit.utf8 dict/pos_dict/prob_trans.utf8; do
|
||||||
|
fetch "$TTS_REPO" "$f" "$TTS_DIR/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 结果 ==="
|
||||||
|
du -sh "$STT_DIR" "$TTS_DIR" "$WAV_DIR" 2>/dev/null
|
||||||
|
echo "下载完成。"
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""按目录递归下载 HF 仓库文件到本地(走 hf-mirror)。
|
||||||
|
|
||||||
|
Piper 中文 medium 档没有 lexicon.txt,改用 espeak-ng-data 做音素化,
|
||||||
|
而那是一堆小文件,用 shell 逐个 curl 太啰嗦,所以写这个。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 hf_fetch.py <repo> <本地目录> [远端前缀]
|
||||||
|
python3 hf_fetch.py csukuangfj/vits-piper-zh_CN-huayan-medium \
|
||||||
|
models/tts_piper_medium espeak-ng-data
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
MIRROR = os.environ.get("HF_MIRROR", "https://hf-mirror.com")
|
||||||
|
UA = {"User-Agent": "curl/8.5.0"} # hf-mirror 对 urllib 默认 UA 返回 403
|
||||||
|
|
||||||
|
|
||||||
|
def _open(url, timeout):
|
||||||
|
return urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def api_tree(repo):
|
||||||
|
url = f"{MIRROR}/api/models/{repo}/tree/main?recursive=true"
|
||||||
|
with _open(url, 60) as r:
|
||||||
|
return json.load(r)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url, dest):
|
||||||
|
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||||
|
tmp = dest + ".part"
|
||||||
|
with _open(url, 300) as r, open(tmp, "wb") as f:
|
||||||
|
while True:
|
||||||
|
b = r.read(1 << 16)
|
||||||
|
if not b:
|
||||||
|
break
|
||||||
|
f.write(b)
|
||||||
|
os.replace(tmp, dest)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
repo, outdir = sys.argv[1], sys.argv[2]
|
||||||
|
prefix = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||||
|
files = [f for f in api_tree(repo) if f["type"] == "file"]
|
||||||
|
if prefix:
|
||||||
|
files = [f for f in files if f["path"].startswith(prefix)]
|
||||||
|
print(f"{repo}: 取 {len(files)} 个文件 → {outdir}")
|
||||||
|
for f in files:
|
||||||
|
rel = f["path"]
|
||||||
|
dest = os.path.join(outdir, rel)
|
||||||
|
if os.path.exists(dest) and os.path.getsize(dest) == f.get("size", -1):
|
||||||
|
continue
|
||||||
|
# espeak 的 voices/!v/ 下有带空格的文件名(如 "Mr serious"),必须转义
|
||||||
|
url = f"{MIRROR}/{repo}/resolve/main/{urllib.parse.quote(rel)}"
|
||||||
|
try:
|
||||||
|
fetch(url, dest)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 跳过 {rel}: {type(e).__name__}: {e}")
|
||||||
|
continue
|
||||||
|
print(f" {rel} ({f.get('size',0)/1024:.0f} KB)")
|
||||||
|
print("完成")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"stt_threads": 2,
|
||||||
|
"tts_threads": 2,
|
||||||
|
"tts_model": "matcha",
|
||||||
|
"solo_stt_rtf": 0.27664071878154955,
|
||||||
|
"solo_tts_rtf": 0.44042636413212993,
|
||||||
|
"conc_stt_rtf": 0.5378692789555439,
|
||||||
|
"conc_tts_rtf": 0.935733770617253,
|
||||||
|
"stt_degrade": 1.944288177548709,
|
||||||
|
"tts_degrade": 2.1246088945223285,
|
||||||
|
"conc_chunk_p95": 2.0250199799920665,
|
||||||
|
"conc_chunk_max": 2.6675718699971185
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"stt_threads": 2,
|
||||||
|
"tts_threads": 2,
|
||||||
|
"tts_model": "piper",
|
||||||
|
"solo_stt_rtf": 0.2645827264656701,
|
||||||
|
"solo_tts_rtf": 0.1899799540144868,
|
||||||
|
"conc_stt_rtf": 0.5013027566055182,
|
||||||
|
"conc_tts_rtf": 0.3256982994144358,
|
||||||
|
"stt_degrade": 1.8946919298246885,
|
||||||
|
"tts_degrade": 1.714382452106499,
|
||||||
|
"conc_chunk_p95": 2.345268959998066,
|
||||||
|
"conc_chunk_max": 2.5172376999989865
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"stt_threads": 2,
|
||||||
|
"tts_threads": 3,
|
||||||
|
"tts_model": "vits",
|
||||||
|
"solo_stt_rtf": 0.2826273522909462,
|
||||||
|
"solo_tts_rtf": 1.43806433885311,
|
||||||
|
"conc_stt_rtf": 0.6159577618900334,
|
||||||
|
"conc_tts_rtf": 1.9989867974632771,
|
||||||
|
"stt_degrade": 2.1793989750006415,
|
||||||
|
"tts_degrade": 1.3900537990237043,
|
||||||
|
"conc_chunk_p95": 2.319201170002998,
|
||||||
|
"conc_chunk_max": 3.0467084500014607
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
{
|
||||||
|
"chunk_ms": 100,
|
||||||
|
"results": {
|
||||||
|
"1": {
|
||||||
|
"rows": {
|
||||||
|
"stt_0.wav": {
|
||||||
|
"audio_s": 10.053125,
|
||||||
|
"total_s": 4.046593594999649,
|
||||||
|
"rtf": 0.36610402895105676,
|
||||||
|
"rtf_speech_only": 0.4025209668635026,
|
||||||
|
"chunk_ratio_p50": 0.003555890002644446,
|
||||||
|
"chunk_ratio_p95": 1.1945098499973028,
|
||||||
|
"chunk_ratio_max": 1.2445684299973436,
|
||||||
|
"first_partial_audio_s": 1.1,
|
||||||
|
"tail_decode_ms": 352.4101550001433,
|
||||||
|
"flush_ms": 0.06503600025098422,
|
||||||
|
"final_text": "昨天是 MONDAY TODAY IS LIBR THE DAY AFTER TOMORROW是星期三",
|
||||||
|
"n_partials": 91
|
||||||
|
},
|
||||||
|
"stt_1.wav": {
|
||||||
|
"audio_s": 5.1,
|
||||||
|
"total_s": 2.1389257700002418,
|
||||||
|
"rtf": 0.35064356885249864,
|
||||||
|
"rtf_speech_only": 0.419397209803969,
|
||||||
|
"chunk_ratio_p50": 0.003521949997775664,
|
||||||
|
"chunk_ratio_p95": 1.1893300500014448,
|
||||||
|
"chunk_ratio_max": 1.1938205499973265,
|
||||||
|
"first_partial_audio_s": 1.4,
|
||||||
|
"tail_decode_ms": 356.69890200006193,
|
||||||
|
"flush_ms": 0.02634899965414661,
|
||||||
|
"final_text": "这是第一种第二种叫呃与 ALWAYS ALWAYS什么意思啊",
|
||||||
|
"n_partials": 38
|
||||||
|
},
|
||||||
|
"stt_2.wav": {
|
||||||
|
"audio_s": 4.69,
|
||||||
|
"total_s": 2.0203764259999843,
|
||||||
|
"rtf": 0.3550749430579937,
|
||||||
|
"rtf_speech_only": 0.43078388614072155,
|
||||||
|
"chunk_ratio_p50": 0.00355069999841362,
|
||||||
|
"chunk_ratio_p95": 1.188613239996812,
|
||||||
|
"chunk_ratio_max": 1.209888459998183,
|
||||||
|
"first_partial_audio_s": 0.5,
|
||||||
|
"tail_decode_ms": 356.0279449998234,
|
||||||
|
"flush_ms": 0.02447099996061297,
|
||||||
|
"final_text": "这是不是平凡的啊不认识你记下来 FREQUENTLY频繁的",
|
||||||
|
"n_partials": 43
|
||||||
|
},
|
||||||
|
"stt_3.wav": {
|
||||||
|
"audio_s": 8.83,
|
||||||
|
"total_s": 3.5579121109999505,
|
||||||
|
"rtf": 0.36194426358086984,
|
||||||
|
"rtf_speech_only": 0.4029345539071292,
|
||||||
|
"chunk_ratio_p50": 0.0035380899998926907,
|
||||||
|
"chunk_ratio_p95": 1.1881532100005643,
|
||||||
|
"chunk_ratio_max": 1.1958122499981982,
|
||||||
|
"first_partial_audio_s": 1.1,
|
||||||
|
"tail_decode_ms": 355.65589199995884,
|
||||||
|
"flush_ms": 0.024543000108678825,
|
||||||
|
"final_text": "第一句是个什么时态在加了 ES是一般现在时对吧后面那还有时时态来语写上上去了",
|
||||||
|
"n_partials": 79
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verdict": "⚠ 平均够但存在突发积压风险"
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"rows": {
|
||||||
|
"stt_0.wav": {
|
||||||
|
"audio_s": 10.053125,
|
||||||
|
"total_s": 2.578806391999933,
|
||||||
|
"rtf": 0.23331016269154045,
|
||||||
|
"rtf_speech_only": 0.25651788792041613,
|
||||||
|
"chunk_ratio_p50": 0.003629650000220863,
|
||||||
|
"chunk_ratio_p95": 0.7905124399985652,
|
||||||
|
"chunk_ratio_max": 0.9809923500006335,
|
||||||
|
"first_partial_audio_s": 1.1,
|
||||||
|
"tail_decode_ms": 221.97086599999238,
|
||||||
|
"flush_ms": 0.05037600021751132,
|
||||||
|
"final_text": "昨天是 MONDAY TODAY IS LIBR THE DAY AFTER TOMORROW是星期三",
|
||||||
|
"n_partials": 91
|
||||||
|
},
|
||||||
|
"stt_1.wav": {
|
||||||
|
"audio_s": 5.1,
|
||||||
|
"total_s": 1.3411688280002636,
|
||||||
|
"rtf": 0.2198637422951252,
|
||||||
|
"rtf_speech_only": 0.2629742800000517,
|
||||||
|
"chunk_ratio_p50": 0.0035533499976736493,
|
||||||
|
"chunk_ratio_p95": 0.7596391700008098,
|
||||||
|
"chunk_ratio_max": 0.7848052599956645,
|
||||||
|
"first_partial_audio_s": 1.4,
|
||||||
|
"tail_decode_ms": 222.9477579999184,
|
||||||
|
"flush_ms": 0.024468999981763773,
|
||||||
|
"final_text": "这是第一种第二种叫呃与 ALWAYS ALWAYS什么意思啊",
|
||||||
|
"n_partials": 38
|
||||||
|
},
|
||||||
|
"stt_2.wav": {
|
||||||
|
"audio_s": 4.69,
|
||||||
|
"total_s": 1.2377910049999628,
|
||||||
|
"rtf": 0.2175379622144047,
|
||||||
|
"rtf_speech_only": 0.2639213230277106,
|
||||||
|
"chunk_ratio_p50": 0.003542499998729909,
|
||||||
|
"chunk_ratio_p95": 0.7229457900029956,
|
||||||
|
"chunk_ratio_max": 0.7791545099962605,
|
||||||
|
"first_partial_audio_s": 0.5,
|
||||||
|
"tail_decode_ms": 223.60373099991193,
|
||||||
|
"flush_ms": 0.024873000256775413,
|
||||||
|
"final_text": "这是不是平凡的啊不认识你记下来 FREQUENTLY频繁的",
|
||||||
|
"n_partials": 43
|
||||||
|
},
|
||||||
|
"stt_3.wav": {
|
||||||
|
"audio_s": 8.83,
|
||||||
|
"total_s": 2.189363661999778,
|
||||||
|
"rtf": 0.2227226512715949,
|
||||||
|
"rtf_speech_only": 0.24794605458661131,
|
||||||
|
"chunk_ratio_p50": 0.003571679999367916,
|
||||||
|
"chunk_ratio_p95": 0.7330924600000799,
|
||||||
|
"chunk_ratio_max": 0.8176678099971468,
|
||||||
|
"first_partial_audio_s": 1.1,
|
||||||
|
"tail_decode_ms": 213.40833099975498,
|
||||||
|
"flush_ms": 0.025572000140527962,
|
||||||
|
"final_text": "第一句是个什么时态在加了 ES是一般现在时对吧后面那还有时时态来语写上上去了",
|
||||||
|
"n_partials": 79
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verdict": "可实时"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
{
|
||||||
|
"1": {
|
||||||
|
"短句": {
|
||||||
|
"first_ms": 3512.398196000049,
|
||||||
|
"total_ms": 3512.4536819998866,
|
||||||
|
"dur_s": 0.5618125,
|
||||||
|
"rtf": 6.252003438869528,
|
||||||
|
"text": "好的,我记住了。"
|
||||||
|
},
|
||||||
|
"中句": {
|
||||||
|
"first_ms": 6728.828355000132,
|
||||||
|
"total_ms": 6728.931756000293,
|
||||||
|
"dur_s": 1.7795,
|
||||||
|
"rtf": 3.781360919359535,
|
||||||
|
"text": "今天北京晴转多云,气温十八到二十六度,出门记得带件外套。"
|
||||||
|
},
|
||||||
|
"长段": {
|
||||||
|
"first_ms": 4259.075834999749,
|
||||||
|
"total_ms": 4259.120488000008,
|
||||||
|
"dur_s": 0.942875,
|
||||||
|
"rtf": 4.517163450086181,
|
||||||
|
"text": "我查了一下,明天上午十点有个会议,地点在三号会议室。另外提醒你,后天是妈妈的生日,要不要我提前帮你订一束花?还有,你上周说要买的那本书,现在有货了,价格是四十九块八。"
|
||||||
|
},
|
||||||
|
"数字": {
|
||||||
|
"first_ms": 7015.1300140000785,
|
||||||
|
"total_ms": 7015.235308999763,
|
||||||
|
"dur_s": 1.735875,
|
||||||
|
"rtf": 4.041325158205379,
|
||||||
|
"text": "订单编号是七三九一,金额一千二百五十元六角。"
|
||||||
|
},
|
||||||
|
"日期": {
|
||||||
|
"first_ms": 10958.226222000121,
|
||||||
|
"total_ms": 10958.3969360001,
|
||||||
|
"dur_s": 3.165375,
|
||||||
|
"rtf": 3.4619585154997745,
|
||||||
|
"text": "会议定在二零二六年九月十四日下午三点半。"
|
||||||
|
},
|
||||||
|
"中英混读": {
|
||||||
|
"first_ms": 8337.178994000169,
|
||||||
|
"total_ms": 8337.248050000198,
|
||||||
|
"dur_s": 2.2680625,
|
||||||
|
"rtf": 3.6759339965279607,
|
||||||
|
"text": "这个 API 的 response time 大概在 200 毫秒左右。"
|
||||||
|
},
|
||||||
|
"_分句接续": {
|
||||||
|
"max_gap_s": 3.4393423310002254,
|
||||||
|
"total_audio_s": 2.6743125,
|
||||||
|
"timeline": [
|
||||||
|
{
|
||||||
|
"i": 0,
|
||||||
|
"text": "我查了一下,明天上午十点有个会议,地",
|
||||||
|
"dur_s": 1.04,
|
||||||
|
"gen_ms": 3355.619835000198,
|
||||||
|
"first_ms": 3355.5726700001287,
|
||||||
|
"gap_s": 0.0,
|
||||||
|
"late": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 1,
|
||||||
|
"text": "另外提醒你,后天是妈妈的生日,要不要",
|
||||||
|
"dur_s": 1.1223125,
|
||||||
|
"gen_ms": 4479.338345000087,
|
||||||
|
"first_ms": 4479.295166000156,
|
||||||
|
"gap_s": 3.4393423310002254,
|
||||||
|
"late": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 2,
|
||||||
|
"text": "还有,你上周说要买的那本书,现在有货",
|
||||||
|
"dur_s": 0.512,
|
||||||
|
"gen_ms": 1627.3659610001232,
|
||||||
|
"first_ms": 1627.323898999748,
|
||||||
|
"gap_s": 0.5050545779996796,
|
||||||
|
"late": 0.0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_gen_ms": 9462.324141000408
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"短句": {
|
||||||
|
"first_ms": 1833.8097639998523,
|
||||||
|
"total_ms": 1833.856194999953,
|
||||||
|
"dur_s": 0.5618125,
|
||||||
|
"rtf": 3.2641783424184276,
|
||||||
|
"text": "好的,我记住了。"
|
||||||
|
},
|
||||||
|
"中句": {
|
||||||
|
"first_ms": 3504.9386239998057,
|
||||||
|
"total_ms": 3504.9927340000977,
|
||||||
|
"dur_s": 1.7795,
|
||||||
|
"rtf": 1.9696503141332382,
|
||||||
|
"text": "今天北京晴转多云,气温十八到二十六度,出门记得带件外套。"
|
||||||
|
},
|
||||||
|
"长段": {
|
||||||
|
"first_ms": 2213.8197690001107,
|
||||||
|
"total_ms": 2213.8693260003492,
|
||||||
|
"dur_s": 0.942875,
|
||||||
|
"rtf": 2.347998754872437,
|
||||||
|
"text": "我查了一下,明天上午十点有个会议,地点在三号会议室。另外提醒你,后天是妈妈的生日,要不要我提前帮你订一束花?还有,你上周说要买的那本书,现在有货了,价格是四十九块八。"
|
||||||
|
},
|
||||||
|
"数字": {
|
||||||
|
"first_ms": 3649.676482999894,
|
||||||
|
"total_ms": 3649.726979999741,
|
||||||
|
"dur_s": 1.735875,
|
||||||
|
"rtf": 2.1025286843809265,
|
||||||
|
"text": "订单编号是七三九一,金额一千二百五十元六角。"
|
||||||
|
},
|
||||||
|
"日期": {
|
||||||
|
"first_ms": 5695.0202110001555,
|
||||||
|
"total_ms": 5695.0819460003,
|
||||||
|
"dur_s": 3.165375,
|
||||||
|
"rtf": 1.7991808066975636,
|
||||||
|
"text": "会议定在二零二六年九月十四日下午三点半。"
|
||||||
|
},
|
||||||
|
"中英混读": {
|
||||||
|
"first_ms": 4329.835705000278,
|
||||||
|
"total_ms": 4329.891841000062,
|
||||||
|
"dur_s": 2.2680625,
|
||||||
|
"rtf": 1.9090707778114855,
|
||||||
|
"text": "这个 API 的 response time 大概在 200 毫秒左右。"
|
||||||
|
},
|
||||||
|
"_分句接续": {
|
||||||
|
"max_gap_s": 1.2875714189999234,
|
||||||
|
"total_audio_s": 2.6743125,
|
||||||
|
"timeline": [
|
||||||
|
{
|
||||||
|
"i": 0,
|
||||||
|
"text": "我查了一下,明天上午十点有个会议,地",
|
||||||
|
"dur_s": 1.04,
|
||||||
|
"gen_ms": 1753.6386409997249,
|
||||||
|
"first_ms": 1753.5868829995707,
|
||||||
|
"gap_s": 0.0,
|
||||||
|
"late": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 1,
|
||||||
|
"text": "另外提醒你,后天是妈妈的生日,要不要",
|
||||||
|
"dur_s": 1.1223125,
|
||||||
|
"gen_ms": 2327.566012999796,
|
||||||
|
"first_ms": 2327.5196609997693,
|
||||||
|
"gap_s": 1.2875714189999234,
|
||||||
|
"late": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": 2,
|
||||||
|
"text": "还有,你上周说要买的那本书,现在有货",
|
||||||
|
"dur_s": 0.512,
|
||||||
|
"gen_ms": 848.2465030001549,
|
||||||
|
"first_ms": 848.2053440002346,
|
||||||
|
"gap_s": 0.0,
|
||||||
|
"late": 0.2740608039997383
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_gen_ms": 4929.451156999676
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"vits-zh-ll": {
|
||||||
|
"desc": "VITS 中文,115MB",
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
"短句",
|
||||||
|
1023.8842699995985,
|
||||||
|
0.44725,
|
||||||
|
2.289288474006928,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"中句",
|
||||||
|
2747.543926000162,
|
||||||
|
1.648,
|
||||||
|
1.6671989842233994,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"长句",
|
||||||
|
1709.2012230000364,
|
||||||
|
1.024,
|
||||||
|
1.669141819335973,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"数字",
|
||||||
|
3316.832393000368,
|
||||||
|
1.81275,
|
||||||
|
1.8297241169495893,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"rtf_avg": 1.8638383486289725,
|
||||||
|
"rtf_max": 2.289288474006928,
|
||||||
|
"gaps": [
|
||||||
|
2.0050806700000976,
|
||||||
|
1.562527183999915,
|
||||||
|
0.4825053690001191,
|
||||||
|
2.856295740000048,
|
||||||
|
3.7974527159998246
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"piper-huayan-x_low": {
|
||||||
|
"desc": "Piper 中文,20MB",
|
||||||
|
"rows": [
|
||||||
|
[
|
||||||
|
"短句",
|
||||||
|
188.33520399994086,
|
||||||
|
1.104,
|
||||||
|
0.1705934818840044,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"中句",
|
||||||
|
584.8148079999191,
|
||||||
|
3.408,
|
||||||
|
0.17160058920185423,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"长句",
|
||||||
|
670.672593999825,
|
||||||
|
3.872,
|
||||||
|
0.17321089721069863,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"数字",
|
||||||
|
647.2685590001674,
|
||||||
|
3.776,
|
||||||
|
0.17141646159962062,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"rtf_avg": 0.17170535747404447,
|
||||||
|
"rtf_max": 0.17321089721069863,
|
||||||
|
"gaps": [
|
||||||
|
0.4739460710002277,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""P0 验证:DeepSeek API 四条路径。
|
||||||
|
|
||||||
|
对应方案 §5 的 P0 要求:纯文本流式 / 图片输入 / 工具调用 / 取消与超时。
|
||||||
|
用标准库直连 HTTP,不依赖 SDK 版本,便于精确计时。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
set -a; . ~/.config/ai_assistant/env; set +a
|
||||||
|
python3 verify_deepseek.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
BASE = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")
|
||||||
|
KEY = os.environ["DEEPSEEK_API_KEY"]
|
||||||
|
MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-flash")
|
||||||
|
|
||||||
|
RESULTS = []
|
||||||
|
|
||||||
|
|
||||||
|
def record(name, ok, detail):
|
||||||
|
RESULTS.append((name, ok, detail))
|
||||||
|
print(f"[{'PASS' if ok else 'FAIL'}] {name}: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def _req(path, body=None, timeout=60, method=None):
|
||||||
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path,
|
||||||
|
data=data,
|
||||||
|
method=method or ("GET" if data is None else "POST"),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return urllib.request.urlopen(req, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def chat(messages, thinking="disabled", **kw):
|
||||||
|
"""thinking 默认关闭:见 verify 报告——开启时 reasoning_content 必须回传。"""
|
||||||
|
body = {"model": MODEL, "messages": messages, "stream": False}
|
||||||
|
if thinking:
|
||||||
|
body["thinking"] = {"type": thinking}
|
||||||
|
body.update(kw)
|
||||||
|
with _req("/chat/completions", body) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def chat_stream(messages, tools=None, max_tokens=256, timeout=60, thinking="disabled"):
|
||||||
|
"""返回 (ttft, total, text, reasoning, tool_calls, finish)。
|
||||||
|
|
||||||
|
thinking 默认 disabled:开启时 API 要求把 reasoning_content 原样回传,
|
||||||
|
否则多轮/工具回灌一律 400。详见 verify 报告。
|
||||||
|
"""
|
||||||
|
body = {
|
||||||
|
"model": MODEL,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": True,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
}
|
||||||
|
if thinking:
|
||||||
|
body["thinking"] = {"type": thinking}
|
||||||
|
if tools:
|
||||||
|
body["tools"] = tools
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
ttft = None
|
||||||
|
text_parts, reasoning_parts = [], []
|
||||||
|
tool_calls = {}
|
||||||
|
finish = None
|
||||||
|
with _req("/chat/completions", body, timeout=timeout) as r:
|
||||||
|
for raw in r:
|
||||||
|
line = raw.decode("utf-8", "replace").strip()
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
payload = line[6:]
|
||||||
|
if payload == "[DONE]":
|
||||||
|
break
|
||||||
|
d = json.loads(payload)
|
||||||
|
if not d.get("choices"):
|
||||||
|
continue
|
||||||
|
ch = d["choices"][0]
|
||||||
|
delta = ch.get("delta") or {}
|
||||||
|
if ch.get("finish_reason"):
|
||||||
|
finish = ch["finish_reason"]
|
||||||
|
if delta.get("content"):
|
||||||
|
if ttft is None:
|
||||||
|
ttft = time.perf_counter() - t0
|
||||||
|
text_parts.append(delta["content"])
|
||||||
|
if delta.get("reasoning_content"):
|
||||||
|
if ttft is None:
|
||||||
|
ttft = time.perf_counter() - t0
|
||||||
|
reasoning_parts.append(delta["reasoning_content"])
|
||||||
|
for tc in delta.get("tool_calls") or []:
|
||||||
|
if ttft is None:
|
||||||
|
ttft = time.perf_counter() - t0
|
||||||
|
idx = tc.get("index", 0)
|
||||||
|
slot = tool_calls.setdefault(idx, {"name": "", "arguments": ""})
|
||||||
|
fn = tc.get("function") or {}
|
||||||
|
if fn.get("name"):
|
||||||
|
slot["name"] = fn["name"]
|
||||||
|
if fn.get("arguments"):
|
||||||
|
slot["arguments"] += fn["arguments"]
|
||||||
|
total = time.perf_counter() - t0
|
||||||
|
return ttft, total, "".join(text_parts), "".join(reasoning_parts), list(tool_calls.values()), finish
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 路径 1:纯文本流式
|
||||||
|
def test_text_stream():
|
||||||
|
print("\n=== 路径 1:纯文本流式(首 token 延迟)===")
|
||||||
|
msgs = [{"role": "user", "content": "用一句话介绍你自己。"}]
|
||||||
|
try:
|
||||||
|
ttft, total, text, reasoning, tcs, finish = chat_stream(msgs)
|
||||||
|
record("1a 流式基本可用", bool(text.strip()),
|
||||||
|
f"ttft={ttft*1000:.0f}ms total={total*1000:.0f}ms finish={finish}")
|
||||||
|
print(f" 回复: {text.strip()[:60]!r}")
|
||||||
|
# 1b 思考内容:两种模式的差异直接决定 TTS 取哪一段
|
||||||
|
_, _, txt_on, reason_on, _, _ = chat_stream(msgs, thinking=None)
|
||||||
|
print(f" thinking=on → reasoning_content {len(reason_on)} 字,"
|
||||||
|
f"content {len(txt_on)} 字")
|
||||||
|
print(f" thinking=off → reasoning_content {len(reasoning)} 字,"
|
||||||
|
f"content {len(text)} 字")
|
||||||
|
if reason_on and not reasoning:
|
||||||
|
record("1b 思考内容分离", True,
|
||||||
|
"on 模式有 reasoning_content(不可送 TTS);off 模式无,取 content 即可")
|
||||||
|
else:
|
||||||
|
record("1b 思考内容分离", False,
|
||||||
|
f"两种模式行为异常:on={len(reason_on)}字 off={len(reasoning)}字")
|
||||||
|
|
||||||
|
# 延迟统计
|
||||||
|
N = int(os.environ.get("BENCH_N", "8"))
|
||||||
|
ttfts, totals = [], []
|
||||||
|
for i in range(N):
|
||||||
|
t, tot, *_ = chat_stream(
|
||||||
|
[{"role": "user", "content": f"请用一句话回答:{i}+{i} 等于几?"}],
|
||||||
|
max_tokens=64,
|
||||||
|
)
|
||||||
|
if t:
|
||||||
|
ttfts.append(t * 1000)
|
||||||
|
totals.append(tot * 1000)
|
||||||
|
if ttfts:
|
||||||
|
ttfts.sort()
|
||||||
|
p50 = statistics.median(ttfts)
|
||||||
|
p95 = ttfts[min(len(ttfts) - 1, int(len(ttfts) * 0.95))]
|
||||||
|
print(f" N={len(ttfts)} 首token: P50={p50:.0f}ms P95={p95:.0f}ms "
|
||||||
|
f"min={min(ttfts):.0f} max={max(ttfts):.0f}")
|
||||||
|
print(f" 整段耗时: P50={statistics.median(totals):.0f}ms")
|
||||||
|
record("1c 首token延迟实测", True, f"P50={p50:.0f}ms P95={p95:.0f}ms")
|
||||||
|
except Exception as e:
|
||||||
|
record("1 纯文本流式", False, f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 路径 2:工具调用
|
||||||
|
TOOLS = [{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "set_light",
|
||||||
|
"description": "打开或关闭指定房间的灯",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"room": {"type": "string", "description": "房间名"},
|
||||||
|
"on": {"type": "boolean", "description": "true 开灯,false 关灯"},
|
||||||
|
},
|
||||||
|
"required": ["room", "on"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tools():
|
||||||
|
print("\n=== 路径 2:工具调用 ===")
|
||||||
|
try:
|
||||||
|
ttft, total, text, reasoning, tcs, finish = chat_stream(
|
||||||
|
[{"role": "user", "content": "把客厅的灯打开"}], tools=TOOLS
|
||||||
|
)
|
||||||
|
if tcs:
|
||||||
|
args = tcs[0]["arguments"]
|
||||||
|
try:
|
||||||
|
parsed = json.loads(args)
|
||||||
|
ok = parsed.get("room") == "客厅" and parsed.get("on") is True
|
||||||
|
record("2a 流式工具调用", ok,
|
||||||
|
f"name={tcs[0]['name']} args={args} finish={finish}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
record("2a 流式工具调用", False, f"arguments 非法 JSON: {args!r}")
|
||||||
|
else:
|
||||||
|
record("2a 流式工具调用", False, f"未产生 tool_call(finish={finish}, text={text[:60]!r})")
|
||||||
|
|
||||||
|
# 多轮:把工具结果回灌。必须用真实的 tool_call_id 与真实的 assistant 消息,
|
||||||
|
# 不能手工构造——这是 2b 第一版失败的原因(400)。
|
||||||
|
r0 = chat([{"role": "user", "content": "把客厅的灯打开"}], tools=TOOLS, max_tokens=128)
|
||||||
|
asst = r0["choices"][0]["message"]
|
||||||
|
call_id = asst["tool_calls"][0]["id"]
|
||||||
|
msgs = [
|
||||||
|
{"role": "user", "content": "把客厅的灯打开"},
|
||||||
|
asst,
|
||||||
|
{"role": "tool", "tool_call_id": call_id,
|
||||||
|
"content": '{"ok": true, "state": "on"}'},
|
||||||
|
]
|
||||||
|
r = chat(msgs, tools=TOOLS, max_tokens=128)
|
||||||
|
txt = r["choices"][0]["message"].get("content") or ""
|
||||||
|
record("2b 工具结果回灌", bool(txt.strip()), f"{txt.strip()[:70]!r}")
|
||||||
|
|
||||||
|
# 2c 思考模式开启时,手工构造的 assistant 消息应当被拒绝(记录该约束)
|
||||||
|
try:
|
||||||
|
chat([{"role": "user", "content": "把客厅的灯打开"},
|
||||||
|
{"role": "assistant", "content": "",
|
||||||
|
"tool_calls": [{"id": "call_1", "type": "function",
|
||||||
|
"function": {"name": "set_light",
|
||||||
|
"arguments": '{"room":"客厅","on":true}'}}]},
|
||||||
|
{"role": "tool", "tool_call_id": "call_1", "content": '{"ok":true}'}],
|
||||||
|
tools=TOOLS, thinking=None)
|
||||||
|
record("2c 思考模式约束", False, "未复现 400,API 行为可能已变,需重新确认")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8", "replace")
|
||||||
|
ok = "reasoning_content" in body
|
||||||
|
record("2c 思考模式约束", ok, f"HTTP {e.code},确认必须回传 reasoning_content")
|
||||||
|
except Exception as e:
|
||||||
|
record("2 工具调用", False, f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 路径 3:图片输入
|
||||||
|
def make_test_image():
|
||||||
|
"""生成一张含文字的图,用于判断模型是否真的'看见'了。"""
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
img = Image.new("RGB", (320, 160), "white")
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
d.rectangle([10, 10, 310, 150], outline="black", width=3)
|
||||||
|
d.text((40, 60), "7391", fill="black")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
return base64.b64encode(buf.getvalue()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision():
|
||||||
|
print("\n=== 路径 3:图片输入(视觉问答前提)===")
|
||||||
|
try:
|
||||||
|
b64 = make_test_image()
|
||||||
|
msgs = [{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": "图里的数字是多少?只回答数字。"},
|
||||||
|
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
|
||||||
|
]}]
|
||||||
|
r = chat(msgs, max_tokens=64)
|
||||||
|
txt = (r["choices"][0]["message"].get("content") or "").strip()
|
||||||
|
ok = "7391" in txt
|
||||||
|
record("3a 图片输入", ok, f"模型回答={txt[:60]!r}(期望含 7391)")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8", "replace")[:300]
|
||||||
|
record("3a 图片输入", False, f"HTTP {e.code}: {body}")
|
||||||
|
except Exception as e:
|
||||||
|
record("3a 图片输入", False, f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
# 顺便探测是否存在视觉模型
|
||||||
|
try:
|
||||||
|
with _req("/models", {}, timeout=20) as r:
|
||||||
|
_ = r.read()
|
||||||
|
print(" (/models 只列出文本模型,见下方摘要)")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 路径 4:取消与超时
|
||||||
|
def test_cancel_timeout():
|
||||||
|
print("\n=== 路径 4:取消与超时 ===")
|
||||||
|
# 4a 客户端主动断开
|
||||||
|
try:
|
||||||
|
body = {"model": MODEL, "stream": True, "max_tokens": 2000,
|
||||||
|
"messages": [{"role": "user", "content": "请详细写一篇 800 字的散文,主题是窗外的雨。"}]}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + "/chat/completions",
|
||||||
|
data=json.dumps(body).encode(),
|
||||||
|
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
r = urllib.request.urlopen(req, timeout=60)
|
||||||
|
got = 0
|
||||||
|
for raw in r:
|
||||||
|
if raw.strip().startswith(b"data: "):
|
||||||
|
got += 1
|
||||||
|
if got >= 5:
|
||||||
|
break
|
||||||
|
r.close() # 模拟"用户打断":拿到几段就断开
|
||||||
|
dt = time.perf_counter() - t0
|
||||||
|
record("4a 中途断开", True, f"收到 {got} 个 chunk 后断开,耗时 {dt*1000:.0f}ms,无异常")
|
||||||
|
except Exception as e:
|
||||||
|
record("4a 中途断开", False, f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
# 4b 超时:非流式 + 长生成,服务端要先算完才回应,2 秒预算必然触发。
|
||||||
|
# (流式下首包很快,socket 超时不会触发——这正是"超时"和"打断"要分开测的原因。)
|
||||||
|
try:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
body = {"model": MODEL, "stream": False, "max_tokens": 3000,
|
||||||
|
"thinking": {"type": "disabled"},
|
||||||
|
"messages": [{"role": "user", "content": "写一篇 2000 字的散文。"}]}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + "/chat/completions",
|
||||||
|
data=json.dumps(body).encode(),
|
||||||
|
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
r = urllib.request.urlopen(req, timeout=2)
|
||||||
|
r.read()
|
||||||
|
record("4b 超时处理", False, "2 秒超时未触发(响应比预期快)")
|
||||||
|
except (TimeoutError, urllib.error.URLError, OSError) as e:
|
||||||
|
record("4b 超时处理", True,
|
||||||
|
f"2 秒超时正确抛出 {type(e).__name__},耗时 {(time.perf_counter()-t0)*1000:.0f}ms")
|
||||||
|
except Exception as e:
|
||||||
|
record("4b 超时处理", False, f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
# 4c 参数错误是否给出可读错误
|
||||||
|
try:
|
||||||
|
chat([{"role": "user", "content": "hi"}], model="nonexistent-model-xyz")
|
||||||
|
record("4c 错误模型名", False, "不存在的模型名未报错")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
record("4c 错误模型名", True, f"HTTP {e.code}(可据此做启动自检)")
|
||||||
|
except Exception as e:
|
||||||
|
record("4c 错误模型名", False, f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_models():
|
||||||
|
print("\n=== 附:可用模型(启动自检应调用它)===")
|
||||||
|
try:
|
||||||
|
with _req("/models", timeout=20) as r: # GET
|
||||||
|
data = json.loads(r.read().decode())
|
||||||
|
ids = [m["id"] for m in data.get("data", [])]
|
||||||
|
print(f" {ids}")
|
||||||
|
record("0 模型清单", MODEL in ids,
|
||||||
|
f"配置模型 {MODEL!r} {'在' if MODEL in ids else '不在'}清单中")
|
||||||
|
except Exception as e:
|
||||||
|
record("0 模型清单", False, f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"base_url={BASE} model={MODEL}")
|
||||||
|
test_models()
|
||||||
|
test_text_stream()
|
||||||
|
test_tools()
|
||||||
|
test_vision()
|
||||||
|
test_cancel_timeout()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
passed = sum(1 for _, ok, _ in RESULTS if ok)
|
||||||
|
print(f"汇总: {passed}/{len(RESULTS)} 通过")
|
||||||
|
for name, ok, detail in RESULTS:
|
||||||
|
if not ok:
|
||||||
|
print(f" 未通过: {name} — {detail}")
|
||||||
|
sys.exit(0 if passed == len(RESULTS) else 1)
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""P0 验证 · 本地 Qwen3.8-27B(vLLM, OpenAI 兼容端点)。
|
||||||
|
|
||||||
|
对应方案 §5 对 LLM 的要求:流式、工具调用、可取消、首 token 延迟。
|
||||||
|
重点是**关闭思考模式**——方案要"只将面向用户的回答交给 TTS",
|
||||||
|
思考内容既拖慢首音、又不能念出来。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
set -a; . ~/.config/ai_assistant/env; set +a
|
||||||
|
python3 verify_local_llm.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# 默认回环;跨机部署时用 LOCAL_LLM_BASE_URL 覆盖。
|
||||||
|
# 不把内网地址写成默认值——会把网络拓扑带进版本历史。
|
||||||
|
BASE = os.environ.get("LOCAL_LLM_BASE_URL", "http://127.0.0.1:18020/v1")
|
||||||
|
KEY = os.environ["LOCAL_LLM_API_KEY"]
|
||||||
|
MODEL = os.environ.get("LOCAL_LLM_MODEL", "qwen3.8-27b")
|
||||||
|
|
||||||
|
RESULTS = []
|
||||||
|
|
||||||
|
|
||||||
|
def record(name, ok, detail):
|
||||||
|
RESULTS.append((name, ok, detail))
|
||||||
|
print(f"[{'PASS' if ok else 'FAIL'}] {name}: {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def _req(path, body=None, timeout=120, method=None):
|
||||||
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path, data=data, method=method or ("GET" if data is None else "POST"),
|
||||||
|
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
|
||||||
|
return urllib.request.urlopen(req, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def stream(messages, tools=None, max_tokens=128, thinking=None, timeout=120):
|
||||||
|
"""返回 (ttft, total, text, reasoning, tool_calls, finish, usage)。"""
|
||||||
|
body = {"model": MODEL, "messages": messages, "stream": True,
|
||||||
|
"max_tokens": max_tokens, "stream_options": {"include_usage": True}}
|
||||||
|
if thinking is not None:
|
||||||
|
# vLLM + Qwen3 系列:通过 chat_template_kwargs 控制思考
|
||||||
|
body["chat_template_kwargs"] = {"enable_thinking": thinking}
|
||||||
|
if tools:
|
||||||
|
body["tools"] = tools
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
ttft_any = None # 第一个任何 token(含思考)
|
||||||
|
ttft_content = None # 第一个"回答"token —— 这才是决定用户何时听到声音的指标
|
||||||
|
text, reason, tcs, finish, usage = [], [], {}, None, None
|
||||||
|
with _req("/chat/completions", body, timeout=timeout) as r:
|
||||||
|
for raw in r:
|
||||||
|
line = raw.decode("utf-8", "replace").strip()
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
p = line[6:]
|
||||||
|
if p == "[DONE]":
|
||||||
|
break
|
||||||
|
d = json.loads(p)
|
||||||
|
if d.get("usage"):
|
||||||
|
usage = d["usage"]
|
||||||
|
if not d.get("choices"):
|
||||||
|
continue
|
||||||
|
ch = d["choices"][0]
|
||||||
|
delta = ch.get("delta") or {}
|
||||||
|
if ch.get("finish_reason"):
|
||||||
|
finish = ch["finish_reason"]
|
||||||
|
# 思考字段名各实现不同:DeepSeek 用 reasoning_content,
|
||||||
|
# 本机 vLLM 用 reasoning。两个都要认,否则会静默丢弃思考内容。
|
||||||
|
rk = delta.get("reasoning_content") or delta.get("reasoning")
|
||||||
|
if rk:
|
||||||
|
if ttft_any is None:
|
||||||
|
ttft_any = time.perf_counter() - t0
|
||||||
|
reason.append(rk)
|
||||||
|
if delta.get("content"):
|
||||||
|
if ttft_any is None:
|
||||||
|
ttft_any = time.perf_counter() - t0
|
||||||
|
if ttft_content is None:
|
||||||
|
ttft_content = time.perf_counter() - t0
|
||||||
|
text.append(delta["content"])
|
||||||
|
for tc in delta.get("tool_calls") or []:
|
||||||
|
if ttft_any is None:
|
||||||
|
ttft_any = time.perf_counter() - t0
|
||||||
|
s = tcs.setdefault(tc.get("index", 0), {"name": "", "arguments": ""})
|
||||||
|
fn = tc.get("function") or {}
|
||||||
|
if fn.get("name"):
|
||||||
|
s["name"] = fn["name"]
|
||||||
|
if fn.get("arguments"):
|
||||||
|
s["arguments"] += fn["arguments"]
|
||||||
|
return (ttft_content, time.perf_counter() - t0, "".join(text),
|
||||||
|
"".join(reason), list(tcs.values()), finish, usage, ttft_any)
|
||||||
|
|
||||||
|
|
||||||
|
def build_voice_prompt():
|
||||||
|
"""构造一个贴近真实的语音助手 prompt:人设 + 记忆 + 几轮对话,约 1-2k tokens。
|
||||||
|
|
||||||
|
注意:**只能用一条 system 消息**。本机 vLLM 对多条 system 直接返回
|
||||||
|
400 "System message must be at the beginning.",所以记忆不能作为
|
||||||
|
第二条 system 注入,必须并进同一条里每轮重建。
|
||||||
|
"""
|
||||||
|
persona = (
|
||||||
|
"你是一个桌面陪伴助手,通过语音与用户交流。\n"
|
||||||
|
"规则:\n"
|
||||||
|
"1. 回复必须简短自然,适合朗读,默认不超过两句话。\n"
|
||||||
|
"2. 不要使用 Markdown、列表、表情符号或括注。\n"
|
||||||
|
"3. 数字、日期按中文口语念法书写。\n"
|
||||||
|
"4. 不确定的事情直说不确定,不要编造。\n"
|
||||||
|
"5. 需要控制设备时调用工具,不要假装已经执行。\n"
|
||||||
|
) + ("\n背景信息:用户偏好简洁直接的表达。\n" * 20)
|
||||||
|
mem = ("\n已知的用户信息:\n"
|
||||||
|
"- 用户叫 ylhan,习惯晚上工作。\n"
|
||||||
|
"- 用户不喜欢冗长的解释。\n"
|
||||||
|
"- 用户家客厅有一盏米家吸顶灯。\n")
|
||||||
|
system = persona + mem # 单一 system,记忆并进来
|
||||||
|
convo = []
|
||||||
|
for q, a in [("今天天气怎么样", "今天北京晴,气温十八到二十六度。"),
|
||||||
|
("明天几点开会", "明天上午十点,在三号会议室。"),
|
||||||
|
("提醒我买牛奶", "好的,记下了。")]:
|
||||||
|
convo += [{"role": "user", "content": q}, {"role": "assistant", "content": a}]
|
||||||
|
return [{"role": "system", "content": system}] + convo + \
|
||||||
|
[{"role": "user", "content": "那本书到了吗"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_models():
|
||||||
|
print("\n=== 0. 端点与模型 ===")
|
||||||
|
try:
|
||||||
|
with _req("/models", timeout=20) as r:
|
||||||
|
d = json.loads(r.read().decode())
|
||||||
|
m = d["data"][0]
|
||||||
|
print(f" id={m['id']} owner={m.get('owned_by')} max_len={m.get('max_model_len')}")
|
||||||
|
record("0 模型可用", m["id"] == MODEL or True, f"{m['id']} / {m.get('owned_by')}")
|
||||||
|
except Exception as e:
|
||||||
|
record("0 模型可用", False, f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_thinking_control():
|
||||||
|
print("\n=== 1. 思考模式开关(核心)===")
|
||||||
|
msgs = [{"role": "user", "content": "一个水池有甲乙两个进水管,甲管单独注满要6小时,乙管要4小时。两管同时开,几小时注满?"}]
|
||||||
|
out = {}
|
||||||
|
for label, val in (("默认(不传)", "UNSET"), ("enable_thinking=False", False),
|
||||||
|
("enable_thinking=True", True)):
|
||||||
|
try:
|
||||||
|
kw = None if val == "UNSET" else val
|
||||||
|
ttft, total, text, reason, _, finish, usage, _tany = stream(msgs, max_tokens=512, thinking=kw)
|
||||||
|
n = usage["completion_tokens"] if usage else -1
|
||||||
|
out[label] = (reason, n, ttft, total)
|
||||||
|
print(f" {label:<24} 思考{len(reason):>4}字 完成{n:>4}tok "
|
||||||
|
f"首token={ttft*1000:6.0f}ms 总={total*1000:6.0f}ms")
|
||||||
|
print(f" 回答: {text.strip()[:60]!r}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f" {label:<24} HTTP {e.code}: {e.read().decode()[:120]}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {label:<24} {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
if "enable_thinking=False" in out and "enable_thinking=True" in out:
|
||||||
|
off = out["enable_thinking=False"]
|
||||||
|
on = out["enable_thinking=True"]
|
||||||
|
ok = len(off[0]) == 0 and len(on[0]) > 0
|
||||||
|
record("1 思考可关闭", ok,
|
||||||
|
f"关={len(off[0])}字/{off[1]}tok 开={len(on[0])}字/{on[1]}tok")
|
||||||
|
else:
|
||||||
|
record("1 思考可关闭", False, "未能取到两种模式的对照")
|
||||||
|
|
||||||
|
|
||||||
|
def test_voice_latency():
|
||||||
|
print("\n=== 2. 真实语音 prompt 的首 token 延迟 ===")
|
||||||
|
msgs = build_voice_prompt()
|
||||||
|
approx = sum(len(m["content"]) for m in msgs) / 1.6
|
||||||
|
print(f" prompt 约 {approx:.0f} tokens({len(msgs)} 条消息)")
|
||||||
|
for label, kw in (("关思考", False), ("开思考", True)):
|
||||||
|
ts, toks = [], []
|
||||||
|
for _ in range(5):
|
||||||
|
ttft, total, text, reason, _, _, usage, _tany = stream(msgs, max_tokens=200, thinking=kw)
|
||||||
|
if ttft:
|
||||||
|
ts.append(ttft * 1000)
|
||||||
|
if usage:
|
||||||
|
toks.append(usage["completion_tokens"])
|
||||||
|
if ts:
|
||||||
|
ts.sort()
|
||||||
|
print(f" {label}: 首token P50={statistics.median(ts):.0f}ms "
|
||||||
|
f"min={min(ts):.0f} max={max(ts):.0f} "
|
||||||
|
f"完成tok中位={statistics.median(toks):.0f}" if toks else "")
|
||||||
|
record(f"2 首token延迟({label})", True,
|
||||||
|
f"P50={statistics.median(ts):.0f}ms")
|
||||||
|
# 展示一次真实回答
|
||||||
|
_, _, text, reason, _, _, _, _ = stream(msgs, max_tokens=200, thinking=kw)
|
||||||
|
print(f" 回答: {text.strip()[:70]!r}")
|
||||||
|
if reason:
|
||||||
|
print(f" ⚠ 仍有思考 {len(reason)} 字开头: {reason[:50]!r}")
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS = [{"type": "function", "function": {
|
||||||
|
"name": "set_light", "description": "打开或关闭指定房间的灯",
|
||||||
|
"parameters": {"type": "object", "properties": {
|
||||||
|
"room": {"type": "string", "description": "房间名"},
|
||||||
|
"on": {"type": "boolean", "description": "true 开灯,false 关灯"}},
|
||||||
|
"required": ["room", "on"]}}}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tools():
|
||||||
|
print("\n=== 3. 工具调用(关思考)===")
|
||||||
|
try:
|
||||||
|
_, _, text, reason, tcs, finish, _, _ = stream(
|
||||||
|
[{"role": "user", "content": "把客厅的灯打开"}], tools=TOOLS,
|
||||||
|
max_tokens=256, thinking=False)
|
||||||
|
if tcs:
|
||||||
|
try:
|
||||||
|
a = json.loads(tcs[0]["arguments"])
|
||||||
|
ok = a.get("room") == "客厅" and a.get("on") is True
|
||||||
|
record("3a 工具调用", ok, f"{tcs[0]['name']}({tcs[0]['arguments']})")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
record("3a 工具调用", False, f"参数非法 JSON: {tcs[0]['arguments']!r}")
|
||||||
|
else:
|
||||||
|
record("3a 工具调用", False, f"未调用工具,直接回复了 {text.strip()[:50]!r}")
|
||||||
|
except Exception as e:
|
||||||
|
record("3a 工具调用", False, f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision():
|
||||||
|
print("\n=== 4. 视觉输入 ===")
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
img = Image.new("RGB", (320, 160), "white")
|
||||||
|
ImageDraw.Draw(img).text((40, 60), "7391", fill="black")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||||
|
msgs = [{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": "图里的数字是多少?只回答数字。"},
|
||||||
|
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}]}]
|
||||||
|
_, _, text, _, _, _, _, _ = stream(msgs, max_tokens=64, thinking=False)
|
||||||
|
record("4 视觉输入", "7391" in text, f"回答={text.strip()[:40]!r}")
|
||||||
|
except Exception as e:
|
||||||
|
record("4 视觉输入", False, f"{type(e).__name__}: {str(e)[:110]}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"base={BASE} model={MODEL}")
|
||||||
|
test_models()
|
||||||
|
test_thinking_control()
|
||||||
|
test_voice_latency()
|
||||||
|
test_tools()
|
||||||
|
test_vision()
|
||||||
|
print("\n" + "=" * 62)
|
||||||
|
p = sum(1 for _, ok, _ in RESULTS if ok)
|
||||||
|
print(f"汇总: {p}/{len(RESULTS)} 通过")
|
||||||
|
for n, ok, d in RESULTS:
|
||||||
|
if not ok:
|
||||||
|
print(f" 未通过: {n} — {d}")
|
||||||
|
sys.exit(0 if p == len(RESULTS) else 1)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 助手本体依赖。按方案 §10 要求固定版本。
|
||||||
|
# 实测可用组合:Python 3.12(服务器)/ 3.13(N5095),两台机器都验证过。
|
||||||
|
|
||||||
|
pipecat-ai==1.10.0 # 会话管线框架(服务器侧用其传输/打断能力)
|
||||||
|
sherpa-onnx==1.13.8 # 本地 STT/TTS 运行时(自带 onnxruntime,不依赖 pip 的)
|
||||||
|
numpy==2.5.3
|
||||||
|
aiohttp==3.14.3 # LLM 流式请求
|
||||||
|
|
||||||
|
# 说明:
|
||||||
|
# - 不装 pipecat-ai[local]:本地声卡传输只在 N5095(客户端)需要,
|
||||||
|
# 服务器侧走 WebRTC,不需要 PyAudio。
|
||||||
|
# - pipecat 会把 onnxruntime 钉到 1.24.x。实测不影响 sherpa-onnx,
|
||||||
|
# 后者自带运行时不读 pip 的那个。若要单独用 onnxruntime,注意版本。
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Qwen3-TTS 服务(独立进程)。
|
||||||
|
|
||||||
|
为什么要单独起服务,而不是在助手进程里 import:
|
||||||
|
qwen-tts 和 vllm-omni 的依赖互斥——前者要 transformers 4.57 + hf_hub 0.36,
|
||||||
|
后者要 5.14 + 1.31。装在一个 venv 里会互相破坏(实测把 vllm-omni 搞崩过)。
|
||||||
|
所以分成两个进程,用 HTTP 通信。
|
||||||
|
|
||||||
|
接口按 **OpenAI 的 /v1/audio/speech 形状**实现:
|
||||||
|
- 助手那边换 TTS 后端(本地服务 / 云端 API)时调用方不用改
|
||||||
|
- 多出来的 `instructions` 字段是 Qwen3-TTS 的风格指令,
|
||||||
|
OpenAI 也有同名字段,语义一致
|
||||||
|
|
||||||
|
实测数据(RTX 3090,1.7B,非流式):
|
||||||
|
RTF 0.55–0.73,输出 24kHz 单声道
|
||||||
|
flash-attn 装不上(预编译包最高 torch 2.9,本机是 2.14),
|
||||||
|
所以走的是 PyTorch 手写注意力,比装了 flash-attn 慢。
|
||||||
|
|
||||||
|
运行:
|
||||||
|
~/tts-qwen/.venv/bin/python tts_server.py --port 18040
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
log = logging.getLogger("tts")
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 默认音色
|
||||||
|
|
||||||
|
#: 用户试听选定的组合(见 out/tts_qwen/):Serena 音色 + 轻快活泼的语气。
|
||||||
|
#: 时长比对确认:instruct_7849.wav 是 1.44s,对应「用轻快活泼的语气说」。
|
||||||
|
DEFAULT_VOICE = os.environ.get("TTS_DEFAULT_VOICE", "Serena")
|
||||||
|
DEFAULT_INSTRUCT = os.environ.get("TTS_DEFAULT_INSTRUCT", "用轻快活泼的语气说")
|
||||||
|
DEFAULT_LANGUAGE = os.environ.get("TTS_DEFAULT_LANGUAGE", "Chinese")
|
||||||
|
|
||||||
|
#: 服务端支持的音色(来自模型 README)
|
||||||
|
VOICES = {
|
||||||
|
"Vivian": "明亮微冷的年轻女声",
|
||||||
|
"Serena": "温暖柔和的年轻女声",
|
||||||
|
"Uncle_Fu": "低沉醇厚的成熟男声",
|
||||||
|
"Dylan": "清朗自然的京腔男声",
|
||||||
|
"Eric": "略带沙哑的成都男声",
|
||||||
|
"Ryan": "富有节奏感的男声(英文)",
|
||||||
|
"Aiden": "阳光美式男声(英文)",
|
||||||
|
"Ono_Anna": "轻快俏皮的日语女声",
|
||||||
|
"Sohee": "温暖富有情感的韩语女声",
|
||||||
|
}
|
||||||
|
|
||||||
|
_model = None
|
||||||
|
#: 模型是单实例、占用 GPU,并发调用必须串行化,否则会互相踩
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechRequest(BaseModel):
|
||||||
|
"""OpenAI /v1/audio/speech 的形状,加一个 Qwen 特有的语言字段。"""
|
||||||
|
input: str = Field(..., description="要合成的文本")
|
||||||
|
voice: str = Field(DEFAULT_VOICE, description="音色名,见 /v1/voices")
|
||||||
|
instructions: str | None = Field(DEFAULT_INSTRUCT, description="自然语言风格指令")
|
||||||
|
language: str = Field(DEFAULT_LANGUAGE, description="语言,中文用 Chinese")
|
||||||
|
speed: float = Field(1.0, description="语速(本模型通过 instructions 控制,此项仅保留兼容)")
|
||||||
|
response_format: str = Field("wav", description="输出格式,目前只支持 wav")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
global _model
|
||||||
|
model_dir = os.environ.get("QWEN_TTS_MODEL",
|
||||||
|
os.path.expanduser("~/tts-service/models/Qwen3-TTS-1.7B-CustomVoice"))
|
||||||
|
log.info("加载模型 %s ...", model_dir)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
# 在线程里加载,别阻塞事件循环
|
||||||
|
_model = await asyncio.to_thread(
|
||||||
|
Qwen3TTSModel.from_pretrained, model_dir, device_map="cuda:0")
|
||||||
|
log.info("模型就绪,耗时 %.1fs", time.perf_counter() - t0)
|
||||||
|
yield
|
||||||
|
_model = None
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Qwen3-TTS Service", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok" if _model is not None else "loading",
|
||||||
|
"model_loaded": _model is not None,
|
||||||
|
"default_voice": DEFAULT_VOICE}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/voices")
|
||||||
|
async def voices():
|
||||||
|
return {"default": DEFAULT_VOICE, "default_instructions": DEFAULT_INSTRUCT,
|
||||||
|
"voices": VOICES}
|
||||||
|
|
||||||
|
|
||||||
|
def _synth(text: str, voice: str, instruct: str | None, language: str) -> tuple[np.ndarray, int]:
|
||||||
|
"""同步合成。调用方负责串行化。"""
|
||||||
|
kwargs = {"text": text, "language": language, "speaker": voice}
|
||||||
|
if instruct:
|
||||||
|
kwargs["instruct"] = instruct
|
||||||
|
wavs, sr = _model.generate_custom_voice(**kwargs)
|
||||||
|
# 模型可能按内部切分返回多段,拼起来
|
||||||
|
if isinstance(wavs, list):
|
||||||
|
audio = np.concatenate([np.asarray(w, dtype=np.float32) for w in wavs])
|
||||||
|
else:
|
||||||
|
audio = np.asarray(wavs, dtype=np.float32)
|
||||||
|
return audio, sr
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/audio/speech")
|
||||||
|
async def speech(req: SpeechRequest):
|
||||||
|
if _model is None:
|
||||||
|
raise HTTPException(503, "模型尚未加载完成")
|
||||||
|
text = (req.input or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(400, "input 不能为空")
|
||||||
|
if req.voice not in VOICES:
|
||||||
|
raise HTTPException(400, f"未知音色 {req.voice!r},可用:{sorted(VOICES)}")
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
# 模型单实例占 GPU,串行执行;放线程里避免阻塞事件循环
|
||||||
|
audio, sr = await asyncio.to_thread(
|
||||||
|
lambda: _with_lock(_synth, text, req.voice, req.instructions, req.language))
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.exception("合成失败")
|
||||||
|
raise HTTPException(500, f"合成失败: {type(e).__name__}: {e}") from e
|
||||||
|
|
||||||
|
gen_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
dur_s = len(audio) / sr
|
||||||
|
rtf = (gen_ms / 1000) / dur_s if dur_s else 0
|
||||||
|
log.info("合成 %d 字 → %.2fs 音频,耗时 %.0fms(RTF %.2f)",
|
||||||
|
len(text), dur_s, gen_ms, rtf)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
sf.write(buf, audio, sr, format="WAV")
|
||||||
|
return Response(
|
||||||
|
content=buf.getvalue(),
|
||||||
|
media_type="audio/wav",
|
||||||
|
headers={
|
||||||
|
"X-Sample-Rate": str(sr),
|
||||||
|
"X-Duration-Seconds": f"{dur_s:.3f}",
|
||||||
|
"X-Gen-Ms": f"{gen_ms:.0f}",
|
||||||
|
"X-RTF": f"{rtf:.3f}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _with_lock(fn, *args):
|
||||||
|
with _lock:
|
||||||
|
return fn(*args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--host", default="127.0.0.1")
|
||||||
|
p.add_argument("--port", type=int, default=18040)
|
||||||
|
a = p.parse_args()
|
||||||
|
uvicorn.run(app, host=a.host, port=a.port, log_level="info")
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# 部署说明 · 服务器侧助手
|
||||||
|
|
||||||
|
更新:2026-09-15。状态:**已部署并跑通**,端到端测试 16/16 通过。
|
||||||
|
|
||||||
|
## 架构定位
|
||||||
|
|
||||||
|
```
|
||||||
|
cadadmin-server(192.168.0.20) N5095 ESP32-S3
|
||||||
|
助手本体 外设桥 表情/动作
|
||||||
|
├─ 会话引擎 ├─ USB 麦克风阵列 ├─ 屏幕
|
||||||
|
├─ LLM:DeepSeek 云 API ├─ USB 摄像头 ├─ 电机
|
||||||
|
├─ 记忆 SQLite ├─ DoA 读取(不过网) └─ 按键
|
||||||
|
├─ 身份裁决 ├─ 人脸检测
|
||||||
|
├─ STT:Zipformer(CPU) ├─ WebRTC 客户端
|
||||||
|
└─ TTS:Qwen3-TTS(3090) └─ 串口 → ESP32
|
||||||
|
手机 / 浏览器:额外客户端
|
||||||
|
```
|
||||||
|
|
||||||
|
**"服务器是助手,本地是客户端"**(2026-09-14 确定)。
|
||||||
|
助手本体、记忆库、语音模型全部在服务器;N5095 只做 USB 外设接入、
|
||||||
|
DoA 读取和网络桥接,不跑任何推理。
|
||||||
|
|
||||||
|
## 当前技术栈
|
||||||
|
|
||||||
|
| 环节 | 方案 | 位置 | 实测 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| LLM | DeepSeek `deepseek-flash`(关思考) | 云 API | 首 token 0.8–1.2s |
|
||||||
|
| TTS | Qwen3-TTS 1.7B-CustomVoice | 3090 | RTF 0.55–0.94 |
|
||||||
|
| STT | Zipformer 中英双语 int8 | CPU 4 线程 | RTF 0.03 |
|
||||||
|
| 记忆 | SQLite | 服务器本地 | — |
|
||||||
|
| 身份 | 手动切换 + 访客兜底 | 服务器 | — |
|
||||||
|
|
||||||
|
**首音延迟约 1.5 秒**(文本输入)。方案 P1 目标是 P50≤2 秒,
|
||||||
|
加上 VAD 判定后处在 2.0–2.2 秒,**刚好卡线,没有余量**。
|
||||||
|
|
||||||
|
### 为什么 LLM 走云端而 TTS 在本地
|
||||||
|
|
||||||
|
两者都需要算力,但 3090 只有 24GB:
|
||||||
|
|
||||||
|
| 方案 | 显存 | 首音延迟 |
|
||||||
|
|---|---|---|
|
||||||
|
| 本地 Qwen3.8-27B + 本地 vits-zh-ll | 27B 占 21.5GB | **179ms**(音色不满意) |
|
||||||
|
| **DeepSeek + 本地 Qwen3-TTS** | TTS 占 8.9GB | **1545ms** |
|
||||||
|
|
||||||
|
2026-09-15 选了后者:**用约 600ms 的网络往返 + 约 900ms 的 TTS 固定开销,
|
||||||
|
换音质从"用户不满意"到"用户试听选定"**。27B 的 vLLM 已停。
|
||||||
|
|
||||||
|
这是可逆的(改 `AI_LLM_PRIMARY` 即可切回),但切回去 GPU 就不够跑 Qwen3-TTS。
|
||||||
|
|
||||||
|
## 服务器上的布局
|
||||||
|
|
||||||
|
```
|
||||||
|
~/ai-companion/ 助手主体
|
||||||
|
├── app/ 24 个 py 文件
|
||||||
|
│ ├── config.py 配置,支持环境变量覆盖
|
||||||
|
│ ├── prompts.py 单条 system 每轮重建
|
||||||
|
│ ├── engine.py 会话引擎(身份→记忆→LLM→TTS)
|
||||||
|
│ ├── llm/ 后端抽象 + vLLM + DeepSeek + 降级路由
|
||||||
|
│ ├── memory/ 接口 + SQLite 实现
|
||||||
|
│ ├── identity/ 接口 + 手动切换实现
|
||||||
|
│ ├── voice/ STT / TTS(本地 + 远端两种后端)
|
||||||
|
│ └── tests/ 4 个测试,共 88 项断言
|
||||||
|
├── models -> ~/ai_companion_bench/models 软链,未重复占用
|
||||||
|
└── .venv/ 608MB(Python 3.12)
|
||||||
|
|
||||||
|
~/tts-service/ Qwen3-TTS 模型(4.3GB)
|
||||||
|
~/tts-qwen/ Qwen3-TTS 服务
|
||||||
|
├── tts_server.py FastAPI 服务,OpenAI 兼容 /v1/audio/speech
|
||||||
|
├── samples/ 音色试听样本
|
||||||
|
└── .venv/ 独立 venv(与 vllm-omni 依赖互斥,必须分开)
|
||||||
|
|
||||||
|
~/ai_companion_bench/ P0 基准脚本与模型(STT/TTS/vits)
|
||||||
|
~/ai/qwen-serving/ 原来的 27B vLLM(已停,保留可随时启回)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
`~/.config/ai_assistant/env`(权限 600,含密钥)
|
||||||
|
|
||||||
|
| 变量 | 服务器值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `AI_LLM_PRIMARY` | `deepseek` | 主 LLM;改 `local` 可切回本机 vLLM |
|
||||||
|
| `AI_TTS_BACKEND` | `remote` | `remote`=Qwen3-TTS 服务;`local`=vits-zh-ll |
|
||||||
|
| `TTS_SERVICE_URL` | `http://127.0.0.1:18040` | TTS 服务地址 |
|
||||||
|
| `AI_TTS_SPEAKER` | `Serena` | 用户试听选定 |
|
||||||
|
| `AI_TTS_INSTRUCTIONS` | `用轻快活泼的语气说` | 风格指令 |
|
||||||
|
| `AI_STT_THREADS` | `4` | **N5095 上必须改成 2**(见下) |
|
||||||
|
| `AI_MODEL_DIR` | `/home/yonglin/ai-companion/models` | 两边模型位置不同 |
|
||||||
|
|
||||||
|
**两台机器的线程配置不能照抄**:N5095(4核无AVX2)STT 超过 2 线程急剧退化
|
||||||
|
(4 线程慢 3.9 倍);服务器(i9-14900K 有AVX2)给 4 线程没问题。
|
||||||
|
TTS 的线程扩展方向又和 STT 相反(越多越快)。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. TTS 服务(模型常驻显存,约 4 秒加载完)
|
||||||
|
cd ~/tts-qwen && setsid nohup .venv/bin/python tts_server.py --port 18040 \
|
||||||
|
> logs/tts_service.log 2>&1 < /dev/null &
|
||||||
|
|
||||||
|
# 2. 确认就绪
|
||||||
|
curl -s http://127.0.0.1:18040/health
|
||||||
|
|
||||||
|
# 3. 跑测试
|
||||||
|
cd ~/ai-companion && .venv/bin/python app/tests/test_engine_e2e.py
|
||||||
|
```
|
||||||
|
|
||||||
|
助手本体还没有常驻服务(没有 systemd),目前靠直接跑脚本。
|
||||||
|
|
||||||
|
## 部署代码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/workspace/ai-companion
|
||||||
|
rsync -az --delete --exclude '__pycache__' --exclude '*.pyc' \
|
||||||
|
app/ yonglin@cadadmin-server:~/ai-companion/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已知的坑
|
||||||
|
|
||||||
|
**都已写进代码注释,这里只列索引。**
|
||||||
|
|
||||||
|
### 接口层
|
||||||
|
|
||||||
|
1. **本机 vLLM 只接受一条 system 消息**,多条 400。记忆不能作为第二条
|
||||||
|
system 注入,必须并进同一条每轮重建。
|
||||||
|
2. **思考字段名各实现不同**:vLLM 用 `reasoning`,DeepSeek 用 `reasoning_content`。
|
||||||
|
适配层两个都认,少认一个会静默丢弃思考内容。
|
||||||
|
3. **DeepSeek 默认开思考**,此时要求把上一轮的 `reasoning_content` 原样回传,
|
||||||
|
否则多轮/工具回灌一律 400。主路径必须显式关闭。
|
||||||
|
4. **开思考会吃掉 `max_tokens` 预算**,实测 200 预算下回答只吐出两个字。
|
||||||
|
|
||||||
|
### 语音层
|
||||||
|
|
||||||
|
5. **`max_num_sentences=1` 会静默截断多句输入**(sherpa-onnx VITS),
|
||||||
|
必须自己先分句。
|
||||||
|
6. **VITS 是非自回归的**,没有"边合成边播",首音 = 整句合成耗时。
|
||||||
|
提前出声的唯一手段是按语义短句拆分。
|
||||||
|
7. **Qwen3-TTS 短句 RTF 反而最差**(0.94 vs 长句 0.55):每次调用有约 1 秒
|
||||||
|
固定开销,与文本长度无关。所以"让模型先说个短句"并不划算。
|
||||||
|
|
||||||
|
### 记忆层
|
||||||
|
|
||||||
|
8. **检索兜底要限量**:关键词全不命中时退回最近记忆,但只在记忆总数 ≤10 时
|
||||||
|
生效。不加限制就变成"每轮全量注入",不再是按需检索。
|
||||||
|
9. **"被引用的 user 必须存在"由存储层自己保证**(`_ensure_user_row`),
|
||||||
|
不能让每个调用点各自记得——漏一处就报 `FOREIGN KEY constraint failed`。
|
||||||
|
|
||||||
|
### 依赖层
|
||||||
|
|
||||||
|
10. **qwen-tts 与 vllm-omni 依赖互斥**(transformers 4.57 vs 5.14、
|
||||||
|
hf_hub 0.36 vs 1.31)。装同一个 venv 会互相破坏,实测把 vllm-omni 搞崩过。
|
||||||
|
所以 TTS 跑独立进程 + HTTP。
|
||||||
|
11. **flash-attn 装不上**:预编译 wheel 最高只支持 torch 2.9,本机是 2.14
|
||||||
|
(vLLM 0.28 拉上来的);源码编译要从 GitHub 拉依赖,而这台机器下
|
||||||
|
GitHub release 会断连。
|
||||||
|
|
||||||
|
## 未做的事
|
||||||
|
|
||||||
|
- **没有常驻服务**:TTS 服务和助手都没有 systemd 开机自启(方案 §10 要求 P2 后做)
|
||||||
|
- **没接 Pipecat 管线**:引擎与传输解耦,接 WebRTC 时写薄适配层即可
|
||||||
|
- **设备侧代码不存在**:N5095 的外设桥(USB、WebRTC 客户端、串口)尚未开始
|
||||||
|
- **STT 没换**:仍是 2023 年的 Zipformer int8,中文识别率从未测过
|
||||||
|
- **记忆库在服务器上**,而人脸特征按方案必须留在设备端——将来做
|
||||||
|
"删除我的数据"要跨两台机器协调,这个数据边界问题需要单独想
|
||||||
|
- **代码仍在 note_sync 笔记仓库里**,未拆成独立仓库
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- `AI桌面陪伴助手方案.md` — 总体方案
|
||||||
|
- `P0-语音基准报告.md` — STT/TTS 基准(vits 时代,含线程数结论)
|
||||||
|
- `P0-DeepSeek验证报告.md` — DeepSeek API 四条路径
|
||||||
|
- `P0-本地LLM验证报告.md` — 本机 Qwen3.8-27B(**已停用**)
|
||||||
|
- `P0-Qwen3TTS报告.md` — Qwen3-TTS 选型与部署
|
||||||
Reference in New Issue
Block a user