블로그 목록
AI15분 읽기

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을 명시적으로 관리합니다.

Conversational AIIntegration

Conversational AI 통합 가이드

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에서 별도로 만듭니다.

AccessToken2(v007) 생성

// Agora 공식 AccessToken2(v007) 구현과 동일한 검증된 builder 사용
const userToken = await buildToken(
  channel,
  USER_UID,
  APP_ID,
  APP_CERTIFICATE,
  String(USER_UID),
);
const agentToken = await buildToken(
  channel,
  AGENT_UID,
  APP_ID,
  APP_CERTIFICATE,
  agentRtmUid,
);

하나의 토큰에 RTC 권한(채널 참여, 오디오/비디오 퍼블리시)과 RTM 권한(로그인)이 함께 포함됩니다. RTM으로 사용자 텍스트를 전송하려면 이 토큰이 RTC와 RTM 권한을 모두 담고 있어야 하며, 동시에 join 페이로드에 properties.advanced_features.enable_rtm: true가 설정되어야 RTM 텍스트 경로가 활성화됩니다(아래 ②, 4단계 참고).

/session-config는 { appId, channel, token: userToken, uid, agentRtmUid }를 반환합니다. App Certificate와 토큰 서명 코드는 서버에만 둡니다.

Conversational AI 요청 구성

const payload = {
  name: channel,
  properties: {
    channel,
    token: agentToken,
    agent_rtc_uid: "100",
    agent_rtm_uid: "100-{channel}",
    remote_rtc_uids: [String(USER_UID)],
    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" } },
    },
    advanced_features: { enable_rtm: true },
    parameters: { data_channel: "rtm" }, // 공식 Web transcript toolkit 경로
  },
};

// ConvoAI REST API 인증은 HTTP Basic 인증입니다.
// Customer ID와 Customer Secret을 콜론으로 결합 → Base64 인코딩 → "Basic <base64>"
const basicAuth = btoa(`${CUSTOMER_ID}:${CUSTOMER_SECRET}`);

const agoraResponse = await fetch(
  `https://api.agora.io/api/conversational-ai-agent/v2/projects/${APP_ID}/join`,
  {
    method: "POST",
    headers: {
      Authorization: `Basic ${basicAuth}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(payload),
  },
);

const { agent_id: agentId } = await agoraResponse.json();

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 오류 본문을 확인한 뒤 성공 응답을 만들어야 합니다.


2단계: 브라우저에서 RTC·Signaling 연결과 transcript 구독

import {
  ConversationalAIAPI,
  EConversationalAIAPIEvents,
  ETranscriptHelperMode,
} from "@/conversational-ai-api";

// 클라이언트 경계에서 SDK 로드
const AgoraRTC = (await import("agora-rtc-sdk-ng")).default;
const AgoraRTM = (await import("agora-rtm-sdk")).default;
const { RTM } = AgoraRTM;

const rtcClient = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });
const rtmClient = new RTM(appId, String(uid));

ConversationalAIAPI.init({
  rtcEngine: rtcClient,
  rtmEngine: rtmClient,
  renderMode: ETranscriptHelperMode.TEXT,
});
const conversationalAIAPI = ConversationalAIAPI.getInstance();

// 이벤트 리스너는 join() 전에 등록
rtcClient.on("user-published", async (user, mediaType) => {
  await rtcClient.subscribe(user, mediaType);
  if (mediaType === "audio") user.audioTrack?.play();
});

await rtmClient.login({ token });
await rtcClient.join(appId, channel, token, uid);

conversationalAIAPI.subscribeMessage(channel);
conversationalAIAPI.on(
  EConversationalAIAPIEvents.TRANSCRIPT_UPDATED,
  (messages) => setMessages(messages),
);

// 마이크 트랙 생성 및 퍼블리시
const audioTrack = await AgoraRTC.createMicrophoneAudioTrack({
  AEC: true,
  ANS: true,
  AGC: true,
});
await rtcClient.publish(audioTrack);

3단계: 구독 완료 후 에이전트 시작

현재 공식 Web 통합 가이드는 Conversational AI toolkit과 Signaling을 사용합니다. 2단계의 subscribeMessage가 끝난 뒤 /start-agent를 호출합니다. 에이전트 요청에는 advanced_features.enable_rtm: true와 parameters.data_channel: "rtm"이 필요합니다. 문서화되지 않은 msgId|partIdx|partSum|base64 청크 형식에 직접 의존하지 않습니다.

const startResponse = await fetch("/functions/v1/start-agent", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ channel }),
});
if (!startResponse.ok) throw new Error(await startResponse.text());
const { agentId } = await startResponse.json();

세션이 끝나면 unsubscribeMessage(channel)과 destroy()를 호출해 toolkit의 구독과 캐시를 정리합니다. 원시 이벤트 스키마가 필요하면 사용 중인 toolkit 소스의 타입 정의를 기준으로 처리합니다.


4단계: RTM 텍스트 메시지 전송

RTM은 사용자 텍스트를 에이전트에 보내고, 트랜스크립트와 에이전트 이벤트를 받는 데 사용합니다. 음성은 RTC 채널을 사용합니다.

전제 조건: 이 경로가 동작하려면 (1) join 페이로드에 advanced_features.enable_rtm: true가 있어야 하고, (2) rtm.login()에 넘기는 토큰이 RTM 권한을 포함해야 합니다(1단계 AccessToken2 생성 참고). 둘 중 하나라도 빠지면 메시지가 에이전트에 전달되지 않습니다.

const AgoraRTM = (await import("agora-rtm-sdk")).default;
const { RTM } = AgoraRTM;
const rtm = new 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
OpenAIopenai
ElevenLabselevenlabs
Cartesiacartesia

위 표는 예시일 뿐 기본 권장 순위가 아닙니다. 지원 vendor와 Beta 표시는 바뀔 수 있으므로 현재 Join API 스키마의 tts.vendor 목록과 언어·음성·샘플레이트 요구 사항을 확인하세요.


고급: 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 연결이 끝날 때까지 중복 조작 방지
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. 트랜스크립트 실시간 업데이트 (스트리밍 느낌 주기)

트랜스크립트는 중간 결과 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 버튼을 누른 흐름에서 요청하는 편이 좋습니다. 페이지 로드 직후 자동 요청하면 사용 맥락이 부족해 거부 가능성이 커지고 브라우저 정책의 영향을 받을 수 있습니다.

// 버튼 클릭 핸들러 내부에서 요청
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) {
  // 원시 오류 문자열 대신 상황별 메시지로 변환
  if (error.code === "INVALID_TOKEN" || error.code === "TOKEN_EXPIRED") {
    setError("연결 토큰이 만료되었습니다. 다시 시도해 주세요.");
  } else if (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. 리소스 정리 (메모리 누수 방지)

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

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 Web SDK는 브라우저 API를 사용하므로 Next.js에서는 해당 코드를 클라이언트 컴포넌트 경계 안에서 로드해야 합니다. 프로젝트의 번들러·SDK 버전에 따라 정적 import가 동작할 수도 있으므로, 무조건 서버 크래시가 난다고 단정하기보다 client-only 모듈 또는 동적 import로 실행 경계를 명시합니다.

// 한 가지 안전한 패턴: 사용자 동작 뒤 클라이언트에서 동적 import
async function joinChannel() {
  const AgoraRTC = (await import("agora-rtc-sdk-ng")).default;
  const { default: AgoraRTM } = await import("agora-rtm-sdk");
  // ...
}

관련 글


참고 자료

© 2026 Frank Kim. All rights reserved.