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/ 5 个测试脚本,全部无需硬件
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 基准、模型下载脚本、线程扫描
测试
# 服务器上(完整)
cd ~/ai-companion
.venv/bin/python app/tests/test_llm.py # 27 项
.venv/bin/python app/tests/test_memory.py # 28 项
.venv/bin/python app/tests/test_identity.py # 17 项
.venv/bin/python app/tests/test_engine_e2e.py # 16 项,端到端
.venv/bin/python app/tests/test_transport.py # 3 项,媒体时钟/VAD/静音判定
端到端测试不需要麦克风、音箱、摄像头、ESP32——
这是"服务器即助手"架构的直接好处:链路可以在任何有 CPU 的地方验证,
硬件到货后再接最后一段。跑完会把合成音频写到 out/,可以人工试听。
设计上几个刻意的选择
接口按最坏情况设计。 MemoryStore 全是 async、可超时、可失败,
虽然现在背后是本地 SQLite(微秒级)。因为将来多设备共享记忆时要搬到服务器,
如果现在写成同步直连本地文件,那时要重写所有调用方。
身份、会话、视觉追踪三者分开。 user_id(永久身份)、session_id(一次会话)、
track_id(画面里的一个目标)互不冒充。人脸认出来不等于确认对方在说话——
所以人脸只产生"建议",不自动切换身份。做错的后果是路人经过就把你的私人记忆念出来。
记忆查询强制带 owner_user_id。 没有"按 memory_id 单独取"的口子:
猜到 id 就能读别人的记忆,是这类系统最容易犯的错。删除同理,必须同时匹配 owner。
自动抽取的记忆是"候选",默认不参与检索。 只有用户显式"记住"或工具调用 写入的才是"已确认"。对应方案 §8 的"模型推测不当作确认事实"。
异步任务绑定创建时的身份。 回复后写记忆的任务在创建时捕获 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 笔记库里)。
实验挖出的 10 个坑
每个都表现为"什么都没发生",极难排查:
- Pipecat 客户端 SDK 不覆盖"Linux x86 盒子接 USB 阵列"这种形态 → 客户端得自己写
- 客户端必须建 data channel 并定期发
"ping",否则服务端一帧音频都不推 - offer 里没有媒体时 aiortc 完全不收集 ICE 候选(连连通性测试都要加静音轨)
- ICE 收集完成前发 offer → SDP 里 0 个候选地址
PipelineWorker.run()要WorkerParams(task_manager=...),不是PipelineParamsTaskManager()必须在事件循环内创建- 后台任务里的异常被静默吞掉,日志里查不到
- 信令回调里必须
create_task,不能await task.run() - Pipecat 会预热 NLTK
punkt_tab;生产部署必须预装,不能在首个会话临时下载 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 的线程扩展方向相反