用 FastAPI + WebRTC + RAG 搭一个简化版 Voice Agent Demo

最近我在拆智能客服和 Voice Agent 平台时,发现很多文章会把概念讲得很完整:ASR、RAG、LLM、TTS、WebRTC、转人工、工单流转……每个词都对,但读完以后还是不知道一个最小系统到底怎么串起来。

这篇文章不追求做一个生产级语音客服平台,而是搭一个能跑通链路的最小 demo:

  • 浏览器通过 WebRTC 建立实时音频通道;
  • 前端用 DataChannel 向后端发送一条用户问题;
  • FastAPI 负责信令接口和后端服务;
  • 后端用一个很轻量的 RAG 检索模块,从本地知识库里找上下文;
  • 最后把带引用依据的回答返回给浏览器。

为了让 demo 足够短,本文先把 ASR 和 TTS 做成可替换接口:WebRTC 音频通道已经建立,真正的语音识别和语音合成可以在后续接入。也就是说,这不是“完整商用 Voice Agent”,而是一个适合继续扩展的骨架。

一、先看最小架构

一个最小 Voice Agent 可以拆成 6 层:

Browser Mic

WebRTC PeerConnection

FastAPI Signaling

Audio Track Receiver

DataChannel

RAG Retriever

Answer Composer

如果放到真实智能客服场景里,链路会变成:

用户语音
  -> VAD 判断是否在说话
  -> ASR 转文字
  -> RAG 检索知识库 / 订单 / 工单 / CRM
  -> LLM 生成回答
  -> 规则层判断是否转人工
  -> TTS 合成语音
  -> WebRTC / 电话线路回传给用户

本文先实现最小闭环:

WebRTC 建连
  -> DataChannel 发送问题
  -> 本地知识库检索
  -> 返回带依据的回答

这样做的好处是:先把实时通道、信令、知识库检索、回复格式跑通,再逐步替换 ASR、LLM、TTS。

二、为什么用 FastAPI + WebRTC + RAG

模块在 demo 里的作用真实 Voice Agent 中的作用
FastAPI提供 /offer 信令接口、托管前端页面承接 Web 服务、鉴权、日志、业务接口
WebRTC建立浏览器和服务端之间的实时连接支撑低延迟语音通话、打断、实时互动
DataChannel传输文本问题和回答结果可传 ASR 中间结果、状态事件、调试日志
RAG从本地知识库找上下文接企业知识库、FAQ、工单、产品文档
Answer Composer拼装回答可替换为大模型、工作流或规则引擎

一个常见误区是:Voice Agent 不只是“LLM + TTS”。在客服场景里,真正难的是实时交互、知识命中、业务边界和可追溯。用户问一句“我这个订单为什么还没发货”,系统不仅要会说话,还要知道:

  • 应该查哪个订单;
  • 有没有权限看订单;
  • 回答依据来自哪里;
  • 什么时候必须转人工;
  • 有没有把这次对话记录进工单。

所以一个哪怕很小的 demo,也应该把链路拆清楚。

三、项目目录

voice-agent-demo/
  main.py
  static/
    index.html

安装依赖:

pip install fastapi "uvicorn[standard]" aiortc

这里没有强行接入某个大模型 SDK。为了让 demo 能直接跑,RAG 和回答生成先用本地函数模拟。后面可以把 compose_answer() 换成 OpenAI、通义、DeepSeek、Qwen、GLM 或企业内部模型。

四、后端代码:FastAPI + aiortc

新建 main.py:

import asyncio
import json
from typing import List, Dict

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from aiortc import RTCPeerConnection, RTCSessionDescription


app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")

pcs = set()


