Зачем без фреймворка?
LangChain, LlamaIndex, CrewAI — мощные инструменты, но они скрывают то, что происходит «под капотом». Когда агент ломается на продакшне, вы не знаете, где именно и почему. Research-агент написан без фреймворков намеренно: каждая строка кода — ваша, каждое решение — объяснено.
После этого урока вы поймёте, что любой фреймворк для агентов — это просто обёртка над теми же примитивами: цикл while, список сообщений, вызов API. Понять примитивы важнее, чем выучить конкретный фреймворк.
Структура проекта
Проект разбит на слои. Каждый слой знает только о соседнем — это делает компоненты независимыми и тестируемыми по отдельности.
Архитектура: компоненты и их роли
Прежде чем смотреть на код, разберём, кто за что отвечает. Принцип единственной ответственности (SRP) — каждый модуль знает одну вещь и делает её хорошо.
Настройка и запуск
Для запуска нужен Python 3.11+ и ключ любого LLM-провайдера. Рекомендуем начать с GateLLM — он доступен из России и поддерживает мощные open-source модели.
# 1. Клонируем и устанавливаем зависимости
git clone <repo-url>
cd research-agent
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
# 2. Создаём .env из шаблона
cp .env.example .env
Откройте .env и укажите провайдер и ключ. Нужен только один:
# Вариант 1: GateLLM (OpenAI-совместимый, работает в России)
LLM_PROVIDER=gatellm
GATELLM_API_KEY=sk-...
DEFAULT_MODEL=qwen/qwen-2.5-72b-instruct
# Вариант 2: Anthropic Claude
LLM_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-...
DEFAULT_MODEL=claude-sonnet-4-6
# Вариант 3: Ollama (локально, бесплатно)
LLM_PROVIDER=ollama
OLLAMA_BASE_URL=http://localhost:11434
DEFAULT_MODEL=llama3.2
# Параметры агента
MAX_STEPS=10
REQUEST_TIMEOUT=30
# 3. Проверяем установку
pytest tests/ -x -q
python3 -c "from tools.registry import ToolRegistry; print(ToolRegistry().list_tools())"
# ['search_web', 'fetch_pages', 'summarize_page', 'write_report']
# 4. Первый запуск
python main.py "Что такое RAG и как его построить?"
# Опции
python main.py "тема" --provider deepseek --max-steps 15 --save --verbose
Поток данных: что происходит за один запрос
Разберём по шагам, что происходит, когда вы запускаете:
python main.py "RAG best practices"
main.py читает запрос из командной строки, создаёт LLMClient и Orchestrator, запускает asyncio.run(orchestrator.run("RAG best practices")).AgentState(query="RAG best practices") — пустая «память» сессии. В неё добавляется первое сообщение: Message(role="user", content="Research topic: RAG best practices").stop_reason: "tool_use" — решение вызвать search_web.tool_use с именем и аргументами. Orchestrator передаёт это в ToolRegistry.dispatch("search_web", query=...), который находит нужную Python-функцию и вызывает её.role="user" с блоком tool_result. Это требование Anthropic API — результаты инструментов передаются от имени пользователя.fetch_pages. Получает текст страниц. Снова вызов LLM... Так до тех пор, пока LLM не вызовет write_report.write_report. Сохраняет готовый отчёт в state.report и немедленно выходит из цикла. main.py выводит отчёт через Rich.Оркестратор: ReAct-цикл изнутри
Orchestrator — самый важный файл проекта. Давайте разберём его код по частям, начиная с системного промпта, который программирует поведение LLM.
Системный промпт — инструкции для LLM
LLM без инструкций может ответить на вопрос из памяти, не вызывая инструменты. Системный промпт задаёт жёсткие правила поведения.
SYSTEM_PROMPT = """\
You are a research assistant. Your job is to research a topic and produce a report.
RULES — follow them strictly:
1. You MUST use tools. Do NOT answer from memory alone.
2. You MUST end every session by calling the write_report tool.
3. Never stop with a plain text response — always call a tool.
WORKFLOW:
Step 1 — Call search_web with a specific query to find sources.
Step 2 — Call fetch_pages on the most promising URLs (pick 2-4).
Step 3 — If any page is very long, call summarize_page on it.
Step 4 — Call write_report with a structured Markdown report and all sources.
"""
write_report, зациклиться на поиске.
Чёткие правила в системном промпте — это программирование поведения модели.
Главный цикл
async def run(self, query: str) -> AgentState:
state = AgentState(query=query)
state.append_message(Message(role="user", content=f"Research topic: {query}"))
tools = self.registry.get_schemas() # JSON-схемы для LLM
while state.step < self.max_steps: # Инвариант 1: лимит шагов
state.increment_step()
log.info("react_step", step=state.step)
# --- Шаг 1: запрос к LLM ---
response = await self.llm.complete(
messages=state.to_api_messages(), # вся история
tools=tools, # доступные инструменты
system=SYSTEM_PROMPT,
)
# --- Шаг 2: сохраняем ответ LLM в историю ---
state.append_message(Message(role="assistant", content=response["content"]))
stop_reason = response.get("stop_reason", "")
# --- Случай А: LLM не вызвала инструмент ---
if stop_reason == "end_turn":
# ... обработка (см. ниже)
break
# --- Случай Б: LLM вызывает инструмент ---
if stop_reason == "tool_use":
tool_results = []
done = False
for block in response["content"]:
if block.get("type") != "tool_use":
continue
result_str = await self._dispatch_tool(
state, block["name"], block["input"], block["id"]
)
tool_results.append({
"type": "tool_result",
"tool_use_id": block["id"],
"content": result_str,
})
if block["name"] == "write_report":
done = True # Инвариант 5: write_report завершает
break
# Все результаты — одним сообщением user (требование API)
if tool_results:
state.append_message(Message(role="user", content=tool_results))
if done:
break
return state
Обработка end_turn: LLM не вызвала инструмент
Иногда LLM возвращает текст вместо вызова инструмента. Оркестратор обрабатывает три сценария:
if stop_reason == "end_turn":
text_parts = [b["text"] for b in response["content"] if b.get("type") == "text"]
if text_parts and not state.report:
# Сценарий 1: LLM написала отчёт как текст, не через write_report
# Сохраняем как fallback
state.report = f"# Research: {query}\n\n" + "\n\n".join(text_parts)
log.warning("fallback_report_from_text")
break
if not state.report and state.step < self.max_steps:
# Сценарий 2: пустой ответ — напоминаем LLM, что нужно написать отчёт
log.warning("end_turn_no_content_retry")
state.append_message(Message(
role="user",
content="You stopped without calling write_report. "
"You MUST call write_report now."
))
continue # ← ещё одна итерация
# Сценарий 3: отчёт уже есть или шаги закончились
break
while
с continue гарантирует постоянное потребление стека O(1) и соблюдение
лимита шагов max_steps.
Безопасный dispatch инструмента
async def _dispatch_tool(self, state, tool_name, tool_input, tool_use_id) -> str:
log.info("tool_dispatched", tool=tool_name, step=state.step) # Инвариант 2
try:
result = await self.registry.dispatch(tool_name, **tool_input)
if tool_name == "write_report":
state.report = result.content # сохраняем отчёт
for src in result.sources:
state.add_source(Source(...))
return f"Report written: {result.title}"
if tool_name == "search_web":
for r in result:
state.add_source(Source(url=r["url"], title=r.get("title", "")))
return json.dumps(result, ensure_ascii=False, default=str)
except ToolError as e:
# Инвариант 3: ToolError НИКОГДА не ломает цикл
log.warning("tool_error", tool=tool_name, error=str(e))
return f"Error executing {tool_name}: {e}" # LLM увидит ошибку и адаптируется
5 инвариантов цикла
Инвариант — утверждение, которое остаётся истинным при любых условиях. Это контракт между разработчиком и системой. В комментарии класса Orchestrator прописаны 5 инвариантов:
max_steps итераций, даже если write_report не был вызван. Защита от бесконечного зацикливания и бесконечных трат на API.break. Невозможна ситуация, когда агент продолжает работу после написания отчёта.Диаграмма состояний оркестратора
Полная картина всех переходов между состояниями цикла:
LLM-клиент: абстракция над провайдерами
llm_client.py — единственное место, где происходит запрос к API.
Оркестратор не знает, с каким провайдером работает — он вызывает
client.complete(messages, tools) и получает единый формат ответа.
class LLMClientProtocol(Protocol):
"""Интерфейс, которому должны соответствовать все клиенты."""
async def complete(
self,
messages: list[dict],
tools: list[dict],
system: str = "",
) -> LLMResponse: ...
# Все провайдеры возвращают единый формат:
{
"content": [
{"type": "text", "text": "Сначала поищу источники."},
{
"type": "tool_use",
"id": "tu_001",
"name": "search_web",
"input": {"query": "RAG best practices 2024"}
}
],
"stop_reason": "tool_use", # или "end_turn"
"usage": {"input_tokens": 150, "output_tokens": 30}
}
Поддерживаемые провайдеры: Anthropic (нативный SDK), OpenAI, DeepSeek, Qwen,
OpenRouter, GateLLM, Minimax, Ollama, Custom (любой OpenAI-совместимый).
Для смены провайдера достаточно изменить одну переменную в .env.
Шпаргалка
| Компонент | Файл | Роль |
|---|---|---|
| Orchestrator | agent/orchestrator.py | ReAct-цикл, 5 инвариантов, dispatch через ToolRegistry |
| AgentState | agent/state.py | Append-only история сообщений + источники + отчёт |
| LLMClient | agent/llm_client.py | Единственная точка работы с API; поддержка 9 провайдеров |
| ToolRegistry | tools/registry.py | JSON-схемы для LLM + dispatch по имени |
| search_web | tools/search.py | DuckDuckGo через asyncio.to_thread |
| fetch_pages | tools/fetch.py | Параллельный httpx + BeautifulSoup |
| summarize_page | tools/summarize.py | LLM-суммаризация длинного текста |
| write_report | tools/report.py | ★ Терминальный — завершает сессию |
| Settings | config/settings.py | Pydantic Settings: .env → типизированные настройки |
| stop_reason | Что означает | Действие оркестратора |
|---|---|---|
| "tool_use" | LLM хочет вызвать инструмент | dispatch → добавить tool_result → следующий шаг |
| "end_turn" | LLM завершила ответ текстом | fallback в отчёт или retry с напоминанием |
| "max_tokens" | Превышен лимит токенов | log warning, break |
Практическое задание
- Запустите агента с любым запросом и понаблюдайте за логами. Убедитесь, что видите: номер шага, имя вызванного инструмента, количество токенов.
-
Измените MAX_STEPS=2 в
.envи повторите запрос. Что происходит, когда шаги заканчиваются до вызоваwrite_report? Где в коде это обрабатывается? -
Найдите место в
orchestrator.py, где нарушение одного из 5 инвариантов привело бы к зависанию или некорректному отчёту. Напишите, какого инварианта не хватает в такой ситуации.