📚 Agentic AI - 기업용 자율 에이전트 개발 4장 · 에이전트 ② 웹검색 Agentic AI란

따라하기 — 검색어를 LLM이 직접 만들게 하기

한 줄 요약

code/ch04_web_agent.py 를 만들고, 도구 두 개를 LLM 없이 먼저 확인합니다. 그리고 사용자 문장이 어떻게 검색어로 바뀌는지 봅니다. 그 변환 코드는 우리가 짜지 않습니다.


1. 파일 만들기

code 폴더에 ch04_web_agent.py 를 만들고 아래를 전부 붙여 넣습니다. (파일 전체입니다. 뒷부분은 다음 절들에서 설명합니다.)

새 파일을 만드세요agentic_ai/code/ch04_web_agent.py
VS Code 왼쪽 탐색기에서 code 폴더를 오른쪽 클릭 → New File → 파일명 ch04_web_agent.py 입력 → 아래를 전부 복사해 붙여 넣고 저장(Ctrl+S / Cmd+S).

# -*- coding: utf-8 -*-
"""4장 — 에이전트 ②: 웹검색 (도구 여러 개 + ReAct 루프)

실행:  python code/ch04_web_agent.py

1장의 두 번째 질문을 해결합니다. LLM 은 학습이 끝난 뒤의 세상을 모릅니다.
검색 도구를 쥐여 주면 스스로 찾아봅니다.

핵심 개념 — ReAct 루프
  생각(무엇을 할까) → 행동(도구 호출) → 관찰(결과 확인) → 다시 생각 ...
  이 반복을 우리가 for 문으로 직접 돌려 봅니다. 5장부터는 라이브러리가 대신합니다.
"""
import sys
import pathlib

sys.path.append(str(pathlib.Path(__file__).resolve().parent))

import pandas as pd
from ddgs import DDGS
from common import get_genai_client, GEMINI_MODEL, DATA
from google.genai import types

client = get_genai_client()
products = pd.read_csv(DATA / "products.csv", encoding="utf-8-sig")

MAX_STEPS = 5          # 안전장치: 아무리 꼬여도 5번 안에 멈춘다


# ── 도구 1: 자사 상품 정보 ────────────────────────────────────
def get_product_info(product_name: str) -> str:
    """승승장구몰이 파는 상품의 가격·평점·카테고리를 조회한다.

    상품명 일부만 넣어도 된다. 예: '스마트워치'
    """
    hit = products[products["product_name"].str.contains(product_name.strip(), na=False)]
    if hit.empty:
        return f"'{product_name}' 상품을 찾을 수 없습니다."
    r = hit.iloc[0]
    return (f"{r.product_name} | 카테고리 {r.category} | 판매가 {int(r.price):,}원 "
            f"| 카탈로그 평점 {r.rating}")


# ── 도구 2: 웹검색 ────────────────────────────────────────────
def search_web(query: str) -> str:
    """인터넷에서 최신 정보를 검색한다. 시장 동향·경쟁 제품처럼 사내 데이터에 없는 내용에 쓴다."""
    try:
        results = DDGS().text(query, max_results=4, region="kr-kr")
        if not results:
            return "검색 결과가 없습니다. 다른 검색어로 시도해 보세요."
        return "\n".join(f"- {r.get('title','')}: {r.get('body','')[:180]}"
                         for r in results)
    except Exception as e:
        # [중요] 검색은 외부 서비스라 언제든 실패한다.
        # 예외를 그대로 터뜨리면 에이전트가 멈추므로, 상황을 알리는 문장을 돌려준다.
        return (f"검색에 실패했습니다({type(e).__name__}). "
                "네트워크 상태를 확인하거나 잠시 후 다시 시도해 주세요.")


TOOLS = {"get_product_info": get_product_info, "search_web": search_web}

SYSTEM = (
    "너는 승승장구몰의 상품기획 담당자다. "
    "자사 상품 정보는 get_product_info 로, 시장 동향처럼 바깥 정보는 search_web 으로 확인한다. "
    "확인한 내용만 근거로, 한국어로 간결하게 정리해 답한다."
)


