[TIL-0412] DuckDuckGo 라이브러리

jiny·2026년 4월 12일

AI Agent 실습

목록 보기
15/21

🌟 개요

🗣️ 프로젝트 소개

이 프로젝트는 LangGraph 기반의 AI Agent를 활용한 인플루언서 매칭 플랫폼이다. 광고주가 광고 컨셉, 광고 아이템, 요구사항을 입력하면 Agent가 DB에서 최적의 인플루언서를 검색하고 추천해준다.
인플루언서를 잘 추천하려면 단순히 팔로워 수나 카테고리 정보만으로는 부족하다. "이 사람이 어떤 콘텐츠를 만드는 사람인지", "최근 논란은 없는지"처럼 텍스트로 된 정성적 정보가 함께 있어야 매칭 품질이 올라간다.

🤔 DuckDuckGo 라이브러리를 선택한 이유

처음에는 Instagram 스크래핑으로 인플루언서의 게시글, 해시태그, 팔로워 정보를 직접 수집하려 했다. 하지만 실제로 운영하다 보니 문제가 생겼다.

  • Instagram은 로그인 없이 접근 가능한 데이터가 제한적이다.
  • 스크래퍼가 자주 차단(block)되어 안정적인 수집이 어렵다.
  • 스크래핑이 가능한 환경이더라도, DB에 이미 등록된 인플루언서의 description이 비어 있는 경우를 일괄 보완할 방법이 필요했다.

이 상황에서 찾은 대안이 "웹 검색 결과를 LLM에게 넘겨 요약시키는 방식"이었다. 별도 API 키 없이 바로 쓸 수 있고, 한국어 검색 품질도 준수한 duckduckgo-search를 선택했다. Selenium 같은 브라우저 드라이버도 필요 없고, 단순히 pip install만으로 텍스트 검색 결과를 바로 가져올 수 있다는 점이 결정적이었다.


🌟 duckduckgo-search란?

🗣️ 라이브러리 소개

duckduckgo-search는 DuckDuckGo 검색 결과를 Python 코드에서 바로 가져올 수 있게 해주는 라이브러리이다. PyPI(Python Package Index) 패키지명은 duckduckgo-search이고, import 시에는 ddgs로 사용한다.

uv pip install duckduckgo-search

주요 특징은 다음과 같다.

항목내용
API 키불필요
브라우저 드라이버불필요
검색 종류텍스트, 이미지, 뉴스, 동영상
지역/언어 설정region 파라미터로 지원
상업적 이용DuckDuckGo 이용약관 준수 필요

Google Search API나 Bing Search API는 API 키 발급과 월별 사용량 제한이 있는 반면, duckduckgo-search는 별도 인증 없이 바로 사용할 수 있어 프로토타입이나 개인 프로젝트에 적합하다.

한 가지 주의할 점이 있다. duckduckgo-search는 DuckDuckGo의 내부 엔드포인트를 활용하는 방식이라 공식 API가 아니다. DuckDuckGo 이용약관상 상업적 목적의 대량 자동화 요청은 제한될 수 있다.
현재는 프로토타입 수준의 프로젝트라 문제없이 사용하고 있지만, 만약 서비스가 상업화되거나 요청량이 늘어난다면 DuckDuckGo 공식 정책을 확인하거나 공식 Search API로 전환하는 것을 고려해야 한다.

🤔 Selenium/스크래핑 없이 API처럼 사용 가능한 이유

