Recent Posts
Recent Comments
반응형
«   2025/11   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30
Archives
Today
Total
관리 메뉴

오늘도 공부

"Ask-the-Web" Agent (Perplexity 스타일) - 완벽 핸즈온 가이드 #3 본문

카테고리 없음

"Ask-the-Web" Agent (Perplexity 스타일) - 완벽 핸즈온 가이드 #3

행복한 수지아빠 2025. 10. 31. 16:14
반응형

🎯 학습 목표

  • 웹 검색 기능을 가진 AI 에이전트 구축
  • LangChain Agent 프레임워크 이해
  • Tool/Function Calling 마스터
  • 멀티스텝 추론 (ReAct 패턴) 구현
  • 실시간 웹 정보를 활용한 답변 생성

📋 사전 준비

1. 개발 환경 설정

# 새 프로젝트 디렉토리
mkdir ask-the-web-agent
cd ask-the-web-agent

# 가상환경 생성
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate

# 필수 패키지 설치
pip install openai anthropic streamlit python-dotenv
pip install langchain langchain-openai langchain-community
pip install duckduckgo-search wikipedia beautifulsoup4
pip install requests lxml playwright
pip install tavily-python  # 고급 웹 검색 API

# Playwright 브라우저 설치 (웹 스크래핑용)
playwright install chromium

2. API 키 발급

3. 프로젝트 구조

mkdir -p src tools utils data
touch .env app.py requirements.txt
ask-the-web-agent/
├── src/
│   ├── agent.py              # 메인 에이전트 로직
│   ├── react_agent.py        # ReAct 패턴 구현
│   ├── chain_agent.py        # 체인 기반 에이전트
│   └── orchestrator.py       # 멀티 에이전트 조율
├── tools/
│   ├── search_tools.py       # 검색 도구들
│   ├── scraper_tools.py      # 웹 스크래핑
│   ├── calculator_tools.py   # 계산기
│   └── wikipedia_tools.py    # 위키피디아
├── utils/
│   ├── prompt_templates.py   # 프롬프트 템플릿
│   └── response_formatter.py # 응답 포맷팅
├── app.py                    # Streamlit UI
└── .env

.env 파일

OPENAI_API_KEY=your_openai_key
ANTHROPIC_API_KEY=your_anthropic_key
TAVILY_API_KEY=your_tavily_key  # https://tavily.com

🔧 Step 1: 기본 검색 도구 구축 (25분)

tools/search_tools.py

import os
from typing import List, Dict, Optional
from tavily import TavilyClient
from duckduckgo_search import DDGS
import requests
from bs4 import BeautifulSoup

class SearchTools:
    """다양한 검색 도구 모음"""
    
    def __init__(self):
        self.tavily_api_key = os.getenv("TAVILY_API_KEY")
        if self.tavily_api_key:
            self.tavily_client = TavilyClient(api_key=self.tavily_api_key)
        else:
            self.tavily_client = None
            print("⚠️ Tavily API 키가 없습니다. DuckDuckGo를 사용합니다.")
    
    def search_tavily(self, query: str, max_results: int = 5) -> List[Dict]:
        """Tavily 검색 (고품질, 빠름)"""
        if not self.tavily_client:
            return []
        
        try:
            response = self.tavily_client.search(
                query=query,
                max_results=max_results,
                search_depth="advanced"  # basic or advanced
            )
            
            results = []
            for result in response.get('results', []):
                results.append({
                    'title': result.get('title', ''),
                    'url': result.get('url', ''),
                    'content': result.get('content', ''),
                    'score': result.get('score', 0)
                })
            
            return results
        except Exception as e:
            print(f"Tavily 검색 오류: {e}")
            return []
    
    def search_duckduckgo(self, query: str, max_results: int = 5) -> List[Dict]:
        """DuckDuckGo 검색 (무료, API 키 불필요)"""
        try:
            ddgs = DDGS()
            results = []
            
            for result in ddgs.text(query, max_results=max_results):
                results.append({
                    'title': result.get('title', ''),
                    'url': result.get('href', ''),
                    'content': result.get('body', ''),
                    'score': 0.5  # DuckDuckGo는 점수를 제공하지 않음
                })
            
            return results
        except Exception as e:
            print(f"DuckDuckGo 검색 오류: {e}")
            return []
    
    def search(self, query: str, max_results: int = 5) -> List[Dict]:
        """자동으로 사용 가능한 검색 엔진 선택"""
        if self.tavily_client:
            results = self.search_tavily(query, max_results)
            if results:
                return results
        
        # Tavily 실패 시 DuckDuckGo 사용
        return self.search_duckduckgo(query, max_results)
    
    def get_content_from_url(self, url: str) -> str:
        """URL에서 텍스트 콘텐츠 추출"""
        try:
            headers = {
                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
            }
            response = requests.get(url, headers=headers, timeout=10)
            response.raise_for_status()
            
            soup = BeautifulSoup(response.text, 'html.parser')
            
            # 스크립트, 스타일 제거
            for script in soup(["script", "style"]):
                script.decompose()
            
            # 텍스트 추출
            text = soup.get_text()
            lines = (line.strip() for line in text.splitlines())
            chunks = (phrase.strip() for line in lines for phrase in line.split("  "))
            text = ' '.join(chunk for chunk in chunks if chunk)
            
            # 길이 제한 (첫 3000자)
            return text[:3000]
        
        except Exception as e:
            return f"URL 접근 오류: {str(e)}"