def run_agent(question: str, verbose: bool = True) -> str:
    """ReAct 루프를 직접 돌린다: 모델이 도구를 그만 부를 때까지 반복."""
    config = types.GenerateContentConfig(
        tools=list(TOOLS.values()),
        system_instruction=SYSTEM,
        temperature=0,
        # 자동 실행을 꺼야 우리가 루프를 직접 제어할 수 있다
        automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
    )
    # 대화 기록. 도구를 부를 때마다 '무엇을 불렀는지'와 '결과가 무엇인지'를 여기에 쌓는다.
    history = [types.Content(role="user", parts=[types.Part(text=question)])]

    for step in range(1, MAX_STEPS + 1):
        resp = client.models.generate_content(
            model=GEMINI_MODEL, contents=history, config=config)

        # 더 부를 도구가 없다 = 이제 답할 수 있다 (정상 종료)
        if not resp.function_calls:
            if verbose:
                print(f"  [{step}단계] 도구 호출 없음 → 최종 답변")
            return (resp.text or "").strip()

        history.append(resp.candidates[0].content)      # 모델의 '결정'을 기록

        for fc in resp.function_calls:
            result = TOOLS[fc.name](**dict(fc.args))       # 실행은 우리가 한다
            if verbose:
                print(f"  [{step}단계] {fc.name}({dict(fc.args)})")
                print(f"           └ {result.splitlines()[0][:70]} ...")
            # 관찰 결과를 기록에 넣어야 모델이 다음 판단에 쓸 수 있다
            history.append(types.Content(role="user", parts=[
                types.Part.from_function_response(
                    name=fc.name, response={"result": result})]))

    return "(최대 단계에 도달해 중단했습니다)"


if __name__ == "__main__":
    print("=" * 62)
    print("[1] 도구 두 개를 따로 확인")
    print("  ", get_product_info("스마트워치"))
    print("  ", search_web("스마트워치 배터리 지속시간 비교").splitlines()[0][:80], "...")

    print("\n" + "=" * 62)
    print("[2] 두 도구가 모두 필요한 질문 — ReAct 루프가 도는 모습")
    q = ("승승 스마트워치 Fit 5는 얼마인가요? "
         "요즘 스마트워치 시장 가격대와 비교하면 어느 수준인지도 알려주세요.")
    print(f"  질문: {q}\n")
    answer = run_agent(q)
    print(f"\n  [답변]\n  {answer}")

    print("\n" + "=" * 62)
    print("관찰: 사내 데이터(가격)는 CSV에서, 시장 정보는 웹에서 각각 가져와 합쳤습니다.")
    print("      어떤 도구를 먼저 쓸지는 우리가 정하지 않았습니다. 모델이 스스로 정했습니다.")

2. 실행 — [1] 부분

터미널에서 실행 — 프로젝트 폴더 agentic_ai 에서, (agentic) 표시를 확인한 뒤.

python code/ch04_web_agent.py
==============================================================
[1] 도구 두 개를 따로 확인
   승승 스마트워치 Fit 5 | 카테고리 전자기기 | 판매가 159,000원 | 카탈로그 평점 4.8
   - 현존 가장 배터리 타임이 긴 스마트 워치는 무엇일까요? : 클리앙: 흔히 말하는 절전 모드에서는 ...

두 도구가 각각 잘 동작합니다. 3장에서 배운 습관 그대로 — LLM 없이 먼저 확인합니다.

검색 결과는 실행할 때마다 다릅니다. 인터넷이 계속 바뀌니까요. 오류만 안 나면 성공입니다.

search_web 줄에서 오류 메시지가 나온다면 9절을 먼저 보세요. 검색은 원래 자주 실패합니다.


3. get_product_info — 3장과 다른 점

아래는 code/ch04_web_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.

