用户愿意等待一篇长文生成,却很难容忍语音对话里连续几秒没有反馈;用户也可能认可大模型的复杂推理能力,却无法接受 AI 一口气讲两分钟、自己插不上话。

这正是实时 AI 语伴常见的工程张力:模型回答得更完整,不等于语音交互更自然。 长上下文和多步推理适合处理复杂信息,但语音场景还有另一套约束——何时开始回答、能否被打断、被打断后是否继续、失败时怎样把控制权还给用户。

本文不比较具体模型,而是实现一套可复用的“轮次编排层”。目标是让大模型负责生成内容,让应用负责实时性与用户控制。

Tencent Conversational AI 面向用户与大模型之间的实时语音交互,并支持连接不同的大模型提供方。产品能力与接入边界可参考官方概览:
https://trtc.io/document/conversational-ai-overview?product=conversationalai


一、先确定语音体验的验收标准

不要先问“模型够不够强”,先把一次对话拆成用户可感知的行为。

本文要实现以下流程:

  1. 用户开始说话,系统进入监听状态;
  2. 用户一句话结束后,语音识别结果送入 LLM;
  3. 等待期间展示或播放非语义的“正在思考”提示,但不编造答案;
  4. LLM 输出按适合口语的短段落交给语音合成;
  5. AI 播报期间检测到用户明确开口,立即停止当前播报;
  6. 同时取消仍在进行的 LLM、TTS 请求,防止旧回答继续流入;
  7. 保存已经播完的位置,但不擅自恢复;
  8. 用户决定继续上一段、提出新问题,或结束会话;
  9. 任一环节失败时,界面保留重试、改用文字和退出入口。

这里最重要的边界是:模型可以判断内容,不能单独决定用户是否想继续听。 “嗯”“对”可能只是附和,也可能表示用户准备接话。最终的打断规则应由产品交互、语音活动信号和用户操作共同决定。


二、把实时链路拆开,避免只优化模型

一个可排障的 AI 语伴至少包含六层:

麦克风
  ↓
RTC / 实时媒体传输
  ↓
语音活动检测与语音识别
  ↓
应用轮次编排器
  ↓
大模型
  ↓
语音合成
  ↓
RTC / 本地音频播放

此外还应独立维护:

  • 会话与角色设定;
  • 内容安全和隐私策略;
  • 请求路由与观测信息;
  • 用户可见的停止、继续和退出控制。

如果不拆层,“AI 反应慢”会成为一个无法行动的结论。实际上,慢可能发生在用户尾音判定、语音识别、大模型首段生成、语音合成首段,甚至播放器排队中的任意位置。

Tencent RTC 的大模型配置文档说明了 OpenAI 兼容模型以及 Dify、Coze 等智能体平台的连接方式,并涉及请求标识在路由和观测中的使用:
https://trtc.io/document/68338

接入时应把官方配置与本文的应用编排层分开:官方配置负责连接实际服务,下面的代码负责管理本轮对话生命周期。


三、为“强推理模型”增加口语输出契约

复杂模型常倾向于给出完整背景、分析过程和结论,但语音陪伴不适合照着长文朗读。可以在系统提示中加入一个面向口语的输出契约:

你正在进行实时语音对话。

回答规则:
1. 第一段先直接回应用户,控制在两到三句;
2. 如果内容较复杂,先给结论,再询问是否继续展开;
3. 不朗读 Markdown 标记、链接和表格;
4. 不假装已经执行外部操作;
5. 不确定时明确说明缺少什么信息;
6. 用户要求详细解释后,再分段展开;
7. 每一段都应能独立停止,不依赖下一段才能成立。

这不是让模型“变笨”,而是把文字推理能力转换成适合语音消费的结构。

对于不同任务,可以使用以下决策框架:

用户意图首次语音回答后续动作
闲聊或情绪陪伴简短回应并追问等待用户继续表达
事实型问题先给直接答案询问是否需要来源或解释
多步骤分析先说结论和步骤数量用户确认后逐步展开
高风险建议说明能力边界引导用户核实或联系专业人员
信息不足不猜测关键事实只追问一个最必要的问题

一个可调整的应用配置

以下是应用自己的配置结构,不是 Tencent RTC SDK 的 API:

export const voicePolicy = {
  // 仅作为起始实验值,应根据真实语料调整
  interruption: {
    minSpeechMs: 350,
    ignoreShortBackchannel: true,
    stopPlaybackImmediately: true
  },
  response: {
    maxSpokenCharsPerSegment: 90,
    askBeforeLongExpansion: true,
    resumeAfterInterruption: "user_confirm"
  },
  recovery: {
    allowTextFallback: true,
    maxAutomaticRetries: 1
  }
} as const;

350ms 不是通用标准,只是初始实验值。嘈杂环境、语言习惯和终端拾音能力不同,阈值必须通过录制的测试样本和线上匿名统计校准。


四、实现一个可取消的轮次编排器

轮次编排器需要解决两个容易被忽视的问题:

  • 取消旧请求:用户已经提出新问题,旧模型结果不能继续播放;
  • 隔离过期结果:即使底层请求未能及时取消,迟到的数据也不能污染当前轮次。

下面用 TypeScript 定义应用层接口。接口名称是本文示例抽象,不代表官方 SDK API。

type TurnState =
  | "listening"
  | "thinking"
  | "speaking"
  | "interrupted"
  | "failed";

interface LlmAdapter {
  streamReply(input: {
    text: string;
    requestId: string;
    signal: AbortSignal;
  }): AsyncIterable<string>;
}

interface SpeechOutput {
  play(text: string, signal: AbortSignal): Promise<void>;
  stop(): void;
}

interface TurnTrace {
  conversationId: string;
  turnId: string;
  requestId: string;
  state: TurnState;
  asrFinalAt?: number;
  llmStartAt?: number;
  llmFirstChunkAt?: number;
  ttsStartAt?: number;
  playbackStartAt?: number;
  interruptedAt?: number;
  failureStage?: "asr" | "llm" | "tts" | "playback";
}

核心控制逻辑

class VoiceTurnCoordinator {
  private state: TurnState = "listening";
  private generation = 0;
  private controller?: AbortController;
  private spokenSegments: string[] = [];

  constructor(
    private llm: LlmAdapter,
    private output: SpeechOutput,
    private saveTrace: (trace: TurnTrace) => Promise<void>
  ) {}

  async submitFinalTranscript(params: {
    conversationId: string;
    text: string;
  }) {
    // 终止上一轮,并增加代际编号。
    this.cancelCurrentTurn();
    const currentGeneration = ++this.generation;
    const controller = new AbortController();
    this.controller = controller;
    this.spokenSegments = [];

    const turnId = crypto.randomUUID();
    const requestId = crypto.randomUUID();

    const trace: TurnTrace = {
      conversationId: params.conversationId,
      turnId,
      requestId,
      state: "thinking",
      asrFinalAt: Date.now(),
      llmStartAt: Date.now()
    };

    this.state = "thinking";
    let buffer = "";

    try {
      for await (const chunk of this.llm.streamReply({
        text: params.text,
        requestId,
        signal: controller.signal
      })) {
        // 丢弃已经过期的异步结果。
        if (currentGeneration !== this.generation) return;

        if (!trace.llmFirstChunkAt) {
          trace.llmFirstChunkAt = Date.now();
        }

        buffer += chunk;
        const segments = this.extractSpeakableSegments(buffer);
        buffer = segments.rest;

        for (const segment of segments.ready) {
          if (currentGeneration !== this.generation) return;

          this.state = "speaking";
          trace.state = "speaking";
          trace.ttsStartAt ??= Date.now();

          await this.output.play(segment, controller.signal);
          trace.playbackStartAt ??= Date.now();
          this.spokenSegments.push(segment);
        }
      }

      if (buffer.trim() && currentGeneration === this.generation) {
        this.state = "speaking";
        await this.output.play(buffer.trim(), controller.signal);
        this.spokenSegments.push(buffer.trim());
      }

      this.state = "listening";
      trace.state = "listening";
      await this.saveTrace(trace);
    } catch (error) {
      if (controller.signal.aborted) return;

      this.state = "failed";
      trace.state = "failed";
      trace.failureStage = this.inferFailureStage(trace);
      await this.saveTrace(trace);
      throw error;
    }
  }

