实时 AI 语伴如何切换大模型而不重做语音链路:统一适配、灰度路由与故障回退实战
近期开发者社区明显偏好“快速接入大模型”的内容,但实时 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 能力 |
| 请求取消 | 需实测取消是否传到服务端 | 可自行实现 | 不能只看客户端是否停止回调 |
| 运维责任 | 主要由服务方承担 | 主要由自己的团队承担 | 双方共同构成故障面 |
| 适合阶段 | 快速验证、弹性需求 | 明确的数据或定制需求 | 深度使用特定平台能力 |
一个实用判断顺序是:
- 先验证交互契约:是否支持流式返回、取消、超时和请求标识;
- 再验证治理要求:数据能否发送到该服务,日志保存到哪里;
- 然后比较回答质量与成本;
- 最后才决定是否值得承担本地部署或专有 SDK 的绑定成本。
模型榜单回答不了这些问题,必须由产品、安全、算法和后端共同决定。
二、稳定版本的链路应该怎样拆
推荐将实时 AI 语伴拆成以下边界:
用户麦克风
↓
RTC / 实时媒体传输
↓
ASR / 语音识别
↓
会话控制器 ──→ 安全与业务规则
↓
LLM Gateway ──→ 云端 API / 本地服务 / 厂商 SDK
↓
TTS / 语音合成
↓
RTC 播放给用户
其中,LLM Gateway 只负责四件事:
- 把应用的统一请求转换成模型请求;
- 把不同返回格式转换成统一文本事件;
- 传播请求标识与取消信号;
- 根据健康状态执行路由和回退。
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>;
}
这个契约刻意不暴露某家模型的 completion、thread 或 agent 对象。专有字段应留在适配器内部,否则更换模型时,业务代码仍会被绑定。
同时要注意两个边界:
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);
}
}
}
VendorClient、generateStream 只是展示适配模式的占位名称,落地时必须替换为所选 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 可记录 primary、candidate 或 fallback;outcome 可由应用定义为 success、timeout、cancelled、protocol_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 或固定配置。
更多推荐
所有评论(0)