일반적으로 웹 검색 결과를 코드로 가져오려면 두 가지 방법이 있다.

  1. 공식 Search API 사용: 키 발급, 요금 발생, 호출 제한
  2. 직접 스크래핑: Selenium이나 Playwright로 브라우저를 띄워 HTML 파싱

    ⭐ Selenium, Playwright

    둘 다 브라우저를 코드로 자동 조종하는 도구이다.
    예를 들어 Instagram 페이지를 스크래핑하려면

    1. 브라우저를 열고
    2. 로그인하고
    3. 특정 프로필 페이지로 이동해서
    4. HTML에서 게시글 데이터를 추출

    이 과정을 사람 대신 코드가 자동으로 수행하게 해준다.

    • Selenium: 오래된 표준 도구, 다양한 언어 지원
    • Playwright: Microsoft가 만든 최신 버전, 더 빠르고 안정적

    둘의 공통점은 실제 브라우저를 띄워야 한다는 점이다. Chrome이나 Firefox 같은 브라우저가 백그라운드에서 실행되기 때문에 서버 환경에서 세팅하기 번거롭고 무겁다.
    반면 duckduckgo-search는 브라우저 없이 HTTP 요청만으로 결과를 가져오기 때문에 훨씬 가볍고 세팅이 간단하다.

duckduckgo-searchDuckDuckGo의 내부 HTTP 엔드포인트에 직접 요청을 보내는 방식으로 동작한다. 덕분에 브라우저를 띄울 필요도 없고, JavaScript 렌더링을 기다릴 필요도 없다.
단순한 HTTP 요청만으로 구조화된 검색 결과(제목, URL, 본문 snippet)를 딕셔너리 리스트로 받아볼 수 있다.

📘 ddgs.text() 기본 사용법

from ddgs import DDGS

with DDGS() as ddgs:
   results = list(ddgs.text("히밥 유튜브", region="kr-kr", max_results=3))

for r in results:
   print(r["title"])  # 검색 결과 제목
   print(r["href"])   # URL
   print(r["body"])   # 본문 snippet

반한되는 딕셔너리의 주요 필드는 세 가지이다.

필드설명
title검색 결과 제목
href페이지 URL
body본문 요약 snippet

region 파라미터에 "kr-kr"을 지정하면 한국어 검색 결과 위주로 반환된다. max_results로 가져올 결과 수를 제한할 수 있어 프롬프트 길이 조절에 유용하다.
with 문으로 컨텍스트 매니저를 사용하는 게 공식 권장 방식이며, 내부적으로 HTTP 세션을 열고 닫는 처리를 해준다.


🌟 프로젝트에서의 활용 배경

🥲 Instagram 스크래핑의 한계

인플루언서 매칭 플랫폼을 만들면서 가장 먼저 부딪힌 문제는 데이터 수집이었다. 인플루언서의 활동 특성을 파악하려면 실제 게시글과 해시태그를 봐야 하는데, 가장 자연스러운 방법은 Instagram을 직접 스크래핑하는 것이었다.
하지만 실제로 구현해보니 여러 장벽이 있었다.

  1. 로그인 없이는 접근이 제한된다
    Instagram은 비로그인 상태에서 볼 수 있는 게시글 수가 매우 적다. 프로필 페이지 자체가 로그인 유도 팝업으로 막히는 경우도 많아서, 자동화 스크립트로 안정적인 데이터를 수집하기가 어렵다.

  2. 계정이 자주 차단된다
    로그인 기반 스크래퍼를 만들어도 Instagram의 봇 탐지 시스템에 걸려 계정이 일시 정지되거나 CAPTCHA가 발생한다. IP 차단까지 겹치면 운영 자체가 불안정해진다.

  3. 구조가 자주 바뀐다
    Instagram의 HTML 구조나 내부 API 응답 형식은 예고 없이 바뀐다. 스크래퍼를 한 번 만들어놔도 몇 주 후에 동작이 멈추는 경우가 생겨 유지보수 비용이 높다.

🥲 스크래핑 없이 인플루언서 정보를 채워야 했던 상황

스크래핑의 한계를 인정하고 나니 다른 문제가 생겼다. DB에 이미 등록된 인플루언서들의 description 필드가 비어 있었다.
인플루언서 이름과 Instagram 계정명 정도는 직접 수집할 수 있었지만, "이 사람이 어떤 콘텐츠를 만드는 사람인가"를 한 줄로 설명하는 정보가 없었다. 이 필드가 비어 있으면 Agent가 매칭을 할 때 판단 근거가 부족해지고, 추천 결과의 설득력도 떨어진다.
수동으로 50명, 100명의 description을 직접 작성하는 건 현실적이지 않았다. 자동화된 방법으로 이 필드를 채울 수 있어야 했다.

