多模态开发(09):全双工对话 Agent

QuibblerAgentQuibblerAgent 2026-10-30 约 27 分钟 3 次阅读

第 7 篇讲 TTS 解决了"说",第 8 篇讲 ASR 解决了"听",把两者与 LLM 串成流水线,看起来就能对话了——识别成文字、交给模型、合成语音返回。真跑起来你会发现每轮问答要卡一两秒,而人对话的换句间隔只有两三百毫秒:延迟在语音交互里是第一等公民,慢半拍用户觉得"它在想",慢一秒就是"卡死了"。本篇讲"边听边想边说"的全双工实时语音,先算清链式拼接为什么到不了实时,再对比三条实时路线,重点拆 OpenAI Realtime 与 Gemini Live 两套接口,落到打断、端点检测、首包优化三个深坑和一个语音客服实战。全文按"毫秒账 → 选型 → 接口 → 深坑 → 实战 → 成本"组织。

1、链式拼接的毫秒账

ASR → LLM → TTS 三段级联是多数团队的第一版语音 Agent,因为每段都是现成 API。问题出在账要一起算:三段串行,还叠加两笔容易被忽略的隐藏开销。

环节 典型耗时 说明
VAD 断句等待 300~800 ms 判定"说完了"的静音尾巴
ASR 定稿 200~500 ms 尾部音频补齐后重新打分
LLM 首 token 300~800 ms 网络、排队、推理
TTS 首字节 150~300 ms 首句合成
传输与转码 约 100 ms 采集、编码、播放缓冲

合计 1~2.5 s,这是每轮回答都逃不掉的固定开销。第一笔隐藏账是 VAD(Voice Activity Detection,语音活动检测)尾巴:你得等用户"说完"才能拿到完整语音,静音阈值压得太低又会把"嗯——"后面的半句截掉,这是结构性等待,优化空间有限。第二笔是段间完整性依赖:ASR 要完整音频才出定稿文本,LLM 要完整文本才组提示词,TTS 要完整首句才好切分合成——每一段都在等上一段的"完成信号"。

级联还有个只在实战里才疼的问题:打断。用户插话时,你要同时停掉 LLM 生成、清空 TTS 缓冲、掐断播放器,还要让三段状态机归位——每段单独都行,合在一起就是分布式取消问题。结论:级联适合录音转写、播客入库这类异步场景;实时对话,换架构。

2、实时方案三条路

2026 年做实时语音有三条路:模型厂商的原生 Realtime(端到端语音模型,一条会话同时听和说)、编排框架(把三段流水线全部流式化拼起来)、自建全链路(VAD + 流式三段自己拼)。

维度 原生 Realtime 编排框架 自建三段
典型首响延迟 300~800 ms 800~1500 ms 1200~2500 ms
开发量 小,一份会话协议 中,拼装框架 大,全套状态机
可控性 低,端到端黑盒 中,段间可插拔 高,每段可调
换模型自由度 绑定单家 聚合多家 任意
适合 快速上线、体验优先 多渠道、多厂商容灾 深度定制、硬合规

原生方案的体验天花板最高:端到端模型直接吃 PCM 吐 PCM,没有"转文字再转回来"的信息损耗,还能听出语气与犹豫。编排框架的代表是 pipecat——Daily.co 开源的 Python 框架,BSD-2 协议,既支持级联三段,也支持接入 OpenAI Realtime 与 Gemini Live 的语音到语音模式,传输层聚合 Daily、WebRTC、电话等渠道。要接电话线、要多厂商容灾的团队,用它省下的状态机代码是实打实的。自建三段自由度最高也最贵:把每段流式化、把打断做成一等公民,工程量约等于自己写一个 pipecat。

级联与端到端的链路差异如下:

flowchart LR subgraph C["级联三段"] C1["语音"] --> C2["ASR"] --> C3["LLM"] --> C4["TTS"] --> C5["语音"] end subgraph E["端到端实时"] E1["语音"] --> E2["实时语音模型"] --> E3["语音"] end

