不只陪聊:我用 Agora Conversational AI 做了一个会调用 MCP 的语音随记
不只陪聊:我用 Agora Conversational AI 做了一个会调用 MCP 的语音随记
下午一点四十三分,我对着电脑说了一句:“记一下,周六下午打篮球。”几秒后,页面回了一句:“已记下:周6下午打篮球。”
它确实听懂了我要记事,也调用工具把内容保存了下来,只是把汉字“六”转成了数字“6”。这个细微的格式呈现差异,反而让我确认了一件事:语音智能体真正进入日常场景后,问题不只是能不能聊天,还包括它能不能正确判断意图、调用工具、保存结果,以及在不确定时让人确认。
我想做的“随记”很简单。打开电脑或手机网页,说一句待办,系统负责识别、理解并保存;过一会儿再问“我记了些什么”,它还能把之前的事项读回来。整个过程不需要手动点进表单,也不要求一直停留在同一台设备上。
为了跑通这个想法,我选择了 Agora Conversational AI Engine,并在官方 MCP Recipe 的基础上,把原本的时间查询示例改造成了一个中文语音记事助手。

先弄清楚:语音 Agent 不只是给模型接一个麦克风
Agora 对 Conversational AI Engine 的定位,是让已有 AI 模型获得实时语音交互能力。产品页不只谈语音转文字,还覆盖了实时传输、模型接入、语音回复和打断处理。这和我最初的判断一致:如果只做“录音转文字”,浏览器调用一个语音识别接口就够了;要让助手持续参与对话并在合适的时候执行动作,还需要一个负责会话和工具编排的运行层。

官方 Voice Agent overview 把语音智能体拆成 实时传输、Agent 运行时、AI 模型、端上体验 四层。实时传输负责音频、事件和会话状态;Agent 运行时管理会话生命周期、轮次、打断、记忆与工具编排;模型层包含 ASR、LLM 和 TTS;终端则可以是 Web、移动应用、桌面端或专用设备。
这不是一张抽象架构图,在“随记”里每一层都有实际落点:
| 官方能力层 | “随记”中的实现 | 已有验证 |
|---|---|---|
| 实时传输 | 浏览器通过 RTC/RTM 加入语音频道 | Analytics 中同一频道出现 iPhone 和 Linux Agent |
| Agent 运行时 | 后端启动会话、配置 VAD 轮次检测,并开启工具调用 | 实时对话状态、MCP tools/call 请求 |
| AI 模型 | Deepgram nova-3 识别中文,托管 OpenAI 理解意图,MiniMax 合成回复 |
页面依次出现“语音识别 / 理解与工具 / 语音回复” |
| 端上体验 | Next.js 页面同时在电脑和 iPhone 浏览器中运行 | 手机 HTTPS 访问、麦克风授权和真实对话 |
也正因为这四层已经被整理为一个 Voice Agent 能力框架,我不需要从零拼接 ASR + LLM + TTS + 实时传输 + 轮次检测 + 工具编排。项目里仍要写业务系统指令、MCP 工具、数据保存和安全边界,但实时对话最容易散落在多个 SDK 里的部分,已经由 Agora 的 Agent 运行时和实时传输能力承接。
代码里将 interrupt_duration_ms 设为 160ms、将一句话结束的静音时长设为 480ms,这对应了 Voice Agent 对轮次与打断的运行时管理。官网还提供背景噪声抑制和实时打断等能力;当前测试没有单独做嘈杂环境或连续抢话的对比,因此我只把它们作为平台能力,不把它们写成“随记”已经测出的性能结论。

我没有在控制台里再创建一个可视化 Agent,而是采用代码方式启动 Agent。官方的 recipe-agent-mcp 已经准备好了 Next.js 前端、FastAPI 后端、Agora 会话管理以及 MCP 接入方式,适合从一个确定能运行的基础开始改造。原始示例只有 get_time 工具,用户问时间后,模型发出工具调用,Agora 云端请求公开的 MCP 地址,再把工具结果交还给模型说出来。
我的改造没有改变这套调用关系,只是把“查询服务器时间”换成了真正需要持久化的“保存事项”和“读取事项”。这比另起一个只在本地伪造回复的页面更能检验 Conversational AI 的作用。
从控制台到本地:先把服务接通
控制台中的项目提供 App ID 和 App Certificate,当前账号也能看到 RTC、Signaling 与 Conversational AI 的用量入口。App Certificate 只保存在后端环境变量中,前端不直接持有它;浏览器拿到的是后端按频道和用户生成的临时 Token。

进入 RTC Services 后,Conversational AI Engine 已经处于启用状态。这里容易混淆的地方是,左侧虽然还有 Agents 入口,但代码 Recipe 并不要求先在那里手工创建 Agent。网页请求后端的 /startAgent,后端再通过 Agora Agent SDK 动态创建会话。