😊 DuckDuckGo 검색 → LLM 요약이라는 흐름을 택한 이유

이 문제를 해결하기 위해 다양한 방법을 검토했다.

방법특징
Instagram 스크래핑앞서 설명한 차단/불안정 문제
Google Custom Search APIAPI 키 필요, 하루 100건 무료 제한
위키피디아유명인 위주, 인플루언서 데이터 부족
DuckDuckGo 검색 + LLM 요약API 키 불필요, 한국어 검색 가능, 즉시 사용 가능

결국 선택한 흐름은 다음과 같다.이 방식의 핵심 장점은 두 가지이다.

  1. 인프라 의존성이 없다
    브라우저 드라이버도, API 키도 필요 없다. uv pip install duckduckgo-search 한 줄로 끝난다. CI 환경이나 서버 배포 시에도 별도 설정이 필요 없어서 운영이 단순해진다.

  2. LLM이 노이즈를 자연스럽게 걸러준다
    검색 결과에는 동명이인, 관련 없는 뉴스, 오래된 정보가 섞여 있다. 이걸 정규식이나 규칙 기반으로 필터링하려면 엣지 케이스가 무수히 많다.
    반면 LLM에게 "인플루언서 관련 내용만 추려서 요약해줘"라고 하면 문맥을 이해해서 알아서 걸러준다. 사람이 직접 검색해서 읽고 요약하는 과정을 자동화한 것과 같다.

이 흐름을 바탕으로 두 가지 기능을 구현했다. 하나는 인플루언서 프로필(description 필드) 자동 생성, 다른 하나는 광고 모델 선정 전 평판 리스크 점검이다. 각각 다음 섹션에서 구체적인 코드와 함께 설명한다.


🌟 구현 사례 1: 프로필 자동 생성

📎 관련 파일: profile_generator.py

✨ 두 가지 생성 방식

이 파일에는 두 개의 함수가 있다.

함수데이터 소스사용 시점
generate_profile_from_postsInstagram 스크래핑 게시글스크래핑 성공했을 때
generate_profile_from_webDuckDuckGo 웹 검색 결과스크래핑 불가능할 때

실제 호출 순서는 enrich_descriptions.py에서 결정된다.

# 게시물 있으면 먼저 시도
if posts:
   result = generate_profile_from_posts(posts)
   if result.get("description") and result.get("confidence") != "low":
      # 성공하면 바로 사용
      ...
# 실패하거나 게시물 없으면 웹 검색으로 넘어감
result = generate_profile_from_web(name, instagram_handle, hint=hint)

게시글 기반을 먼저 시도하고, 없거나 결과가 부실하면 웹 검색으로 넘어가는 우선순위 기반 fallback 구조이다.

generate_profile_from_posts: 게시글 기반 생성

스크래핑한 게시글이 있을 때 사용한다. DuckDuckGo는 사용하지 않고, 게시글의 캡션과 해시태그를 직접 LLM에 넘겨 요약시킨다.

for p in posts[:15]:
   tags = " ".join(p.hashtags[:10])
   cap = p.caption[:80] if p.caption else ""
   samples.append(f"{cap} {tags}".strip())

실제 게시글 데이터를 분석하기 때문에 신뢰도가 높다. 다만 스크래핑이 가능한 환경에서만 동작한다는 제약이 있다.

generate_profile_from_web: 웹 검색 기반 생성

