Введение
Первый ИИ-агент у большинства так и не покидает пределов ноутбука. Его запускают один раз в терминале, получают приличный ответ, и на этом всё заканчивается — никто не дописывает те пятнадцать строк связующего кода, которые превращают скрипт в инструмент, доступный другим людям или системам. Разрыв между «заработало при запуске» и «работает в продакшене, и им кто-то пользуется» — это то место, где тихо умирает большинство проектов с агентами.
Цифры подтверждают: около 79% компаний заявляют, что в том или ином виде внедрили ИИ-агентов, но в реальном продакшене работает лишь примерно 11% — по данным сводки за 2026 год, собранной из источников Gartner, McKinsey и IDC. Это огромный разрыв, и он почти никогда не вызван слабостью модели. Дело в том, что никто толком не определил объём задачи, не продумал обработку ошибок или не потрудился упаковать решение в контейнер и дать ему URL.
Эта статья поможет закрыть этот разрыв на примере одного конкретного проекта. К концу вы создадите и развернёте исследовательского агента: даёте ему тему, он ищет информацию в интернете, собирает источники и выдаёт короткий текстовый отчёт со ссылками. Задача достаточно сложна, чтобы потребовать реального использования инструментов, памяти и ограждений, но при этом достаточно компактна, чтобы сделать всё за один присест. Все блоки кода ниже — полные и с комментариями, каждый сопровождается простым объяснением, что он делает и зачем.
Перед стартом важное замечание: мы строим агента на базе LangGraph, а не CrewAI или закрытых SDK какого-то одного вендора. CrewAI быстрее приводит к работающему прототипу, но LangGraph к 2026 году стал почти стандартом для stateful-агентов в продакшене благодаря встроенному чекпоинтингу и графовой модели, которая масштабируется без полной переделки. Если вам удобнее прототипировать в CrewAI, концепции из шагов 1, 2, 6 и 7 переносятся напрямую; будет отличаться только код в шагах 4 и 5.

Шаг 1: Определяем, что агент должен делать (и чего не должен)
Прежде чем открывать редактор, запишите три вещи: единственная задача агента, как выглядит успешный результат и что ему никогда не разрешено делать без проверки человеком.
Для нашего исследовательского агента это выглядит так:
Задача: получить тему на вход, выполнить поиск в интернете, прочитать результаты и выдать письменный отчёт с резюме и списком источников.
Успех: связный, подкреплённый фактами отчёт объёмом до 500 слов, где каждое утверждение можно отследить по URL-источнику.
Жёсткое ограничение: агенту разрешено искать и читать свободно, но он никогда ничего не публикует, не отправляет и не записывает в файлы за пределами своей рабочей области без явного одобрения человека.
Третий пункт важнее, чем кажется. Пропуск этого шага — одна из причин, почему Gartner прогнозирует отмену более 40% проектов с агентным ИИ к концу 2027 года: обычно границы не были определены заранее, и проект либо делал слишком мало, чтобы быть полезным, либо слишком много, чтобы ему доверяли.

