近期开发者社区明显偏好“快速接入大模型”的内容,但实时 AI 语伴真正让人纠结的,往往不是模型能不能回答,而是:今天用云端 API 做出的 Demo,明天为了隐私改成本地部署,后天又要接某个厂商 SDK,语音链路是否要全部重写?

这种焦虑背后其实是一个架构问题。API、本地部署、SDK 并不是三个完全对等的选择:

  • API 与本地部署主要决定模型在哪里运行、由谁维护;
  • SDK主要决定应用如何调用某个服务,可能仍然访问云端;
  • 实时语音体验还取决于流式输出、取消请求、超时恢复和协议兼容,不能只看模型回答质量。

本文以实时 AI 语伴为例,设计一层独立的 LLM 接入网关。目标不是追求“一句话接入”的演示效果,而是让模型可以替换、灰度和回退,并且不把 RTC、语音识别与语音合成一起拖入改造。

Tencent Conversational AI 支持实时语音交互以及对接多个 LLM 提供方。官方概览:
https://trtc.io/document/conversational-ai-overview?product=conversationalai


一、先把三种接入方式放到同一张决策表里

不要先问“哪个最先进”,而要先确定团队愿意承担什么责任。

维度托管模型 API自建或本地模型服务厂商 SDK / Agent SDK
上线成本通常较低需要部署、扩缩容和运维取决于 SDK 封装程度
数据边界需要审查服务条款与数据路径可由团队控制运行环境需要同时审查 SDK 与后端服务
协议可替换性OpenAI 兼容协议通常较容易适配建议主动暴露兼容协议容易绑定专有对象和回调
流式输出需验证,不应默认具备取决于推理服务实现取决于 SDK 能力
请求取消需实测取消是否传到服务端可自行实现不能只看客户端是否停止回调
运维责任主要由服务方承担主要由自己的团队承担双方共同构成故障面
适合阶段快速验证、弹性需求明确的数据或定制需求深度使用特定平台能力

一个实用判断顺序是:

  1. 先验证交互契约:是否支持流式返回、取消、超时和请求标识;
  2. 再验证治理要求:数据能否发送到该服务,日志保存到哪里;
  3. 然后比较回答质量与成本
  4. 最后才决定是否值得承担本地部署或专有 SDK 的绑定成本。

模型榜单回答不了这些问题,必须由产品、安全、算法和后端共同决定。


二、稳定版本的链路应该怎样拆

推荐将实时 AI 语伴拆成以下边界:

用户麦克风
   ↓
RTC / 实时媒体传输
   ↓
ASR / 语音识别
   ↓
会话控制器 ──→ 安全与业务规则
   ↓
LLM Gateway ──→ 云端 API / 本地服务 / 厂商 SDK
   ↓
TTS / 语音合成
   ↓
RTC 播放给用户

其中,LLM Gateway 只负责四件事:

  1. 把应用的统一请求转换成模型请求;
  2. 把不同返回格式转换成统一文本事件;
  3. 传播请求标识与取消信号;
  4. 根据健康状态执行路由和回退。

RTC、ASR、TTS 不应该知道当前使用的是本地模型还是云端 API。这样切换模型时,语音链路不需要跟着重写。

Tencent Conversational AI 的大模型配置文档说明了 OpenAI 兼容模型以及 Dify、Coze 等 Agent 平台的连接方式,并涉及用于路由和观测的请求标识。实际配置项应以官方文档为准:
https://trtc.io/document/68338


三、定义一个不依赖厂商的 LLM 契约

以下 TypeScript 是应用侧适配层示例,不是 Tencent RTC 官方 API。

export type Role = "system" | "user" | "assistant";

export interface ChatMessage {
  role: Role;
  content: string;
}

export interface LLMRequest {
  requestId: string;
  conversationId: string;
  messages: ChatMessage[];
  signal: AbortSignal;
  metadata: {
    scene: "voice_companion";
    locale: string;
    userConsented: boolean;
  };
}

export type LLMEvent =
  | { type: "text_delta"; text: string }
  | { type: "completed"; finishReason: string }
  | { type: "usage"; input?: number; output?: number };