图上最直观的是中间环节的数量:级联每段都要等上一段的完整产物才能开工,端到端把中间三跳折叠成一跳——首响延迟的差距本质是跳数差距。

选型一句话:先上原生 Realtime 验证产品假设,需要多渠道或容灾时引入 pipecat,自建只留给有特殊合规与成本约束的场景。

3、OpenAI Realtime 接入

OpenAI 的 Realtime API 用一条长连接承载整个会话,通道两种:WebSocket 适合服务端中继,WebRTC 适合浏览器与移动端直连(配临时 token,媒体流不绕服务器)。当前模型家族是 gpt-realtime(含 2、2.1 版本与 mini 档),整体事件驱动,细节见官方 Realtime 指南。WebSocket 会话骨架如下:

# openai_realtime.py —— WebSocket 会话骨架(websockets 库)
import asyncio, base64, json
from websockets import connect

URL = "wss://api.openai.com/v1/realtime?model=gpt-realtime"
HEADERS = {"Authorization": "Bearer $OPENAI_API_KEY",
           "OpenAI-Beta": "realtime=v1"}

async def main():
    async with connect(URL, additional_headers=HEADERS) as ws:
        await ws.send(json.dumps({"type": "session.update", "session": {
            "voice": "sage",                          # 音色
            "input_audio_format": "pcm16",            # 16 kHz 单声道
            "output_audio_format": "pcm16",
            "turn_detection": {"type": "server_vad"}, # 服务端断句
            "input_audio_transcription": {"model": "whisper-1"},
        }}))
        await asyncio.gather(push_mic(ws), handle_events(ws))

async def push_mic(ws):
    async for chunk in mic_stream():                  # 100 ms 一片
        await ws.send(json.dumps({
            "type": "input_audio_buffer.append",
            "audio": base64.b64encode(chunk).decode()}))

asyncio.run(main())

连接后先发 session.update 配置音色、音频格式与 VAD;push_mic 以 100 ms 一片推麦克风 PCM,两个协程并发才叫全双工。真正让 Realtime 好用的是事件循环里顺手就能做打断与函数调用:

# handle_events —— 主循环:音频、打断、函数调用
async def handle_events(ws):
    buffer = []                                       # 播放缓冲
    async for raw in ws:
        ev = json.loads(raw)
        t = ev["type"]
        if t == "response.audio.delta":               # 模型在说话
            buffer.append(base64.b64decode(ev["delta"]))
        elif t == "input_audio_buffer.speech_started":  # 用户开口
            buffer.clear()                            # 打断:清播放
            await ws.send(json.dumps({"type": "response.cancel"}))
        elif (t == "response.output_item.done"
                and ev["item"]["type"] == "function_call"):
            out = await call_tool(ev["item"])         # 查订单等本地函数
            await ws.send(json.dumps({
                "type": "conversation.item.create",
                "item": {"type": "function_call_output",
                         "call_id": ev["item"]["call_id"], "output": out}}))
            await ws.send(json.dumps({"type": "response.create"}))

打断的关键两行在 speech_started 分支:清播放缓冲、发 response.cancel 掐掉在途响应,这 20 行就是全双工的核心。函数调用走"工具定义进会话、模型产出调用、客户端回填结果、再触发响应"的回路——tools 在 session.update 里注册,参数格式与 Chat Completions 同构,第 6 节实战会接上真实的工具函数。当前计费音频按 token 计(见 OpenAI 价格页):

模型 音频输入 音频输出 文本输入 文本输出
gpt-realtime $32/1M $64/1M $4/1M $16~24/1M
gpt-realtime-mini $10/1M $20/1M $0.60/1M $2.40/1M

