안녕하세요! 오늘은 로컬에서 돌릴 수 있는 오픈소스 LLM인 Ollama를 이용해서 AI 챗봇을 만드는 과정을 공유한다. 특히 FastAPI와 WebSocket을 활용해서 실시간 채팅이 가능한 HairAI 상담 챗봇을 구현했다.
AI 챗봇의 성능을 결정하는 가장 중요한 요소 중 하나는 시스템 프롬프트다. 아래는 HairAI 상담 챗봇에 사용한 시스템 프롬프트의 일부분이다:
SYSTEM_PROMPT = """당신은 친절하고 전문적인 HairAI 고객 상담 AI입니다.
당신의 역할은 HairAI 서비스에 관한 사용자의 질문에 정확하고 간결하게 답변하는 것입니다.
HairAI 서비스 정보:
- HairAI는 AI를 활용한 헤어스타일 추천 및 가상 체험 서비스입니다.
- 주요 기능: 얼굴 분석, 헤어스타일 추천, 헤어스타일 시뮬레이션, 인물 사진 배경 제거, 얼굴 변환 기능 제공
- 사용자는 자신의 사진을 업로드하여 다양한 헤어스타일을 가상으로 체험해볼 수 있습니다.
- 서비스는 웹과 모바일 앱 모두 지원합니다.
- 무료 회원가입 후 10번의 무료 사용권을 제공하며, 이후에는 유료 구독이 필요합니다.
응답 지침:
1. 항상 공손하고 친절한 어조를 유지하세요.
2. 사용자의 질문을 정확히 이해하고 직접적인 답변을 제공하세요.
3. 모르는 질문에는 솔직히 모른다고 답변하고, 추가 정보를 요청하세요.
4. 응답은 간결하게 유지하되, 필요한 정보는 모두 포함하세요.
5. 기술적인 질문에는 전문적이지만 이해하기 쉬운 설명을 제공하세요.
"""
이 프롬프트는 다음과 같은 요소로 구성된다:
좋은 시스템 프롬프트는 AI가 적절한 맥락에서 일관되게 응답하도록 도와주며, 서비스의 성격에 맞는 응답을 생성하는 데 결정적 역할을 한다.
이 파일은 Ollama API와 연동해서 AI 응답을 생성하는 핵심 모듈이다. 주요 기능은:
# Ollama API 엔드포인트
OLLAMA_API_URL = "http://localhost:11434/api/generate"
MODEL_NAME = "llama3:8b"
# 시스템 프롬프트
SYSTEM_PROMPT = """당신은 친절하고 전문적인 HairAI 고객 상담 AI입니다.
당신의 역할은 HairAI 서비스에 관한 사용자의 질문에 정확하고 간결하게 답변하는 것입니다.
...
"""
특히 눈여겨볼 부분은 WebSocket을 통한 실시간 스트리밍 응답 구현이다:
async def generate_ai_response(message: str, client_id: str, user_id: str, chat_id: str, db):
# 중략...
try:
# Ollama API 호출 (스트리밍 모드)
async with httpx.AsyncClient() as client:
response = await client.post(
OLLAMA_API_URL,
json={
"model": MODEL_NAME,
"prompt": prompt,
"stream": True,
},
timeout=60.0,
headers={"Content-Type": "application/json"}
)
# 응답 텍스트 초기화
full_response = ""
# 스트리밍 응답 처리
async for line in response.aiter_lines():
if not line.strip():
continue
try:
data = json.loads(line)
# 응답 토큰 처리
if "response" in data:
token = data["response"]
full_response += token
# 클라이언트에 토큰 전송
ai_message = {
"text": full_response,
"userId": user_id,
"userName": "AI 상담원",
"type": "ai",
"timestamp": datetime.now().isoformat(),
"tabType": "ai",
"chatId": chat_id
}
await manager.send_message(client_id, json.dumps(ai_message))
이렇게 하면 응답이 생성되는 대로 실시간으로 사용자에게 보여줄 수 있다.
FastAPI를 사용해 웹 서버를 구현한 메인 파일이다. WebSocket 엔드포인트를 추가해서 실시간 채팅을 구현했다:
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
try:
# 클라이언트 연결 수락
await websocket.accept()
logger.info("WebSocket 연결 수락됨")
# Firestore DB 인스턴스 얻기
db = firestore.client()
# 클라이언트에게 연결 성공 메시지 전송
await websocket.send_text(json.dumps({
"type": "connection_established",
"message": "서버에 성공적으로 연결되었습니다."
}))
# Aichat 모듈로 처리 위임
await Aichat.websocket_endpoint(websocket, db, already_accepted=True)
except WebSocketDisconnect:
logger.info("WebSocket 연결이 종료되었습니다")
except Exception as e:
logger.error(f"WebSocket 처리 중 오류 발생: {str(e)}")
React로 클라이언트 측 UI를 구현했는데, 특히 ServiceChat.js에서 WebSocket 연결을 관리한다:
// WebSocket 연결 설정
useEffect(() => {
if (user) { // 사용자가 로그인했을 때만 연결
let websocket;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const connectWebSocket = () => {
// 동적 WebSocket URL 사용
const wsUrl = getWebSocketUrl();
console.log(`WebSocket 연결 시도: ${wsUrl}`);
websocket = new WebSocket(wsUrl);
websocket.onopen = () => {
console.log('WebSocket Connected');
setNotification({
open: true,
message: '서버에 연결되었습니다.',
severity: 'success'
});
reconnectAttempts = 0;
// 사용자 정보 전송
const userType = {
userId: user.uid,
userName: user.displayName || '사용자',
userPhoto: user.photoURL,
type: 'user_connect',
tabType: activeTab === 0 ? 'ai' : 'personal'
};
websocket.send(JSON.stringify(userType));
};
그리고 메시지 처리와 AI 타이핑 상태를 표시하는 부분도 중요하다:
// WebSocket 메시지 수신 처리
websocket.onmessage = (event) => {
try {
console.log(`메시지 수신: ${event.data.slice(0, 100)}...`);
const data = JSON.parse(event.data);
// AI 타이핑 상태 처리
if (data.type === 'ai_typing') {
setIsAiTyping(true);
return;
}
// 일반 메시지 처리
setIsAiTyping(false);
// 스트리밍 응답 처리 개선
setMessages(prev => {
// 이미 동일한 chatId의 AI 메시지가 있는지 확인
const existingMsgIndex = prev.findIndex(msg =>
msg.chatId === data.chatId && msg.type === 'ai'
);
// 업데이트 로직...
});
} catch (error) {
console.error('Message parsing error:', error);
}
};
Ollama로 챗봇을 만들 때 모델 선택이 중요한데, 다음 사항을 고려했다:
Ollama를 사용하는 가장 큰 장점 중 하나는 바로 비용이다. OpenAI의 GPT 모델이나 Anthropic의 Claude와 같은 유료 API를 사용하면 토큰 기반 비용 구조로 인해 많은 대화를 처리할수록 비용이 증가한다. 하지만 Ollama를 사용하면:
일회성 비용만 발생: 초기 하드웨어 구축 외에는 추가 비용이 없다
사용량 제한 없음: API 호출 제한이나 토큰 제한 없이 무제한 사용 가능하다
고정 인프라 비용: 클라우드 API 비용이 사용량에 따라 불예측하게 증가하는 것과 달리, 비용이 일정하게 유지된다
오프라인 작동: 인터넷 연결 없이도 작동할 수 있어 네트워크 비용이 절감된다
유료 API를 사용했다면 사용자에 따라 월 수백만 원의 비용이 발생했을 것이다. Ollama를 사용함으로써 이러한 비용을 모두 절감할 수 있었다. 비용 절감 최고!
실제 비용 비교 예시:
특히 스타트업이나 소규모 프로젝트에서는 이런 비용 효율성이 결정적인 장점이 될거 같다.
Ollama를 활용한 실시간 챗봇 개발 과정에서 몇 가지 중요한 도전에 직면했다. 이 섹션에서는 문제들과 해결 방법을 공유한다.
문제: 초기에는 Ollama API 응답을 일괄로 처리했는데, 사용자 경험이 좋지 않았다. 응답이 길어질수록 사용자는 완료될 때까지 기다려야 했다.
해결책: AsyncIO와 스트리밍 응답 처리를 구현했다.
# 문제가 있던 초기 코드
async def generate_response_old(prompt):
async with httpx.AsyncClient() as client:
response = await client.post(
OLLAMA_API_URL,
json={"model": MODEL_NAME, "prompt": prompt},
timeout=60.0
)
data = response.json()
return data["response"] # 전체 응답을 한 번에 반환
# 개선된 스트리밍 코드
async def generate_response_streaming(prompt, websocket):
async with httpx.AsyncClient() as client:
response = await client.post(
OLLAMA_API_URL,
json={"model": MODEL_NAME, "prompt": prompt, "stream": True},
timeout=60.0
)
full_response = ""
async for line in response.aiter_lines():
if line.strip():
data = json.loads(line)
if "response" in data:
token = data["response"]
full_response += token
# 토큰 단위로 즉시 클라이언트에 전송
await websocket.send_text(json.dumps({
"text": full_response,
"type": "ai"
}))
이 변경으로 사용자는 AI가 생각하는 과정을 실시간으로 볼 수 있게 되었고, 체감 응답 시간이 크게 개선되었다.
문제: 대화가 길어질수록 컨텍스트 크기가 증가하여 메모리 사용량이 늘어나고 응답 속도가 느려졌다.
해결책: 효율적인 컨텍스트 관리 전략을 구현했다.
class ConversationManager:
def __init__(self, max_history=10):
self.max_history = max_history
self.conversations = {}
def add_message(self, user_id, role, content):
if user_id not in self.conversations:
self.conversations[user_id] = []
self.conversations[user_id].append({"role": role, "content": content})
# 컨텍스트 윈도우 관리: 최대 기록 수 유지
if len(self.conversations[user_id]) > self.max_history * 2: # 사용자와 AI 메시지 쌍
# 가장 오래된 메시지 쌍 제거 (사용자 + AI 응답)
self.conversations[user_id] = self.conversations[user_id][-self.max_history * 2:]
def get_conversation(self, user_id):
return self.conversations.get(user_id, [])
이 접근 방식으로 메모리 사용량을 제한하면서도 대화의 일관성을 유지할 수 있었다.
문제: 동시 접속자가 많을 때 Ollama 서버의 리소스 한계로 응답 시간이 길어지는 문제가 발생했다.
해결책: 간단한 큐 시스템을 구현하여 요청을 관리했다.
class RequestQueue:
def __init__(self, max_concurrent=5):
self.semaphore = asyncio.Semaphore(max_concurrent)
self.queue = asyncio.Queue()
self.worker_task = None
async def start_workers(self):
self.worker_task = asyncio.create_task(self._worker())
async def _worker(self):
while True:
request, future = await self.queue.get()
try:
async with self.semaphore:
# 실제 요청 처리
result = await self._process_request(request)
future.set_result(result)
except Exception as e:
future.set_exception(e)
finally:
self.queue.task_done()
async def _process_request(self, request):
# Ollama API 호출 로직
async with httpx.AsyncClient() as client:
response = await client.post(
OLLAMA_API_URL,
json=request,
timeout=60.0
)
return response
async def add_request(self, request):
future = asyncio.Future()
await self.queue.put((request, future))
return await future
이 큐 시스템을 통해 Ollama 서버의 과부하를 방지하고 모든 요청이 처리되도록 보장할 수 있었다.
메모리 제약이 있는 환경에서 더 큰 모델을 실행하기 위해 양자화 기법을 적용했다. 특히 GPTQ 양자화를 사용하여 큰 모델도 적은 메모리에서 실행할 수 있었다.
# Modelfile 예시 (양자화 적용)
FROM llama3:70b-q4_K_M
PARAMETER temperature 0.7
PARAMETER top_p 0.9
PARAMETER stop "</s>"
이렇게 구성된 양자화 모델은 약간의 품질 저하가 있었지만, 8GB GPU 메모리에서도 70B 모델을 실행할 수 있었고 응답 품질도 충분히 만족스러웠다.
Ollama와 FastAPI를 사용하면 클라우드 API에 의존하지 않고도 나만의 AI 챗봇을 쉽게 만들 수 있다. 특히 WebSocket을 활용한 실시간 응답은 사용자 경험을 크게 향상시킨다.
다음에는 모델 파인튜닝을 통해 더 특화된 챗봇을 만드는 방법을 소개할 예정이다. 질문이나 의견이 있으면 댓글로 남겨달라.
이 프로젝트의 전체 코드는 GitHub에서 확인할 수 있다: https://github.com/jaepalworld/choi-fastapi