# 테스트
if __name__ == "__main__":
    search_tools = SearchTools()
    
    query = "2024년 노벨 물리학상"
    print(f"🔍 검색: {query}\n")
    
    results = search_tools.search(query, max_results=3)
    
    for i, result in enumerate(results, 1):
        print(f"{i}. {result['title']}")
        print(f"   URL: {result['url']}")
        print(f"   내용: {result['content'][:100]}...")
        print(f"   점수: {result['score']}\n")

tools/wikipedia_tools.py

import wikipedia

class WikipediaTools:
    """위키피디아 검색 도구"""
    
    def __init__(self, language: str = "ko"):
        """
        Args:
            language: 위키피디아 언어 코드 (ko, en, ja, etc.)
        """
        wikipedia.set_lang(language)
        self.language = language
    
    def search(self, query: str, max_results: int = 3) -> List[str]:
        """위키피디아 제목 검색"""
        try:
            return wikipedia.search(query, results=max_results)
        except Exception as e:
            print(f"위키피디아 검색 오류: {e}")
            return []
    
    def get_summary(self, title: str, sentences: int = 3) -> str:
        """위키피디아 페이지 요약"""
        try:
            return wikipedia.summary(title, sentences=sentences)
        except wikipedia.exceptions.DisambiguationError as e:
            # 동음이의어 페이지인 경우 첫 번째 옵션 사용
            return wikipedia.summary(e.options[0], sentences=sentences)
        except wikipedia.exceptions.PageError:
            return f"'{title}' 페이지를 찾을 수 없습니다."
        except Exception as e:
            return f"오류: {str(e)}"
    
    def get_page(self, title: str) -> Dict:
        """전체 페이지 정보"""
        try:
            page = wikipedia.page(title)
            return {
                'title': page.title,
                'url': page.url,
                'content': page.content[:2000],  # 처음 2000자
                'summary': page.summary
            }
        except Exception as e:
            return {'error': str(e)}

# 테스트
if __name__ == "__main__":
    wiki = WikipediaTools(language="ko")
    
    query = "인공지능"
    print(f"🔍 위키피디아 검색: {query}\n")
    
    # 1. 제목 검색
    titles = wiki.search(query)
    print("검색 결과:")
    for title in titles:
        print(f"  - {title}")
    
    # 2. 요약 가져오기
    if titles:
        print(f"\n📄 '{titles[0]}' 요약:")
        summary = wiki.get_summary(titles[0])
        print(summary)

tools/calculator_tools.py

import math
import re
from typing import Union

class CalculatorTools:
    """계산 도구"""
    
    @staticmethod
    def calculate(expression: str) -> Union[float, str]:
        """수식 계산"""
        try:
            # 보안을 위해 허용된 문자만 사용
            allowed_chars = r'[0-9+\-*/().\s]'
            if not re.match(f'^{allowed_chars}+$', expression):
                return "오류: 허용되지 않은 문자가 포함되어 있습니다."
            
            # eval 대신 안전한 계산
            result = eval(expression, {"__builtins__": {}}, {})
            return float(result)
        
        except ZeroDivisionError:
            return "오류: 0으로 나눌 수 없습니다."
        except Exception as e:
            return f"계산 오류: {str(e)}"
    
    @staticmethod
    def advanced_calculate(expression: str) -> Union[float, str]:
        """고급 수학 함수 지원"""
        try:
            # math 모듈 함수 허용
            safe_dict = {
                'sqrt': math.sqrt,
                'sin': math.sin,
                'cos': math.cos,
                'tan': math.tan,
                'log': math.log,
                'log10': math.log10,
                'exp': math.exp,
                'pi': math.pi,
                'e': math.e
            }
            
            result = eval(expression, {"__builtins__": {}}, safe_dict)
            return float(result)
        
        except Exception as e:
            return f"계산 오류: {str(e)}"

# 테스트
if __name__ == "__main__":
    calc = CalculatorTools()
    
    expressions = [
        "2 + 2",
        "100 * 0.15",
        "(500 - 200) / 3",
        "sqrt(16)",
        "sin(pi/2)"
    ]
    
    for expr in expressions:
        result = calc.advanced_calculate(expr)
        print(f"{expr} = {result}")

🤖 Step 2: ReAct 에이전트 구현 (35분)

utils/prompt_templates.py

