这是进入 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/覆盖现有改动。此提交即该基线。
262 lines
14 KiB
Markdown
262 lines
14 KiB
Markdown
# 部署说明 · 服务器侧助手
|
||
|
||
更新:2026-09-16。状态:**已部署并跑通**,语音、网页文字与联网搜索均完成闭环。
|
||
|
||
## 架构定位
|
||
|
||
```
|
||
cadadmin-server(192.168.0.20) N5095 ESP32-S3
|
||
助手本体 外设桥 表情/动作
|
||
├─ 会话引擎 ├─ USB 麦克风阵列 ├─ 屏幕
|
||
├─ LLM:DeepSeek 云 API ├─ USB 摄像头 ├─ 电机
|
||
├─ 记忆 SQLite ├─ DoA 读取(不过网) └─ 按键
|
||
├─ 身份裁决 ├─ 人脸检测
|
||
├─ STT:Zipformer(CPU) ├─ WebRTC 客户端
|
||
└─ TTS:Qwen3-TTS(3090) └─ 串口 → ESP32
|
||
手机 / 浏览器:额外客户端
|
||
```
|
||
|
||
**"服务器是助手,本地是客户端"**(2026-09-14 确定)。
|
||
助手本体、记忆库、语音模型全部在服务器;N5095 只做 USB 外设接入、
|
||
DoA 读取和网络桥接,不跑任何推理。
|
||
|
||
## 当前技术栈
|
||
|
||
| 环节 | 方案 | 位置 | 实测 |
|
||
|---|---|---|---|
|
||
| LLM | DeepSeek `deepseek-flash`(关思考) | 云 API | 首 token 0.8–1.2s |
|
||
| TTS | Qwen3-TTS 1.7B-CustomVoice | 3090 | RTF 0.55–0.94 |
|
||
| STT | Zipformer 中英双语 int8 | CPU 4 线程 | RTF 0.03 |
|
||
| 记忆 | SQLite | 服务器本地 | — |
|
||
| 身份 | 手动切换 + 访客兜底 | 服务器 | — |
|
||
|
||
**首音延迟约 1.5 秒**(文本输入)。方案 P1 目标是 P50≤2 秒,
|
||
加上 VAD 判定后处在 2.0–2.2 秒,**刚好卡线,没有余量**。
|
||
|
||
### 为什么 LLM 走云端而 TTS 在本地
|
||
|
||
两者都需要算力,但 3090 只有 24GB:
|
||
|
||
| 方案 | 显存 | 首音延迟 |
|
||
|---|---|---|
|
||
| 本地 Qwen3.8-27B + 本地 vits-zh-ll | 27B 占 21.5GB | **179ms**(音色不满意) |
|
||
| **DeepSeek + 本地 Qwen3-TTS** | TTS 占 8.9GB | **1545ms** |
|
||
|
||
2026-09-15 选了后者:**用约 600ms 的网络往返 + 约 900ms 的 TTS 固定开销,
|
||
换音质从"用户不满意"到"用户试听选定"**。27B 的 vLLM 已停。
|
||
|
||
这是可逆的(改 `AI_LLM_PRIMARY` 即可切回),但切回去 GPU 就不够跑 Qwen3-TTS。
|
||
|
||
## 服务器上的布局
|
||
|
||
```
|
||
~/ai-companion/ 助手主体
|
||
├── client/test_client.py 自写 aiortc 客户端(实验用)
|
||
├── services/bot_server.py Pipecat SmallWebRTC 服务端(实验,端口 7860)
|
||
├── app/ 24 个 py 文件
|
||
│ ├── config.py 配置,支持环境变量覆盖
|
||
│ ├── prompts.py 单条 system 每轮重建
|
||
│ ├── engine.py 会话引擎(身份→记忆→LLM→TTS)
|
||
│ ├── llm/ 后端抽象 + vLLM + DeepSeek + 降级路由
|
||
│ ├── memory/ 接口 + SQLite 实现
|
||
│ ├── identity/ 接口 + 手动切换实现
|
||
│ ├── voice/ STT / TTS(本地 + 远端两种后端)
|
||
│ └── tests/ 5 个测试脚本(含传输适配层回归)
|
||
├── models -> ~/ai_companion_bench/models 软链,未重复占用
|
||
└── .venv/ 608MB(Python 3.12)
|
||
|
||
~/tts-service/ Qwen3-TTS 模型(4.3GB)
|
||
~/tts-qwen/ Qwen3-TTS 服务
|
||
├── tts_server.py FastAPI 服务,OpenAI 兼容 /v1/audio/speech
|
||
├── samples/ 音色试听样本
|
||
└── .venv/ 独立 venv(与 vllm-omni 依赖互斥,必须分开)
|
||
|
||
~/ai_companion_bench/ P0 基准脚本与模型(STT/TTS/vits)
|
||
~/ai/qwen-serving/ 原来的 27B vLLM(已停,保留可随时启回)
|
||
```
|
||
|
||
## 配置
|
||
|
||
`~/.config/ai_assistant/env`(权限 600,含密钥)
|
||
|
||
| 变量 | 服务器值 | 说明 |
|
||
|---|---|---|
|
||
| `AI_LLM_PRIMARY` | `deepseek` | 主 LLM;改 `local` 可切回本机 vLLM |
|
||
| `AI_TTS_BACKEND` | `remote` | `remote`=Qwen3-TTS 服务;`local`=vits-zh-ll |
|
||
| `TTS_SERVICE_URL` | `http://127.0.0.1:18040` | TTS 服务地址 |
|
||
| `AI_TTS_SPEAKER` | `Serena` | 用户试听选定 |
|
||
| `AI_TTS_INSTRUCTIONS` | `用轻快活泼的语气说` | 风格指令 |
|
||
| `AI_STT_THREADS` | `4` | **N5095 上必须改成 2**(见下) |
|
||
| `AI_MODEL_DIR` | `/home/yonglin/ai-companion/models` | 两边模型位置不同 |
|
||
| `AI_DEFAULT_USER_ID` | `u_ylhan`(默认) | 单用户原型的内部档案 ID;不能作为姓名展示 |
|
||
| `SEARCH_ENABLED` | `true` | 向 LLM 注册只读 `web_search` 工具 |
|
||
| `SEARXNG_BASE_URL` | `http://127.0.0.1:18080` | 私有 SearXNG,仅服务器回环可达 |
|
||
| `SEARCH_TIMEOUT_S` | `6`(默认) | 搜索总超时,失败后让模型明确降级 |
|
||
| `SEARCH_MAX_RESULTS` | `5`(默认) | 单次最多回灌的搜索结果 |
|
||
|
||
**两台机器的线程配置不能照抄**:N5095(4核无AVX2)STT 超过 2 线程急剧退化
|
||
(4 线程慢 3.9 倍);服务器(i9-14900K 有AVX2)给 4 线程没问题。
|
||
TTS 的线程扩展方向又和 STT 相反(越多越快)。
|
||
|
||
## 启动
|
||
|
||
```bash
|
||
# 1. TTS 服务(模型常驻显存,约 4 秒加载完)
|
||
cd ~/tts-qwen && setsid nohup .venv/bin/python tts_server.py --port 18040 \
|
||
> logs/tts_service.log 2>&1 < /dev/null &
|
||
|
||
# 2. 私有 SearXNG(首次部署先从 example 生成 settings.yml 并替换随机密钥)
|
||
cd ~/ai-companion/deploy/searxng && docker compose up -d
|
||
|
||
# 3. Pipecat 实时服务端(含 /client/ 网页测试入口)
|
||
cd ~/ai-companion && setsid nohup .venv/bin/python services/bot_server.py \
|
||
--host 0.0.0.0 --port 7860 > logs/bot_server.log 2>&1 < /dev/null &
|
||
|
||
# 4. 确认就绪
|
||
curl -s http://127.0.0.1:18040/health
|
||
curl -GsS http://127.0.0.1:18080/search \
|
||
--data-urlencode 'q=DeepSeek' --data-urlencode 'format=json'
|
||
|
||
# 5. 跑测试
|
||
cd ~/ai-companion && .venv/bin/python app/tests/test_engine_e2e.py
|
||
.venv/bin/python app/tests/test_search.py
|
||
.venv/bin/python app/tests/test_search_live.py
|
||
```
|
||
|
||
助手本体还没有常驻服务(没有 systemd),目前靠直接跑脚本。
|
||
|
||
## 部署代码
|
||
|
||
```bash
|
||
cd ~/workspace/ai-companion
|
||
rsync -az --delete --exclude '__pycache__' --exclude '*.pyc' \
|
||
app/ yonglin@cadadmin-server:~/ai-companion/app/
|
||
```
|
||
|
||
## 已知的坑
|
||
|
||
**都已写进代码注释,这里只列索引。**
|
||
|
||
### 接口层
|
||
|
||
1. **本机 vLLM 只接受一条 system 消息**,多条 400。记忆不能作为第二条
|
||
system 注入,必须并进同一条每轮重建。
|
||
2. **思考字段名各实现不同**:vLLM 用 `reasoning`,DeepSeek 用 `reasoning_content`。
|
||
适配层两个都认,少认一个会静默丢弃思考内容。
|
||
3. **DeepSeek 默认开思考**,此时要求把上一轮的 `reasoning_content` 原样回传,
|
||
否则多轮/工具回灌一律 400。主路径必须显式关闭。
|
||
4. **开思考会吃掉 `max_tokens` 预算**,实测 200 预算下回答只吐出两个字。
|
||
|
||
### 语音层
|
||
|
||
5. **`max_num_sentences=1` 会静默截断多句输入**(sherpa-onnx VITS),
|
||
必须自己先分句。
|
||
6. **VITS 是非自回归的**,没有"边合成边播",首音 = 整句合成耗时。
|
||
提前出声的唯一手段是按语义短句拆分。
|
||
7. **Qwen3-TTS 短句 RTF 反而最差**(0.94 vs 长句 0.55):每次调用有约 1 秒
|
||
固定开销,与文本长度无关。所以"让模型先说个短句"并不划算。
|
||
|
||
### 记忆层
|
||
|
||
8. **检索兜底要限量**:关键词全不命中时退回最近记忆,但只在记忆总数 ≤10 时
|
||
生效。不加限制就变成"每轮全量注入",不再是按需检索。
|
||
9. **"被引用的 user 必须存在"由存储层自己保证**(`_ensure_user_row`),
|
||
不能让每个调用点各自记得——漏一处就报 `FOREIGN KEY constraint failed`。
|
||
10. **内部 `user_id` 不是用户姓名。** 网页客户端当前把每个连接绑定到稳定档案
|
||
`AI_DEFAULT_USER_ID`,但提示词不得暴露该值。用户明确说“我叫林”时,称呼同步
|
||
写入用户表和 confirmed 姓名记忆;同步写是为了避免刚说完就断线导致后台任务丢失。
|
||
新连接从用户表恢复 `display_name`。自报姓名不构成身份认证,不改变 trust level。
|
||
|
||
### 依赖层
|
||
|
||
11. **qwen-tts 与 vllm-omni 依赖互斥**(transformers 4.57 vs 5.14、
|
||
hf_hub 0.36 vs 1.31)。装同一个 venv 会互相破坏,实测把 vllm-omni 搞崩过。
|
||
所以 TTS 跑独立进程 + HTTP。
|
||
12. **flash-attn 装不上**:预编译 wheel 最高只支持 torch 2.9,本机是 2.14
|
||
(vLLM 0.28 拉上来的);源码编译要从 GitHub 拉依赖,而这台机器下
|
||
GitHub release 会断连。
|
||
|
||
## Pipecat 实时链路状态
|
||
|
||
2026-09-15 已闭环:`PipelineWorker` 自动启动,音频帧进入自定义处理器,完整
|
||
STT → DeepSeek → Qwen3-TTS → WebRTC 返回非静音语音。保留 RTVI 与 `/client/`
|
||
网页测试入口,并补齐 Playground 启动所需的 `/start`;架构确定采用
|
||
“Pipecat 管传输,自有引擎管语义”的 A 方案。
|
||
|
||
原帧路由问题的根因是 NLTK `punkt_tab` 首次运行下载拖住 Pipecat 预热,导致
|
||
20 秒 setup timeout;不是框架不支持自定义处理器。服务器已把数据预装到权限 700 的
|
||
`~/.local/share/ai-companion/nltk_data`,服务启动时会 fail-fast 检查。
|
||
|
||
单次文件音频实测:源音频结束到客户端首个非静音约 5.8 秒;其中包含约 0.7 秒
|
||
VAD 等待,当前仍高于 P1 的 2 秒目标,需要后续单独优化。完整记录见 Obsidian
|
||
笔记库里的 `P0-Pipecat传输层实验报告.md`。
|
||
|
||
2026-09-16 通过 SSH 隧道打开 `http://localhost:7860/client/` 真人实测成功;补齐
|
||
Playground 使用的 `/start` 后,浏览器麦克风、RTVI、语音回复均正常,单轮服务端
|
||
指标约 3.0 秒。当前是 P1 调试模式:连接后持续上传并响应每段有效语音,尚未实现
|
||
P2 的本地唤醒和 20 秒连续对话窗口。
|
||
|
||
网页文字输入由 RTVI `send-text` 转为 `LLMMessagesAppendFrame`,适配层会调用
|
||
`ConversationEngine.respond_to_text()`;回复用标准 LLM 帧返回,因此 Conversation
|
||
区域可显示用户文字和助手回复,默认也会合成语音。当前 Playground 1.7 还需要
|
||
完整的 `bot-transcription` / `bot-output` 事件,不能只发送 token 级
|
||
`bot-llm-text`。
|
||
|
||
**姓名记忆已修复并迁移。** 用户明确自报姓名时,同步更新 `users.display_name`
|
||
和 confirmed 姓名记忆;新连接从用户表恢复称呼,内部 `user_id` 不进入用户可见
|
||
称呼。姓名疑问句(例如“知道我是谁吗”)不会再被误写为姓名。需要注意:通用的
|
||
“记住/我喜欢/我习惯”目前仍全部写成 candidate,默认检索不会读取,而且还没有确认
|
||
和晋升流程;所以通用长期记忆尚未闭环。
|
||
|
||
**联网搜索已实现并部署。** DeepSeek 通过 Function Calling 请求 `web_search`,引擎
|
||
聚合完整的流式工具参数,调用回环地址上的 SearXNG,并把标题、摘要、URL 作为
|
||
`tool` 消息回灌后生成最终回答。最多连续搜索两轮,搜索失败不会拖垮对话;网页保留
|
||
一到三个来源 URL,TTS 从“来源:”开始停止朗读。2026-09-16 真实验收中,模型主动
|
||
搜索、取得 5 条结果并生成带两个来源的回答。
|
||
|
||
服务器到 DuckDuckGo、Brave、Wikipedia 会超时,因此 SearXNG 当前只保留并显式
|
||
启用 Bing、360 搜索、搜狗;其中 360 与搜狗实测有结果,Bing 当前返回空结果。
|
||
容器只监听 `127.0.0.1:18080`,不向局域网或公网暴露。
|
||
|
||
## Agent 与打断的下一阶段
|
||
|
||
架构已选择 **Pipecat Flows 渐进改造**,不整体迁移 OpenClaw、Agno、LangGraph 或
|
||
LiveKit Agents。现有 `ConversationEngine` 已手写最多两轮的搜索工具循环,而
|
||
Pipecat Flows 依赖 Pipecat `LLMService`、context aggregator 和 PipelineTask;
|
||
两者不能直接替换。实施必须先完成无传输的兼容性 spike,验证 DeepSeek 流式内容、
|
||
工具参数聚合、取消传播和 RTVI 文本事件,再用 `AI_AGENT_RUNTIME=legacy|flows`
|
||
保留回退路径。详细任务与验收见笔记库
|
||
`P1-Pipecat Flows Agent化与记忆改造交接.md`。
|
||
|
||
当前打断不是完整闭环:`SimpleVAD` 在助手推送音频时检测到高能量只记录日志;
|
||
语音话段要等 700ms 尾静音后才会取消旧轮次。收到 `InterruptionFrame` 时任务确实
|
||
可以取消,但服务端尚未在用户开口时主动生成该信号,也没有同时清空浏览器已缓冲音频。
|
||
下一阶段先用合成帧验证“开始说话 → 取消生成 → 清空输出”的控制链;真实 AEC、回声
|
||
条件下的误触发率仍必须等 N5095 音频硬件到位后验收。
|
||
|
||
## 未做的事
|
||
|
||
- **没有常驻服务**:TTS 服务和助手都没有 systemd 开机自启(方案 §10 要求 P2 后做)
|
||
- **网页 HTTPS 尚未启用**:`/client/` 已挂载且 HTTP 返回正常,但浏览器远程取麦克风
|
||
需要 HTTPS;Tailscale Serve 仍需在 tailnet 管理页启用
|
||
- **唤醒尚未实现**:当前网页连接后会持续上传麦克风并响应每段有效语音;正式方案
|
||
是在 N5095 本地做关键词检测,待机时音频不出网
|
||
- **实时打断尚未闭环**:有能量 VAD、异步任务取消和客户端停播能力,但三者还没有
|
||
由可靠的 user-started-speaking 事件串起来
|
||
- **通用长期记忆尚未闭环**:除姓名外的自动抽取均为 candidate,缺少确认、晋升、
|
||
修改、忘记的用户可见流程
|
||
- **设备侧代码不存在**:N5095 的外设桥(USB、WebRTC 客户端、串口)尚未开始
|
||
- **STT 没换**:仍是 2023 年的 Zipformer int8,中文识别率从未测过
|
||
- **记忆库在服务器上**,而人脸特征按方案必须留在设备端——将来做
|
||
"删除我的数据"要跨两台机器协调,这个数据边界问题需要单独想
|
||
- **代码与设计文档分属两个仓库**:实现位于 `ai-companion`,设计记录位于
|
||
`note_sync`,状态变更需同步更新两边
|
||
|
||
## 相关文档
|
||
|
||
- `AI桌面陪伴助手方案.md` — 总体方案
|
||
- `P0-语音基准报告.md` — STT/TTS 基准(vits 时代,含线程数结论)
|
||
- `P0-DeepSeek验证报告.md` — DeepSeek API 四条路径
|
||
- `P0-本地LLM验证报告.md` — 本机 Qwen3.8-27B(**已停用**)
|
||
- `P0-Qwen3TTS报告.md` — Qwen3-TTS 选型与部署
|