Ваш AI-агент — просто болванка без интернета
Я перестал считать, сколько раз видел демо "агента", который отвечает на вопросы про погоду данными 2023 года. LLM без веб-доступа — это энциклопедия, которую закрыли на реставрацию. В 2026 году, когда свежие данные обновляются каждые несколько секунд, заставлять модель полагаться на внутренние знания — преступление против пользователя.
Проблема в том, что просто дать агенту поисковый API — мало. Google вернёт 10 синих ссылок, а модель должна их распарсить, извлечь суть и решить, что делать дальше. Это путь к лапше из кода и потерянному контексту. Нужна связка: фреймворк для агентов (с состояниями, инструментами, ReAct) и поиск, который отдаёт структурированные данные, а не html-огрызки.
Здесь появляются Strands Agents SDK (AWS, версия 2.0.0) и Exa (AI-native поисковик). О первом я рассказывал в контексте LangSmith Agent Builder, но сегодня копаем глубже. Exa, в отличие от Tavily или обычного Google Custom Search, выдаёт тип объекта, эмбеддинги, авторов — всё, что нужно агенту для принятия решений без лишнего парсинга.
Что конкретно мы построим
Агента, который:
- Принимает естественно-языковый запрос (например, "Расскажи последние новости про квантовые вычисления и проверь, подтвердил ли IBM свой roadmap")
- С помощью Exa ищет релевантные документы
- Извлекает полные тексты статей через
exa_get_contents - Формирует ответ с цитатами и ссылками
- При неоднозначности — задаёт уточняющий вопрос пользователю
Вся логика упакована в инструменты Strands SDK. Режим — ReAct с loop до 5 шагов.
Почему не взять готовый Tavily Search Agent
Потому что Tavily возвращает summary, а не сырой контент. Для фактчекинга это смерть — вы не можете проверить первоисточник. Exa отдаёт вам полный текст, метаданные, даты, а в платной версии — score достоверности. Кроме того, Exa умеет искать похожие документы по эмбеддингу, что идеально для Research Agent (я писал про подход Яндекса к DeepResearch — там та же идея, но на своей инфраструктуре).
Пошаговый план (с кровью и кодом)
1 Установка и ключи
Убедитесь, что Python >= 3.11. Strands SDK 2.0.0 требует pydantic>=2.0.
pip install strands-agents-sdk==2.0.0 exa-py requests
# Настройка переменных окружения
export STRANDS_API_KEY="sk-..."
export EXA_API_KEY="exa-..."
export OPENAI_API_KEY="sk-..." # или любой другой LLM провайдер
Кстати, Strands SDK теперь поддерживает не только AWS Bedrock, но и OpenAI, Anthropic, Google Gemini. В этом примере я использую GPT-4o-mini, потому что он дёшев и быстр.
2 Создаём инструменты Exa
Главная фишка — мы определяем два инструмента: exa_search (ищет ссылки) и exa_get_contents (грузит полный текст). Агент сам решает, когда какой вызывать.
from strands import Tool, Agent
from exa_py import Exa
exa = Exa(api_key="...")
def exa_search(query: str, num_results: int = 5) -> list[dict]:
"""Поиск в вебе через Exa. Возвращает список результатов с заголовком, url, snippet."""
response = exa.search(query, num_results=num_results, type="neural")
return [
{
"title": r.title,
"url": r.url,
"snippet": r.snippet,
"published_date": r.published_date
}
for r in response.results
]
def exa_get_contents(urls: list[str]) -> list[dict]:
"""Получает полный текст страниц по списку URL."""
response = exa.get_contents(urls)
return [
{
"url": c.url,
"text": c.text[:5000], # обрезаем, чтобы не раздувать контекст
"author": c.author
}
for c in response.contents
]
search_tool = Tool(name="exa_search", func=exa_search, description="Поиск в интернете. Используй, когда нужно найти свежие данные.")
contents_tool = Tool(name="exa_get_contents", func=exa_get_contents, description="Получить полный текст страницы по URL. Используй после поиска.")
⚠️ Частая ошибка: type="neural" в Exa даёт семантический поиск, но медленнее. Для новостей лучше type="keyword" — дешевле и быстрее. Экспериментируйте.
3 Собираем агента
Strands SDK использует declarative конфиг. Мы задаём LLM, список инструментов, максимальное число итераций и системный промпт.
agent = Agent(
name="web_researcher",
llm={
"provider": "openai",
"model": "gpt-4o-mini-2026-05-12",
"api_key": "..."
},
tools=[search_tool, contents_tool],
max_iterations=5,
system_prompt="""Ты — исследовательский агент. Твоя задача — отвечать на вопросы пользователя, используя веб-поиск.
Алгоритм:
1. Если вопрос подразумевает свежие данные, сначала вызови exa_search.
2. Если нужно проверить детали, вызови exa_get_contents для конкретных URL.
3. Обязательно указывай источники в ответе в формате [1], [2] и ссылки.
4. Если информации недостаточно, задай уточняющий вопрос."""
)
# Запуск
result = agent.run("Какие новые модели LLM выпустили в апреле 2026? Найди 3 источника и суммируй их.")
print(result)
4 Добавляем валидацию и кэширование (production-ready)
Чистый код из прошлого шага упадёт при rate limit Exa (50 req/min на бесплатном плане). Используем декоратор с повторными попытками и кэширование в Redis, чтобы не долбить API одинаковыми запросами.
from strands.cache import RedisCache
from tenacity import retry, stop_after_attempt, wait_exponential
cache = RedisCache(ttl=3600) # час
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def exa_search_cached(query: str, num_results: int = 5) -> list[dict]:
cache_key = f"exa_search:{query}:{num_results}"
if cached := cache.get(cache_key):
return cached
result = exa_search(query, num_results)
cache.set(cache_key, result)
return result
# Переопределяем инструмент
search_tool_cached = Tool(name="exa_search", func=exa_search_cached, description=...)
Реальный сценарий: фактчекинг заявления
Допустим, пользователь говорит: "Я слышал, что Anthropic выпустила модель Claude 4.5 в мае 2026. Это правда?". Агент запускает поиск, получает 5 ссылок, среди них — официальный блог Anthropic и новость на TechCrunch. Затем вызывает exa_get_contents для этих двух URL, читает текст и отвечает: "Да, 12 мая 2026 Anthropic анонсировала Claude 4.5 (source: blog.anthropic.com [1]), но TechCrunch пишет [2], что это не замена Claude 4, а промежуточная версия."
Грабли, на которые я наступил (и вы наступите)
- Контекстное окно. Exa возвращает до 10 000 символов на страницу. После двух страниц контекст может переполниться. Решение: обрезайте текст до 2000-3000 символов в инструменте, а если нужно больше — вызывайте отдельно.
- Галлюцинации ссылок. Модель может выдумать URL, которых нет в результатах Exa. Всегда проверяйте: в инструменте возвращайте список
{url, text}, а в промпте требуйте ссылаться только на реальные URL из вызова. - Rate limit Exa. Бесплатный план — 50 запросов/мин. На бесплатном плане я получал 429 ошибки. Решение: кэширование (см. шаг 4) и очередь через
asyncioдля параллельных запросов.
Кстати, про rate limit отлично описано в статье о снижении латентности поиска — там показан подход с батчингом запросов, который легко адаптировать под Exa.
Сравнение: Strands + Exa vs. другие связки
| Критерий | Strands + Exa | LangChain + Tavily | n8n + SerpAPI |
|---|---|---|---|
| Контроль над контентом | Полный текст (raw) | Только summary | Только snippet |
| Стоимость (1000 запросов) | ~$3 (Exa) + LLM | ~$5 (Tavily Pro) | ~$10 (SerpAPI Business) |
| Простота интеграции с Strands | Прямая (3 строки кода) | Через LangChain Tool | Через HTTP API |
Как НЕ надо делать (мой утренний провал)
Я решил сэкономить и не обрезать текст в exa_get_contents. Результат: агент получил 25 000 токенов контекста, запутался и начал выдумывать факты из середины статьи. Пришлось добавить обрезание до 3000 символов и явно указать в промпте: "Используй только первые 3000 символов. Если нужно больше — запроси другой URL".
# Как НЕ надо
def exa_get_contents_no_limit(urls: list[str]):
return exa.get_contents(urls) # вернёт всё
# Как надо
def exa_get_contents_trimmed(urls: list[str]):
contents = exa.get_contents(urls)
trimmed = []
for c in contents:
if len(c.text) > 3000:
c.text = c.text[:3000] + "... [обрезано]"
trimmed.append(c)
return trimmed
Бонус: деплой в production
Strands SDK из коробки поддерживает мониторинг через CloudWatch и X-Ray. Но для простых сценариев достаточно обернуть агента в FastAPI endpoint:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Query(BaseModel):
text: str
@app.post("/ask")
def ask(query: Query):
result = agent.run(query.text)
return {"answer": result.output, "trace": [s.name for s in result.steps]}
И да, не забудьте добавить лимитер запросов на уровне API, чтобы один пользователь не сжёг ваш Exa budget. Я использую slowapi с 10 req/min на IP.
Что дальше?
Если вы дочитали до сюда, у вас есть работающий агент с веб-поиском. Но не останавливайтесь. Добавьте sub-agent, который будет ходить за дополнительными источниками — про это есть отдельная статья с тремя сценариями. Или подключите гибридный RAG с OpenSearch (мы разбирали здесь), чтобы агент умел искать не только в интернете, но и в вашей базе знаний.
А самое смешное, что через полгода выйдет Strands SDK 3.0 с нативной поддержкой Exa (судя по дорожной карте AWS), и весь этот код можно будет заменить двумя строками. Но пока — пишем руками и не жалуемся.