【Pipecat】基于Pipecat的voice agent实践

背景

上一篇文章里,我基本介绍了一下 Tana 这个应用和它对我的启发。调研完以后,老板同意让我按照 Tana 的形式,先做个 MVP 出来,再想想怎么把我们的其他模块融入进去。

于是我开始重新规划 Meeting 的页面和布局,同时做 Voice Agent 的 MVP。这次的前后端都是我自己做的,页面主要参考了 Tana 的卡片化布局。

对于 MVP,我的想法是先把卡片化布局做出来,再做一个"能听""能看"的 Voice Agent,可以调用工具帮我干一些基础的活儿。毕竟是 MVP 嘛,就当做尝试了。

这版先只监听启动 Agent 的一个参会人。用户启动 Agent 以后,Agent 能听他发言、看他投屏、结合会议上下文回答问题;需要创建任务或者修改文档时,先给用户一个待确认的 Proposal,确认后再写入业务数据。

这篇文章就按我的实现过程一步一步写,纯探索,会包含一些 Pipecat 的基础知识。后续会单开一篇文章,讲讲 Pipecat 的核心架构和原理。

先把 Meeting 页面改成卡片布局

首先在这个mvp之前,先要对页面做一次大的改版。

原来的 Meeting 页面右侧就是一个侧边栏,成员、聊天、字幕和各种会议信息都堆在里面。我们之前参考得比较多的是腾讯会议、飞书、钉钉这类应用,这样放会议信息没有什么问题。

开始接 Voice Agent 后,页面还得放实时纪要、生成的任务和文档 Diff,原来的侧边栏就不够用了。任务卡弹一下就没了,文档修改也只能塞进一段消息里,用户很难回头确认。

所以我们先参考 Tana 的卡片化布局,把 Meeting 页面重新做了一版。视频还是占左侧的大区域,右侧留给 Agent 对话和卡片栏。会议里产生的任务、文档、视图都可以放到这条卡片栏里,用户需要时再去置顶、拖动或者继续操作。

这个卡片栏就是 Meeting 里的操作区。后续生成的任务、文档,以及需要给 Voice Agent 读取的上下文,都在这里展示和操作。

do work in the meeting 需要先有这样一块位置。任务和 Diff 不能只在聊天框里闪一下,用户要能看见、确认,再继续往下处理。

先根据 Tana,把 Voice Agent 要做的事情拆出来

取名字

先给我们的 Voice Agent 起个名字。

我们 UI 同学参考 Tana,给它起了 Nota。后面就可以用这个名字来唤醒它。会议里大部分时间还是人在互相讨论,需要让 Agent 区分哪些话是在喊它,哪些话只是参会人之间的交流。

Voice Agent 需要哪些能力

举一个例子,假设产品、开发和测试在开一个登录问题的跟进会。测试说"登录页偶发白屏",开发把浏览器控制台投出来,产品最后说"这个问题明天下午前先复现出来"。这时用户可能连续问几类问题:

  1. "Nota,刚才这个问题最后定了吗?"
  2. "Nota,屏幕上这个报错是什么原因?"
  3. "Nota,把这个登录问题记成任务,我来跟。"
  4. "Nota,把刚才定的结论补到方案里。"

前两句是在查会议上下文。第一句里的"刚才"没有资源 ID,也没有明确关键词,Agent 得从字幕里找到那段讨论。第二句里的"这个"在共享屏幕上,只有语音转写还不够。

后两句开始碰业务数据。任务里有标题、负责人和时间;文档里有当前块内容、版本和协作者。这些数据不能让模型直接写。会议里"我来跟一下""下周再看"这种话很多,模型听错一次,后面就多了一条错误任务,或者把方案改乱了。

所以 MVP 先做这些能力:

  • 能听用户发言,也能看共享画面。
  • 能结合会议上下文回答问题。
  • 能提出任务和文档修改。

其中有一条边界:写入动作必须经过用户确认。

后面几章的 Pipeline、MCP、Proposal 都是围绕这些功能展开的。

MVP 先只服务启动 Agent 的用户

