Фреймворки — это костыли, а не серебряная пуля
Каждый второй пост в LinkedIn начинается с «Я запилил агента на LangGraph за 15 минут». Круто. А потом этот агент жрет токены, теряет контекст на третьем шаге и падает с cryptic error из-за скрытой зависимости в графе. Знакомо?
Фреймворки вроде LangChain, CrewAI или AutoGen обещают абстракцию, а по факту дают слой неопределенности. Когда ваш агент делает что-то не то, вы тратите часы на дебаг чужого кода вместо того, чтобы просто поправить логику. В 2026 году, когда модели стали дешевле и умнее (GPT-4.5 Turbo, Claude 3.7 Sonnet, Gemini 2.5 Pro), а OpenAI Responses API обзавелся Structured Outputs и стримингом «из коробки», писать агентов на чистом Python — не мазохизм, а рациональный выбор. Особенно на этапе прототипирования.
В этой статье я покажу, как собрать рабочего LLM-агента с нуля: без зависимостей, без магии, с полным контролем над каждым вызовом. Мы пройдем путь от простого «болтательного» цикла до агента с инструментами и структурированным выводом. И да, я покажу, как НЕ надо — на реальных граблях, которые я сам набил.
Архитектура простого автономного агента
Любой LLM-агент — это цикл: получил задачу -> подумал -> сделал действие -> получил результат -> подумал снова -> ... -> финальный ответ. Без фреймворков этот цикл пишется одним while True. Давайте разберем на конкретном примере: агент, который умеет искать информацию в интернете и отвечать на вопросы.
1Настраиваем клиент и модель
Используем openai версии >= 1.70 (на июнь 2026 актуальна 1.73). Ключ API можно брать напрямую или через прокси — например, AITunnel, который дает стабильный доступ к OpenAI из регионов с блокировками.
import os
from openai import OpenAI
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
MODEL = "gpt-4.5-turbo" # или gpt-4o, если бюджет меньше
2Базовый ReAct-цикл
Классический ReAct: агент получает промпт, модель генерирует Thought и Action (в виде JSON), мы парсим, выполняем инструмент, возвращаем Observation. Повторяем, пока не получится Final Answer.
SYSTEM_PROMPT = """Ты — полезный ассистент. У тебя есть доступ к инструментам:
- search(query: str) — поиск в интернете
Отвечай в формате JSON:
{"reasoning": "...", "action": "tool_name", "parameters": {...}}
или
{"reasoning": "...", "final_answer": "..."}
"""
def run_agent(user_query: str, max_steps: int = 5):
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
messages.append({"role": "user", "content": user_query})
for step in range(max_steps):
response = client.chat.completions.create(
model=MODEL,
messages=messages,
response_format={"type": "json_object"}
)
reply = response.choices[0].message.content
parsed = json.loads(reply)
if "final_answer" in parsed:
return parsed["final_answer"]
# Выполняем инструмент
tool_name = parsed.get("action")
params = parsed.get("parameters", {})
if tool_name == "search":
observation = search(params["query"])
else:
observation = "Unknown tool"
messages.append({"role": "assistant", "content": reply})
messages.append({"role": "user", "content": f"Observation: {observation}"})
return "Max steps reached. Partial result."
⚠️ Ошибка, которую я часто видел: не добавлять в историю собственное сообщение ассистента. Если пропустить messages.append с ответом модели, она не увидит предыдущий вывод и начнет «галлюцинировать» (или повторяться). Это частая причина контекстного дрейфа, о которой мы писали в статье «RLM: практическое руководство по созданию LLM-агентов без контекстного дрейфа».
3Структурированный вывод вместо хаоса
Парсить JSON из сырого ответа модели — рискованно: модель может забыть кавычки, поменять типы. Structured Outputs в OpenAI API (с мая 2025) решают это: вы задаете схему через Pydantic, и модель гарантированно возвращает валидный JSON.
from pydantic import BaseModel
from typing import Optional
class Action(BaseModel):
reasoning: str
action: Optional[str] = None
parameters: Optional[dict] = None
final_answer: Optional[str] = None
# В запросе указываем response_format
response = client.beta.chat.completions.parse(
model=MODEL,
messages=messages,
response_format=Action
)
parsed = response.choices[0].message.parsed # уже объект Action
Больше не нужно ловить json.JSONDecodeError. Это повышает надежность агента в разы. А если нужно передать сложные параметры — наследники Pydantic работают как швейцарские часы.
reasoning во все шаги — оно помогает дебажить. А в production можно убрать из финального ответа, оставив только результат.Как не скатиться в адскую рекурсию
Проблема, описанная в «Почему ломаются LLM-агенты» — агенты могут зацикливаться. Без фреймворков вы контролируете это сами:
- Лимит шагов (max_steps) — обязательно.
- Детектор повторений: если модель трижды подряд выдает одинаковое reasoning или action — прерываем.
- Circuit breaker: если стоимость вызовов превысила бюджет — стоп.
Вот простой детектор:
last_actions = []
for step in range(max_steps):
action = parsed.action
if action and action in last_actions[-2:]:
print(f"Detected loop at step {step}")
break
last_actions.append(action)
Этого достаточно для прототипа. В статье «Как правильно использовать суб-агентов» мы показывали, как делегировать подзадачи — это тоже снижает риск циклов, потому что каждый суб-агент живет свою короткую жизнь и умирает.
Добавляем настоящие инструменты: поиск, API, базы
В примере выше был абстрактный search(). Реальный инструмент — это простая функция, которая возвращает строку. Например, обертка над Google Search API или обертка над вашей внутренней базой.
Секрет в том, что инструменты не должны быть частью фреймворка. Вы сами решаете, как передавать результат — строкой, структурой, файлом. Для прототипа я бы рекомендовал всегда возвращать строку (observation). Модели удобнее работать с текстом, чем с бинарными данными.
def search_web(query: str) -> str:
# какой-то настоящий поиск
result = requests.get(f"https://api.duckduckgo.com/?q={query}&format=json")
return result.json().get("AbstractText", "No results")
⚠️ Ошибка: передавать слишком длинные результаты. Если модель получает на вход 10 000 токенов «наблюдения», она начинает игнорировать их и полагаться на свою память. Обрезайте результаты до 1000 символов, либо используйте суб-агент для суммаризации — как в «Skills, MCP и сабагенты».
Когда самописный агент превращается в монстра — и что делать
Наш простой цикл отлично работает для 3-5 шагов. Но как только вы захотите:
- поддерживать долгие диалоги (часы работы),
- параллельно вызывать несколько инструментов,
- иметь state-машину с памятью о предыдущих сессиях,
- оркестрировать десятки агентов —
ваш код распухнет, и вы начнете изобретать велосипед. Именно в этот момент стоит посмотреть в сторону легковесных фреймворков, но не перегруженных. Или разбить систему на микроагентов по принципу orchestrator-workers, описанному в статье «Как спроектировать современного AI-агента».
Но для прототипа — первый MVP, который вы покажете заказчику или протестируете гипотезу — 150 строк без фреймворков это идеальный старт. Вы получите полный контроль, а когда поймете, что именно нужно, — перепишете на фреймворк осознанно, а не по инерции.
Итог: голый код дисциплинирует
Фреймворки скрывают сложность, но они же скрывают и проблемы. Писать агента без них — значит каждый раз видеть, где модель ошибается, где контекст раздувается, где инструменты возвращают мусор. Это единственный способ научиться чувствовать агентную архитектуру. А когда навык сформирован — оборачивайте хоть в LangGraph, хоть в AutoGen.
Совет напоследок: начните прототип с одного файла и одного цикла. Не думайте о масштабировании на старте. Через день у вас будет работающий агент, который вы полностью понимаете. И это стоит дороже любого фреймворка.