ИИ-агент для 1С на LangGraph: пошаговое руководство
Сразу договоримся, о чём речь. Тема статьи — создание ИИ-агента для 1С на LangGraph: как из языковой модели и десятка строк кода собрать штуку, которая сама решает, когда полезть в базу «1С», а когда просто ответить словами. Если вы гуглили, как создать ИИ-агента, и после третьей статьи про «революционных AI-агентов» хотелось закрыть вкладку — эта другая. Тут мы руками собираем граф на LangGraph, шаг за шагом, от бота из одного узла до цикла «подумал, сходил за данными, ответил». Весь код рабочий, на актуальной ветке LangGraph 1.x, и вытащен из настоящего учебного проекта, а не написан ради красивого примера.
Теперь про сам термин, а то его затёрли до дыр. Агент — это не любой скрипт, к которому прикрутили нейросеть. Агент — это модель, которая в цикле сама выбирает следующий шаг: ответить сразу или сходить за данными через инструмент, посмотреть, что вернулось, и продолжить. Не прямой конвейер «промпт → ответ», а замкнутая петля с ветвлением. Вот эта петля и отличает агента от обычного чат-бота, и собирать её удобно именно как граф.
Разница простая, но её стоит прочувствовать. Обычная автоматизация — это когда вы заранее прописали все шаги, и программа идёт по ним, что бы ни случилось. Агент решает следующий шаг сам, на каждой итерации, глядя на то, что уже увидел. Спросили «сколько на складе А-100» — он понял, что надо лезть в базу, дёрнул инструмент, получил число, ответил. Сказали «привет» — никаких инструментов, сразу ответ. Один и тот же граф, поведение разное, и выбирает его модель, а не заранее написанный if. То есть по факту создание ИИ-агента — это не сценарий на все случаи жизни, а сборка среды, в которой модель принимает такие решения безопасно.
Содержание
- Почему граф, а не просто цикл
while - Шаг 1. Самый простой агент — граф из одного узла
- Шаг 2. Добавляем память между вызовами
- Шаг 3. Условные переходы — агент выбирает, что делать дальше
- Шаг 4. Добавляем инструменты — агент, который умеет действовать
- Как подключить агента к «1С»: MCP или свой HTTP-сервис
- От одного агента к мультиагентной системе
- Как запустить это у себя
- Что в итоге
Почему граф, а не просто цикл while
Честный вопрос, который надо задавать любому фреймворку, а не глотать его на веру. Агентная петля пишется в лоб: крутим while, на каждой итерации зовём llm.invoke(), смотрим, не попросила ли модель инструмент, выполняем его и складываем результат обратно в историю. Для одного инструмента и одного сценария этого хватает за глаза, и тащить сюда фреймворк я смысла не вижу.
Проблемы начинаются, когда агент делает больше одной вещи. И приходят они всегда одни и те же. Нужно ветвление — развести намерения пользователя по разным веткам. Нужна память, которая переживёт перезапуск процесса. Нужны точки, где агента можно остановить и спросить живого человека. Нужно видеть, что творится внутри, когда он на проде принял странное решение. В голом while каждую из этих штук придётся писать самому, а потом ещё и тащить на себе.
LangGraph описывает ту же петлю как граф: узлы — это что делаем, рёбра — куда идём дальше. А взамен отдаёт даром то, что в цикле пришлось бы городить руками: визуализацию (graph.get_graph().draw_mermaid_png() рисует схему), сохранение состояния через чекпоинтеры, условные переходы и точки прерывания. Логика перестаёт быть спрятанной внутри одной функции и становится структурой, которую видно и можно менять по кусочку.
Проговорю предметно, а то «граф лучше цикла» звучит как маркетинг. Визуализация — это не картинка ради картинки: когда у агента пять узлов и три развилки, схема на экране экономит часы, потому что маршрут запроса видишь глазами, а не собираешь по логам. Чекпоинтеры дают не только память диалога, но и возможность откатиться на любой прошлый шаг и переиграть ветку — удобно и в проде, и когда разбираешь инцидент. Точки прерывания (human-in-the-loop) позволяют тормознуть агента перед опасным действием — скажем, перед записью документа в «1С» — и дождаться, пока оператор подтвердит. В голом цикле каждая такая возможность — отдельная подсистема, которую надо спроектировать и потом поддерживать. Отсюда правило: делает агент ровно одну вещь — хватит цикла; появилось ветвление, память или ручной контроль — берите граф.
Ниже — интерактивная карта того, что будем собирать: четыре этапа, от простого узла до агента с инструментами. Пощёлкайте по вкладкам, видно, как граф обрастает узлами и рёбрами.
Шаг 1. Самый простой агент — граф из одного узла
Начнём с минимума. Один узел chatbot, который зовёт модель и отдаёт её ответ. Практического толку ноль, зато здесь сразу видны все три сущности LangGraph: состояние, узел, ребро.
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
class State(TypedDict):
messages: Annotated[list, add_messages]
def chatbot(state: State) -> State:
return {"messages": [llm.invoke(state["messages"])]}
graph_builder = StateGraph(State)
graph_builder.add_node("chatbot", chatbot)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)
graph = graph_builder.compile()Запускается так:
graph.invoke({"messages": [("user", "Привет")]})Кажется, что всё очевидно. Но одна строчка здесь — источник самой популярной ошибки новичка. Смотрите на Annotated[list, add_messages].
Ловушка add_messages: почему просто list ломает память
Каждый узел возвращает частичное обновление состояния: {"messages": [новое_сообщение]}. Вопрос в том, что LangGraph с этим обновлением сделает — допишет к тому, что уже накопилось, или заменит весь список целиком.
Решает это редьюсер — вторая часть аннотации. add_messages — это функция-редьюсер, которая дописывает новые сообщения к истории. Уберёте её, оставите просто messages: list — и обновление затрёт список, после каждого узла в состоянии повиснет ровно одно последнее сообщение. Агент моментально забудет весь диалог.
Виджет ниже показывает это вживую. Отправьте три реплики по очереди в оба варианта и посмотрите, что осталось в state["messages"].
В коде разница выглядит безобидно, а на деле именно из-за неё агент «забывает» имя пользователя через одно сообщение. Правило простое: поле с историей сообщений почти всегда должно идти с редьюсером add_messages.
Состояние графа — это не только сообщения
State — обычный TypedDict, и messages в нём не единственное поле. В живом агенте состояние обычно таскает и служебное: id пользователя, найденного контрагента, черновик документа, счётчик попыток. Каждый узел получает на вход всё состояние, а возвращает частичное обновление тех полей, что менял.
class State(TypedDict):
messages: Annotated[list, add_messages]
user_id: str # кто спрашивает
context: dict # что уже нашли: контрагент, склад, документПравило про редьюсер работает для любого поля, не только для messages. Пишут в одно поле несколько узлов — задайте через Annotated функцию, которая их значения объединит. Пишет один узел — хватит простого типа, новое значение просто заменит старое. Так состояние превращается в общую «доску», через которую узлы обмениваются данными, а не в свалку глобальных переменных.
Шаг 2. Добавляем память между вызовами
Пока граф помнит переписку только внутри одного invoke. Между вызовами всё теряется — каждый запрос начинается с чистого листа. Чинится добавлением чекпоинтера: объекта, который после каждого шага сохраняет состояние графа.
from langgraph.checkpoint.memory import InMemorySaver
checkpointer = InMemorySaver()
graph = graph_builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "user-42"}}
graph.invoke({"messages": [("user", "Меня зовут Илья")]}, config)
graph.invoke({"messages": [("user", "Как меня зовут?")]}, config) # помнитКлючевая деталь тут — thread_id. Считайте его идентификатором диалога: чекпоинтер держит отдельную историю под каждый thread_id. Два пользователя — два разных thread_id, и их переписка не смешивается. На втором вызове LangGraph сам подтянет историю по ключу, поэтому на «Как меня зовут?» агент отвечает осмысленно.
InMemorySaver держит всё в оперативке и умирает вместе с процессом — то есть годится для разработки, но не для боя. В проде его меняют на PostgresSaver или чекпоинтер поверх Redis, и это замена буквально одной строки: остальной код графа не трогается. После этого агент переживает рестарт, деплой и падение воркера. А про то, как не отправлять модели всю разросшуюся переписку целиком — суммаризация, обрезка, долговременная память — я подробно пишу в статье про context engineering для ИИ-агента; здесь важно, что сама память включается одной строкой на compile.
Ещё приятный побочный эффект чекпоинтера — всю историю состояний можно поднять и посмотреть. Спрашиваем у графа, каким состояние было на каждом шаге:
for snapshot in graph.get_state_history(config):
print(snapshot.values["messages"][-1])При отладке это спасает: не гадаешь, почему агент решил именно так, а разворачиваешь всю ленту его состояний и смотришь, что он видел в каждый момент. Для конвейера, который заводит документы в учётку, такая прослеживаемость — не роскошь, а требование.
Шаг 3. Условные переходы — агент выбирает, что делать дальше
Пока граф линейный. Настоящему агенту нужно ветвиться: справочный вопрос вести одной веткой, свободный разговор — другой. За развилки в LangGraph отвечает add_conditional_edges — вы отдаёте функцию-роутер, она возвращает имя следующего узла.
Для наглядности начнём с игрушечного роутера, обычная проверка строки:
from typing import Literal
def router(state: State) -> Literal["СПРАВКА", "РАЗГОВОР"]:
last = state["messages"][-1]
return "СПРАВКА" if "?" in last.content else "РАЗГОВОР"
graph_builder.add_conditional_edges(START, router, {
"СПРАВКА": "spravka_node",
"РАЗГОВОР": "chat_node",
})Работает, но «есть ли знак вопроса» — так себе классификатор. Он развалится на первом же «подскажи остаток по А-100» без вопросительного знака. В настоящем агенте роль роутера играет та же модель, только не свободным текстом, а структурированным выводом через with_structured_output.
from pydantic import BaseModel
class RouteDecision(BaseModel):
route: Literal["СПРАВКА", "ЗАДАЧА", "РАЗГОВОР"]
def llm_router(state: State) -> str:
decision = llm.with_structured_output(RouteDecision).invoke(
state["messages"][-4:]
)
return decision.routeРазберём приём, а не только этот код. with_structured_output(RouteDecision) заставляет модель вернуть не свободный текст, а объект по схеме Pydantic — тут это одно поле route из трёх допустимых значений. Так вы гарантированно получаете ровно одну из веток графа, а не рассуждение на три абзаца, которое потом парсить. В роутер передаём не всю историю, а последние несколько сообщений ([-4:]) — этого хватает понять намерение и не раздувает запрос. И на роутинг разумно ставить модель попроще и подешевле основной, задача у неё узкая. Какая модель годится на роутер, а какая на основной ответ, я разбираю в обзоре нейросетей для программиста 1С. Три ветки СПРАВКА / ЗАДАЧА / РАЗГОВОР — это generic-триаж, под свою задачу называете ветки как удобно.
Шаг 4. Добавляем инструменты — агент, который умеет действовать
Вот тут чат-бот и превращается в агента. Инструмент (tool) — это обычная функция Python, к которой модель получает доступ и зовёт сама, когда ей не хватает данных. Опишем простой инструмент и соберём агентную петлю на готовых блоках ToolNode и tools_condition.
from datetime import datetime
from langchain_core.tools import tool
from langgraph.prebuilt import ToolNode, tools_condition
@tool
def get_current_datetime() -> str:
"""Возвращает текущую дату и время."""
return datetime.now().isoformat()
llm_with_tools = llm.bind_tools([get_current_datetime])
def call_model(state: State) -> State:
return {"messages": [llm_with_tools.invoke(state["messages"])]}
graph_builder.add_node("agent", call_model)
graph_builder.add_node("tools", ToolNode([get_current_datetime]))
graph_builder.add_edge(START, "agent")
graph_builder.add_conditional_edges("agent", tools_condition)
graph_builder.add_edge("tools", "agent")Разберём цикл по шагам, потому что это и есть сердце любого агента. llm.bind_tools([...]) рассказывает модели, какие у неё есть инструменты и с какими аргументами. Узел agent зовёт эту модель; решит воспользоваться инструментом — вернёт не текст, а сообщение с непустым tool_calls. Дальше tools_condition — готовая развилка: смотрит на последнее сообщение, есть вызов — гонит граф в узел tools, нет — завершает (END). ToolNode сам находит функцию по имени, выполняет с переданными аргументами и кладёт результат обратно в messages. После инструмента ребро tools → agent возвращает управление модели — она видит результат и решает, что делать дальше.
Заметьте, чего в коде нет. Нет ни одного if, который решал бы за модель, какой инструмент дёрнуть. Нет ручного разбора аргументов. Мы описали инструменты, отдали их модели через bind_tools и нарисовали цикл — всё остальное её решение на каждом шаге. Вот эта передача управления модели и отличает агента от пайплайна.
Так и крутится агентный цикл agent → tools → agent → …, пока модель не перестанет просить инструменты и не ответит текстом. Прогоните виджет ниже на конкретном вопросе — видно каждый оборот петли и как растёт список сообщений.
У цикла есть очевидная опасность: а если модель зациклится и будет дёргать инструменты бесконечно? Классика — инструмент вернул ошибку, модель попробовала снова с теми же аргументами, снова ошибка, и так до конца вашего бюджета на токены. LangGraph подстрахован лимитом рекурсии, по умолчанию это 25 шагов графа:
graph.invoke(state, config={"recursion_limit": 10})При превышении прилетает GraphRecursionError — явная ошибка, а не подвисший процесс. Но это защитная сетка, а не решение. Упёрся агент в лимит — вопрос обычно не в лимите, а в инструментах: возвращают ли они внятный текст ошибки (модель на такое реагирует и меняет аргументы), написано ли в системном промпте, сколько попыток допустимо.
И ещё одна вещь, без которой боевого агента неудобно ни отлаживать, ни показывать пользователю, — стриминг. invoke отдаёт только финальный ответ, а stream (и его асинхронный брат astream) — результат каждого узла сразу после выполнения:
for step in graph.stream(state, config, stream_mode="updates"):
for node_name, update in step.items():
print(node_name, "→", update["messages"][-1])С stream_mode="updates" видно живой ход: вот agent решил дёрнуть инструмент, вот tools вернул результат, вот agent ответил. Так и выглядят агенты, которые печатают ответ «на глазах», а не после долгой паузы, — этот поток удобно гнать прямо в интерфейс.
Теперь мысленно замените игрушечный get_current_datetime на инструмент, который лезет в вашу базу «1С» за остатками или статусом заказа, — и вот вам боевой агент. Схема графа при этом не сдвинется ни на строчку, поменяется только тело инструмента. Но именно за этой заменой стоит настоящая работа, и без неё агент вполне сможет «попросить» систему о том, чего делать нельзя. Как именно подключить такой инструмент к «1С» — через готовый MCP-сервер или свой HTTP-сервис прямо в конфигурации — разберём в следующем разделе. А полноценные боевые агенты, например агент распознавания сканов документов, — это уже практика курса.
Кстати, для инструментов важен выбор модели: bind_tools по-настоящему работает только с LLM, которая умеет function calling. Слабая или неподходящая модель будет «забывать» звать инструменты или путать аргументы — это отдельный критерий при подборе.
Как подключить агента к «1С»: MCP или свой HTTP-сервис
Всё, что мы собрали выше, крутится вокруг игрушечного get_current_datetime. Боевой агент для «1С» отличается ровно одним: его инструменты ходят в вашу учётную систему — за остатком на складе, за статусом заказа, за карточкой контрагента. Связать агента с «1С» можно двумя способами, и это выбор между готовым протоколом и полным контролем над кодом.
MCP-сервер. MCP (Model Context Protocol) — стандарт, по которому агент подключается к внешним данным единообразно, без отдельной обёртки под каждый инструмент. Для «1С» такой сервер поднимается расширением конфигурации и отдаёт наружу готовый набор инструментов, который агент видит сам. Способ оправдан, когда инструментов много и их хочется переиспользовать между разными агентами. Как устроен MCP-сервер в «1С», я разбираю в отдельной статье про MCP-сервер для 1С.
Свой HTTP-сервис в «1С». Если инструментов один-два и под конкретную задачу, MCP избыточен: достаточно опубликовать HTTP-сервис прямо в конфигурации, лучше в расширении, чтобы не трогать типовую. Агент дёргает его обычным POST с JSON, вся специфика «1С» остаётся внутри сервиса. На старте это проще и не требует ничего, кроме встроенных средств платформы, поэтому дальше разберу именно его.
Обработчик POST читает тело как UTF-8, разбирает JSON, вызывает нужный метод и отдаёт JSON обратно. Скелет всегда один и тот же:
Функция RootPOST(Запрос)
Ответ = Новый HTTPСервисОтвет(200);
Ответ.Заголовки.Вставить("Content-Type", "application/json; charset=utf-8");
Попытка
Тело = ПолучитьСтрокуИзДвоичныхДанных(
Запрос.ПолучитьТелоКакДвоичныеДанные(), "UTF-8");
ДанныеЗапроса = ПрочитатьДанныеJSON(Тело);
Артикул = ДанныеЗапроса.Получить("article");
Если Артикул = Неопределено ИЛИ Артикул = "" Тогда
ВызватьИсключение "Не задан article";
КонецЕсли;
Результат = Новый Структура;
Результат.Вставить("article", Артикул);
Результат.Вставить("stock", ПолучитьОстатокПоАртикулу(Артикул));
Ответ.УстановитьТелоИзСтроки(ЗаписатьДанныеJSON(Результат),
КодировкаТекста.UTF8, ИспользованиеByteOrderMark.НеИспользовать);
Исключение
Ответ.КодСостояния = 400;
Ошибка = Новый Структура("error", ОписаниеОшибки());
Ответ.УстановитьТелоИзСтроки(ЗаписатьДанныеJSON(Ошибка),
КодировкаТекста.UTF8, ИспользованиеByteOrderMark.НеИспользовать);
КонецПопытки;
Возврат Ответ;
КонецФункцииЗдесь важны две вещи. Тело читается строго как двоичные данные и декодируется в UTF-8 руками: возьмёте строку напрямую — кириллица приедет битой, и «1С» ответит синтаксической ошибкой на ровном месте. И любое исключение ловится и уходит наружу как JSON с кодом 400, а не роняет сервис пятисоткой: по внятному тексту ошибки агент сам переспросит или поправит аргументы и попробует снова. Внутри ПолучитьОстатокПоАртикулу лежит обычный запрос к регистру остатков, который вы и так умеете писать; в расширении, откуда взят этот скелет, тем же способом наружу отдаётся выполнение произвольных запросов, с колонками и строками результата в ответе.
Публикуется сервис через Администрирование → Публикация на веб-сервере, галка «Публиковать HTTP-сервисы расширений». Авторизация — встроенный BasicAuth публикации: заведите отдельного пользователя с минимальной ролью, чтобы агент не ходил в базу под администратором.
Сторона Python короче. Инструмент агента — тонкая обёртка над POST к сервису, и в граф он встаёт так же, как игрушечный get_current_datetime из четвёртого шага:
import requests
from langchain_core.tools import tool
ONEC_URL = "http://host:8080/ones/hs/agent/stock"
AUTH = ("agent", "пароль") # BasicAuth публикации 1С
@tool
def get_stock(article: str) -> str:
"""Возвращает остаток товара на складе по артикулу в 1С."""
resp = requests.post(ONEC_URL, json={"article": article}, auth=AUTH, timeout=10)
data = resp.json()
if data.get("error"):
return f"Ошибка 1С: {data['error']}"
return f"Остаток по артикулу {data['article']}: {data['stock']}"Схема графа при этом не меняется — get_stock просто встаёт в список tools рядом с остальными. А вот дальше начинается работа, которой у игрушечного инструмента не было. Аргументы, которые придумала модель, надо проверять до обращения к базе: артикул с опечаткой, период из будущего, отрицательное количество. На чтении остатков хватит валидации, на записи документов в «1С» её мало: там ставят точку прерывания (interrupt) и ждут подтверждения оператора, ровно тот human-in-the-loop, ради которого мы и брали граф вместо голого цикла. Игрушечный инструмент прощает небрежность сразу, боевой — до первого инцидента в проде.
Хотите собрать такого агента под свою «1С»? На курсе «ChatGPT для 1С» есть отдельная группа уроков «ИИ-агенты на LangGraph» — от простого графа до агента с инструментами — и практический проект: агент, который читает сканы документов и заводит их в учётную систему.
От одного агента к мультиагентной системе
Один агент отлично живёт, пока у него один круг обязанностей. Ломается он тогда, когда системный промпт распухает в свод правил на три страницы: тут отвечай по документации, тут проверь заказ в «1С», тут запиши клиента на встречу, а сюда вообще не лезь. Модель начинает путать сценарии, инструментов набегает пара десятков, и она выбирает не те. Симптом всегда один: правишь промпт под одну задачу — роняешь качество на другой.
Лечится это разделением на специализированных агентов за общим роутером: каждый со своим узким промптом, своим набором инструментов и своей зоной ответственности. Механика вам уже знакома — это тот же add_conditional_edges из третьего шага, только ветки ведут не в отдельные узлы, а в целые подграфы. Каждый агент — отдельный граф со своими инструментами и промптом, а координатор — граф этажом выше, узлы которого и есть эти агенты. Ровно поэтому важно было собрать первый граф руками: как только узлом графа может стать другой граф, мультиагентная система перестаёт быть магией и оказывается тем же конструктором из узлов и рёбер, только уровнем выше. Как проектировать такие системы и где эта архитектура себя не оправдывает, я разбираю в статье про мультиагентные системы для бизнеса.
Разработка ИИ-агентов почти всегда идёт по этой траектории: сперва один агент закрывает узкую задачу, потом задач становится больше, и его дробят на команду. Важно увидеть, что путь от чат-бота к команде агентов прямой и логичный, и начинается он ровно с того графа, что мы собрали выше.
Как запустить это у себя
Чтобы код из статьи заработал, нужен сам LangGraph и клиент к LLM. Ставим:
pip install langgraph langchain-openailangchain-openai даёт класс ChatOpenAI — им подключаются не только модели OpenAI, но и любой OpenAI-совместимый сервер, хоть локальный vLLM, хоть облачный шлюз:
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)Дальше собираете граф ровно как в шагах выше и первым делом рисуете его схему — лучший способ убедиться, что рёбра расставлены правильно:
with open("agent.png", "wb") as f:
f.write(graph.get_graph().draw_mermaid_png())Совпала картинка с тем, что задумали, — зовите graph.invoke и проверяйте поведение. Совет: держите под рукой игрушечный инструмент вроде get_current_datetime, пока отлаживаете сам цикл, а реальные вызовы «1С» подключайте только после того, как петля agent → tools → agent заработала на безопасной заглушке.
Что в итоге
Прошли весь путь: простой граф из одного узла → память между вызовами через чекпоинтер и thread_id → условные переходы с роутером → агентный цикл с инструментами. Все четыре шага — это один и тот же StateGraph, к которому мы постепенно докидывали узлы и рёбра. Потому граф и удобнее ручного while: логика растёт по кусочку, остаётся на виду и не превращается в клубок вложенных условий.
Дальше — практика. Берите этот код, подключайте нормальную LLM с инструментами, меняйте игрушечный инструмент на реальный вызов вашей «1С» — и у вас на руках агент, решающий живую задачу. Если хочется сразу прикинуть, какую модель ставить и во что это обойдётся, загляните в обзор ИИ для 1С. А чтобы не собирать коннектор, валидацию и боевые сценарии на ощупь, приходите на курс «ChatGPT для 1С»: там весь этот путь — от первого графа до агента в проде — разобран на реальных проектах.