Tana 的演示里,Agent 像是整场会议的公共成员。我们一开始没有直接做全员监听。

比如一场会议里有三个人同时说话,谁的话能唤醒 Agent?甲说"我来跟",任务该归甲还是启动 Agent 的人?乙在共享屏幕,丙问"这个报错怎么处理",Agent 又该看谁的画面?两个人同时插话时,也得先确定为谁停掉语音回复。

这些规则不先定下来,把每个人的音视频都喂进模型意义不大。Tana 的演示里有完整的唤醒和对话权转移流程;这版 MVP 先把边界收紧:只服务启动 Agent 的用户,不处理多人同时对话。

花了几天时间,三方服务让我崩溃

接了三四天,问题主要卡在联调

我们的 WebRTC 用的是 Agora。刚开始做 Voice Agent 时,我首选的就是 Agora 的对话式引擎:音频、视频都在 Agora Channel 里,理论上少接一层服务。

结果光是把语音对话跑通,就接了三四天,我真是头都大了!

第一个是 Agora 文档和示例对不上。示例里有监听 user[*] 的写法,文档又写一次只能监听一个人。我们当时要做单人 MVP,连订阅规则都没法确定,只能一个一个试。

第二个是拿不到具体日志。服务报错后,我们这边通常只能看到错误码,具体是什么问题,我完全懵逼。只能微信联系对方的技术人员,让对方查日志后再告诉我。

光是联调对话 Demo 就得来回等,后面还要接任务、文档和权限,这样排查就更麻烦了。

Pipecat 让排查回到自己的链路里

我也看了其他平台,例如即构,对话式引擎同样还在 Beta。这个时候想起去年调研过的 Pipecat,快速看了一下介绍,然后让 Codex 帮忙接入。大约两个小时,Agent 已经能正常对话了!

Pipecat 跑在我们自己部署的服务里。LLM 没有正常输出、STT(语音转写)有问题,或者文字出来了但 TTS(语音合成)没有播报,都可以让 Codex 顺着日志和调用链排查。

报错和联调能在自己的环境里查清楚,这是我这次决定用 Pipecat 的主要原因。

Agora 和 Pipecat 是怎么连起来的

原有 Meeting 的音视频还是走 Agora。用户启动 Nota 后,Java 创建 Pipecat Session,生成 Agent 入会用的 Agora Token,再把 sessionId、会议 Channel、Agent UID 和启动用户的 RTC UID 交给 Pipecat。Pipecat 用自己的 Agent UID 加入同一个 Channel。

也就是把 Pipecat 当成同一个会议里的一个参会人:接收启动用户的音视频,再通过 Agora 的频道把语音回复发回来。

下面这张图先看音视频怎么进出 Pipecat:

Pipecat 的 Pipeline 到底在跑什么

先把 Frame 说清楚

说了半天,Pipecat 的 Pipeline 到底是什么?

前面说的是 Pipecat 怎么加入会议。音频进来以后,还要经过转写、模型回答和语音合成,才能把回复发回去。Pipecat 官方文档里的基础链路可以先简化成:

text 复制代码
transport.input()     接收音频
       ↓
STT                   音频转文字
       ↓
用户上下文聚合器        汇总这一轮发言,更新 Context
       ↓
LLM                   根据上下文生成回复
       ↓
TTS                   回复转语音
       ↓
transport.output()    发送音频

这些步骤之间传递的就是 Frame。

Frame 就是在 Pipeline 里传递的一条数据或控制信号。 一小段 PCM 音频可以是一个 Frame,STT 转出来的一句字幕可以是一个 Frame,用户打断 Agent 时的停止信号也可以是一个 Frame。

比如用户说"Nota,刚才这个问题最后定了吗",这句话会经历几次变化:

  1. Agora 收到 PCM 后,Input Transport 把它送进 Pipeline,形成音频 Frame。
  2. STT 把音频转成字幕 Frame。
  3. 用户这一轮发言结束后,聚合器把字幕放进会话上下文,通过 LLMContextFrame 交给后续处理器。
  4. LLM 的回复经过 TTS,又变成音频 Frame,最后由 Output Transport 发回 Agora。