hit = products[products["product_name"].str.contains(product_name.strip(), na=False)]

3장은 정확히 일치하는 주문번호를 찾았습니다.

아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.

hit = orders[orders["order_id"] == oid]       # 3장 — 완전 일치

여기는 부분 일치입니다. 그래서 "스마트워치" 만 넣어도 "승승 스마트워치 Fit 5" 를 찾습니다.

왜 이렇게 했을까요? 사용자가 상품명을 정확히 말하지 않기 때문입니다. "스마트워치 얼마예요?" 라고 묻지, "승승 스마트워치 Fit 5의 가격은?" 이라고 묻지 않습니다.

독스트링에도 이걸 적어 뒀습니다.

아래는 code/ch04_web_agent.py 의 일부입니다 — 위에서 이미 붙여 넣었으니 다시 넣지 마세요.

"""승승장구몰이 파는 상품의 가격·평점·카테고리를 조회한다.

상품명 일부만 넣어도 된다. 예: '스마트워치'
"""

"일부만 넣어도 된다" 를 알려 주면, 모델이 굳이 정확한 전체 이름을 만들어 내려 애쓰지 않습니다.

na=False 는 상품명이 비어 있는 행에서 오류가 나지 않게 하는 옵션입니다.


4. 검색어는 모델이 만듭니다

[2] 를 실행하면 이런 줄이 나옵니다. (다음 절들에서 자세히 봅니다)

  [2단계] search_web({'query': '스마트워치 시장 가격대'})

사용자가 던진 문장은 이랬습니다.

"승승 스마트워치 Fit 5는 얼마인가요? 요즘 스마트워치 시장 가격대와
 비교하면 어느 수준인지도 알려주세요."

모델이 이 긴 문장에서 "스마트워치 시장 가격대" 라는 검색어를 뽑아냈습니다.

우리가 짠 코드에는 이런 게 없습니다.

아래는 개념 설명용 코드입니다 — 실습 파일에 넣지 않습니다.

# 이런 코드를 우리는 짜지 않았다
search_query = question.replace("요즘", "").replace("비교하면 어느 수준인지도 알려주세요", "")

문장에서 검색에 적합한 구절을 뽑는 일 — 규칙으로 짜기 아주 어려운 일입니다. 이런 게 LLM이 잘하는 종류의 일입니다.

여기서 AI와 사람의 역할이 나뉩니다.
- AI가 한 것 — 문장을 검색어로 변환, 어느 도구를 먼저 쓸지 결정
- 사람이 한 것 — 도구를 두 개로 나눈 것, 각 도구의 경계선을 독스트링에 적은 것, max_results=4 로 정한 것


스스로 해 보기

  1. get_product_info("로봇청소기") 를 실행해 보세요. 부분 일치가 어떻게 동작하는지 확인하세요.
  2. get_product_info("없는상품") 은 어떻게 되나요?
  3. search_webmax_results1 로 줄여 보세요. 결과가 어떻게 달라지나요? 모델의 답변 품질에는 영향이 있을까요?
  4. products.csv 를 열어 다른 상품 이름을 넣어 보세요.

핵심 정리

  • 도구 두 개를 만들었습니다 — get_product_info(CSV)search_web(인터넷).
  • 습관대로 LLM 없이 먼저 각각 돌려 봅니다.
  • get_product_info부분 일치로 찾습니다. 사용자는 정확한 이름을 말하지 않기 때문입니다.
  • 독스트링에 "일부만 넣어도 된다" 를 적어 모델이 헤매지 않게 합니다.
  • 검색어는 모델이 만듭니다. 사용자 문장에서 검색에 적합한 구절을 뽑는 코드를 우리는 짜지 않았습니다.
  • 다음 절에서 도구가 둘일 때 모델이 무엇을 보고 고르는지 봅니다.
← 이전 절02. 검색 도구 붙이기 키가 필요 없는 DuckDuckGo다음 절 →04. 도구가 여러 개일 때 모델은 무엇을 보고 고르나