스크래핑이 불가능할 때의 대안이다. DuckDuckGo 검색 → snippet 수집 → LLM 요약의 세 단계로 동작한다.

  1. 검색 쿼리 구성
    이름 하나로 단일 쿼리를 날리면 검색 품질이 떨어지기 때문에 여러 각도의 쿼리를 조합한다.
    if handle:
       queries.append(f"{handle} 인스타그램")
       queries.append(f"{query_name} 방송 출연 화제")
       queries.append(f"{query_name} 유튜브")
    else:
       queries.append(f"{query_name} 인플루언서 인스타그램")
       queries.append(f"{query_name} 방송 출연 예능 화제")
       queries.append(f"{query_name} 유튜브 채널")
    계정명(handle)이 있으면 계정명으로도 검색하고, "방송 출연", "유튜브" 같은 키워드를 붙여 인플루언서 관련 결과가 상위에 오도록 유도한다.
    계정명 앞의 @는 미리 제거한다. @가 붙으면 검색 엔진이 SNS 멘션으로 인식해서 검색 품질이 떨어지기 때문이다.
    handle = instagram_username.lstrip("@") if instagram_username else None
  1. snippet 수집
    with DDGS() as ddgs:
       hits = list(ddgs.text(q, region="kr-kr", max_results=2))
       for h in hits:
          body = h.get("body", "")[:100]
          snippets.append(f"[{h.get('title', '')}] {body}")
    쿼리당 2개씩만 가져오고 본문도 100자로 자른다.
    쿼리가 여러 개이기 때문에 결과를 너무 많이 가져오면 LLM에 넘기는 프롬프트가 길어져서 속도와 비용 모두 나빠진다.
  1. LLM에 전달
    수집한 snippet을 컨텍스트로 묶어 LLM에 전달하고, JSON 형식으로 응답받는다.
    context = "\n".join(f"{i+1}. {s}" for i, s in enumerate(snippets[:5]))
    응답을 JSON으로 강제하는 이유는 이후 코드에서 파싱해서 DB에 바로 저장해야 하기 때문이다.
    LLM이 마크다운 코드블록으로 감싸는 경우도 있어서 re.search(r"\{[\s\S]*\}", response)JSON 블록만 추출하는 처리도 포함되어 있다.

✨ 동명이인 문제와 hint 파라미터

인플루언서 이름으로 검색하면 배우, 운동선수, 정치인 등 동명이인이 검색 결과에 섞여 들어온다. 이를 해결하기 위해 hint 파라미터를 추가했다.

def generate_profile_from_web(
   name: str,
   instagram_username: Optional[str] = None,
   hint: Optional[str] = None,  # 동명이인 구별용 키워드
) -> dict:

예를 들어 Netflix <솔로지옥 5> 출연자라면 hint="솔로지옥 5"를 넣는 식이다. hint가 있으면 해당 쿼리를 리스트 맨 앞에 배치해서 관련 snippet이 우선적으로 수집되도록 한다.

if hint:
   queries.append(f"{query_name} {hint}")  # 최우선
   queries.append(f"{query_name} {hint} 방송 화제")

LLM 프롬프트에도 명시적으로 지시를 넣어 2차로 걸러낸다.

f"동명이인(배우, 정치인 등)이 섞여 있어도 인플루언서 관련 내용만 사용하세요.\n"

검색 쿼리 단에서 1차, LLM 지시로 2차로 걸러내는 이중 방어 구조이다.

hint는 터미널에서 스크립트를 실행할 때 직접 옵션으로 넣어주는 것이다.

# hint 없이 실행 (기본)
uv run python scripts/enrich_descriptions.py --name 김민준

# hint 넣어서 실행
uv run python scripts/enrich_descriptions.py --name 김민준 --hint "흑백요리사 2"

--hint 옵션을 안 넣으면 None으로 넘어가서 동명이인 처리 없이 검색하고, 넣으면 그 키워드가 쿼리에 추가된다.
즉, hint사람이 판단해서 수동으로 넣어줘야 하는 값이다. 동명이인이 많은 인플루언서를 처리할 때만 선택적으로 쓰는 구조이다.

confidence 수준에 따른 fallback 처리

confidence는 세 가지 값을 가진다.

의미
high검색 결과가 충분하고 LLM이 확신함
medium결과는 있지만 다소 불확실함
low검색 결과가 부족하거나 handle 기반 최소값