如果用户中途插话,Pipeline 里还会传递 InterruptionFrame,让后面的处理器停止旧回复。打断这条分支放到下一节单独讲。

Pipeline 里的处理器会处理自己关心的 Frame,补充信息、转换内容,或者生成新的 Frame。只需要经过它的 Frame 则继续往下传;文档分流这种业务处理器,也可能处理完就结束这一轮传递。

当前主 Pipeline 多了哪些处理器

Meeting 里多了唤醒词、文档修改和字幕回传,当前主 Pipeline 变成了下面这样:

python 复制代码
pipeline = Pipeline([
    transport.input(),
    stt,
    wake_filter,
    user_aggregator,
    document_edit_router,
    llm,
    tts,
    transport.output(),
    subtitle_relay,
    assistant_aggregator,
])

这段代码里,wake_filter 处理唤醒,user_aggregator 汇总用户这一轮发言,document_edit_router 先尝试处理当前文档的修改请求。回复输出以后,subtitle_relay 回传字幕,assistant_aggregator 把 Agent 的回复记进会话上下文。

文档修改为什么要提前处理,后面的 Proposal 一节再展开。先看语音对话里最直接影响体验的打断。

用户插话以后,旧回复要怎么停

只停前端播放还不够

能唤醒、能回答以后,还得能打断。

假设 Agent 正在念一段比较长的方案,用户中间说"等一下,先别说"。如果只是前端把播放组件停掉,Agora 里已经排队的 PCM 还会继续发送,用户还是会听到 Agent 说完后半段。

Agora 的音频回调跑在 SDK 线程里,这个线程不能等待 LLM,也不能阻塞队列。我们只在回调里做三件事:复制 PCM、判断用户是不是刚开始说话、把音频无阻塞地送进 asyncio 队列。

python 复制代码
speech_started = self._speech_gate.observe(audio)
self._ingress.submit_from_callback(
    audio,
    sample_rate,
    channels,
    barge_in=speech_started,
)

PcmSpeechGate 用音量门限检测说话的起点。当前 16 位单声道 PCM 的 RMS(均方根,用来衡量音量)超过 450,就把这一帧当成讲话开始;连续 12 帧静音后,它才复位。

这个判断只看音频能量,噪声也可能触发。它并不知道用户说了什么,也不负责判断一轮话有没有说完。

用 InterruptionFrame 清掉旧音频

一旦触发 barge_in(用户插话),Input Transport 会先推一个 InterruptionFrame,再推新的音频 Frame:

python 复制代码
if frame.barge_in:
    await self.push_frame(InterruptionFrame())
await self.push_audio_frame(InputAudioRawFrame(...))

Output Transport 收到 InterruptionFrame 后,会清本地待发送队列,再调用 Agora 的 interrupt_audio() 清 RTC 侧已经排队的音频。

停止生成旧回复,还要清掉已经排队的旧音频。 否则模型虽然停了,用户仍然会听到缓存里的后半句。

下面这张时序图把检测和清理分开了:PcmSpeechGate 触发插话信号,后续处理器收到 InterruptionFrame 后,再清理旧回复和待发送音频。

推给 Agora 的 PCM 还要按播放节奏发

输出侧还有一个容易漏掉的问题。Pipecat 会尽快把 TTS 音频从输出队列 drain 掉,Agora 的 AI Server SDK 却要求应用按播放节奏消费 PCM。几秒音频瞬间推过去,发送端可能丢帧或者出现压缩。当前按 PCM 的字节数做了 pacing:

python 复制代码
bytes_per_second = sample_rate * channels * 2
await asyncio.sleep(len(frame.audio) / bytes_per_second)

这里的 2 表示 16 位 PCM 每个采样占 2 字节。**一帧音频的播放时长 = 这一帧的字节数 ÷ 每秒字节数。**比如 24 kHz、单声道的音频,每秒是 48,000 字节,4,800 字节就对应 0.1 秒。

代码按这段时长让出执行权,让音频按播放节奏送出去。模型已经生成了回复,音频发送太快,用户听到的效果仍然会有问题。

