챗봇 개발 프로젝트
LLM API를 활용하여 시스템 프롬프트 설계부터 Function Calling, 대화 히스토리 관리, 안전장치 적용, Gradio ChatInterface 배포까지 완전한 챗봇을 구축하는 프로젝트입니다. 이 프로젝트는 프롬프트 엔지니어링과 LLM 활용 실무 섹션의 내용을 종합적으로 적용합니다.사전 준비
Copy
pip install openai anthropic gradio python-dotenv
Copy
# .env 파일
OPENAI_API_KEY=sk-...
실습
시스템 프롬프트 설계
챗봇의 성격, 역할, 행동 규칙을 정의하는 시스템 프롬프트를 설계합니다.
Copy
# 시스템 프롬프트 설계 - NLP 학습 어시스턴트
SYSTEM_PROMPT = """당신은 "NLP 학습 도우미"입니다. 자연어 처리(NLP)를 학습하는 사람들을 돕는 AI 어시스턴트입니다.
## 역할과 성격
- NLP/딥러닝 분야의 친절한 멘토입니다
- 복잡한 개념을 쉬운 비유로 설명합니다
- 질문자의 수준에 맞춰 답변 깊이를 조절합니다
## 행동 규칙
1. 한국어로 답변하되, 영어 기술 용어는 그대로 사용합니다
2. 코드 예시가 필요하면 Python으로 작성합니다
3. 확실하지 않은 정보는 "확실하지 않습니다"라고 솔직하게 밝힙니다
4. 답변 끝에 관련 추가 질문을 제안합니다
## 제한 사항
- NLP/AI/딥러닝 관련 질문에만 답변합니다
- 의료, 법률, 금융 조언은 하지 않습니다
- 개인정보를 요구하거나 저장하지 않습니다
## 도구
현재 시간 조회와 NLP 용어 검색 도구를 사용할 수 있습니다.
"""
# 프롬프트 품질 체크리스트
checklist = {
"역할 정의": "NLP 학습 도우미" in SYSTEM_PROMPT,
"행동 규칙": "행동 규칙" in SYSTEM_PROMPT,
"제한 사항": "제한 사항" in SYSTEM_PROMPT,
"출력 형식": "한국어" in SYSTEM_PROMPT,
}
print("프롬프트 품질 체크:")
for item, passed in checklist.items():
print(f" {'[O]' if passed else '[X]'} {item}")
좋은 시스템 프롬프트의 구조: 역할 정의 → 행동 규칙 → 제한 사항 → 출력 형식. 이 네 가지 요소를 빠짐없이 포함하면 일관된 챗봇 동작을 기대할 수 있습니다.
OpenAI/Claude API 연동
여러 LLM 제공자를 유연하게 전환할 수 있는 통합 인터페이스를 구현합니다.
Copy
from openai import OpenAI
from typing import Generator
import os
from dotenv import load_dotenv
load_dotenv()
class LLMClient:
"""LLM API 통합 클라이언트"""
def __init__(self, provider: str = "openai"):
self.provider = provider
if provider == "openai":
self.client = OpenAI()
self.model = "gpt-4o-mini"
else:
raise ValueError(f"지원하지 않는 제공자: {provider}")
def chat(
self,
messages: list,
tools: list = None,
stream: bool = False,
):
"""메시지를 전송하고 응답을 받습니다."""
kwargs = {
"model": self.model,
"messages": messages,
"temperature": 0.7,
"stream": stream,
}
if tools:
kwargs["tools"] = tools
kwargs["tool_choice"] = "auto"
return self.client.chat.completions.create(**kwargs)
def stream_chat(self, messages: list) -> Generator[str, None, None]:
"""스트리밍으로 응답을 생성합니다."""
stream = self.chat(messages, stream=True)
for chunk in stream:
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
# 클라이언트 생성
llm = LLMClient(provider="openai")
# 기본 테스트
response = llm.chat([
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": "안녕하세요! Transformer가 뭔가요?"},
])
print(response.choices[0].message.content)
대화 히스토리 관리
대화 맥락을 유지하면서 토큰 사용량을 효율적으로 관리합니다.
Copy
import tiktoken
from typing import Optional
from dataclasses import dataclass, field
@dataclass
class ConversationManager:
"""대화 히스토리를 관리합니다."""
system_prompt: str
max_tokens: int = 4000 # 히스토리 최대 토큰 수
messages: list = field(default_factory=list)
def __post_init__(self):
self.encoding = tiktoken.encoding_for_model("gpt-4o-mini")
self.messages = [
{"role": "system", "content": self.system_prompt}
]
def _count_tokens(self, messages: list) -> int:
"""메시지 목록의 토큰 수를 계산합니다."""
total = 0
for msg in messages:
total += 4 # 메시지 구조 오버헤드
total += len(self.encoding.encode(msg.get("content", "")))
return total
def add_user_message(self, content: str):
"""사용자 메시지를 추가합니다."""
self.messages.append({"role": "user", "content": content})
self._trim_if_needed()
def add_assistant_message(self, content: str):
"""어시스턴트 메시지를 추가합니다."""
self.messages.append({"role": "assistant", "content": content})
def _trim_if_needed(self):
"""토큰 제한을 초과하면 오래된 메시지를 제거합니다."""
while self._count_tokens(self.messages) > self.max_tokens:
if len(self.messages) <= 2: # system + 최소 1개 메시지
break
# 가장 오래된 대화 쌍을 제거 (시스템 메시지 다음부터)
self.messages.pop(1)
def get_messages(self) -> list:
"""현재 대화 히스토리를 반환합니다."""
return self.messages.copy()
def clear(self):
"""대화를 초기화합니다."""
self.messages = [
{"role": "system", "content": self.system_prompt}
]
@property
def token_count(self) -> int:
return self._count_tokens(self.messages)
# 사용 예시
conversation = ConversationManager(system_prompt=SYSTEM_PROMPT)
# 대화 진행
conversation.add_user_message("BERT가 뭔가요?")
print(f"현재 토큰 수: {conversation.token_count}")
Function Calling으로 기능 확장
챗봇에 외부 도구를 연결하여 기능을 확장합니다.
Copy
import json
from datetime import datetime
# 도구 함수 구현
def get_current_time() -> str:
"""현재 시간을 반환합니다."""
now = datetime.now()
return now.strftime("%Y년 %m월 %d일 %H시 %M분")
def search_nlp_glossary(term: str) -> dict:
"""NLP 용어를 검색합니다."""
glossary = {
"transformer": {
"korean": "트랜스포머",
"definition": "Self-Attention 메커니즘 기반의 시퀀스 처리 아키텍처",
"paper": "Attention Is All You Need (2017)",
"key_concept": "Query-Key-Value 기반 Attention",
},
"bert": {
"korean": "버트",
"definition": "양방향 인코더 사전학습 모델 (Bidirectional Encoder Representations from Transformers)",
"paper": "BERT: Pre-training of Deep Bidirectional Transformers (2018)",
"key_concept": "Masked Language Model + Next Sentence Prediction",
},
"tokenization": {
"korean": "토큰화",
"definition": "텍스트를 모델이 처리할 수 있는 최소 단위로 분할하는 과정",
"methods": "BPE, WordPiece, SentencePiece, Unigram",
"key_concept": "서브워드 토큰화로 OOV 문제 해결",
},
"attention": {
"korean": "어텐션",
"definition": "시퀀스 내 중요한 정보에 선택적으로 가중치를 부여하는 메커니즘",
"types": "Bahdanau, Luong, Self-Attention, Multi-Head",
"key_concept": "Query와 Key의 유사도로 Value에 가중 합산",
},
}
term_lower = term.lower().replace(" ", "")
result = glossary.get(term_lower)
if result:
return {"found": True, "term": term, **result}
else:
return {"found": False, "term": term, "message": f"'{term}'에 대한 정보를 찾을 수 없습니다."}
# 도구 정의
chatbot_tools = [
{
"type": "function",
"function": {
"name": "get_current_time",
"description": "현재 날짜와 시간을 조회합니다.",
"parameters": {"type": "object", "properties": {}, "required": []},
}
},
{
"type": "function",
"function": {
"name": "search_nlp_glossary",
"description": "NLP 용어의 정의, 관련 논문, 핵심 개념을 검색합니다.",
"parameters": {
"type": "object",
"properties": {
"term": {
"type": "string",
"description": "검색할 NLP 용어 (예: transformer, bert, attention)"
}
},
"required": ["term"],
}
}
}
]
# 도구 매핑
tool_functions = {
"get_current_time": get_current_time,
"search_nlp_glossary": search_nlp_glossary,
}
def process_tool_calls(response, messages: list) -> list:
"""도구 호출을 처리하고 결과를 메시지에 추가합니다."""
assistant_message = response.choices[0].message
if not assistant_message.tool_calls:
return messages
messages.append(assistant_message)
for tool_call in assistant_message.tool_calls:
func_name = tool_call.function.name
func_args = json.loads(tool_call.function.arguments)
# 도구 실행
try:
result = tool_functions[func_name](**func_args)
except Exception as e:
result = {"error": str(e)}
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result, ensure_ascii=False),
})
return messages
Gradio ChatInterface UI
대화형 UI를 Gradio ChatInterface로 구현합니다.
Copy
import gradio as gr
import json
from openai import OpenAI
client = OpenAI()
def chat_with_bot(message: str, history: list) -> str:
"""Gradio ChatInterface용 챗봇 응답 함수"""
# 대화 히스토리 구성
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
# Gradio history 형식 변환
for user_msg, bot_msg in history:
messages.append({"role": "user", "content": user_msg})
if bot_msg:
messages.append({"role": "assistant", "content": bot_msg})
messages.append({"role": "user", "content": message})
# 1차 호출 (도구 포함)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=chatbot_tools,
tool_choice="auto",
temperature=0.7,
)
# 도구 호출 처리
assistant_message = response.choices[0].message
if assistant_message.tool_calls:
messages = process_tool_calls(response, messages)
# 도구 결과를 포함하여 최종 응답
final_response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
temperature=0.7,
)
return final_response.choices[0].message.content
else:
return assistant_message.content
def chat_with_bot_streaming(message: str, history: list):
"""스트리밍 버전의 챗봇 응답 함수"""
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
for user_msg, bot_msg in history:
messages.append({"role": "user", "content": user_msg})
if bot_msg:
messages.append({"role": "assistant", "content": bot_msg})
messages.append({"role": "user", "content": message})
# 스트리밍 호출
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
temperature=0.7,
stream=True,
)
partial_response = ""
for chunk in stream:
if chunk.choices[0].delta.content:
partial_response += chunk.choices[0].delta.content
yield partial_response
# Gradio ChatInterface
demo = gr.ChatInterface(
fn=chat_with_bot_streaming,
title="NLP 학습 도우미",
description=(
"NLP/딥러닝 학습을 도와주는 AI 어시스턴트입니다. "
"Transformer, BERT, 토큰화 등 NLP 관련 질문을 해보세요!"
),
examples=[
"Transformer의 Self-Attention이 뭔가요?",
"BERT와 GPT의 차이점을 알려주세요",
"한국어 NLP에서 형태소 분석이 왜 중요한가요?",
"지금 몇 시인가요?",
],
theme=gr.themes.Soft(),
retry_btn="다시 생성",
undo_btn="이전으로",
clear_btn="대화 초기화",
)
demo.launch(share=False)
스트리밍 모드에서는 Function Calling이 자동으로 처리되지 않습니다. 스트리밍과 도구 호출을 모두 지원하려면, 도구 호출이 필요한 경우 먼저 비스트리밍으로 도구를 처리한 뒤, 최종 응답만 스트리밍하는 하이브리드 방식을 사용하세요.
안전장치 적용
이전 섹션에서 학습한 안전장치를 챗봇에 통합합니다.
Copy
import re
class ChatbotGuardrails:
"""챗봇 전용 안전장치"""
# 주제 이탈 키워드 (NLP/AI와 무관한 주제)
OFF_TOPIC_PATTERNS = [
r"(?i)(주식|투자|비트코인|부동산)",
r"(?i)(처방|진단|약|병원|치료)",
r"(?i)(법률|소송|재판|변호사)",
]
# 프롬프트 인젝션 패턴
INJECTION_PATTERNS = [
r"(?i)ignore\s+(all\s+)?previous",
r"(?i)시스템\s*프롬프트",
r"(?i)(너의|당신의)\s*규칙",
]
def check_input(self, user_input: str) -> dict:
"""입력을 검사합니다."""
# 길이 제한
if len(user_input) > 2000:
return {"allowed": False, "reason": "메시지가 너무 깁니다 (최대 2000자)."}
# 프롬프트 인젝션 검사
for pattern in self.INJECTION_PATTERNS:
if re.search(pattern, user_input):
return {"allowed": False, "reason": "보안상 처리할 수 없는 요청입니다."}
# 주제 이탈 검사
for pattern in self.OFF_TOPIC_PATTERNS:
if re.search(pattern, user_input):
return {
"allowed": False,
"reason": "NLP/AI 관련 질문에만 답변 가능합니다. 다른 질문을 해주세요."
}
return {"allowed": True, "reason": None}
# 안전장치가 적용된 최종 챗봇 함수
guardrails = ChatbotGuardrails()
def safe_chat(message: str, history: list) -> str:
"""안전장치가 적용된 챗봇 응답 함수"""
# 입력 안전장치
check = guardrails.check_input(message)
if not check["allowed"]:
return check["reason"]
# 정상 처리
return chat_with_bot(message, history)
# 안전한 Gradio ChatInterface
safe_demo = gr.ChatInterface(
fn=safe_chat,
title="NLP 학습 도우미 (안전장치 적용)",
description="NLP/딥러닝 학습을 도와주는 AI 어시스턴트입니다.",
theme=gr.themes.Soft(),
)
safe_demo.launch(share=False)
트러블슈팅
Gradio ChatInterface에서 history 형식이 다릅니다
Gradio ChatInterface에서 history 형식이 다릅니다
Gradio 버전에 따라 history 형식이 다를 수 있습니다.
- Gradio 4.x:
history는list[list[str, str]](사용자, 봇 쌍) - Gradio 5.x:
history는list[dict]형태일 수 있습니다 type="messages"를 ChatInterface에 지정하면 OpenAI 호환 형식으로 사용 가능합니다
스트리밍 응답이 한 글자씩 나옵니다
스트리밍 응답이 한 글자씩 나옵니다
정상적인 동작입니다. LLM은 토큰 단위로 생성하며, 한국어 한 글자가 하나의 토큰인 경우가 많습니다. 사용자 경험을 위해 버퍼링은 Gradio가 자동으로 처리합니다.
Function Calling이 호출되지 않습니다
Function Calling이 호출되지 않습니다
도구의
description이 충분히 구체적인지 확인하세요. 시스템 프롬프트에 “도구를 사용할 수 있습니다”라는 안내를 포함하면 도구 호출률이 높아집니다.대화가 길어지면 응답이 느려집니다
대화가 길어지면 응답이 느려집니다
대화 히스토리의 토큰 수가 누적되기 때문입니다.
ConversationManager의 max_tokens를 조절하거나, 오래된 대화를 요약하는 전략을 적용하세요.