class AgentPrompts:
    """에이전트 프롬프트 템플릿"""
    
    REACT_SYSTEM = """당신은 ReAct (Reasoning + Acting) 패턴을 따르는 AI 에이전트입니다.

사용 가능한 도구:
{tools}

작업 수행 방식:
1. Thought: 현재 상황을 분석하고 다음에 해야 할 일을 생각합니다
2. Action: 사용할 도구와 입력을 선택합니다
3. Observation: 도구의 실행 결과를 확인합니다
4. ... (필요시 1-3을 반복)
5. Final Answer: 최종 답변을 제공합니다

형식:
Thought: [분석 내용]
Action: [도구 이름]
Action Input: [도구 입력값]
Observation: [도구 결과]
... (필요시 반복)
Thought: 이제 최종 답변을 할 수 있습니다
Final Answer: [최종 답변]

중요:
- 반드시 위 형식을 정확히 따라야 합니다
- 추측하지 말고 도구를 사용하여 정확한 정보를 얻으세요
- 최종 답변은 "Final Answer:" 뒤에 작성하세요"""

    REACT_USER = """질문: {question}

Thought:"""

    @staticmethod
    def get_react_prompt(question: str, tools: List[Dict]) -> str:
        """ReAct 프롬프트 생성"""
        tools_desc = "\n".join([
            f"- {tool['name']}: {tool['description']}"
            for tool in tools
        ])
        
        system = AgentPrompts.REACT_SYSTEM.format(tools=tools_desc)
        user = AgentPrompts.REACT_USER.format(question=question)
        
        return system, user

src/react_agent.py

import os
import re
from typing import List, Dict, Optional, Tuple
from openai import OpenAI
from dotenv import load_dotenv
import sys
sys.path.append('..')

from tools.search_tools import SearchTools
from tools.wikipedia_tools import WikipediaTools
from tools.calculator_tools import CalculatorTools
from utils.prompt_templates import AgentPrompts

load_dotenv()

class ReActAgent:
    """ReAct 패턴 기반 에이전트"""
    
    def __init__(self, model: str = "gpt-4o-mini", max_iterations: int = 5):
        self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
        self.model = model
        self.max_iterations = max_iterations
        
        # 도구 초기화
        self.search_tools = SearchTools()
        self.wiki_tools = WikipediaTools()
        self.calc_tools = CalculatorTools()
        
        # 사용 가능한 도구 정의
        self.tools = [
            {
                'name': 'search',
                'description': '웹에서 최신 정보를 검색합니다. 실시간 뉴스, 최신 정보가 필요할 때 사용하세요.',
                'function': self.search_tools.search
            },
            {
                'name': 'wikipedia',
                'description': '위키피디아에서 백과사전 정보를 검색합니다. 역사, 개념, 인물 등의 정보가 필요할 때 사용하세요.',
                'function': self.wiki_tools.get_summary
            },
            {
                'name': 'calculator',
                'description': '수학 계산을 수행합니다. 숫자 계산이 필요할 때 사용하세요.',
                'function': self.calc_tools.advanced_calculate
            },
            {
                'name': 'get_url_content',
                'description': 'URL의 내용을 가져옵니다. 특정 웹페이지의 상세 내용이 필요할 때 사용하세요.',
                'function': self.search_tools.get_content_from_url
            }
        ]
    
    def parse_action(self, text: str) -> Optional[Tuple[str, str]]:
        """텍스트에서 Action과 Action Input 파싱"""
        # Action 찾기
        action_match = re.search(r'Action:\s*(.+?)(?:\n|$)', text)
        if not action_match:
            return None
        
        action = action_match.group(1).strip()
        
        # Action Input 찾기
        input_match = re.search(r'Action Input:\s*(.+?)(?:\n|$)', text, re.DOTALL)
        if not input_match:
            return None
        
        action_input = input_match.group(1).strip()
        
        return action, action_input
    
    def execute_action(self, action: str, action_input: str) -> str:
        """도구 실행"""
        # 도구 찾기
        tool = None
        for t in self.tools:
            if t['name'].lower() == action.lower():
                tool = t
                break
        
        if not tool:
            return f"오류: '{action}' 도구를 찾을 수 없습니다. 사용 가능한 도구: {[t['name'] for t in self.tools]}"
        
        # 도구 실행
        try:
            if tool['name'] == 'search':
                results = tool['function'](action_input, max_results=3)
                if not results:
                    return "검색 결과가 없습니다."
                
                output = ""
                for i, result in enumerate(results, 1):
                    output += f"\n[{i}] {result['title']}\n"
                    output += f"URL: {result['url']}\n"
                    output += f"내용: {result['content'][:200]}...\n"
                
                return output
            
            elif tool['name'] == 'wikipedia':
                return tool['function'](action_input, sentences=3)
            
            else:
                return str(tool['function'](action_input))
        
        except Exception as e:
            return f"도구 실행 오류: {str(e)}"
    
    def run(self, question: str, verbose: bool = True) -> Dict:
        """에이전트 실행"""
        system_prompt, user_prompt = AgentPrompts.get_react_prompt(question, self.tools)
        
        conversation = system_prompt + "\n\n" + user_prompt
        
        iterations = []
        final_answer = None
        
        for iteration in range(self.max_iterations):
            if verbose:
                print(f"\n{'='*60}")
                print(f"Iteration {iteration + 1}")
                print(f"{'='*60}")
            
            # LLM 호출
            response = self.client.chat.completions.create(
                model=self.model,
                messages=[{"role": "user", "content": conversation}],
                temperature=0.0,
                max_tokens=1000
            )
            
            agent_output = response.choices[0].message.content
            
            if verbose:
                print(agent_output)
            
            # Final Answer 체크
            if "Final Answer:" in agent_output:
                final_answer = agent_output.split("Final Answer:")[-1].strip()
                iterations.append({
                    'iteration': iteration + 1,
                    'output': agent_output,
                    'action': None,
                    'observation': None
                })
                break
            
            # Action 파싱
            parsed = self.parse_action(agent_output)
            
            if not parsed:
                # Action이 없으면 다시 시도
                conversation += "\n\n" + agent_output + "\n\n(Action과 Action Input을 명시해주세요)"
                continue
            
            action, action_input = parsed
            
            # Action 실행
            observation = self.execute_action(action, action_input)
            
            iterations.append({
                'iteration': iteration + 1,
                'output': agent_output,
                'action': action,
                'action_input': action_input,
                'observation': observation
            })
            
            # 대화에 Observation 추가
            conversation += "\n\n" + agent_output
            conversation += f"\nObservation: {observation}\n\nThought:"
        
        if not final_answer:
            final_answer = "최대 반복 횟수에 도달했습니다. 답변을 생성할 수 없습니다."
        
        return {
            'question': question,
            'final_answer': final_answer,
            'iterations': iterations,
            'num_iterations': len(iterations)
        }

