这是进入 Pipecat Flows 改造前的回滚点。 提交时已核对本地与线上代码 md5 一致(engine.py 7e97bad8、 bot_server.py 1cf543ae),所以这个提交就是当前线上状态。 内容(此前一直在线上运行但未提交的改动): - 姓名档案:用户自报"我叫林"同步写 confirmed 姓名,重连后恢复 - 疑问句防误写:"谁吗"之类的疑问句不再被覆盖成姓名 - 新增 test_profile_memory.py(姓名档案专项回归) - 新增 test_profile_live.py(需要真实 LLM 的现场测试) - 搜索、身份、记忆测试的相应补充 背景:下一步要按 P1-Pipecat Flows Agent化与记忆改造交接.md 渐进引入 Pipecat Flows,交接文档明确要求先保护基线、不得 reset/checkout/覆盖现有改动。此提交即该基线。
178 lines
10 KiB
Markdown
178 lines
10 KiB
Markdown
# AI 桌面陪伴助手 · 代码说明
|
||
|
||
**架构一句话**:服务器是助手,本地是客户端。
|
||
助手本体(会话引擎、记忆、身份、STT/TTS 调度)跑在 cadadmin-server;
|
||
N5095 只做 USB 外设接入和网络桥接。理由与实测见 `P0-语音基准报告.md`。
|
||
|
||
运行方式与配置见 `部署说明.md`;总体方案见 `AI桌面陪伴助手方案.md`。
|
||
|
||
## 目录结构
|
||
|
||
```
|
||
app/ 助手本体(部署到服务器)
|
||
├── config.py 配置。全部可用环境变量覆盖——部署时不改代码
|
||
├── prompts.py 提示词构造。单条 system 每轮重建
|
||
├── engine.py 会话引擎:身份 → 记忆 → LLM → TTS 的核心回路
|
||
├── llm/ LLM 抽象与后端
|
||
│ ├── base.py 接口 + SSE 解析 + 消息归一化
|
||
│ ├── local_vllm.py 本机 vLLM(Qwen3.8-27B,已停用)
|
||
│ ├── deepseek.py DeepSeek 云 API(当前主用)
|
||
│ └── __init__.py 工厂 + 降级路由
|
||
├── memory/ 记忆
|
||
│ ├── base.py 接口。**按"可能远程"设计**——全 async、可超时、可失败
|
||
│ └── sqlite_store.py SQLite 实现
|
||
├── identity/ 身份
|
||
│ ├── base.py 接口。人脸只提供候选,**不自动切身份**
|
||
│ └── manual.py 首版实现:显式切换 + 访客兜底
|
||
├── voice/ 语音
|
||
│ ├── stt.py sherpa-onnx 流式 Zipformer(CPU)
|
||
│ ├── tts.py 本地 vits-zh-ll(sherpa-onnx)
|
||
│ └── remote_tts.py Qwen3-TTS 远端服务适配(当前主用)
|
||
└── tests/ 回归与真实服务验收脚本
|
||
|
||
deploy/searxng/ 私有联网搜索服务(仅监听服务器回环地址)
|
||
|
||
services/
|
||
├── tts_server.py Qwen3-TTS 服务(跑在 ~/tts-qwen,独立 venv)
|
||
└── bot_server.py Pipecat SmallWebRTC 服务端(实验,见下)
|
||
|
||
client/
|
||
└── test_client.py 自写 aiortc 客户端(实验用,也是 N5095 客户端的雏形)
|
||
|
||
p0/ P0 阶段的验证与基准脚本(保留用于复现)
|
||
├── verify_deepseek.py DeepSeek 四条路径
|
||
├── verify_local_llm.py 本机 Qwen3.8-27B
|
||
└── stt_tts_bench/ STT/TTS 基准、模型下载脚本、线程扫描
|
||
```
|
||
|
||
## 测试
|
||
|
||
```bash
|
||
# 服务器上(完整)
|
||
cd ~/ai-companion
|
||
.venv/bin/python app/tests/test_llm.py # 27 项
|
||
.venv/bin/python app/tests/test_memory.py # 30 项
|
||
.venv/bin/python app/tests/test_identity.py # 19 项
|
||
.venv/bin/python app/tests/test_engine_e2e.py # 16 项,端到端
|
||
.venv/bin/python app/tests/test_transport.py # 4 项,媒体时钟/VAD/静音/RTVI文本
|
||
.venv/bin/python app/tests/test_search.py # 12 项,搜索 API/工具回灌/TTS 链路过滤
|
||
.venv/bin/python app/tests/test_profile_memory.py # 22 项,自报姓名/断开重连/疑问句防误写
|
||
```
|
||
|
||
端到端测试**不需要麦克风、音箱、摄像头、ESP32**——
|
||
这是"服务器即助手"架构的直接好处:链路可以在任何有 CPU 的地方验证,
|
||
硬件到货后再接最后一段。跑完会把合成音频写到 `out/`,可以人工试听。
|
||
|
||
## 联网搜索
|
||
|
||
DeepSeek 负责判断何时需要实时资料,`app/search.py` 提供 `web_search` 工具并调用
|
||
服务器本机的 SearXNG。工具结果回灌模型后再生成最终回答;网页保留来源 URL,TTS
|
||
只朗读正文。SearXNG 仅绑定 `127.0.0.1:18080`,配置见 `deploy/searxng/`。
|
||
|
||
服务器实测 DuckDuckGo、Brave、Wikipedia 超时,当前启用 360 搜索和搜狗;Bing
|
||
保留但暂时返回空结果。真实服务验收:
|
||
|
||
```bash
|
||
.venv/bin/python app/tests/test_search_live.py
|
||
```
|
||
|
||
## 设计上几个刻意的选择
|
||
|
||
**接口按最坏情况设计。** `MemoryStore` 全是 async、可超时、可失败,
|
||
虽然现在背后是本地 SQLite(微秒级)。因为将来多设备共享记忆时要搬到服务器,
|
||
如果现在写成同步直连本地文件,那时要重写所有调用方。
|
||
|
||
**身份、会话、视觉追踪三者分开。** `user_id`(永久身份)、`session_id`(一次会话)、
|
||
`track_id`(画面里的一个目标)互不冒充。人脸认出来**不等于**确认对方在说话——
|
||
所以人脸只产生"建议",不自动切换身份。做错的后果是路人经过就把你的私人记忆念出来。
|
||
|
||
**内部 ID 与用户称呼分开。** `u_ylhan` 只用于数据库隔离,绝不是姓名。用户明确说
|
||
“我叫林”时,会同步写入 `users.display_name` 和一条 confirmed 姓名记忆;新连接创建
|
||
引擎时从档案恢复称呼。自报姓名只用于个性化,不提升身份信任等级。
|
||
|
||
**记忆查询强制带 `owner_user_id`。** 没有"按 memory_id 单独取"的口子:
|
||
猜到 id 就能读别人的记忆,是这类系统最容易犯的错。删除同理,必须同时匹配 owner。
|
||
|
||
**自动抽取的记忆是"候选",默认不参与检索。** 当前只有自报姓名走同步 confirmed
|
||
档案路径;“记住/我喜欢/我习惯”等通用规则仍统一写成 candidate,而且尚无候选确认/
|
||
晋升入口。因此除姓名外,内容可能已经落库却不会参与后续回答。这是下一阶段必须补齐
|
||
的功能,不应把“数据库里有记录”误报为“助手已经会使用这条记忆”。
|
||
|
||
**异步任务绑定创建时的身份。** 回复后写记忆的任务在创建时捕获 `user_id`,
|
||
不读"当前用户"——否则用户在这几百毫秒内切了身份,这条记忆会写到别人名下。
|
||
|
||
**引擎与传输解耦。** 不依赖 Pipecat、不依赖任何传输方式,只依赖四个接口。
|
||
好处:可以用文件音频端到端测试;接 WebRTC 时只需写薄适配层。
|
||
|
||
## Pipecat 实时链路:已闭环
|
||
|
||
2026-09-15 复查并修复传输层实验:
|
||
|
||
- ✅ 信令、ICE、data channel、双向媒体轨
|
||
- ✅ `PipelineWorker` 自动 `StartFrame`,自定义 `EngineProcessor` 正常收帧
|
||
- ✅ 文件音频 → STT → DeepSeek → Qwen3-TTS → WebRTC 非静音回复
|
||
- ✅ 保留 RTVI 和 `/client/` prebuilt 网页客户端,并提供其需要的 `/start` 入口
|
||
|
||
原来的“`process_frame` 调用次数为 0”并非管线接线或模型不兼容。真实根因是
|
||
Pipecat 启动预热 NLTK `punkt_tab` 时发生运行时下载,20 秒 setup 超时,worker
|
||
尚未发送 `StartFrame` 就退出。数据预装后帧路由正常,因此确定采用 A 方案:
|
||
Pipecat 管传输、帧与 RTVI,自有 `ConversationEngine` 管会话语义。
|
||
|
||
自写客户端另有两处测试缺陷也已修复:文件结束后的静音帧没有推进 PTS,导致 VAD
|
||
收不到 700ms 尾静音;接收端又把 WebRTC 自动静音误判为回复。现在成功条件是收到
|
||
非静音语音,并按实际 WebRTC 采样率保存 WAV。
|
||
|
||
2026-09-16 又通过 SSH 隧道用 `/client/` 做了真人浏览器测试:补齐 Playground
|
||
要求的 `/start` 后,麦克风 → STT → LLM → TTS → 浏览器播放正常,单轮服务端指标
|
||
约 3.0 秒。这个页面是调试工具而非产品 UI;当前连接期间由 VAD 检出每段话后都会
|
||
响应,**尚无唤醒门控**。正式唤醒仍按方案放在 P2 的 N5095 本地端,待机时不上传音频。
|
||
|
||
完整记录见代码仓库外的 `P0-Pipecat传输层实验报告.md`(在 Obsidian 笔记库里)。
|
||
|
||
### 当前打断边界(2026-09-16)
|
||
|
||
当前 `SimpleVAD` 是实验性的 RMS 能量判定:阈值 350、最短语音 300ms、尾静音
|
||
700ms。它能切分话段和触发下一轮,但不是完整的 barge-in。助手推送音频时检测到
|
||
用户声音目前只记日志;只有等话段结束,或收到外部 `InterruptionFrame`,才会取消
|
||
当前异步轮次。浏览器播放器虽有清空能力,服务端尚未把“用户开始说话”可靠地转换为
|
||
取消生成、清空服务端输出和清空客户端缓冲三个动作。现有测试只证明任务可取消,
|
||
未证明声学打断端到端闭环。
|
||
|
||
### 下一阶段 Agent 方向:Pipecat Flows
|
||
|
||
已决定不整体迁移 OpenClaw、Agno、LangGraph 或 LiveKit Agents,而是在现有 Pipecat
|
||
实时链路上渐进引入 `pipecat-ai-flows`。目标是把 `web_search`、`remember`、
|
||
`search_memory`、`forget` 等能力变成受策略约束的工具,同时保留现有
|
||
`MemoryStore`、身份隔离和低延迟语音链路。Flows 与当前自有 `ConversationEngine`
|
||
的接口并不直接兼容,必须先做独立兼容性实验,再通过 feature flag 灰度迁移;不得
|
||
直接重写已部署链路。实施交接见笔记库
|
||
`P1-Pipecat Flows Agent化与记忆改造交接.md`。
|
||
|
||
### 实验挖出的 10 个坑
|
||
|
||
每个都表现为"什么都没发生",极难排查:
|
||
|
||
1. Pipecat 客户端 SDK 不覆盖"Linux x86 盒子接 USB 阵列"这种形态 → 客户端得自己写
|
||
2. 客户端必须建 data channel 并定期发 `"ping"`,否则服务端一帧音频都不推
|
||
3. offer 里没有媒体时 aiortc **完全不收集 ICE 候选**(连连通性测试都要加静音轨)
|
||
4. ICE 收集完成前发 offer → SDP 里 0 个候选地址
|
||
5. `PipelineWorker.run()` 要 `WorkerParams(task_manager=...)`,不是 `PipelineParams`
|
||
6. `TaskManager()` 必须在事件循环内创建
|
||
7. **后台任务里的异常被静默吞掉**,日志里查不到
|
||
8. 信令回调里必须 `create_task`,不能 `await task.run()`
|
||
9. Pipecat 会预热 NLTK `punkt_tab`;生产部署必须预装,不能在首个会话临时下载
|
||
10. `PipelineWorker` 自动发送 `StartFrame`,手工再发会造成重复启动帧
|
||
|
||
## 踩过的坑
|
||
|
||
全部写在对应文件的注释里,这里只列索引,详见 `部署说明.md` §已知的坑:
|
||
|
||
- 本机 vLLM 只接受一条 system 消息(多条 400)
|
||
- 思考字段名 vLLM 用 `reasoning`、DeepSeek 用 `reasoning_content`
|
||
- DeepSeek 默认开思考,多轮/工具回灌会 400;且思考会吃掉 `max_tokens`
|
||
- `max_num_sentences=1` 静默截断多句输入
|
||
- VITS 非自回归,首音 = 整句合成耗时
|
||
- Qwen3-TTS 短句 RTF 反而最差(固定开销约 1 秒)
|
||
- qwen-tts 与 vllm-omni 依赖互斥,必须分 venv
|
||
- STT 与 TTS 的线程扩展方向相反
|