本地配置集中在 server/.env.local,正文里只保留变量名,不填写真实凭据:
AGORA_APP_ID=your_agora_app_id
AGORA_APP_CERTIFICATE=your_agora_app_certificate
MCP_ENDPOINT=https://your-domain.ngrok-free.app/mcp
OPENAI_MODEL=gpt-4o-mini
NOTE_DB_PATH=./notes.db
这些字段各自对应不同的配置职责:
-
AGORA_APP_ID和AGORA_APP_CERTIFICATE用于后端生成进入频道的临时 Token,可对照 Voice Agent quickstart 中的项目绑定与本地启动流程。 -
MCP_ENDPOINT指向公开的工具地址。官方 MCP Recipe 采用同样的方式,让 Agora 云端访问/mcp,因此它不能写成localhost。 -
OPENAI_MODEL配合 Agora 托管 OpenAI 配置,可以使用平台托管模式而不把 OpenAI Key 放进浏览器。 -
NOTE_DB_PATH是“随记”自己的持久化配置,决定 SQLite 事项库的位置。
其中最容易配错的是 MCP_ENDPOINT。调用 MCP 的并不是当前浏览器,而是 Agora 云端;云端无法访问我电脑上的 127.0.0.1:8000,所以需要先用 ngrok 暴露后端:
bun run setup
ngrok http 8000
bun run dev
后端和 FastMCP 运行在同一个 FastAPI 进程、同一个 8000 端口。ngrok 生成公开 HTTPS 域名后,我把域名末尾补上 /mcp 写入 MCP_ENDPOINT。前端仍然运行在 3000 端口,并通过 Next.js 的 /api/* 重写访问后端,所以 App Certificate、MCP 实现和 SQLite 数据库都不会放到浏览器里。
模型层也没有额外塞进浏览器密钥。官方 OpenAI 配置文档 说明,使用 Agora 托管模式时可以省去自备 OpenAI API Key;当前代码正是用可选的 OPENAI_API_KEY 配置托管 OpenAI,并保留系统指令、问候语和模型名称等可控参数。官方 Deepgram ASR 配置 支持 nova-3、语言与 keyterm,因此中文识别设置和“篮球”关键词不是页面上的假参数,而是 Agent 启动时实际传入的 ASR 配置。
第一次进入对话页时,浏览器会询问麦克风权限。允许后,网页加入 RTC 频道,后端启动云端 Agent;页面上的绿点和“结束对话”按钮表示会话已经建立。桌面端先完成基础连通性检查,真实记事和转录结果放到后面的对话中验证。
把 get_time 换成能落库的记事工具
语音记事只有两个核心动作:增加一条事项,以及读出最近保存的事项。我在 FastMCP 服务里注册了 add_note 和 list_notes,并用 SQLite 做持久化。
@mcp.tool()
def add_note(content: str) -> str:
"""保存用户口述的待办或提醒事项。"""
note_content = _normalize_note(content)
with _connect() as connection:
connection.execute(
"INSERT INTO notes (content) VALUES (?)",
(note_content,),
)
return f"已记下:{note_content}"
@mcp.tool()
def list_notes() -> str:
"""读取最近保存的十条事项,供用户查询。"""
with _connect() as connection:
rows = connection.execute(
"SELECT content FROM notes ORDER BY id DESC LIMIT 10"
).fetchall()
if not rows:
return "你还没有记录事项。"
note_list = ";".join(row[0] for row in rows)
return f"最近记录的事项有:{note_list}。"
写入前会压缩多余空白、拒绝空内容,并把单条事项限制在 240 个字符以内。查询则只返回最近十条,避免一次语音回复读出过多内容。SQL 使用参数绑定,没有把识别文本直接拼进语句。
工具存在还不够,模型必须知道什么时候调用。我给 Agent 增加了一段中文系统指令:用户明确说“记录、添加、记下待办或提醒”时调用 add_note;询问“记了什么、有哪些待办”时调用 list_notes;如果识别文本明显不完整,不要直接落库,先复述并确认。Agent 配置中同时打开 enable_tools,并把公开的 MCP 地址作为 streamable_http 服务交给托管 OpenAI 模型。
语音部分采用 Deepgram nova-3,语言设为 zh-CN,打开标点和智能格式;TTS 使用 MiniMax 中文音色。针对测试内容,我还增加了“篮球”作为识别关键词。这里没有把关键词当成万能修复,它只能给特定词更多提示,数字、同音字和口音仍可能影响转写。
第一次真实开口:从转录到工具调用
配置完成后,我先在电脑上说:“记一下,周六下午打篮球。”页面依次走完 语音识别—理解与工具—语音回复,Agent 随后回复保存成功。当前这次会话记录的识别到首包为 707ms、回复音频首包为 401ms,这只是单次页面观测值,不代表固定性能。
这次转录有一个轻微的格式差异:页面和数据库里保存的是“周6下午打篮球”。语义和工具动作都没有偏离,ASR 只是把汉字数字呈现成了阿拉伯数字。

对普通备忘录来说,“周6”和“周六”不影响人阅读;如果下一步要自动创建日历、计算提醒时间,就应当再做一次确认。继续堆识别关键词不是完整答案,更稳妥的办法是对日期、时间和姓名等高风险字段增加确认。
后面的手机测试里,我说“记一下,下周日上午9点开会”,Agent 没有马上保存,而是先问:“你是要记下‘下周日上午9点开会’吗?请确认一下。”我回答“确认”后,它才执行写入。这个多出来的对话回合牺牲了一点速度,但比把错误时间直接写进待办更可靠。
手机访问为什么需要两条公开路径
电脑访问 localhost:3000 时,浏览器、Next.js 和后端都在同一台电脑上。手机打开这个地址,localhost 指向的是手机自己,自然找不到电脑上的服务。为了验证移动端,我又为前端建立了一条 ngrok HTTPS 隧道:
ngrok http 3000
手机只需要打开前端的 HTTPS 地址。前端请求 /api/get_config、/api/startAgent 和 /api/stopAgent,Next.js 再把它们重写到电脑上的 localhost:8000;与此同时,Agora 云端通过另一条后端隧道访问 /mcp。因此开发环境里实际有两个不同用途的入口:一个给手机访问页面,一个给 Agora 云端调用工具。
iPhone 打开地址后会再次申请麦克风权限。授权弹窗来自移动浏览器,页面下方已经显示“对话记录”和实时转录区域,说明同一个 Web 体验可以直接在手机浏览器里使用,不需要额外打包原生应用。

确认事项后,手机端收到了“已记下:下周日上午9点开会”的回复。页面在这次交互中记录的识别到首包为 654ms、语音回复首包为 314ms,同样只用于还原这次测试,不拿它推导平均延迟或准确率。

怎么证明不是前端写了一句假回复
只看聊天气泡,无法判断内容究竟有没有经过工具。ngrok 的 Traffic Inspector 给出了更直接的证据:Agora 云端向 /mcp 发起 POST 请求,请求体中的方法是 tools/call,工具名为 add_note,参数 content 是“下周日上午9点开会”。服务返回 200 OK,响应内容是“已记下:下周日上午9点开会”。模型的理解结果通过 MCP 进入了业务函数,而不是停留在对话文本里。
请求列表里还保留了一次 GET /mcp 的 502 Bad Gateway。那是调试期间隧道或本地服务没有正确响应的失败请求;随后重新确认端口和服务状态,POST /mcp 才稳定返回成功。我没有把这条失败记录裁掉,因为它也说明公开地址不是配上就一定能用,云端能否真正访问 /mcp 必须以请求记录为准。

保存之后,我回到电脑端问:“我记了些什么?”模型调用 list_notes,返回最近两条事项:“下周日上午9点开会;周6下午打篮球。”手机记录的内容能在电脑上读出,原因不是浏览器之间做了同步,而是两个会话最终访问了同一个后端 SQLite 数据库。

这也给“记住”划出了一条清楚的边界:对话历史负责当前会话的上下文,真正需要跨设备、跨会话保留的事项交给外部工具和数据库。即使后端服务重启,只要 notes.db 还在,保存的事项就不会跟着内存一起消失。
再到 Agora Analytics 核对通话
前端对话和 ngrok 请求分别证明了交互结果与工具调用,我又到 Agora Analytics 查询相同时间段。产品页提供了会话监控与质量诊断能力,项目后端也开启了会话指标;列表里能看到多个以 mcp- 开头的频道,这些名称由后端在创建会话时生成。手机测试对应的频道从 13:47:52 持续到 13:49:12,状态为已结束,参与用户数为 2。

进入这次通话的详情后,两端信息更加明确:一端设备是 Apple iPhone,平台为 iOS;另一端是 Linux,对应云端 Agent。时间轴上两名参与者在同一个频道内加入并离开,时间也与手机上的 13:47 至 13:49 对得上。
列表中的“2 个参与者”只能说明频道里有两个连接,设备详情则把这两个连接的角色补全了。iPhone 一端对应手机浏览器,Linux 一端的加入时间比手机早约两秒,并在手机离开后结束,符合后端先启动 Agent、用户随后完成加入的实际顺序。这里没有用通话时长推算业务性能,只把它作为会话确实发生过的旁证。

这组记录让我能够比较克制地确认结果:手机浏览器确实加入了 Agora 实时频道,云端 Agent 同时在线,用户语音经过识别和模型判断触发了 MCP 工具,事项被 SQLite 保存,后续又能通过另一次工具调用读回。实时会话、工具调用与后台可观测性 在这里不是三套孤立功能,而是同一次实际使用留下的互相印证。
随记已经跑到哪里了
目前“随记”已经完成两项核心动作:用语音保存事项,以及在另一台设备上读回已保存的事项。编辑、定时提醒和多人数据隔离留给真正需要它们的业务场景。
从官方 get_time 到真实的语音随记,变化发生在工具边界上。Agora Conversational AI 承担实时语音会话、Agent 运行时和模型编排;MCP 把模型意图接到 add_note、list_notes;SQLite 留下跨会话数据。
现在,我可以在手机上说“下周日上午9点开会”,确认后放下手机;再回到电脑问“我记了些什么”,听到它把刚才的事项读出来。这就是“随记”已验证过的使用方式。