# 테스트
if __name__ == "__main__":
    agent = ReActAgent(max_iterations=5)
    
    test_questions = [
        "2024년 노벨 물리학상 수상자는 누구인가요?",
        "서울의 인구는 얼마이고, 부산 인구의 몇 배인가요?",
        "양자컴퓨터란 무엇인가요?"
    ]
    
    for question in test_questions:
        print(f"\n\n{'#'*60}")
        print(f"질문: {question}")
        print(f"{'#'*60}")
        
        result = agent.run(question, verbose=True)
        
        print(f"\n{'='*60}")
        print(f"최종 답변:")
        print(f"{'='*60}")
        print(result['final_answer'])
        print(f"\n총 {result['num_iterations']}번의 반복")

🔗 Step 3: LangChain 에이전트 (더 간단한 방법) (25분)

src/agent.py

import os
from typing import List
from langchain.agents import Tool, AgentExecutor, create_react_agent
from langchain_openai import ChatOpenAI
from langchain.prompts import PromptTemplate
from langchain_community.tools import DuckDuckGoSearchRun
from langchain_community.utilities import WikipediaAPIWrapper
from dotenv import load_dotenv
import sys
sys.path.append('..')

from tools.calculator_tools import CalculatorTools

load_dotenv()

class LangChainAgent:
    """LangChain 기반 에이전트 (더 간단)"""
    
    def __init__(self, model: str = "gpt-4o-mini", verbose: bool = True):
        self.llm = ChatOpenAI(
            model=model,
            temperature=0,
            api_key=os.getenv("OPENAI_API_KEY")
        )
        self.verbose = verbose
        
        # 도구 정의
        self.tools = self._setup_tools()
        
        # 에이전트 생성
        self.agent = self._create_agent()
    
    def _setup_tools(self) -> List[Tool]:
        """도구 설정"""
        # 검색 도구
        search = DuckDuckGoSearchRun()
        
        # 위키피디아 도구
        wikipedia = WikipediaAPIWrapper(lang="ko")
        
        # 계산기 도구
        calculator = CalculatorTools()
        
        tools = [
            Tool(
                name="Search",
                func=search.run,
                description="웹에서 최신 정보를 검색할 때 유용합니다. 뉴스, 현재 이벤트, 최신 데이터 등을 찾을 때 사용하세요."
            ),
            Tool(
                name="Wikipedia",
                func=wikipedia.run,
                description="백과사전 정보를 검색할 때 유용합니다. 역사적 사실, 과학 개념, 유명 인물 등에 대한 정보를 찾을 때 사용하세요."
            ),
            Tool(
                name="Calculator",
                func=calculator.advanced_calculate,
                description="수학 계산이 필요할 때 사용합니다. 산술 연산, 제곱근, 삼각함수 등을 계산할 수 있습니다."
            )
        ]
        
        return tools
    
    def _create_agent(self):
        """ReAct 에이전트 생성"""
        # 프롬프트 템플릿
        template = """당신은 질문에 답하기 위해 도구를 사용할 수 있는 AI 어시스턴트입니다.
다음 도구들을 사용할 수 있습니다:

{tools}

다음 형식을 사용하세요:

Question: 답해야 할 질문
Thought: 무엇을 해야 할지 생각
Action: 수행할 작업, [{tool_names}] 중 하나여야 합니다
Action Input: 작업에 대한 입력
Observation: 작업의 결과
... (이 Thought/Action/Action Input/Observation을 여러 번 반복할 수 있습니다)
Thought: 이제 최종 답변을 알았습니다
Final Answer: 원래 질문에 대한 최종 답변

시작!

Question: {input}
Thought: {agent_scratchpad}"""

        prompt = PromptTemplate.from_template(template)
        
        # 에이전트 생성
        agent = create_react_agent(
            llm=self.llm,
            tools=self.tools,
            prompt=prompt
        )
        
        # 실행기 생성
        agent_executor = AgentExecutor(
            agent=agent,
            tools=self.tools,
            verbose=self.verbose,
            max_iterations=5,
            handle_parsing_errors=True
        )
        
        return agent_executor
    
    def run(self, question: str) -> Dict:
        """에이전트 실행"""
        try:
            result = self.agent.invoke({"input": question})
            return {
                'question': question,
                'answer': result['output'],
                'success': True
            }
        except Exception as e:
            return {
                'question': question,
                'answer': f"오류 발생: {str(e)}",
                'success': False
            }

