Conversational AI 통합 가이드
Agora Conversational AI를 사용하면 실시간 음성 AI 에이전트를 빠르게 구축할 수 있습니다. 사용자가 말하면 LLM이 응답하고, TTS가 음성으로 변환해 Agora RTC를 통해 실시간으로 전달됩니다.
아키텍처 개요
Browser (React + Agora RTC/RTM SDK)
│
├── [1] POST /start-agent
│ └── Supabase Edge Function
│ └── Agora ConvoAI API (/v2/projects/{appId}/join)
│
├── [2] Agora RTC 채널 (음성 + 트랜스크립트)
│ ├── UID 100 — AI 에이전트
│ └── UID 101 — 사용자
│
└── [3] Agora RTM (텍스트 메시지 전송 전용)
전체 코드 보기 복사
핵심 포인트: LLM API Key / TTS API Key는 서버(Edge Function)에서만 사용 됩니다. 브라우저에는 Agora App ID와 임시 토큰만 전달됩니다.
1단계: 에이전트 시작 (Edge Function)
POST /functions/v1/start-agent를 호출하면 Edge Function이 다음을 수행합니다.
① v007 토큰 생성
const userToken = await buildToken (channel, USER_UID , APP_ID , APP_CERTIFICATE );
const agentToken = await buildToken (
channel,
AGENT_UID ,
APP_ID ,
APP_CERTIFICATE ,
agentRtmUid,
);
전체 코드 보기 복사
하나의 토큰에 RTC 권한(채널 참여, 오디오/비디오 퍼블리시)과 RTM 권한(로그인)이 함께 포함됩니다.
② Agora ConvoAI API 호출
const payload = {
name : channel,
properties : {
channel,
token : agentToken,
agent_rtc_uid : "100" ,
agent_rtm_uid : "100-{channel}" ,
llm : {
url : "https://api.openai.com/v1/chat/completions" ,
api_key : LLM_API_KEY ,
system_messages : [{ role : "system" , content : prompt }],
greeting_message : greeting,
max_history : 32 ,
params : { model : "gpt-4o-mini" },
},
asr : { vendor : "ares" , language : "en-US" },
tts : buildTtsConfig (TTS_VENDOR , TTS_KEY , TTS_VOICE_ID ),
turn_detection : {
config : { end_of_speech : { mode : "semantic" } },
},
},
};
await fetch (
`https://api.agora.io/api/conversational-ai-agent/v2/projects/${APP_ID} /join` ,
{
method : "POST" ,
headers : { Authorization : `agora token=${authToken} ` },
body : JSON .stringify (payload),
},
);
전체 코드 보기 복사
Edge Function은 { appId, channel, token, uid, agentRtmUid, agentId }를 브라우저에 반환합니다.
2단계: 브라우저에서 RTC 채널 참여
const AgoraRTC = (await import ("agora-rtc-sdk-ng" )).default ;
const rtcClient = AgoraRTC .createClient ({ mode : "rtc" , codec : "vp8" });
rtcClient.on ("user-published" , async (user, mediaType) => {
await rtcClient.subscribe (user, mediaType);
if (mediaType === "audio" ) user.audioTrack ?.play ();
});
rtcClient.on ("stream-message" , (_uid, data ) => {
handleTranscript (data);
});
await rtcClient.join (appId, channel, token, uid);
const audioTrack = await AgoraRTC .createMicrophoneAudioTrack ({
AEC : true ,
ANS : true ,
AGC : true ,
});
await rtcClient.publish (audioTrack);
전체 코드 보기 복사
3단계: 트랜스크립트 처리 (핵심)
트랜스크립트는 RTM이 아닌 RTC stream-message로 수신됩니다. 프로토콜 v2는 base64 청크 방식을 사용합니다.
rtcClient.on ("stream-message" , (_uid, data ) => {
const raw = new TextDecoder ().decode (data);
const parts = raw.split ("|" );
if (parts.length === 4 ) {
const [msgId, partIdxStr, partSumStr, partData] = parts;
const partIdx = parseInt (partIdxStr, 10 );
const partSum = partSumStr === "???" ? -1 : parseInt (partSumStr, 10 );
if (!cache.has (msgId)) cache.set (msgId, []);
const chunks = cache.get (msgId)!;
chunks.push ({ part_idx : partIdx, content : partData });
chunks.sort ((a, b ) => a.part_idx - b.part_idx );
if (partSum !== -1 && chunks.length === partSum) {
const base64 = chunks.map (c => c.content ).join ("" );
const msg = JSON .parse (atob (base64));
cache.delete (msgId);
}
}
});
전체 코드 보기 복사
주의 : JSON.parse(raw) 직접 파싱은 동작하지 않습니다. 반드시 |로 분리 → 청크 누적 → atob() → JSON.parse() 순서로 처리해야 합니다.
4단계: RTM 텍스트 메시지 전송
RTM은 텍스트 전송 전용 입니다. 수신은 RTC stream-message를 통해 에이전트가 에코해줍니다.
const AgoraRTM = await import ("agora-rtm" );
const rtm = new AgoraRTM .default .RTM (appId, String (uid));
await rtm.login ({ token });
await rtm.publish (
agentRtmUid,
JSON .stringify ({
message : text,
priority : "APPEND" ,
}),
{
customType : "user.transcription" ,
channelType : "USER" ,
},
);
전체 코드 보기 복사
주의 : 타겟은 채널명이 아닌 agentRtmUid (예: "100-CHANNELID")입니다.
TTS 제공업체 선택
제공업체 vendor 값 특징 Rime rime낮은 레이턴시, 자연스러운 음성 (기본 권장) OpenAI openaiGPT 계열과 친숙, PCM 포맷 ElevenLabs elevenlabs고품질 음성 복제 Cartesia cartesia빠른 응답, Sonic 모델
고급: RAG 연동
사내 지식베이스를 활용하려면 커스텀 LLM 엔드포인트를 구성합니다.
llm : {
url : "https://your-server.com/rag/chat/completions" ,
api_key : "" ,
system_messages : [{ role : "system" , content : "검색된 정보를 기반으로 답변하세요." }],
}
전체 코드 보기 복사
커스텀 서버는 OpenAI Chat Completions 인터페이스와 호환되어야 하며, SSE(Server-Sent Events) 스트리밍을 지원해야 합니다.
app.post ("/rag/chat/completions" , async (req, res) => {
res.setHeader ("Content-Type" , "text/event-stream" );
res.write (`data: ${JSON .stringify({ choices: [{ delta: { content: "잠시만요..." } }] })} \n\n` );
const context = await retrieveFromKnowledgeBase (req.body .messages );
const completion = await openai.chat .completions .create ({ stream : true , ... });
for await (const chunk of completion) {
res.write (`data: ${JSON .stringify(chunk)} \n\n` );
}
res.write ("data: [DONE]\n\n" );
});
전체 코드 보기 복사
에이전트 종료
await fetch (`/functions/v1/hangup-agent` , {
method : "POST" ,
body : JSON .stringify ({ agentId }),
});
await rtmClient.logout ();
localAudioTrack.close ();
await rtcClient.leave ();
전체 코드 보기 복사
로컬 개발 환경
Supabase Edge Function(Deno)을 로컬에서 실행하기 어려울 때, 동일한 엔드포인트를 모방하는 Node.js 테스트 서버를 사용합니다.
APP_ID=xxx APP_CERTIFICATE=xxx LLM_API_KEY=xxx \
TTS_VENDOR=rime TTS_KEY=xxx TTS_VOICE_ID=astra \
node test-server.mjs
전체 코드 보기 복사
.env 파일에서 VITE_SUPABASE_URL=http://localhost:3002로 설정하면 로컬 테스트 서버를 가리킵니다.
정리
컴포넌트 역할 Agora RTC 양방향 오디오 + 트랜스크립트 수신 Agora RTM 텍스트 메시지 전송 (단방향) Supabase Edge Function 토큰 생성 + 에이전트 라이프사이클 관리 Agora ConvoAI API LLM/TTS/ASR 통합 AI 에이전트 실행
데모: agora-convo-ai-web-demo-korea.vercel.app
Best Practices
1. 에이전트 상태를 UI에 명확히 반영하기
음성 AI는 사용자가 "지금 듣고 있나? 처리 중인가? 말하는 중인가?"를 직관적으로 알아야 합니다. 에이전트 상태를 5단계로 구분해 각 상태마다 시각적 피드백을 다르게 줍니다.
not-joined → joining → listening → talking → disconnected
(기본) (연결 중) (대기 중) (응답 중) (종료)
전체 코드 보기 복사
상태 UI 표현 이유 joining스피너 / 테두리 회전 API 호출 + RTC 연결에 1~3초 소요 — 사용자가 기다릴 수 있게 listening잔잔한 펄스 애니메이션 "말해도 됩니다" 신호 talking강한 글로우 + 링 확장 에이전트가 말 중임을 명확히 disconnected흐릿하게 dim 처리 세션 종료 상태를 명시
const volumeInterval = setInterval (() => {
const volume = remoteAudioTrack.getVolumeLevel ();
if (volume > 0 ) setAgentState ("talking" );
else setAgentState ("listening" );
}, 100 );
return () => clearInterval (volumeInterval);
전체 코드 보기 복사
2. 트랜스크립트 실시간 업데이트 (스트리밍 느낌 주기)
LLM 응답은 한 번에 오지 않고 isFinal: false → isFinal: true 순서로 옵니다. 중간 상태를 표시하면 사용자는 AI가 "생각하고 있다"는 느낌을 받습니다.
setMessages (prev => {
const existing = prev.find (m => m.id === turnId);
if (existing) {
return prev.map (m =>
m.id === turnId ? { ...m, text : transcript, isFinal } : m
);
}
return [...prev, { id : turnId, role, text : transcript, isFinal }];
});
전체 코드 보기 복사
isFinal: false인 메시지는 바운싱 점(...)과 낮은 opacity로 표시해 "아직 말하는 중"임을 나타냅니다.
3. 마이크 권한 요청 타이밍
마이크 권한은 사용자가 직접 Start 버튼을 누른 직후 요청해야 합니다. 페이지 로드 시 자동 요청하면 브라우저가 차단하거나 사용자가 거부할 가능성이 높습니다.
async function handleStartCall ( ) {
const audioTrack = await AgoraRTC .createMicrophoneAudioTrack ({
AEC : true ,
ANS : true ,
AGC : true ,
});
}
useEffect (() => {
navigator.mediaDevices .getUserMedia ({ audio : true });
}, []);
전체 코드 보기 복사
4. 연결 중 UI 비활성화
에이전트가 연결되기 전에 설정을 변경하거나 메시지를 보내는 것을 막아야 합니다.
{isConnected && (
<div className ="text-input-area" >
<input placeholder ="메시지 입력..." />
<button > 전송</button >
</div >
)}
<button disabled={isConnected} onClick={openSettings}>
설정
</button>
전체 코드 보기 복사
5. 에러 처리 — 사용자 친화적 메시지
기술적 에러를 그대로 노출하지 말고 상황에 맞는 안내 메시지를 제공합니다.
try {
await joinChannel (config);
} catch (error) {
if (error.code === 104 ) {
setError ("연결 토큰이 만료되었습니다. 다시 시도해 주세요." );
} else if (error.name === "NotAllowedError" ) {
setError ("마이크 접근이 차단되어 있습니다. 브라우저 설정에서 허용해 주세요." );
} else {
setError ("연결에 실패했습니다. 네트워크 상태를 확인해 주세요." );
}
setAgentState ("disconnected" );
}
전체 코드 보기 복사
6. 세션 자동 종료 처리 (idle_timeout)
에이전트는 기본 120초 동안 아무 입력이 없으면 자동 종료됩니다. 사용자에게 미리 알려주지 않으면 "갑자기 끊겼다"는 인상을 줍니다.
rtcClient.on ("user-left" , (user ) => {
if (String (user.uid ) === AGENT_UID ) {
setAgentState ("disconnected" );
showToast ("대화가 종료되었습니다. (2분 동안 대화가 없었습니다)" );
}
});
전체 코드 보기 복사
timeout 시간은 start-agent 페이로드에서 조절:
7. 리소스 정리 (메모리 누수 방지)
컴포넌트 언마운트 또는 연결 종료 시 반드시 모든 리소스를 해제합니다.
async function leaveChannel ( ) {
clearInterval (volumeIntervalRef.current );
clearTimeout (deferredAudioFallbackRef.current );
await rtmClient.logout ();
localAudioTrack.close ();
await rtcClient.leave ();
setMessages ([]);
setIsConnected (false );
}
전체 코드 보기 복사
8. SDK 동적 import (SSR 안전)
Agora SDK는 window, navigator 등 브라우저 API에 의존합니다. Next.js 같은 SSR 환경에서 파일 상단에 import하면 서버에서 크래시가 발생합니다.
import AgoraRTC from "agora-rtc-sdk-ng" ;
async function joinChannel ( ) {
const AgoraRTC = (await import ("agora-rtc-sdk-ng" )).default ;
const { default : AgoraRTM } = await import ("agora-rtm" );
}
전체 코드 보기 복사