两个省钱点:缓存命中的音频输入降到 $0.40/1M(mini 档 $0.30),重复的问候语与系统提示都会命中;同家族另有按分钟计费的分支——gpt-realtime-whisper 实时转写 $0.017/分钟、gpt-realtime-translate 同传 $0.034/分钟,价格页还给出按秒结算的 gpt-live 会话 $0.05/分钟。对话场景社区实测单分钟成本约 $0.04~0.20,语速与缓存命中影响极大,上量前务必用自己的语料压测。

4、Gemini Live 接入

Gemini Live API 是 Google 的对位产品:一条双向流连接,官方 SDK 封装为 client.aio.live.connect,也提供 WebSocket 接入,细节见 Live API 文档。模型从 gemini-2.5-flash-native-audio-preview 起步,2026 年已迭代到 gemini-3.8-live 家族(含 extended-thinking 变体),面向实时语音 Agent 优化。SDK 版接入骨架:

# gemini_live.py —— google-genai SDK 双向流式会话
import asyncio
from google import genai
from google.genai import types

client = genai.Client()                               # 读 GEMINI_API_KEY
CONFIG = types.LiveConnectConfig(
    response_modalities=["AUDIO"],
    system_instruction="你是电商语音客服,答不上来转人工。")

async def main():
    async with client.aio.live.connect(
            model="gemini-2.5-flash-native-audio-preview-12-2025",
            config=CONFIG) as session:
        await asyncio.gather(send_mic(session), recv(session))

async def send_mic(session):                          # 麦克风 → 模型
    async for chunk in mic_stream():                  # PCM 16 kHz 分片
        await session.send_realtime(types.Blob(
            data=chunk, mime_type="audio/pcm;rate=16000"))

async def recv(session):                              # 模型 → 扬声器
    async for msg in session.receive():
        sc = msg.server_content
        if sc and sc.output_audio:
            play(sc.output_audio.data)                # PCM 24 kHz
        if sc and sc.interrupted:                     # 用户打断
            clear_playback()

与 OpenAI 事件流一一对应:send_realtime 推麦克风分片,session.receive() 收增量音频与状态。打断是内置行为——服务端检测到插话会下发 interrupted 标记,客户端要做的是清空播放队列,别让已被否决的半句话继续播。函数调用同样支持:msg.tool_call 拿到调用、以 FunctionResponse 回填,思路与 OpenAI 一致。计费口径见 Gemini 价格页:gemini-3.8-live 音频输入 $3/1M token(官方折算 $0.005/分钟)、音频输出 $12/1M(折算 $0.018/分钟),文本输入 $0.75/1M、输出 $4.50/1M。

对开发者最有吸引力的是标准层免费额度:Live 系列模型在免费层标注免费,做原型与内测基本不花钱。两个注意:免费层数据可能被用于改进产品,生产环境别默认走它;限流口径价格页未明示,社区实测配额比名义请求数更早耗尽,压测前先读官方 rate limits 文档。

5、打断与首包延迟

三条路线都绕不开三个坑:打断、端点检测、首包延迟,逐个说。

5.1、打断处理

**打断(barge-in)**指用户在 Agent 播报时插话,正确处理要做三件事:停生成,用 response.cancel 之类指令掐掉在途响应;清播放,播放缓冲里未播的音频全部丢弃,否则用户已经听到新回应、扬声器还在念旧稿;记上下文,被打断的那句不要捡起来复读——OpenAI 在会话里标记未播完的音频,Gemini 用 interrupted 事件区分。两处细节容易翻车:播放端骤停会出爆音,留 20 ms 淡出;打断风暴(用户连说带咳)要加 200 ms 去抖,别让响应反复起停。级联方案这三件事要在三个服务间自己协调,这正是原生方案最大的体验红利。

5.2、VAD 端点检测

端点检测回答"用户说完了吗"。OpenAI 的 server_vad 默认静音约 500 ms 判定断句,silence_duration_ms 等参数可调:压到 300 ms 换句更快,代价是拖音被拦腰截断、模型答非所问。背景噪音与电视声还会持续触发假断句,生产上要么在采集端开噪声抑制,要么在业务关键节点(用户报手机号、订单号时)改用显式确认——"好的,订单号 8866,对吗"。自建 VAD 选型上,Silero VAD 一类轻量模型单核 CPU 实时跑几十路,是流式管线的常客。

