따라하기 — 검색어를 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로 정한 것
스스로 해 보기
get_product_info("로봇청소기")를 실행해 보세요. 부분 일치가 어떻게 동작하는지 확인하세요.get_product_info("없는상품")은 어떻게 되나요?search_web의max_results를1로 줄여 보세요. 결과가 어떻게 달라지나요? 모델의 답변 품질에는 영향이 있을까요?products.csv를 열어 다른 상품 이름을 넣어 보세요.
핵심 정리
- 도구 두 개를 만들었습니다 —
get_product_info(CSV) 와search_web(인터넷). - 습관대로 LLM 없이 먼저 각각 돌려 봅니다.
get_product_info는 부분 일치로 찾습니다. 사용자는 정확한 이름을 말하지 않기 때문입니다.- 독스트링에 "일부만 넣어도 된다" 를 적어 모델이 헤매지 않게 합니다.
- 검색어는 모델이 만듭니다. 사용자 문장에서 검색에 적합한 구절을 뽑는 코드를 우리는 짜지 않았습니다.
- 다음 절에서 도구가 둘일 때 모델이 무엇을 보고 고르는지 봅니다.