# 테스트
if __name__ == "__main__":
    agent = LangChainAgent(verbose=True)
    
    questions = [
        "현재 미국 대통령은 누구인가요?",
        "파이썬이 언제 만들어졌고, 만든 사람은 누구인가요?",
        "100달러를 원화로 환산하면 얼마인가요? (환율 1400원 기준)"
    ]
    
    for question in questions:
        print(f"\n{'='*60}")
        print(f"질문: {question}")
        print(f"{'='*60}\n")
        
        result = agent.run(question)
        
        print(f"\n답변: {result['answer']}\n")

🎨 Step 4: Streamlit UI - Perplexity 스타일 (30분)

app.py

import streamlit as st
import sys
sys.path.append('src')
sys.path.append('tools')

from agent import LangChainAgent
from react_agent import ReActAgent
import time

# 페이지 설정
st.set_page_config(
    page_title="Ask-the-Web Agent",
    page_icon="🔍",
    layout="wide"
)

# CSS 스타일
st.markdown("""
<style>
    .main-header {
        font-size: 2.5rem;
        font-weight: bold;
        text-align: center;
        margin-bottom: 1rem;
        background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
        -webkit-background-clip: text;
        -webkit-text-fill-color: transparent;
    }
    .search-box {
        border: 2px solid #667eea;
        border-radius: 10px;
        padding: 20px;
        margin: 20px 0;
        background-color: #f8f9fa;
    }
    .source-card {
        border-left: 4px solid #667eea;
        padding: 15px;
        margin: 10px 0;
        background-color: #f8f9fa;
        border-radius: 5px;
    }
    .answer-box {
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        color: white;
        padding: 25px;
        border-radius: 10px;
        margin: 20px 0;
    }
    .thinking-step {
        background-color: #fff9e6;
        border-left: 4px solid #ffc107;
        padding: 15px;
        margin: 10px 0;
        border-radius: 5px;
    }
</style>
""", unsafe_allow_html=True)

# 세션 상태 초기화
if 'agent' not in st.session_state:
    st.session_state.agent = None
if 'search_history' not in st.session_state:
    st.session_state.search_history = []

# 헤더
st.markdown('<h1 class="main-header">🔍 Ask-the-Web Agent</h1>', unsafe_allow_html=True)
st.markdown('<p style="text-align: center; color: #666;">실시간 웹 검색으로 최신 정보를 제공하는 AI 에이전트</p>', unsafe_allow_html=True)