KNOWLEDGE_BASE: List[Dict[str, str]] = [
    {
        "title": "Voice Agent 首响延迟",
        "content": "Voice Agent 的首响延迟通常由 VAD、ASR、LLM 推理、RAG 检索和 TTS 合成共同决定。企业客服场景建议把首轮响应控制在 1.5 秒以内。",
    },
    {
        "title": "RAG 在智能客服中的作用",
        "content": "RAG 可以把企业 FAQ、产品手册、订单规则、售后政策等内容检索出来,作为大模型回答的依据,降低幻觉风险。",
    },
    {
        "title": "转人工策略",
        "content": "当用户情绪升高、连续两轮未解决、涉及退款投诉、隐私权限或高风险业务动作时,Voice Agent 应主动转人工。",
    },
    {
        "title": "WebRTC 的价值",
        "content": "WebRTC 适合浏览器侧低延迟音视频通信,可用于网页客服、在线咨询、实时语音助手等场景。",
    },
]


def retrieve(query: str, top_k: int = 2) -> List[Dict[str, str]]:
    """一个极简 RAG 检索器:用关键词重合度模拟召回。

    生产环境可以替换为向量数据库,例如 Milvus、Qdrant、pgvector、Elasticsearch
    或企业知识库自己的召回接口。
    """
    query_chars = set(query.lower())
    scored = []

    for doc in KNOWLEDGE_BASE:
        text = (doc["title"] + doc["content"]).lower()
        score = len(query_chars & set(text))
        scored.append((score, doc))

    scored.sort(key=lambda item: item[0], reverse=True)
    return [doc for score, doc in scored[:top_k] if score > 0]


def compose_answer(query: str, docs: List[Dict[str, str]]) -> Dict[str, object]:
    if not docs:
        return {
            "answer": "知识库里没有找到足够依据,建议转人工或补充知识库内容。",
            "citations": [],
            "route": "human_fallback",
        }

    context = "\n".join([f"- {doc['title']}:{doc['content']}" for doc in docs])

    # 这里先用模板模拟 LLM。真实项目中可以把 query + context 交给大模型。
    answer = (
        f"根据当前知识库,我会这样判断:{docs[0]['content']} "
        f"如果这是线上客服场景,还需要记录本轮问题、命中的知识条目和是否触发转人工策略。"
    )

    return {
        "answer": answer,
        "citations": [doc["title"] for doc in docs],
        "route": "rag_answer",
        "debug_context": context,
    }


async def consume_audio(track):
    """接收浏览器传来的音频轨道。

    这个 demo 只消费音频帧,避免连接阻塞。
    后续可以在这里接 VAD / ASR,把音频转成文字后交给 RAG。
    """
    while True:
        try:
            frame = await track.recv()
            # 为了避免刷屏,这里不打印每一帧。
            # 可以在这里加入音量检测、VAD、ASR streaming。
        except Exception:
            break


@app.get("/")
async def index():
    with open("static/index.html", "r", encoding="utf-8") as f:
        return HTMLResponse(f.read())


@app.post("/offer")
async def offer(request: Request):
    params = await request.json()
    offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"])

    pc = RTCPeerConnection()
    pcs.add(pc)

    @pc.on("datachannel")
    def on_datachannel(channel):
        @channel.on("message")
        def on_message(message):
            if not isinstance(message, str):
                return

            try:
                payload = json.loads(message)
                query = payload.get("query", "").strip()
            except json.JSONDecodeError:
                query = message.strip()

            docs = retrieve(query)
            result = compose_answer(query, docs)
            channel.send(json.dumps(result, ensure_ascii=False))

    @pc.on("track")
    def on_track(track):
        if track.kind == "audio":
            asyncio.create_task(consume_audio(track))

    @pc.on("connectionstatechange")
    async def on_connectionstatechange():
        if pc.connectionState in ["failed", "closed", "disconnected"]:
            await pc.close()
            pcs.discard(pc)

    await pc.setRemoteDescription(offer)
    answer = await pc.createAnswer()
    await pc.setLocalDescription(answer)

    return JSONResponse(
        {
            "sdp": pc.localDescription.sdp,
            "type": pc.localDescription.type,
        }
    )


@app.on_event("shutdown")
async def on_shutdown():
    await asyncio.gather(*[pc.close() for pc in pcs])
    pcs.clear()

这段代码做了几件事:

  1. /offer 接收浏览器发来的 WebRTC offer;
  2. 服务端创建 RTCPeerConnection 并返回 answer;
  3. 浏览器音频轨道会进入 consume_audio();
  4. 文本问题通过 DataChannel 进入后端;
  5. 后端先检索知识库,再返回答案、引用来源和路由类型。

