[ blog · tutorial ]9 min read

Search + URL Extract로 리서치 에이전트 만들기 (Claude tool use, 처음부터 끝까지)

Sarah Choy2026년 5월 3일 게시9분 분량
Search + URL Extract로 리서치 에이전트 만들기 (Claude tool use, 처음부터 끝까지)

대부분의 'AI 리서치 에이전트' 튜토리얼은 '여기 도구 정의입니다'에서 멈춘다. 이 글은 실제로 동작하는 에이전트를 내놓는다: 질문이 들어가고, 출처가 달린 답이 나온다. 검색·추출·추론·인용 모두 Python 120줄 이내.

한눈에

  • 쓸모 있는 최소 리서치 에이전트 루프는: 검색 → 링크 선택 → 본문 추출 → 모델에게 인라인 인용을 달아 답하게 하기.
  • 두 도구 — Web Search(15 크레딧)와 URL Extract(URL당 2 크레딧) — 가 근거 기반 답변 사용 사례의 95%를 커버한다.
  • 오케스트레이션은 Claude tool use가 처리한다. 당신은 tool_use 블록 → API 호출 → tool_result 블록을 모델이 멈출 때까지 실어 나르기만 하면 된다.
  • 처음부터 끝까지 비용: 일반적인 깊이에서 질문당 약 25 크레딧 + LLM 토큰 약 $0.02.

무엇을 만드는가

입력 하나 — 자연어 질문 — 를 받아 출처가 달린 답을 반환하는 리서치 에이전트. 아키텍처로 보면:

question → [Claude]
              ↓ tool_use(search)
            [API Pick Web Search] → ranked URLs
              ↓ tool_use(extract)
            [API Pick URL Extract] → cleaned bodies
              ↓ Claude reads, decides if more is needed
              ↓ end_turn
          answer with inline [source: URL] citations

Python 약 120줄. 도구 두 개 — 검색과 추출 — 그리고 Claude가 무엇을 하기로 하든 처리하는 에이전트 루프 하나. 4단계로 처음부터 만든다.

1도구 스키마 가져오기 (JSON 손으로 안 짜기)

두 엔드포인트 모두 messages.create가 기대하는 정확한 형태로 Claude 도구 정의를 반환하는 tool-schema 라우트를 제공한다.

import requests

API_KEY = "pk_yourkey"

def fetch_tool(tool_path: str) -> dict:
    """Fetch a Claude tool definition from API Pick's tool-schema endpoint."""
    schema = requests.get(f"https://www.apipick.com{tool_path}/tool-schema").json()
    return schema["claude"]

WEB_SEARCH_TOOL = fetch_tool("/api/search/web")
URL_EXTRACT_TOOL = fetch_tool("/api/extract")

# Cache these once at module load. They don't change between requests.

이것이 거저 주는 중요한 두 가지: 파라미터 스키마(그래서 Claude는 당신이 query, country_code, start_date 등을 받는다는 것을 안다)와, 도구가 무엇을 하는지에 대한 명확하고 모델 친화적인 설명.

2도구 핸들러 작성

Claude가 tool_use 블록을 반환하면, 당신의 일은 실제 API를 호출하고 tool_result 블록을 반환하는 것이다. 도구마다 함수 하나, 그리고 Claude가 고른 이름으로 라우팅하는 디스패처 하나.

def call_tool(tool_use_block) -> dict:
    """Execute the tool Claude asked for and return a tool_result block."""
    name = tool_use_block.name
    args = tool_use_block.input

    if name == "web_search":
        endpoint = "https://www.apipick.com/api/search/web"
    elif name == "extract_urls":
        endpoint = "https://www.apipick.com/api/extract"
    else:
        return {
            "type": "tool_result",
            "tool_use_id": tool_use_block.id,
            "content": f"Unknown tool: {name}",
            "is_error": True,
        }

    resp = requests.post(
        endpoint,
        headers={"x-api-key": API_KEY},
        json=args,
        timeout=30,
    )

    if resp.status_code != 200:
        return {
            "type": "tool_result",
            "tool_use_id": tool_use_block.id,
            "content": f"HTTP {resp.status_code}: {resp.text[:500]}",
            "is_error": True,
        }

    return {
        "type": "tool_result",
        "tool_use_id": tool_use_block.id,
        "content": resp.text,
    }