# 사이드바 설정
with st.sidebar:
    st.header("⚙️ 설정")
    
    agent_type = st.selectbox(
        "에이전트 타입",
        ["LangChain (간단)", "Custom ReAct (상세)"],
        help="LangChain은 간단하고 빠르며, Custom ReAct는 상세한 추론 과정을 보여줍니다"
    )
    
    model_choice = st.selectbox(
        "LLM 모델",
        ["gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"],
        help="gpt-4o-mini는 빠르고 저렴하며, gpt-4o는 더 정확합니다"
    )
    
    show_thinking = st.checkbox(
        "사고 과정 표시",
        value=True,
        help="에이전트의 추론 과정을 단계별로 보여줍니다"
    )
    
    max_iterations = st.slider(
        "최대 반복 횟수",
        min_value=3,
        max_value=10,
        value=5,
        help="에이전트가 도구를 사용할 수 있는 최대 횟수"
    )
    
    # 에이전트 초기화
    if st.button("🔄 에이전트 초기화"):
        with st.spinner("에이전트 초기화 중..."):
            if "LangChain" in agent_type:
                st.session_state.agent = LangChainAgent(
                    model=model_choice,
                    verbose=show_thinking
                )
            else:
                st.session_state.agent = ReActAgent(
                    model=model_choice,
                    max_iterations=max_iterations
                )
            st.success("✅ 초기화 완료!")
    
    st.markdown("---")
    
    # 검색 히스토리
    st.subheader("📜 검색 기록")
    if st.session_state.search_history:
        for i, item in enumerate(reversed(st.session_state.search_history[-5:]), 1):
            with st.expander(f"{i}. {item['question'][:30]}..."):
                st.write(f"**답변:** {item['answer'][:100]}...")
                st.caption(f"⏱️ {item['time']}")
    else:
        st.info("아직 검색 기록이 없습니다")
    
    if st.button("🗑️ 기록 삭제"):
        st.session_state.search_history = []
        st.rerun()
    
    st.markdown("---")
    
    # 예시 질문
    st.subheader("💡 예시 질문")
    example_questions = [
        "2024년 노벨상 수상자는?",
        "현재 비트코인 가격은?",
        "최신 AI 뉴스는?",
        "양자컴퓨터란 무엇인가요?",
        "서울과 부산의 인구 비교"
    ]
    
    for question in example_questions:
        if st.button(question, key=f"ex_{question}", use_container_width=True):
            st.session_state.current_question = question

# 메인 영역
col1, col2, col3 = st.columns([1, 3, 1])

with col2:
    # 검색 입력
    question = st.text_input(
        "질문을 입력하세요",
        placeholder="예: 2024년 노벨 물리학상 수상자는 누구인가요?",
        value=st.session_state.get('current_question', ''),
        key="question_input"
    )
    
    search_button = st.button("🔍 검색", type="primary", use_container_width=True)

# 검색 실행
if search_button and question:
    # 에이전트 초기화 확인
    if st.session_state.agent is None:
        with st.spinner("에이전트 초기화 중..."):
            if "LangChain" in agent_type:
                st.session_state.agent = LangChainAgent(
                    model=model_choice,
                    verbose=show_thinking
                )
            else:
                st.session_state.agent = ReActAgent(
                    model=model_choice,
                    max_iterations=max_iterations
                )
    
    # 검색 시작
    start_time = time.time()
    
    with st.spinner("🤔 답변 생성 중..."):
        # 진행 상황 표시
        progress_bar = st.progress(0)
        status_text = st.empty()
        
        if "LangChain" in agent_type:
            # LangChain 에이전트
            status_text.text("도구 선택 중...")
            progress_bar.progress(33)
            
            result = st.session_state.agent.run(question)
            
            progress_bar.progress(100)
            status_text.empty()
            progress_bar.empty()
            
            # 답변 표시
            st.markdown(f"""
            <div class="answer-box">
                <h3>💡 답변</h3>
                <p>{result['answer']}</p>
            </div>
            """, unsafe_allow_html=True)
        
        else:
            # Custom ReAct 에이전트
            result = st.session_state.agent.run(question, verbose=False)
            
            progress_bar.progress(100)
            status_text.empty()
            progress_bar.empty()
            
            # 사고 과정 표시
            if show_thinking and result['iterations']:
                st.subheader("🧠 사고 과정")
                
                for iteration in result['iterations']:
                    with st.expander(f"Step {iteration['iteration']}", expanded=False):
                        st.markdown(f"""
                        <div class="thinking-step">
                            {iteration['output'].replace(chr(10), '<br>')}
                        </div>
                        """, unsafe_allow_html=True)
                        
                        if iteration.get('action'):
                            st.info(f"🔧 도구: **{iteration['action']}**")
                            st.code(iteration['action_input'])
                            st.success(f"📊 결과:\n{iteration['observation'][:300]}...")
            
            # 최종 답변
            st.markdown(f"""
            <div class="answer-box">
                <h3>💡 최종 답변</h3>
                <p>{result['final_answer']}</p>
            </div>
            """, unsafe_allow_html=True)
    
    # 소요 시간
    elapsed_time = time.time() - start_time
    st.caption(f"⏱️ 소요 시간: {elapsed_time:.2f}초 | 반복: {result.get('num_iterations', 'N/A')}회")
    
    # 기록 저장
    st.session_state.search_history.append({
        'question': question,
        'answer': result.get('answer', result.get('final_answer', '')),
        'time': time.strftime('%Y-%m-%d %H:%M:%S')
    })
    
    # current_question 초기화
    if 'current_question' in st.session_state:
        del st.session_state.current_question

# Footer
st.markdown("---")
st.markdown("""
<div style='text-align: center; color: #666;'>
    <p>🤖 Powered by ReAct Agent Pattern</p>
    <p style='font-size: 0.9em;'>실시간 웹 검색 • 위키피디아 • 계산기</p>
</div>
""", unsafe_allow_html=True)

실행

streamlit run app.py

🎯 Step 5: 멀티 에이전트 오케스트레이션 (고급, 25분)

