Files
ylhan c37a968548 Phase 1 spike:Pipecat Flows 兼容性验证,7 项全通过
按 P1-Pipecat Flows Agent化与记忆改造交接.md 的 Phase 1 要求,
只做独立 spike,不改生产入口。

新增
- p1/flows_spike.py:探索脚本,带请求体抓取与逐项诊断
- app/tests/test_flows_spike.py:断言版,复用探索脚本的检查项避免漂移

验证结果(9 个断言全过)
- DeepSeek 在 Flows 中流式输出中文(14–22 个 token 级分块)
- 工具调用参数聚合完整、无重复拼接;回灌后能出最终回答
- worker.cancel() 能立即终止 LLM 流
- role_message 生效(模型自称"桌面陪伴小助手")
- context 里只有一条 system 消息

三条最重要的结论
1. **不要装 pipecat-ai-flows**。Pipecat 1.10 内置 Flows(pipecat.flows),
   装了会报 _warn_if_standalone_flows_installed;且独立包 1.4.0 会把
   pipecat 从 1.10.0 降到 1.4.0。requirements.txt 已加说明。
2. **必须用 PipelineRunner().run(worker)**。直接 worker.run() 时 runner
   没挂上,工具执行失败并抛 "runner is not set; call attach() first",
   该异常被转成给 LLM 的通用错误串,handler 从不执行,默认日志看不到。
3. **await flow.initialize(node)**。它是协程但签名显示 -> None,
   漏 await 不报错,只是 role_message/task_messages/工具全部静默失效——
   极易误判成"框架不支持这些特性"。

另两条
- respond_immediately 默认 True,initialize 会立刻触发一轮回复,
  做归因测试时必须清空观察窗口,否则会归因错。
- role_message(走 system_instruction)与 task_messages 各产生一条
  system 消息,两者不要同时用;切回本地 vLLM 时会 400。

LLMService 选择:复用 Pipecat 的 DeepSeekLLMService。它自带
DeepSeekLLMAdapter 处理 reasoning_content 回传约束(我们 P0 踩过的坑),
thinking 默认 disabled,与既有结论一致。代价是失去本地 vLLM 降级
(Flows 要求 LLMService 实例,FallbackProvider 不能直接用;且当前
本机 vLLM 已停用,fallback 实际无可用目标)。

环境:NLTK punkt_tab 本地也需预装(服务器已有),spike 加了 fail-fast
检查与拷贝命令提示。
2026-09-16 13:13:39 +08:00

198 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`,手工再发会造成重复启动帧
## Pipecat Flows 兼容性 spike(2026-09-16)
Phase 1 spike 已完成,7 项验证全通过。代码:`p1/flows_spike.py`(探索)+
`app/tests/test_flows_spike.py`(断言版)。完整结论见笔记库的
`P1-Flows兼容性Spike结论.md`。
**最重要的三条**:
1. **不要装 `pipecat-ai-flows`**。Pipecat 1.10 内置 Flows(`pipecat.flows`),
装独立包会报错提示卸载;且独立包 1.4.0 会把 pipecat 降到 1.4.0。
2. **用 `PipelineRunner().run(worker)`,不要直接 `worker.run()`**。
直接跑时 runner 没挂上,工具执行会失败,异常被转成一句通用的
"The function X failed and returned no result.",handler 从不执行。
3. **`await flow.initialize(node)`**。它是协程,但签名显示 `-> None` 看不出,
漏 await 不报错,只是 role_message/task_messages/工具全部静默失效。
另有两条:`respond_immediately` 默认 True(initialize 会立刻触发一轮回复,
做归因测试时要注意);`role_message` 与 `task_messages` 会**各产生一条
system 消息**,两者不要同时用(切回本地 vLLM 时会 400)。
## 踩过的坑
全部写在对应文件的注释里,这里只列索引,详见 `部署说明.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 的线程扩展方向相反