Compare commits

...
11 Commits
Author SHA1 Message Date
ylhan 8a12b2de6a 整理: 输入法笔记结构化, 清除知乎追踪链接与远端图床图片 2026-09-17 00:17:20 +08:00
ylhan f0dce05ac1 新增 Pipecat Flows 兼容性 spike 结论
交接文档规定"未经 spike 结论,不进入 Phase 3"。本次完成 Phase 1 spike,
7 项验证全部通过,并给出可进入 Phase 2 的结论。

新增 P1-Flows兼容性Spike结论.md

核心结论:
- **不装 pipecat-ai-flows**。Pipecat 1.10 内置 Flows(pipecat.flows),
  装独立包会报错提示卸载;独立包 1.4.0 会把 pipecat 降到 1.4.0
- **复用 DeepSeekLLMService**,不写 FallbackProvider 适配器。它自带
  DeepSeekLLMAdapter 处理 reasoning_content 回传(P0 踩过的坑),
  thinking 默认 disabled。代价是失去本地 vLLM 降级
- 实测:中文流式 14–22 块/回复、工具参数无重复拼接、回灌后出最终回答、
  worker.cancel() 立即终止、role_message 生效

定位了 5 个坑(4 个 Flows 相关 + 1 个环境):
1. FlowManager.initialize 是协程但签名显示 -> None,漏 await 静默失效,
   role_message/task_messages/工具全部不生效而 LLM 照样回复,极易误判
2. 必须用 PipelineRunner 跑 worker;直接 run() 会让工具执行失败,
   异常被转成通用错误串,handler 从不执行,默认日志看不到
3. respond_immediately 默认 True,initialize 会立刻触发一轮回复,
   做归因测试时不清空观察窗口会归因错
4. role_message 与 task_messages 各产生一条 system 消息,
   两者不要同时用(切回本地 vLLM 时会 400)
5. NLTK punkt_tab 本地也需预装,否则 setup 超时卡住

对现有功能的影响:分句 TTS、RTVI、记忆身份不受影响;
SearXNG 工具需迁移;本地 vLLM fallback 会失去。
第四节给出 Phase 3 的最小生产集成 diff 要点。
2026-09-16 13:13:52 +08:00
ylhan 05933d168f 记录 SearXNG 私有搜索服务部署 2026-09-16 11:14:06 +08:00
ylhan 26182e971e 更新 Pipecat 实时链路实验结论 2026-09-16 09:50:38 +08:00
ylhan 2ef6fe215c 新增 Pipecat 传输层实验报告;方案 §10 记录当前卡点
背景:方案文档写"Pipecat 作为唯一会话管线框架",但核实后发现
代码里它只在 requirements.txt 里(零引用)。于是做实验验证架构问题:
Pipecat 该用多深——只做传输(A),还是按框架 service 模型重构(B)。

新增 P0-Pipecat传输层实验报告.md

- 传输层跑通:信令(47–280ms)、ICE 连通、data channel、双向媒体轨
- 帧路由未通:自定义处理器的 process_frame 调用次数为 0,
  连自己注入的 StartFrame 都没进去;引擎从未运行
- 现象:连接全对、音轨协商成功、助手永远不说话,日志里一条错误都没有
- 记下 8 个集成坑,每个都表现为"什么都没发生"
- 结论:实验未能支持 A 方案,反而间接给了 B 更多理由;
  但根因未定位,两种可能必须区分(接线错误 vs 模型不兼容)

方案 §10 更新

- 第 1 步改为"服务端 WebRTC 入口 + 客户端骨架",状态标注为
  "传输层跑通、帧路由未通"
- 新增 1b 步:查清帧路由根因 → 定 A/B 方案,标为当前卡点
- header 加注实验状态与 A/B 待定
- 架构图补传输层,标注"实验,帧路由未通"

顺带修正

- 局域网于当日重新编址(192.168.0.x → 172.16.1.x),
  架构图里的服务器地址更新
- 报告数量 4 → 5
2026-09-15 17:55:53 +08:00
ylhan bb4fa3028e 方案:定稿客户端/服务器音频边界,修正下一步顺序
音频边界(§5 新增章节,从"待定稿"改为"定稿")

四个问题的决定:
- 传什么:闲置关闭,会话中持续上传(含 0.5–1s 预卷)
  闲置时服务器完全听不到房间声音;会话中上传完整音频流,
  服务端才能做可靠的"说完判定"。唤醒控制的是"是否上传",
  不是"是否处理"。
- 唤醒:本地。延迟低、断网可响应;它是注意力的门,不是传输的门。
- 打断:客户端判定。三条理由——WebRTC 的 AEC 本来就在客户端跑
  (需要播放参考),客户端麦克风信号已是去回声的;客户端知道自己
  正在播什么,"用户在此刻开口"本地可判,不需额外协议;比服务端
  判定少一个来回,更容易满足 ≤300ms。
- 断线:WebRTC 内建 ICE restart;抖动缓冲 100–200ms 待实测。
  缓冲换稳定性但直接加首音延迟,不能凭感觉设。

同时写明接口约定(上下行各传什么)、三条约束(播放参考须本地可得、
预卷缓冲、上传不等于存储)和这条边界的已知代价。

修正下一步顺序

原顺序把"N5095 WebRTC 客户端"列为第一步,但核实后发现:
代码里 pipecat 一次都没被 import 过(只在 requirements.txt 里),
引擎是独立 asyncio 由测试脚本驱动,**服务端没有任何客户端能连的入口**。
所以第一块是服务端 WebRTC 入口,客户端排第二。

新顺序列出六步并标注每步的状态与阻塞条件,明确说明:
第 2 步用合成音频只能验证传输层,**验不了 AEC 与远场识别**——
那要等真实硬件。
2026-09-15 13:05:35 +08:00
ylhan 2b6093d392 方案 v2.4 修订:按评审意见修正 8 处
评审指出的问题,逐条修正:

1. AEC 结论过早
   原文写"WebRTC 自带 AEC,因此解决 P2",这是把"框架有实现"当成了
   "链路配置正确"。补充了三个前提:麦克风采集与播放参考要正确送入、
   XVF3800 自身 AEC 路由需实测(两处 AEC 串联可能互相干扰)、
   打断判定依赖 AEC 输出质量。P2 该行改标"待验证"。

2. 音频路径旧描述
   第 157 行仍写"N5095 的 TTS",已改为完整链路:
   服务器 Qwen3-TTS → WebRTC → N5095 USB 播放端点 → XVF3800 AUX → 音箱。
   并补一条:AEC 所需播放参考必须在 N5095 本地可得,不能依赖服务器回放时钟
   (两侧时钟不同步会导致参考信号对不齐)。

3. "N5095 不跑任何推理"不准确
   方案自己安排 N5095 跑 VAD、唤醒、人脸检测,这些也是推理。
   改为"不运行 STT/TTS/LLM 等重型推理"。

4. 架构变更理由说过重
   原文称"N5095 跑不动可用的语音模型",但 P0 数据证明它能跑
   Zipformer STT(RTF 0.22)和 Piper TTS(RTF 0.36)。
   改为"N5095 能完成低配本地语音,但达不到当前的目标音质与延迟"——
   音质达标的 Qwen3-TTS 在它上面 RTF 1.86,在服务器上 0.09。

5. 客户端/服务器音频边界未定义(新增一节)
   传什么(持续音频 vs 带预卷的语音段)、唤醒在哪端、打断谁判定、
   断线抖动如何恢复——四个问题列了选项与权衡,给出当前倾向,
   并标注必须在做 WebRTC 客户端之前定稿,否则接口要返工。

6. STT 是最大功能风险
   在 §5 表格和 §10 各标出:中文真人识别率至今无可信数据,
   音频硬件到手后应优先录制本人语音算 CER,并与 SenseVoice /
   faster-whisper large-v3 对比。

7. 首音延迟需按真实链路重测
   当前 1545ms 是非流式、无 VAD、无传输、无播放缓冲的数据。
   补入必须计入分句等待、WebRTC 抖动、播放缓冲,并测 P50/P95。

8. 尾部旧状态与重复段
   §10 重写:原文还写"继续确认 Ubuntu 版本""未做实机测试",
   与 v2.4 实际进度冲突。改为已完成/下一步/风险/待办四段。
   删除 §5 中完全重复的 P0 测试说明段。

同时采纳评审给出的下一步顺序:先闭环实时语音链路
(WebRTC 客户端 → 真实音频 → AEC/打断/STT 正确率),
再推 ESP32 表情,最后摄像头/多人/电机。
2026-09-15 12:49:42 +08:00
ylhan bdd6bfb63d AI桌面陪伴助手:方案更新至 v2.4,代码剥离到独立仓库
代码剥离
- 助手本体、TTS 服务、P0 基准脚本、ESP32 固件移到 ~/workspace/ai-companion
  (独立 git 仓库),本目录只留方案、P0 报告与硬件记录
- 因此删除 esp32_smile_test.ino / esp32_touch_eyes.ino
- 同时移除已失效的 .gitignore(原本只为挡住模型体积,代码已搬走)

方案 v2.4 重大变更:架构从"单机"改为"服务器即助手、本地为客户端"
- 起因:实测 N5095 无 AVX2,跑不动可用的语音模型
- N5095 降为客户端/外设桥,只做 USB 接入、DoA、WebRTC 客户端
- 实施阶段表加进度列(P0/P1/P3/P4a 部分完成),新增三条验收要求
- 采购清单按实际到货更新,标出屏幕形态待确认(2.8寸 vs 原定1.28寸圆屏)

技术栈(实测更新)
- LLM:DeepSeek deepseek-flash(云端,必须关思考);本机 27B 已停用
- TTS:Qwen3-TTS 1.7B(服务器 3090);vits-zh-ll 因音色不满意被替换
- STT:Zipformer int8 未变,中文识别率仍是未闭合项

新增两份 P0 报告
- P0-Qwen3TTS报告.md:TTS 换型过程与实测
- P0-DeepSeek验证报告.md:四条路径验证