Запишите свои три предложения для того, что будете строить после этого урока. Это займёт пять минут и избавит от необходимости переделывать всё на полпути.
Шаг 2: Выбираем модель и фреймворк
Здесь два решения: какая модель будет рассуждать и какой фреймворк управлять циклом «думать — действовать — проверять результат».
Что касается модели — подойдёт любая современная передовая модель с надёжной поддержкой инструментов: Claude, GPT, Gemini. В коде мы используем Claude, потому что его вызовы инструментов стабильно работают в многошаговых задачах, но замена на другого провайдера меняет только одну строку.
По фреймворкам ситуация на вторую половину 2026 года такова:
- LangGraph моделирует агента как узлы и рёбра графа, со встроенным чекпоинтингом, так что упавший запуск может продолжиться, а не начинаться заново. Ежемесячно более 38 миллионов скачиваний из PyPI, используют в production Klarna, Uber, LinkedIn. Плата — более крутая кривая обучения, обычно требуется неделя-две, чтобы всё уложилось в голове.
- CrewAI представляет агентов как «команду» с ролями и целями, и работающий прототип можно получить меньше чем в 20 строках кода. Самый быстрый способ проверить идею, собрал более 44 тысяч звёзд на GitHub, но даёт меньше контроля, когда рабочий процесс усложняется, и команды часто перерастают его и переходят на LangGraph.
- AutoGen, когда-то стандартный выбор для многоагентных паттернов общения, стоит упомянуть лишь чтобы отсоветовать для новых проектов. Microsoft перевела его в режим поддержки и переместила разработку в единый Microsoft Agent Framework, так что в 2026 году это не та основа, на которой стоит строить что-то новое.
- SDK вендоров, такие как OpenAI Agents SDK или Anthropic Claude Agent SDK, заслуживают внимания, если вы полностью привязаны к одному провайдеру и хотите минимум фреймворчного оверхеда, но они запирают вас на моделях этого вендора.
Мы используем LangGraph для этой сборки, потому что исследовательскому агенту на пользу чекпоинтинг (поиск, завершившийся по тайм-ауту, не должен означать начало заново), и потому что этот навык напрямую переносится на реальную production-работу.
Шаг 3: Настройка проекта
Создайте папку, виртуальное окружение и установите всё необходимое.
# Create and enter the project folder
mkdir research-agent && cd research-agent
# Create a virtual environment so dependencies stay isolated
python3 -m venv venv
source venv/bin/activate # on Windows use: venv\Scripts\activate
# Install the core libraries:
# langgraph - the agent orchestration framework
# langchain-anthropic - lets LangGraph call Claude models
# langchain-community - gives us the Tavily search tool and the page loader
# beautifulsoup4 - the HTML parser the page loader depends on
# python-dotenv - loads API keys from a .env file instead of hardcoding them
pip install langgraph langchain-anthropic langchain-community python-dotenv tavily-python beautifulsoup4Что делает этот блок: виртуальное окружение изолирует пакеты этого проекта от всего остального на вашей машине, избегая классической проблемы, когда обновление зависимости в одном проекте незаметно ломает другой. Установленные пакеты: langgraph — оркестрация, langchain-anthropic — связь с моделью, langchain-community и tavily-python — готовые инструменты поиска и загрузки страниц, beautifulsoup4 — парсер HTML, python-dotenv — для безопасной загрузки ключей из .env.
Далее создайте файл .env в корне проекта для хранения ключей. Вам понадобится API-ключ Anthropic из Anthropic Console и ключ поискового API от Tavily, где бесплатного тарифа вполне хватит для этого урока.
# .env file, never commit this to version control
ANTHROPIC_API_KEY=your-anthropic-key-here
TAVILY_API_KEY=your-tavily-key-hereТеперь структура папок:
research-agent/
├── venv/
├── .env
├── .gitignore
├── agent.py
├── app.py
├── requirements.txt
└── DockerfileЧто это даёт: разделение agent.py (логика агента) и app.py (веб-сервер, который его предоставляет) сохраняет читаемость кода и позволяет тестировать агента прямо в терминале, прежде чем оборачивать в API. Добавьте .env и venv/ в .gitignore, чтобы ключи никогда не попали в репозиторий.
Шаг 4: Основной цикл агента
Это сердце проекта: цикл, в котором агент читает задачу, решает, нужен ли инструмент, вызывает его, читает результат и определяет, что делать дальше. Откройте agent.py и соберите по частям.
# agent.py
from dotenv import load_dotenv
from langchain_anthropic import ChatAnthropic
from langchain_community.tools.tavily_search import TavilySearchResults
from langgraph.prebuilt import create_react_agent
# Load API keys from the .env file
load_dotenv()
# Initialize the model. Temperature is kept low because we want
# consistent, grounded output rather than creative variation.
model = ChatAnthropic(
model="claude-sonnet-4-6",
temperature=0.2,
max_tokens=1500,
)
# Set up the search tool. max_results caps how many pages come back
# per search so the agent doesn't drown in low-quality results.
search_tool = TavilySearchResults(max_results=5)
# create_react_agent wires the model and tools into a working loop:
# the model reads the task, decides if it needs the tool, calls it,
# reads the result, and repeats until it has enough to answer.
agent = create_react_agent(model, tools=[search_tool])
def run_research(topic: str) -> str:
"""Takes a topic string and returns the agent's final written brief."""
result = agent.invoke({
"messages": [
(
"system",
"You are a research assistant. When given a topic, search "
"for current, credible information and write a brief under "
"500 words. Every factual claim must be followed by the "
"source URL in parentheses. If sources disagree, say so."
),
("user", topic),
]
})
# The final message in the returned list is the agent's answer
return result["messages"][-1].content
if __name__ == "__main__":
topic = input("What should I research? ")
print(run_research(topic))Что происходит по строкам: load_dotenv() считывает ключи из .env, так что ничего секретного в коде нет. ChatAnthropic настраивает соединение с моделью, низкая температура держит ответы приземлёнными, а не изобретательными — для исследовательского инструмента важнее точность, чем разнообразие. TavilySearchResults даёт агенту реальный доступ к живому интернету, а не только к тому, что модель уже знает. create_react_agent — это короткий путь LangGraph для построения классического цикла рассуждения и действия (ReAct) без ручного описания узлов и рёбер графа. Системное сообщение внутри run_research тоже делает реальную работу: оно заставляет агента ссылаться на источники и отмечать расхождения, а не просто выдавать уверенно звучащий абзац.
Запустите в терминале python agent.py, введите тему, и вы получите короткий отчёт со ссылками. Если зависает или сыпет ошибками — проверьте, что оба ключа в .env правильные и что вы находитесь в активированном виртуальном окружении.
Шаг 5: Память и второй инструмент
Сейчас агент всё забывает, как только завершает работу. Для одного вопроса это нормально, но всё ломается, стоит задать уточняющий вопрос вроде «а теперь сравни это с X». Исправим это, добавив память диалога, и добавим второй инструмент, чтобы агент мог извлечь полное содержимое страницы, когда поискового сниппета недостаточно.
# agent.py (updated)
from dotenv import load_dotenv
from langchain_anthropic import ChatAnthropic
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_community.document_loaders import WebBaseLoader
from langchain_core.tools import tool
from langgraph.prebuilt import create_react_agent
from langgraph.checkpoint.memory import MemorySaver
load_dotenv()
model = ChatAnthropic(model="claude-sonnet-4-6", temperature=0.2, max_tokens=1500)
search_tool = TavilySearchResults(max_results=5)
@tool
def read_page(url: str) -> str:
"""Fetches the readable text of a single web page, given its URL.
Use this when a search result snippet doesn't have enough detail."""
try:
loader = WebBaseLoader(url)
docs = loader.load()
# Trim to 3000 characters so one long page doesn't eat the context budget
return docs[0].page_content[:3000]
except Exception as e:
return f"Could not load that page: {e}"
# The system prompt now lives on the agent itself. Once a checkpointer is
# saving history, re-sending it with every call would stack up duplicate
# copies of the same instructions inside the thread.
SYSTEM_PROMPT = (
"You are a research assistant. Search for current, credible "
"information and write a brief under 500 words. Cite every claim "
"with the source URL in parentheses. Use the read_page tool when a "
"search snippet is too thin to confirm a fact."
)
# MemorySaver checkpoints the conversation so the agent remembers
# earlier turns within the same thread_id
memory = MemorySaver()
agent = create_react_agent(
model,
tools=[search_tool, read_page],
prompt=SYSTEM_PROMPT,
checkpointer=memory,
)
def run_research(topic: str, thread_id: str = "default") -> str:
"""thread_id lets you keep separate memory per user or session."""
config = {"configurable": {"thread_id": thread_id}}
result = agent.invoke({"messages": [("user", topic)]}, config=config)
return result["messages"][-1].content
if __name__ == "__main__":
thread = "cli-session-1"
while True:
topic = input("\nWhat should I research? (or 'quit') ")
if topic.lower() == "quit":
break
print(run_research(topic, thread_id=thread))Что изменилось и зачем: WebBaseLoader даёт агенту второй инструмент read_page, который извлекает полный текст конкретного URL, а не короткий сниппет из поиска. Декоратор @tool превращает обычную функцию Python в нечто, что агент может вызывать самостоятельно, а docstring прямо под определением функции — не просто документация; модель читает его, чтобы понять, когда инструмент полезен. MemorySaver — это встроенный чекпоинтер LangGraph, позволяющий агенту помнить предыдущие реплики в той же беседе, а не воспринимать каждое сообщение как начало с нуля. Именно поэтому системный промт был вынесен из run_research и прикреплён к самому агенту: всё, что передаётся в списке messages, записывается в сохранённый поток, так что системное сообщение, отправляемое при каждом вызове, накапливало бы свои копии. thread_id — это ключ, разделяющий память разных диалогов; если бы вы обслуживали нескольких пользователей, каждый получал бы свой поток, а не смешивался с чужими.