짚어둘 세 가지. tool_use_id는 Claude가 결과를 앞선 호출과 연결하는 방식이다 — 반드시 그대로 되돌려줘야 한다. is_error: True는 Claude에게 우아하게 복구하라고(흔히는 다른 쿼리를 시도해서) 알린다. 그리고 우리는 원시 응답 텍스트를 그대로 넘긴다 — Claude는 API Pick이 반환하는 JSON 형태를 편하게 다루므로 변환이 필요 없다.

3에이전트 루프

루프는 짧다: 대화를 보내고, 응답의 모든 블록을 살펴보고, 도구가 있으면 실행하고, Claude의 tool_use와 당신의 tool_result를 둘 다 대화에 덧붙이고, stop_reasonend_turn이 될 때까지 반복한다.

import anthropic

client = anthropic.Anthropic()

SYSTEM_PROMPT = """You are a research assistant. When the user asks a question:

1. Use web_search to find relevant sources. Prefer recent (set start_date) for time-sensitive queries.
2. Use extract_urls to read the body of the top 3-5 most relevant URLs from the search results.
3. Answer the question concisely. After every factual claim, include an inline citation in the form [source: URL].
4. If you don't have enough information, say so — don't fabricate.

Be concise. Aim for 3-5 sentence answers unless the user asks for depth."""

def research(question: str) -> str:
    messages = [{"role": "user", "content": question}]

    while True:
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            system=SYSTEM_PROMPT,
            tools=[WEB_SEARCH_TOOL, URL_EXTRACT_TOOL],
            messages=messages,
        )

        # Append the assistant's response (may contain tool_use blocks)
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            # Pull out the text answer
            return "\n".join(b.text for b in response.content if b.type == "text")

        if response.stop_reason == "tool_use":
            # Run every tool the model asked for in this turn
            tool_results = [
                call_tool(b)
                for b in response.content if b.type == "tool_use"
            ]
            messages.append({"role": "user", "content": tool_results})
            continue

        raise RuntimeError(f"Unexpected stop_reason: {response.stop_reason}")

모델이 한 턴에 여러 도구 호출을 발사할 수 있다는 점에 주목하라 — 예를 들어 URL 다섯 개를 배치로 묶으려고 extract_urls 호출 하나를 낼 수 있다. 우리는 어시스턴트 턴의 모든 tool_use 블록을 모으고 다음 사용자 턴에서 모든 tool_result 블록을 반환함으로써 이를 처리한다.

4실행하기

if __name__ == "__main__":
    answer = research("What were the major announcements at OpenAI DevDay this year?")
    print(answer)

이런 결과가 보인다:

