# P0 实验报告 · Pipecat 传输层 日期:2026-09-15。状态:**传输层跑通,帧路由未跑通——实验目标部分达成。** 代码:`~/workspace/ai-companion/services/bot_server.py`(服务端)、 `client/test_client.py`(自写 aiortc 客户端) ## 一、为什么做这个实验 方案文档写"Pipecat 作为唯一会话管线框架",但核实后发现**它一行都没被用上** (只在 `requirements.txt` 里声明,代码里零引用)。助手引擎是独立 asyncio, 由测试脚本用文件音频驱动。 于是有个待定的架构问题:**Pipecat 该用多深?** - **A 方案**:Pipecat 只做传输与帧模型,会话语义归自有引擎(一个 FrameProcessor 吞掉整条管线) - **B 方案**:按 Pipecat 的 service 模型重构,STT/LLM/TTS/记忆都做成框架原生 service 实验目的:验证 A 方案的核心假设能否成立,并拿到真实传输下的延迟数字。 ## 二、结论摘要 | 部分 | 结果 | |---|---| | WebRTC 信令(offer/answer) | ✅ 往返 47–280ms | | ICE 连通 | ✅ 走 172.16.1.x 直连 | | data channel + 心跳 | ✅ | | 双向媒体轨 | ✅ 客户端音轨送达服务端,服务端音轨客户端收到 | | 管线启动、处理器 setup() | ✅ | | **帧路由到自定义处理器** | ❌ **`process_frame` 调用次数 = 0** | | 端到端出声 | ❌ 因上一行,引擎从未运行 | **即:传输层可用,但"把整条会话逻辑塞进一个 FrameProcessor"不是开箱即用的。** ## 三、没跑通的部分(关键) 自定义 `EngineProcessor` 被创建了、`setup()` 也被调用了,但**一帧都没进来**—— 连自己用 `task.queue_frames([StartFrame()])` 注入的 StartFrame 都没有。 现象是:**连接全对、音轨协商成功、ICE connected,但助手永远不说话, 而且日志里连一条错误都没有。** 这与 A 方案的核心假设直接相关:如果帧送不进自定义处理器, "Pipecat 只做传输、逻辑归引擎"这个切分方式就不成立。 **根因未定位。** 不能确定是这个版本 Pipecat 的管线用法与我们写的不符, 还是模型本身不接受这种用法。这两者的区别很重要——见 §五。 ## 四、挖出的 8 个集成坑 每个都会表现为"什么都没发生",极难排查。无论最终选 A 还是 B 都会踩。 | # | 坑 | 现象 | 怎么办 | |---|---|---|---| | 1 | Pipecat 客户端 SDK 不覆盖 N5095 形态 | 浏览器/移动端/C++/ESP32 都有,没有"Linux x86 盒子接 USB 阵列" | 自己写 aiortc 客户端(已有雏形) | | 2 | 客户端必须建 data channel 并定期发 `"ping"` | 不建则服务端**一帧音频都不推**,日志只有一句 "Data channel not established within 10s" | 建 channel + 1s 心跳 | | 3 | offer 里没有媒体时 aiortc **完全不收集 ICE 候选** | 信令成功、SDP 交换正常,但 ICE 永远停在 `new` | 即使只测连通也要加一条(静音)音轨 | | 4 | ICE 收集完成前就发 offer | SDP 里 0 个候选地址 | 等 `iceGatheringState == "complete"` | | 5 | `PipelineTask.run()` 要 `WorkerParams(task_manager=...)`,**不是** `PipelineParams` | 不传报 "missing 1 required positional argument";传错报 "no attribute 'task_manager'" | 两个参数分别给构造函数和 run() | | 6 | `TaskManager()` 内部调 `asyncio.get_running_loop()` | 模块级创建报 "no running event loop" | 必须在异步上下文里建 | | 7 | **后台任务里的异常被静默吞掉** | 日志里查不到任何线索,表现为"连接正常但没反应" | 自己 try/except + log.exception | | 8 | 信令回调里 `await task.run()` | 阻塞整个会话生命周期,管线初始化时序走歪 | `asyncio.create_task(task.run(...))` | 第 5、6、7 条各花了好几轮才发现——因为异常在后台任务里,日志什么都不显示。 ## 五、对 A/B 决策的影响 实验前的判断是"Pipecat 的独特价值收敛到传输层"(因为方案 §5 的音频边界决定 把 AEC 和播放放在客户端,绕开了 Pipecat 的打断机制)。 **现在要修正**: - 传输层确实能用——前半部分的结论成立 - 但**传输层与自定义会话逻辑之间的接缝,比预想的深** - B 方案恰恰绕开这个接缝:它不把逻辑塞进一个处理器, 而是用框架原生的 STT/LLM/TTS service,由框架自己编排 所以这次实验**没有支持 A 方案**,反而间接给了 B 方案更多理由。 **但前提是根因要查清**: - 如果是我接线错(管线用法和 Pipecat 1.10 的实际约定不符)→ 接缝是浅的,A 仍然可行 - 如果是模型不兼容(这个版本不接受"单一处理器吞整条管线")→ 接缝是深的,应该转 B ## 六、下一步 **建议先查清帧路由的根因**,再决定 A 还是 B。候选方向: 1. 对照 Pipecat 官方 smallwebrtc 示例的管线接法(我们的接法可能有细节差异) 2. 检查 `Pipeline([...])` 在 1.10 里的连接语义(是否有自动 link) 3. 检查 `queue_frames` 的注入点与处理器位置的匹配关系 4. 在传输层的 input/output 处理器上加日志,看帧到底停在哪一环 若排查成本过高,**建议直接转 B**——因为 B 用的是框架原生 service, 不存在"帧送不进自定义处理器"这个问题。 ## 七、当前代码状态 **服务端** `services/bot_server.py`: - 已实现:SmallWebRTC 信令端点 `/api/offer`、prebuilt 网页客户端挂载 `/client/`、 `EngineProcessor`(自写能量 VAD + 引擎调用) - **仍带着诊断日志**(`[诊断]` 前缀),用于下一轮排查。定位后应清理 - VAD 是**极简能量判定**,不是最终方案(方案 §5 的说完判定还没选型) **客户端** `client/test_client.py`: - 已实现:非 trickle ICE、data channel + 心跳、文件音频按实时速率发送、回复录音 - **不做 AEC、不做播放参考、不做打断**——那些要真实声卡和硬件 - 它是 N5095 客户端的雏形,不是产品代码 ## 八、复现 ```bash # 服务端(服务器上) cd ~/ai-companion .venv/bin/python services/bot_server.py --host 0.0.0.0 --port 7860 # 客户端(N5095 上) cd ~/workspace/ai-companion python client/test_client.py \ --server http://cadadmin-server.taild0f91d.ts.net:7860 \ --wav p0/stt_tts_bench/wavs/stt_1.wav --out /tmp/reply.wav ``` 预期(当前实际):连接成功、收到 34 秒音频轨,**但全是静音**。 ## 九、顺带发现的环境问题 **局域网被重新编址了**:两台机器从 `192.168.0.x` 变成 `172.16.1.x` (N5095 现为 `172.16.1.99`,服务器 `172.16.1.101`)。 已处理:客户端默认地址改用 tailscale 域名 (`cadadmin-server.taild0f91d.ts.net`),抗重新编址。 **未处理**:方案文档与 N5095 配置里仍有 `192.168.0.20` 的残留引用。