export interface LLMAdapter {
  readonly name: string;
  healthCheck(): Promise<boolean>;
  stream(request: LLMRequest): AsyncIterable<LLMEvent>;
}

这个契约刻意不暴露某家模型的 completionthreadagent 对象。专有字段应留在适配器内部,否则更换模型时,业务代码仍会被绑定。

同时要注意两个边界:

  • AbortSignal 表示应用要求停止当前生成,但不能想当然地认为所有服务端都已停止计算;需要通过服务端日志或供应方文档验证。
  • requestId 用于单次请求观测,conversationId 用于会话关联,两者不要混用。

四、用同一个适配器覆盖云 API 与本地兼容服务

如果云端 API 和本地推理服务都暴露 OpenAI 兼容接口,可以共用一个 HTTP 适配器,只替换地址、模型名和凭证。

interface CompatibleEndpoint {
  name: string;
  baseUrl: string;
  apiKey?: string;
  model: string;
}

class CompatibleLLMAdapter implements LLMAdapter {
  constructor(private readonly config: CompatibleEndpoint) {}

  get name() {
    return this.config.name;
  }

  async healthCheck(): Promise<boolean> {
    // 健康检查路径应按所接服务的真实协议实现,不能假定所有服务一致。
    return Boolean(this.config.baseUrl);
  }

  async *stream(req: LLMRequest): AsyncIterable<LLMEvent> {
    const response = await fetch(
      `${this.config.baseUrl}/chat/completions`,
      {
        method: "POST",
        signal: req.signal,
        headers: {
          "Content-Type": "application/json",
          ...(this.config.apiKey
            ? { Authorization: `Bearer ${this.config.apiKey}` }
            : {}),
          "X-Request-Id": req.requestId
        },
        body: JSON.stringify({
          model: this.config.model,
          stream: true,
          messages: req.messages
        })
      }
    );

    if (!response.ok || !response.body) {
      throw new Error(`LLM_HTTP_${response.status}`);
    }

    // 生产环境应使用经过测试的 SSE/流式协议解析器。
    // 不要直接假定一个网络分片就是一个完整 JSON 事件。
    for await (const text of parseCompatibleStream(response.body)) {
      if (text) yield { type: "text_delta", text };
    }

    yield { type: "completed", finishReason: "stop" };
  }
}

应用侧配置可以写成:

llmRoutes:
  primary:
    type: openai-compatible
    baseUrl: ${PRIMARY_LLM_BASE_URL}
    apiKey: ${PRIMARY_LLM_API_KEY}
    model: ${PRIMARY_LLM_MODEL}

  localFallback:
    type: openai-compatible
    baseUrl: ${LOCAL_LLM_BASE_URL}
    model: ${LOCAL_LLM_MODEL}

这里的路径和 YAML 字段均为自建网关示例,不代表官方配置格式。接入 Tencent Conversational AI 时,应按照官方大模型配置文档填写实际参数。

SDK 怎么接入

遇到只能通过 SDK 使用的模型,不要让 SDK 对象进入会话控制器,而是额外实现 LLMAdapter

class VendorSDKAdapter implements LLMAdapter {
  readonly name = "vendor-sdk";

  constructor(private readonly client: VendorClient) {}

  async healthCheck(): Promise<boolean> {
    return this.client !== undefined;
  }

  async *stream(req: LLMRequest): AsyncIterable<LLMEvent> {
    const result = this.client.generateStream({
      messages: req.messages,
      requestId: req.requestId
    });

    const onAbort = () => result.cancel?.();
    req.signal.addEventListener("abort", onAbort, { once: true });

    try {
      for await (const chunk of result) {
        const text = normalizeVendorChunk(chunk);
        if (text) yield { type: "text_delta", text };
      }
      yield { type: "completed", finishReason: "stop" };
    } finally {
      req.signal.removeEventListener("abort", onAbort);
    }
  }
}

VendorClientgenerateStream 只是展示适配模式的占位名称,落地时必须替换为所选 SDK 的真实接口。


五、切换模型不能只改一个环境变量

直接把主模型地址从 A 改成 B,风险在于你同时改变了输出节奏、错误格式、取消行为和内容风格。更稳妥的上线流程分为四步。

第 1 步:离线契约测试

为所有适配器执行同一组测试:

