블로그 목록
AI15분 읽기

Conversational AI 통합 가이드

Conversational AI를 활용하여 실시간 AI 에이전트를 구축하고 Agora와 통합하는 전체 프로세스를 설명합니다.

Conversational AIIntegration

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 토큰 생성

// npm 패키지 없이 HMAC-SHA256 + deflate로 직접 생성
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 채널 참여

// Agora SDK는 브라우저 API 필요 → 반드시 동적 import
const AgoraRTC = (await import("agora-rtc-sdk-ng")).default;

const rtcClient = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });

// ⚠️ 이벤트 리스너는 join() 전에 등록해야 합니다
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) {
    // v2 포맷: messageId|partIdx|partSum|base64data
    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));
      // msg.object, msg.text, msg.turn_id 포함
      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 값특징
Rimerime낮은 레이턴시, 자연스러운 음성 (기본 권장)
OpenAIopenaiGPT 계열과 친숙, PCM 포맷
ElevenLabselevenlabs고품질 음성 복제
Cartesiacartesia빠른 응답, Sonic 모델

고급: RAG 연동

사내 지식베이스를 활용하려면 커스텀 LLM 엔드포인트를 구성합니다.

// start-agent payload에서 llm.url을 커스텀 서버로 변경
llm: {
  url: "https://your-server.com/rag/chat/completions",
  api_key: "",
  system_messages: [{ role: "system", content: "검색된 정보를 기반으로 답변하세요." }],
}

커스텀 서버는 OpenAI Chat Completions 인터페이스와 호환되어야 하며, SSE(Server-Sent Events) 스트리밍을 지원해야 합니다.

// RAG 서버 예시 흐름
app.post("/rag/chat/completions", async (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");

  // 1. 대기 메시지 전송 (UX 개선)
  res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: "잠시만요..." } }] })}\n\n`);

  // 2. 지식베이스에서 컨텍스트 검색
  const context = await retrieveFromKnowledgeBase(req.body.messages);

  // 3. 컨텍스트 포함해서 LLM 호출 후 스트리밍
  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");
});

에이전트 종료

// 1. Agora API로 에이전트 종료
await fetch(`/functions/v1/hangup-agent`, {
  method: "POST",
  body: JSON.stringify({ agentId }),
});

// 2. 브라우저에서 채널 퇴장
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 APILLM/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 처리세션 종료 상태를 명시
// 볼륨을 100ms 간격으로 폴링해 talking 상태 감지
const volumeInterval = setInterval(() => {
  const volume = remoteAudioTrack.getVolumeLevel();
  if (volume > 0) setAgentState("talking");
  else setAgentState("listening");
}, 100);

// ⚠️ 반드시 정리: 메모리 누수 방지
return () => clearInterval(volumeInterval);

2. 트랜스크립트 실시간 업데이트 (스트리밍 느낌 주기)

LLM 응답은 한 번에 오지 않고 isFinal: falseisFinal: true 순서로 옵니다. 중간 상태를 표시하면 사용자는 AI가 "생각하고 있다"는 느낌을 받습니다.

// 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, isFinal } : m
    );
  }
  return [...prev, { id: turnId, role, text: transcript, isFinal }];
});

isFinal: false인 메시지는 바운싱 점(...)과 낮은 opacity로 표시해 "아직 말하는 중"임을 나타냅니다.


3. 마이크 권한 요청 타이밍

마이크 권한은 사용자가 직접 Start 버튼을 누른 직후 요청해야 합니다. 페이지 로드 시 자동 요청하면 브라우저가 차단하거나 사용자가 거부할 가능성이 높습니다.

// ✅ 올바른 방식: 버튼 클릭 핸들러 내부에서 요청
async function handleStartCall() {
  // 권한 요청이 createMicrophoneAudioTrack() 내부에서 자동 발생
  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>
)}

// System Prompt / 인사말 설정: 연결 중에는 비활성화
<button disabled={isConnected} onClick={openSettings}>
  설정
</button>

5. 에러 처리 — 사용자 친화적 메시지

기술적 에러를 그대로 노출하지 말고 상황에 맞는 안내 메시지를 제공합니다.

try {
  await joinChannel(config);
} catch (error) {
  // ❌ "Error: AGORA_INVALID_TOKEN 104"
  // ✅ 상황별 메시지로 변환
  if (error.code === 104) {
    setError("연결 토큰이 만료되었습니다. 다시 시도해 주세요.");
  } else if (error.name === "NotAllowedError") {
    setError("마이크 접근이 차단되어 있습니다. 브라우저 설정에서 허용해 주세요.");
  } else {
    setError("연결에 실패했습니다. 네트워크 상태를 확인해 주세요.");
  }
  setAgentState("disconnected");
}

6. 세션 자동 종료 처리 (idle_timeout)

에이전트는 기본 120초 동안 아무 입력이 없으면 자동 종료됩니다. 사용자에게 미리 알려주지 않으면 "갑자기 끊겼다"는 인상을 줍니다.

// user-left 이벤트 = 에이전트가 채널에서 나갔음
rtcClient.on("user-left", (user) => {
  if (String(user.uid) === AGENT_UID) {
    setAgentState("disconnected");
    showToast("대화가 종료되었습니다. (2분 동안 대화가 없었습니다)");
  }
});

timeout 시간은 start-agent 페이로드에서 조절:

idle_timeout: 300, // 5분으로 연장

7. 리소스 정리 (메모리 누수 방지)

컴포넌트 언마운트 또는 연결 종료 시 반드시 모든 리소스를 해제합니다.

async function leaveChannel() {
  // 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 SDK는 window, navigator 등 브라우저 API에 의존합니다. Next.js 같은 SSR 환경에서 파일 상단에 import하면 서버에서 크래시가 발생합니다.

// ❌ 파일 상단 정적 import — SSR에서 크래시
import AgoraRTC from "agora-rtc-sdk-ng";

// ✅ async 함수 내부에서 동적 import
async function joinChannel() {
  const AgoraRTC = (await import("agora-rtc-sdk-ng")).default;
  const { default: AgoraRTM } = await import("agora-rtm");
  // ...
}

© 2026 Frank Kim. All rights reserved.