OpenAI's DevDay focused on three themes: cheaper inference (a new gpt-mini
tier at roughly half the prior cost) [source: https://openai.com/devday-2026],
agent infrastructure (a managed agent runtime with persistent memory and
tool sandboxing) [source: https://techcrunch.com/...], and developer
tooling (a new Responses API replacing the legacy Assistants API)
[source: https://platform.openai.com/docs/...].

이것이 리서치 에이전트다. 약 120줄, 도구 두 개, 출처가 달린 출력.

비용은 얼마인가

전형적인 리서치 호출:

  • 검색 호출 1회 — 15 크레딧($0.015)
  • URL 3~5개를 커버하는 추출 호출 1회 — 6~10 크레딧($0.006~$0.010)
  • Claude 토큰 입력 약 3,000 + 출력 800 — 약 $0.02

대략의 수치: 리서치된 답변당 약 3센트. 하루 1,000회 리서치 호출이면 약 $30/일 — 분석가 한 명의 모닝 커피 예산 정도다. 예산은 검색 API가 아니라 LLM에 책정하라.

운영을 위한 세 가지 개선

1. 질문 단위 캐싱

당신의 제품이 비슷한 질문을 받는다면, (question, today's date)를 해시해서 최종 답변을 한 시간 동안 캐시하라. 대부분의 사용자 주도 리서치는 롱테일 적중률이 30%를 넘는다.

2. 신선도가 중요할 때 제약

'이번 주' / '오늘' 질문에는 검색 호출의 start_date를 현재 날짜나 어제로 설정하라. 없으면 검색이 작년 기사를 반환할 수 있다. 가장 단순한 버전: '질문에 today, this week, recent, latest가 포함되면 항상 start_date를 넣어라'는 시스템 프롬프트 규칙.

3. 환각 방지 가드레일

가장 큰 지렛대 효과를 내는 단일 규칙: '답하기에 충분한 추출 콘텐츠가 없으면, "이에 대한 신뢰할 만한 출처를 찾지 못했습니다"라고 반환하라. 추측하지 말라.' 이것을 시스템 프롬프트에 추가하면 우리 테스트에서 날조된 답변이 한 자릿수 배율로 떨어졌다.

다음으로 어디까지 갈까

자연스러운 확장 세 가지:

  • 버티컬화하기: 문헌 리뷰 에이전트라면 Web SearchAcademic Search로, 실사라면 SEC Filings Search로, 의학 리서치라면 Clinical Search로 바꿔라. 핸들러는 그대로다 — 바뀌는 것은 시스템 프롬프트뿐.
  • 구조화된 출력 추가: answercitations[] 필드를 가진 JSON을 반환하게 해서 UI 카드로 렌더링하라.
  • 토큰 스트리밍: 증분 출력을 위해 client.messages.stream()으로 전환하라 — 답변이 길 때 유용하다. 도구 호출 루프는 동일하다.

자주 묻는 질문

왜 단일 'answer' 엔드포인트 대신 도구 두 개인가?

검색과 추출을 분리하면 URL을 몇 개나 읽을지, 언제 멈출지, 어떤 것을 인용할지 제어할 수 있다. 단일 호스팅 'answer' 엔드포인트는 이를 감춰버려서 — 아키텍처를 다시 짜지 않고는 전략을 쉽게 바꿀 수 없다. 도구 두 개라면 같은 코드가 프롬프트만 손봐서 모닝 브리핑 에이전트, 문헌 리뷰어, 경쟁사 인텔 스크레이퍼가 된다.

OpenAI Assistants / Responses API에서도 동작하는가?

그렇다. 아키텍처는 동일하다 — 바뀌는 것은 tool-call 블록을 어떻게 파싱하고 결과를 어떻게 제출하느냐뿐이다. 핸들러 형태가 tool_result content 블록 대신 submit_tool_outputs(...)가 되지만, 에이전트 루프와 도구 정의는 같은 JSON이다.

에이전트는 언제 멈출지 어떻게 결정하는가?

Claude는 답하기에 충분한 컨텍스트가 있으면 stop_reason: end_turn을, 도구를 호출하고 싶으면 tool_use를 반환한다. 루프는 end_turn이 나올 때까지 계속 돈다. 실제로는 모델이 답하기 전에 보통 1~3회의 검색/추출 사이클을 돈다.

출처를 실제로 인용하게 하려면?

지렛대는 둘. 첫째: 시스템 프롬프트에서 모든 주장 뒤에 '[source: URL]'을 인라인으로 붙이도록 명시적으로 요구한다. 둘째: URL Extract 응답에 URL이 포함되므로, 모델이 그것을 컨텍스트에 가지고 있으면 자연스럽게 인용하는 경향이 있다. 인용이 누락되면, 답변을 거부하고 모델에게 인용을 추가하라고 요청하는 최종 포매팅 패스를 추가하라.

지연 시간은?

검색 왕복은 매번 약 1초, 추출 배치는 URL 개수와 JS 렌더링에 따라 1~4초다. 전형적인 '질문 리서치' 호출은 실측 5~15초에 안착한다. 더 빠르게 하려면 추출 단계를 병렬화하고(추출은 이미 한 번의 호출에서 URL 리스트를 받는다) 시스템 프롬프트 지시로 질문당 도구 1회 라운드로 모델을 제약하라.

이 글에서 사용한 API

Sarah Choy
작성
Sarah Choy
CEO, API Pick

Sarah Choy는 API Pick의 CEO입니다. AI 에이전트와 LLM 워크플로를 위한 프로덕션 등급 API에 대해 씁니다.