5.3、首包延迟优化

首包延迟是从用户说完到你出声的间隔。原生方案厂商已做完大部分优化:端到端模型省掉段间往返,你要做的是会话预热——提前建连、提前发 session.update,别让用户第一句话等握手。级联方案的优化清单长得多:LLM 流式输出按句切给 TTS、首句用预合成的短话术垫场(一句"正在为您查询"能盖住 500 ms)、接入点选离模型最近的区域、播放缓冲压到 100 ms 以内。每项都是工程活,这也是第 2 节把级联首响标到 800~1500 ms 的原因——那是把优化全做完的下限,不是默认值。

6、语音客服 Agent 实战

串一个完整场景:电商语音客服,用户开口查物流,能被打断,工具超时有兜底。架构是浏览器麦克风 ⇄ FastAPI WebSocket 中继 ⇄ Realtime 会话,中继层拦两类消息:浏览器的音频分片直通,模型的函数调用就地处理。会话与工具注册:

# agent_relay.py —— FastAPI 中继:浏览器 ⇄ Realtime
import asyncio, json
from fastapi import FastAPI, WebSocket

app = FastAPI()
TOOLS = [{"type": "function", "name": "get_order_status",
          "description": "按订单号查询物流状态",
          "parameters": {"type": "object", "properties": {
              "order_id": {"type": "string"}},
              "required": ["order_id"]}}]

@app.websocket("/voice")
async def voice(ws: WebSocket):
    await ws.accept()
    async with realtime_connect("gpt-realtime") as rt:  # 第 3 节骨架
        await rt.send(json.dumps({"type": "session.update", "session": {
            "instructions": "电商客服,查物流先调 get_order_status。",
            "tools": TOOLS,
            "turn_detection": {"type": "server_vad"}}}))
        await asyncio.gather(pump(ws, rt), pump(rt, ws))

realtime_connect 复用第 3 节的会话建立;两个 pump 把浏览器与 Realtime 连成双向管道,两端都按"文本消息的异步迭代器"看待。转发器的核心是拦截函数调用并回填:

async def pump(src, dst):                          # 双向转发消息
    async for text in src:
        ev = json.loads(text)
        if ev.get("type") == "response.output_item.done" \
                and ev["item"]["type"] == "function_call":
            text = json.dumps({                    # 拦截函数调用
                "type": "conversation.item.create",
                "item": {"type": "function_call_output",
                         "call_id": ev["item"]["call_id"],
                         "output": await call_tool(ev["item"])}})
        await dst.send(text)

async def call_tool(item):                         # 2 秒超时兜底
    try:
        args = json.loads(item["arguments"])
        return await asyncio.wait_for(
            get_order_status(**args), timeout=2)
    except asyncio.TimeoutError:
        return json.dumps({"error": "timeout",
                           "say": "查询超时,为您转人工。"})

普通消息原样透传(音频分片、转写增量都不动),函数调用被拦截后就地执行并改写成 function_call_output 回填,服务端自动续上响应。兜底逻辑在 call_tool:内部订单接口 2 s 不回,就回填一条带兜底话术的超时结果——Realtime 会话里 function_call_output 缺席会让对话挂起,这是新手最容易撞上的卡死点。打断沿用第 3 节 speech_started → response.cancel 的拦截逻辑,浏览器端收到清空播放队列即可。

7、实时会话成本账

实时是按"会话时长 × 语速"烧钱,与级联各段各计各的有本质区别。按 10 分钟客服对话折算:

方案 计费口径 折合每分钟 10 分钟会话
gpt-realtime 音频 $32/$64 每 1M token $0.04~0.20 $0.4~2
gpt-realtime-mini 音频 $10/$20 每 1M token 约 $0.02~0.08 $0.2~0.8
gemini-3.8-live 付费层 官方折算入 $0.005/出 $0.018 约 $0.023 约 $0.23
级联三段 ASR $0.006/分钟 + TTS + LLM $0.02~0.04 $0.2~0.4

