在模型输出和实际执行间建立事务边界,防止中断后仍执行已生成操作。
当语音助手能做实事时——发一条房间消息、加一项到队列、更新个人资料、或调用其他服务——它才变得真正有用。
这也正是令人信服的 Demo 如何变成不可靠产品的转折点。
「告诉房间里的朋友我八点就走」——算了,还是别发了。
模型可能在中断到来之前已经生成了正确的工具参数。如果工具执行直接耦合在模型输出上,那么这条消息会在助手还在说「好的」的时候就发布出去。一个更好的 prompt 可能会降低发生频率,但它无法创建一个事务边界。
令人不安的工程现实并非你「不擅长写 prompt」,而是系统在模型提议动作和应用提交动作之间缺少一个可强制执行的状态。
在本教程中,我们将为一个腾讯 RTC 对话式 AI 场景构建这个边界。助手可以使用 OpenAI 兼容模型或 Dify 等智能体平台,但两个提供者都不会获得直接提交动作的权限。
我们的应用将强制执行一条规则:
模型输出可以准备一个动作,但只有新鲜的、明确的用户确认才能提交它。
结果路径如下:
RTC 音频
-> 语音识别
-> 应用轮次协调器
-> LLM 或 Dify
-> 经过验证的动作提案
-> 服务端准备的票据
-> 语音预览
-> 明确用户确认
-> 权限重新检查
-> 幂等提交
腾讯 RTC 将其对话式 AI 场景文档描述为可连接多个 LLM 提供者的实时语音交互。其 LLM 配置文档涵盖了 OpenAI 兼容模型和包括 Dify 在内的智能体平台,以及对路由和可观测性有用的请求标识符:
腾讯对话式 AI 概览
大语言模型配置
媒体、语音、模型和应用授权层保持独立。以下代码位于应用编排层,不会发明新的 RTC 或消息 API。
并非每个模型操作都需要语音确认。在将每个应用定义的工具暴露给模型之前先对其分类:
本教程使用一个应用定义的 publishRoomMessage 动作。腾讯 RTC 的社交娱乐素材包括 AI 伴侣、语音房间、社区和角色对话作为相关场景,但工具和授权策略属于我们自己:社交娱乐解决方案。
mkdir voice-action-boundary
cd voice-action-boundary
npm init -y
npm install --save-dev typescript tsx @types/node
npx tsc --init
mkdir src
在 package.json 中添加脚本:
{
"scripts": {
"start": "tsx src/demo.ts",
"test": "tsx --test src/*.test.ts"
}
}
不要用 isLoading、isTalking、isConfirmed 这样的布尔值来表示整个交互。它们的无意义组合会迅速增长。
// src/types.ts
export type MessageProposal = {
kind: 'publishRoomMessage';
roomId: string;
text: string;
};
export type PreparedAction = {
ticket: string;
turnId: string;
proposal: MessageProposal;
preview: string;
expiresAt: number;
};
export type VoiceActionState =
| { kind: 'idle' }
| { kind: 'requesting-model'; turnId: string }
| { kind: 'previewing'; action: PreparedAction }
| { kind: 'awaiting-confirmation'; action: PreparedAction }
| { kind: 'executing'; action: PreparedAction }
| { kind: 'completed'; turnId: string }
| {
kind: 'recovery';
turnId: string;
reason: 'expired' | 'denied' | 'provider-error' | 'commit-uncertain';
};
这个联合类型使几种非法状态变得不可表示。例如,动作不能同时处于「等待确认」和「已完成」状态。
票据很重要。它标识一个准备好的动作,而不是给模型一个可复用的工具凭证。
Prompt 可以告诉模型返回 JSON,但应用仍然需要将该 JSON 视为不可信输入。
// src/proposal.ts
import type { MessageProposal } from './types.js';
export function parseProposal(value: unknown): MessageProposal {
if (!value || typeof value !== 'object') {
throw new Error('Proposal must be an object');
}
const candidate = value as Record<string, unknown>;
if (candidate.kind !== 'publishRoomMessage') {
throw new Error('Unsupported action kind');
}
if (typeof candidate.roomId !== 'string' || !candidate.roomId.trim()) {
throw new Error('Invalid room ID');
}
if (typeof candidate.text !== 'string') {
throw new Error('Message text is required');
}
const text = candidate.text.trim();
if (text.length === 0 || text.length > 280) {
throw new Error('Message must contain between 1 and 280 characters');
}
return {
kind: 'publishRoomMessage',
roomId: candidate.roomId,
text
};
}
房间 ID 通常不应该来自模型的想象。在准备动作之前,将其与可信的会话上下文进行比较。
代理颁发短期票据、在提交时重新检查权限,并阻止同一票据被并发提交。
// src/broker.ts
import { randomUUID } from 'node:crypto';
import type { MessageProposal, PreparedAction } from './types.js';
type SessionContext = {
userId: string;
roomId: string;
};
type RecordState =
| 'prepared'
| 'executing'
| 'committed'
| 'cancelled'
| 'uncertain';
type StoredAction = {
ownerId: string;
action: PreparedAction;
state: RecordState;
};
export interface RoomPublisher {
publish(
roomId: string,
text: string,
options: { idempotencyKey: string }
): Promise<void>;
}
export class ActionBroker {
private records = new Map<string, StoredAction>();
constructor(
private readonly publisher: RoomPublisher,
private readonly canPublish: (context: SessionContext) => Promise<boolean>,
private readonly now: () => number = Date.now
) {}
prepare(
proposal: MessageProposal,
turnId: string,
context: SessionContext
): PreparedAction {
if (proposal.roomId !== context.roomId) {
throw new Error('Proposal targeted a different room');
}
const ticket = randomUUID();
const action: PreparedAction = {
ticket,
turnId,
proposal,
preview: `Post this message to the room: ${proposal.text}`,
expiresAt: this.now() + 30_000
};
this.records.set(ticket, {
ownerId: context.userId,
action,
state: 'prepared'
});
return action;
}
cancel(ticket: string): void {
const record = this.records.get(ticket);
if (record?.state === 'prepared') record.state = 'cancelled';
}
async commit(ticket: string, context: SessionContext): Promise<void> {
const record = this.records.get(ticket);
if (!record) throw new Error('Unknown action ticket');
if (record.ownerId !== context.userId) {
throw new Error('Ticket belongs to another user');
}
if (record.action.proposal.roomId !== context.roomId) {
throw new Error('Room context changed');
}
if (record.state === 'committed') return;
if (record.state !== 'prepared') {
throw new Error(`Action cannot commit from ${record.state}`);
}
if (this.now() >= record.action.expiresAt) {
record.state = 'cancelled';
throw new Error('Action ticket expired');
}
if (!(await this.canPublish(context))) {
record.state = 'cancelled';
throw new Error('Permission denied at commit time');
}
record.state = 'executing';
try {
await this.publisher.publish(
context.roomId,
record.action.proposal.text,
{ idempotencyKey: ticket }
);
record.state = 'committed';
} catch (error) {
record.state = 'uncertain';
throw error;
}
}
}
这个 Map 使教程易于运行。在生产环境中,准备好的票据和状态转换应使用持久化存储或其他原子协调机制。否则,进程重启可能会抹掉外部操作是否成功。
如果允许自动重试,发布适配器也必须遵守幂等性密钥。如果下游服务超时且不提供幂等性保证,则正确的状态是 uncertain(不确定),而非「失败」。盲目重试可能会发布两次。
不要问 LLM 用户是否确认了自己的提案。使用一个窄域识别器来处理授权事件。
// src/confirmation.ts
export type Confirmation = 'yes' | 'no' | 'ambiguous';
export function classifyConfirmation(transcript: string): Confirmation {
const normalized = transcript
.toLowerCase()
.replace(/[^a-z\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (['yes', 'yes post it', 'confirm', 'send it'].includes(normalized)) {
return 'yes';
}
```typescript
if (['no', 'cancel', 'dont send it', 'do not send it'].includes(normalized)) {
return 'no';
}
return 'ambiguous';
}
严格的词汇表会增加对话摩擦,尤其是在语音识别不确定的情况下。对于高风险操作来说,这是一种刻意的权衡。可见的确认/取消控件是一个有用的兜底方案,但它应该接入同一个状态机,而不是绕过它。
协调预览、打断和提交
语音控制器从适配器接收转录文本和语音生命周期事件。确切的 SDK 连接方式取决于客户端平台,因此下面的接口标记了集成边界,而不会凭空发明产品 API 名称。
// src/controller.ts
import { classifyConfirmation } from './confirmation.js';
import type { ActionBroker } from './broker.js';
import type { PreparedAction, VoiceActionState } from './types.js';
type Context = { userId: string; roomId: string };
type SpeechOutput = {
speak(text: string): Promise<void>;
stop(): void;
};
export class VoiceActionController {
private state: VoiceActionState = { kind: 'idle' };
constructor(
private readonly broker: ActionBroker,
private readonly speech: SpeechOutput,
private readonly context: Context
) {}
snapshot(): VoiceActionState {
return this.state;
}
async present(action: PreparedAction): Promise<void> {
this.state = { kind: 'previewing', action };
await this.speech.speak(`${action.preview}. Say yes to confirm or no to cancel.`);
if (this.state.kind === 'previewing' &&
this.state.action.ticket === action.ticket) {
this.state = { kind: 'awaiting-confirmation', action };
}
}
onUserSpeechStarted(): void {
if (this.state.kind === 'previewing') {
this.speech.stop();
this.state = {
kind: 'awaiting-confirmation',
action: this.state.action
};
}
}
async onTranscript(transcript: string): Promise<void> {
if (this.state.kind !== 'awaiting-confirmation') return;
const action = this.state.action;
const answer = classifyConfirmation(transcript);
if (answer === 'no') {
this.broker.cancel(action.ticket);
this.state = { kind: 'idle' };
await this.speech.speak('Cancelled. Nothing was posted.');
return;
}
if (answer === 'ambiguous') {
await this.speech.speak('I did not get a clear yes or no. The action is still waiting.');
return;
}
this.state = { kind: 'executing', action };
try {
await this.broker.commit(action.ticket, this.context);
this.state = { kind: 'completed', turnId: action.turnId };
await this.speech.speak('Posted.');
} catch {
this.state = {
kind: 'recovery',
turnId: action.turnId,
reason: 'commit-uncertain'
};
await this.speech.speak(
'I could not verify whether that completed. I will not retry it automatically.'
);
}
}
}
注意这里的打断意味着什么。如果用户在预览期间开始说话,播放会立即停止,但操作不会执行。生成的后续转录文本仍然需要包含可接受的确认。
一旦外部提交已经开始,打断无法自动回滚。对于支持补偿的工具,将其建模为一个单独的授权操作。不要仅仅因为本地音频停止了就告诉用户"已取消"。
安全地连接 OpenAI 兼容模型或 Dify
LLM 适配器只需要足够的权限来返回一个提案:
export interface LlmAdapter {
propose(input: {
requestId: string;
transcript: string;
roomId: string;
allowedActions: readonly ['publishRoomMessage'];
}): Promise<unknown>;
}
每个语音轮次使用一个应用生成的请求 ID,并将其贯穿模型路由和日志。连接选定的 OpenAI 兼容提供商或 Dify 工作流时,Tencent RTC LLM 配置文档应该是实现参考:Large Language Model configuration。
一个合适的模型指令可以请求结构化输出,但它不是安全控制:
返回对话式回复或对一个允许操作的提案。
永远不要声称某个操作已完成。应用程序将验证、确认和执行它。
所展示的 AI 能力是解释用户的语言并提出结构化参数。不可支持的跳跃是假设语言置信度等于授权。人类控制权在于票据、确认事件、权限检查和提交路径——而不是模型的措辞。
对于 Dify,应用相同的规则:工作流可以产生提案,但不应接收发布消息的应用程序凭据。对于 OpenAI 兼容模型,不要将 commit(ticket) 暴露为另一个可由模型选择的工具。应用程序协调器拥有那个转换。
用失败导向的测试验证行为
一条快乐路径的语音对话能证明的东西非常有限。从回调重叠的情况开始。
// src/controller.test.ts
import test from 'node:test';
import assert from 'node:assert/strict';
import { ActionBroker } from './broker.js';
import { VoiceActionController } from './controller.js';
const context = { userId: 'user-1', roomId: 'room-1' };
function fixture() {
const published: string[] = [];
const spoken: string[] = [];
const broker = new ActionBroker(
{
async publish(_roomId, text) {
published.push(text);
}
},
async () => true
);
const controller = new VoiceActionController(
broker,
{
async speak(text) { spoken.push(text); },
stop() {}
},
context
);
const action = broker.prepare(
{
kind: 'publishRoomMessage',
roomId: 'room-1',
text: 'I will leave at eight.'
},
'turn-1',
context
);
return { broker, controller, action, published, spoken };
}
test('an interruption followed by no never publishes', async () => {
const f = fixture();
const presenting = f.controller.present(f.action);
f.controller.onUserSpeechStarted();
await f.controller.onTranscript('No, do not send it');
await presenting;
assert.deepEqual(f.published, []);
assert.equal(f.controller.snapshot().kind, 'idle');
});
test('ambiguous speech does not become consent', async () => {
const f = fixture();
await f.controller.present(f.action);
await f.controller.onTranscript('Maybe change eight to nine');
assert.deepEqual(f.published, []);
assert.equal(f.controller.snapshot().kind, 'awaiting-confirmation');
});
test('explicit confirmation commits once', async () => {
const f = fixture();
await f.controller.present(f.action);
await f.controller.onTranscript('Yes, post it');
await f.controller.onTranscript('Yes, post it');
assert.deepEqual(f.published, ['I will leave at eight.']);
assert.equal(f.controller.snapshot().kind, 'completed');
});
npm test
然后围绕你的实际语音和发布适配器添加集成测试。
发布前的故障演练
LLM 返回了不同的 room ID
在准备阶段拒绝提案。永远不要让模型输出选择与已认证的 RTC 会话不一致的授权范围。
用户听到预览后失去了权限
在提交时重新检查授权。准备阶段不是永久的权限授予。
语音识别从背景音频中产生"yes"
要求一个狭窄的确认短语,将其与活跃的票据关联,并提供按钮兜底方案。对于更高风险的操作,仅语音确认可能是不够的。
用户在思考时票据过期了
取消它,如果用户仍然想要该操作,则生成一个新的预览。不要静默地延长旧权限。
模型提供商超时
恢复到可恢复的对话状态。没有票据存在,因此没有可执行的内容。重试应保持相同的轮次关联,同时避免重复的准备操作。
发布请求在到达服务器后超时
将结果标记为不确定。如果下游系统支持该操作,则通过幂等性键查询。否则,升级到可见的恢复选择,而不是自动再次发布。
确认期间 RTC 连接断开
允许票据过期。重连媒体会话不得将旧的转录文本或延迟的回调重新解释为新的同意。
提示注入要求模型跳过确认
它不能。模型只有提案权限;它没有提交能力。这就是"在提示中描述边界"和"在代码中强制执行边界"之间的区别。
实用的发布检查清单
在启用一个高风险的智能体工具之前,验证以下内容:
[ ] 模型输出被解析并验证为不受信任的数据。
[ ] 信任的会话上下文决定用户和房间范围。
[ ] 模型可以提案但不能提交操作。
[ ] 准备的票据是单一用途的、短命的且绑定到用户的。
[ ] 实际效果在确认前会被预览。
[ ] 模糊的语音不构成同意。
[ ] 打断功能停止播放但不提交操作。
[ ] 权限在执行前立即再次检查。
[ ] 重复的确认回调不会产生重复效果。
[ ] 超时可以表示不确定结果,而不会编造成功或失败。
[ ] 日志关联 RTC 轮次、模型请求、准备好的凭证和提交结果,且不存储不必要的私人音频或文本。
[ ] 界面提供可见的停止、取消或确认控制。
仍然重要的技能
随着模型在选择工具方面越来越擅长,开发者的角色并不会缩小到只是打磨提示词。更困难也更持久的工作是:决定存在哪些权限、何时生效、如何过期,以及当确定性不可能时用户看到的是什么。
流畅的语音只是表现层。信任来自背后的状态机。
关系声明:撰写本文时我与腾讯 RTC 存在合作关系上述腾讯 RTC 官方文档链接作为实现参考。
For further actions, you may consider blocking this person and/or reporting abuse