  interruptByUser() {
    if (this.state !== "speaking" && this.state !== "thinking") return;

    this.output.stop();
    this.controller?.abort();
    this.generation++;
    this.state = "interrupted";

    // spokenSegments 可用于生成“继续上一段”的候选操作,
    // 但不能自动恢复播放。
  }

  cancelCurrentTurn() {
    this.output.stop();
    this.controller?.abort();
  }

  private extractSpeakableSegments(text: string) {
    const parts = text.split(/(?<=[。!?!?])/);
    const hasCompleteEnding = /[。!?!?]$/.test(text);

    return {
      ready: hasCompleteEnding ? parts.filter(Boolean) : parts.slice(0, -1),
      rest: hasCompleteEnding ? "" : parts.at(-1) ?? ""
    };
  }

  private inferFailureStage(trace: TurnTrace): TurnTrace["failureStage"] {
    if (!trace.llmFirstChunkAt) return "llm";
    if (!trace.playbackStartAt) return "tts";
    return "playback";
  }
}

这段代码有三个关键点:

  1. AbortController 尝试取消当前生成和播放;
  2. generation 形成一道代际栅栏,阻止迟到结果进入新一轮;
  3. 已播报内容单独记录,为用户主动恢复提供依据。

生产环境还要处理逗号分段、数字、缩写和中英文混合文本。不能简单地把每个模型 token 都送去合成,否则会产生过碎的语音和不稳定的播放队列。


五、打断不是一个事件,而是三种意图

AI 正在说话时检测到用户声音,不应全部按同一种方式处理。

1. 明确打断

例如“停一下”“不是这个意思”“我换个问题”。处理顺序应是:

停止本地播报
→ 取消 TTS 与 LLM
→ 清空尚未播放的旧音频
→ 保存断点
→ 回到监听状态

停止本地声音要优先,不能等模型服务确认取消后才停。

2. 短促附和

例如“嗯”“对”“是”。它可能只是 backchannel,不一定代表用户要夺回话轮。可采用组合判断:

  • 持续时间是否超过实验阈值;
  • 识别文本是否属于明确停止词;
  • 用户是否按下停止按钮;
  • AI 当前是否正在关键句中;
  • 短促声音后是否继续形成完整语句。

不要让 LLM单独判断是否打断,因为模型拿到转写结果时,用户可能已经等待了一段时间。

3. 环境噪声与回声

扬声器声音被麦克风重新采集,可能导致系统“自己打断自己”。排查时要区分:

  • 用户佩戴耳机时是否消失;
  • 静音房间与公共环境是否不同;
  • 被识别的内容是否与 AI 正在播报的文本高度相似;
  • 问题是否只发生在部分设备或播放音量下。

应用层可以拒绝明显过短的事件,但不应假设一个阈值能解决全部回声问题。设备音频处理、拾音方式和交互按钮仍需共同参与。


六、用阶段时间线定位“冷场”

只记录总耗时无法判断该优化哪里。建议每轮至少记录以下时间点:

{
  "conversation_id": "c_123",
  "turn_id": "t_456",
  "request_id": "r_789",
  "user_speech_end_at": 1760000000000,
  "asr_final_at": 1760000000200,
  "llm_request_at": 1760000000230,
  "llm_first_chunk_at": 1760000001200,
  "tts_request_at": 1760000001230,
  "playback_start_at": 1760000001700,
  "result": "completed"
}

由此计算四段应用指标:

尾音判定与识别耗时 = asr_final_at - user_speech_end_at
模型首段等待       = llm_first_chunk_at - llm_request_at
首段合成等待       = playback_start_at - tts_request_at
用户感知等待       = playback_start_at - user_speech_end_at

这些是测量方法,不是产品承诺值。上线前应先建立自身设备、网络与语言组合下的基线,再设置告警范围。

对应的恢复策略

卡住阶段用户可见反馈自动动作人工控制
语音识别迟迟不结束显示“仍在聆听”允许重新收音提供“说完了”按钮
LLM 尚未返回首段显示思考状态最多有限重试可取消或改用文字
TTS 失败展示文字答案尝试文字降级用户决定是否重播
播放被打断标记已暂停保存已播位置继续、换问题或结束
整体链路失效明确说明暂不可用停止循环重试保留退出和反馈入口