const contractCases = [
  "普通短问答能否返回文本事件",
  "空输入是否被应用层拒绝",
  "请求取消后是否停止继续向 TTS 投递",
  "服务端返回非 2xx 时能否产生标准错误",
  "流式数据被拆包时能否正确重组",
  "同一 requestId 能否贯穿日志"
];

测试重点不是答案是否一字不差,而是适配器行为是否一致。

第 2 步:影子请求,但不播放影子答案

对已获得用户同意且符合数据规则的流量,可以把同一份脱敏输入发送给候选模型做比较,但只能将主模型结果交给 TTS。

影子链路需要遵守三个限制:

  • 不复制用户未同意发送的数据;
  • 不把影子模型的输出写入正式会话记忆;
  • 不执行影子模型产生的工具调用或业务动作。

如果无法满足这些条件,应改用离线、合成或人工整理的测试集。

第 3 步:小范围灰度

路由键应稳定,避免同一用户每轮对话随机切换模型:

function chooseRoute(userId: string, rolloutPercent: number) {
  const bucket = stableHash(userId) % 100;
  return bucket < rolloutPercent ? "candidate" : "primary";
}

灰度期间至少比较:

  • 首个可播报文本到达时间;
  • 完整回答结束时间;
  • 空返回、协议错误和超时数量;
  • 用户主动停止播报的比例;
  • 内容安全拦截与人工反馈结果。

这里不要套用统一的“行业标准值”。应先记录现有版本基线,再结合产品允许的等待时间设置阈值。

第 4 步:保留可见的人工控制

即使模型自动回退成功,用户仍应能:

  • 停止当前播报;
  • 退出 AI 对话;
  • 清除或管理会话数据;
  • 对明显不当内容进行反馈;
  • 在涉及交易、健康、安全等高风险决定时转向人工或明确的非 AI 流程。

AI 可以生成陪伴式回答,但不应替用户决定是否同意数据使用,也不应通过角色设定掩盖其 AI 身份。


六、实现“只在尚未开口时回退”的路由器

实时语音中最危险的回退方式,是主模型已经说了一半,备用模型又从头回答。用户听到的会是两个互相冲突的答案。

因此需要区分“尚未输出”与“已经输出”:

class LLMRouter {
  constructor(
    private readonly primary: LLMAdapter,
    private readonly fallback: LLMAdapter
  ) {}

  async *stream(req: LLMRequest): AsyncIterable<LLMEvent> {
    let emittedText = false;

    try {
      for await (const event of this.primary.stream(req)) {
        if (event.type === "text_delta" && event.text) {
          emittedText = true;
        }
        yield event;
      }
    } catch (error) {
      if (req.signal.aborted) throw error;

      if (emittedText) {
        // 已经向用户播报内容,不让备用模型从头续写。
        throw new Error("PRIMARY_FAILED_AFTER_OUTPUT");
      }

      for await (const event of this.fallback.stream(req)) {
        yield event;
      }
    }
  }
}

产品层可以按故障时机处理:

故障时机建议处理
主模型尚未产生文本尝试备用模型,并记录回退原因
已产生文本但尚未送入 TTS丢弃未播放内容后再回退
已开始语音播放停止本轮,提示用户重试,不自动拼接另一模型答案
两个模型都不可用结束生成,提供明确的重试或退出入口

这比“无限自动重试”更克制。对于陪伴场景,可靠性不是假装永远在线,而是在失败时不制造更混乱的对话。


七、建立最小观测模型

至少为每次模型调用记录以下结构化事件:

CREATE TABLE llm_attempts (
  id              BIGSERIAL PRIMARY KEY,
  request_id      VARCHAR(128) NOT NULL,
  conversation_id VARCHAR(128) NOT NULL,
  adapter_name    VARCHAR(64) NOT NULL,
  route_role      VARCHAR(16) NOT NULL,
  started_at      TIMESTAMPTZ NOT NULL,
  first_text_at   TIMESTAMPTZ,
  completed_at    TIMESTAMPTZ,
  outcome         VARCHAR(32) NOT NULL,
  error_code      VARCHAR(64),
  emitted_text    BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE INDEX idx_llm_attempt_request
ON llm_attempts(request_id);

route_role 可记录 primarycandidatefallbackoutcome 可由应用定义为 successtimeoutcancelledprotocol_error 等有限枚举。

默认不要把完整用户语音、识别文本和模型回答塞进诊断表。排障字段与内容日志应分开设计,并根据用户同意、业务需要和保存策略处理。


八、效果验证:不要只录一段成功视频

上线前建议按以下清单逐项验收。

接入契约

  • 云端 API、本地服务和 SDK 都能映射到统一事件格式;
  • 流式解析可处理拆包、粘包和不完整事件;
  • 所有模型调用都携带可关联的请求标识;
  • 密钥只保存在服务端,不进入客户端安装包和日志;
  • 本地服务不可达时不会阻塞整个会话进程。

实时交互

  • 用户停止播报后,不再把后续文本送入 TTS;
  • 主模型首段输出前失败,可以回退;
  • 已经开始播报后失败,不会拼接备用模型答案;
  • 用户连续说话时,旧请求不会覆盖新一轮界面状态;
  • 模型输出过长时,应用可以安全结束本轮,而不是等待 SDK 自行结束。

灰度与恢复

  • 同一用户稳定进入同一灰度分组;
  • 候选模型可以单独下线,不影响主模型;
  • 熔断后有明确的恢复条件,而不是永久停用;
  • 回退原因能通过 requestId 查询;
  • 关闭影子流量后,不再产生额外模型请求。

安全与用户控制

  • 进入 AI 语伴前清楚说明 AI 身份及数据处理边界;
  • 用户可以停止、退出和管理会话数据;
  • 影子请求受用户同意和数据政策约束;
  • 高风险问题不会仅依赖模型自动决定;
  • 内容审核失败时有可理解的降级提示。

九、常见坑:模型换成功了,产品却更不稳定

1. 把 SDK 当成本地部署

安装在服务器里的 SDK 可能仍然调用厂商云端。判断数据边界时,应检查实际网络路径与服务条款,而不是看依赖包安装在哪里。

2. 只验证最终文本,不验证流式行为

两个模型最终答案都正确,不代表实时体验相同。一个模型可能很晚才返回整段内容,另一个可能持续输出短片段;这会直接影响 TTS 的启动与停顿。

3. 备用模型使用不同的人设和安全规则

故障回退后语气突然改变,通常不是 RTC 问题,而是备用模型没有使用同一套系统约束。公共业务规则应由会话控制器装配,模型适配器只负责协议转换。

4. 失败后自动重放用户整段语音

回退应复用已经确认的文本输入,而不是默认重新上传音频。否则可能造成重复识别、额外数据传输和不一致结果。

5. 把“客户端停止显示”当成真正取消

UI 不再显示,不等于模型服务已经终止生成。至少要分别记录:应用发出取消、适配器收到取消、上游连接结束。无法验证服务端取消时,要在容量与成本评估中保留这个不确定性。

6. 只留一个全局模型开关

全局开关适合紧急停用,但不适合灰度。稳定路由至少应支持主模型、候选模型和备用模型,并保留快速回滚能力。


十、可复用总结

把实时 AI 语伴从 Demo 推向可靠实现,可以复用下面这条路线:

统一 LLM 契约
  → 为 API、本地服务、SDK 编写独立适配器
  → 执行流式、取消、错误和请求标识契约测试
  → 影子验证,不播放也不执行候选结果
  → 按稳定路由键灰度
  → 只在尚未播报时自动回退
  → 用阶段事件观测故障
  → 始终保留停止、退出与人工决策入口

大模型真正改善的是自然语言理解与生成,并不能自动解决实时媒体传输、协议差异、取消传播、数据治理和故障责任。把这些边界拆清后,团队就不必在“云 API、本地部署、厂商 SDK”之间做一次性押注,而可以让选择保持可逆。

社交娱乐中的 AI 虚拟陪伴、角色对话等场景可参考 Tencent RTC 的方案页面:
https://trtc.io/solutions/social-entertainment


**关系披露:**作者与 Tencent RTC 存在内容合作关系;本文以 Tencent RTC 官方文档作为实现事实参考,示例中的应用侧适配器、数据表和路由策略为通用工程设计,不代表官方 API 或固定配置。

更多推荐