confidence 값은 LLM이 스스로 판단하여 검색 결과가 풍부하면 high, 애매하면 medium, 거의 없으면 low로 매긴다.
중요한 건 어떤 함수의 결과냐에 따라 confidence: "low" 처리가 다르다는 점이다.
이 부분은 enrich_descriptions.py를 참조해야 한다.

  • generate_profile_from_posts에서 low가 나오면 ➡️ 버린다

    if result.get("description") and result.get("confidence") != "low":
       # low면 이 블록을 건너뜀
       ...
       
    # 바로 웹 검색으로 넘어감
    result = generate_profile_from_web(...)

    게시글 기반 결과가 low면 신뢰할 수 없다고 판단해서 버리고 웹 검색으로 넘어간다.

  • generate_profile_from_web에서 low가 나오면 ➡️ 그냥 저장한다

    result = generate_profile_from_web(name, instagram_handle, hint=hint)
    if result.get("description"):  # confidence 체크 없음
       update_description(...)  # low도 저장

    웹 검색은 마지막 수단이기 때문에 confidence를 따지지 않는다. description이 있기만 하면 저장한다. "인스타그램 @handle 계정으로 활동하는 크리에이터." 같은 최소값도 low로 저장된다.

  • 웹 검색 내부의 fallback 흐름
    웹 검색 자체에서도 단계별로 재시도한다.재시도할 때는 JSON 형식 요구 없이 프롬프트를 단순화해서 성공률을 높인다. 그래도 실패하면 빈 값보다는 최소한의 정보라도 채우는 게 매칭 품질에 유리하기 때문에 handle 기반 최소 description으로 마무리한다.


🌟 구현 사례 2: 평판/화제성 분석

📎 관련 파일: reputation_tools.py

🤔 평판 분석이 필요한 이유

인플루언서 매칭에서 팔로워 수나 카테고리만큼 중요한 게 리스크 점검이다. 아무리 팔로워가 많아도 최근 논란이 있거나 부정적인 뉴스가 많은 인플루언서를 광고 모델로 선정하면 브랜드 이미지에 타격이 생길 수 있다.
그래서 Agent가 인플루언서를 추천하기 전에 웹에서 최신 평판을 검색하고, 광고 모델로서 적합한지 판단하는 도구를 만들었다.

✨ Agent Tool로 등록된 구조

reputation_tools.py는 단순한 유틸 함수가 아니라 LangGraph Agent가 직접 호출하는 Tool로 등록되어 있다.

from langchain_core.tools import tool

@tool
def search_influencer_reputation(influencer_name: str) -> str:
   """인플루언서의 최근 뉴스와 화제성을 웹에서 검색하고 분석합니다.
   
   광고 모델 선정 전 리스크 점검 및 화제성 확인에 사용하세요.
   """

@tool 데코레이터를 붙이면 LangGraph Agent가 이 함수를 도구로 인식하고, 필요하다고 판단할 때 스스로 호출한다. 사람이 직접 실행하는 게 아니라 Agent가 추론 과정에서 "이 인플루언서 평판을 확인해야겠다"고 판단하면 자동으로 호출되는 구조이다.
파일 맨 아래에서 tool 목록으로 묶어 내보낸다.

REPUTATION_TOOLS = [search_influencer_reputation]

도구가 하나뿐인데도 리스트로 내보내는 이유는 호출하는 쪽 코드를 안정적으로 유지하기 위해서이다. Agent에 도구를 등록할 때 리스트끼리 합치는 구조로 설계되어 있어서, 나중에 평판 관련 도구가 추가되더라도 REPUTATION_TOOLS에만 추가하면 되고 이 변수를 쓰는 다른 코드는 건드릴 필요가 없다.

# adapters/outbound/agent/agent_factory.py

tools = [
   ...
   *NEO4J_TOOLS,       # 리스트 언팩
   *LLAMAINDEX_TOOLS,  # 리스트 언팩
   *REPUTATION_TOOLS,  # 리스트 언팩
]

