
下午一点四十三分,我对着电脑说了一句:"记一下,周六下午打篮球。"几秒后,页面回了一句:"已记下:周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=
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 到真实的语音随记,真正的变化发生在工具边界上。实时语音会话、Agent 运行时和模型编排由 Agora Conversational AI 承担;模型意图经 MCP 接到 add_note、list_notes;跨会话数据则留在 SQLite 里。
现在,我可以在手机上说"下周日上午9点开会",确认后放下手机;再回到电脑问"我记了些什么",听到它把刚才的事项读出来。这就是"随记"已验证过的使用方式。

评论0