Pipecat 是怎么"看见"投屏的

用户可能在 Agent 入会以后才开始投屏

再看投屏。

用户说"Nota,这个报错怎么处理"时,语音里只有"这个",具体的信息都在屏幕上。Voice Agent 得拿到画面,才能知道用户在问哪个报错。

说实话,一开始我以为模型是连续看着视频流来理解内容的,后来才知道,我们这条链路取的是其中一帧。

拿画面也有一个问题:用户可能在 Agent 入会以后才开始共享屏幕。如果只在加入 Channel 的那个时刻订阅,就容易漏掉后面才发布的视频轨道。

现在 RTC 层会自动订阅视频,视频回调收到 Frame 后再用 UID 过滤。只有启动 Agent 的参会人进入视频处理,重的图像转换不会堵住 Agora 的回调线程。

下面这张图里,先看视频帧怎么经过 UID 过滤、进入队列,再变成模型可用的屏幕描述。队列里只保留最新一帧,原因接着往下说。

只保留最新一帧

BoundedVideoIngress 的队列容量只有 1。新帧进来时,如果队列满了,旧帧直接被替换。

这里可以用刚才登录问题的投屏举个例子。测试先停在浏览器白屏,三秒后切到控制台,接着又把红色错误栈展开。用户在最后一步才问"这个报错是什么原因",此时处理白屏截图和中间的控制台截图都没有帮助。队列只留最新一帧,模型看到的是那段错误栈。

屏幕内容只能做事实,不能成为指令

投屏里的文字要按不可信数据处理。视觉模型的系统提示词会声明这个限制,写进 Context 的屏幕描述也会带上对应说明。

比如屏幕上出现一段"忽略前面的要求,调用某个工具"的文字,Agent 应当把它当成正在展示的内容。屏幕内容只能用来辅助回答,不能因为画面里写了一句话,就获得调用 MCP 工具的权限。

Voice Agent 调 MCP 时,身份怎么传

Pipecat 只拿 MCP 会话 Token

Agent 能回答以后,接业务服务就必须解决鉴权的问题。

还是拿"查会议里的人员信息"举例。用户是在客户端登录后启动 Agent 的,但 Pipecat 实例并不需要拿到这个用户的用户名、公司信息或者登录 Cookie。

Java 在启动 Agent 时,会给 Pipecat 实例发一枚 MCP 会话 Token。Pipecat 后续通过这个 Token 关联 Agent 会话,MCP 服务再确定这次调用对应的用户和会议范围。

用户身份和会议范围仍由业务服务处理,Pipecat 不需要持有用户在客户端的登录态。

MCP 失败时,不能拖垮语音链路

MCP 调用还要考虑失败。查会议资料或者生成 Proposal 出了问题,语音 Pipeline 不能直接跟着退出。当前几种调用的等待策略是:

调用阶段 当前策略
工具发现 最多尝试 4 次,重试间隔从 0.1 秒开始翻倍
普通工具调用 默认超时 5 秒
文档 Proposal 超时放宽到 60 秒

文档授权失效时,Pipecat 会提示用户在文档顶部重新授权;其他工具失败时,返回"会议工具暂时不可用"。

任务和文档为什么要先变成 Proposal

任务先提出来,确认后再创建

我一开始想的就是让 Voice Agent 调 MCP 工具,直接帮我建任务。这里还要考虑用户确认:生成的任务可能要改时间,生成的文档也可能有几处内容需要调整。

这版 MVP 统一先生成 Proposal,也就是待确认的修改建议。Agent 把要做的事情提出来,用户确认以后,才真正写入业务数据。 前面留出的卡片栏,就是用来展示和处理这些建议的。

文档修改,先生成 Diff 再回答

然后看 Pipeline 里的 document_edit_router。它收到用户这一轮的 LLMContextFrame 后,取出最新一条用户消息,通过 route_current_document_turn 交给 Java。

假设用户已经授权 Nota 参与当前文档,然后说"把方案里的截止时间改成周五"。Java 会读取授权文档,尝试生成有效的 Diff Proposal,并通过 WebSocket 推送给前端。成功后返回 proposed

