很多实时语音 Agent 的第一版,都能完成一段令人满意的演示:用户说话,大模型理解,AI 用自然语音回答。
但只要加入一句"帮我创建明晚 8 点的语音房,并邀请最近一起玩的人",问题立刻变了:
- 用户是在随口讨论,还是已经下达指令?
- AI 播报确认信息时,用户插话修改时间,旧操作还会执行吗?
- 创建成功后网络断开,重试会不会生成两个房间?
- 大模型说"已经创建",能不能代表业务系统真的成功了?
这也是从聊天机器人走向 Agent 时最容易被低估的张力:**模型生成能力越来越强,但开发者仍然要为每一次真实副作用划定边界。**你需要提升的并不只是 Prompt 技巧,而是把模糊的人类表达转换成可确认、可执行、可恢复的系统契约。
本文以社交娱乐场景中的实时 AI 语音伙伴为例,设计一个最小但完整的 Agent:它可以理解用户意图、提出操作方案、接受语音修改,并在获得有效确认后调用业务工具。
本文代码用于说明应用层编排,不对应或虚构任何 Tencent RTC SDK API。
先明确:哪些能力已经成立,哪些仍是产品责任
根据 Tencent Conversational AI overview,Tencent Conversational AI 面向用户与大模型之间的实时语音交互,并支持跨平台集成及多种大模型提供方。官方的大模型配置文档还说明了 OpenAI 兼容模型以及 Dify、Coze 等 Agent 平台的连接方式,并涉及请求标识在路由和可观测性中的使用。
这些能力可以帮助开发者连接实时语音链路与大模型,但不能自动推出以下结论:
- 模型知道何时可以代表用户执行操作;
- 一句"好"一定是在确认当前方案;
- 中断语音播放等于取消后台业务请求;
- 模型生成"成功"就等于业务提交成功;
- 更换模型后,工具调用行为仍然完全一致。
因此,语音 Agent 至少要分成六个责任层:
text
实时媒体层 → 语音识别 → Agent 编排层 → 大模型
↓
策略与确认层
↓
业务工具执行器
↓
语音合成、界面卡片与回执
RTC、ASR、LLM 和 TTS 解决"听见、理解、生成、说出";真正决定能否行动的,应当是应用自己的策略与执行层。
不要从"万能助手"开始,先选一条最薄的行动链路
实时语音 Agent 的 0 到 1,不宜按功能数量衡量。更有效的最小闭环是:
- 用户提出一个范围明确的目标;
- 模型只能生成结构化操作建议;
- 应用验证参数和权限;
- Agent 向用户复述即将产生的影响;
- 用户确认、修改或取消;
- 工具执行器提交操作;
- Agent 根据真实回执反馈结果。
以"创建语音房"为例,第一版可以只支持:
- 房间主题;
- 开始时间;
- 一组明确选择的邀请对象;
- 创建前确认;
- 成功、失败和结果未知三种回执。
先不要加入"根据关系自动邀请朋友""自行判断最佳时间""连续协调多个人"等自主能力。它们增加的不是几个参数,而是权限、隐私和错误责任。
Tencent RTC 的社交娱乐解决方案覆盖语音聊天室、1 对 1 社交、社区和 AI 虚拟伙伴等场景,因此这种"对话伙伴 + 社交动作"的组合是合理的产品方向;但具体业务动作、授权规则和审核责任仍应由应用实现。
用副作用等级决定控制权,而不是看模型有多聪明
同一个大模型,可以同时回答闲聊问题和创建社交活动,但两类任务不应拥有同样的执行权限。
| 操作类型 | 示例 | 建议控制方式 | 失败影响 |
|---|---|---|---|
| 无副作用 | 解释房间规则、推荐聊天话题 | 可直接回答 | 内容不准确 |
| 只读查询 | 查询自己的日程或房间状态 | 明确数据范围后执行 | 隐私泄露、结果过期 |
| 可逆操作 | 创建尚未发布的房间草稿 | 语音确认或界面确认 | 产生多余草稿 |
| 外部可见操作 | 发布房间、发送邀请 | 绑定具体方案确认 | 打扰他人、暴露关系 |
| 高风险操作 | 付费、封禁、公开敏感内容 | 强认证与界面确认,必要时人工介入 | 资金、账号或安全影响 |
这里的判断标准不是"模型准确率看起来够不够高",而是三个问题:
- 错误发生后能否完整撤销?
- 操作会不会影响其他人?
- 用户是否能在执行前看懂具体后果?
只要答案涉及不可逆、外部影响或身份风险,就不应让自然语言中的模糊肯定词直接触发执行。
把模型输出降级为"提案"
模型不应该直接调用业务服务。它只负责产生一个候选提案,应用层再决定接受、补充还是拒绝。
可以先定义以下数据结构:
ts
type ActionRisk = 'read_only' | 'reversible' | 'external' | 'high';
type AgentProposal = {
proposalId: string;
conversationId: string;
turnId: string;
requestId: string;
action: 'create_voice_room';
risk: ActionRisk;
args: {
title?: string;
startsAt?: string;
inviteeIds?: string[];
};
missingFields: Array<'title' | 'startsAt' | 'inviteeIds'>;
expiresAt: number;
};
type Confirmation = {
proposalId: string;
source: 'voice' | 'ui';
decision: 'approve' | 'revise' | 'reject';
transcript?: string;
confirmedAt: number;
};
type ActionReceipt = {
proposalId: string;
idempotencyKey: string;
status: 'succeeded' | 'failed' | 'unknown';
resourceId?: string;
errorCode?: string;
};
这个模型有几个刻意的限制:
proposalId把用户确认绑定到一个具体方案;expiresAt防止很久以前的一句"可以"被误用于旧操作;missingFields由应用检查,不能让模型用猜测填满;ActionReceipt保留unknown,因为请求超时并不等于执行失败;idempotencyKey用于防止重试造成重复副作用。
其中 requestId 可以贯穿模型路由和链路观测。官方大模型配置文档说明了请求标识相关的配置思路,但应用仍应维护自己的 conversationId、turnId 与 proposalId,因为它们表达的是业务语义,不只是一次模型请求。
一次安全执行应该经过两次校验
第一次校验发生在提案生成后,第二次发生在真正执行前。两次之间,用户可能已经插话、修改方案、切换账号或失去权限。
ts
interface LlmPlanner {
propose(input: {
requestId: string;
transcript: string;
context: unknown;
}): Promise<AgentProposal | { answer: string }>;
}
interface ToolExecutor {
createVoiceRoom(input: {
idempotencyKey: string;
title: string;
startsAt: string;
inviteeIds: string[];
}): Promise<ActionReceipt>;
}
async function prepareProposal(
transcript: string,
planner: LlmPlanner,
context: unknown
) {
const requestId = crypto.randomUUID();
const result = await planner.propose({ requestId, transcript, context });
if ('answer' in result) {
return { kind: 'answer' as const, text: result.answer };
}
if (result.action !== 'create_voice_room') {
throw new Error('UNSUPPORTED_ACTION');
}
if (result.missingFields.length > 0) {
return {
kind: 'clarify' as const,
proposal: result,
fields: result.missingFields
};
}
return { kind: 'confirm' as const, proposal: result };
}
收到确认后,不要直接信任缓存中的参数:
ts
async function commitProposal(
proposal: AgentProposal,
confirmation: Confirmation,
tools: ToolExecutor,
now = Date.now()
): Promise<ActionReceipt> {
if (confirmation.proposalId !== proposal.proposalId) {
throw new Error('CONFIRMATION_MISMATCH');
}
if (confirmation.decision !== 'approve') {
throw new Error('NOT_APPROVED');
}
if (proposal.expiresAt <= now) {
throw new Error('PROPOSAL_EXPIRED');
}
const { title, startsAt, inviteeIds } = proposal.args;
if (!title || !startsAt || !inviteeIds?.length) {
throw new Error('INCOMPLETE_ARGUMENTS');
}
// 这里还应重新检查当前用户身份、权限、成员可见性与时间合法性。
const idempotencyKey = `proposal:${proposal.proposalId}`;
return tools.createVoiceRoom({
idempotencyKey,
title,
startsAt,
inviteeIds
});
}
两次校验的代价是多了一层编排代码,但换来的好处很直接:模型供应商、Prompt 或 Agent 平台发生变化时,业务权限不会跟着漂移。
插话时,取消的是哪一件事?
实时语音场景里,"用户打断 AI"不是一个单一动作。至少要区分三个对象:
- 正在播放的 TTS;
- 尚未完成的模型请求;
- 已经提交的业务操作。
用户插话时,可以停止旧语音播放,也可以取消尚未采用的模型生成;但如果业务操作已经提交,停止播报绝不能被解释为撤销操作。
尤其在等待确认时,任何新语音都不应直接等价为批准。可以按以下规则处理:
| 用户插话 | 当前阶段 | 处理方式 |
|---|---|---|
| "等等,改成九点" | 等待确认 | 废弃旧提案,生成新提案 |
| "不用了" | 等待确认 | 标记拒绝,不执行 |
| "可以" | 等待确认 | 绑定当前 proposalId 后确认 |
| "可以,顺便邀请小王" | 等待确认 | 视为修改,不执行旧提案 |
| 任意语音 | 已提交执行 | 不重复提交,先查询或等待回执 |
| "取消" | 已执行成功 | 进入独立的撤销流程,而不是篡改原回执 |
实现上,可以给会话维护一个递增版本:
ts
type ConversationRuntime = {
revision: number;
activeProposal?: AgentProposal;
committedProposalIds: Set<string>;
};
function reviseConversation(runtime: ConversationRuntime) {
runtime.revision += 1;
runtime.activeProposal = undefined;
}
async function runWithRevision<T>(
runtime: ConversationRuntime,
task: () => Promise<T>
): Promise<T | undefined> {
const captured = runtime.revision;
const result = await task();
if (captured !== runtime.revision) {
return undefined; // 结果已经过期,不再播报或进入确认
}
return result;
}
版本号适合淘汰旧的识别、模型和播报结果,但不要用它删除已经存在的业务事实。业务提交必须由回执和幂等键管理。
延迟设计:优先消除"不知道发生了什么"
实时语音 Agent 不可能只用一个总耗时解释体验。至少要记录这些事件:
ts
type TraceEvent = {
requestId: string;
turnId: string;
proposalId?: string;
name:
| 'speech_started'
| 'transcript_finalized'
| 'llm_requested'
| 'proposal_ready'
| 'confirmation_requested'
| 'confirmation_received'
| 'tool_submitted'
| 'tool_receipt_received'
| 'tts_started';
at: number;
};
这些事件分别回答不同问题:
- 用户说完后,系统多久才形成可用文本?
- 延迟发生在模型生成,还是发生在业务工具?
- 用户是在等待 AI 思考,还是不知道系统正在等待确认?
- 工具已经成功,只是语音播报失败了吗?
不要在没有测量数据时承诺一个普遍适用的延迟数字。更可执行的策略是为每个阶段准备反馈和降级:
- 模型尚未完成:显示或播放非承诺性的处理中状态;
- 确认语音过长:同步展示结构化确认卡片;
- TTS 失败:保留文字方案与确认按钮;
- 工具执行较慢:明确显示"正在提交",禁止重复确认;
- 回执未知:查询既有操作,不立即重放创建请求。
这里的目标不是用更多话术掩盖等待,而是让用户知道系统当前拥有哪种事实。
四个必须写进测试集的失败场景
1. 大模型生成了不存在的工具
模型返回 invite_everyone_nearby,但应用没有这个能力。编排层必须按工具白名单拒绝,不能动态拼接函数名调用。
预期结果:Agent 说明当前无法完成,并提供受支持的替代操作。
2. 用户的"好"指向了另一句话
AI 先问"要把主题改成音乐吗",随后又说"将在明晚 8 点邀请三人"。用户回答"好"时,语义可能不明确。
预期结果:高影响操作应复述完整方案,确认必须绑定唯一提案。存在多个待确认问题时,不接受裸 yes/no。
3. 创建成功,但客户端没有收到响应
工具调用超时后,服务端可能已经创建资源。
预期结果:保存同一个幂等键并查询执行状态;在状态明确前返回 unknown,不要告诉用户"失败了",也不要生成新键重试。
4. 邀请对象来自过期上下文
用户先说"邀请刚才那几个人",但成员列表已经变化,或者当前账号无权查看其中部分成员。
预期结果:执行前重新检查可见性与权限;确认卡片展示最终邀请对象,而不是只展示"他们"。
从 0 到 1 的四次交付
不必一开始就实现高度自主的语音 Agent。可以按风险逐步扩大能力:
第一次:只回答,不行动
跑通实时语音、ASR、LLM、TTS 和基础观测。确认每一层都能独立失败和降级。
第二次:生成操作草稿
模型可以生成结构化房间方案,但用户只能在界面中手动提交。此时重点验证参数质量和隐私边界。
第三次:确认后执行一种可逆动作
引入 proposalId、过期时间、二次权限校验、幂等键和真实回执。只开放一种边界清晰的工具。
第四次:加入语音修改与中断
支持"改时间""去掉某人""取消"等修订,但每次修改都生成新提案。高风险操作仍保留界面确认或人工介入。
每次升级前都问三个问题:
- 当前错误能否被可靠发现?
- 用户能否在执行前理解后果?
- 执行结果不明确时,系统是否知道该查询、停止还是转人工?
任何一个答案是否定的,就不应继续增加自主权。
上线前验证清单
对话与轮次
- 插话后,旧模型结果不会再次播报;
- 修改参数会生成新的
proposalId; - 过期确认不能触发操作;
- 多个待确认问题不会共享一句模糊的"好"。
工具与业务事实
- 大模型只能选择白名单工具;
- 参数经过应用层 Schema 校验;
- 执行前重新检查身份、权限和资源状态;
- 有副作用的请求具备幂等键;
- 超时与失败被区分;
- Agent 只根据真实回执宣告成功。
用户控制与安全
- 麦克风采集、AI 身份和数据用途对用户可见;
- 外部可见操作展示具体影响对象;
- 高风险操作不以普通语音确认代替强认证;
- 用户可以拒绝、修改并退出 AI 对话;
- 内容审核、隐私处理和人工接管有明确责任人。
可观测与恢复
-
requestId、turnId、proposalId和幂等键可以关联; - 能区分 ASR、LLM、TTS 与工具执行故障;
- TTS 失败时仍能展示文字回执;
- 重连后不会自动播放已过期的确认问题;
- 状态未知时优先查询,而不是重复执行。
结语:Agent 的核心不是"替人决定",而是"在授权内行动"
大模型确实降低了意图识别、自然语言生成和工具编排的开发门槛,但一个可上线的实时语音 Agent,价值并不来自它表现得多像一个自主的人。
真正可靠的系统应该让用户始终看得见三件事:AI 理解了什么、准备做什么、最终是否真的做成。
对开发者而言,这也重新定义了需要保留在人手里的能力:不是和模型比赛生成代码或话术,而是定义权限、事务、失败语义与责任边界。Prompt 可以快速迭代,业务事实不能靠语气确定。
如果你的语音 Agent 下一步准备接入工具,可以先在评论区回答一个问题:**它能执行的第一个动作是什么?这个动作失败或重复后,谁会受到影响?**答案通常会直接决定你需要哪一级确认和恢复机制。
关系披露:作者与 Tencent RTC 存在内容合作关系;本文使用 Tencent RTC 官方文档作为实现事实与场景边界的参考。