Tencent RTC AI 語音房教學:測試管理流程,而非只看示範效果
Benchmark the Moderator, Not the Demo: A Safer AI Voice Room with Tencent RTC
教程以 Tencent RTC 語音聊天室為場景,建立可重複執行的 AI 管理基準,將模型建議、人工審批、伺服器驗證與房間狀態核對分開處理。測試涵蓋 AI 不可自行批准、座位版本變更後拒絕舊建議,以及操作逾時標記為結果不確定。只有新座位狀態符合預期,操作才標記為已驗證;文中亦説明如何透過 LiveListStore 和 LiveSeatStore 接入應用程式適配層。
A moderation bot can look remarkably capable in a controlled voice-room demo. The difficult question is what happens when its recommendation is late, the reported speaker has already left the mic, or the bot attempts to speak without a seat.
That creates a real tension for developers: you may want AI to reduce a moderator’s workload, but you do not want model confidence to become room authority.
The useful reframe is to stop evaluating the system as one intelligent character. Evaluate it as a protocol with separate responsibilities:
- Tencent RTC carries the live audio experience.
- Room and seat state determine who may publish audio.
- Speech recognition, if used, produces text for review.
- A model may classify or recommend an action.
- A human moderator owns consequential decisions.
- The application server validates and applies approved actions.
In this tutorial, we will build the application-owned control layer around that protocol. The result is a small moderation benchmark you can run repeatedly before connecting a model or live room.
The behavior we want to prove
Our example room has human speakers and an optional AI moderator participant. The AI can submit a recommendation such as “mute this occupied seat,” but it cannot approve the recommendation—even if its participant role is labeled moderator.
If the AI needs to read a warning aloud, it follows the same floor rules as everyone else:
- It must be assigned an occupied mic seat.
- That seat must not be muted.
- The room must still be open.
- A human must have approved the warning workflow.
This separates two commonly conflated permissions:
- Moderation authority: who may approve a room action.
- Audio capability: who currently has a seat and may publish sound.
Tencent RTC’s Voice Chat Room material describes the scenario in terms of rooms, mic seats, audio streams, moderation, and related room capabilities. Its implementation guide builds room and mic-seat behavior around LiveListStore and LiveSeatStore. Those documented concepts are the integration boundary used here:
We will not invent SDK methods. Instead, the tutorial creates a testable domain layer that an adapter can connect to the documented stores and operations used by your application.
Create the project
Use a recent Node.js installation, then create a TypeScript project with Vitest:
mkdir voice-room-moderation-bench
cd voice-room-moderation-bench
npm init -y
npm install --save-dev typescript vitest @types/node
npm pkg set scripts.test="vitest run"
npx tsc --init
mkdir src
Add this minimal compiler configuration to tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true
},
"include": ["src"]
}
Represent authority and seat state separately
Create src/moderation.ts:
export type ParticipantKind = "human" | "ai";
export type ParticipantRole = "host" | "moderator" | "member";
export type RoomPhase = "open" | "closing" | "closed";
export type ModerationAction = "mute" | "removeFromSeat";
export interface Participant {
id: string;
kind: ParticipantKind;
role: ParticipantRole;
}
export interface Seat {
id: string;
occupantId: string | null;
muted: boolean;
// Application-owned revision assigned whenever a fresh seat
// snapshot is accepted. It is not presented as an SDK field.
revision: number;
}
export type ProposalStatus =
| "pending"
| "applying"
| "awaitingObservation"
| "verified"
| "failed"
| "uncertain"
| "dismissed";
export interface ModerationProposal {
id: string;
sourceId: string;
targetId: string;
action: ModerationAction;
reason: string;
evidenceRefs: string[];
expectedSeatId: string;
expectedSeatRevision: number;
expiresAt: number;
status: ProposalStatus;
operationId?: string;
reviewedBy?: string;
failureReason?: string;
}
export interface RoomState {
phase: RoomPhase;
participants: Record<string, Participant>;
seats: Record<string, Seat>;
proposals: Record<string, ModerationProposal>;
}
export interface ApplyModerationEffect {
type: "APPLY_MODERATION";
operationId: string;
proposalId: string;
action: ModerationAction;
targetId: string;
seatId: string;
expectedSeatRevision: number;
}
export interface Transition {
state: RoomState;
effect: ApplyModerationEffect;
}
export function canPublishAudio(
state: RoomState,
participantId: string
): boolean {
if (state.phase !== "open") return false;
return Object.values(state.seats).some(
(seat) =>
seat.occupantId === participantId &&
seat.muted === false
);
}
export function approveProposal(
current: RoomState,
proposalId: string,
reviewerId: string,
operationId: string,
now: number
): Transition {
const state = structuredClone(current);
const proposal = state.proposals[proposalId];
const reviewer = state.participants[reviewerId];
if (!proposal) throw new Error("proposal_not_found");
if (!reviewer) throw new Error("reviewer_not_found");
if (state.phase !== "open") throw new Error("room_not_open");
if (proposal.status !== "pending") throw new Error("proposal_not_pending");
if (now >= proposal.expiresAt) throw new Error("proposal_expired");
// An AI participant can recommend, but cannot commit an action.
if (reviewer.kind !== "human") throw new Error("human_review_required");
if (reviewer.role !== "host" && reviewer.role !== "moderator") {
throw new Error("reviewer_not_authorized");
}
const seat = state.seats[proposal.expectedSeatId];
if (!seat) throw new Error("seat_not_found");
if (seat.occupantId !== proposal.targetId) {
throw new Error("target_no_longer_on_expected_seat");
}
if (seat.revision !== proposal.expectedSeatRevision) {
throw new Error("stale_seat_revision");
}
proposal.status = "applying";
proposal.operationId = operationId;
proposal.reviewedBy = reviewerId;
return {
state,
effect: {
type: "APPLY_MODERATION",
operationId,
proposalId,
action: proposal.action,
targetId: proposal.targetId,
seatId: proposal.expectedSeatId,
expectedSeatRevision: proposal.expectedSeatRevision
}
};
}
export type ApplyResult = "accepted" | "rejected" | "unknown";
export function recordApplyResult(
current: RoomState,
proposalId: string,
operationId: string,
result: ApplyResult
): RoomState {
const state = structuredClone(current);
const proposal = state.proposals[proposalId];
if (!proposal) throw new Error("proposal_not_found");
if (proposal.status !== "applying") throw new Error("proposal_not_applying");
if (proposal.operationId !== operationId) throw new Error("stale_operation");
if (result === "accepted") {
// An accepted request is not yet proof that observed room state changed.
proposal.status = "awaitingObservation";
} else if (result === "rejected") {
proposal.status = "failed";
proposal.failureReason = "adapter_rejected_action";
} else {
proposal.status = "uncertain";
proposal.failureReason = "action_result_unknown";
}
return state;
}
export function verifyObservedState(
current: RoomState,
proposalId: string,
observedSeat: Seat
): RoomState {
const state = structuredClone(current);
const proposal = state.proposals[proposalId];
if (!proposal) throw new Error("proposal_not_found");
if (proposal.status !== "awaitingObservation") {
throw new Error("proposal_not_awaiting_observation");
}
const conditionMet =
proposal.action === "mute"
? observedSeat.occupantId === proposal.targetId && observedSeat.muted
: observedSeat.occupantId !== proposal.targetId;
if (conditionMet) {
proposal.status = "verified";
}
state.seats[observedSeat.id] = observedSeat;
return state;
}
There are three deliberate constraints in this model.
First, kind and role are separate. Labeling an AI participant as a moderator does not make it human or grant approval authority.
Second, a proposal refers to a specific seat revision. A recommendation about seat 2 should not be applied after seat 2 has been vacated and occupied by someone else.
Third, an accepted adapter request is not displayed as a completed action. Completion requires an observed seat state that matches the requested result.
Turn moderation scenarios into executable benchmarks
A generic “moderation accuracy” score will not reveal authority or race-condition failures. Build cases from situations your room must survive.
Create src/moderation.test.ts:
import { describe, expect, it } from "vitest";
import {
approveProposal,
canPublishAudio,
recordApplyResult,
verifyObservedState,
type RoomState
} from "./moderation";
function room(): RoomState {
return {
phase: "open",
participants: {
host: { id: "host", kind: "human", role: "host" },
speaker: { id: "speaker", kind: "human", role: "member" },
bot: { id: "bot", kind: "ai", role: "moderator" }
},
seats: {
s1: { id: "s1", occupantId: "speaker", muted: false, revision: 7 },
s2: { id: "s2", occupantId: null, muted: false, revision: 3 }
},
proposals: {
p1: {
id: "p1",
sourceId: "bot",
targetId: "speaker",
action: "mute",
reason: "Possible repeated interruption",
evidenceRefs: ["transcript-window-42"],
expectedSeatId: "s1",
expectedSeatRevision: 7,
expiresAt: 20_000,
status: "pending"
}
}
};
}
describe("voice-room moderation protocol", () => {
it("does not let an AI approve its own proposal", () => {
expect(() =>
approveProposal(room(), "p1", "bot", "op-1", 10_000)
).toThrow("human_review_required");
});
it("does not let an off-seat AI participant publish audio", () => {
expect(canPublishAudio(room(), "bot")).toBe(false);
});
it("uses the same seat rule when the AI is allowed to speak", () => {
const state = room();
state.seats.s2 = {
id: "s2",
occupantId: "bot",
muted: false,
revision: 4
};
expect(canPublishAudio(state, "bot")).toBe(true);
});
it("rejects approval when the seat changed after the proposal", () => {
const state = room();
state.seats.s1 = {
id: "s1",
occupantId: "speaker",
muted: false,
revision: 8
};
expect(() =>
approveProposal(state, "p1", "host", "op-2", 10_000)
).toThrow("stale_seat_revision");
});
it("waits for observed seat state before claiming success", () => {
const approved = approveProposal(
room(),
"p1",
"host",
"op-3",
10_000
);
expect(approved.state.proposals.p1.status).toBe("applying");
const accepted = recordApplyResult(
approved.state,
"p1",
"op-3",
"accepted"
);
expect(accepted.proposals.p1.status).toBe("awaitingObservation");
const verified = verifyObservedState(accepted, "p1", {
id: "s1",
occupantId: "speaker",
muted: true,
revision: 8
});
expect(verified.proposals.p1.status).toBe("verified");
});
it("shows an ambiguous timeout instead of pretending success", () => {
const approved = approveProposal(
room(),
"p1",
"host",
"op-4",
10_000
);
const uncertain = recordApplyResult(
approved.state,
"p1",
"op-4",
"unknown"
);
expect(uncertain.proposals.p1.status).toBe("uncertain");
expect(uncertain.proposals.p1.failureReason).toBe(
"action_result_unknown"
);
});
});
Run the benchmark:
npm test
These tests demonstrate the control protocol, not the quality of any particular model. A model’s moderation accuracy remains an empirical question that you must evaluate with representative, consented data and a written room policy.
Connect the controller to a Tencent RTC Voice Chat Room
The official implementation guide uses LiveListStore for room lifecycle and LiveSeatStore for mic-seat state. Keep those live concerns behind an adapter rather than importing SDK behavior into your policy functions.
Define the application boundary like this:
import type { ApplyModerationEffect, RoomPhase, Seat } from "./moderation";
export interface VoiceRoomAdapter {
readRoomPhase(): Promise<RoomPhase>;
readSeats(): Promise<Seat[]>;
applyModeration(
effect: ApplyModerationEffect
): Promise<"accepted" | "rejected" | "unknown">;
}
The concrete adapter in your application should:
- Read the current room lifecycle from the room state connected to
LiveListStore. - Convert the latest
LiveSeatStoresnapshot into the domainSeatrepresentation. - Increment an application-owned revision whenever it accepts a changed seat snapshot.
- Send the approved mute or remove request through the documented room integration used by your project.
- Treat a timeout as
unknown, notacceptedorrejected. - Wait for a fresh seat observation before marking the proposal
verified.
Do not let a client-provided role or an AI response directly invoke the adapter. Approval should be validated on a trusted application service using fresh room and seat state.
A practical event flow is:
seat snapshot
↓
application assigns seat revision
↓
AI or human submits recommendation
↓
moderation queue displays evidence and expiration
↓
human host/moderator approves
↓
server revalidates room, reviewer, occupant, and revision
↓
adapter requests the room action
↓
fresh seat snapshot verifies the resulting condition
The UI should display the actual states rather than collapsing them into a spinner:
| Protocol state | Moderator-facing text |
|---|---|
pending |
Review requested |
applying |
Sending approved action |
awaitingObservation |
Action accepted; checking room state |
verified |
Room state verified |
failed |
Action rejected; review current seat state |
uncertain |
Result unknown; refresh before retrying |
dismissed |
No action taken |
Where an AI moderator fits
An AI participant can be useful for narrowing a busy review queue, grouping similar reports, or pointing a human toward a short evidence window. None of those capabilities proves that it understands humor, reclaimed language, speaker relationships, or the room’s social context.
Keep the AI path explicit:
consented audio or text
→ speech recognition, if audio is used
→ bounded evidence window
→ model recommendation
→ pending proposal
→ human decision
→ server validation
→ RTC room action
Text-to-speech is a different path:
approved warning text
→ AI participant receives a mic seat
→ seat permission is verified
→ speech synthesis
→ audio publication
The model should not be able to produce a special response token that bypasses either path.
Also decide what evidence is retained. A reference such as transcript-window-42 is safer in domain state than copying raw audio or a full transcript into every moderation record, but the referenced data still needs access controls, retention rules, and user-visible consent appropriate to your service.
Build a benchmark from your policy, not from model confidence
Start with a small case matrix before evaluating any model:
| Case | Expected recommendation | Required system behavior |
|---|---|---|
| Repeated interruption | Possibly review | Human decides; no automatic mute |
| Speaker leaves before review | Proposal becomes stale | Reject action against the old seat revision |
| New participant occupies the seat | No action on newcomer | Occupant and revision checks fail safely |
| AI requests to read a warning | Speech only after approval | AI needs an occupied, unmuted seat |
| Adapter times out | Unknown | Reconcile from fresh seat state before retry |
| Room begins closing | No new action | Approval fails because the room is not open |
| AI flags ambiguous humor | Review or dismiss | Preserve human contextual judgment |
For each case, record three separate results:
- Recommendation quality: Did the model produce a useful suggestion?
- Protocol safety: Did the application prevent unauthorized or stale action?
- Recovery clarity: Could the moderator understand what to do after failure?
This prevents a high classification score from hiding a dangerous control path.
Failure policies to decide before launch
The target leaves while the moderator is reading
The seat occupant or revision no longer matches. Reject the approval and label the proposal stale. Do not redirect the action to whoever occupies that seat next.
The AI recommends muting the host
Do not solve this with a prompt alone. Define protected-target policy in deterministic server code, or require an elevated review path appropriate to your room model.
The action request times out
A timeout does not reveal whether the remote operation happened. Mark the result uncertain, refresh authoritative seat state, and use an operation identifier or your persistence layer’s idempotency mechanism before allowing a retry.
The room closes during approval
Reject new approvals after the room leaves open. Closing and moderation should not race to create unexplained final actions.
The AI is on a seat when its warning is cancelled
Stop its speech path and remove or mute its speaking capability according to your room policy. Cancelling generated text is insufficient if synthesized audio is already queued.
Speech recognition or the model is unavailable
Keep human moderation usable. The queue should accept human reports and direct decisions without requiring the AI pipeline to recover first.
Release verification
Run the unit benchmark, then perform a live-room verification pass with non-production accounts:
- [ ] A human member without moderation authority cannot approve a proposal.
- [ ] An AI participant cannot approve a proposal, regardless of its role label.
- [ ] An AI participant cannot publish audio while off-seat or muted.
- [ ] A seat change invalidates recommendations created from an older revision.
- [ ] An accepted action is not shown as verified until a fresh seat snapshot matches it.
- [ ] An expired proposal cannot be approved from an old browser tab.
- [ ] Closing the room prevents new moderation effects.
- [ ] An adapter timeout produces an uncertain state and a reconciliation action.
- [ ] The room remains moderatable when speech recognition or the model is unavailable.
- [ ] Audit records distinguish the recommender, human reviewer, operation, and observed result.
- [ ] Participants can understand when AI analysis or generated speech is in use.
- [ ] Evidence access and retention follow the room’s consent and privacy policy.
The engineering skill that remains human
Adding AI does not reduce moderation engineering to choosing a model. The durable work is defining authority, identifying stale evidence, deciding which actions are reversible, and making uncertainty visible.
A model may help sort the queue. It cannot decide what your community promises its members or who is accountable when context is unclear. Encoding those decisions as executable cases gives both developers and moderators something more useful than a convincing demo: a system whose boundaries can be inspected and rerun.
Relationship disclosure: I have a content collaboration relationship with Tencent RTC. Official Tencent RTC documentation was used as the implementation reference for this article.
來源:DEV Community · dev.to