src/orchestrator.py

import os
from typing import List, Dict
from openai import OpenAI
from dotenv import load_dotenv
import sys
sys.path.append('..')

from tools.search_tools import SearchTools
from tools.wikipedia_tools import WikipediaTools

load_dotenv()

class MultiAgentOrchestrator:
    """여러 전문 에이전트를 조율하는 오케스트레이터"""
    
    def __init__(self):
        self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
        
        # 전문 에이전트들
        self.agents = {
            'researcher': {
                'name': '리서처',
                'expertise': '웹 검색을 통한 최신 정보 수집',
                'tools': SearchTools()
            },
            'analyst': {
                'name': '분석가',
                'expertise': '수집된 정보 분석 및 종합',
                'tools': None
            },
            'fact_checker': {
                'name': '팩트체커',
                'expertise': '정보의 정확성 검증',
                'tools': WikipediaTools()
            }
        }
    
    def route_query(self, question: str) -> str:
        """질문을 분석하여 적절한 에이전트 선택"""
        prompt = f"""다음 질문을 분석하여 어떤 에이전트에게 할당할지 결정하세요.

사용 가능한 에이전트:
- researcher: 최신 뉴스, 실시간 정보, 트렌드 등
- analyst: 복잡한 분석, 비교, 추론이 필요한 작업
- fact_checker: 역사적 사실, 백과사전 정보 확인

질문: {question}

가장 적합한 에이전트 하나만 답하세요 (researcher/analyst/fact_checker):"""

        response = self.client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0
        )
        
        agent_choice = response.choices[0].message.content.strip().lower()
        
        if agent_choice not in self.agents:
            agent_choice = 'researcher'  # 기본값
        
        return agent_choice
    
    def research(self, question: str) -> Dict:
        """리서처 에이전트"""
        search_tools = self.agents['researcher']['tools']
        results = search_tools.search(question, max_results=3)
        
        # 검색 결과 요약
        context = "\n\n".join([
            f"출처 {i+1}: {r['title']}\n{r['content']}"
            for i, r in enumerate(results)
        ])
        
        return {
            'agent': 'researcher',
            'raw_results': results,
            'context': context
        }
    
    def analyze(self, question: str, context: str) -> str:
        """분석가 에이전트"""
        prompt = f"""당신은 정보 분석 전문가입니다. 수집된 정보를 바탕으로 질문에 답변하세요.

수집된 정보:
{context}

질문: {question}

답변 (정확하고 객관적으로):"""

        response = self.client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0.3,
            max_tokens=500
        )
        
        return response.choices[0].message.content
    
    def fact_check(self, statement: str) -> Dict:
        """팩트체커 에이전트"""
        wiki_tools = self.agents['fact_checker']['tools']
        
        # 위키피디아 검색
        search_results = wiki_tools.search(statement, max_results=3)
        
        if not search_results:
            return {
                'verified': False,
                'confidence': 'low',
                'note': '검증 불가'
            }
        
        # 첫 번째 결과 요약 가져오기
        summary = wiki_tools.get_summary(search_results[0], sentences=2)
        
        # LLM으로 검증
        prompt = f"""다음 진술이 사실인지 확인해주세요.

진술: {statement}

참고 정보:
{summary}

검증 결과를 JSON 형식으로 답하세요:
{{"verified": true/false, "confidence": "high/medium/low", "explanation": "..."}}"""

        response = self.client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0
        )
        
        return {
            'agent': 'fact_checker',
            'result': response.choices[0].message.content,
            'wiki_summary': summary
        }
    
    def orchestrate(self, question: str) -> Dict:
        """전체 프로세스 오케스트레이션"""
        # 1. 라우팅
        assigned_agent = self.route_query(question)
        print(f"📍 질문이 '{self.agents[assigned_agent]['name']}'에게 할당되었습니다")
        
        # 2. 리서치
        print("🔍 정보 수집 중...")
        research_result = self.research(question)
        
        # 3. 분석
        print("🧠 정보 분석 중...")
        analysis = self.analyze(question, research_result['context'])
        
        # 4. (선택) 팩트 체크
        # fact_check_result = self.fact_check(analysis)
        
        return {
            'question': question,
            'assigned_agent': assigned_agent,
            'sources': research_result['raw_results'],
            'answer': analysis
        }

# 테스트
if __name__ == "__main__":
    orchestrator = MultiAgentOrchestrator()
    
    questions = [
        "2024년 가장 인기 있는 프로그래밍 언어는?",
        "인공지능의 역사를 간단히 설명해주세요",
        "비트코인과 이더리움의 현재 가격 차이는?"
    ]
    
    for question in questions:
        print(f"\n{'='*60}")
        print(f"질문: {question}")
        print(f"{'='*60}\n")
        
        result = orchestrator.orchestrate(question)
        
        print(f"\n담당 에이전트: {result['assigned_agent']}")
        print(f"\n답변:\n{result['answer']}")
        
        print(f"\n참고 출처:")
        for i, source in enumerate(result['sources'], 1):
            print(f"  {i}. {source['title']}")
            print(f"     {source['url']}")

