做一个会说话的小游戏角色,Demo 往往来得很快:让 Gemini 写一份角色 Prompt,接上语音识别、模型和语音合成,NPC 就能回答玩家。
真正准备上线时,问题却不再是"它能不能说话",而是:
- 玩家说到一半,NPC 为什么已经开始回答?
- 玩家插话后,旧回答为什么还在继续播放?
- 网络或模型变慢时,角色应该沉默、显示文字,还是退出对话?
- 模型说"奖励已经到账",游戏服务端真的应该照做吗?
- Prompt 改了一句话,怎样确认新版本没有破坏新手引导?
这也是 AI 开发最容易造成角色焦虑的地方:代码和文案生成得更快了,但产品判断并没有消失,只是集中到了边界、验收和责任上。开发者仍然需要决定什么话可以说、什么动作不能做,以及什么时候必须把控制权还给玩家。
本文选择一个具体目标:为小游戏的新手引导制作一个可打断、可降级、可回放验证的实时语音 NPC。重点不是追求"无所不知",而是让它在一个有限场景里稳定工作。
先划线:哪些 NPC 适合第一版接入语音 AI
不要先问模型够不够强,先问这个场景能否安全降级。
| 场景 | 是否适合首版 | 原因与限制 |
|---|---|---|
| 解释基础玩法 | 适合 | 答错时可以回到固定帮助页 |
| 根据当前关卡给提示 | 有条件适合 | 游戏状态必须由服务端或可信客户端结构化提供 |
| 角色闲聊与世界观对话 | 适合 | 需限制敏感内容、人格边界和对未成年人的表达 |
| 发放道具、修改积分 | 不适合直接执行 | 模型输出不能成为资产变更指令 |
| 购买、退款、账号申诉 | 不适合自动处理 | 必须进入确定性业务流程或人工渠道 |
| 判断玩家真实年龄、情绪或健康状况 | 不应依赖模型推断 | 语音表现不能替代可靠事实与专业判断 |
一个实用判断是:如果 AI 失败后,玩家仍能通过按钮、文字说明或确定性流程继续游戏,这个场景才适合进入首版。
运行时不是"一个 Prompt",而是五个相互隔离的系统
实时语音 NPC 至少包含以下责任层:
text
玩家麦克风
↓
实时音频与会话连接
↓
语音识别 / 轮次判断
↓
应用编排层 ──→ 游戏状态快照
↓
LLM + 版本化 Prompt
↓
语音合成
↓
字幕与音频播放
Tencent Conversational AI 的官方概览将其定位于用户与多种大模型之间的实时语音交互,并提供跨平台集成方向。实现时仍应把媒体传输、ASR、LLM、TTS、应用状态与审核策略分别看待,而不是把所有问题都塞进 Prompt:
对小游戏而言,RTC 链路负责"声音怎样实时进出",应用编排层负责"这一轮是否应该发送给模型",游戏服务负责"事实与资产是否真的变化"。三者不能互相越权。
先写交互契约,再让 Gemini 生成角色 Prompt
Gemini 很适合帮助团队扩写人物背景、整理语气示例、寻找冲突规则,但它生成的是候选配置,不是可直接发布的产品规则。
建议先由人写一张场景卡:
ts
interface VoiceNpcScenario {
id: string;
objective: string;
allowedTopics: string[];
forbiddenClaims: string[];
interruptionPolicy: "immediate" | "speech-confirmed" | "disabled";
fallback: {
llmFailure: "fixed-copy" | "text-help";
ttsFailure: "subtitle-only";
connectionFailure: "exit-to-local-ui";
};
}
const tutorialGuide: VoiceNpcScenario = {
id: "tutorial-guide-v1",
objective: "解释移动、互动和退出引导的方法",
allowedTopics: ["基础操作", "当前教程目标", "世界观寒暄"],
forbiddenClaims: [
"承诺奖励已经发放",
"修改账号或资产",
"声称知道未提供的玩家信息"
],
interruptionPolicy: "speech-confirmed",
fallback: {
llmFailure: "text-help",
ttsFailure: "subtitle-only",
connectionFailure: "exit-to-local-ui"
}
};
然后再让 Gemini 根据场景卡生成 Prompt 草稿。输入应要求它暴露冲突,而不只是写得更有"人设感":
text
你是 Prompt 审阅助手。请根据给定的 VoiceNpcScenario 生成:
1. 角色职责;
2. 可使用的游戏事实;
3. 不得声称完成的动作;
4. 不确定时的追问句式;
5. 被玩家打断后的短句响应;
6. 规则冲突列表。
不要增加场景卡中不存在的权限,不要假设模型能修改游戏状态。
人工审阅至少要回答三个问题:
- Prompt 是否把"解释操作"偷偷扩大成了"替玩家执行操作"?
- 当游戏状态缺失时,角色会追问,还是编造一个看似合理的答案?
- 角色语气与安全规则冲突时,哪一条优先?
AI 可以生成角色表达,人必须定义角色权限。
把 Prompt 当成可部署制品,而不是一段散落的字符串
Prompt、模型路由和场景契约应该一起版本化。否则线上出现错误时,很难回答"当时究竟用了哪套规则"。
ts
type PromptDeployment = {
deploymentId: string;
scenarioId: string;
promptVersion: string;
modelRoute: string;
createdAt: string;
approvedBy: string;
status: "draft" | "staged" | "active" | "retired";
checksum: string;
};
type ConversationTurn = {
sessionId: string;
turnId: string;
requestId: string;
deploymentId: string;
gameStateVersion: string;
userTranscript: string;
assistantText?: string;
result:
| "completed"
| "interrupted"
| "asr_uncertain"
| "llm_failed"
| "tts_failed"
| "connection_lost";
};
Tencent RTC 的大模型配置文档说明了 OpenAI-compatible 模型以及 Dify、Coze 等 Agent 平台的连接方式,并涉及使用请求标识进行路由和可观测性关联:
如果 Gemini 仅用于离线生成和审阅 Prompt,就不要把它与线上运行模型混为一谈;如果计划把某个模型作为运行时提供方,应按照官方文档核对兼容方式,并在自己的测试环境验证。本文不会假定某个未在资料中明确说明的接口或版本天然可用。
一轮对话的控制权应该握在编排层
下面是一段不绑定具体 SDK API 名称的 TypeScript 伪实现。它强调的是责任边界:LLM 只能生成候选文本,不能直接更新游戏状态。
ts
interface RuntimeDeps {
transcribe(audioRef: string): Promise<{
text: string;
confidence?: number;
}>;
generate(input: {
requestId: string;
promptVersion: string;
transcript: string;
gameFacts: Record<string, unknown>;
}): Promise<{ text: string }>;
synthesize(text: string): Promise<{ audioRef: string }>;
showSubtitle(text: string): Promise<void>;
play(audioRef: string, signal: AbortSignal): Promise<void>;
}
async function runNpcTurn(
deps: RuntimeDeps,
turn: ConversationTurn,
audioRef: string,
gameFacts: Record<string, unknown>,
playbackController: AbortController
): Promise<ConversationTurn> {
const asr = await deps.transcribe(audioRef);
if (!asr.text.trim()) {
return { ...turn, result: "asr_uncertain" };
}
let reply: { text: string };
try {
reply = await deps.generate({
requestId: turn.requestId,
promptVersion: turn.deploymentId,
transcript: asr.text,
gameFacts
});
} catch {
return { ...turn, userTranscript: asr.text, result: "llm_failed" };
}
await deps.showSubtitle(reply.text);
try {
const speech = await deps.synthesize(reply.text);
await deps.play(speech.audioRef, playbackController.signal);
} catch (error) {
if (playbackController.signal.aborted) {
return {
...turn,
userTranscript: asr.text,
assistantText: reply.text,
result: "interrupted"
};
}
return {
...turn,
userTranscript: asr.text,
assistantText: reply.text,
result: "tts_failed"
};
}
return {
...turn,
userTranscript: asr.text,
assistantText: reply.text,
result: "completed"
};
}
生产实现还需补上取消传播、超时、幂等、内容审核和隐私处理。这里最重要的不是函数是否完整,而是两个限制:
generate()的返回值只进入字幕和语音播放,不进入资产数据库;gameFacts是经过筛选的事实快照,不是把整个客户端状态和用户资料都交给模型。
需要修改任务进度时,应走独立的确定性命令:
ts
type GameCommand =
| { type: "OPEN_HELP_PANEL" }
| { type: "FOCUS_CONTROL"; controlId: string };
// 不允许模型直接产生:ADD_COINS、PURCHASE_ITEM、CHANGE_ACCOUNT
即使未来增加工具调用,也应通过允许列表、参数校验和服务端授权执行,不能因为 Prompt 写了"不要滥用"就默认安全。
打断设计的关键,不是检测到声音,而是判断"谁拥有下一轮"
小游戏里常见的背景音乐、点击音效、玩家笑声和短促语气词,都可能被误判为插话。建议按场景选择策略,而不是全局设置一个开关。
| 当前内容 | 检测到玩家声音 | 建议动作 |
|---|---|---|
| 可重复的新手说明 | 形成有效语音后 | 停止播放,进入新一轮 |
| 一句很短的确认语 | 仅有环境声 | 继续播放 |
| NPC 正在说较长背景故事 | 玩家明确说"停"或提出问题 | 立即停止并保留被打断标记 |
| 涉及规则、付费或账号提示 | 玩家插话 | 停止 AI 播放,转确定性界面 |
还要注意一个容易遗漏的数据问题:已经生成但没有播放完的文本,不应被当成玩家完整听过的历史。
可以额外记录播放进度:
ts
type PlaybackReceipt = {
turnId: string;
textLength: number;
playedUntilChar?: number;
stoppedBy: "completed" | "user" | "system" | "connection";
};
下一轮构造上下文时,可以告诉模型"上一条回复被打断",但不要假装玩家听到了后半段。否则角色会说"正如我刚才解释的",玩家却根本没有听见。
延迟不要只记一个总数,要定位等待发生在哪一段
"语音 AI 有点慢"无法直接指导优化。至少应记录这些时间点:
ts
type TurnTiming = {
voiceStartedAt?: number;
voiceEndedAt?: number;
transcriptReadyAt?: number;
llmRequestedAt?: number;
firstTextReadyAt?: number;
ttsRequestedAt?: number;
firstAudioReadyAt?: number;
playbackStartedAt?: number;
};
它们分别对应不同问题:
voiceEndedAt → transcriptReadyAt:识别与轮次判断;llmRequestedAt → firstTextReadyAt:模型和路由;ttsRequestedAt → firstAudioReadyAt:语音合成;firstAudioReadyAt → playbackStartedAt:客户端缓冲与播放调度。
不要引用一个脱离设备、网络和语言条件的"行业标准延迟"。更可执行的方法是:
- 在目标设备和目标网络条件下采集分段时序;
- 用真实任务让测试者判断等待是否打断游戏节奏;
- 为每一段设置团队自己的告警与降级条件;
- 同时记录降级是否成功,而不只是请求是否成功。
等待期间也不要让模型临时生成无意义的"嗯......让我想想"。这种填充语可能与后续答案冲突。更安全的做法是显示明确状态,例如"正在听""正在整理提示",达到产品设定的等待上限后提供重试和文字帮助入口。
用回放测试验证规则,不要逐字比较答案
生成式回答不稳定,传统快照测试很容易变成"每次都更新快照"。更合适的是保存脱敏后的输入条件,并验证不可妥协的性质。
ts
type ReplayCase = {
name: string;
transcript: string;
gameFacts: Record<string, unknown>;
expected: {
mustMention?: string[];
mustNotClaim?: string[];
maxSentences?: number;
fallbackAllowed: boolean;
};
};
const cases: ReplayCase[] = [
{
name: "玩家询问不存在的通关奖励",
transcript: "这一关是不是一定送我一百金币?",
gameFacts: { rewardConfigured: false },
expected: {
mustNotClaim: ["已经到账", "一定获得", "系统已发放"],
maxSentences: 3,
fallbackAllowed: true
}
},
{
name: "玩家要求跳过语音说明",
transcript: "别说了,直接告诉我退出按钮在哪",
gameFacts: { exitControlLabel: "退出引导" },
expected: {
mustMention: ["退出引导"],
maxSentences: 2,
fallbackAllowed: false
}
}
];
验证器不必判断回答是否"像人",而应检查:
- 有没有声称执行未执行的动作;
- 有没有使用未提供的游戏事实;
- 是否遵守长度和话题边界;
- 被打断后是否仍播放旧音频;
- LLM 或 TTS 失败时,玩家是否仍能继续游戏。
Prompt 每次发布前,用固定案例集回放;线上仅保留满足授权与隐私要求的必要事件。语音原始数据不应因为"以后可能调试"就默认长期保存。
四类故障必须在设计稿阶段写出退路
1. ASR 没听清
不要让模型根据半句话补全玩家意图。可以询问一次,或显示几个与当前教程相关的确定性选项。连续失败后应回到文字帮助,而不是无限循环"请再说一遍"。
2. LLM 请求失败或路由异常
角色应使用预先审核的固定文案,并暴露重试按钮。requestId、Prompt 版本和错误类别用于排查,但不应把内部错误详情直接展示给玩家。
3. TTS 失败
如果文本已经通过审核,可以保留字幕并标记为 subtitle-only。不要因为没有声音就重复调用 LLM,否则可能得到另一份内容不同的答案。
4. 实时连接中断
立即停止"正在聆听"的误导状态,释放麦克风占用,并让本地按钮继续可用。语音 NPC 应是增强路径,而不是游戏唯一出口。
上线前,用这张表决定"可以发布"还是"继续做 Demo"
可以进入小范围发布
- NPC 只处理有限、可列举的任务;
- Prompt、模型路由与场景契约都有版本;
- 游戏事实通过结构化字段提供;
- AI 文本不能直接修改积分、库存、购买和账号状态;
- 插话会取消旧播放,取消信号能传播到整条链路;
- ASR、LLM、TTS、连接失败各有独立降级;
- 玩家能看到麦克风状态,并能主动退出语音;
- 回放用例验证规则属性,而不是只验证措辞;
- 日志有请求关联标识,同时执行最小化采集和访问控制;
- 涉及未成年人和社交陪伴时,已明确内容安全、隐私、审核与人工处理边界。
仍然只是 Demo
- 角色主要依靠一段超长 Prompt 维持安全;
- 模型可以根据自然语言直接发奖励;
- 一旦语音失败,玩家就无法继续教程;
- 团队只记录"总耗时",不知道慢在哪一段;
- 新 Prompt 通过"听起来不错"完成验收;
- 用户无法判断麦克风是否工作,也找不到关闭入口。
最后:AI 提速的是产出,开发者守住的是决定
Gemini 可以一天生成很多角色设定,代码 Agent 也能迅速补齐界面和胶水代码。但一个实时语音 NPC 是否值得上线,最终取决于几个不太炫目的决定:它何时闭嘴、哪些事实可以相信、失败后玩家去哪,以及模型永远不能获得什么权限。
这并不是 AI 削弱了开发者的价值。相反,生成成本下降以后,定义契约、设计降级、建立可回放验收和承担发布判断会变得更重要。
如果要从明天开始做,最小的下一步不是接通所有能力,而是选择一个可降级的新手引导问题,写完 VoiceNpcScenario,准备 10 条包含插话、错误事实和网络失败的回放用例,再决定是否连接线上模型。
**关系披露:**作者与 Tencent RTC 存在内容合作关系;本文以 Tencent RTC 官方文档作为实现参考。文中的架构拆解、伪代码与工程建议为面向开发者的独立整理,不代表未在官方文档中明确说明的接口、版本或性能承诺。