不要用无限重试掩盖失败。实时对话里,重复等待往往比一次清晰失败更令人困惑。


七、效果验证:专门测试不顺利的对话

轮次测试

  • 用户说完后只生成一轮回答;
  • 新问题提交后,旧结果即使迟到也不会播放;
  • AI 播报时按下停止,声音立即停止;
  • 用户插话后,未播放的旧音频不会重新出现;
  • “继续刚才的内容”必须由用户明确触发;
  • 连续快速提问不会造成多个 TTS 队列并行播放。

长回答测试

准备三类问题:简单事实、多步骤分析、长上下文总结。检查:

  • 简单问题没有被扩写成长演讲;
  • 复杂问题先给结论,再询问是否展开;
  • 每一段被中止后,已经表达的内容仍然成立;
  • 模型输出 Markdown 时,语音层不会朗读星号和表格符号;
  • 用户要求详细解释后,系统可以继续分段回答。

故障注入

  • 人为延迟 LLM 首段,确认等待提示和取消按钮可用;
  • 让 TTS 返回错误,确认文字答案仍可阅读;
  • 在播放中断网,确认不会无限重连并重复播报;
  • 让旧请求延迟返回,确认代际编号能拦截结果;
  • 模拟麦克风噪声,确认短促声音不会频繁误打断;
  • 检查日志中不包含不必要的原始录音、密钥和完整敏感对话。

八、常见坑:模型升级后反而更难用

坑 1:把更长的回答当成更好的回答

文字场景可以滚动阅读,语音场景却会持续占用话轮。解决方法不是限制模型能力,而是采用“结论—确认—展开”的层级回答。

坑 2:只停止播放器,不取消生成

表面上声音停了,后台 LLM 和 TTS 仍在运行;稍后音频可能重新进入队列。应同时停止播放、取消请求,并用代际编号拒绝迟到结果。

坑 3:为了显得自然,让 AI 自动续讲

被打断后自动恢复,等于系统再次替用户占用话轮。更稳妥的默认值是暂停,并提供“继续刚才的内容”。

坑 4:用一句“正在思考”掩盖所有故障

提示只能缓解不确定感,不能替代超时、取消和降级。ASR、LLM、TTS 与播放阶段必须分别观测。

坑 5:日志里只有完整录音

完整录音既不利于快速定位阶段问题,也会增加隐私风险。优先记录时间点、状态、错误类别和请求标识;确需保留音频时,应取得同意并设置访问与留存边界。

坑 6:把角色陪伴误做成无边界拟人化

AI 虚拟陪伴和角色对话属于社交娱乐中的实际应用方向,相关场景可参考 Tencent RTC 社交娱乐解决方案:
https://trtc.io/solutions/social-entertainment

但产品仍应清楚表明 AI 身份,提供退出、静音、删除记录和内容反馈入口,并对敏感内容设置审核与人工处置规则。


九、可复用总结:能力归模型,节奏归应用,决定权归用户

搭建实时 AI 语伴时,可以复用下面这组原则:

  1. RTC 负责实时媒体链路,大模型负责内容生成,两者不要混成一个黑盒。
  2. 把等待拆成 ASR、LLM、TTS 和播放四段分别测量。
  3. 用可取消请求与代际编号共同处理旧结果。
  4. 长推理采用短首答、分段展开,而不是一次性朗读全文。
  5. 用户开口时优先停止声音,再处理后台取消。
  6. 被打断后默认暂停,恢复必须经过用户确认。
  7. 模型失败时保留文字降级、重试和退出,不进行无限自动重试。
  8. 高风险决定、隐私授权和是否继续对话始终由人控制。

大模型的长上下文和逻辑能力确实可以改善复杂问答,但它们不能自动解决实时语音中的轮次礼仪。真正可用的语音陪伴,需要工程团队把“谁现在可以说话、失败后怎么办、用户如何收回控制权”写进系统,而不是留给模型临场猜测。

关系披露:本文作者以 Tencent RTC 社区内容创作者身份撰写本文,并使用 Tencent RTC 官方文档作为实现边界与产品能力的参考;示例中的轮次编排接口和代码为应用层设计,不代表官方 SDK API。

更多推荐