✨ 최근 뉴스/논란/화제 3가지 쿼리 전략

DuckDuckGo 검색을 단일 쿼리로만 날리면 한쪽 정보만 편향되게 수집될 수 있다. 그래서 세 가지 각도로 쿼리를 나눠서 날린다.

search_queries = [
   f"{influencer_name} 최근 뉴스",
   f"{influencer_name} 논란",
   f"{influencer_name} 화제",
]

with DDGS() as ddgs:
   for q in search_queries:
      hits = list(ddgs.text(q, region="kr-kr", max_results=3))
      for h in hits:
         title = h.get("title", "")
         body = h.get("body", "")[:120]
         raw_results.append(f"[{title}] {body}")

각 쿼리마다 3개씩 가져오니 최대 9개의 snippet이 수집된다. 본문은 120자로 자른다. 쿼리를 세 가지로 나눈 이유는 다음과 같다.

  • 최근 뉴스 - 중립적인 최신 활동 파악
  • 논란 - 부정적 리스크 탐지
  • 화제 - 긍정적 화제성 파악

세 방향에서 동시에 수집해야 긍정/부정 양쪽을 균형 있게 볼 수 있다.

✨ LLM이 광고 모델 적합도 점수를 반환하는 구조

수집한 snippet을 LLM에게 넘겨서 광고 모델 관점의 평판 분석을 요청한다.

context = "\n".join(f"{i+1}. {r}" for i, r in enumerate(raw_results[:4]))

prompt = (
   f"다음은 인플루언서 '{influencer_name}'에 대한 최근 뉴스 및 웹 검색 결과입니다.\n\n"
   f"{context}\n\n"
   f"아래 JSON 형식으로만 간결하게 답하세요:\n"
   f'{{"positive_news": [...], "negative_news": [...], '
   f'"recent_highlights": [...], "reputation_score": 7, '
   f'"score_reason": "...", "ad_recommendation": "..."}}'
)

LLM이 반환하는 JSON의 구조는 다음과 같다.

필드내용
positive_news긍정적인 뉴스 항목 리스트
negative_news부정적이거나 논란 항목 리스트
recent_highlights최근 주요 활동 1~2개
reputation_score평판 점수 (1~10)
score_reason점수 이유 한 문장
ad_recommendation광고 모델 추천/비추천 + 이유

단순히 뉴스를 요약하는 게 아니라 광고 모델 적합도라는 관점에서 해석하도록 지시하는 게 핵심이다. LLM이 같은 뉴스를 보더라도 "이게 광고주 입장에서 리스크가 되는가"를 판단해서 응답한다.

✨ 에러 처리

이 함수는 Agent Tool로 등록되어 있어서 Agent 전체 흐름이 끊길 수 있다. 그래서 에러 상황마다 예외를 던지는 대신 JSON 형태의 에러 메시지를 반환한다.

# 패키지가 없을 때
except ImportError:
   return json.dumps({"error": "ddgs 패키지가 설치되지 않았습니다."}, ensure_ascii=False)

# 검색 자체가 실패했을 때
except Exception as e:
   return json.dumps({"status": "search_error", "message": f"웹 검색 오류: {str(e)}"}, ensure_ascii=False)
   
# 검색 결과가 없을 때
if not raw_results:
   return json.dumps({"status": "no_results", "message": "검색 결과가 없습니다."}, ensure_ascii=False)

어떤 상황에서도 문자열을 반환하기 때문에 Agent가 에러 메시지를 읽고 다음 판단을 이어갈 수 있다.


🌟 사용하면서 겪은 문제와 해결책

🚨 문제 1: 한국어 검색 결과 품질이 낮음

처음에 region 파라미터 없이 검색하면 영어 결과가 섞이거나 한국 인플루언서 관련 결과가 하위에 밀리는 문제가 있었다.

해결: region="kr-kr" 파라미터를 추가해 한국어 검색 결과를 우선적으로 가져오도록 했다.

