Compare commits

...
5 Commits
Author SHA1 Message Date
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
3 changed files with 998 additions and 23 deletions
@@ -5,16 +5,28 @@
> (独立 git 仓库),包括:助手本体、TTS 服务、P0 基准脚本、ESP32 固件、 > (独立 git 仓库),包括:助手本体、TTS 服务、P0 基准脚本、ESP32 固件、
> `README.md`(代码说明)、`部署说明.md`(运维)——这两份也在代码仓库。 > `README.md`(代码说明)、`部署说明.md`(运维)——这两份也在代码仓库。
> >
> 更新:2026-09-15。状态:**助手本体已在服务器上实现并跑通**(端到端测试 16/16);设备侧(N5095 外设桥、ESP32)尚未开始。 > 更新: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 寸圆屏)。 > 已确认:Ubuntu 24.04;N5095 / 16G 作为**客户端**;助手本体部署在 cadadmin-server(i9-14900K / 3090);麦克风、音箱、摄像头均未购;屏幕已于 09-14 到货并点亮(2.8 寸 ILI9341 触摸屏,非原定的 1.28 寸圆屏)。
> 核心目标:听得清、回应及时、可打断、表情明确、转头自然。 > 核心目标:听得清、回应及时、可打断、表情明确、转头自然。
> 最新外形要求:屏幕不用太大;摄像头水平可用旋转总行程至少180°,按正前方左右各90°理解,不是镜头视场角180°。 > 最新外形要求:屏幕不用太大;摄像头水平可用旋转总行程至少180°,按正前方左右各90°理解,不是镜头视场角180°。
> 原方案备份:AI桌面陪伴助手方案.v1备份.md。本版指标为拟定目标,不是已测性能。 > 原方案备份:AI桌面陪伴助手方案.v1备份.md。本版指标为拟定目标,不是已测性能。
> >
> **本版重大变更:架构从"单机"改为"服务器即助手、本地为客户端"。** > **本版重大变更:架构从"单机"改为"服务器即助手、本地为客户端"。**
> 起因是实测发现 N5095(无 AVX2)跑不动可用的语音模型——详见 >
> `P0-语音基准报告.md`。变更后 N5095 只做 USB 外设接入与网络桥接。 > 起因要说得准确:**N5095 并非跑不动本地语音**。实测证明它能跑
> 详细部署现状见代码仓库里的 `部署说明.md`,实测数据见本目录的四份 P0 报告。 > 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. 范围与待讨论事项 ## 1. 范围与待讨论事项
@@ -24,7 +36,7 @@
|---|---|---| |---|---|---|
| **主机分工** | **服务器(cadadmin-server)跑助手本体;N5095 降为客户端/外设桥** | **v2.4 重大变更,见 §2** | | **主机分工** | **服务器(cadadmin-server)跑助手本体;N5095 降为客户端/外设桥** | **v2.4 重大变更,见 §2** |
| 服务器 | i9-14900K / 32线程 / 188G / RTX 3090 24G,局域网延迟 1–2ms | 与 N5095 同局域网,1ms 级 | | 服务器 | i9-14900K / 32线程 / 188G / RTX 3090 24G,局域网延迟 1–2ms | 与 N5095 同局域网,1ms 级 |
| N5095 角色 | USB 麦克风阵列、USB 摄像头、DoA 读取、WebRTC 客户端、串口给 ESP32 | 不跑任何推理 | | N5095 角色 | USB 麦克风阵列、USB 摄像头、DoA 读取、WebRTC 客户端、串口给 ESP32 | **不运行 STT/TTS/LLM 等重型推理**;本地仍跑 VAD、唤醒词、人脸检测等轻量推理(这些延迟敏感或依赖本地硬件,放服务器反而更慢) |
| 使用人数 | 本人使用为主,同时考虑多人扩展 | 首版基础多人,身份与会话从一开始分离 | | 使用人数 | 本人使用为主,同时考虑多人扩展 | 首版基础多人,身份与会话从一开始分离 |
| 新增预算 | 服务器为复用已有机器,不算追加采购 | 音频优先,其次屏幕,再机械 | | 新增预算 | 服务器为复用已有机器,不算追加采购 | 音频优先,其次屏幕,再机械 |
| 操作系统、现有外设 | Ubuntu 24.04 已确认;麦克风、音箱、摄像头都没有;屏幕已到货 | 需采购音频与摄像头 | | 操作系统、现有外设 | Ubuntu 24.04 已确认;麦克风、音箱、摄像头都没有;屏幕已到货 | 需采购音频与摄像头 |
@@ -40,15 +52,16 @@
## 2. 总体架构 ## 2. 总体架构
```text ```text
┌─ cadadmin-server(192.168.0.20)—— 助手本体 ────────────────┐ ┌─ cadadmin-server(172.16.1.101)—— 助手本体 ────────────────┐
│ 会话引擎(身份 → 记忆 → LLM → TTS) │ │ 会话引擎(身份 → 记忆 → LLM → TTS) │
│ ├─ LLM:DeepSeek deepseek-flash(云端,关思考) │ │ ├─ LLM:DeepSeek deepseek-flash(云端,关思考) │
│ ├─ TTS:Qwen3-TTS 1.7B(RTX 3090,独立 HTTP 服务 :18040) │ │ ├─ TTS:Qwen3-TTS 1.7B(RTX 3090,独立 HTTP 服务 :18040) │
│ ├─ 传输:Pipecat SmallWebRTC :7860(实时链路已闭环) │
│ ├─ STT:Zipformer 中英宽带 int8(CPU 4 线程) │ │ ├─ STT:Zipformer 中英宽带 int8(CPU 4 线程) │
│ ├─ 记忆:SQLite(按 owner_user_id 强制隔离) │ │ ├─ 记忆:SQLite(按 owner_user_id 强制隔离) │
│ └─ 身份:手动切换 + 访客兜底 │ │ └─ 身份:手动切换 + 访客兜底 │
└────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────┘
▲ WebRTC(计划) ▲ 手机/浏览器客户端 ▲ WebRTC(实验中) ▲ 手机/浏览器客户端
│ 局域网 1–2ms │ │ 局域网 1–2ms │
┌─ N5095 / 16G —— 客户端 / 外设桥 ───────────────────────────┐ ┌─ N5095 / 16G —— 客户端 / 外设桥 ───────────────────────────┐
│ USB 麦克风阵列(固定底座)──处理后音频、DoA──┐ │ │ USB 麦克风阵列(固定底座)──处理后音频、DoA──┐ │
@@ -68,8 +81,16 @@ ESP32-S3:本地表情、运动轨迹、限位、心跳超时处理
- **分工原则:延迟敏感且硬件绑定的留在本地**(音频进出、唤醒、VAD、 - **分工原则:延迟敏感且硬件绑定的留在本地**(音频进出、唤醒、VAD、
插话检测、DoA、表情/动作),**算力密集的放服务器**(STT、TTS、LLM 调用、记忆)。 插话检测、DoA、表情/动作),**算力密集的放服务器**(STT、TTS、LLM 调用、记忆)。
- Pipecat作为唯一会话管线框架。服务器侧用 WebRTC 传输(SmallWebRTC), - Pipecat作为唯一会话管线框架。服务器侧用 WebRTC 传输(SmallWebRTC),
**WebRTC 自带 AEC、抖动缓冲、丢包恢复**——这解决了方案 P2 要求的 AEC 难题, 它**自带** AEC、抖动缓冲、丢包恢复的实现,省去了自行编写。
原本需要自行实现。[传输选型](https://docs.pipecat.ai/client/concepts/choosing-a-transport) [传输选型](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) - 不叠加Vision Agents:它是Stream的独立框架,不是Pipecat官方扩展。人脸检测另选轻量本地模型。[项目官方仓库](https://github.com/GetStream/vision-agents)
- STT/TTS采用可替换适配器(本地/远端两种后端已实现)。LLM 为 DeepSeek - STT/TTS采用可替换适配器(本地/远端两种后端已实现)。LLM 为 DeepSeek
`deepseek-flash`;这是可更新别名,实施时记录实际版本并做流式/工具调用验证。 `deepseek-flash`;这是可更新别名,实施时记录实际版本并做流式/工具调用验证。
@@ -101,7 +122,7 @@ ESP32-S3:本地表情、运动轨迹、限位、心跳超时处理
|---|---|---|---| |---|---|---|---|
| P0 环境与服务 | 盘点Ubuntu版本/USB;固定依赖;本地STT/TTS基准及DeepSeek请求 | 样例音频/文本先测试;设备到货后10次录放测试;记录RTF、首音延迟、CPU和内存 | 🔶 **环境盘点、STT/TTS/LLM 基准、DeepSeek 四条路径均已实测**;缺 10 次录放(无音频硬件)。**STT 中文识别率仍是未闭合项** | | 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,卡在目标线** | | 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 提供**(不自行实现) | | P2 外放与表情 | AEC、打断、唤醒;听/想/说/离线/静音表情 | 指定距离音量下20次打断至少18次成功;检测到有效用户语音至停播目标≤300ms;30分钟外放无自我触发;运行2小时稳定 | ⬜ 未开始。**AEC 待验证**——WebRTC 有现成实现,但需要麦克风采集与播放参考正确送入、且 XVF3800 自身的 AEC 路由要实测(见 §2) |
| P3 多用户记忆 | 用户档案、显式切换、偏好/摘要/修改/遗忘 | 两个用户同一问题得到各自偏好;未知用户无私人记忆;重启保留、更正/删除有效 | 🔶 数据层已实现并测试(身份隔离、越权删除、级联清理、候选不参与检索共 28 项);缺"修改/遗忘"的用户入口 | | P3 多用户记忆 | 用户档案、显式切换、偏好/摘要/修改/遗忘 | 两个用户同一问题得到各自偏好;未知用户无私人记忆;重启保留、更正/删除有效 | 🔶 数据层已实现并测试(身份隔离、越权删除、级联清理、候选不参与检索共 28 项);缺"修改/遗忘"的用户入口 |
| P4 一轴转头 | 静态DoA/人脸验证,再接电机;锁定和限位 | 左/中/右各10次唤醒至少27次转向正确区域;轻微移动不频繁摆动;断联/重启不撞限位;运动时重复P2测试 | ⬜ 未开始,待摄像头与电机 | | P4 一轴转头 | 静态DoA/人脸验证,再接电机;锁定和限位 | 左/中/右各10次唤醒至少27次转向正确区域;轻微移动不频繁摆动;断联/重启不撞限位;运动时重复P2测试 | ⬜ 未开始,待摄像头与电机 |
| P4a 基础多人/身份试验 | 2~3张脸跟踪;轮流发言;可选录入式人脸识别 | 20次轮流唤醒至少18次选中正确目标;同方向/交叉/遮挡时允许拒判;未知人、身份切换不得串记忆 | 🔶 `IdentityProvider` 接口与手动切换已实现(人脸候选**不自动切换**已测试);缺人脸检测 | | P4a 基础多人/身份试验 | 2~3张脸跟踪;轮流发言;可选录入式人脸识别 | 20次轮流唤醒至少18次选中正确目标;同方向/交叉/遮挡时允许拒判;未知人、身份切换不得串记忆 | 🔶 `IdentityProvider` 接口与手动切换已实现(人脸候选**不自动切换**已测试);缺人脸检测 |
@@ -154,7 +175,17 @@ P2的30分钟/2小时测试仅是初步门槛,不能证明长期稳定。阶
阵列官方资料列出USB音频、DoA主机控制、可接耳机/有源音箱的AUX输出。采购成品板,不能仅凭XMOS芯片名替代。[Seeed官方文档](https://wiki.seeedstudio.com/respeaker_xvf3800_introduction/) 阵列官方资料列出USB音频、DoA主机控制、可接耳机/有源音箱的AUX输出。采购成品板,不能仅凭XMOS芯片名替代。[Seeed官方文档](https://wiki.seeedstudio.com/respeaker_xvf3800_introduction/)
拟定路径:N5095的TTS → 阵列USB播放端点 → AUX → 有源音箱。AEC需要实际播放参考;同一板卡也要验证固件/通道配置,不能假定接上即成功。[XMOS官方指南](https://www.xmos.com/documentation/XM-014888-PC/pdf/xvf3800_user_guide_v3.2.1.pdf) 拟定路径(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即可。最终内置喇叭/功放留到结构阶段,按接口、电压、阻抗和功率匹配。 不为了阵列额外购买XIAO ESP32S3,主机直接接USB即可。最终内置喇叭/功放留到结构阶段,按接口、电压、阻抗和功率匹配。
@@ -183,15 +214,19 @@ P2的30分钟/2小时测试仅是初步门槛,不能证明长期稳定。阶
### 语音方案(v2.4 已实测定稿) ### 语音方案(v2.4 已实测定稿)
STT是语音转文字,TTS是文字转语音。**v2.4 起语音推理全部在服务器**, STT是语音转文字,TTS是文字转语音。**v2.4 起重型语音推理(STT/TTS/LLM)全在服务器**,
N5095 只做音频进出——实测证明 N5095 跑不动可用的语音模型,见 `P0-语音基准报告.md`。 N5095 只做音频进出与本地轻量推理。
准确的表述是:**N5095 能完成低配本地语音,但达不到当前的目标音质与延迟**。
实测它能跑 Zipformer STT(RTF 0.22)和 Piper TTS(RTF 0.36);而音质达标的
Qwen3-TTS 在它上面 RTF 1.86(慢于实时),在服务器上只有 0.09。见 `P0-语音基准报告.md`。
| 模块 | 采用方案 | 实测 | 备选与限制 | | 模块 | 采用方案 | 实测 | 备选与限制 |
|---|---|---|---| |---|---|---|---|
| VAD | 本地轻量VAD | — | 单独处理唤醒和说完判断 | | VAD | 本地轻量VAD | — | 单独处理唤醒和说完判断 |
| 唤醒 | sherpa-onnx中文关键词检测作为候选 | — | 单独模型/词表,用户确定称呼后调阈值;普通STT不会自动等于唤醒引擎 | | 唤醒 | sherpa-onnx中文关键词检测作为候选 | — | 单独模型/词表,用户确定称呼后调阈值;普通STT不会自动等于唤醒引擎 |
| STT | **Zipformer 中英双语 int8**(服务器 CPU 4线程) | RTF 0.03 | **仍是 2023 年模型,中文识别率从未测过**(无参考文本)。候选:SenseVoice / faster-whisper large-v3,已下载未测 | | 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(未测) | | 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 | | 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)。以上说明模型/运行时支持,不构成性能保证。 [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)。以上说明模型/运行时支持,不构成性能保证。
@@ -210,7 +245,6 @@ N5095 只做音频进出——实测证明 N5095 跑不动可用的语音模型
P0测试10~20条不同长度中文,覆盖数字、日期、中英混读。记录STT识别错误、语音结束后等待时间、TTS首段耗时和RTF(生成/处理耗时除以音频时长,低于1表示平均快于实时)。音频与视觉并发时重测,限制推理线程和视觉帧率,避免CPU争抢。 P0测试10~20条不同长度中文,覆盖数字、日期、中英混读。记录STT识别错误、语音结束后等待时间、TTS首段耗时和RTF(生成/处理耗时除以音频时长,低于1表示平均快于实时)。音频与视觉并发时重测,限制推理线程和视觉帧率,避免CPU争抢。
**v2.4 补充:STT 与 TTS 的线程配置必须分别调**——实测两者线程扩展方向相反。 **v2.4 补充:STT 与 TTS 的线程配置必须分别调**——实测两者线程扩展方向相反。
P0测试10~20条不同长度中文,覆盖数字、日期、中英混读。记录STT识别错误、语音结束后等待时间、TTS首段耗时和RTF(生成/处理耗时除以音频时长,低于1表示平均快于实时)。音频与视觉并发时重测,限制推理线程和视觉帧率,避免CPU争抢。
TTS按语义短句合成并播放,不把整段一次合成称为原生流式;分句仍要测首音等待、接句停顿及取消响应。本地效果不达标时先调整模型/线程/分句,再讨论是否开启云端后备,不自动发送音频到第三方。 TTS按语义短句合成并播放,不把整段一次合成称为原生流式;分句仍要测首音等待、接句停顿及取消响应。本地效果不达标时先调整模型/线程/分句,再讨论是否开启云端后备,不自动发送音频到第三方。
@@ -227,6 +261,66 @@ TTS按语义短句合成并播放,不把整段一次合成称为原生流式
- 网络失败给简短提示,本地按键、静音和屏幕继续工作,不反复播旧回复。 - 网络失败给简短提示,本地按键、静音和屏幕继续工作,不反复播旧回复。
- 麦克风、音箱、电机尽量机械隔离;软件静音和真实物理断麦需明确区分。 - 麦克风、音箱、电机尽量机械隔离;软件静音和真实物理断麦需明确区分。
### 客户端/服务器的音频边界(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. 朝向与机械 ## 6. 朝向与机械
DoA是声源来向,不代表身份或距离。阵列固定底座,摄像头随头部转动。 DoA是声源来向,不代表身份或距离。阵列固定底座,摄像头随头部转动。
@@ -302,10 +396,83 @@ P5通过Home Assistant统一调用。按实际设备比较官方Xiaomi Home和Xi
## 10. 运行维护与当前下一步 ## 10. 运行维护与当前下一步
- 固定依赖版本,记录板卡SKU、固件、通道、引脚及接线图。 ### 已完成(截至 2026-09-15)
- Ubuntu优先先以普通用户的本机环境打通音频/USB,再确定常驻部署,暂不增加容器设备映射。核对Ubuntu版本、Python兼容性和CPU指令要求;模型文件授权与运行时授权分别记录。
- 日志保存耗时、错误、费用、连接事件并轮转,不默认保存完整私人对话。 - Ubuntu 24.04 已确认,USB 接口已盘点;依赖版本固定在代码仓库的 `requirements.txt`
- P2后配置开机启动、异常重启、音频/串口重连,测试拔插、断网、主机重启恢复。 - 助手本体在服务器上实现并跑通,端到端测试 16/16(**用文件音频,非实时链路**)
- 已确认Ubuntu和四类缺失外设,不再重复询问。继续确认Ubuntu版本、USB接口和使用距离;按清单核价音频/屏幕/摄像头。基础多人和多用户数据结构纳入本次开发,自动身份能力以实测为准。 - STT/TTS/LLM 三方基准实测;DeepSeek 四条路径验证
- 不等待上述答案也能先推进表情和协议设计,通用原型件按缺口采购。 - 记忆与身份的数据层实现并测试(88 项断言)
- 本次只更新规划和采购条件;未安装依赖、重装系统、购买硬件或做实机测试。 - 代码剥离为独立仓库 `~/workspace/ai-companion/`
### 下一步顺序(按风险从高到低)
评审建议的思路——**先把风险最大的实时语音链路闭环**——认可并采纳。
服务端 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 保留 |
| 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 和远场识别**——
那些必须等真实麦克风和音箱。但传输层能提前跑通,硬件一到就能直接接,
不用同时调两件事。
**理由**:语音链路是全局瓶颈。它若走不通,后面的表情、转头做得再好也没用;
而它若走通,其余部分都是可分解的独立工作。
### 最大功能风险: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,151 @@
# 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 客户端的雏形,不是产品代码
## 八、复现
```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,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*