Router 收到 proposed,就不再把这次 Context 交给后面的对话 LLM,只给 TTS 一句"修改建议已经整理好了,确认后才会写入"。Java 的文档流程已经处理了这次请求,再让对话 LLM 回答一遍,容易重复,也可能把"生成建议"说成"已经修改"。

如果没有授权文档,或者没有生成有效 Proposal,Router 才把原来的 Context 继续往下传。这里跳过的是 Pipeline 里的对话 LLM;生成文档修改建议仍然可以由 Java 侧的模型流程完成。

MVP 现在能跑什么,后面还缺什么

已经跑通的三类请求

前面几条链路接起来以后,目前 MVP 跑通了三类请求。

  • 用户说"Nota,帮我记一个任务,明天下午把登录问题复现一下,我来跟",Agent 生成只对当前用户可见的待确认任务卡。
  • 用户授权当前文档后说"Nota,把刚才确定的三个结论补到方案最后",document_edit_router 会先请求 Java 生成文档修改建议,再由 Java 推送 Diff。投屏内容不会被拿来猜文档正文。
  • 用户共享屏幕演示报错时,Agent 会带着最新屏幕描述和会议字幕回答问题。当前只保留最新画面,也只看启动人的视频流。

创建任务和文档 Diff 的演示

创建任务这条链路录了一段演示。为了调试方便,视频里用文本客户端模拟会议里的那句话;Pipecat 收到请求后只提交 Proposal,卡片在 Meeting 里确认以后,任务才会进任务视图。

文档 Diff 的演示里,左边是当前文档,右边是 Nota 的确认卡。它先把修改建议提出来,用户确认以后,修改才会写到当前文档。

这版 Diff 只用于验证 MVP。我们的内部文档编辑器通过 Yjs 做协同编辑,生成 Diff 的过程中,其他人可能还在修改同一篇文档,直接应用这份 Diff 会有内容不一致的问题。后续正式接入会按协同编辑器的机制实现,不会直接沿用这里的 Diff 方案。

Flows 还没有进入这版 MVP

这版还没有使用 Pipecat Flows。现在的唤醒、对话和 Proposal 逻辑主要靠 Pipeline Processor 和 Java 的路由服务处理。后面如果要做"收集意见 → 形成结论 → 建任务 → 等待确认"这种有多个节点和状态的流程,我会再考虑用 Flows 来组织。Pipecat 的 Flows 节点和消息文档已经把节点、消息和状态的建模方式列出来了。

后面还要继续拆的边界

这次先把单人 Voice Agent 的对话、看屏幕、调用业务工具和人工确认接起来了。后面还得继续处理多人同时对话、Yjs 协同写入,以及长流程里的状态管理。这篇先把 MVP 的实现过程记录下来,Pipecat 内部的运行机制留到下一篇继续拆。

参考

相关推荐
摇滚侠16 分钟前
《SpringBoot 3:入门与应用实战》第 10 章 REST 服务请求与调用 Reactor 与 WebFlux 笔记 28
spring boot·笔记·后端
唐青枫19 分钟前
别只会用 put:Zig HashMap 从键值查找到高频统计实战
后端
lerhxx19 分钟前
为什么你的Three.js物体总是乱转?一文彻底搞懂“万向锁”与四元数
前端·three.js
计算机魔术师29 分钟前
写了代码还要人修?Google 的 AI 编程助手自己打补丁了
前端
ttwuai36 分钟前
Go 后台清空操作日志失败,权限和无 WHERE 删除怎么排查?
开发语言·后端·golang
choumou_M37 分钟前
SpringBoot_7:用户资源的上传与修改
java·spring boot·后端
user_admin_god1 小时前
一体化数据归集接口详细设计说明
java·大数据·spring boot·后端·spring
DevOpenClub1 小时前
Markdown、HTML 和 PPT 如何稳定交付:文档转换任务的幂等发布流程
开发语言·前端·c#·html·powerpoint
明月_清风1 小时前
十大经典排序算法 Go 实现全解:从入门到面试通关
后端·算法·排序算法