hits = list(ddgs.text(q, region="kr-kr", max_results=2))

🚨 문제 2: 단일 쿼리로는 원하는 정보를 못 가져옴

인플루언서 이름 하나만으로 검색하면 관련 없는 결과가 많이 섞였다.
예를 들어 "김고은"을 검색하면 인플루언서가 아닌 동명이인 결과가 대부분을 차지하는 경우가 있었다.

해결: 목적에 맞게 여러 쿼리를 조합해서 날리는 방식으로 바꿨다.

queries = [
   f"{handle} 인스타그램",
   f"{query_name} 방송 출연 화제",
   f"{query_name} 유튜브",
]

"인스타그램", "유튜브", "방송 출연" 같은 키워드를 붙여 인플루언서 관련 결과가 상위에 오도록 유도했다.

🚨 문제 3: 계정명의 @가 검색 품질을 떨어뜨림

Neo4j에 Instagram 게정명이 @soyoung_beauty 형식으로 저장되어 있었는데, 이걸 그대로 검색 쿼리에 넣으면 검색 엔진이 SNS 멘션으로 인식해서 엉뚱한 결과가 나왔다.

해결: 검색 전에 @를 제거하도록 처리했다.

handle = instagram_username.lstrip("@") if instagram_username else None

🚨 문제 4: 동명이인이 검색 결과를 오염시킴

흔한 이름의 인플루언서를 검색하면 배우, 정치인, 운동선수 등 동명이인 결과가 섞여서 LLM이 엉뚱한 사람의 description을 생성하는 경우가 있었다.

해결: 두 단계로 방어했다.
1. hint 파라미터로 검색 쿼리 자체를 좁힌다.

# hint가 있으면 특정 키워드를 쿼리 최우선으로 배치
queries.append(f"{query_name} {hint}")
queries.append(f"{query_name} {hint} 방송 화제")
  1. LLM 프롬프트에 명시적으로 지시를 넣는다.
    f"동명이인(배우, 정치인 등)이 섞여 있어도 인플루언서 관련 내용만 사용하세요."

🚨 문제 5: LLM이 JSON을 마크다운으로 감싸서 반환

LLM에게 JSON 형식으로만 답하라고 지시해도, 가끔 아래처럼 마크다운 코드블록으로 감싸서 응답하는 경우가 있었다.

```json
{"description": "...", "categories": [...]}
```

이걸 그대로 json.loads()에 넣으면 파싱 에러가 난다.

해결: 정규식으로 중괄호 블록만 추출하도록 처리했다.

json_match = re.search(r"\{[\s\S]*\}", llm_response)
if json_match:
   parsed = json.loads(json_match.group())

마크다운이든 일반 텍스트든 {}로 감싼 부분만 뽑아내기 때문에 LLM 응답 형식에 관계없이 안정적으로 파싱할 수 있다.

🚨 문제 6: 프롬프트가 너무 길어지면 속도와 비용이 나빠짐

쿼리를 여러 개 날리다 보니 snippet이 많이 쌓였다. 이걸 전부 LLM에 넘기면 프롬프트가 길어져서 응답 속도가 느려지고 토큰 비용도 올라갔다.

해결: 세 단계로 제한했다.

# 1. 쿼리당 최대 2개만 수집
hits = list(ddgs.text(q, region="kr-kr", max_results=2))

# 2. 본문은 100~120자로 자름
body = h.get("body", "")[:100]

# 3. LLM에는 최대 5개만 전달
context = "\n".join(f"{i+1}. {s}" for i, s in enumerate(snippets[:5]))

수집 단계에서 양을 줄이고, LLM에 넘기기 직전에도 한 번 더 잘라내는 다단계 제한 구조이다.

🚨 문제 7: 검색이나 LLM이 실패해도 전체 흐름이 끊기면 안 됨

스크립트가 인플루언서 수십 명을 순차적으로 처리하는데, 한 명에서 에러가 나면 전체가 멈춰버리는 문제가 있었다. Agent Tool로 등록된 reputation_tools.py도 마찬가지로 에러가 나면 Agent 전체 흐름이 끊길 수 있었다.

