ylhan 4fe2755fd6 Phase 2:抽出工具与策略层
按 P1-Pipecat Flows Agent化与记忆改造交接.md 的 Phase 2 完成。
legacy 路径行为不变,只是改为复用新工具层。

新增 app/agent/
- memory_policy.py:记忆写入/删除的裁决层。模型只提议,策略决定。
  不依赖 LLM/网络/真实存储,可纯单元测试。
  - 身份未确认 → 一律 reject,不碰私人记忆
  - 模型传 owner_user_id/scope 等越权字段 → reject(不静默忽略)
  - 只有用户明确说"记住/以后/别忘了/我的偏好是"才写 confirmed
  - "记住我今天吃了香菜"这类一次性事件 → 降级 candidate
  - 不采信模型自称的"用户明确要求"
  - 模糊/多条删除 → needs_confirmation,不猜目标
- tools.py:工具 schema 与执行器,legacy 与 flows 共用。
  - 身份由 ToolContext 注入,**不在工具参数里**
  - 身份未确认时不暴露任何记忆工具(比暴露后拒绝更干净)
  - 写入结果如实回报:candidate 明确告知"尚未生效",
    删除如实返回"已删除/没有找到/需要确认",不把"请求已收到"说成"已做到"
  - 工具失败转成给模型的文本,不拖垮本轮对话
- llm_service.py:Pipecat LLMService 工厂,固化 Phase 1 spike 的选型结论
  (复用 DeepSeekLLMService;必须显式覆盖模型名为 deepseek-flash,
  因为 Pipecat 默认的 deepseek-v4-flash 不存在)

改造 app/engine.py
- _execute_tool 改为委托 app/agent/tools.execute(),删除重复实现
- 本轮用户原话挂到实例上,供策略层判定语义等级
- 移除不再使用的 SearchError 导入

新增 app/tests/test_agent_tools.py:45 项断言,**不依赖 LLM/网络/SQLite**
(用 fake store)。覆盖交接文档 §十 的清单:越权参数、未确认身份、
明确记住 vs 推断、同 key 幂等、模糊删除、精确删除、跨用户隔离。

回归:test_agent_tools 45/45、test_memory 30/30、test_identity 19/19、
test_search 12/12、test_transport 4/4、test_profile_memory 22/22,无退化。
2026-09-16 13:25:46 +08:00
2026-09-16 13:25:46 +08:00

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 基准、模型下载脚本、线程扫描

测试

# 服务器上(完整)
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 保留但暂时返回空结果。真实服务验收:

.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 的线程扩展方向相反
S
Description
No description provided
Readme
418 KiB
Languages
Python 96.8%
C++ 2%
Shell 1.2%