五、前端代码:浏览器建立 WebRTC 连接

新建 static/index.html:

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <title>Mini Voice Agent Demo</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      max-width: 860px;
      margin: 40px auto;
      line-height: 1.7;
    }
    textarea {
      width: 100%;
      min-height: 90px;
      font-size: 15px;
    }
    button {
      margin: 8px 8px 8px 0;
      padding: 8px 14px;
      cursor: pointer;
    }
    pre {
      background: #f6f8fa;
      padding: 14px;
      overflow: auto;
    }
  </style>
</head>
<body>
  <h1>Mini Voice Agent Demo</h1>
  <p>这个页面会建立 WebRTC 音频通道,并通过 DataChannel 向后端发送问题。</p>

  <button id="connect">连接 Voice Agent</button>
  <span id="status">未连接</span>

  <h2>提问</h2>
  <textarea id="query">智能客服什么时候应该转人工?</textarea>
  <br />
  <button id="send">发送问题</button>

  <h2>返回结果</h2>
  <pre id="result">等待回答...</pre>

  <script>
    let pc;
    let channel;

    const statusEl = document.getElementById("status");
    const resultEl = document.getElementById("result");

    document.getElementById("connect").onclick = async () => {
      pc = new RTCPeerConnection();
      channel = pc.createDataChannel("agent-text");

      channel.onopen = () => {
        statusEl.textContent = "DataChannel 已连接";
      };

      channel.onmessage = (event) => {
        const data = JSON.parse(event.data);
        resultEl.textContent = JSON.stringify(data, null, 2);
      };

      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      stream.getTracks().forEach((track) => pc.addTrack(track, stream));

      const offer = await pc.createOffer();
      await pc.setLocalDescription(offer);

      const response = await fetch("/offer", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(pc.localDescription),
      });

      const answer = await response.json();
      await pc.setRemoteDescription(answer);
      statusEl.textContent = "WebRTC 已连接,等待 DataChannel 打开";
    };

    document.getElementById("send").onclick = () => {
      if (!channel || channel.readyState !== "open") {
        resultEl.textContent = "请先点击“连接 Voice Agent”。";
        return;
      }

      const query = document.getElementById("query").value;
      channel.send(JSON.stringify({ query }));
    };
  </script>
</body>
</html>

这里有一个小设计:浏览器会申请麦克风权限,把音频 track 加到 WebRTC 连接里;但问题文本通过 DataChannel 发送。这样可以把“实时通道”和“RAG 回答”先跑通。

如果后续要变成真正语音版,只需要把链路改成:

audio track -> ASR streaming -> transcript -> retrieve() -> compose_answer() -> TTS -> audio track

六、运行 demo

启动服务:

uvicorn main:app --reload

打开浏览器:

http://127.0.0.1:8000

操作顺序:

  1. 点击“连接 Voice Agent”;
  2. 允许浏览器使用麦克风;
  3. 在输入框里输入问题;
  4. 点击“发送问题”;
  5. 查看返回的 answer、citations 和 route。

示例问题:

智能客服什么时候应该转人工?

可能得到的结果:

{
  "answer": "根据当前知识库,我会这样判断:当用户情绪升高、连续两轮未解决、涉及退款投诉、隐私权限或高风险业务动作时,Voice Agent 应主动转人工。如果这是线上客服场景,还需要记录本轮问题、命中的知识条目和是否触发转人工策略。",
  "citations": [
    "转人工策略",
    "RAG 在智能客服中的作用"
  ],
  "route": "rag_answer",
  "debug_context": "- 转人工策略:当用户情绪升高、连续两轮未解决、涉及退款投诉、隐私权限或高风险业务动作时,Voice Agent 应主动转人工。\n- RAG 在智能客服中的作用:RAG 可以把企业 FAQ、产品手册、订单规则、售后政策等内容检索出来,作为大模型回答的依据,降低幻觉风险。"
}

七、这个 demo 离生产级 Voice Agent 还差什么