해결: 모든 에러 상황을 try/catch로 잡아서 예외를 던지는 대신 결과값을 반환하도록 했다.

# profile_generator.py
except Exception as e:
   print(f"    [DDGS 오류] '{q}': {e}")
   # 에러가 나도 다음 쿼리로 계속 진행
# reputation_tools.py
except Exception as e:
   return json.dumps({
      "status": "search_error",
      "message": f"웹 검색 오류: {str(e)}",
   }, ensure_ascii=False)

에러가 나도 프로그램이 계속 실행되고, Agent는 에러 메시지를 읽고 다음 판단을 이어갈 수 있다.


🌟 마치며

👍 이 구조의 장점

이번 프로젝트에서 "DuckDuckGo 검색 → LLM 요약" 조합을 써보면서 가장 크게 느낀 장점은 인프라 부담이 거의 없다는 점이다. API 키도, 브라우저 드라이버도, 별도 서버도 필요 없다. 패키지 하나 설치하면 바로 쓸 수 있고, 어떤 환경에서도 동작한다.

또 하나는 LLM이 노이즈 필터 역할을 자연스럽게 해준다는 점이다. 검색 결과에 동명이인, 오래된 정보, 관련 없는 뉴스가 섞여 있어도 LLM이 문맥을 이해해서 필요한 정보만 추려준다. 규칙 기반으로 이걸 구현하려면 엣지 케이스가 무수히 많아서 사실상 불가능에 가깝다.

👎 이 구조의 단점

반면 명확한 한계도 있다.

첫째, 검색 결과의 최신성을 보장할 수 없다. DuckDuckGo가 어떤 페이지를 상위에 노출할지는 제어할 수 없기 때문에, 오래된 정보가 섞여 들어오는 걸 완전히 막을 수 없다.

둘째, LLM 응답이 항상 일정하지 않다. 같은 인플루언서를 두 번 검색해도 LLM이 다른 description을 생성할 수 있다. confidence 수준도 LLM이 주관적으로 판단하기 때문에 기준이 일정하지 않다.

셋째, 공식 API가 아니다. DuckDuckGo의 내부 엔드포인트를 활용하는 방식이라 언제든 구조가 바뀌거나 차단될 수 있다. 실제로 duckduckgo-search 라이브러리도 DuckDuckGo의 변경에 맞춰 버전 업데이트가 자주 일어난다.

🚀 개선 여지

  1. 검색 결과 캐싱
    같은 인플루언서를 반복 검색할 때마다 DuckDuckGo에 요청을 날리는 건 비효율적이다. Redis나 로컬 파일로 검색 결과를 캐싱해두면 속도와 안정성 모두 개선할 수 있다.

  2. 공식 Search API로 전환
    서비스가 상업화되거나 요청량이 늘어나면 Google Custom Search API나 Bing Search API처럼 안정성이 보장된 공식 API로 전환하는 걸 고려해야 한다.

  3. confidence 기준 명확화
    현재는 LLM이 주관적으로 confidence를 판단하는데, 수집된 snippet 수나 키워드 일치율 같은 정량적 기준을 코드에서 직접 계산해서 보조 지표로 활용하면 더 일관된 결과를 낼 수 있다.

✨ 마무리

처음에는 Instagram 스크래핑의 대안으로 급하게 도입한 방식이었지만, 생각보다 실용적이고 안정적으로 동작했다. API 키 없이 웹 검색 결과를 가져와서 LLM이 요약하는 이 흐름은, 정형화된 데이터가 없는 상황에서 빠르게 정보를 채워야 할 때 충분히 유효한 선택이다.
완성도 높은 프로덕션 시스템을 만들려면 공식 API와 캐싱 레이어가 필요하겠지만, 프로토타입이나 소규모 프로젝트에서는 이 정도 구조로도 충분히 쓸만한 결과를 낼 수 있다.

0개의 댓글