[ blog · tutorial ]9 min read

Собираем research-агента на Search + URL Extract (Claude tool use, от начала до конца)

Sarah ChoyОпубликовано 3 мая 2026 г.9 мин чтения
Собираем research-агента на Search + URL Extract (Claude tool use, от начала до конца)

Большинство туториалов про 'AI research-агента' заканчиваются на 'вот определение инструмента'. Этот шипит рабочего агента: вопрос на входе, ответ с цитатами на выходе. Поиск, извлечение, рассуждение, цитаты — всё меньше чем в 120 строках Python.

Кратко

  • Минимальный полезный цикл research-агента: поиск → выбор ссылок → извлечение текста → запрос к модели на ответ с инлайновыми цитатами.
  • Два инструмента — Web Search (15 кредитов) и URL Extract (2 кредита за URL) — покрывают 95% сценариев с обоснованным ответом.
  • Claude tool use берёт на себя оркестрацию; вы лишь гоняете блоки tool_use → вызовы API → блоки tool_result, пока модель не остановится.
  • Сквозная стоимость: ~25 кредитов + ~$0.02 в LLM-токенах на вопрос при типичной глубине.

Что мы собираем

Research-агент, который принимает один вход — вопрос на естественном языке — и возвращает ответ с источниками. Архитектурно:

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

Около 120 строк Python. Два инструмента — поиск и извлечение — и один цикл агента, который обрабатывает всё, что решит сделать Claude. Соберём с нуля за 4 шага.

1Тянем схемы инструментов (никакого JSON руками)

Оба эндпоинта публикуют tool-schema-роут, возвращающий определение Claude-инструмента ровно в той форме, которую ожидает messages.create.

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 нормально работает с JSON-формой, которую возвращает API Pick, так что никакой трансформации не нужно.

3Цикл агента

Цикл короткий: отправляем диалог, смотрим каждый блок в ответе, выполняем все инструменты, дописываем и tool_use от Claude, и ваш tool_result в диалог, повторяем, пока stop_reason не станет end_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}")

Обратите внимание: модель может выпустить несколько вызовов инструментов за один ход — например, один вызов extract_urls с пятью URL, чтобы обработать их пачкой. Мы обрабатываем это, собирая все блоки 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/...].

Вот и research-агент. ~120 строк, два инструмента, вывод с источниками.

Сколько это стоит

Типичный research-вызов:

  • 1 вызов поиска — 15 кредитов ($0.015)
  • 1 вызов извлечения по 3–5 URL — 6–10 кредитов ($0.006–$0.010)
  • ~3 000 входных + 800 выходных токенов Claude — ~$0.02

Круглая цифра: ~3 цента за исследованный ответ. При 1 000 research-вызовов в день это ~$30/день — примерно бюджет одного аналитика на утренний кофе. Закладывайте в бюджет LLM, а не поисковые API.

Три продакшен-доработки

1. Кеш по вопросу

Если продукт задаёт похожие вопросы, хешируйте (question, today's date) и кешируйте финальный ответ на час. У большинства пользовательских исследований long-tail-попадания превышают 30%.

2. Ограничивайте, когда важна свежесть

Для вопросов 'на этой неделе' / 'сегодня' задавайте start_date в вызове поиска на текущую дату или вчерашнюю. Без этого поиск может вернуть прошлогодние статьи. Простейший вариант: правило в системном промпте вроде 'always include start_date when the question contains today, this week, recent, or latest'.

3. Защита от галлюцинаций

Правило с наибольшим рычагом: 'If you don't have enough extracted content to answer, return: I couldn't find a reliable source for this. Do not guess.' Добавление этого в системный промпт снижает выдуманные ответы на порядок в наших тестах.

Куда развивать дальше

Три естественных расширения:

  • Сделать вертикальным: замените Web Search на Academic Search для агента обзора литературы, SEC Filings Search для due-diligence или Clinical Search для медицинских исследований. Хендлер не меняется — меняется только системный промпт.
  • Добавить структурированный вывод: попросите модель возвращать JSON с полями answer и citations[], чтобы рендерить их как UI-карточку.
  • Стримить токены: переключитесь на client.messages.stream() для инкрементального вывода — полезно, когда ответ длинный. Цикл вызова инструментов тот же.

Часто задаваемые вопросы

Почему два инструмента, а не один эндпоинт 'answer'?

Разделение поиска и извлечения даёт контроль над тем, сколько URL читать, когда остановиться и какие из них цитировать. Единый хостед-эндпоинт 'answer' это прячет — стратегию нельзя легко поменять без переархитектуры. С двумя инструментами тот же код становится агентом утренней сводки, обзорщиком литературы или скрапером конкурентной разведки — достаточно подправить промпт.

Работает ли это с OpenAI Assistants / Responses API?

Да. Архитектура идентична — меняется лишь то, как вы парсите блоки tool-call и отправляете результаты. Хендлер принимает форму submit_tool_outputs(...) вместо контент-блока tool_result, но цикл агента и определения инструментов — тот же JSON.

Как агент решает, когда остановиться?

Claude возвращает stop_reason: end_turn, когда контекста достаточно для ответа, и tool_use, когда хочет вызвать инструмент. Цикл просто крутится, пока не наступит end_turn. На практике модель обычно делает 1–3 цикла поиск/извлечение перед ответом.

Как убедиться, что он действительно цитирует источники?

Два рычага. Первый: в системном промпте явно требуйте инлайн '[source: URL]' после каждого утверждения. Второй: ответ URL Extract содержит URL, поэтому, имея его в контексте, модель цитирует естественно. Если цитаты теряются, добавьте финальный проход форматирования, который отклоняет ответ и просит модель добавить цитаты.

А что с задержкой?

Один round-trip поиска — ~1 с; один батч извлечения — 1–4 с в зависимости от числа URL и JS-рендеринга. Типичный вызов 'исследовать вопрос' укладывается в 5–15 с по wall-clock. Если нужно быстрее — распараллельте шаг извлечения (extract уже принимает список URL за один вызов) и ограничьте модель одним раундом инструментов на вопрос инструкциями в системном промпте.

API, использованные в статье

Sarah Choy
Автор
Sarah Choy
CEO, API Pick

Сара Чой — CEO API Pick. Пишет о продакшен-готовых API для AI-агентов и LLM-воркфлоу.