Agora Conversational AI 통합 — Session, Token, Message 처리
Agora Conversational AI session 생성과 RTC·RTM 권한, provider credential의 server-side 보관, client event 처리 흐름을 구현합니다. Transcript chunk는 사용하는 toolkit의 schema와 sequence 정보를 기준으로 조립하고, agent 상태·microphone permission·cleanup을 명시적으로 관리합니다.
Agora Conversational AI를 사용하면 실시간 음성 AI 에이전트를 빠르게 구축할 수 있습니다. 사용자가 말하면 LLM이 응답하고, TTS가 음성으로 변환해 Agora RTC를 통해 실시간으로 전달됩니다.
아키텍처 개요
Browser (React + Agora RTC/RTM SDK)
│
├── [1] GET /session-config (사용자 RTC+RTM 토큰)
│ └── Supabase Edge Function
│
├── [2] RTC/RTM 연결 + transcript 구독
│
├── [3] POST /start-agent
│ └── Supabase Edge Function
│ └── Agora ConvoAI API (/v2/projects/{appId}/join)
│
├── [4] Agora RTC 채널 (양방향 음성)
│ ├── UID 100 — AI 에이전트
│ └── UID 101 — 사용자
│
└── [5] Agora Signaling/RTM (텍스트 입력 + 트랜스크립트·에이전트 이벤트)
LLM API Key와 TTS API Key는 서버(Edge Function)에만 둡니다. 브라우저에는 Agora App ID와 임시 토큰만 전달합니다.
공식 Web transcript 흐름은 RTC·Signaling 엔진과 toolkit을 초기화하고 subscribeMessage(channel)을 호출한 뒤에 에이전트를 시작합니다. 사용자 토큰 발급과 에이전트 시작을 하나의 요청으로 묶으면 초기 이벤트를 놓칠 수 있으므로, 아래 예시는 /session-config와 /start-agent 책임을 분리합니다.
1단계: 서버에서 토큰과 에이전트 요청 준비
서버는 먼저 브라우저용 세션 정보와 사용자 토큰을 발급합니다. 에이전트용 토큰과 Conversational AI 요청은 /start-agent에서 별도로 만듭니다.
하나의 토큰에 RTC 권한(채널 참여, 오디오/비디오 퍼블리시)과 RTM 권한(로그인)이 함께 포함됩니다. RTM으로 사용자 텍스트를 전송하려면 이 토큰이 RTC와 RTM 권한을 모두 담고 있어야 하며, 동시에 join 페이로드에 properties.advanced_features.enable_rtm: true가 설정되어야 RTM 텍스트 경로가 활성화됩니다(아래 ②, 4단계 참고).
API 요청 인증과 properties.token은 목적이 다릅니다. 위 예시는 Customer ID/Secret으로 만든 HTTP Basic 인증을 사용하고, properties.token에는 에이전트의 RTC·RTM 채널 권한을 넣습니다. 공식 API는 Basic 인증 예시와 Agora project token을 사용하는 Authorization: agora token=... 예시를 모두 제공하므로, 후자를 항상 401로 간주하면 안 됩니다.
/start-agent는 성공 시 { agentId }를 브라우저에 반환합니다. agoraResponse.ok와 Agora 오류 본문을 확인한 뒤 성공 응답을 만들어야 합니다.
현재 공식 Web 통합 가이드는 Conversational AI toolkit과 Signaling을 사용합니다. 2단계의 subscribeMessage가 끝난 뒤 /start-agent를 호출합니다. 에이전트 요청에는 advanced_features.enable_rtm: true와 parameters.data_channel: "rtm"이 필요합니다. 문서화되지 않은 msgId|partIdx|partSum|base64 청크 형식에 직접 의존하지 않습니다.
세션이 끝나면 unsubscribeMessage(channel)과 destroy()를 호출해 toolkit의 구독과 캐시를 정리합니다. 원시 이벤트 스키마가 필요하면 사용 중인 toolkit 소스의 타입 정의를 기준으로 처리합니다.
4단계: RTM 텍스트 메시지 전송
RTM은 사용자 텍스트를 에이전트에 보내고, 트랜스크립트와 에이전트 이벤트를 받는 데 사용합니다. 음성은 RTC 채널을 사용합니다.
전제 조건: 이 경로가 동작하려면 (1) join 페이로드에 advanced_features.enable_rtm: true가 있어야 하고, (2) rtm.login()에 넘기는 토큰이 RTM 권한을 포함해야 합니다(1단계 AccessToken2 생성 참고). 둘 중 하나라도 빠지면 메시지가 에이전트에 전달되지 않습니다.
// 볼륨을 100ms 간격으로 폴링해 talking 상태 감지const volumeInterval = setInterval(() => {
const volume = remoteAudioTrack.getVolumeLevel();
if (volume > 0) setAgentState("talking");
elsesetAgentState("listening");
}, 100);
// 컴포넌트 정리 시 폴링 타이머 해제return() =>clearInterval(volumeInterval);
2. 트랜스크립트 실시간 업데이트 (스트리밍 느낌 주기)
트랜스크립트는 중간 결과 final: false에서 최종 결과 final: true로 갱신될 수 있습니다. 중간 결과와 최종 결과를 구분해 렌더링합니다.
// turn_id 기준으로 메시지를 in-place 업데이트setMessages(prev => {
const existing = prev.find(m => m.id === turnId);
if (existing) {
// 기존 메시지 텍스트 업데이트 (append하지 않음)return prev.map(m =>
m.id === turnId ? { ...m, text: transcript, final } : m
);
}
return [...prev, { id: turnId, role, text: transcript, final }];
});
final: false인 메시지는 낮은 opacity 등으로 표시해 아직 갱신될 수 있음을 알립니다. 애플리케이션 타입에서 isFinal로 매핑한다면 변환 지점을 명시합니다.
3. 마이크 권한 요청 타이밍
마이크 권한은 사용자가 Start 버튼을 누른 흐름에서 요청하는 편이 좋습니다. 페이지 로드 직후 자동 요청하면 사용 맥락이 부족해 거부 가능성이 커지고 브라우저 정책의 영향을 받을 수 있습니다.
// 버튼 클릭 핸들러 내부에서 요청asyncfunctionhandleStartCall() {
// 권한 요청이 createMicrophoneAudioTrack() 내부에서 자동 발생const audioTrack = awaitAgoraRTC.createMicrophoneAudioTrack({
AEC: true, // 에코 제거 (스피커 소리가 마이크로 다시 들어가는 것 방지)ANS: true, // 노이즈 억제 (키보드 소리, 주변 소음 제거)AGC: true, // 자동 게인 조절 (목소리가 너무 작거나 클 때 자동 조절)
});
}
// 페이지 로드 시 미리 요청하는 방식은 피함useEffect(() => {
navigator.mediaDevices.getUserMedia({ audio: true }); // 사용자 경험 저하
}, []);
4. 연결 중 UI 비활성화
에이전트가 연결되기 전에 설정을 변경하거나 메시지를 보내는 것을 막아야 합니다.
// 텍스트 입력: 연결된 상태에서만 노출
{isConnected && (
<divclassName="text-input-area"><inputplaceholder="메시지 입력..." /><button>전송</button></div>
)}
// System Prompt / 인사말 설정: 연결 중에는 비활성화
<button disabled={isConnected} onClick={openSettings}>
설정
</button>
5. 에러 처리 — 사용자 친화적 메시지
기술적 에러를 그대로 노출하지 말고 상황에 맞는 안내 메시지를 제공합니다.
try {
awaitjoinChannel(config);
} catch (error) {
// 원시 오류 문자열 대신 상황별 메시지로 변환if (error.code === "INVALID_TOKEN" || error.code === "TOKEN_EXPIRED") {
setError("연결 토큰이 만료되었습니다. 다시 시도해 주세요.");
} elseif (error.name === "NotAllowedError") {
setError("마이크 접근이 차단되어 있습니다. 브라우저 설정에서 허용해 주세요.");
} else {
setError("연결에 실패했습니다. 네트워크 상태를 확인해 주세요.");
}
setAgentState("disconnected");
}
6. 세션 자동 종료 처리 (idle_timeout)
에이전트는 일정 시간(idle_timeout) 동안 채널에 사용자가 없으면 자동 종료됩니다. 사용자에게 미리 알려주지 않으면 "갑자기 끊겼다"는 인상을 줍니다.
Join API의 최상위 properties.idle_timeout 단위는 초이며 기본값은 30초, 범위는 0~259200초입니다. remote_rtc_uids에 지정한 사용자가 모두 채널을 떠난 뒤부터 계산하고, 0은 유휴 종료를 끕니다. 이 값과 OpenAI Realtime의 mllm.turn_detection.*.idle_timeout_ms는 이름이 비슷하지만 목적과 단위가 다른 필드입니다. 단일 에이전트 세션은 idle_timeout과 무관하게 최대 72시간입니다.
// user-left 이벤트 = 에이전트가 채널에서 나갔음
rtcClient.on("user-left", (user) => {
if (String(user.uid) === AGENT_UID) {
setAgentState("disconnected");
showToast("대화가 종료되었습니다. (일정 시간 동안 대화가 없었습니다)");
}
});
timeout 시간은 start-agent 페이로드에서 조절:
idle_timeout: 300, // 5분으로 연장
7. 리소스 정리 (메모리 누수 방지)
컴포넌트 언마운트 또는 연결 종료 시 반드시 모든 리소스를 해제합니다.
asyncfunctionleaveChannel() {
// 1. 타이머 정리clearInterval(volumeIntervalRef.current);
clearTimeout(deferredAudioFallbackRef.current);
// 2. RTM 로그아웃await rtmClient.logout();
// 3. 마이크 트랙 닫기 (하드웨어 해제)
localAudioTrack.close(); // 이걸 빠뜨리면 탭을 닫을 때까지 마이크 표시등이 켜진 채로 남음// 4. RTC 채널 퇴장await rtcClient.leave();
// 5. 상태 초기화setMessages([]);
setIsConnected(false);
}
8. SDK 동적 import (SSR 안전)
Agora Web SDK는 브라우저 API를 사용하므로 Next.js에서는 해당 코드를 클라이언트 컴포넌트 경계 안에서 로드해야 합니다. 프로젝트의 번들러·SDK 버전에 따라 정적 import가 동작할 수도 있으므로, 무조건 서버 크래시가 난다고 단정하기보다 client-only 모듈 또는 동적 import로 실행 경계를 명시합니다.
// 한 가지 안전한 패턴: 사용자 동작 뒤 클라이언트에서 동적 importasyncfunctionjoinChannel() {
constAgoraRTC = (awaitimport("agora-rtc-sdk-ng")).default;
const { default: AgoraRTM } = awaitimport("agora-rtm-sdk");
// ...
}
관련 글
#2 Agora RTC 실시간 화상통화 구현 — ConvoAI의 음성 채널은 결국 RTC 위에서 동작한다. join/publish/subscribe 기초가 그대로 적용된다.
#3 RTM 실시간 채팅 시스템 구축 — 이 글의 "텍스트 전송 전용 RTM"을 제대로 이해하려면 RTM 로그인·publish 모델을 먼저 알아야 한다.