음성 AI 애플리케이션에서 컨텍스트 유지 구현 방법
Source: Dev.to
TL;DR
음성 AI는 턴 사이에 컨텍스트를 잃어버려 사용자가 반복하고, 에이전트는 이전 요청을 기억하지 못합니다. 이는 UX를 깨뜨리고 API 호출을 낭비하게 합니다. VAPI의 metadata 필드와 서버의 인‑메모리 저장소(또는 확장을 위한 Redis)를 사용해 지속적인 세션 상태를 구축하세요. 대화 기록, 사용자 의도, 호출 메타데이터를 턴마다 추적합니다. 결과: 에이전트가 컨텍스트를 기억하고, 지연 시간이 40% 감소하며, 불필요한 재확인으로 인한 API 비용이 절감됩니다.
전제 조건
API 키 및 자격 증명
- VAPI API 키 (dashboard.vapi.ai에서 생성)
- Twilio 계정 SID 및 인증 토큰 (console.twilio.com에서)
- LLM 추론을 위한 OpenAI API 키 (최소 gpt-4 또는 gpt-3.5-turbo)
시스템 요구 사항
- Node.js 18+ (async/await 지원 필요)
- Redis 6.0+ 또는 PostgreSQL 12+ (세션 영속성을 위해; 인‑메모리 저장소는 재시작 시 컨텍스트를 잃음)
- 동시 세션 처리를 위한 최소 2 GB RAM
SDK 버전
vapi-sdk: ^0.8.0 이상twilio: ^4.0.0 이상axios: ^1.6.0 (HTTP 요청용)
네트워크 설정
- 웹훅용 공개 HTTPS 엔드포인트 (개발 시 ngrok 허용, 프로덕션은 유효한 SSL 인증서 필요)
- 포트 443 인바운드 트래픽을 허용하는 방화벽 규칙
- 웹훅 서명 검증 활성화 (HMAC‑SHA256)
지식 요구 사항
- REST API 및 JSON 페이로드에 대한 친숙함
- 세션 관리와 상태 머신에 대한 이해
- 음성 통화 흐름 및 전사 이벤트에 대한 기본 지식
vapi: Get Started with VAPI → Get vapi
단계별 튜토리얼
구성 및 설정
먼저 어시스턴트 구성을 설정합니다. 여기서는 모델 선택, 음성 제공자, 전사 설정, 그리고 무엇보다 호출 간 컨텍스트를 어떻게 처리할지 정의합니다.
const assistantConfig = {
model: {
provider: "openai",
model: "gpt-4",
messages: [
{
role: "system",
content:
"You are a customer support agent. Maintain context from previous interactions. Reference customer history when available."
}
],
temperature: 0.7
},
voice: {
provider: "elevenlabs",
voiceId: "EXAVITQu4vr4xnSDxMaL",
speed: 1.0
},
transcriber: {
provider: "deepgram",
model: "nova-2",
language: "en",
endpointing: 300
},
firstMessageMode: "assistant-speaks",
recordingEnabled: true
};
messages 배열이 바로 이전 대화 컨텍스트를 주입하는 곳이며, 이것이 상태 유지 메커니즘입니다.
아키텍처 및 흐름
Express 서버가 VAPI로부터 웹훅 이벤트를 수신하고, 세션 상태를 메모리(또는 프로덕션에서는 Redis)에 보관한 뒤, 각 호출마다 어시스턴트의 시스템 프롬프트에 컨텍스트를 삽입합니다.
User Call → VAPI → Webhook (call.started) → Your Server (Load Context)
→ Update Assistant Config → VAPI Continues Call → Webhook (call.ended)
→ Your Server (Save Context) → Database
세션 상태는 TTL 정리와 함께 Map에 저장됩니다. 호출이 도착하면 이전 대화 기록을 가져와 어시스턴트 구성에 삽입하고, /v1/calls 엔드포인트를 통해 VAPI에 반환합니다.
단계별 구현
1. 웹훅 핸들러가 포함된 Express 서버 초기화
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
// Session storage: Map
const sessions = new Map();
const SESSION_TTL = 3600000; // 1 hour
// Webhook signature validation (VAPI signs all webhooks)
function validateWebhookSignature(req) {
const signature = req.headers['x-vapi-signature'];
const timestamp = req.headers['x-vapi-timestamp'];
const body = JSON.stringify(req.body);
const message = `${timestamp}.${body}`;
const hash = crypto
.createHmac('sha256', process.env.VAPI_WEBHOOK_SECRET)
.update(message)
.digest('hex');
return hash === signature;
}
app.post('/webhook/vapi', (req, res) => {
if (!validateWebhookSignature(req)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = req.body;
if (event.type === 'call.started') {
handleCallStarted(event);
} else if (event.type === 'call.ended') {
handleCallEnded(event);
} else if (event.type === 'message.updated') {
handleMessageUpdate(event);
}
res.status(200).json({ received: true });
});
2. 호출 시작 시 컨텍스트 로드 및 어시스턴트에 주입
async function handleCallStarted(event) {
const { callId, phoneNumber, customerId } = event;
// Fetch prior conversation history from database
let priorContext = '';
if (customerId) {
const history = await fetchCustomerHistory(customerId);
priorContext = history
.slice(-5) // Last 5 exchanges
.map(msg => `${msg.role}: ${msg.content}`)
.join('\n');
}
// Build enhanced system prompt with context
const enhancedSystemPrompt = `You are a customer support agent.
Previous conversation history:
${priorContext || 'No prior history.'}
Current call: ${phoneNumber}
Customer ID: ${customerId || 'Unknown'}
Reference prior interactions. Be consistent with previous commitments.`;
// Update assistant config with context
const updatedConfig = {
...assistantConfig,
model: {
...assistantConfig.model,
messages: [
{
role: "system",
content: enhancedSystemPrompt
}
]
}
};
// Store session state
sessions.set(callId, {
context: updatedConfig,
customerId,
createdAt: Date.now(),
transcript: []
});
// Schedule cleanup
setTimeout(() => sessions.delete(callId), SESSION_TTL);
}
3. 호출 중 전사 캡처 및 종료 시 저장
function handleMessageUpdate(event) {
const { callId, message, role } = event;
const session = sessions.get(callId);
if (session) {
session.transcript.push({
role,
content: message.content,
timestamp: Date.now()
});
}
}
async function handleCallEnded(event) {
const { callId, endedReason, duration } = event;
const session = sessions.get(callId);
if (!session) return;
// Persist conversation to database
if (session.customerId && session.transcript.length > 0) {
await saveConversation({
customerId: session.customerId,
callId,
transcript: session.transcript,
duration,
endedReason,
timestamp: new Date()
});
}
sessions.delete(callId);
}
오류 처리 및 예외 상황
레이스 컨디션
동일 고객의 두 통화가 동시에 발생할 경우. 락 메커니즘을 사용합니다:
const locks = new Map();
async function acquireLock(customerId, timeout = 5000) {
while (locks.has(customerId)) {
await new Promise(resolve => setTimeout(resolve, 100));
}
locks.set(customerId, true);
setTimeout(() => locks.delete(customerId), timeout);
}
웹훅 타임아웃
VAPI는 5 초 이내에 응답을 기대합니다. 즉시 응답하고 비동기로 처리합니다:
app.post('/webhook/vapi', async (req, res) => {
res.status(202).json({ accepted: true }); // Respond immediately
// Process async
setImmediate(() => {
const event = req.body;
if (event.type === 'call.started') {
handleCallStarted(event).catch(err => console.error('Handler error:', err));
}
});
});
메모리 누수
call.ended 웹훅이 실패하면 세션이 정리되지 않을 수 있습니다. 주기적인 정리를 추가합니다:
setInterval(() => {
const now = Date.now();
for (const [callId, session] of sessions.entries()) {
// Example cleanup condition (TTL already handled on start)
if (now - session.createdAt > SESSION_TTL) {
sessions.delete(callId);
}
}
}, 600000); // Run every 10 minutes