Шаг 6: Добавляем ограждения, прежде чем доверять
Агент, который только ищет и читает, несёт низкий риск. Совсем другое дело — агент, способный действовать на основе найденного, и даже read-only агенту нужны ограничения по затратам, бесконечным циклам и некорректному вводу. Этот шаг пропускают в большинстве руководств, и это реальная причина, почему многие проекты с агентами застревают, не доходя до продакшена.
# agent.py (guardrails added)
# add "import time" to the imports at the top of the file
import time
MAX_TOPIC_LENGTH = 300
MAX_RETRIES = 2
RECURSION_LIMIT = 15
def validate_topic(topic: str) -> str:
"""Rejects empty or suspiciously long input before it reaches the model."""
topic = topic.strip()
if not topic:
raise ValueError("Topic can't be empty.")
if len(topic) > MAX_TOPIC_LENGTH:
raise ValueError(f"Keep the topic under {MAX_TOPIC_LENGTH} characters.")
return topic
def run_research_safely(topic: str, thread_id: str = "default") -> str:
"""Wraps the agent call with input validation, a retry on transient
failures, and a hard cap so one bad run can't loop forever."""
topic = validate_topic(topic)
for attempt in range(1, MAX_RETRIES + 1):
try:
# recursion_limit caps how many reasoning/tool-call steps
# the agent can take in a single run, preventing runaway loops
result = agent.invoke(
{"messages": [("user", topic)]},
config={
"configurable": {"thread_id": thread_id},
"recursion_limit": RECURSION_LIMIT,
},
)
return result["messages"][-1].content
except Exception as e:
if attempt == MAX_RETRIES:
return f"Research failed after {MAX_RETRIES} attempts: {e}"
time.sleep(2) # brief pause before retryingЧто это делает: validate_topic отлавливает очевидно плохой ввод — пустые строки или абсурдно длинный текст — ещё до того, как он дойдёт до модели, экономя лишний вызов API и непонятную ошибку. recursion_limit — самая важная настройка в этом блоке: она ограничивает число шагов рассуждения и вызовов инструментов, которые агент может совершить за один запуск, так что поиск, постоянно возвращающий бесполезные результаты, не превратится в бесконечный цикл, тихо сжирающий ваш бюджет. Цикл повторов обрабатывает обычные случаи вроде кратковременного сбоя сети или срабатывания rate limit, давая ещё один шанс, прежде чем аккуратно сдаться, а не обрушиться с ошибкой. Обратите внимание, что системный промт здесь вообще не фигурирует, потому что он прикреплён к агенту с предыдущего шага и автоматически применяется к каждому вызову.
Если вы когда-нибудь расширите этого агента действиями, выходящими за пределы поиска и чтения (отправка email, публикация чего-либо, запись в общий файл), — именно в этот момент нужно добавить шаг подтверждения человеком перед выполнением действия, а не после. Read-only исследовательскому агенту такой шлагбаум строго не обязателен, но стоит выработать привычку уже сейчас, чтобы на следующем, более рискованном проекте она была второй натурой.
Шаг 7: Деплой в реальное окружение
Работающий скрипт на ноутбуке — ещё не развёрнутый агент. Чтобы сделать его доступным отовсюду, мы обернём его в небольшое API на FastAPI, упакуем в Docker-контейнер и отправим на Railway — это один из самых простых путей для подобных CPU-bound (не GPU) агентных нагрузок.
# app.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from agent import run_research_safely
app = FastAPI(title="Research Agent API")
class ResearchRequest(BaseModel):
topic: str
thread_id: str = "default"
@app.get("/health")
def health_check():
"""Basic health check so the hosting platform knows the service is alive."""
return {"status": "ok"}
@app.post("/research")
def research(request: ResearchRequest):
"""Accepts a topic and returns the agent's written brief."""
try:
brief = run_research_safely(request.topic, request.thread_id)
return {"topic": request.topic, "brief": brief}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))Что это делает: FastAPI превращает Python-функцию в HTTP-эндпоинт, который может вызвать любое приложение, фронтенд или скрипт простым POST-запросом. Маршрут /health значит больше, чем кажется: хостинговые платформы пингуют этот эндпоинт, чтобы проверить, жив ли сервис, и без него медленный деплой или упавший процесс могут выглядеть как «работает», хотя на самом деле нет. Блок try/except преобразует плохой ввод в правильный HTTP 400, а не в непонятный крах с кодом 500.
Теперь зависимости:
# requirements.txt
langgraph
langchain-anthropic
langchain-community
tavily-python
beautifulsoup4
python-dotenv
fastapi
uvicornИ контейнер:
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
# Install dependencies first so Docker can cache this layer
# and skip reinstalling on every code change
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the application code
COPY . .
# Railway and most platforms inject a PORT variable at runtime, so read it
# here and fall back to 8000 when you run the container locally
EXPOSE 8000
CMD ["sh", "-c", "uvicorn app:app --host 0.0.0.0 --port ${PORT:-8000}"]Что это даёт: копирование requirements.txt и его установка до копирования остального кода — маленькая хитрость, ускоряющая пересборку. Docker переустанавливает зависимости только когда меняется этот файл, а не при каждом изменении строки Python. Команда uvicorn запускает сервер FastAPI внутри контейнера, слушая все сетевые интерфейсы, чтобы хостинговая платформа могла направить трафик, и читает порт из переменной окружения, чтобы работало и локально, и на хосте, который сам назначает порт. Добавьте также .dockerignore с содержимым .env и venv/, чтобы COPY . . не запёк ваши ключи в образ.
Для деплоя на Railway достаточно запушить репозиторий на GitHub, подключить его в панели управления Railway и добавить оба API-ключа как переменные окружения в настройках проекта, а не в коде. Railway обнаружит Dockerfile автоматически, соберёт и выдаст публичный URL. Это совпадает с рекомендациями большинства современных гайдов для слоя оркестрации агентов именно потому, что такая нагрузка — это CPU-bound Python, а не GPU-инференс, так что вам не нужно платить за GPU-инфраструктуру и управлять ей. Если позже вашему агенту потребуется запускать собственные веса моделей или более тяжёлые вычисления, естественным следующим шагом будет Modal, но для инструментального агента, как этот, достаточно обычного контейнерного хостинга.
Когда всё готово, вызов агента из любого места выглядит так:
curl -X POST https://your-app.up.railway.app/research \
-H "Content-Type: application/json" \
-d '{"topic": "current trends in solid-state batteries"}'Этот единственный запрос — итог всех предыдущих шагов: тема на входе, реальный HTTP-вызов запускает цикл агента, и на выходе — отчёт с источниками, работающий на инфраструктуре, за которой не нужно следить из терминала.
Итоги
Теперь у вас есть агент, который ищет, читает, помнит предыдущие обращения, защищён от собственных сбоев и отвечает по реальному URL, а не в локальном терминале. Это дальше, чем добирается большинство первых проектов с агентами, и разрыв между этим и демками, на которых застревают остальные, почти полностью состоит из ограждений и деплоя — тех шагов, которые большинство руководств пропускает.
Следующий логичный шаг — добавить третий инструмент (например, способ сохранять отчёты в базу данных, а не только возвращать их) или, если перейдёте к более крупной многоагентной системе, посмотреть, как прототип на CrewAI может сидеть перед тем же ядром LangGraph для частей, требующих более быстрой итерации. В любом случае, вы уже сделали самое трудное: у вас есть работающий инструмент, которым могут пользоваться другие люди.