上面的 demo 只解决了最小闭环,离真正可用的智能客服平台还有几层关键能力。

能力demo 现状生产环境建议
ASR只接收音频帧,未识别接入流式 ASR,输出中间识别结果
VAD未实现用 VAD 判断用户是否说完,支持打断
RAG关键词召回换成向量召回 + 关键词召回 + rerank
LLM模板生成接大模型,并增加系统提示词和安全边界
TTS未实现接流式 TTS,边生成边播放
转人工返回 route 字段接客服工作台、工单系统、坐席分配
日志只返回 debug_context记录 trace_id、命中文档、耗时、模型版本
权限未实现按用户身份控制可查知识和可执行业务动作

我比较建议按这个顺序扩展:

  1. 先接 ASR,让音频真的转成文本;
  2. 再接向量知识库,提高召回效果;
  3. 然后接 LLM,并要求回答必须带引用;
  4. 最后接 TTS 和转人工。

这样每一步都有可观察的指标,不会一上来就堆成一个黑盒。

八、测评一个 Voice Agent Demo,要看哪些指标

如果只是能回答问题,不算真正的 Voice Agent。做智能客服平台测评时,我会优先看这些指标:

指标为什么重要简单测试方法
首响延迟用户是否觉得“它在卡”从用户停止说话到首字出现/首音播放
识别准确率ASR 错了,后面全错噪声、口音、业务名词混合测试
知识库命中率决定回答是否有依据用 30 条 FAQ 看命中文档是否正确
幻觉率客服场景不能乱编问知识库没有的问题,看是否承认不知道
打断能力语音客服必须能被用户打断TTS 播放中插话,看系统是否停播
转人工策略高风险问题不能硬答投诉、退款、隐私、重复失败场景
日志可追溯方便复盘和审计检查是否记录 trace_id、召回、模型版本

这也是我认为 Voice Agent 和普通聊天机器人最大的区别:它不只是“能不能回答”,而是“能不能在真实业务里可控地回答”。

九、下一步可以怎么改

如果要把本文 demo 往真实项目推进,可以做 4 个小版本。

v0.2:接入流式 ASR

把 consume_audio() 改造成音频缓冲器,把音频帧送给 ASR 服务:

async def consume_audio(track):
    while True:
        frame = await track.recv()
        pcm = frame.to_ndarray()
        # send pcm to streaming ASR
        # transcript = await asr_client.send(pcm)

v0.3:接入向量知识库

把 retrieve() 替换为向量检索:

def retrieve(query: str, top_k: int = 3):
    embedding = embed(query)
    docs = vector_store.search(embedding, top_k=top_k)
    return docs

v0.4:回答必须带依据

给 LLM 的提示词里加约束:

你是企业智能客服助手。
只能根据给定知识库回答。
如果知识库没有依据,请回答“当前资料不足,需要转人工”。
每次回答必须返回引用的知识条目标题。

v0.5:加入转人工

可以先用规则实现:

def should_handoff(query: str, answer: str) -> bool:
    risk_words = ["投诉", "退款", "报警", "隐私", "泄露", "人工", "主管"]
    return any(word in query for word in risk_words)

真实项目里,转人工策略还要结合用户等级、会话轮次、情绪、业务类型和坐席状态。

十、总结

这篇 demo 的重点不是“我写了一个多聪明的 AI 客服”,而是把 Voice Agent 的工程链路拆开:

  • FastAPI 负责服务入口和信令;
  • WebRTC 负责实时连接;
  • DataChannel 负责状态和文本消息;
  • RAG 负责把回答拉回知识库;
  • route 字段为后续转人工和业务编排留出口;
  • debug_context 为日志、审计和测评留依据。

如果你要评估一个 Voice Agent 平台,不建议只看它“声音像不像真人”。更值得看的,是它在真实业务里有没有这些能力:

  1. 能不能低延迟响应;
  2. 能不能基于知识库回答;
  3. 能不能承认不知道;
  4. 能不能在高风险场景转人工;
  5. 能不能把每次回答的依据留下来。

语音只是入口,RAG 和业务控制才是智能客服能不能落地的关键。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