Собираем 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, использованные в статье
Сара Чой — CEO API Pick. Пишет о продакшен-готовых API для AI-агентов и LLM-воркфлоу.