从 0 到 1 做实时语音 Agent:先解决“会不会误操作”,再谈自主行动

很多实时语音 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,不宜按功能数量衡量。更有效的最小闭环是:

  1. 用户提出一个范围明确的目标;
  2. 模型只能生成结构化操作建议;
  3. 应用验证参数和权限;
  4. Agent 向用户复述即将产生的影响;
  5. 用户确认、修改或取消;
  6. 工具执行器提交操作;
  7. Agent 根据真实回执反馈结果。

以"创建语音房"为例,第一版可以只支持:

  • 房间主题;
  • 开始时间;
  • 一组明确选择的邀请对象;
  • 创建前确认;
  • 成功、失败和结果未知三种回执。

先不要加入"根据关系自动邀请朋友""自行判断最佳时间""连续协调多个人"等自主能力。它们增加的不是几个参数,而是权限、隐私和错误责任。

Tencent RTC 的社交娱乐解决方案覆盖语音聊天室、1 对 1 社交、社区和 AI 虚拟伙伴等场景,因此这种"对话伙伴 + 社交动作"的组合是合理的产品方向;但具体业务动作、授权规则和审核责任仍应由应用实现。

用副作用等级决定控制权,而不是看模型有多聪明

同一个大模型,可以同时回答闲聊问题和创建社交活动,但两类任务不应拥有同样的执行权限。

操作类型 示例 建议控制方式 失败影响
无副作用 解释房间规则、推荐聊天话题 可直接回答 内容不准确
只读查询 查询自己的日程或房间状态 明确数据范围后执行 隐私泄露、结果过期
可逆操作 创建尚未发布的房间草稿 语音确认或界面确认 产生多余草稿
外部可见操作 发布房间、发送邀请 绑定具体方案确认 打扰他人、暴露关系
高风险操作 付费、封禁、公开敏感内容 强认证与界面确认,必要时人工介入 资金、账号或安全影响

这里的判断标准不是"模型准确率看起来够不够高",而是三个问题:

  1. 错误发生后能否完整撤销?
  2. 操作会不会影响其他人?
  3. 用户是否能在执行前看懂具体后果?

只要答案涉及不可逆、外部影响或身份风险,就不应让自然语言中的模糊肯定词直接触发执行。

把模型输出降级为"提案"

模型不应该直接调用业务服务。它只负责产生一个候选提案,应用层再决定接受、补充还是拒绝。

可以先定义以下数据结构:

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 可以贯穿模型路由和链路观测。官方大模型配置文档说明了请求标识相关的配置思路,但应用仍应维护自己的 conversationIdturnIdproposalId,因为它们表达的是业务语义,不只是一次模型请求。

一次安全执行应该经过两次校验

第一次校验发生在提案生成后,第二次发生在真正执行前。两次之间,用户可能已经插话、修改方案、切换账号或失去权限。

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"不是一个单一动作。至少要区分三个对象:

  1. 正在播放的 TTS;
  2. 尚未完成的模型请求;
  3. 已经提交的业务操作。

用户插话时,可以停止旧语音播放,也可以取消尚未采用的模型生成;但如果业务操作已经提交,停止播报绝不能被解释为撤销操作。

尤其在等待确认时,任何新语音都不应直接等价为批准。可以按以下规则处理:

用户插话 当前阶段 处理方式
"等等,改成九点" 等待确认 废弃旧提案,生成新提案
"不用了" 等待确认 标记拒绝,不执行
"可以" 等待确认 绑定当前 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 对话;
  • 内容审核、隐私处理和人工接管有明确责任人。

可观测与恢复

  • requestIdturnIdproposalId 和幂等键可以关联;
  • 能区分 ASR、LLM、TTS 与工具执行故障;
  • TTS 失败时仍能展示文字回执;
  • 重连后不会自动播放已过期的确认问题;
  • 状态未知时优先查询,而不是重复执行。

结语:Agent 的核心不是"替人决定",而是"在授权内行动"

大模型确实降低了意图识别、自然语言生成和工具编排的开发门槛,但一个可上线的实时语音 Agent,价值并不来自它表现得多像一个自主的人。

真正可靠的系统应该让用户始终看得见三件事:AI 理解了什么、准备做什么、最终是否真的做成。

对开发者而言,这也重新定义了需要保留在人手里的能力:不是和模型比赛生成代码或话术,而是定义权限、事务、失败语义与责任边界。Prompt 可以快速迭代,业务事实不能靠语气确定。

如果你的语音 Agent 下一步准备接入工具,可以先在评论区回答一个问题:**它能执行的第一个动作是什么?这个动作失败或重复后,谁会受到影响?**答案通常会直接决定你需要哪一级确认和恢复机制。

关系披露:作者与 Tencent RTC 存在内容合作关系;本文使用 Tencent RTC 官方文档作为实现事实与场景边界的参考。

相关推荐
lucas_AI1 小时前
Muse Glimmer 30B:Meta 难得给的真开源,强在哪、虚在哪
人工智能·算法
字节跳动数据库1 小时前
火山引擎 RDS MySQL 向量索引:把高性能向量检索带到 MySQL 上
人工智能·后端·mysql
一心只读圣贤书1 小时前
AI 驱动前端测试实战:从需求文档到 Playwright 自动化用例
前端·人工智能
小白的后端世界1 小时前
LangChain 模型初始化参数详解:从基础配置到企业级实践
java·人工智能·langchain
jimidou1 小时前
第 0 篇:Agent 世界观——先搞懂 LLM、Context、Tool 与 Agent 到底是什么
人工智能
过期的秋刀鱼!1 小时前
带替换的采样
人工智能·python·算法·决策树·机器学习
Python私教1 小时前
AI Agent 可观测性不只是日志:一套可回放的多步执行链
人工智能·后端·python
极新1 小时前
中国机器人、AI、创新药出海:这次出海的重头戏
人工智能·机器人
Python私教1 小时前
模型越强越不需要 Skills?我把 AI 编程能力拆成 4 层
人工智能·后端·python