Короче, на этой неделе я собрал маленький AI-агент
Не чат-бот. Настоящий агент. Даёшь ему вопрос — он сам лезет в интернет, читает что нашёл, решает хватает или надо ещё покопать, и выдаёт ответ со ссылками. Строк двести-триста на Python. Ничего особенного.
Но пока делал, куча buzzword-ов наконец встала на место, и я решил записать что реально понял — с настоящим кодом, потому что именно он всё и прояснил. Весь процесс меня преследовал один занудный вопрос: а почему не собрать это на том, что я уже знаю?
Сначала покажу как штука выглядит в работе, потом разберём по кускам.
Как это выглядит когда запускаешь
Всё приложение — крошечный web-сервер. Кидаешь ему вопрос, он стримит обратно что делает, шаг за шагом. Вот реальный запуск прямо из моего терминала:
$ curl -N "localhost:8000/research?q=Who won the 2024 Nobel Prize in Physics?"
event: searching
data: {"query": "2024 Nobel Prize in Physics winner"}
event: reading
data: {"sources": ["Press release: The Nobel Prize in Physics 2024",
"NSF congratulates the 2024 laureates", "..."]}
event: answer
data: {"text": "The 2024 Nobel Prize in Physics went to John Hopfield and
Geoffrey Hinton, for foundational work that made machine learning with
neural networks possible..."}
event: doneВидишь порядок? Сначала поискал, потом прочитал, потом ответил. На сложном вопросе он гоняет поиск → чтение → поиск → чтение → ответ — крутит пока не насытится. Никто это не хардкодил. Вот и весь фокус, и он проще чем звучит.
Честно — агент это просто цикл
Всё. Это и есть секрет. Снимаешь маркетинговую обёртку — агент это цикл с языковой моделью внутри:
- Отправляешь модели вопрос и говоришь какими тулами она может пользоваться.
- Модель отвечает одним из двух: «запусти вот этот тул с вот этими аргументами» или «ок, вот ответ».
- Если попросила тул — запускаешь, возвращаешь результат, идёшь обратно к шагу 1.
- Если ответила — всё, готово.
«Умная» часть — это то, что модель каждый раз решает: уже знаю достаточно или надо ещё поискать? Я смотрел как моя работает — на хитром вопросе поискала дважды, на простом один раз — и никто не говорил ей сколько раз искать. Сама дошла. Вот эта маленькая решалка — это и есть весь агент. Остальное — сантехника.
Только гуглить сама она не умеет
Момент который многие не знают: OpenAI API не лезет в интернет. Модель обучена до какой-то даты и всё — никакого веба. Спросишь про что-то из прошлого месяца — либо пожмёт плечами, либо уверенно выдумает что-нибудь (весёлый тип бага).
Поэтому агенту нужен тул чтобы тащить свежие данные. Я взял Tavily — по сути поисковик сделанный для AI. Кидаешь запрос, получаешь чистые результаты (заголовок, сниппет, ссылка) обычным текстом который модель может читать. Обычный Google вернул бы грязную HTML-страницу в рекламе; Tavily отдаёт реальный контент. В коде это почти ничего:
from langchain_community.tools.tavily_search import TavilySearchResults # один тул, топ 5 результатов search = TavilySearchResults(max_results=5)
Именно поэтому мой агент смог сказать кто получил Нобелевку по физике 2024 — нашёл, взял свежий текст, подытожил. Поставь вместо него Bing или DuckDuckGo — больше ничего не изменится. Это просто «штука которая идёт и приносит реальность».
Сам граф
Ну вот и то ради чего я сюда пришёл. LangGraph гоняет тот цикл — но моделирует его как граф: ноды (шаги), соединённые рёбрами (что происходит дальше). Весь мой агент — две ноды и одно решение.
Сначала стейт — что течёт через граф. Здесь это просто текущий список сообщений:
from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages
class State(TypedDict):
messages: Annotated[list, add_messages] # add_messages = дописывать, не перезаписыватьПотом решалка — после того как модель ответила, идём искать или всё? Это сердце всей штуки, и это четыре строки:
from langgraph.graph import END
def should_continue(state):
last = state["messages"][-1]
if getattr(last, "tool_calls", None): # модель попросила тул?
return "tools" # -> идём искать
return END # -> нет, всёА потом собираешь всё вместе. Нода agent вызывает модель; нода tools запускает Tavily; conditional edge гоняет их по кругу пока should_continue не скажет стоп:
from langgraph.graph import StateGraph, START
from langgraph.prebuilt import ToolNode
from langchain_openai import ChatOpenAI
def build_graph():
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
tools = [TavilySearchResults(max_results=5)]
llm = llm.bind_tools(tools)
def agent(state):
return {"messages": [llm.invoke(state["messages"])]}
g = StateGraph(State)
g.add_node("agent", agent)
g.add_node("tools", ToolNode(tools))
g.add_edge(START, "agent")
g.add_conditional_edges("agent", should_continue) # agent -> tools ИЛИ конец
g.add_edge("tools", "agent") # tools -> обратно к агенту
return g.compile()Прочитай последние три строки вслух: начинаем с агента; после агента — развилка (искать или стоп); после поиска — обратно к агенту. Это и есть цикл, нарисованный как граф. Когда вызываешь graph.astream(...), LangGraph идёт по нему и отдаёт тебе каждый шаг по мере выполнения — это именно то что стримилось в мой терминал выше.
Стримим в браузер
Серверная часть — это просто FastAPI которая превращает каждый шаг графа в одну из тех строк event: (Server-Sent Events). Коротко:
async def run_agent(question):
graph = build_graph()
inputs = {"messages": [("user", question)]}
async for step in graph.astream(inputs, stream_mode="updates"):
# step это {node_name: {...}} — преобразуем в searching/reading/answer
yield to_sse(step)Маленькая HTML-страница слушает этот стрим и рисует таймлайн вживую — пользователь наблюдает как агент думает, а не смотрит на спиннер. Это важнее чем звучит — «показывай как делаешь» это половина того почему штука ощущается надёжной.
Ладно, так зачем тогда LangGraph?
Вот тут надо было честно посмотреть на себя в зеркало. Посмотри снова на тот цикл. Он аккуратный и чистый... но написанный руками, без фреймворка, это строк пятнадцать:
let messages = [systemPrompt, userQuestion]
while (true) {
const reply = await openai.chat(messages, { tools: [search] })
messages.push(reply)
if (reply.toolCalls) {
for (const call of reply.toolCalls)
messages.push(await runSearch(call)) // ищем, возвращаем результаты
} else {
return reply.content // модель закончила
}
}Для одного тула и простого цикла вот этот голый while — genuinely нормал. Ещё и легче. Никакого фреймворка учить. Ну так почему я вообще взял LangGraph?
«А почему не просто Next.js и OpenAI?»
Ну вот, я всё время крутил этот вопрос — и он хороший, но в нём маленькая ловушка. Next.js и LangGraph не конкуренты. Они даже не стоят на одной полке:
- Next.js — это приложение, UI и сервер. Это замена FastAPI + HTML которые я использовал, а не LangGraph.
- OpenAI — мозг.
- Tavily — веб-тул, нужен в любом случае.
- LangGraph — цикл, та часть которую иначе просто пишешь сам.
Так что «Next.js + OpenAI» значит: Next.js для приложения, OpenAI для мозга, и цикл катаешь руками. Для моего маленького демо? Сработало бы и было бы проще. Не буду делать вид что иначе.
Так когда фреймворк реально стоит того?
LangGraph начинает отрабатывать на второй и третьей фиче, не на первой — когда цикл перестаёт быть аккуратным маленьким while:
- Куча тулов с ветвлением — «математический вопрос сюда, поиск туда, запрос в базу — вон туда». В графе это одно аккуратное ребро, в своём цикле — растущая куча вложенных
if-ов. - Память которая переживёт перезапуск — сохранить стейт в базу, поставить на паузу чтобы человек что-то одобрил, потом продолжить. Это большая тема. Катать своё такое — быстро становится страшно.
- Стриминг каждого шага — прогресс
searching → reading → answerполучил почти бесплатно. - Несколько агентов — researcher передаёт работу writer-у, у каждого свой граф.
Короче: один тул и прямой цикл? Просто пишешь цикл. Как только клиент попросит «агента который юзает пять тулов, помнит весь разговор и консультируется со мной перед любым дорогим действием» — вот тут твой хэнд-роллд превращается в спагетти, а граф остаётся читаемым.
Хочешь попробовать сам?
Всё это реально крошечное. Если есть ключ OpenAI и (бесплатный) ключ Tavily — минут пять настройки:
python -m venv venv && source venv/bin/activate pip install langgraph langchain-openai langchain-community fastapi uvicorn python-dotenv # ключи бросаем в .env echo "OPENAI_API_KEY=sk-..." >> .env echo "TAVILY_API_KEY=tvly-..." >> .env uvicorn main:app --reload --port 8000
И структура проекта примерно настолько плоская насколько возможно — никакого лабиринта src/, никаких двенадцати конфиг-файлов:
research-agent/ graph.py # LangGraph-агент (стейт, ноды, цикл) main.py # FastAPI + SSE-стриминг index.html # крошечный UI, рисует таймлайн вживую .env # твои два ключа
Открой localhost:8000, спроси что-нибудь из недавних событий и смотри как он ищет. Первый раз когда он сам покрутит цикл дважды и выдаст точный ответ — это перестаёт ощущаться как «OpenAI с лишними шагами» и начинает ощущаться как маленькая штука которая реально соображает что ей нужно.
Зачем я вообще возился с чем-то таким маленьким
Я мог подождать пока какой-нибудь проект не затащит меня в агентов насильно. Не захотел. Выучить вещь на простой задаче — значит когда придёт страшная версия, она уже будет знакома. А на Upwork «напиши AI-агент который делает X, Y и Z» — это запрос который я вижу примерно раз в месяц. Так что когда придёт настоящий — не буду одновременно разбираться и в туле и в задаче.
Вот в чём весь смысл weekend-проектов, если честно. Плати налог на обучение пока ничего не стоит на кону — и сохраняй чек на тот момент когда это будет важно.