两份旧报告加了状态说明(结论仍有效但选型已变),
并修正了随代码移走而失效的引用与复现命令。
2026-09-15 11:08:56 +08:00
ylhan 152b919b25 添加子目录 2026-09-14 17:02:12 +08:00
ylhan 525e9f20d9 feat: 添加ESP32触摸眼睛动画及接线文档 2026-09-14 16:51:31 +08:00
ylhan 62d5acaecd docs: 完善AI桌面陪伴助手实施规划与采购方案 2026-09-12 18:21:41 +08:00
12 changed files with 2994 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
# Ubuntu 中文输入法(Fcitx5 + Rime)安装与配置
> 环境:Ubuntu 22.04,GNOME + Wayland
> 方案:Fcitx5 + Rime(朙月拼音),默认输出简体中文
## 一、卸载 IBus(避免冲突)
Ubuntu 默认使用 IBus,建议彻底移除以防止干扰:
```bash
sudo apt remove ibus
killall ibus-daemon # 确保进程终止
```
## 二、安装 Fcitx5 及 Rime 组件
```bash
sudo apt update
sudo apt install fcitx5 fcitx5-chinese-addons fcitx5-rime fcitx5-module-cloudpinyin fcitx5-config-qt
```
各组件说明:
- `fcitx5-rime`:Rime 引擎支持
- `fcitx5-chinese-addons`:中文相关扩展
- `fcitx5-module-cloudpinyin`:可选,启用云拼音(新词联想)
- `fcitx5-config-qt`:图形化配置工具(即使在 GNOME 也能用)
## 三、配置环境变量
为了让所有应用(GTK/Qt)识别 Fcitx5,需配置环境变量:
```text
vim ~/.pam_environment
```
添加以下内容:
```bash
GTK_IM_MODULE=fcitx
QT_IM_MODULE=fcitx
XMODIFIERS=@im=fcitx
```
> 💡 注意:Ubuntu 22.04 使用 GNOME + Wayland,**必须使用 `.pam_environment`**,`.profile` 或 `.bashrc` 在某些场景下会失效。
## 四、重启或重新登录
注销当前用户并重新登录,使环境变量生效。
## 五、配置输出简体中文
刚安装完 Rime,默认可能输出**繁体字**(如「妳好」)。我们需要配置默认使用简体方案。
Rime 的配置文件位于:
```bash
~/.local/share/fcitx5/rime/
```
创建或编辑:
```bash
vim ~/.local/share/fcitx5/rime/default.custom.yaml
```
填入以下内容(关键!):
```yaml
patch:
# 把简体拼音放在第一个,默认就用它
schema_list:
- schema: luna_pinyin_simp # 朙月拼音·简化字(简体)
- schema: luna_pinyin # 朙月拼音(繁体,保留备用)
# 你其他的方案,比如五笔、双拼等
```
> ⚠️ 重要提醒:
>
> - 通过 `schema_list` 设置输入方案列表,**放在第一个的方案即为默认方案**。
> - `luna_pinyin_simp` 是朙月拼音简化字版本,直接输出简体中文。
修改配置后,**必须执行 Deploy 才会生效**:
右键点击系统托盘中的 **Fcitx5 图标**(键盘或"中"字),选择 **"Deploy"**(部署),等待弹出"成功"提示。
> ✅ 部署成功后,输入 `nihao` 应显示 **「你好」**(简体)。
## 六、美化面板主题
默认面板较为朴素,可通过主题美化。
### 1. 安装 Material 主题
```bash
sudo apt install fcitx5-material-color
```
### 2. 配置主题与字体
打开 Fcitx5 配置工具:
```bash
fcitx5-config-qt
```
在图形界面中:
1. 点击左侧的 **"附加组件"**(Addons)
2. 找到 **"经典用户界面"**(Classic User Interface)
3. 点击右侧的 **"配置"** 按钮
4. 在弹出的窗口中设置:
- **主题**:选择 `Material-Blue`(或其他喜欢的主题)
- **字体**:设置为 `Noto Sans CJK SC 12`
- **垂直候选列表**:勾选(可选)
点击 **"应用"** 或 **"确定"** 保存设置。
> 其他可选主题:`Material-Red`、`Material-Green`、`Dark` 等。
重启 Fcitx5 或重新登录即可看到效果:圆角、阴影、彩色高亮,现代感十足。
### 3. 使用 GitHub 主题包(更丰富)
如果想要更接近 macOS 风格的主题,可以使用开源主题包:
- 仓库地址:<https://github.com/thep0y/fcitx5-themes-candlelight>
下载并安装:
```bash
git clone https://github.com/thep0y/fcitx5-themes-candlelight.git
cd fcitx5-themes-candlelight
./install.sh # 按照下面提示输入然后回车就能自动安装好
```
> 安装完成后,主题已经就绪,此时只需要执行一次 Deploy:
>
> - 右键点击系统托盘中的 **Fcitx5 图标**(键盘或"中"字)
> - 选择 **"Deploy"**(部署)
> - 等待弹出"成功"提示
> 💡 主题效果预览图来自知乎图床,未随笔记保留;实际效果以本地安装后为准。
## 七、常用技巧
- **切换中英文**:`Ctrl + Space`(可自定义)
- **切换简繁**:`F4`
- **翻页候选词**:`PageDown` / `+` 键
- **部署配置快捷方式**:右键 Fcitx5 图标 → Deploy
@@ -0,0 +1,499 @@
# AI 桌面陪伴助手 · 实施规划 v2.4
> 📁 **代码已与本文档分离**(2026-09-15)。本目录(Obsidian 笔记库)只保留
> 方案、P0 报告和硬件记录;所有代码在 **`~/workspace/ai-companion/`**
> (独立 git 仓库),包括:助手本体、TTS 服务、P0 基准脚本、ESP32 固件、
> `README.md`(代码说明)、`部署说明.md`(运维)——这两份也在代码仓库。
>
> 更新:2026-09-15。状态:**助手本体已在服务器上实现并跑通**(端到端测试 16/16);
> **Pipecat 实时语音链路已闭环**(见 §10 与
> `P0-Pipecat传输层实验报告.md`)——A 方案已确定:Pipecat 管传输/帧/RTVI,
> 自有引擎管会话语义;当前风险转为真实首音延迟与真人中文识别率;
> 设备侧(N5095 外设桥、ESP32)尚未开始。
> 已确认:Ubuntu 24.04;N5095 / 16G 作为**客户端**;助手本体部署在 cadadmin-server(i9-14900K / 3090);麦克风、音箱、摄像头均未购;屏幕已于 09-14 到货并点亮(2.8 寸 ILI9341 触摸屏,非原定的 1.28 寸圆屏)。
> 核心目标:听得清、回应及时、可打断、表情明确、转头自然。
> 最新外形要求:屏幕不用太大;摄像头水平可用旋转总行程至少180°,按正前方左右各90°理解,不是镜头视场角180°。
> 原方案备份:AI桌面陪伴助手方案.v1备份.md。本版指标为拟定目标,不是已测性能。
>
> **本版重大变更:架构从"单机"改为"服务器即助手、本地为客户端"。**
>
> 起因要说得准确:**N5095 并非跑不动本地语音**。实测证明它能跑
> Zipformer STT(RTF 0.22)和 Piper TTS(RTF 0.36),是一条可用的低配本地链路。
> 真正的问题是——**它达不到当前的目标音质与延迟**:音质令人满意的
> Qwen3-TTS 在 N5095 上 RTF 高达 1.86(慢于实时),而服务器上只有 0.09。
> 所以是"本地语音可用但不够好",不是"本地语音不可行"。
> 详见 `P0-语音基准报告.md`。
>
> 变更后 N5095 只做 USB 外设接入与网络桥接,**不运行 STT/TTS/LLM 等重型推理**
> (轻量推理仍在本地,见 §1)。
> 详细部署现状见代码仓库里的 `部署说明.md`,实测数据见本目录的五份 P0 报告。
## 1. 范围与待讨论事项
第一版做好中文语音、屏幕表情、按用户隔离的记忆;同一轮开发加入多人脸跟踪、轮流交互和显式身份切换。录入式人脸识别作为可关闭的试验功能,实测达标后用于身份建议;重叠说话分离、可靠声纹认人不作为首版要求。视觉问答和米家后续加入。
| 项目 | 状态/建议 | 影响 |
|---|---|---|
| **主机分工** | **服务器(cadadmin-server)跑助手本体;N5095 降为客户端/外设桥** | **v2.4 重大变更,见 §2** |
| 服务器 | i9-14900K / 32线程 / 188G / RTX 3090 24G,局域网延迟 1–2ms | 与 N5095 同局域网,1ms 级 |
| N5095 角色 | USB 麦克风阵列、USB 摄像头、DoA 读取、WebRTC 客户端、串口给 ESP32 | **不运行 STT/TTS/LLM 等重型推理**;本地仍跑 VAD、唤醒词、人脸检测等轻量推理(这些延迟敏感或依赖本地硬件,放服务器反而更慢) |
| 使用人数 | 本人使用为主,同时考虑多人扩展 | 首版基础多人,身份与会话从一开始分离 |
| 新增预算 | 服务器为复用已有机器,不算追加采购 | 音频优先,其次屏幕,再机械 |
| 操作系统、现有外设 | Ubuntu 24.04 已确认;麦克风、音箱、摄像头都没有;屏幕已到货 | 需采购音频与摄像头 |
| 使用距离 | 待确认;暂以桌面约0.5~2米作为测试设计假设 | 不能当作已确认需求或性能保证 |
| 屏幕 | **已到货:2.8 寸 ILI9341 触摸屏**(与原定 1.28 寸圆屏不符,待确认最终形态) | 见 `ESP32-S3与2.8寸SPI触摸屏接线记录.md` |
| 摄像头旋转 | 水平可用总行程至少180°,正前方左右各90° | 优先屏幕与摄像头共用一轴头部,麦克风固定底座 |
| **LLM** | **DeepSeek `deepseek-flash`(云端,关思考)**;本机 Qwen3.8-27B 已停用 | 首 token 0.8–1.2s;月费来自 LLM 与按需视觉 |
| **TTS** | **Qwen3-TTS 1.7B(服务器 3090)**,Serena 音色 + 轻快活泼指令 | RTF 0.55–0.94;首音延迟的主要来源 |
| **STT** | Zipformer 中英双语 int8(CPU)——**仍是 2023 年模型,中文识别率未测** | v2.4 未替换,遗留项 |
| 米家设备 | 到扩展阶段收集型号、地区、网关/HA情况 | 集成与采购选择 |
| 结构 | 屏幕/摄像头到货后称重,确认加工条件 | 电机、支架、电源 |
## 2. 总体架构
```text
┌─ cadadmin-server(172.16.1.101)—— 助手本体 ────────────────┐
│ 会话引擎(身份 → 记忆 → LLM → TTS) │
│ ├─ LLM:DeepSeek deepseek-flash(云端,关思考) │
│ ├─ TTS:Qwen3-TTS 1.7B(RTX 3090,独立 HTTP 服务 :18040) │
│ ├─ 传输:Pipecat SmallWebRTC :7860(实时链路已闭环) │
│ ├─ STT:Zipformer 中英宽带 int8(CPU 4 线程) │
│ ├─ 记忆:SQLite(按 owner_user_id 强制隔离) │
│ └─ 身份:手动切换 + 访客兜底 │
└────────────────────────────────────────────────────────────┘
▲ WebRTC(实验中) ▲ 手机/浏览器客户端
│ 局域网 1–2ms │
┌─ N5095 / 16G —— 客户端 / 外设桥 ───────────────────────────┐
│ USB 麦克风阵列(固定底座)──处理后音频、DoA──┐ │
│ USB 摄像头(随头部转动)──本地图像───────────┤ │
│ ├─ DoA 读取(本地,**不过网**——转头回路延迟敏感) │
│ ├─ 本地人脸检测 / 目标锁定 → 角度控制(不依赖LLM响应) │
│ ├─ 插话检测、音频编解码、WebRTC 客户端 │
│ └─ USB 串口 115200 → ESP32 │
└────────────────────────────────────────────────────────────┘
│
▼
ESP32-S3:本地表情、运动轨迹、限位、心跳超时处理
├─ SPI → 2.8寸 ILI9341 触摸屏(已到货)
└─ 控制信号 → 一轴执行器(独立合适供电支路)
```
- **分工原则:延迟敏感且硬件绑定的留在本地**(音频进出、唤醒、VAD、
插话检测、DoA、表情/动作),**算力密集的放服务器**(STT、TTS、LLM 调用、记忆)。
- Pipecat作为唯一会话管线框架。服务器侧用 WebRTC 传输(SmallWebRTC),
它**自带** AEC、抖动缓冲、丢包恢复的实现,省去了自行编写。
[传输选型](https://docs.pipecat.ai/client/concepts/choosing-a-transport)
- ⚠️ **但"WebRTC 自带 AEC"不等于 AEC 问题已解决。** 框架有实现,不代表
链路配置对了。至少要满足:
1. N5095 的 WebRTC 客户端把**麦克风采集**和**扬声器播放参考信号**都正确
送入音频处理模块——播放参考缺失或时间戳不对齐,AEC 就不会工作;
2. XVF3800 自身的 AEC 路由要实测(它自己也会做回声消除,两处 AEC
串联可能互相干扰,需要确认该用哪一个、另一个是否要关);
3. 打断检测依赖 AEC 的输出质量,AEC 不对,打断就会误触发或漏触发。
**P2 的 AEC 项保持"待验证",在真实麦克风+音箱接上并实测前不算通过。**
- 不叠加Vision Agents:它是Stream的独立框架,不是Pipecat官方扩展。人脸检测另选轻量本地模型。[项目官方仓库](https://github.com/GetStream/vision-agents)
- STT/TTS采用可替换适配器(本地/远端两种后端已实现)。LLM 为 DeepSeek
`deepseek-flash`;这是可更新别名,实施时记录实际版本并做流式/工具调用验证。
**实测必须显式关闭思考模式**,否则多轮与工具回灌一律 400。
[官方公告](https://deepseek.com/news/deepseek-v4-1-flash/) · 详见 `P0-DeepSeek验证报告.md`
- **视觉跟随在客户端做**(N5095 本地),先试640×480、5~10fps,
按CPU、温度和延迟调整,未承诺该帧率已可达到。视觉问答按请求抓拍和缩放上传,
不持续发送视频。
- CPU标称TDP不能当整机常开功耗;实际插座功率、风扇噪声、温度在原型阶段测量。
- **降级策略**:服务器或网络失效时,本地按键、静音和屏幕继续工作;
建议保留一个极小的本地 TTS 兜底(piper-x_low 仅 20MB、RTF 0.17),
用于播报"网络断了"——这是设备失联时唯一能出声的东西。
### 视频采集与框架边界
- Ubuntu由单一CameraService通过OpenCV/V4L2打开摄像头,维护带单调时间戳的最新帧;检测、身份候选和视觉问答共享采集源,不分别抢占设备。
- 本地检测以低分辨率/限帧消费最新画面,积压时丢旧帧;按需问答读取足够清晰的新帧并缩放编码。必要时从较高分辨率采集再降采样,实际USB带宽和解码负载需测试。
- Pipecat通过自定义适配器接入图像帧/取图工具。模型支持图像不等于当前Pipecat适配器已支持该模型的图像格式;P0补测纯文本流式、图片输入、工具调用、取消/超时四条路径。图像不通过时先修适配,不因此更换整套框架。[Pipecat DeepSeek服务](https://docs.pipecat.ai/api-reference/server/services/llm/deepseek)
- 图像请求带request_id、拍摄时间和会话轮次,过期帧拒绝使用,图片不无限留在对话历史中。摄像头拔出后禁用视觉,语音保持可用。
- 音频回调只做轻量读写;识别、合成和视觉推理在受控工作线程/进程执行,不能阻塞会话事件循环。各队列有限长,TTS限制预生成量。
## 3. 实施阶段与验收
阶段通过后再扩大范围,不预先给出未经验证的固定工期。
**进度列说明**:✅ 完成 · 🔶 部分完成 · ⬜ 未开始
| 阶段 | 实施内容 | 拟定完成标准 | 进度 |
|---|---|---|---|
| P0 环境与服务 | 盘点Ubuntu版本/USB;固定依赖;本地STT/TTS基准及DeepSeek请求 | 样例音频/文本先测试;设备到货后10次录放测试;记录RTF、首音延迟、CPU和内存 | 🔶 **环境盘点、STT/TTS/LLM 基准、DeepSeek 四条路径均已实测**;缺 10 次录放(无音频硬件)。**STT 中文识别率仍是未闭合项** |
| P1 基础语音 | 按键启动;流式回复;停止按钮;超时提示 | 20轮固定中文用例至少18轮完成;短问答结束到首段有声回复,目标P50≤2秒、P95≤4秒 | 🔶 **链路已跑通**(文本/音频 → STT → 记忆 → LLM → TTS → 音频,16/16 通过);缺实时音频进出、按键、20 轮用例。首音实测约 1.5s,加 VAD 后 **2.0–2.2s,卡在目标线** |
| P2 外放与表情 | AEC、打断、唤醒;听/想/说/离线/静音表情 | 指定距离音量下20次打断至少18次成功;检测到有效用户语音至停播目标≤300ms;30分钟外放无自我触发;运行2小时稳定 | ⬜ 未开始。**AEC 待验证**——WebRTC 有现成实现,但需要麦克风采集与播放参考正确送入、且 XVF3800 自身的 AEC 路由要实测(见 §2) |
| P3 多用户记忆 | 用户档案、显式切换、偏好/摘要/修改/遗忘 | 两个用户同一问题得到各自偏好;未知用户无私人记忆;重启保留、更正/删除有效 | 🔶 数据层已实现并测试(身份隔离、越权删除、级联清理、候选不参与检索共 28 项);缺"修改/遗忘"的用户入口 |
| P4 一轴转头 | 静态DoA/人脸验证,再接电机;锁定和限位 | 左/中/右各10次唤醒至少27次转向正确区域;轻微移动不频繁摆动;断联/重启不撞限位;运动时重复P2测试 | ⬜ 未开始,待摄像头与电机 |
| P4a 基础多人/身份试验 | 2~3张脸跟踪;轮流发言;可选录入式人脸识别 | 20次轮流唤醒至少18次选中正确目标;同方向/交叉/遮挡时允许拒判;未知人、身份切换不得串记忆 | 🔶 `IdentityProvider` 接口与手动切换已实现(人脸候选**不自动切换**已测试);缺人脸检测 |
| P5 扩展 | 按需视觉问答;HA接一盏灯 | 检查10次视觉请求;开关灯、离线、超时均反馈真实结果 | 🔶 工具调用链路已验证(DeepSeek 与本机 vLLM 均通过);缺米家与视觉 |
验收记录距离、背景声音、播放音量、网络、模型版本、样本数。目标用于定位瓶颈,不保证所有环境都达到。耳麦通过不能代替远场外放验收。
**v2.4 新增的验收项**:
- **首音延迟必须按部署机(服务器)测**,不能沿用开发机的数字。
同一份代码在 N5095 上首音 1980ms,在服务器上 179ms(vits 时代)。
- **TTS 与 STT 的线程配置分别测**。实测两者线程扩展方向相反:
STT 超过 2 线程急剧退化(N5095 无 AVX2,4 线程慢 3.9 倍),
TTS 是线程越多越快。不能沿用一套配置。
- **换语音模型时必须重测首音延迟**。Qwen3-TTS 比 vits-zh-ll 音质好一个档次,
但首音从 179ms 涨到 1545ms(8.6 倍)。音质与延迟在这个硬件上是此消彼长的。
P2的30分钟/2小时测试仅是初步门槛,不能证明长期稳定。阶段通过后做一次正常音量下至少8小时运行:记录误唤醒次数、内存增长、音频欠载、重连及温度。P4a把“转对人”和“认对身份”分开统计,额外报告误认、拒判、切换次数,不用总成功率掩盖串身份。测试人数不足时,多用户记忆可用模拟身份测试,人脸/发言者匹配则明确标记未实测。
## 4. 分批采购清单
> **v2.4 更新(2026-09-15)**:ESP32-S3 开发板与屏幕**已于 09-14 到货并点亮**,
> 但屏幕是 **2.8 寸 ILI9341 触摸屏(240×320)**,与原定的 1.28 寸 GC9A01 圆屏不符。
> 接线与测试程序见 `ESP32-S3与2.8寸SPI触摸屏接线记录.md`。
> **最终屏幕形态待确认**——这会影响外壳与转台结构设计,越早定越省返工。
> 下列清单已按现状调整。
本表未报价:用户预算尚未确定,具体SKU和商家现价需核价。**当前实际还缺的只有音频(麦克风阵列+音箱)和摄像头。**
### 第一批:已基本到位
| 物品 | 数量 | 状态 |
|---|---:|---|
| ESP32-S3开发板 | 1 | ✅ 已有 N16R8(带 PSRAM) |
| 屏幕 | 1 | ✅ 已有 2.8 寸 ILI9341 触摸屏(**非原定 1.28 寸圆屏**) |
| USB数据线 | 按缺口 | 按缺口补 |
| 杜邦线/面包板/绝缘固定件 | 1套 | 按缺口补;只用于逻辑低电流,不走电机大电流 |
| 瞬时按键 | 2 | 待购;会话停止、软件静音,不宣称物理断麦 |
若最终决定用 1.28 寸圆屏形态,仍需单独采购 GC9A01 模块;若接受 2.8 寸方屏,则本批已完成。
两种形态的表情素材、外壳和结构设计都不同,**先定形态再动结构**。
### 同期优先核价:音频套件
| 物品 | 数量 | 优先候选/要求 | 下单条件 |
|---|---:|---|---|
| USB麦克风阵列 | 1 | Seeed reSpeaker XVF3800 USB 4-Mic Array成品板 | 核对具体SKU、USB固件、目标系统、DoA例程、录放音通道、输出与附件;报价合适再买 |
| 有源小音箱 | 1套 | 需新购;3.5mm模拟输入,体积小,合适独立供电 | 与阵列输出匹配;初期不选只能走USB音频/蓝牙的音箱 |
| 音频线 | 按缺口 | 匹配两端的3.5mm音频线 | 查套装是否自带 |
阵列官方资料列出USB音频、DoA主机控制、可接耳机/有源音箱的AUX输出。采购成品板,不能仅凭XMOS芯片名替代。[Seeed官方文档](https://wiki.seeedstudio.com/respeaker_xvf3800_introduction/)
拟定路径(v2.4 修正:TTS 已不在 N5095):
```
服务器 Qwen3-TTS → WebRTC → N5095 → 阵列 USB 播放端点 → AUX → 有源音箱
```
AEC 需要实际播放参考;同一板卡也要验证固件/通道配置,不能假定接上即成功。[XMOS官方指南](https://www.xmos.com/documentation/XM-014888-PC/pdf/xvf3800_user_guide_v3.2.1.pdf)
**注意**:播放链路跨了服务器和客户端两侧,AEC 所需的"播放参考信号"必须在
N5095 本地可得(即从阵列 USB 播放端点或本地播放缓冲取),**不能依赖服务器侧的回放时钟**——
两侧时钟不同步,参考信号就对不齐。[详见 §2 的 AEC 说明]
不为了阵列额外购买XIAO ESP32S3,主机直接接USB即可。最终内置喇叭/功放留到结构阶段,按接口、电压、阻抗和功率匹配。
### 第二批:摄像头
- 需新购普通USB UVC单目摄像头1个,支持1080p及低分辨率模式。为早测多人/身份,可与第一批一起买;无需增加第二台摄像头。
- 优先小巧轻便、可固定、柔软线缆、有视场角资料的型号;近距离物品问答需核对最近对焦距离。
- 摄像头随头部水平旋转,可用总行程至少180°(左右各90°)。这是机械旋转范围,不要求购买180°鱼眼镜头。单摄像头只覆盖当前朝向的视野,转动可依次查看不同方向,不能同时看全180°范围。
- 首版多人按当前视野内、轮流发言验证。镜头选型兼顾多人覆盖与脸部像素,过广视角会让远处脸变小。
- 不需要双目/深度相机和ESP32-CAM。摄像头不影响P0~P3先行。
### 第三批:机械结构确定后购买
| 物品 | 为什么暂缓 | 解锁条件 |
|---|---|---|
| 舵机/步进 | 已要求可用行程≥180°,重量/重心/噪声未知,暂不锁MG996R/DS3218 | 称重后选型;额定/机械行程要有软限位余量,实测可用行程≥180° |
| 电机电源/降压 | 电压及启动/堵转电流依赖电机 | 数据表确认,峰值留余量 |
| 转台/轴承/支架/外壳 | 安装孔、重心、线缆弯曲需配合 | 原型尺寸确定 |
| 步进驱动、回零开关、编码器 | 取决于执行器及是否需要实测位置 | 电机方案确定 |
| USB Hub | 主机可能已有足够接口 | P0盘点确认不足 |
| 米家网关/新设备 | 兼容性依型号地区而异 | 现有设备验证后再定 |
当前不采购:新主机、GPU、双目相机、HC-05蓝牙、第二块视觉MCU、电池系统。
## 5. 音频与陪伴行为
### 语音方案(v2.4 已实测定稿)
STT是语音转文字,TTS是文字转语音。**v2.4 起重型语音推理(STT/TTS/LLM)全在服务器**,
N5095 只做音频进出与本地轻量推理。
准确的表述是:**N5095 能完成低配本地语音,但达不到当前的目标音质与延迟**。
实测它能跑 Zipformer STT(RTF 0.22)和 Piper TTS(RTF 0.36);而音质达标的
Qwen3-TTS 在它上面 RTF 1.86(慢于实时),在服务器上只有 0.09。见 `P0-语音基准报告.md`。
| 模块 | 采用方案 | 实测 | 备选与限制 |
|---|---|---|---|
| VAD | 本地轻量VAD | — | 单独处理唤醒和说完判断 |
| 唤醒 | sherpa-onnx中文关键词检测作为候选 | — | 单独模型/词表,用户确定称呼后调阈值;普通STT不会自动等于唤醒引擎 |
| STT | **Zipformer 中英双语 int8**(服务器 CPU 4线程) | RTF 0.03 | ⚠️ **最大功能风险**:仍是 2023 年模型,**中文真人识别率至今无可信数据**。音频硬件到手后优先录制本人语音算 CER,并与 SenseVoice / faster-whisper large-v3 对比(已下载待测) |
| TTS | **Qwen3-TTS 1.7B-CustomVoice**(服务器 3090,独立 HTTP 服务) | RTF 0.55–0.94 | 音色 Serena + 指令"用轻快活泼的语气说"(用户试听选定)。备选:vits-zh-ll(RTF 0.09 但音色不满意)、CosyVoice 2/3(未测)。**首音延迟需按真实链路重测**,见 §10 |
| LLM | **DeepSeek `deepseek-flash`**(云端,关思考) | 首 token 0.8–1.2s | 本机 Qwen3.8-27B 的 vLLM 已停用——GPU 让给 TTS。可改 `AI_LLM_PRIMARY=local` 切回,但切回后 GPU 不够跑 Qwen3-TTS |
[sherpa-onnx官方功能](https://k2-fsa.github.io/sherpa/intro.html)、[SenseVoice模型说明](https://k2-fsa.github.io/sherpa/onnx/sense-voice/pretrained.html)、[中文VITS模型](https://k2-fsa.github.io/sherpa/onnx/tts/pretrained_models/vits.html)。以上说明模型/运行时支持,不构成性能保证。
**音质与延迟的取舍(v2.4 的核心权衡)**:同一台服务器上
| 配置 | 首音延迟 | 音质 |
|---|---|---|
| 本地 Qwen3.8-27B + vits-zh-ll | **179 ms** | 用户不满意 |
| DeepSeek + Qwen3-TTS | **1545 ms** | 用户试听选定 |
用约 1.4 秒的首音延迟换音质。这个取舍是可逆的(改配置即可),
但**在 24GB 显存下两者不可兼得**。后续若装成 flash-attn 或实现真流式,
延迟可望收回一部分——详见 `P0-Qwen3TTS报告.md`。
P0测试10~20条不同长度中文,覆盖数字、日期、中英混读。记录STT识别错误、语音结束后等待时间、TTS首段耗时和RTF(生成/处理耗时除以音频时长,低于1表示平均快于实时)。音频与视觉并发时重测,限制推理线程和视觉帧率,避免CPU争抢。
**v2.4 补充:STT 与 TTS 的线程配置必须分别调**——实测两者线程扩展方向相反。
TTS按语义短句合成并播放,不把整段一次合成称为原生流式;分句仍要测首音等待、接句停顿及取消响应。本地效果不达标时先调整模型/线程/分句,再讨论是否开启云端后备,不自动发送音频到第三方。
- 本地唤醒引擎保持工作;闲置时暂停完整识别、降低视觉频率。保留短时内存音频预卷(初始0.5~1秒)避免唤醒后吞掉指令开头,静音时清除缓冲。[关键词检测官方文档](https://k2-fsa.github.io/sherpa/onnx/kws/index.html)
- 活跃会话内允许免唤醒接续,初始闲置超时20秒后回到待机,参数可调。多人时其他人的普通谈话不应自动抢占;以唤醒/显式切换为主要入口。
- 记录阵列实际采样率、通道和采样格式,STT/TTS边界显式重采样;避免把原始麦克风/回放参考通道当成处理后语音。Ubuntu固定设备标识和录放音路由,不依赖重启后会变化的默认声卡序号。
- DeepSeek输出区分最终回答、思考内容、工具调用与工具结果,只将面向用户的回答交给TTS;数字/日期/单位和Markdown做轻量朗读归一化。
- P1按键启动,P2再加唤醒。语音活动检测、说完判断、唤醒是不同环节。
- 打断时取消旧回复生成、TTS和待播缓冲,丢弃旧轮次迟到结果,不能暂停后继续放旧回复。
- 分别测安静、设备外放、背景音乐、风扇噪声;P4加入运动噪声,装壳后重测。
- 默认简短自然回复;等待时及时更新表情,长回复按语义分段播放。
- 明确待机、在听、思考、说话、静音、离线状态。专注/夜间默认不主动开口,主动问候后续再加。
- 网络失败给简短提示,本地按键、静音和屏幕继续工作,不反复播旧回复。
- 麦克风、音箱、电机尽量机械隔离;软件静音和真实物理断麦需明确区分。
### 客户端/服务器的音频边界(v2.4 定稿)
**决定日期:2026-09-15。这条边界决定 WebRTC 客户端的接口形态、
首音延迟预算和隐私边界,先定下来再写代码。**
| 问题 | 决定 | 理由 |
|---|---|---|
| **传什么** | **闲置关闭,会话中持续上传**(含 0.5–1s 预卷) | 闲置时服务器**完全听不到**房间声音(隐私);会话中上传完整音频流,服务端才能做可靠的"说完判定"(方案 §5 要求)。折中方案见下 |
| **唤醒** | **本地**(N5095) | 延迟低、断网也能响应;唤醒词模型轻量,N5095 跑得动。且它是"**注意力**的门",不是"音频传输"的门 |
| **打断判定** | **客户端** | 三条理由:① WebRTC 的 AEC 本来就在客户端跑(需要播放参考),客户端的麦克风信号已是去过回声的;② 客户端**知道自己正在播什么**——"用户在助手说话时开口"本地就能判,不需要额外协议;③ 比服务端判定少一个网络来回,更容易满足 ≤300ms |
| **断线/抖动** | WebRTC 内建 ICE restart 重连;抖动缓冲初始 **100–200ms**,实测调整 | 缓冲换稳定性,但**直接加首音延迟**,不能凭感觉设,必须实测 |
#### 状态机
```
待机(不上传任何音频)
│
│ 本地唤醒词命中
▼
会话中(持续上传音频 + 控制信号)
│
│ 闲置超时 20s(可调)
▼
待机(停止上传)
```
**关键点:唤醒控制的是"是否上传",不是"是否处理"。**
闲置时麦克风数据只进本地唤醒引擎,不出网。
#### 接口约定
**N5095 → 服务器**
- 音频流:16kHz 单声道(WebRTC 内部用 Opus 编码)
- `wake` 信号:本地唤醒词命中,会话开始
- `barge_in` 信号:用户在助手播放期间开口,服务端据此取消当前生成
- `identity_hint`:本地人脸候选(**仅建议,不自动切身份**,见 §6)
**服务器 → N5095**
- 音频流:TTS 合成结果(Qwen3-TTS 输出 24kHz,需重采样到播放端)
- 状态事件:`listening` / `thinking` / `speaking` / `idle` —— 客户端据此渲染表情并判断"是否正在说话"
#### 三条必须遵守的约束
1. **播放参考信号必须在 N5095 本地可得**。AEC 需要它,而服务器与客户端的
回放时钟不同步,不能依赖服务器侧的时间戳。从阵列 USB 播放端点或
本地播放缓冲取。
2. **预卷缓冲 0.5–1s**。唤醒/打断判定完成到开始上传之间有延迟,
不留预卷会吞掉用户指令的开头。静音时清除缓冲。
3. **会话中持续上传 ≠ 持续存储。** 服务端处理完即丢弃,
默认不落盘(方案 §8:"默认不存原始音视频")。
#### 这条边界的已知代价
- 会话中上传的是**连续音频**,包括用户的停顿、思考声和周围环境音。
服务器处理这些数据,但不存储。
- 客户端 VAD/唤醒的**误判会切掉话头**——这是"闲置不上传"的固有代价,
靠预卷缓冲缓解,不能消除。
- 抖动缓冲直接计入首音延迟。**做 WebRTC 客户端时必须一并测**,
否则首音延迟数字不完整(见 §10)。
## 6. 朝向与机械
DoA是声源来向,不代表身份或距离。阵列固定底座,摄像头随头部转动。
状态:待机 → 有效唤醒 → 可靠DoA粗转 → 匹配人脸 → 锁定 → 丢失短暂保持 → 超时待机。多人先按轮流发言处理;同方向多人或重叠说话时保持目标/请求轮流,不凭DoA猜身份。
### 多人能力与避免返工
- 基础多人:同画面维护多个临时track_id,记录位置和最后出现时间;用带时间戳的DoA与人脸位置关联当前发言目标,设置切换滞后。走位/遮挡后track_id可能变化,不能充当永久身份。
- 读取DoA时核对固件、波束选择和单位。XVF3800可返回多个波束角度,它们不是“几个人的身份/独立语音”;不要直接把第一个角度当发言者。优先用与当前处理后音频对应的选中波束,查不到可靠对应时允许拒判。[官方主机控制代码](https://github.com/respeaker/reSpeaker_XVF3800_USB_4MIC_ARRAY/blob/master/python_control/xvf_host.py)
- 首版可靠身份入口:手动/对话确认切换到已建档的user_id;无法确认时使用unknown/guest。看到熟人不等于确认他正在说话。
- 显式报名字用于个性化档案选择,不是身份认证,不能据此解锁敏感信息或权限。信任等级与user_id分开,未来增加认证时不改普通偏好存储。
- 可选人脸试验:OpenCV YuNet检测+SFace特征作为候选,需用户主动录入、相似度阈值和未知人拒判。低频识别,平时跟踪;不用于门锁/支付或敏感身份认证。[OpenCV官方教程](https://docs.opencv.org/5.0/tutorials/dnn/dnn_face/dnn_face.html)
- 注册特征本地保存,可查看和删除;识别不确定时不读取/写入私人记忆。群体会话默认仅共享明确公开的上下文,不主动播报私密记录。
- 预留IdentityProvider接口:输入时间戳、track_id、候选身份、置信度与来源;输出确认的user_id或unknown。后续声纹模块可接入此接口,不改记忆表结构。
- 声纹认人、声音与人脸可靠绑定、重叠语音分离属于更高难度阶段,不承诺通过增加一块阵列即可解决。第一版不做全场多人同时自由插话。
- 调试候选:3~5°死区、丢失保持1~2秒;速度/加速度实机调节。
- 坐标约定:从设备视角向右为正。标定阵列零点、摄像头中心、电机零点;统一DoA单位和方向。
- 人脸像素位置经相机内参/标定换算成相对角度,再加头部角度估计,不能把像素直接当绝对角度。
- 普通位置舵机先做平滑目标角度,不默认再加内环PID;无反馈时只有估计位置。
- 摄像头与麦克风有位置偏移,近距离DoA和画面方位不完全重合;先粗转,再以视觉纠偏。运动过程中按时间戳使用对应角度估计,避免拿旧图配新角度。无反馈舵机的首版身份匹配优先在稳定停转后进行。
- 步进需独立驱动器、明确STEP/DIR等接口,补回零/失步/软限位。
- 只做左右一轴,用户要求摄像头可用总行程至少180°;初始工作范围设为[-90°, +90°],屏幕与摄像头共用转动头部,麦克风固定底座。不要求连续360°旋转。
- 执行器实际机械行程须大于工作范围,以留软/硬限位余量;采购候选可考察标称270°的位置舵机,但型号、真实行程及噪声仍待核对。不能把普通标称180°舵机两端硬顶当作已满足要求,也不选无角度定位反馈的连续旋转舵机来直接代替位置舵机。
- 转轴/轴承承担结构负载;USB摄像头线、屏幕/MCU线缆在左右端点均要有弯曲余量,避免拉扯插头。有限180°往返先用柔软线缆和固定应力释放,不把滑环列为必需采购件。
- P4增加行程验收:实测摄像头左右端点至少相差180°,端点不碰撞、不拉线;低速往返100次检查线缆、USB掉线、抖动和供电。上电及重连仍须受限速和限位约束。
- MCU独立限位和断联停止;失联先停止更新轨迹,保持/卸力按结构实测,避免盲目断电造成坠落。
- 电机独立合适供电支路并正确共地,选合适线径/端子/去耦,不由ESP32板载电源或面包板供大电流。
## 7. 串口协议与恢复
先用UTF-8单行JSON、换行分帧,字符串换行由JSON转义。115200起步。Linux可能ttyUSB/ttyACM,Windows为COM,使用可配置稳定设备标识。
```json
{"v":1,"seq":101,"type":"expression","state":"listening"}
{"v":1,"seq":102,"type":"motion","yaw_deg":20,"max_speed_deg_s":25}
{"v":1,"seq":102,"type":"ack","status":"accepted"}
```
- 表情和运动分开;转向目标最新值优先,不排队执行过时动作。
- 定义版本、字段范围、最大帧长(初始1KB)、坏帧丢弃和超时恢复。
- seq关联请求与确认;重试不能重复动作。accepted仅表示收到,无传感器不能报告实测已到位。
- 心跳500ms、失联2秒触发本地停止作为初始候选参数,实测后定稿。
- MCU重启发hello/能力信息,重连恢复当前状态,不回放历史运动。
- 字体和表情素材本地存储,串口不传全屏视频;WiFi/MQTT旁路暂不做。
## 8. 记忆、费用与数据
P3用SQLite,Mem0和向量库不是前置条件。从建表起就包含users、sessions、memories;记忆带owner_user_id、scope(private/shared)、来源会话/轮次、时间和确认状态。查询和写入均强制身份过滤,未知身份只用访客临时上下文。视觉track_id、会话session_id、永久user_id分开。
| 类型 | 保存策略 |
|---|---|
| 明确偏好 | 结构化键值,来源、确认状态、更新时间,新值替换旧值 |
| 临时事项 | 带时间与过期规则;记住事项不等于实现提醒调度 |
| 对话摘要 | 限长、按需检索、可清除,不默认永久保存逐字稿 |
- 支持记住/修改/忘掉/查看;模型推测不当作确认事实。
- 默认显式切换身份;人脸只提供候选,经当前发言者关联及确认后才启用对应记忆。切换身份清理上一人的私有检索上下文;异步任务绑定创建时的user_id/session_id,避免迟到结果写入新用户。
- 删除同时覆盖会话上下文、摘要和未来索引,防止删库后仍引用。
- 回复后异步写记忆,检索有超时;记忆失败不阻塞对话。
- 真实使用中检索不足再评估Mem0 + Chroma,支持不代表无需配置测试。[Mem0官方文档](https://docs.mem0.ai/components/vectordbs/dbs/chroma)
- 本地STT/TTS记录耗时与资源,不产生语音API调用费;统计LLM/按需视觉及未来云端后备费用,按实际日用量估月费并讨论上限。
- 默认不存原始音视频;视觉按需上传。密钥保存在本机配置,不写日志;提供静音和相机关闭入口。
## 9. 米家接入
P5通过Home Assistant统一调用。按实际设备比较官方Xiaomi Home和Xiaomi Miot Auto,核实地区、网关、本地/云端路径,不提前采购网关。[小米官方集成](https://github.com/XiaoMi/ha_xiaomi_home)
先接一盏现有灯:意图 → 工具/实体白名单 → 参数校验 → HA执行 → 状态确认 → 回复。请求受理和设备已改变分开描述,超时明确未确认。高风险设备不默认加入。
工具执行与语音回复生命周期分开:用户打断播报不意味着已发出的设备操作被撤销。每次动作保留执行ID和状态;超时先查状态,不盲目重试;优先使用“设为开/关”而非“切换”以降低重复执行风险。摄像头中的文字和检索记忆作为数据,不能自行扩大工具权限。
## 10. 运行维护与当前下一步
### 已完成(截至 2026-09-16)
- Ubuntu 24.04 已确认,USB 接口已盘点;依赖版本固定在代码仓库的 `requirements.txt`
- 助手本体在服务器上实现并跑通,端到端测试 16/16(**用文件音频,非实时链路**)
- STT/TTS/LLM 三方基准实测;DeepSeek 四条路径验证
- 记忆与身份的数据层实现并测试(88 项断言)
- 代码剥离为独立仓库 `~/workspace/ai-companion/`
- 网页文字与语音对话、助手文字显示、DeepSeek Function Calling + 私有 SearXNG 已部署
- 姓名档案跨连接恢复已修复;内部 user_id 与用户称呼隔离
### 下一步顺序(按风险从高到低)
评审建议的思路——**先把风险最大的实时语音链路闭环**——认可并采纳。
服务端 WebRTC 入口和客户端骨架现已完成。2026-09-16 通过 SSH 隧道打开
Pipecat Playground 做了真人浏览器对话,麦克风 → STT → LLM → TTS → 浏览器播放
正常。当前仍是 P1 调试形态:连接后持续上传并响应有效语音;正式的本地唤醒、
待机不上传和 20 秒连续对话窗口留在 P2,本轮按决定暂不实现。
| # | 步骤 | 状态 | 能验证什么 / 不能验证什么 |
|---|---|---|---|
| 0 | **音频边界定稿** | ✅ 2026-09-15 完成(见 §5) | 决定了客户端接口形态 |
| 1 | **服务端 WebRTC 入口 + 客户端骨架** | ✅ **实时链路已闭环**(见 `P0-Pipecat传输层实验报告.md`) | 已验证:信令、ICE、data channel、帧路由、STT→LLM→TTS、文件与浏览器真人非静音回复 |
| 1b | **查清帧路由根因 → 定 A/B 方案** | ✅ **根因已定位,采用 A 方案** | NLTK 运行时下载导致 setup timeout;不是模型不兼容。网页 RTVI 保留 |
| 1c | **Agent 与通用记忆工具化** | 🟡 **已选 Pipecat Flows,待兼容性 spike** | 保留现有实时链路与 MemoryStore;先文字、后语音,legacy 可回滚。见 `P1-Pipecat Flows Agent化与记忆改造交接.md` |
| 2 | **N5095 客户端骨架** | 🔶 雏形已有(`client/test_client.py`) | 非 trickle ICE、data channel 心跳、文件音频实时发送、回复录音都已实现。**不做 AEC/播客参考/打断**——那些要真实声卡 |
| 3 | **接入真实麦克风与音箱** | 阻塞:硬件未购;网页仅验证普通麦克风对话 | 唯一能完整验证阵列 AEC、打断、本地唤醒的一步,也是 P2 的全部内容 |
| 4 | **STT 中文识别正确率** | 阻塞:需录音 | 见下"最大功能风险" |
| 5 | ESP32 表情串口 | 硬件已到货,可独立推进 | 串口协议、表情渲染 |
| 6 | 摄像头、多人跟踪、电机 | 阻塞:硬件未购 | — |
### 第 1 步的实验结论(2026-09-15)
**实时链路已闭环,A 方案成立。** 原 `process_frame` 为 0 的根因是 Pipecat
setup 阶段预热 NLTK `punkt_tab`,首次下载阻塞后触发 20 秒 timeout;worker 尚未
发送自动 StartFrame,并非 Pipeline 接线或自定义处理器模型不兼容。
修复后自动 StartFrame、250+ 个输入音频帧均正常经过 `EngineProcessor`,完整
文件音频 → STT → DeepSeek → Qwen3-TTS → WebRTC 返回非静音语音。保留 RTVI
和 `/client/` 网页入口,同时继续支持自写 aiortc/N5095 客户端。
实验累计挖出 **13 个集成坑**(客户端需发 ping、offer 无媒体则不收集
ICE 候选、Playground 需要 `/start`、`run()` 要 WorkerParams、后台异常被静默吞掉等),
每个都表现为"什么都没发生",详见实验报告。
**第 2 步的边界要说清楚**:用合成音频能验证传输层,**验不了 AEC 和远场识别**——
那些必须等真实麦克风和音箱。但传输层能提前跑通,硬件一到就能直接接,
不用同时调两件事。
**理由**:语音链路是全局瓶颈。它若走不通,后面的表情、转头做得再好也没用;
而它若走通,其余部分都是可分解的独立工作。
### Agent、记忆与打断决策(2026-09-16)
当前系统是固定实时对话流水线加一个最多两轮的 `web_search` 工具循环,不是通用
Agent。框架对比后决定渐进引入 **Pipecat Flows**,不整体迁移 OpenClaw、Agno、
LangGraph、LiveKit Agents 或 smolagents。原因是现有 Pipecat/WebRTC 链路已经闭环,
Flows 能提供状态、函数注册和对话节点,而身份、隐私、记忆与低延迟策略仍保留在
项目内。
必须先验证接口差异:Flows 期待 Pipecat `LLMService + context aggregator`,当前
DeepSeek fallback、历史、STT/TTS 和工具循环都在自有 `ConversationEngine` 中。
因此先做独立 spike,再以 `legacy|flows` feature flag 渐进迁移,禁止直接替换线上
路径。完整实施范围、测试和回滚要求见 [[P1-Pipecat Flows Agent化与记忆改造交接]]。
当前通用长期记忆尚未闭环:姓名是同步 confirmed 档案;“记住/我喜欢/我习惯”等
规则仍统一写 candidate,默认检索排除,也没有确认晋升入口。当前打断也只是能量 VAD
切分话段加任务取消能力;助手说话时检测到用户声音只记日志,尚未同时取消生成并清空
客户端播放缓冲。这两项是 Flows 改造后的核心验收,不得写成“已完成”。
### 最大功能风险:STT 中文识别率
Zipformer 的速度没问题(服务器 RTF 0.03),但**中文真人识别率至今没有可信数据**——
自带的 4 段测试音频没有参考文本,而 TTS→STT 往返法在这个模型上不成立
(合成音频会触发大量重复字,真人录音却正常)。
音频硬件到手后应优先做:**录一组本人语音**,覆盖普通话、数字、日期、中英混读、
以及真实桌面距离(0.5–2m)与背景噪声,计算 CER,并**同时与 SenseVoice、
faster-whisper large-v3 对比**(两者已下载待测)。这是 P0 唯一未闭合的功能项。
### 首音延迟必须按真实链路重测
当前 1545ms 是**非流式、无 VAD、无网络传输、无播放缓冲**的链路数据。
真实对话还要加上:分句等待、WebRTC 传输抖动、N5095 侧播放缓冲。
**必须测 P50/P95,不能只看 RTF。** 加 VAD 后已处在 2 秒目标边缘,
这些额外开销很可能把它推过线。
### 运维待办
- **没有常驻服务**:TTS 服务和助手本体都没有 systemd 开机自启
(方案要求 P2 后配置)。服务器重启后需手动拉起
- 日志保存耗时、错误、费用、连接事件并轮转,不默认保存完整私人对话
- P2 后配置异常重启、音频/串口重连,测试拔插、断网、主机重启恢复
- 记录板卡 SKU、固件、通道、引脚及接线图(ESP32 部分已有记录)
- 模型文件授权与运行时授权分别记录(Qwen3-TTS 为 Apache-2.0;
Matcha 用的 Baker 数据集**标注仅限非商用**,若将来产品化需换音色)
### 仍未确认
- **屏幕最终形态**:2.8 寸方屏 vs 原定 1.28 寸圆屏,影响外壳与转台设计
- **使用距离**:暂按 0.5–2m 设计,未实测确认
- 音频套件与摄像头的具体 SKU 与核价
@@ -0,0 +1,126 @@
# ESP32-S3 与 2.8 英寸 SPI 触摸屏接线记录
更新:2026-09-14。
## 设备与当前状态
- 主控:ESP32-S3 N16R8 开发板,已焊排针。
- 屏幕:2.8英寸、240×320、SPI TFT,带触摸和SD卡座。
- 显示驱动:当前程序按ILI9341配置,用户提供的型号尚未通过芯片读取确认。
- 触摸驱动:当前按XPT2046兼容控制器配置;具体芯片型号尚未独立确认。
- 用户已反馈屏幕接好并点亮、触摸线已接好;动态眼睛程序将X/Y同时翻转后,用户确认点击与视线方向正确。
- SD卡功能暂不连接、不初始化。
## 接线表
GPIO编号指开发板丝印数字,不是排针从一端数起的序号。插拔杜邦线前先断开USB供电。
### 显示部分
| 屏幕标注 | ESP32-S3 | 说明 |
|---|---|---|
| VCC | 3V3(此前试接方案) | 用户反馈已亮,但未单独确认最终电源接法;按实物补核,不据此改接5V |
| GND | GND | 公共地 |
| CS | GPIO10 | 显示片选,低有效 |
| RESET | GPIO8 | 显示复位,由程序控制 |
| DC | GPIO9 | 显示数据/命令选择 |
| SDI〈MOSI〉 | GPIO11 | 主控向屏幕发送数据 |
| SCK | GPIO12 | SPI时钟 |
| LED | ESP32-S3 的 3V3(用户已确认) | 直接接3.3V,背光常亮;不接GPIO,当前程序不控制背光亮度或开关 |
| SDO〈MISO〉 | 不接 | 暂不读取显示芯片寄存器或像素,不影响写入图像 |
### 触摸部分
| 屏幕标注 | ESP32-S3 | 说明 |
|---|---|---|
| T_CLK | GPIO12 | 与显示SCK共用 |
| T_DIN | GPIO11 | 与显示SDI/MOSI共用 |
| T_DO | GPIO13 | 触摸数据返回,即MISO |
| T_CS | GPIO7 | 触摸独立片选,不能与显示CS合并 |
| T_IRQ | GPIO6 | 触摸通知;现有程序以轮询读取,暂不依赖中断 |
### 共用连线示意
```text
ESP32-S3 GPIO12 ─┬─ 屏幕 SCK
└─ 触摸 T_CLK
ESP32-S3 GPIO11 ─┬─ 屏幕 SDI / MOSI
└─ 触摸 T_DIN
ESP32-S3 GPIO13 ─── 触摸 T_DO
ESP32-S3 GPIO10 ─── 屏幕 CS
ESP32-S3 GPIO7 ─── 触摸 T_CS
ESP32-S3 GPIO9 ─── 屏幕 DC
ESP32-S3 GPIO8 ─── 屏幕 RESET
ESP32-S3 GPIO6 ─── 触摸 T_IRQ
ESP32-S3 GND ─── 屏幕 GND
```
GPIO11、GPIO12各需要分接两根线,可通过面包板或分线连接。触摸T_DO与显示SDO是不同引脚,目前只连接T_DO。SD卡接口所有信号保持不接,测试时不插SD卡。
## 软件配置
Arduino IDE开发板:`ESP32S3 Dev Module`(安装Espressif Systems提供的esp32开发板包)。
库管理器安装:
- Adafruit ILI9341
- Adafruit GFX Library,以及安装提示中的依赖
- XPT2046_Touchscreen,作者Paul Stoffregen
与接线对应的关键配置:
```cpp
Adafruit_ILI9341 tft(&SPI, 9, 10, 8); // DC, CS, RESET
XPT2046_Touchscreen touch(7); // T_CS;采用轮询
// 初始化总线前,两个片选均设为输出高电平。
pinMode(10, OUTPUT);
digitalWrite(10, HIGH);
pinMode(7, OUTPUT);
digitalWrite(7, HIGH);
pinMode(6, INPUT_PULLUP);
SPI.begin(12, 13, 11, 10); // SCK, MISO, MOSI, SS
tft.begin(10000000); // 显示SPI初始10MHz
tft.setRotation(1); // 横屏320×240
touch.begin(SPI);
touch.setRotation(1);
```
## 测试程序
### 笑脸与触摸基础测试
文件:`~/workspace/ai-companion/esp32_smile_test/esp32_smile_test.ino`(在代码仓库,不在本笔记库)。
- 启动切换红、绿、蓝,再显示笑脸。
- 按下屏幕时笑脸在黄色/青色之间切换,显示触摸次数。
- 串口115200输出原始坐标、压力及IRQ电平;原始坐标不是屏幕像素坐标。
### 动态眼睛测试
文件:`~/workspace/ai-companion/esp32_touch_eyes/esp32_touch_eyes.ino`(在代码仓库)。
- 已取消开机三点校准,直接显示眼睛,使用代码顶部的默认触摸范围。
- 点击时眨眼,视线朝向触摸位置;按住移动可跟随。
- 最后一次有效触摸后3秒,视线平滑回到正前方。
- 连续15秒没有触摸后,随机眨眼、向周围看,并间歇回正;触摸立即接管。当前“无人互动”仅按触摸计时,不代表摄像头检测到无人。
- 根据用户实测上下左右均反向,已设TOUCH_FLIP_X=true、TOUCH_FLIP_Y=true,TOUCH_SWAP_XY仍为false;重新上传后用户确认方向正确。边缘误差调整TOUCH_MIN/MAX_X/Y。IDLE_AFTER_MS控制闲置等待时间。
两个程序分别打开、分别上传,不将两个ino复制到同一草图目录。动态眼睛程序已获用户实机反馈:触摸与视线方向正确;长期运行及完整功能验收尚未记录。
## 排查提示
- 背光亮不代表显示初始化成功;白屏时检查ILI9341型号、CS/DC/RESET和SPI连线。
- `XPT2046_Touchscreen.h: No such file or directory`表示缺库,先安装触摸库。
- 显示正常但触摸无响应:检查T_DO→GPIO13、T_CS→GPIO7,以及共用的T_CLK/T_DIN。
- 触摸方向不对:调整动态眼睛程序顶部的轴交换/翻转和范围参数,更改屏幕旋转方向后需重新检查映射。
- 当前不要将触摸信号接5V,也不要用显示GPIO给电机供电。
## 参考与适用边界
同类[MSP2807模块接口说明](https://www.lcdwiki.com/2.8inch_SPI_Module_ILI9341_SKU:MSP2807)与实物照片的引脚名称相符,但不能由外观证明具体板卡版本完全一致。用户已确认LED直接接ESP32-S3的3V3,背光常亮;VCC的最终接法和触摸芯片型号仍需单独核对,不能由LED接法推定。
本记录仅适用于当前2.8英寸模块,不能直接套用到此前的GC9A01小圆屏。
@@ -0,0 +1,113 @@
# P0 验证报告 · DeepSeek API
日期:2026-09-14。机器:N5095 / Ubuntu 24.04.3 / Python 3.13.5。
脚本:`~/workspace/ai-companion/p0/verify_deepseek.py`。结果:**11/11 通过**。
复现:
```bash
set -a; . ~/.config/ai_assistant/env; set +a
cd ~/workspace/ai-companion && python3 p0/verify_deepseek.py
```
密钥存放:`~/.config/ai_assistant/env`(权限 600,不在项目目录内,不进版本管理)。
## 结论摘要
| # | 项目 | 结果 |
|---|---|---|
| 0 | 模型清单 | 仅 `deepseek-flash`、`deepseek-v4-pro` |
| 1a | 纯文本流式 | 可用 |
| 1b | 思考内容分离 | on 模式有 `reasoning_content`;off 模式无 |
| 1c | 首 token 延迟 | **P50 = 786ms,P95 = 1100ms**(N=8) |
| 2a | 流式工具调用 | 可用,参数 JSON 正确 |
| 2b | 工具结果回灌 | 可用 |
| 2c | 思考模式约束 | 开启时**必须回传 `reasoning_content`** |
| 3a | **图片输入** | **可用**,正确读出图中数字 |
| 4a | 中途断开(打断) | 无异常 |
| 4b | 超时 | 正确抛出 `TimeoutError` |
| 4c | 错误模型名 | HTTP 400,可作启动自检 |
## 逐条说明
### 1. 模型名:方案文档正确,Pipecat 默认值错误
`GET /v1/models` 实际返回:
```json
[{"id": "deepseek-flash"}, {"id": "deepseek-v4-pro"}]
```
- 方案文档 §2 记的 `deepseek-flash` **正确**。
- **Pipecat 的 `DeepSeekLLMService` 默认模型是 `deepseek-v4-flash`,该模型不存在**,直接用会 400。
实例化时必须显式覆盖为 `deepseek-flash`。
### 2. 思考模式必须显式关闭(重要)
`deepseek-flash` 默认开启思考模式。此时 API 要求**把上一轮 assistant 消息里的
`reasoning_content` 原样回传**,否则任何多轮对话与工具回灌都返回:
```
400 invalid_request_error: The `reasoning_content` in the thinking mode must be passed back to the API.
```
实测三种手工构造 assistant 消息的写法(`content=None` / `content=""` / 不带 tools)**全部 400**;
只有回传服务端返回的原始 assistant 消息才通过。
**影响**:多数框架的上下文层按 OpenAI 格式存储消息,不保留 `reasoning_content`——
一旦开启思考模式,Pipecat 的上下文管理与工具循环会持续踩这个坑。
**决定**:会话主路径传 `"thinking": {"type": "disabled"}`。两条理由:
1. 彻底消除 `reasoning_content` 回传要求,上下文层可安全裁剪消息;
2. 开启时**第一个流式 chunk 是 `reasoning_content` 而非 `content`**,若直接送 TTS 会念出思考过程。
关闭后两者合一,无需分流。
Pipecat 该服务的默认值恰好是 `DeepSeekThinkingConfig(type="disabled")`,与本结论一致。
若将来要开思考以提升回答质量,必须同时改造上下文层保证 `reasoning_content` 不丢。
### 3. 首 token 延迟
N=8,短问答,`thinking=disabled`:**P50 = 786ms,P95 = 1100ms**(min 515 / max 1100)。
对照方案 P1 验收目标"短问答结束到首段有声回复 P50≤2 秒、P95≤4 秒",
LLM 占约 0.8 秒,**余下约 1.2 秒预算**留给 STT 收尾判定 + TTS 首段合成。这个余量是否够,
取决于本地 TTS 的 RTF(待测,见下)。
注:thinking 开关对首包时间影响不大(约 700ms vs 718ms),但关闭后总耗时更短,因为不再生成思考 token。
### 4. 图片输入可用(P5 的意外好消息)
`deepseek-flash` 接受 `image_url` 的 base64 data URI,正确读出图中文字("7391")。
方案 §P5 的视觉问答不需要另找模型,按需抓拍 → 缩放 → base64 上传即可。
**未测**:视觉请求的 TTFT(预估显著长于纯文本)、大图 token 消耗、并发下的表现。
按需视觉是按次计费项,接 P5 时需单独测成本。
### 5. 工具调用链路完整
流式工具调用可用,`finish_reason="tool_calls"`,参数 JSON 正确。
回灌工具结果后模型能生成自然语言确认("客厅的灯已经打开了")。
这是 P5 米家控制的基础链路,已验证通畅。
**注意**:回灌时 `tool_call_id` 必须用服务端返回的真实值,assistant 消息必须用服务端返回的原始对象,不能手工构造。
### 6. 打断与超时
- **打断**(收到若干 chunk 后主动 close):无异常抛出,连接正常释放。对应方案"打断时取消旧回复生成"的客户端能力成立。
- **超时**:非流式长生成 + 2 秒预算,正确抛出 `TimeoutError`。
- 流式下 socket 超时不会因首包快而触发——**"打断"和"超时"必须分开实现检测**,不能只做一种。
### 7. 计费与缓存
`usage` 返回 `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens`,实测命中 256/398。
系统提示词固定可吃到缓存,对月费有利。方案 §8 要求的费用统计应记录这两个字段。
## 遗留与未验证
- 视觉请求的延迟与 token 成本未测。
- 长上下文(多轮 + 记忆注入)下的延迟退化未测。
- 并发请求未测(多用户场景下的表现)。
- 本次仅验证 API 链路,**未接入 Pipecat、未写任何管线代码**。
- 本机 `github.com` / `huggingface.co` 直连不通,需走本地 mihomo 代理 `127.0.0.1:7890`
或国内镜像。DeepSeek API 直连正常,不需要代理。
@@ -0,0 +1,166 @@
# P0 实验报告 · Pipecat 传输层
日期:2026-09-15。状态:**实时语音链路已闭环;A 方案验证通过。**
代码:`~/workspace/ai-companion/services/bot_server.py`(服务端)、
`client/test_client.py`(自写 aiortc 客户端)
## 一、为什么做这个实验
方案文档写"Pipecat 作为唯一会话管线框架",但核实后发现**它一行都没被用上**
(只在 `requirements.txt` 里声明,代码里零引用)。助手引擎是独立 asyncio,
由测试脚本用文件音频驱动。
于是有个待定的架构问题:**Pipecat 该用多深?**
- **A 方案**:Pipecat 只做传输与帧模型,会话语义归自有引擎(一个 FrameProcessor 吞掉整条管线)
- **B 方案**:按 Pipecat 的 service 模型重构,STT/LLM/TTS/记忆都做成框架原生 service
实验目的:验证 A 方案的核心假设能否成立,并拿到真实传输下的延迟数字。
## 二、结论摘要
| 部分 | 结果 |
|---|---|
| WebRTC 信令(offer/answer) | ✅ 往返 47–280ms |
| ICE 连通 | ✅ 走 172.16.1.x 直连 |
| data channel + 心跳 | ✅ |
| 双向媒体轨 | ✅ 客户端音轨送达服务端,服务端音轨客户端收到 |
| 管线启动、处理器 setup() | ✅,只产生一个自动 StartFrame |
| **帧路由到自定义处理器** | ✅ StartFrame 与 250+ 个音频帧实测到达 |
| 端到端出声 | ✅ 文件音频 → STT → DeepSeek → Qwen3-TTS → 非静音回复 |
| 浏览器真人对话 | ✅ SSH 隧道打开 Playground,麦克风输入与语音回复正常 |
| 单次真实链路延迟 | 源音频结束→客户端首个非静音约 5.8s;仍需优化 |
**即:Pipecat 负责传输/帧/RTVI、自有引擎负责会话语义的 A 方案成立。**
## 三、原问题根因与修复
原先看到 `EngineProcessor.setup()` 被调用、`process_frame` 却为 0,于是误判为
Pipeline 接线或模型不兼容。服务器完整日志揭示了真实顺序:Pipecat 在 setup 阶段
预热 NLTK 分句器,首次运行尝试下载 `punkt_tab`,20 秒后 setup timeout。worker
尚未走到自动发送 `StartFrame` 的阶段,因此所有业务帧都还在等待。
修复后把 `punkt_tab` 预装到权限 700 的
`~/.local/share/ai-companion/nltk_data`,启动时 fail-fast 检查;同时改用
`PipelineWorker(params=PIPELINE_PARAMS)`,删除客户端连接事件中手工发送的第二个
`StartFrame`。复测显示自动 StartFrame 正常抵达管线末端,音频持续进入处理器。
之后仍不出声是第二个独立问题:文件客户端播放完 WAV 后没有继续推进 PTS,服务端
收不到 VAD 所需的 700ms 尾静音。客户端现在固定发送 1.2s 尾静音且持续推进时钟。
接收端也改为只有检测到非静音音频才算成功,不再把 WebRTC 空闲静音当作回复。
## 四、挖出的 13 个集成坑
每个都会表现为"什么都没发生",极难排查。无论最终选 A 还是 B 都会踩。
| # | 坑 | 现象 | 怎么办 |
|---|---|---|---|
| 1 | Pipecat 客户端 SDK 不覆盖 N5095 形态 | 浏览器/移动端/C++/ESP32 都有,没有"Linux x86 盒子接 USB 阵列" | 自己写 aiortc 客户端(已有雏形) |
| 2 | 客户端必须建 data channel 并定期发 `"ping"` | 不建则服务端**一帧音频都不推**,日志只有一句 "Data channel not established within 10s" | 建 channel + 1s 心跳 |
| 3 | offer 里没有媒体时 aiortc **完全不收集 ICE 候选** | 信令成功、SDP 交换正常,但 ICE 永远停在 `new` | 即使只测连通也要加一条(静音)音轨 |
| 4 | ICE 收集完成前就发 offer | SDP 里 0 个候选地址 | 等 `iceGatheringState == "complete"` |
| 5 | `PipelineWorker.run()` 要 `WorkerParams(task_manager=...)`,**不是** `PipelineParams` | 不传报 "missing 1 required positional argument";传错报 "no attribute 'task_manager'" | 两个参数分别给构造函数和 run() |
| 6 | `TaskManager()` 内部调 `asyncio.get_running_loop()` | 模块级创建报 "no running event loop" | 必须在异步上下文里建 |
| 7 | **后台任务里的异常被静默吞掉** | 日志里查不到任何线索,表现为"连接正常但没反应" | 自己 try/except + log.exception |
| 8 | 信令回调里 `await task.run()` | 阻塞整个会话生命周期,管线初始化时序走歪 | `asyncio.create_task(task.run(...))` |
| 9 | NLTK `punkt_tab` 在首个 worker 内临时下载 | setup 20 秒超时,看起来像帧路由坏了 | 部署时预装到私有目录,启动前检查 |
| 10 | `PipelineWorker` 本身会自动发送 StartFrame | 手工再发会看到两个启动帧 | 删除连接回调里的手工 StartFrame |
| 11 | 文件结束后静音帧 PTS 不增长 | VAD 永远等不到说完 | 源位置与媒体时钟分离,额外发送 1.2s 静音 |
| 12 | WebRTC 输出轨空闲时也有静音 | “收到 34 秒音频/首音 206ms”的假成功 | 按非静音 RMS 判断业务回复 |
| 13 | prebuilt Playground 连接前先请求 `/start` | 页面正常但 Connect 在 authenticating 阶段报 Not Found | 增加 `/start`,返回 `/api/offer` 的 `webrtcRequestParams` |
第 5、6、7 条各花了好几轮才发现——因为异常在后台任务里,日志什么都不显示。
## 五、对 A/B 决策的影响
实验前的判断是"Pipecat 的独特价值收敛到传输层"(因为方案 §5 的音频边界决定
把 AEC 和播放放在客户端,绕开了 Pipecat 的打断机制)。
根因已经证明既不是 Pipeline 接线,也不是自定义处理器模型不兼容。因此确定采用
**A 方案**:保留已验证的 `ConversationEngine`,由薄 `EngineProcessor` 适配
Pipecat 的输入输出帧。网页客户端需要的 RTVI 继续开启;N5095 精简客户端可以忽略
暂不认识的 RTVI data-channel 消息。
不做 B 方案的全量重构。后续只在确有收益时渐进引入 Pipecat 的 VAD、turn processor、
metrics 与 interruption,不重写已有的记忆、身份、LLM/TTS 适配。
## 六、下一步
1. 优化真实首音延迟:分别记录 VAD、STT、LLM 首 token、TTS 首段和客户端播放时间
2. 继续用 prebuilt 网页客户端做真人语音、浏览器 AEC 与主观试听(基础对话已通过)
3. Tailscale tailnet 开启 Serve 后,用 HTTPS 暴露 `/client/`
4. 音频硬件到货后实现 N5095 声卡、AEC、播放参考与打断
5. 录本人普通话语料,对 Zipformer / SenseVoice / faster-whisper 做 CER 对比
## 七、当前代码状态
**服务端** `services/bot_server.py`:
- 已实现:SmallWebRTC 信令端点 `/api/offer`、prebuilt 网页客户端挂载 `/client/`、
Playground 启动端点 `/start`、`EngineProcessor`(自写能量 VAD + 引擎调用)
- 已改用 `PipelineWorker`,保留 RTVI;启动/超时/错误有显式日志
- 已适配 RTVI `send-text` 产生的 `LLMMessagesAppendFrame`,网页文字输入与
Conversation 区回复走自有引擎,不依赖框架原生 LLM service;Playground 1.7
需补发完整 `bot-transcription` / `bot-output`,token 级事件不足以稳定渲染
- 音频缓冲加入 1 秒预卷、60 秒上限和短噪声丢弃,连接结束释放会话资源
- VAD 是**极简能量判定**,不是最终方案(方案 §5 的说完判定还没选型)
- 当前没有唤醒门控:网页连接后每段有效语音都会触发回复。正式唤醒仍在 P2
的 N5095 本地实现,待机时不上传音频;本次按用户决定暂不处理
- 已接入联网搜索:DeepSeek 通过 Function Calling 请求 `web_search`,执行层调用
仅绑定 `127.0.0.1:18080` 的私有 SearXNG,结果作为 `tool` 消息回灌后继续生成;
网页保留来源 URL,TTS 不朗读来源段
- 2026-09-16 真实闭环验收通过:模型主动搜索,SearXNG 返回 5 条结果,最终回答带
两个来源;服务器网络下 360 搜索、搜狗可用,DuckDuckGo/Brave/Wikipedia 超时
- SearXNG 的完整选型、部署、API、运维和排错记录见
[[SearXNG私有搜索服务部署与使用]]
**客户端** `client/test_client.py`:
- 已实现:非 trickle ICE、data channel + 心跳、文件音频实时发送、1.2s 尾静音、回复录音
- 只有检测到非静音回复才成功;按 WebRTC 实际 48kHz 保存 WAV
- **不做 AEC、不做播放参考、不做打断**——那些要真实声卡和硬件
- 它是 N5095 客户端的雏形,不是产品代码
## 八点五、2026-09-16 后续架构补充
媒体传输闭环不等于 Agent、长期记忆和实时打断已经闭环。真人网页测试后确认:
- 姓名档案已经能跨连接恢复,但通用“记住/偏好”仍写 candidate,默认不会召回;
- 当前 `SimpleVAD` 能判断 700ms 尾静音,助手说话时检测到用户声音却只记日志;
- `InterruptionFrame` 和异步任务取消能力存在,但没有由 user-started-speaking
可靠触发,也没有保证清空浏览器已经缓冲的音频;
- 当前只有 `web_search` 的手写工具循环。
后续 Agent 层选择 **Pipecat Flows 渐进集成**,继续坚持 A 方案,不重写已经验证的
传输层。因为 Flows 使用 Pipecat `LLMService` 与 context aggregator,而项目当前
使用自有 `ConversationEngine`,必须先做独立兼容性 spike,并保留 legacy feature
flag。详细交接见 [[P1-Pipecat Flows Agent化与记忆改造交接]]。
## 八、复现
```bash
# 服务端(服务器上)
cd ~/ai-companion
.venv/bin/python services/bot_server.py --host 0.0.0.0 --port 7860
# 客户端(N5095 上)
cd ~/workspace/ai-companion
python client/test_client.py \
--server http://cadadmin-server.taild0f91d.ts.net:7860 \
--wav p0/stt_tts_bench/wavs/stt_1.wav --out /tmp/reply.wav
```
当前实测:连接成功,收到非静音语音回复;源音频结束到首个非静音约 5.8 秒。
浏览器通过 SSH 隧道访问 `http://localhost:7860/client/` 也已完成真人对话;
2026-09-16 一轮服务端指标约 3.0 秒。
## 九、顺带发现的环境问题
**局域网被重新编址了**:两台机器从 `192.168.0.x` 变成 `172.16.1.x`
(N5095 现为 `172.16.1.99`,服务器 `172.16.1.101`)。
已处理:客户端默认地址改用 tailscale 域名
(`cadadmin-server.taild0f91d.ts.net`),抗重新编址。
**未处理**:方案文档与 N5095 配置里仍有 `192.168.0.20` 的残留引用。
@@ -0,0 +1,128 @@
# P0 报告 · Qwen3-TTS 选型与部署
日期:2026-09-15。起因:用户试听后反馈 vits-zh-ll 音色不满意,
要求换"体量更大效果更好的模型",并提出 LLM 可以用线上 API、
把 GPU 让给语音。
服务:`~/tts-qwen/`(服务器)· 模型:`~/tts-service/models/Qwen3-TTS-1.7B-CustomVoice`
## 一、为什么选 Qwen3-TTS
查证后的结论(来源见文末):
- **实时对话场景的首选是 Qwen3-TTS 或 CosyVoice 2/3**;
IndexTTS2 和 F5-TTS 虽然中文指标也强,但流式不是它们的目标,不做实时助手首选
- Qwen3-TTS 官方标称**流式首包 97ms**,RTX 3090 上实测约 130ms
- 官方推荐硬件就是 **RTX 3090/4090 24GB**,与现有硬件吻合
- Apache-2.0,10 种语言 + 中文方言
- 9 个预置音色 + **自然语言风格指令**(这是 vits 那一代没有的能力)
## 二、部署路上撞到的两个障碍
### 障碍 1:vLLM-Omni 装不起来能用的服务
原本的打算是走 vLLM-Omni(它提供 OpenAI 兼容的流式 `/v1/audio/speech`
分块接口,是延迟最优的路径)。两个问题叠加:
1. **`--task-type` 参数没被注册**。vllm-omni 把 CLI 扩展注册在
`vllm.general_plugins`,但 vllm 0.28 的 serve 解析器没加载它,
`vllm-omni serve --help` 里根本看不到这个参数。
2. **绕过后仍崩溃**。模型架构能正确识别
(`Resolved architecture: Qwen3TTSForConditionalGeneration`),
但 vLLM 因为模型 config 里的 mrope 参数
(transformers 5.14 报 `Unrecognized keys in rope_parameters:
{'interleaved', 'mrope_section'}`)把它当多模态模型走 mm 路径,
崩在 `assert isinstance(model, SupportsMRoPE)`。
根因是 **transformers / vllm / vllm-omni 三者的版本组合问题**,
不是配置错误。没有继续深挖。
### 障碍 2:qwen-tts 与 vllm-omni 依赖互斥
改用厂商自己的 `qwen-tts` 包后,发现它会把 transformers 从
**5.14.1 降到 4.57.3**、huggingface_hub 从 **1.31.0 降到 0.36.2**,
直接破坏 vllm-omni(报 `StrictDataclassFieldValidationError`)。
**结论:两者不能共存于一个 venv。** 最终分两个 venv,
TTS 跑独立进程,助手通过 HTTP 调用。
## 三、实测数据
硬件:RTX 3090 24GB,1.7B 模型,非流式,`Serena` 音色。
| 文本 | 音频时长 | 合成耗时 | RTF |
|---|---|---|---|
| 好的,我记住了。(8字) | 1.52s | 1429ms | **0.94** |
| 今天北京晴转多云…(19字) | 3.36s | 1881ms | 0.56 |
| 我查了一下,明天上午…(26字) | 4.64s | 2527ms | 0.55 |
**关键发现:短句 RTF 反而最差。**
每次调用有约 1 秒的固定开销,与文本长度无关
(talker 模型自回归解码的启动成本 + 声码器解码)。
这条推翻了"让模型先说个短句来降低首音延迟"的直觉——
短句单位时间成本更高,反而不划算。
输出:**24kHz 单声道**(vits-zh-ll 是 16kHz,接播放链路时注意重采样)。
## 四、用户选定的配置
试听样本见 `out/tts_qwen/`。用户选定:
```
音色:Serena —— 温暖柔和的年轻女声
指令:用轻快活泼的语气说
```
(`instruct_7849.wav`,1.44s。通过时长比对确认对应关系:
默认 2.56s、轻快活泼 1.44s、温柔关切 1.72s。)
9 个预置音色中 5 个是中文:
Vivian(明亮微冷)、**Serena(温暖柔和)**、Uncle_Fu(低沉男声)、
Dylan(京腔男声)、Eric(成都话男声)。
## 五、代价:首音延迟涨了 8.6 倍
| 配置 | 首音延迟 |
|---|---|
| 本地 Qwen3.8-27B + vits-zh-ll | **179 ms** |
| DeepSeek + Qwen3-TTS | **1545 ms** |
拆解这 1545ms:DeepSeek 网络往返约 600ms + Qwen3-TTS 固定开销约 900ms。
加上 VAD 判定(200–500ms),总首音落在 **2.0–2.2 秒**,
方案 P1 目标是 P50≤2 秒——**刚好卡线,没有余量**。
换来的是音质从"用户不满意"到"用户试听选定"。
## 六、速度还能救,但都受阻
| 手段 | 预估收益 | 障碍 |
|---|---|---|
| flash-attn | 1.5–2× | 预编译 wheel 最高支持 torch 2.9,本机 2.14;源码编译要从 GitHub 拉依赖而该机下 GitHub release 断连 |
| 真流式(接管 codes→音频 解码循环) | 可绕开约 900ms 固定开销 | qwen-tts 封装把解码放在全部 codes 生成完之后,拿不到增量;要自己实现 |
| 降 torch 版本装 flash-attn | 1.5–2× | 会牵动 vLLM 0.28 的依赖 |
**真流式的收益最大**——那 900ms 固定开销正是流式能覆盖掉的部分。
但工作量也最大。
## 七、备份路线(未采用)
若 Qwen3-TTS 的速度最终不可接受,可考虑的替代:
- **CosyVoice 2/3**:同为实时对话第一梯队,Apache-2.0,工程成熟度最高,
18+ 中文方言。但搜索结果里首包延迟数据有冲突(150ms vs 1.5s),需实测。
- **云端 TTS API**:最快出效果,但助手要说的话会出网,
与方案"不自动发送音频到第三方"的口径有冲突(发的是文本不是音频,
但内容一样出网),且按量计费。
未测:CosyVoice 的本地部署与本机延迟。
## 八、来源
- [2026 开源 TTS 模型横向对比](https://juejin.cn/post/7649764223339495475)
- [Best Open-Source TTS Models for Production in 2026](https://instavar.com/research/tts/open-source-tts-models)
- [Qwen3-TTS 与 vLLM 集成部署](https://blog.csdn.net/weixin_30633869/article/details/157918741)
- [Speech Generation on vLLM-Omni:性能优化](https://docs.vllm.ai/projects/vllm-omni/en/stable/design/qwen3_omni_tts_performance_optimization/)
- [vllm-omni PR #3932:Code2Wav CUDA Graph 调优](https://github.com/vllm-project/vllm-omni/pull/3932)
- 模型 README:`~/tts-service/models/Qwen3-TTS-1.7B-CustomVoice/README.md`
@@ -0,0 +1,169 @@
# P0 验证报告 · 本地 Qwen3.8-27B
> ⚠️ **状态:该服务已于 2026-09-15 停用。**
> 原因:用户要求换更强的语音模型(vits-zh-ll 音色不满意),
> 而 3090 只有 24GB——27B 的 vLLM 占 21.5GB,装不下 Qwen3-TTS。
> 决定让 LLM 走云端 DeepSeek,GPU 让给语音。见 `P0-Qwen3TTS报告.md`。
>
> **本报告的结论仍然有效**(用于将来重启该服务,或评估其它本地 LLM):
> 关思考可让首 token 从 1390ms 降到 367ms;思考 token 会吃掉 max_tokens 预算;
> 只接受一条 system 消息;思考字段名与 DeepSeek 不同。
日期:2026-09-14。端点:`http://192.168.0.20:18020/v1`(vLLM,局域网 1.4ms)。
脚本:`~/workspace/ai-companion/p0/verify_local_llm.py`。结果:**5/6 通过**(未通过项为视觉,见 §5)。
复现:
```bash
set -a; . ~/.config/ai_assistant/env; set +a
cd ~/workspace/ai-companion && python3 p0/verify_local_llm.py
```
## 一、服务实况
方案文档假设 LLM 用 DeepSeek 云 API。实测发现局域网内已有可用服务:
| 项 | 值 |
|---|---|
| 服务 | **vLLM**(不是 llama.cpp,服务器上两套都有) |
| 模型 | `Qwen3.8-27B-W4A16-AutoRound-fast`(4bit 权重,16bit 激活) |
| `max_model_len` | 65536 |
| 思考解析 | `--reasoning-parser qwen3` |
| 到 N5095 延迟 | **1.4ms** |
| 视觉 | **不支持**(见 §5) |
## 二、思考模式:可关,且必须关
关闭方式(**已实测有效**):
```json
"chat_template_kwargs": {"enable_thinking": false}
```
实测三种模式(同一个数学题,`max_tokens=512`):
| 模式 | 思考字数 | 完成 tokens | 首 token(回答) |
|---|---|---|---|
| 默认(不传) | 168 | 214 | 527ms |
| **enable_thinking=false** | **0** | 512 | **81ms** |
| enable_thinking=true | 289 | 239 | 620ms |
**开思考的代价不只是慢**。看上面第二组数字:开思考时完成 tokens 只有 239,
而关思考是 512——因为思考 token 占用 `max_tokens` 预算。
在真实语音 prompt 上这个问题更严重(`max_tokens=200`):
| 模式 | 首 token(回答)P50 | 完成 tokens 中位 | 实际回答 |
|---|---|---|---|
| **关思考** | **367ms** | 10 | 「物流显示昨天已签收。」 |
| 开思考 | **1390ms** | 187 | 「我不」 ← **被截断** |
开思考时首 token 慢 3.8 倍,而且 200 的预算被思考吃光,回答只吐出两个字。
## 三、对比 DeepSeek 云 API
| 指标 | 本机 Qwen3.8-27B | DeepSeek `deepseek-flash` |
|---|---|---|
| 首 token(关思考) | **367ms** | 786ms |
| 网络延迟 | 1.4ms | 含在 786ms 内 |
| 思考模式 | 可关(`chat_template_kwargs`) | 可关(`thinking`),但字段名不同 |
| 视觉 | ❌ 本实例不支持 | ✅ 可用 |
| 费用 | 0(本地) | 按 token 计费 |
| 数据 | 不出局域网 | 上传第三方 |
**本机首 token 比云端快一倍多**,因为省掉了网络往返与排队。
## 四、两个会改设计的集成约束
### 1. 思考内容的字段名不一致
| 后端 | 字段名 |
|---|---|
| 本机 vLLM | `reasoning` |
| DeepSeek | `reasoning_content` |
适配层**必须两个都认**。只按 DeepSeek 的字段名写,会静默丢弃思考内容——
不报错,但"把思考念出来"或"思考占用显示"这类问题会莫名其妙出现。
### 2. 只允许一条 system 消息
发送两条 system 消息直接 400:
```
System message must be at the beginning.
```
**这推翻了原记忆设计**。原方案打算"检索到的记忆作为一条独立 system message 注入",
在本机 vLLM 上会直接失败。
修正后的做法:**保持唯一一条 system 消息,每轮重建**:
```
system = 人设 + 规则 + 本轮检索到的记忆
```
这其实更干净,也和"注入的记忆每轮替换而非追加"的要求一致。
## 五、未通过项:视觉
本实例返回:
```
At most 0 image(s) may be provided in one prompt.
```
模型目录 `Qwen3.8-27B-W4A16-AutoRound-fast/` 只有 safetensors 与 config,
**没有视觉塔**,是纯文本部署。
服务器上另有视觉能力:`~/models/mmproj-F16.gguf`(928MB)配合
llama.cpp 的 `128k_vision.sh`,以及 llama.cpp 的 `--mmproj` 参数。
但那是另一套服务,需要单独起。
**影响**:方案 §P5 的视觉问答在本端点不可用,需要么另起一个带 mmproj 的
llama.cpp 实例,要么走 DeepSeek。留到 P5 再定。
## 六、其余验证项
| 项 | 结果 |
|---|---|
| 工具调用(关思考) | ✅ `set_light({"room":"客厅","on":true})` |
| 纯文本流式 | ✅ |
| 端点与模型清单 | ✅ |
**未测**:取消/超时、长上下文退化、多客户端并发。
## 七、对 P1 延迟预算的影响
更新后的预算(用本机 LLM):
| 环节 | 耗时 | 来源 |
|---|---|---|
| VAD 判定说完 | 200–500ms | 估算,待实测 |
| STT 冲刷 | ≈0ms | 实测 |
| **LLM 首 token** | **367ms** | 实测 |
| TTS 首音(vits-zh-ll @服务器) | 57–152ms | 实测 |
| 网络 | 1.4–2ms | 实测 |
| **合计** | **约 0.6–1.0 秒** | |
方案 P1 目标 P50≤2 秒。**余量超过一倍。**
## 八、上下文建议
实测语音 prompt 约 406 tokens(人设 + 记忆 + 3 轮对话)。
建议按 4k tokens 设上限,超出时截断最旧的轮次:
- system(人设 + 本轮记忆):400–800 tokens
- 最近对话轮次:500–1000 tokens
- 当前输入:~50 tokens
vLLM 服务端的 `max_model_len=65536` 是全局配置,**不需要为助手改动**
(那是共享服务)——短上下文靠客户端控制实际 prompt 长度即可。
短上下文不只省资源,还直接降低首 token 延迟,这对"回应及时"是关键。
## 九、工程陷阱记录
1. **本机 vLLM 思考字段名是 `reasoning`**,不是 `reasoning_content`(DeepSeek 的写法)。
2. **只接受一条 system 消息**,多条直接 400。
3. **开思考会吃掉 `max_tokens` 预算**,导致回答被截断。
语音场景下 `max_tokens` 本来就要设小,这个问题会被放大。
4. N5095 上系统 `python3` 无 PIL,测试脚本要用 venv 里的解释器。
@@ -0,0 +1,228 @@
# P0 基准报告 · 本地 STT / TTS
> ⚠️ **TTS 选型已于 2026-09-15 变更。**
> 本报告选出的 vits-zh-ll 虽然速度达标(服务器 RTF 0.09),
> 但用户试听反馈**音色不满意**,最终换成 Qwen3-TTS 1.7B(RTF 0.55–0.94)。
> 见 `P0-Qwen3TTS报告.md`。
>
> **本报告的核心结论仍然有效且在用**:
> - STT 线程数不能按"核多给多"猜(N5095 上 4 线程比 2 线程慢 3.9 倍)
> - 冲刷耗时 ≈ 0,流式解码已持续输出
> - VITS 非自回归、`max_num_sentences=1` 会静默截断——这两条对 Qwen3-TTS 同样适用
> - STT 中文识别率未测(无参考文本),此结论仍然成立
>
> 另:本报告测出的"服务器(i9-14900K)上 STT RTF 0.03"仍是当前生产配置。
日期:2026-09-14。机器:Celeron N5095(4 核,**无 AVX2**)/ Ubuntu 24.04.3 / 16G。
脚本目录:`p0/stt_tts_bench/`。全部为实测数据,非估算。
复现:
```bash
cd ~/workspace/ai-companion/p0/stt_tts_bench
./download_models.sh # 走 hf-mirror
python3 bench_stt.py # STT
python3 bench_tts_compare.py # TTS 横向对比
python3 bench_concurrent.py # 并发
python3 bench_roundtrip.py # 往返
python3 bench_threads.py # 线程扫描
```
## 一、结论
**本地语音方案成立,但必须换 TTS 模型。**
内核:**STT 没问题,vits-zh-ll 不行,Piper 可以。**
| 组件 | 选型 | RTF | 判定 |
|---|---|---|---|
| STT | 流式 Zipformer 中英双语 int8 | 0.22(单跑)/ 0.50(并发) | ✅ 可用 |
| TTS | vits-zh-ll(115MB) | **1.86** | ❌ 慢于实时,不可用 |
| TTS | **piper-huayan-medium(61MB)** | **0.36** | ✅ 推荐 |
| TTS | piper-huayan-x_low(20MB) | 0.17 | ⚠ 快但音质存疑,待试听 |
## 二、STT
### 速度
| 线程数 | RTF 中位 | 单块解码 p95 | 判定 |
|---|---|---|---|
| 1 | 0.37 | 1.19 | 平均够,但存在突发积压 |
| **2** | **0.22** | **0.79** | ✅ 最佳 |
| 3 | 0.63 | — | 反而变慢 |
| 4 | 0.86 | 3.78 | 严重变慢 |
**关键发现:线程数超过 2 会急剧退化**,2→4 线程 RTF 从 0.22 涨到 0.86(慢 3.9 倍)。
重复 5 轮稳定复现,不是噪声。
原因判断:N5095(Jasper Lake)是**单通道内存**,且 L2 仅 1.5MB、L3 仅 4MB。
并且 onnxruntime 空闲工作线程默认自旋占核;4 个 ORT 线程 + Python 主线程 = 5 线程抢 4 个核,
主线程(负责喂音频)被饿死。
→ **实现时 STT 固定 `num_threads=2`。** 不要"核多就多给线程"。
### 其他指标
- **首字延迟**:喂进 0.5~1.4 秒音频后出现第一个中间结果。这是模型的算法前瞻量,与算力无关。
- **冲刷耗时 = 0ms**:`input_finished()` 后立即拿到最终结果。流式解码过程中已经持续输出,
收尾不额外花钱。**"说完到出结果"的开销主要在 VAD 判定说完的等待,不在识别器。**
- 注:并非每个 100ms 块都触发解码(约每 3 块解一次,即模型 chunk 约 300ms),
所以"单块 p95 > 1"不等于积压,应看 RTF。
### 识别正确率:未测
4 段测试音频是 sherpa-onnx 自带的中英双语真人录音,**没有参考文本**,无法算字错率。
尝试用"TTS 合成已知文本 → 喂给 STT"来算 CER,但**这个方法在这台机器上不成立**:
合成音频会让 STT 产生大量重复("晴晴转转"、"月月月"),而同样的解码配置下真人录音完全正常。
换 greedy / beam_search、换块大小、开关端点检测,四种配置结果完全一致 —— 说明问题出在合成音频
本身,不是解码配置。
**结论:STT 在中文真人语音上的正确率目前没有可信数据。** 需要一份带标注的中文真人测试集,
或用麦克风录一批带文本的语音。这是 P0 尚未闭合的一项,不能靠现有数据下结论。
## 三、TTS
### 速度
| 模型 | 大小 | RTF(2线程) | 19字句首音延迟 | 判定 |
|---|---|---|---|---|
| vits-zh-ll | 115MB | 1.86 | 2748ms | ❌ |
| piper-x_low | 20MB | 0.17 | 585ms | ✅ 快 |
| piper-huayan-medium | 61MB+espeak | 0.36 | ~650ms | ✅ 推荐 |
**VITS 非自回归**:验收时确认回调只在合成完成后触发一次,
所以**首音延迟 = 总合成耗时**,没有"边合成边播"的可能。
分句播放是唯一的提前出声手段(`max_num_sentences=1` 逐句合成)。
### 连续播放模拟(5 句)
| 模型 | 各句空档 | 最大空档 |
|---|---|---|
| vits-zh-ll | 2.0 / 1.6 / 0.5 / 2.9 / 3.8 秒 | **3.80s** |
| piper-x_low | 0.5 / 0 / 0 / 0 / 0 秒 | 0.47s(仅首句) |
vits-zh-ll 每句之间都会出现 0.5~3.8 秒的静默空档,说话断断续续。
Piper 除首句外无空档。
### 音质:必须人工试听
往返测试的 CER 不可信(见上),但可以给出**相对**信号:
| 模型 | 往返 CER | 输出峰值 | 输出 RMS |
|---|---|---|---|
| vits-zh-ll | 0.25 | **0.15** | 0.045 |
| piper-x_low | 0.72 | 0.49 | 0.092 |
| piper-huayan-medium | 0.32 | 0.59 | 0.105 |
两点值得注意:
1. **vits-zh-ll 输出音量极低**(峰值 0.15,只有 Piper 的 1/4)。接音箱时必须补增益,
否则要么听不见,要么底噪被一起放大。
2. **piper-x_low 音质存疑**:合成时有 `Unknown token: ̪` 警告,且往返 CER 明显偏差。
速度虽好(RTF 0.17),但**不建议直接用**。
已生成三个模型同一句话的试听文件,请人工判断:
```
~/workspace/ai-companion/p0/stt_tts_bench/listening_test/vits-zh-ll.wav
p0/stt_tts_bench/listening_test/piper-x_low.wav
p0/stt_tts_bench/listening_test/piper-medium.wav
```
**音质只能靠耳朵定,仪器测不出来。这一项在试听前不能算通过。**
## 四、并发(这是最接近真实负载的测试)
助手说话时麦克风不关(要检测打断),所以 STT 必须与 TTS 同时跑。
| TTS 模型 | STT 单跑 | TTS 单跑 | STT 并发 | TTS 并发 | 退化 | 判定 |
|---|---|---|---|---|---|---|
| piper-x_low | 0.26 | 0.19 | **0.50** | **0.33** | 1.9× / 1.7× | ✅ 余量 2.0× / 3.1× |
| vits-zh-ll | 0.26 | 1.97 | 0.48 | **2.39** | 1.9× / 1.2× | ❌ 跟不上 |
并发下两者都退化约 1.8 倍(4 核争抢所致,符合预期)。
用 Piper 时 STT 0.50 + TTS 0.33,都远低于 1,**架构成立**。
但这只是 STT + TTS 两项。方案还要同时跑人脸检测/跟踪。视觉再加进来是否还够,
需要单独测(未测)。
## 五、对方案 P1 指标的影响
方案 P1 目标:"短问答结束到首段有声回复,P50≤2 秒、P95≤4 秒"。
用实测数据推算(`thinking` 关闭):
| 环节 | 耗时 | 来源 |
|---|---|---|
| VAD 判定说完 | 200~500ms(估) | 待实测 |
| STT 冲刷 | ≈0ms | 实测 |
| LLM 首 token | 786ms(P50) | P0-DeepSeek 报告 |
| TTS 首音(Piper medium,短句) | ~650ms | 实测 |
| **合计** | **约 1.6~2.0 秒** | |
**用 Piper 时 P50 目标勉强可达**,但没有富余;P95 需要看网络抖动。
用 vits-zh-ll 则完全不可能(TTS 单项就 2.7 秒)。
## 六、未测事项(不要当成已通过)
- **STT 中文真人识别率**:无参考文本,未测。
- **TTS 音质**:待人工试听 `listening_test/`。
- **视觉并发**:人脸检测加入后的 CPU 争抢未测。
- **真实音频链路**:麦克风阵列、远场、AEC、回声参考全部未测,无硬件。
- **int8 量化损失**:只测了 int8 encoder,未与 fp32 对照(fp32 315MB,本机大概率跑不动)。
- **长时稳定性**:未做 2 小时/8 小时测试。
## 六之二、补充:更多 TTS 候选(同日追加)
用户试听后反馈 **vits-zh-ll 听感最好**,但它是 RTF 1.86 的不可用项。
据此追加测试了 5 个候选,全部实测:
| 模型 | 大小 | 单独 RTF(最佳线程) | 并发 RTF | 备注 |
|---|---|---|---|---|
| piper-x_low | 20MB | 0.17 @2 | 0.33 | 有 Unknown token 警告,音质存疑 |
| **piper-medium** | 61MB | **0.32 @2** | **0.51** | 用户反馈不如 vits |
| **matcha-icefall-zh-baker** | 88MB + 3.6MB 声码器 | **0.38 @2** | **0.94** | 新候选,流匹配 + HiFi-GAN |
| vits-zh-ll | 115MB | 1.14 @4 / 1.87 @2 | 1.48–2.39 | 听感最好,但不可用 |
| kokoro fp32 | 383MB | 1.96 @4 | — | 排除 |
| kokoro int8 | 181MB | **3.54 @4** | — | 排除 |
### 两条新增的机理结论
1. **TTS 的线程扩展性与 STT 相反**。STT 超过 2 线程急剧退化(4线程慢 3.9 倍),
而 vits-zh-ll 是线程越多越快(1线程 3.59 → 4线程 1.14)。
原因:STT 是大量小算子(每算子一次线程同步,线程越多同步开销越大),
TTS 是少数大算子(CPU 密集,可有效并行)。
**两类服务的线程配置必须分开调,不能统一。**
2. **int8 量化在这台机器上反而更慢**。Kokoro int8(3.54)比 fp32(1.96)慢近一倍。
原因:Tremont 没有 VNNI 指令,int8 的快速点积路径用不上,
权重需要现场反量化,额外开销盖过了带宽收益。
→ **本机不要走 int8 量化路线做加速。**(STT 的 int8 encoder 是发布方直接训练的量化模型,
不是运行时量化,不受此限。)
### 当前选型状态
- **速度与余量最优**:piper-medium(并发 0.51,余量约 2 倍)
- **架构档次更高、速度尚可**:matcha-baker(并发 0.94,**几乎没有余量**)
- matcha 的并发退化(0.38→0.94,2.5 倍)明显大于 piper-medium(0.32→0.51,1.6 倍),
判断与 HiFi-GAN 声码器吃内存带宽有关(本机单通道内存)。
**若选 matcha,人脸检测等其它负载再加进来很可能压垮实时性,需要实测。**
已生成响度归一化的 A/B 试听(统一到 -20 dBFS RMS,
避免"响度差被误当成音质差"——vits-zh-ll 原始峰值仅 0.17,比 Piper 低 3.5 倍):
```
~/workspace/ai-companion/p0/stt_tts_bench/listening_ab/{vits-zh-ll,matcha-baker,piper-medium}_{0,1}.wav
```
## 七、工程陷阱记录
1. **`max_num_sentences=1` 会截断多句输入**为第一句,且不报错。
传含多个句号的文本会静默丢失后半段。必须自己先分句逐句送。
2. **hf-mirror 对 urllib 默认 UA 返回 403**,需伪装 User-Agent。
3. **espeak-ng-data 里有带空格的文件名**(`voices/!v/Mr serious`),URL 必须转义。
4. **代码曾位于 note_sync(Obsidian 笔记仓库)内,是经软链接访问的**,
模型一度差点被推进笔记仓库。**2026-09-15 已把代码剥离为独立仓库**
(`~/workspace/ai-companion/`),笔记库只剩文档,这个问题不复存在。
教训:代码和笔记共用版本控制,迟早要在 ignore 规则上反复花时间。
@@ -0,0 +1,256 @@
# P1 · Pipecat Flows 兼容性 Spike 结论
> 日期:2026-09-16
> 状态:**spike 完成,7 项验证全部通过**
> 代码:`p1/flows_spike.py`(探索脚本)、`app/tests/test_flows_spike.py`(断言版)
> 前置:[P1-Pipecat Flows Agent化与记忆改造交接](P1-Pipecat%20Flows%20Agent化与记忆改造交接.md)
交接文档规定:**未经 spike 结论,不进入 Phase 3**。本文即该结论。
---
## 一、结论摘要
| 问题 | 结论 |
|---|---|
| 兼容的 flows 版本 | **不装独立包**。Pipecat 1.10.0 **内置** Flows(`pipecat.flows`) |
| LLMService 选择 | 复用 Pipecat 的 `DeepSeekLLMService`;**代价是失去本地 vLLM 降级** |
| token 级文本 | ✅ 实测 14–22 块/回复,满足网页显示与分句 TTS |
| 工具调用 | ✅ 参数聚合完整,无重复拼接;工具结果回灌后能出最终回答 |
| 取消 | ✅ `worker.cancel()` 能立即终止 LLM 流 |
| context | ⚠️ **会产生两条 system 消息**(见 §五) |
| 是否破坏现有功能 | 见 §六——**有两处需要处理** |
---
## 二、版本:不需要独立包,而且装了有害
**这是最重要的一条发现。**
`pipecat-ai 1.10.0` **已经内置了 Pipecat Flows**,模块路径 `pipecat.flows`。
装独立的 `pipecat-ai-flows` 包时,Pipecat 会直接报错提示:
```
ERROR | pipecat:_warn_if_standalone_flows_installed:45 -
The separate `pipecat-ai-flows` package is installed alongside a version of
Pipecat that already includes Pipecat Flows as `pipecat.flows`.
You do not need both — uninstall `pipecat-ai-flows` and import Flows from `pipecat.flows`.
```
而且独立包的版本区间有陷阱:
| 独立包版本 | 要求的 pipecat-ai | 后果 |
|---|---|---|
| 1.4.0 | `>=1.4.0,<1.5.0` | **会把 pipecat 从 1.10.0 降到 1.4.0** |
| 1.3.0 | `>=1.4.0,<2` | 可安装,pytest dry-run 只新增自己 |
(1.4.0 是"更新"的版本,却钉死在更老的 pipecat 区间——如果按"装最新版"的直觉操作,
会静默把整个框架降级。)
**决定**:
- `requirements.txt` **不添加** `pipecat-ai-flows`
- 代码 `from pipecat.flows import FlowManager, NodeConfig, FlowsFunctionSchema`
- 内置版与独立包 **API 一致**(FlowManager / NodeConfig / FlowsFunctionSchema 签名相同),
所以这个切换不影响任何写法
内置版的导出比独立包更多(多出 `Flow`、`FlowConfig`、`FlowProblem`、`FlowReferenceError`),
像是更演进的 API,但 spike 只用到了两者共有的部分。
---
## 三、LLMService 选择
**结论:复用 Pipecat 的 `DeepSeekLLMService`,不写 `FallbackProvider → LLMService` 适配器。**
理由:
1. **它自带 `DeepSeekLLMAdapter`**,源码注释明写 "Supplies the `reasoning_content`
DeepSeek requires on assistant messages in thinking mode"——**这正是我们在 P0
阶段踩过的坑**(见 `P0-DeepSeek验证报告.md`:开思考时多轮/工具回灌一律 400)。
框架已经处理了。
2. `thinking` 默认就是 `disabled`,与 P0 的结论一致。
3. 支持自定义 `base_url`,将来要接本地 vLLM 也能用。
4. **无法适配 `FallbackProvider`**:Flows 要求的是 `LLMService` 实例
(`LLMService | LLMSwitcher`),而 `pipecat_flows` 里那个 `LLMAdapter`
**只管摘要生成**,不是 provider 适配器。要保留 fallback 必须自己写
`LLMService` 子类,成本中等。
**必须覆盖模型名**:默认是 `deepseek-v4-flash`,**该模型不存在**,用默认值会 400。
必须显式设 `DeepSeekLLMService.Settings(model="deepseek-flash")`。
**失去本地 vLLM 降级的代价**:目前本机 27B 的 vLLM 已停用,fallback 实际上没有可用的
后备目标。所以 Phase 3 先只用 DeepSeek 是合理的。将来若要恢复双后端,
Pipecat 有 `LLMSwitcher` 可用,不必自写。
---
## 四、实测结果(7 项,9 个断言全通过)
```
=== 2. DeepSeek 在 Flows 中流式输出中文 ===
回复: '我是你的桌面陪伴小助手,平时就待在这儿陪你聊聊天、说说话。'
分块数: 18
[PASS] 有中文回复
[PASS] 回复不含 Markdown
[PASS] 7. token 级文本事件(>1 块) — 18 块
=== 3+4. 流式工具参数聚合 / 工具结果回灌 ===
工具调用参数: [{'city': '北京'}]
最终回复: '北京现在是下午三点半。'
[PASS] 3. 工具被调用 — 1 次
[PASS] 3. 参数聚合完整(无重复拼接) — {"city": "北京"}
[PASS] 4. 工具结果回灌后有最终回答
=== 5. 取消 ===
取消前 13 块 → 取消后 1.5s 内 13 块(又收到 0 块)
[PASS] 5. 取消后停止输出
=== 6. system message ===
context 消息角色序列: ['system', 'user', 'assistant']
[PASS] 6. system 消息不超过 1 条
[PASS] 6. 有 user 与 assistant 轮次
```
补充观察:
- **role_message 生效**:设"你是桌面陪伴助手"后,模型自称"桌面陪伴小助手",
不再是通用的"DeepSeek AI 助手"
- **工具调用只有 1 次**(修好 runner 之前会失败重试 2 次,见 §五)
---
## 五、暴露的问题(4 个坑,都已定位)
### 坑 1:`FlowManager.initialize` 是协程,漏 `await` 会静默失效
```python
inspect.signature(FlowManager.initialize) # (self, initial_node=None) -> None
inspect.iscoroutinefunction(...) # True ← 签名上看不出来
```
漏 `await` 时**不报错**,只是整个 flow 从不初始化:role_message、task_messages、
工具注册**全部没生效**,而 LLM 照样会回复(因为 `run_llm=True` 直接触发推理)。
现象极具误导性:会让人误判成"Flows 不支持这些特性"。实测中我一度得出
"role_message 不生效"的结论,就是这个原因。
### 坑 2:必须用 `PipelineRunner` 跑 worker,不能直接 `worker.run()`
直接 `worker.run(...)` 时 runner 没挂上,**工具执行会失败**:
```
Error executing function call [get_time]:
Worker 'PipelineWorker#0': runner is not set; call attach() first.
```
这个异常被 Pipecat 转成给 LLM 的通用错误串
`"The function X failed and returned no result."`,**handler 从不执行**,
日志里默认看不到真实原因。要开 DEBUG 才看得到。
正确写法:`await PipelineRunner().run(worker)`
### 坑 3:`respond_immediately` 默认 `True`,initialize 会立刻触发一轮回复
`FlowManager.initialize()` 会 queue 一个 `LLMRunFrame`,**在用户说话之前**
就产生一轮 LLM 回复。
做测试/归因时如果不清空观察窗口,会把"节点初始化的自动回复"当成
"对用户问题的回复"——我第一版 spike 就误判了第 2 项。
生产上这通常是想要的行为(节点进入即主动开口),但**必须知道它存在**。
### 坑 4:会产生两条 system 消息
实际发给 DeepSeek 的请求里:
```
[system] '你是桌面陪伴助手……' ← role_message(走 system_instruction)
[system] '当前任务:普通陪伴对话。' ← task_messages
[user] '……'
```
`role_message` 通过 `LLMUpdateSettingsFrame(system_instruction=...)` 下发,
而 `task_messages` 里的 system 消息留在 context 里,**两者都进请求**。
交接文档 §九 Phase 1 第 6 项要求"context aggregator 不产生第二条 system message"——
严格说,**context 里确实只有一条**(`task_messages` 那条),
`role_message` 走的是 LLM settings 而非 context。但**发出去的请求有两条 system**。
风险:
- 多数模型能处理多条 system,但**优先级不保证**(谁覆盖谁取决于实现)
- 我们之前在**本机 vLLM** 上就吃过"多条 system 直接 400"的亏
(见 `P0-本地LLM验证报告.md`)——如果将来把 LLM 切回本地 vLLM,这条会立刻炸
**建议**:Phase 3 集成时**只用一种**。要么把节点任务说明也写进 `role_message`,
要么不用 `role_message`、全部走 `task_messages`。不要两个都用。
### 坑 5(环境):NLTK `punkt_tab` 本地也要装
Pipecat 启动会预热 NLTK 分句器,缺 `punkt_tab` 会尝试联网下载并被安全策略拦
(`SSRF attempt to restricted IP`),setup 超时后管线卡住。
服务器上已装(`~/.local/share/ai-companion/nltk_data`),**本地开发机也要装**,
否则 spike 跑不起来。`p1/flows_spike.py` 已加 fail-fast 检查与拷贝命令提示。
---
## 六、对现有功能的影响
| 现有功能 | 是否受影响 | 说明 |
|---|---|---|
| 分句 TTS | **不受影响** | Flows 输出 token 级 `TextFrame`(实测 18 块),自有分句逻辑照用 |
| RTVI 网页显示 | **不受影响** | token 增量与完整文本都能拿到 |
| 记忆 / 身份 / SQLite | 不受影响 | Flows 的 state 只存会话控制状态,长期数据仍走 `MemoryStore` |
| SearXNG 搜索 | 需迁移 | 现在的 `web_search` 手写循环要改成 Flows 工具 |
| **本地 vLLM fallback** | **会失去** | `FallbackProvider` 不能被 Flows 直接使用(见 §三) |
| 两条 system 消息 | **需要注意** | 见坑 4;切回本地 vLLM 时会 400 |
---
## 七、建议的最小生产集成 diff(Phase 3 用)
不改动现有 `EngineProcessor` 的音频路径,只在**文字路径**上分流:
1. `app/config.py` 加 `AI_AGENT_RUNTIME=legacy|flows`,**默认 legacy**
2. `app/agent/` 新增 `tools.py`(工具定义)、`memory_policy.py`(记忆策略)、
`flows_runtime.py`(FlowManager 装配)
3. `services/bot_server.py` 里,RTVI `send-text` 按 `AI_AGENT_RUNTIME` 分流:
`legacy` → 现有 `EngineProcessor`;`flows` → 新建的 Flows 路径
4. 语音路径**暂不动**,留到 Phase 5
5. `requirements.txt` **不加** `pipecat-ai-flows`
关键注意点:
- 用 `PipelineRunner().run(worker)`,不要直接 `worker.run()`
- `await flow.initialize(node)`
- `role_message` 与 `task_messages` 的 system 消息**二选一**
- `DeepSeekLLMService.Settings(model="deepseek-flash")` 必须显式覆盖
---
## 八、下一步
spike 已给出结论,**可以进入 Phase 2**(抽出工具与策略层):
- 把现有 `web_search` 执行从 `ConversationEngine` 抽成可复用函数
- 实现记忆工具,先用 fake store 单测
- legacy 路径继续工作,Flows 不是唯一调用者
Phase 3(接网页文字路径)等 Phase 2 完成标准达成后再开始。
---
## 九、复现
```bash
cd ~/workspace/ai-companion
# 探索脚本(带请求体抓取与逐项诊断)
~/venvs/ai_assistant/bin/python p1/flows_spike.py
~/venvs/ai_assistant/bin/python p1/flows_spike.py --only 3
# 断言版(接 CI 用)
~/venvs/ai_assistant/bin/python app/tests/test_flows_spike.py
# 需要 DEBUG 日志排查时
SPIKE_DEBUG=1 ~/venvs/ai_assistant/bin/python p1/flows_spike.py --only 3
```
@@ -0,0 +1,501 @@
# P1 · Pipecat Flows Agent 化与记忆改造交接
> 日期:2026-09-16
> 状态:方案已选定,等待实现
> 实现仓库:`~/workspace/ai-companion`
> 部署目录:`yonglin@cadadmin-server.taild0f91d.ts.net:~/ai-companion`
## 一、给 Claude Code 的任务摘要
在**不推翻现有实时语音链路**的前提下,渐进引入 Pipecat Flows,把当前手写的
单工具循环升级为受策略约束的 Agent 工具层,并补齐通用长期记忆的确认、读取、
修改和忘记流程。
必须遵守以下边界:
1. 保留现有 `MemoryStore`、SQLite 数据、身份隔离、DeepSeek fallback、SearXNG、
RTVI 网页显示和流式 TTS。
2. 先做 Pipecat Flows 与当前 `ConversationEngine` 的兼容性 spike;验证通过前,
不得替换线上默认路径。
3. 使用 `AI_AGENT_RUNTIME=legacy|flows` 保留可立即回滚的 legacy 路径。
4. 模型不得决定或传入 `owner_user_id`;用户身份必须由服务端会话上下文注入。
5. 用户明确要求记忆与模型推测必须分开。模型推测不得直接成为 confirmed。
6. 不把 Pipecat Flow state 当成长期记忆;长期数据仍通过 `MemoryStore` 持久化。
7. 不顺手实现唤醒词、硬件 AEC、人脸自动切换、向量数据库或 systemd。
## 二、开始前必须知道的仓库状态
### 代码和部署
- Git 已提交基线包含:
- `c3d2c7f`:修复 Pipecat 实时链路并补齐网页测试
- `719fc13`:修复网页对话并接入 SearXNG 搜索
- 2026-09-16 的姓名档案修复、疑问句防误写和测试目前仍是**未提交改动**。
- 线上服务已运行这些未提交改动,不等于服务器代码与 Git HEAD 相同。
- 开始工作前必须运行 `git status --short` 和 `git diff`;不得 reset、checkout
或覆盖现有改动。
- 线上健康接口:`http://127.0.0.1:7860/health`。
- 网页调试入口通过 SSH 隧道访问:`http://localhost:7860/client/`。
### 当前已验证能力
- SmallWebRTC 信令、ICE、data channel、音频输入输出已闭环。
- 浏览器真人麦克风 → STT → DeepSeek → Qwen3-TTS → 浏览器播放正常。
- 网页文字输入、用户文本、助手文本和语音回复正常。
- DeepSeek 能自主调用 `web_search`,SearXNG 工具结果能回灌后继续回答。
- 用户自报“我叫林”会同步写入 confirmed 姓名档案,disconnect/reconnect 后恢复。
- 姓名疑问句不会再把“谁吗”之类内容覆盖为姓名。
- 当前专项回归:
- 姓名档案 22/22
- 记忆存储 30/30
- 身份 19/19
- 搜索 12/12
- 传输 4/4
### 线上姓名数据库备份
- 首次档案迁移前:
`/home/yonglin/.local/share/ai_assistant/memory.db.before-name-profile-20260916`
- 疑问句误写修复前:
`/home/yonglin/.local/share/ai_assistant/memory.db.before-name-question-20260916-1140`
不要删除这些备份。后续若改 schema,必须另建带时间戳的备份。
## 三、当前系统到底是什么
当前不是完整通用 Agent,而是:
```text
SmallWebRTC / RTVI
↓
EngineProcessor
↓
SimpleVAD → StreamingSTT
↓
ConversationEngine
身份 → 自动记忆检索 → DeepSeek/搜索工具循环 → 分句 TTS
↓
WebRTC 音频与 RTVI 文本
```
`ConversationEngine._stream_and_speak()` 手写了最多两轮的 `web_search` Function
Calling 循环。模型能自主决定是否搜索,但还不能自主调用记忆工具。
Pipecat 当前主要负责传输、Frame、PipelineWorker 和 RTVI;STT、LLM、TTS 语义链路
仍由自有引擎调度。
## 四、当前记忆决策与真实缺口
### 写入
| 输入 | 当前行为 | 实际是否可复用 |
|---|---|---|
| “我叫林” | 同步更新 `users.display_name`,写 confirmed 姓名 | 是 |
| “记住我不吃香菜” | 回复后规则抽取,写 candidate | 否,默认检索排除 |
| “我喜欢咖啡” | 回复后规则抽取,写 candidate | 否,默认检索排除 |
| 普通聊天 | 只保留当前内存历史和 user turn | 重连后不可用 |
当前没有 candidate 的确认、晋升或管理入口。因此除姓名外,“写进数据库”和“助手会在
以后使用”不是一回事。
### 读取
- 每条已确认身份的用户消息都会尝试检索,不是模型按需决定。
- 默认只读取 confirmed、private/shared、未过期记忆。
- 最多 5 条,超时 300ms 后无记忆继续回答。
- 当前是中文字符/二元组字面相似度;记忆总量很小时,无命中会退回最近记忆。
- 用户姓名作为档案固定注入,不依赖语义检索。
### 必须达到的目标语义
1. **稳定档案**:姓名、称呼、语言和明确的稳定偏好;连接开始时加载。
2. **显式长期记忆**:用户明确说“记住/以后/别忘了”,经策略校验后 confirmed。
3. **候选观察**:普通聊天中推断出的可能信息只能 candidate,不直接影响回答。
4. **事件/摘要**:会话事件与逐字稿不冒充永久事实,可过期、可删除。
5. **矛盾更新**:同 owner + kind + key 的 confirmed 新值覆盖或 supersede 旧值。
6. **透明控制**:支持查看、确认、修改、忘记;模糊删除必须追问。
## 五、为什么选择 Pipecat Flows
### 候选比较
| 框架 | 优点 | 与本项目的主要问题 | 结论 |
|---|---|---|---|
| Pipecat Flows | 已在 Pipecat 生态;状态、节点、函数注册和校验;支持 DeepSeek 等 OpenAI-compatible 服务 | 期待 Pipecat `LLMService + context aggregator`,与当前自有引擎有接口差异 | **选择,渐进集成** |
| Agno | 内置 user/session/memory,最接近轻量版 OpenClaw | 会与现有会话、历史和记忆层重叠 | 暂不采用 |
| PydanticAI | 类型安全、依赖注入、流式工具事件、较轻 | 长期记忆策略仍要自建;与现有工具循环收益有限 | 备选 |
| LangGraph | 持久状态、checkpoint、长任务和人工审批成熟 | 对当前实时陪伴链路过重 | 暂不采用 |
| LiveKit Agents | 完整实时语音 Agent、工具、handoff | 相当于替换已跑通的 Pipecat/WebRTC;长期记忆仍需外接 | 不迁移 |
| smolagents | 代码少、多步工具 Agent | 偏任务型,多步延迟高,API仍偏实验 | 不用于主链路 |
| OpenClaw | 通用 Agent runtime、分层记忆和后台整理完整 | 运行时过重、默认个人 Agent 假设、实时语音和身份边界仍需定制 | 不整体迁移 |
官方资料:
- [Pipecat Flows Overview](https://docs.pipecat.ai/api-reference/pipecat-flows/overview)
- [Pipecat Function Calling](https://docs.pipecat.ai/pipecat/learn/function-calling)
- [Agno Memory](https://docs.agno.com/examples/memory/overview)
- [PydanticAI Agents](https://ai.pydantic.dev/agents/)
- [LangGraph Memory](https://docs.langchain.com/oss/python/langgraph/add-memory)
- [LiveKit Chat Context](https://docs.livekit.io/agents/logic/chat-context/)
- [OpenClaw Memory](https://docs.openclaw.ai/concepts/memory)
### 选型原则
选择 Pipecat Flows,不是为了追求“框架化”,而是为了:
- 复用现有实时媒体栈;
- 把工具 schema、执行、结果回灌和状态切换从手写循环中抽离;
- 给记忆确认/删除等多轮流程提供显式状态;
- 保留产品特有的身份、隐私和低延迟策略。
## 六、最重要的集成风险
Pipecat Flows 的正常构造需要:
- `PipelineTask`
- Pipecat `LLMService`
- context aggregator
- 由 Pipeline Frame 驱动的函数调用和消息更新
当前项目则是:
- `PipelineWorker` + 自定义 `EngineProcessor`
- 自有 `FallbackProvider`,不是 Pipecat `LLMService`
- 自有消息历史与 prompt 构造
- 自有 STT/TTS 调用,不是标准 Pipecat service processor
- RTVI 事件由 `EngineProcessor` 手工补发
因此不能直接“安装 Flows,然后包住 ConversationEngine”。如果忽略这一点,很容易出现:
- 两套消息历史同时增长;
- 工具调用重复执行;
- token 既被自有引擎又被 Pipecat aggregator 消费;
- RTVI 文本重复或消失;
- TTS 重复播报;
- 取消只停止其中一套任务;
- 身份切换后旧 context 泄漏。
## 七、目标架构
```text
┌─────────────────────────────┐
WebRTC / RTVI ─────▶│ Pipecat Pipeline │
│ transport + turn + metrics │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ Flows Runtime(可切换) │
│ node / state / tools │
└──────────────┬──────────────┘
│
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
LLMService 适配器 Memory Policy Web Search
DeepSeek/Fallback + MemoryStore SearXNG
│ │
▼ ▼
流式文本与 TTS SQLite(继续使用)
```
### Flow state 只保存会话控制状态
建议字段:
- `session_id`
- 服务端绑定的 `user_id`(内部使用,不展示给模型)
- `display_name`
- `identity_version`
- `pending_memory_action`
- `pending_confirmation`
- 当前 node
不要把全部记忆内容复制进 Flow state,也不要把 state 当数据库。
### 初始节点
第一版只需要两个节点,不要设计庞大状态图:
1. `conversation`
- 普通陪伴对话;
- 可用只读搜索和受限记忆工具;
- 默认节点。
2. `confirm_memory_change`
- 只在修改、删除目标不明确或敏感写入时进入;
- 用户确认/取消后返回 `conversation`。
## 八、工具设计
### 全局工具
#### `web_search(query)`
- 复用 `app/search.py` 与 SearXNG。
- 保留最多两次连续搜索限制。
- 搜索失败返回工具错误结果,不让整轮异常退出。
- 来源 URL 保留给网页,TTS 继续不朗读来源段。
#### `memory_search(query)`
- `owner_user_id` 从会话注入,禁止模型传入。
- 只返回 confirmed、未过期、授权 scope。
- 不确认身份时返回不可用,不搜索任何私人记忆。
- 结果条数和超时沿用配置上限。
#### `memory_remember(kind, key, value)`
- 仅确认身份可用。
- 只有当前用户明确表达“记住/以后/别忘了/我的偏好是”等意图时,才能 confirmed。
- 模型推断或总结只能写 candidate。
- `owner_user_id`、source session/turn、scope 由服务端填充。
- 默认 private;模型不得扩大成 shared。
- 同 key confirmed 写入保持幂等并更新旧值。
- 姓名确定性快路径暂时保留,不立即交给模型。
#### `memory_forget(selector)`
- 仅确认身份可用。
- 精确匹配一条时可返回待删除摘要;删除前应有明确用户意图。
- 多条或模糊匹配进入 `confirm_memory_change`,不得猜测删除目标。
- 删除必须同时匹配 owner;不得只凭 memory_id。
- 返回“已删除/没有找到/需要确认”,不能把“请求已接收”说成“已经删除”。
#### `memory_update(key, value)`
- 用于用户明确纠正已有事实或偏好。
- 更新必须保留来源和更新时间。
- 若未来增加 superseded 状态,先做向后兼容 migration,不删旧 schema。
#### `profile_update(display_name)`
- 第一阶段可继续使用现有确定性姓名提取,不必立即暴露给模型。
- 如果以后工具化,也必须经过同一疑问句和角色描述过滤。
### 记忆执行策略必须独立于 prompt
不要仅依赖“请不要误记”的 system prompt。新增类似:
```text
app/agent/memory_policy.py
```
策略层输入至少包含:
- 当前原始 user text
- 工具请求
- 已确认身份
- 当前 session/turn
- 已有同 key 记忆
策略层输出:
- allow_confirmed
- allow_candidate
- needs_confirmation
- reject
模型可以提出动作,策略层拥有最终决定权。
## 九、分阶段实施
### Phase 0:保护基线
1. 运行 `git status --short`、保存 `git diff`。
2. 运行当前相关测试。
3. 记录网页文字、网页语音、搜索、姓名重连的基线延迟与行为。
4. 不修改线上数据库 schema。
完成标准:现有未提交修复没有丢失,基线测试结果有记录。
### Phase 1:独立兼容性 spike
新增独立实验,不先改生产入口,例如:
```text
p1/flows_spike.py
app/tests/test_flows_spike.py
```
需要验证:
1. 找到与 `pipecat-ai==1.10.0` 兼容的 `pipecat-ai-flows` 版本并精确 pin。
2. DeepSeek OpenAI-compatible 接口能在 Flows 中流式输出中文内容。
3. 流式 function-call arguments 能被正确聚合一次,不重复拼接。
4. 工具结果回灌后能生成最终回答。
5. 任务取消能终止 LLM 请求和后续工具执行。
6. context aggregator 不产生第二条 system message。
7. 能获得 token 级文本事件,满足网页显示和分句 TTS。
若任何一项不成立,停止生产集成,先记录结论;不得为了使用框架而绕过现有正确行为。
### Phase 2:抽出工具和策略层
建议新增:
```text
app/agent/
├── __init__.py
├── tools.py
├── memory_policy.py
├── flows_runtime.py
└── llm_service.py
```
- 先把现有 `web_search` 执行从 `ConversationEngine` 抽成可复用函数。
- 实现记忆工具,但先用 fake store 单测。
- legacy 路径继续工作;不要让 Flows 成为唯一调用者。
完成标准:工具层可被 legacy 和 flows 两条路径复用,策略测试不依赖真实 LLM。
### Phase 3:先接网页文字路径
- 新增 `AI_AGENT_RUNTIME`,默认仍为 `legacy`。
- 只让 RTVI `send-text` 在显式 `flows` 配置下走新路径。
- 保持网页用户文本、token 增量、完整 assistant 文本和可选 TTS。
- 语音路径暂时保持 legacy。
完成标准:同一服务可切换两条 runtime;legacy 回归无变化;flows 文字路径通过工具、
姓名、记忆和搜索验收。
### Phase 4:补齐通用记忆闭环
至少覆盖:
1. “记住我不吃香菜” → confirmed。
2. disconnect/reconnect → “我不吃什么”能召回香菜。
3. “我今天吃了香菜”不得自动变成稳定偏好。
4. “其实我现在吃香菜了”能明确更新旧偏好,而不是保留冲突 active 值。
5. “忘掉我的饮食偏好”能删除;目标模糊时先确认。
6. 访客无法读取或写入私人记忆。
7. 用户 A 的记忆绝不出现在用户 B 的 prompt、工具结果或日志中。
### Phase 5:迁移语音路径
只有 Phase 3、4 稳定后才迁移语音:
- 保持 STT transcript 和原始 user text 一致;
- 保持首句尽快进入 TTS;
- 工具调用期间不得把工具 JSON 念出来;
- 来源段不进入 TTS;
- 用户打断时取消 Flows、LLM、工具和 TTS 的同一轮 execution;
- `ConversationEngine` 中已经稳定的身份与记忆接口可以拆分复用,不要求一次删除旧类。
### Phase 6:打断控制链
当前 `SimpleVAD` 只做 RMS 话段切分。下一步先实现控制闭环,不在本阶段承诺真实硬件
声学效果:
```text
user-started-speaking
├─ 发出 InterruptionFrame / 客户端停播信号
├─ 取消当前 Flows/LLM/TTS execution
├─ 清空未发送和客户端已缓冲音频
└─ 保留新用户话语的 pre-roll 并继续收集
```
要求:
- 不等待 700ms 尾静音才停止助手;
- 使用开始阈值、连续帧或正式 VAD,避免单个噪声帧误触发;
- `_bot_speaking` 不能只代表服务端推帧结束,要考虑客户端仍在播放;
- 文字请求期间的麦克风回采不得错误抢占;
- 先做合成 Frame 回归;真实 AEC、扬声器回声和远场误触发留到硬件 P2。
### Phase 7:部署与清理
- flows 默认值只有在线上验收后才能从 legacy 改为 flows。
- 更新 README、部署说明和本交接文档。
- 在修改数据库前创建新备份。
- 确认 `/health`、网页文字、网页语音、搜索和重连记忆。
- 确认日志没有 API key、私人全文记忆或内部 ID 泄漏。
## 十、测试与验收矩阵
### 必须新增的单元测试
- 工具参数 schema 与非法参数。
- 模型不能传 owner_user_id/scope 越权。
- 未确认身份拒绝 private memory。
- 明确“记住”写 confirmed。
- 推断信息只写 candidate。
- 同 key 更新幂等。
- 模糊忘记返回 needs_confirmation。
- 精确忘记只删除当前 owner。
- Flows node 切换后不会重复执行工具。
- 取消后不再写记忆或发送音频。
### 必须保留的现有测试
```bash
.venv/bin/python app/tests/test_profile_memory.py
.venv/bin/python app/tests/test_memory.py
.venv/bin/python app/tests/test_identity.py
.venv/bin/python app/tests/test_search.py
.venv/bin/python app/tests/test_transport.py
.venv/bin/python app/tests/test_engine_e2e.py
```
### 真人网页验收
1. 文字:“记住我不吃香菜。”
2. disconnect/reconnect。
3. 文字:“我不吃什么?”
4. 语音重复 1–3。
5. 问实时问题,确认会搜索且显示来源。
6. 助手播长回答时插话,确认音频停止、插话未丢失。
7. 问“我是谁”,只能回答用户称呼,不能回答内部 ID。
### 性能验收
- 无工具普通对话不能因为 Flows 固定增加一次额外 LLM 调用。
- 记忆检索仍有超时和失败降级。
- 分别记录 VAD、STT、记忆检索、LLM 首 token、工具、TTS 首段和首音。
- 对比 legacy/flows 的 P50/P95;无工具首音出现明显回退时不得切默认。
## 十一、明确非目标
本次不要做:
- 唤醒词和 20 秒连续对话窗口;
- N5095 产品客户端、声卡 AEC 或阵列波束形成;
- 人脸自动确认说话者;
- Mem0、Chroma、Graphiti、LanceDB 等新存储;
- OpenClaw、Agno、LangGraph 或 LiveKit 迁移;
- 多 Agent、子 Agent 或复杂任务规划;
- 米家/Home Assistant 工具;
- systemd、自启动和公网暴露;
- 清理或重写所有 legacy 代码。
## 十二、回滚要求
1. `AI_AGENT_RUNTIME=legacy` 必须能绕过全部 Flows 代码。
2. 新数据库字段必须向后兼容,禁止 drop/rename 现有表或列。
3. 工具执行要有幂等键或 source turn,避免重试重复写入。
4. 新路径异常时,本轮可以无记忆继续,但不能自动切换后重复执行有副作用工具。
5. 部署前保留数据库与上一版本代码;回滚不能依赖重新生成用户数据。
## 十三、建议 Claude Code 的第一轮动作
```bash
cd ~/workspace/ai-companion
git status --short
git diff --check
# 先读这些文件,不要先改
sed -n '1,240p' services/bot_server.py
sed -n '240,560p' services/bot_server.py
sed -n '180,500p' app/engine.py
sed -n '1,220p' app/memory/base.py
sed -n '220,380p' app/memory/sqlite_store.py
```
然后只完成 Phase 1 spike,提交一份结论:
- 兼容的 flows 版本;
- 选择复用 Pipecat OpenAI-compatible `LLMService`,还是实现
`FallbackProvider → LLMService` 适配器;
- token、tool-call、取消和 context 的实测结果;
- 建议的最小生产集成 diff;
- 是否会破坏现有分句 TTS、RTVI 和 fallback。
未经 spike 结论,不进入 Phase 3。
@@ -0,0 +1,657 @@
# SearXNG 私有搜索服务部署与使用
> 2026-09-16 记录。为 [[AI桌面陪伴助手方案]] 部署一个不绑定 LLM 厂商的联网搜索后端。服务运行在 `cadadmin-server`,只监听宿主机回环地址 `127.0.0.1:18080`,由同机助手调用,不对局域网或公网开放。
---
## 一、结论与当前状态
当前方案是:**DeepSeek 负责判断何时搜索和组织回答,SearXNG 负责查询互联网。**
```text
用户问题
↓
DeepSeek 判断是否需要实时资料
↓ Function Calling: web_search(query)
AI Companion 调用 SearXNG JSON API
↓
SearXNG 聚合 360 搜索 / 搜狗等上游结果
↓ 标题、摘要、URL
结果作为 tool 消息回灌 DeepSeek
↓
生成正文 + 来源 URL
├─ 网页:显示正文和来源
└─ TTS:只朗读正文,不读网址
```
截至 2026-09-16:
| 项目 | 状态 |
|---|---|
| 容器 | `ai-companion-searxng`,运行中 |
| 宿主机地址 | `http://127.0.0.1:18080` |
| 暴露范围 | 仅服务器回环地址 |
| 重启策略 | `unless-stopped` |
| JSON API | 已开启 |
| 可用上游 | 360 搜索、搜狗 |
| 暂不可用 | DuckDuckGo、Brave、Wikipedia 超时;Bing 暂时返回空结果 |
| 助手接入 | 已完成并通过真实工具调用验收 |
| 镜像 | 固定到已验收的 OCI digest,未使用浮动 `latest` |
真实闭环验收结果:DeepSeek 主动调用 `web_search('DeepSeek 最新消息 2025')`,SearXNG 返回 5 条结果,结果回灌后模型生成带两个来源 URL 的最终回答。
---
## 二、SearXNG 是什么
SearXNG 是开源的**元搜索引擎**。它本身不维护网页索引,而是把查询转发给多个搜索服务和数据库,再统一整理结果。官方项目强调不追踪、不画像用户,代码采用 AGPL-3.0 许可证。
这意味着:
- 自建 SearXNG 不需要维护爬虫和海量网页索引;
- 搜索质量、时延和可用性仍依赖上游搜索引擎;
- 同一个配置换到不同网络环境,结果可能完全不同;
- 自建的主要价值是统一 API、集中控制搜索源、减少对单一商业 API 的绑定,而不是“完全脱离外部搜索服务”。
官方资料:
- [SearXNG GitHub](https://github.com/searxng/searxng)
- [容器部署文档](https://docs.searxng.org/admin/installation-docker.html)
- [`settings.yml` 文档](https://docs.searxng.org/admin/settings/settings.html)
- [搜索引擎配置](https://docs.searxng.org/admin/settings/settings_engines.html)
- [Search API](https://docs.searxng.org/dev/search_api.html)
### 为什么选择它
| 方案 | 优点 | 缺点 | 本项目结论 |
|---|---|---|---|
| **SearXNG** | 开源、自建、统一 JSON API、可更换上游、无按次 API Key | 需要运维;上游会限流、验证码或封禁 | **采用** |
| DeepSeek 官方搜索 | 模型接入简单 | DeepSeek API 当前没有可直接使用的内置网页搜索,仍需自行实现工具 | 不可单独解决 |
| DDGS/DuckDuckGo 库 | 代码少、原型快 | 单一来源,服务器网络下 DuckDuckGo 实测超时 | 不采用 |
| Tavily / Brave / Exa | API 稳定,面向 Agent | 需要 Key,可能按量收费,绑定厂商 | 可作为未来备用 |
| Vane/Perplexica | 完整开源 AI 搜索应用 | 自带 UI、LLM 编排,与现有助手架构重叠,集成过重 | 只作参考 |
选择 SearXNG 的核心原因不是“绝对免费”,而是把搜索后端抽象成独立服务:以后更换 DeepSeek、本地模型或其他 LLM,不需要重写搜索层。
---
## 三、部署布局
代码仓库中的配置:
```text
~/ai-companion/deploy/searxng/
├── docker-compose.yml
├── settings.yml.example # 可提交的模板,密钥是占位符
└── settings.yml # 服务器实际配置,含随机密钥,不进 Git
```
Docker 数据:
```text
容器名:ai-companion-searxng
配置挂载:./settings.yml → /etc/searxng/settings.yml:ro
缓存卷:searxng_searxng-cache → /var/cache/searxng
端口:127.0.0.1:18080 → 容器 8080
```
官方文档列出的两个持久化位置是 `/etc/searxng` 和 `/var/cache/searxng`。本项目把配置作为只读单文件挂载,缓存放 named volume。配置必须备份,缓存丢失可以重建。
---
## 四、从零部署
### 1. 前提检查
```bash
docker version
docker compose version
ss -ltn | grep ':18080 ' || true
```
本次服务器环境:Docker 28.4.0、Docker Compose v2.39.4,`18080` 原先未占用。
### 2. Compose 文件
`deploy/searxng/docker-compose.yml`:
```yaml
services:
searxng:
# 固定到 2026-09-16 已通过真实搜索验收的镜像。
image: docker.io/searxng/searxng@sha256:f1aca3f61c7ff4552b8d54c0ab7fb458e907b78f51c503210cf450c900adca2c
container_name: ai-companion-searxng
restart: unless-stopped
ports:
- "127.0.0.1:18080:8080"
volumes:
- ./settings.yml:/etc/searxng/settings.yml:ro
- searxng-cache:/var/cache/searxng
environment:
- SEARXNG_BASE_URL=http://127.0.0.1:18080/
volumes:
searxng-cache:
```
为什么不写 `0.0.0.0:18080:8080`:当前服务没有对公网用户设计认证和限流,只供同机助手调用。绑定 `127.0.0.1` 能从网络边界直接阻止其他机器访问。
为什么固定 digest:`latest` 会在下次拉取时静默改变版本,可能带来配置兼容或搜索引擎行为变化。应先在新镜像上完成 API 和真实工具调用验收,再更新 digest。
### 3. `settings.yml` 模板
```yaml
use_default_settings:
engines:
keep_only:
- bing
- bing news
- 360search
- sogou
general:
instance_name: AI Companion Search
debug: false
search:
safe_search: 1
formats:
- html
- json
server:
secret_key: "__SEARXNG_SECRET__"
bind_address: "0.0.0.0"
limiter: false
public_instance: false
image_proxy: false
engines:
- name: bing
disabled: false
- name: bing news
disabled: false
- name: 360search
disabled: false
- name: sogou
disabled: false
```
关键解释:
- `use_default_settings`:继承官方完整默认配置,只覆盖本项目关心的部分,减少升级维护量;
- `keep_only`:只加载指定引擎,避免每次等待当前网络必然超时的搜索源;
- `disabled: false`:必须显式启用。**只写 `keep_only` 不等于启用引擎**,它会保留默认的禁用状态;
- `formats: [html, json]`:JSON API 必须显式开放。官方文档说明,未启用的输出格式会返回 HTTP 403;
- `safe_search: 1`:默认使用中等级别安全搜索;
- `limiter: false`:仅因为宿主机端口只绑定回环地址且是单用户内部调用;如果对外暴露,不能照抄;
- 容器内部 `bind_address` 为 `0.0.0.0`,是为了让 Docker 端口映射能访问;真正的外部边界由宿主机的 `127.0.0.1:18080` 控制。
### 4. 生成实际配置
不要把真实 `secret_key` 写进仓库:
```bash
cd ~/ai-companion/deploy/searxng
search_secret=$(openssl rand -hex 32)
sed "s/__SEARXNG_SECRET__/${search_secret}/" \
settings.yml.example > settings.yml
chmod 600 settings.yml
```
仓库 `.gitignore` 已包含:
```gitignore
deploy/searxng/settings.yml
```
### 5. 启动
```bash
cd ~/ai-companion/deploy/searxng
docker compose pull
docker compose up -d
docker compose ps
```
预期端口应显示为:
```text
127.0.0.1:18080->8080/tcp
```
首次拉取镜像在本次环境中比较慢,但下载一直有进度;不要因为几十秒没有完成就重复启动多个 `docker pull`。
---
## 五、验证与使用
### 1. 最小健康验证
SearXNG 没有必要为本项目额外暴露公开健康接口,直接查询 JSON API:
```bash
curl -GsS --max-time 20 http://127.0.0.1:18080/search \
--data-urlencode 'q=DeepSeek' \
--data-urlencode 'format=json' \
| jq '.results | length'
```
返回大于零的数字才算真正可用。**HTTP 200 或容器处于 Up 状态不代表上游搜索可用。**
查看前三条结果:
```bash
curl -GsS --max-time 20 http://127.0.0.1:18080/search \
--data-urlencode 'q=DeepSeek 最新消息' \
--data-urlencode 'format=json' \
--data-urlencode 'language=zh-CN' \
| jq -r '.results[0:3][] | .title + " | " + .url'
```
官方 API 支持 `/` 和 `/search` 两个端点、GET/POST 两种方法;常用参数包括:
| 参数 | 用途 |
|---|---|
| `q` | 必填,搜索词 |
| `format` | `json` / `csv` / `rss`,必须在配置中启用 |
| `language` | 搜索语言,如 `zh-CN` |
| `categories` | 搜索分类 |
| `engines` | 临时指定引擎,逗号分隔 |
| `pageno` | 页码 |
| `time_range` | `day` / `month` / `year`,仅对支持的引擎有效 |
| `safesearch` | `0` / `1` / `2` |
指定单一引擎诊断:
```bash
curl -GsS http://127.0.0.1:18080/search \
--data-urlencode 'q=DeepSeek' \
--data-urlencode 'format=json' \
--data-urlencode 'engines=sogou' \
| jq '.results | length'
```
### 2. 从其他电脑临时打开 Web UI
服务没有直接暴露到局域网。需要人工查看时,用 SSH 隧道:
```bash
ssh -L 18080:127.0.0.1:18080 \
yonglin@cadadmin-server.taild0f91d.ts.net
```
然后在本机浏览器打开:
```text
http://localhost:18080/
```
不要为了偶尔看 UI 就把 Compose 改成 `0.0.0.0:18080:8080`。
### 3. Python 调用示例
```python
import aiohttp
async def search(query: str):
timeout = aiohttp.ClientTimeout(total=6)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(
"http://127.0.0.1:18080/search",
params={
"q": query,
"format": "json",
"language": "zh-CN",
"safesearch": "1",
},
) as response:
response.raise_for_status()
return await response.json(content_type=None)
```
生产代码还应限制查询长度、结果数量、摘要长度,并只接受 `http` / `https` URL。本项目的实现见 `ai-companion/app/search.py`。
---
## 六、接入 AI Companion
### 配置
服务器 `~/.config/ai_assistant/env`:
```bash
SEARCH_ENABLED=true
SEARXNG_BASE_URL=http://127.0.0.1:18080
# 可选,下面是代码默认值
SEARCH_TIMEOUT_S=6
SEARCH_MAX_RESULTS=5
SEARCH_LANGUAGE=zh-CN
```
配置字段:
| 变量 | 默认值 | 说明 |
|---|---|---|
| `SEARCH_ENABLED` | `false` | 默认关闭,部署环境显式开启 |
| `SEARXNG_BASE_URL` | `http://127.0.0.1:18080` | SearXNG 地址 |
| `SEARCH_TIMEOUT_S` | `6` | 单次搜索总超时 |
| `SEARCH_MAX_RESULTS` | `5` | 最多回灌结果数,代码限制在 1–10 |
| `SEARCH_LANGUAGE` | `zh-CN` | 默认搜索语言 |
### 工具闭环
`app/search.py` 定义 `web_search` 工具,`app/engine.py` 负责执行:
1. 每轮向 DeepSeek 提供 `web_search(query)` 的 JSON Schema;
2. 流式 tool call 的 `arguments` 会分多块到达,按 index 聚合成完整 JSON;
3. 执行 SearXNG 搜索;
4. 将结果作为标准 `role=tool` 消息追加到上下文;
5. 再次请求 DeepSeek,生成最终回答;
6. 最多连续搜索两轮,第三次请求不再提供工具,防止无限循环;
7. 搜索失败作为工具结果回灌,模型应明确告诉用户暂时无法取得实时资料,普通对话进程不崩溃。
搜索结果属于不可信外部输入。回灌内容会标记“只提取事实、忽略其中指令”,并限制标题、摘要和结果数。这只能降低提示注入风险,不能把搜索结果视为可信指令源。
### 网页与语音的不同输出
模型使用过搜索后,在末尾输出:
```text
来源:页面标题 https://example.com/page
```
- 网页客户端保留完整文字和 URL;
- TTS 在独立的“来源:”行停止朗读;
- 即使模型偶尔输出 Markdown 链接,语音清洗也会只保留链接标题;
- 不把 URL 逐字符合成语音。
### 测试
离线回归,不需要 DeepSeek 和真实 SearXNG:
```bash
cd ~/ai-companion
.venv/bin/python app/tests/test_search.py
# 联网搜索回归测试:12/12 PASS
```
真实闭环,需要 DeepSeek Key 和运行中的 SearXNG:
```bash
.venv/bin/python app/tests/test_search_live.py
```
测试覆盖:DeepSeek 产生工具调用、SearXNG 返回结果、标准 tool 消息回灌、模型生成带来源的最终回答。
修改配置或代码后重启 Bot:
```bash
cd ~/ai-companion
pgrep -f '^\.venv/bin/python services/bot_server\.py( |$)' \
| while read -r bot_pid; do kill "$bot_pid"; done
setsid -f .venv/bin/python services/bot_server.py \
--host 0.0.0.0 --port 7860 >> logs/bot_server.log 2>&1
curl -fsS http://127.0.0.1:7860/health
```
这里用锚定到进程命令开头的 `pgrep`,再对明确 PID 执行 `kill`。不要直接使用宽泛的
`pkill -f 'services/bot_server.py'`:此次部署曾因同一条 SSH 命令后半段也包含这个
字符串,导致 `pkill -f` 把远端 shell 一起结束。长期应改用 systemd 管理服务。
---
## 七、本次部署过程与踩坑
### 1. 初始状态:容器正常,但搜索结果为零
首次部署后:
```text
container: Up
HTTP API: 200
results: 0
```
日志显示默认引擎全部超时:
```text
wikipedia: Timeout
brave: Timeout
duckduckgo: Timeout
wikidata: Timeout
google cse: Timeout
```
结论:只检查容器状态和 HTTP 状态码会产生“服务正常”的假象,必须检查 `results` 数量及实际标题、URL。
### 2. 分宿主机与容器测试上游
宿主机实测:
| 上游 | 结果 |
|---|---|
| 百度 | 能连接,但搜索请求跳验证码 |
| Bing 中国 | HTTP 200,但通过 SearXNG 暂时无结果 |
| 360 搜索 | HTTP 200,SearXNG 有结果 |
| 搜狗 | HTTP 200,SearXNG 有结果 |
| DuckDuckGo HTML | 8 秒超时 |
容器内访问百度成功,说明不是 Docker bridge 完全断网,而是特定境外上游不可达或受限。因此没有改成 host 网络,而是调整搜索源。
### 3. `keep_only` 不会自动启用引擎
第一次只配置:
```yaml
use_default_settings:
engines:
keep_only:
- bing
- 360search
- sogou
```
逐个指定 `engines=360search` 或 `engines=sogou` 时有结果,但不指定 `engines` 的聚合查询仍返回 0。
原因:`keep_only` 只限制加载范围,仍继承这些引擎在默认配置中的 `disabled` 状态。补上以下配置后聚合查询恢复:
```yaml
engines:
- name: 360search
disabled: false
- name: sogou
disabled: false
```
最终聚合测试先后返回 8、14、5 条结果;数量会随查询和上游状态变化,判断标准应是“大于零且内容合理”,不能写死固定数量。
### 4. JSON API 默认不一定开放
SearXNG 的默认输出格式通常只有 HTML。如果没有在 `search.formats` 中加入 `json`:
```text
/search?q=test&format=json → 403 Forbidden
```
这是配置行为,不是路由不存在。
### 5. 搜索源会动态失效
元搜索无法消除上游限制。常见变化:
- 某引擎开始返回验证码;
- 服务器 IP 被限流;
- 引擎页面结构变化,SearXNG 解析器暂时失效;
- 境外域名在当前网络超时;
- 语言参数或复杂搜索词导致特定引擎零结果。
所以必须保留多引擎、超时、日志和失败降级,不能假定“一次配置永久可用”。
---
## 八、日常运维
### 状态和日志
```bash
cd ~/ai-companion/deploy/searxng
docker compose ps
docker compose logs --tail=100 searxng
docker stats --no-stream ai-companion-searxng
```
确认端口没有意外对外暴露:
```bash
docker inspect --format '{{(index (index .HostConfig.PortBindings "8080/tcp") 0).HostIp}}:{{(index (index .HostConfig.PortBindings "8080/tcp") 0).HostPort}}' \
ai-companion-searxng
# 必须是 127.0.0.1:18080
```
### 重启与停止
```bash
docker compose restart searxng
docker compose stop searxng
docker compose up -d searxng
```
`docker compose down` 会删除容器和 Compose 网络,但默认不会删除 named volume;不要随意加 `-v`。
### 升级
当前 Compose 固定 digest。升级步骤:
```bash
docker pull docker.io/searxng/searxng:latest
docker image inspect docker.io/searxng/searxng:latest \
--format '{{index .RepoDigests 0}}'
```
把得到的 `searxng/searxng@sha256:...` 更新到 Compose,然后:
```bash
docker compose up -d
# API 验收
curl -GsS --max-time 20 http://127.0.0.1:18080/search \
--data-urlencode 'q=DeepSeek' \
--data-urlencode 'format=json' \
| jq '.results | length'
# DeepSeek 工具闭环验收
cd ~/ai-companion
.venv/bin/python app/tests/test_search_live.py
```
两个测试都通过后再提交新的 digest。官方文档建议升级时检查 Compose 模板和环境变量变化;固定 digest 不意味着永不升级,而是把升级变成显式、可验证的操作。
### 备份与恢复
需要备份:
```text
deploy/searxng/docker-compose.yml
deploy/searxng/settings.yml.example
服务器实际的 deploy/searxng/settings.yml(包含 secret,放安全备份)
```
缓存卷不属于关键数据,可以重新创建。恢复时也可以从模板生成新的随机 secret,不要求保留旧值,因为当前没有外部登录会话依赖它。
---
## 九、故障排查
| 现象 | 可能原因 | 检查与处理 |
|---|---|---|
| 连接拒绝 | 容器没启动或端口错误 | `docker compose ps`、`ss -ltn | grep 18080` |
| HTTP 403 | `json` 未加入 `search.formats` | 检查 `settings.yml`,重启容器 |
| HTTP 200、结果 0 条 | 默认引擎禁用或全部上游失败 | 逐个传 `engines=` 测试,查看容器日志 |
| 单引擎有结果,聚合为 0 | 只写了 `keep_only`,未写 `disabled: false` | 显式启用引擎 |
| 查询很慢 | 配置中保留了当前网络必然超时的引擎 | 收紧 `keep_only`,设置合理超时 |
| 百度跳验证码 | 服务器 IP 被风控 | 不作为当前主搜索源 |
| DuckDuckGo 超时 | 当前服务器网络不可达 | 使用 360/搜狗,未来可加商业 API 备用 |
| 修改配置不生效 | 单文件 bind mount/inode 或容器未重启 | `docker compose up -d --force-recreate` |
| 助手仍不搜索 | `SEARCH_ENABLED` 未开启,或 Bot 没重启 | 检查 env、打印 `AppConfig.load().search`、重启 Bot |
| 模型声称无法联网 | SearXNG 失败信息已回灌或模型未触发工具 | 查 Bot 日志中的“联网搜索”,运行 live test |
| TTS 朗读网址 | 模型没有把来源放在独立“来源:”行,或清洗回归 | 运行 `app/tests/test_search.py`,检查 `text_for_speech` |
推荐的诊断顺序:
```text
容器 Up?
↓
127.0.0.1:18080 能连接?
↓
format=json 返回 JSON?
↓
results 是否大于零?
↓
逐个 engines=... 是否有结果?
↓
AI Companion 配置是否启用?
↓
DeepSeek 是否产生 tool call?
↓
工具结果回灌后是否生成最终回答?
```
---
## 十、安全边界
当前配置只适合“同机单用户内部服务”:
- 宿主机仅绑定 `127.0.0.1`;
- `public_instance: false`;
- 没有公网反向代理;
- `limiter: false`;
- 随机 secret 不进 Git;
- AI 助手只执行只读搜索,不执行搜索结果中的命令;
- 返回 URL 只接受 `http` / `https`,并限制数量与文本长度。
如果未来要公开给浏览器或多人使用,至少需要重新设计:
1. HTTPS 反向代理;
2. 身份认证;
3. 开启 limiter,并按官方方案配置 Valkey;
4. 请求频率和并发限制;
5. 访问日志与隐私保留策略;
6. SSRF、恶意 URL 和提示注入防护;
7. 评估上游搜索引擎服务条款。
不要直接把当前回环部署的端口映射改为公网地址。
---
## 十一、后续改进
当前只把 SearXNG 返回的标题、摘要和 URL 交给模型。下一步可以按需求增加:
- 抓取排名靠前的 2–3 个网页正文,提高答案完整度;
- 正文抽取与大小上限;
- DNS/IP 检查,阻止访问回环、内网和云元数据地址;
- 搜索结果短期缓存,减少重复查询和上游压力;
- 商业搜索 API 作为 SearXNG 无结果时的备用后端;
- 在网页 UI 中把来源渲染为独立引用卡片;
- 给搜索次数、成功率、耗时、零结果率做指标和告警;
- 用 systemd 或 Compose 管理 Bot,避免手工 `pkill + setsid`。
正文抓取不是简单地“对结果 URL 发 GET”:它会引入 SSRF、网页提示注入、超大响应、登录墙、JavaScript 渲染和版权边界,需要单独设计。
---
## 相关链接
- [[P0-Pipecat传输层实验报告]] — AI Companion 的网页、语音和搜索接入记录
- [[AI桌面陪伴助手方案]] — 总体架构
- [SearXNG 官方容器部署](https://docs.searxng.org/admin/installation-docker.html)
- [SearXNG Search API](https://docs.searxng.org/dev/search_api.html)
- [SearXNG `settings.yml`](https://docs.searxng.org/admin/settings/settings.html)
---
*最后更新:2026-09-16*