级联三段沿用系列第 7、8 篇口径:ASR $0.006/分钟、TTS 约 $0.015/分钟、对话轮次的 LLM 文本 token 几乎可忽略。两个结论:其一,Gemini Live 付费层已经与级联打平,"实时必然贵一个数量级"在 2026 年不再成立,选型该回到体验与生态;其二,OpenAI 标准档仍是级联的 3~5 倍,mini 档与音频缓存是省钱的常规操作。

成本警告:实时会话挂起不清是事故级浪费——用户已挂机、连接还活着、VAD 还在喂环境音。生产必配最大会话时长与闲置超时断连,监控盯 token 消耗而不是请求数。

8、总结

链式拼接输在串行等待与段间完整性依赖,1~2.5 s 的首响撑不起对话。三条路里,原生 Realtime 体验最好、开发最省;pipecat 换来多厂商与电话渠道;自建只留给硬约束场景。OpenAI Realtime 用 WebSocket/WebRTC 加一套事件流把全双工、打断、函数调用都收进会话,gpt-realtime 按 token 计费,mini 档与缓存是省钱位;Gemini Live 用双向流接入,官方折算约 $0.023/分钟且标准层免费,开发内测成本近零。工程上盯死三件事:打断三连(停生成、清播放、记上下文)、端点阈值与截断兜底、会话预热与首句垫场。

练习:给现有的 Agent 加一条语音通道,分三步走。

  1. 用第 3 节骨架跑通浏览器到 Realtime 的回声对话;
  2. 注册一个函数调用,让它真的查到一条数据;
  3. 对着它插话,验证打断三连全部生效。

对于想把文本 Agent 升级为语音交互的团队,建议先用 Gemini 免费层验证交互设计,再按并发与成本预算落到 OpenAI 或混合部署。

相关推荐

​Jev 详解:不做生成的判断模型
精选AI

​Jev 详解:不做生成的判断模型

Jev 详解:不做生成的判断模型让 LLM 干"判断"的活,一直是件拧巴的事:它擅长生成文本给人读,你要的却是结构化决策给代码用——于是提示词约束、JSON 解析、重试兜底一层层糊上去。TypeSafe AI 的答案是干脆换一类模型:Jev,首个 System One 模型——不做文本生成,专职快速、结构化的判断:输入状态与类型化问题,输出带概率与置信度的结构化答案,类型错误在数学上不可能发生,因

598
网站被注入恶意脚本排查实录:AI 辅助安全分析实战
置顶 精选AI

网站被注入恶意脚本排查实录:AI 辅助安全分析实战

网站被注入恶意脚本排查实录:AI 辅助安全分析实战某天收到华为云监管反馈,称个人博客网站存在色情违规界面。作为技术博主,这简直是晴天霹雳——网站一直正常运行,怎么会突然出现这类问题?经过排查发现,网站被注入了恶意跳转脚本。本文记录完整的排查过程,重点分享如何借助 AI 快速定位问题并修复,希望能给遇到类似情况的开发者提供参考。1、问题发现与初步排查收到监管反馈后,第一时间打开网站检查。首页看起来完

370
时隔三年,再次备份:从手搓博客到 AI 全面自动接管
精选AI

时隔三年,再次备份:从手搓博客到 AI 全面自动接管

时隔三年,再次备份:从手搓博客到 AI 全面自动接管今天打开尘封多年的备份目录,看到了一个熟悉的文件名——2023_07_09.sql。那一刻,恍如隔世。三年前的今天,我曾经认真地导出过一次数据库;三年后的今天——2026 年 7 月 9 日,自动备份启用。整整三年,1095 天,我的博客就这样安安静静地跑着,没有人备份,也没有人管,它就那么稳稳当当地活着。感慨万千,大概是此刻最准确的四个字——不

338