📊 Step 6: 성능 평가 및 비교 (20분)

src/evaluator.py

import time
from typing import List, Dict
import sys
sys.path.append('..')

from agent import LangChainAgent
from react_agent import ReActAgent

class AgentEvaluator:
    """에이전트 성능 평가"""
    
    def __init__(self):
        self.langchain_agent = LangChainAgent(verbose=False)
        self.react_agent = ReActAgent(max_iterations=5)
    
    def evaluate_accuracy(self, test_cases: List[Dict]) -> Dict:
        """정확성 평가"""
        results = {
            'langchain': {'correct': 0, 'total': 0, 'details': []},
            'react': {'correct': 0, 'total': 0, 'details': []}
        }
        
        for case in test_cases:
            question = case['question']
            expected_keywords = case['expected_keywords']  # 답변에 포함되어야 할 키워드
            
            print(f"\n테스트 중: {question}")
            
            # LangChain 평가
            lc_result = self.langchain_agent.run(question)
            lc_correct = any(kw.lower() in lc_result['answer'].lower() for kw in expected_keywords)
            
            results['langchain']['total'] += 1
            if lc_correct:
                results['langchain']['correct'] += 1
            
            results['langchain']['details'].append({
                'question': question,
                'answer': lc_result['answer'],
                'correct': lc_correct
            })
            
            # ReAct 평가
            react_result = self.react_agent.run(question, verbose=False)
            react_correct = any(kw.lower() in react_result['final_answer'].lower() for kw in expected_keywords)
            
            results['react']['total'] += 1
            if react_correct:
                results['react']['correct'] += 1
            
            results['react']['details'].append({
                'question': question,
                'answer': react_result['final_answer'],
                'correct': react_correct
            })
        
        # 정확도 계산
        results['langchain']['accuracy'] = results['langchain']['correct'] / results['langchain']['total']
        results['react']['accuracy'] = results['react']['correct'] / results['react']['total']
        
        return results
    
    def evaluate_speed(self, questions: List[str]) -> Dict:
        """속도 평가"""
        results = {
            'langchain': [],
            'react': []
        }
        
        for question in questions:
            print(f"\n속도 테스트: {question}")
            
            # LangChain
            start = time.time()
            self.langchain_agent.run(question)
            lc_time = time.time() - start
            results['langchain'].append(lc_time)
            
            # ReAct
            start = time.time()
            self.react_agent.run(question, verbose=False)
            react_time = time.time() - start
            results['react'].append(react_time)
        
        # 평균 계산
        results['langchain_avg'] = sum(results['langchain']) / len(results['langchain'])
        results['react_avg'] = sum(results['react']) / len(results['react'])
        
        return results

# 실행
if __name__ == "__main__":
    evaluator = AgentEvaluator()
    
    # 정확성 테스트
    test_cases = [
        {
            'question': '파이썬은 누가 만들었나요?',
            'expected_keywords': ['Guido', 'van Rossum', '귀도']
        },
        {
            'question': '현재 미국 대통령은?',
            'expected_keywords': ['Biden', 'Trump', '바이든', '트럼프']
        }
    ]
    
    print("="*60)
    print("정확성 평가")
    print("="*60)
    
    accuracy_results = evaluator.evaluate_accuracy(test_cases)
    
    print(f"\nLangChain 정확도: {accuracy_results['langchain']['accuracy']:.2%}")
    print(f"ReAct 정확도: {accuracy_results['react']['accuracy']:.2%}")
    
    # 속도 테스트
    speed_questions = [
        '서울의 인구는?',
        '양자컴퓨터란?'
    ]
    
    print("\n" + "="*60)
    print("속도 평가")
    print("="*60)
    
    speed_results = evaluator.evaluate_speed(speed_questions)
    
    print(f"\nLangChain 평균 속도: {speed_results['langchain_avg']:.2f}초")
    print(f"ReAct 평균 속도: {speed_results['react_avg']:.2f}초")

🎓 학습 과제 및 개선 방향

필수 과제 체크리스트

  • [ ] 기본 검색 도구 구현 완료
  • [ ] ReAct 에이전트 동작 확인
  • [ ] LangChain 에이전트 비교
  • [ ] Streamlit UI 실행
  • [ ] 5개 이상의 질문 테스트

심화 과제

  1. 병렬 검색: 여러 소스를 동시에 검색
  2. 캐싱: 이전 검색 결과 재사용
  3. 스트리밍 응답: 답변을 실시간으로 표시
  4. 출처 표시: 각 문장마다 출처 링크
  5. 멀티모달: 이미지 검색 추가

다음 단계

  • Project 4: Deep Research (복잡한 추론, Tree of Thoughts)
  • 프로덕션 배포: Docker, API 서버화
  • 모니터링: 로깅, 성능 추적

 

반응형