Описание товара нейросеть: ИИ-агент для 1С на LangChain, LangGraph, Qwen3, Ollama, FastAPI

Нейросеть для описания товара: ИИ-сервис для 1С
Видео: Нейросеть для описания товара: ИИ-сервис для 1С

Описание товара нейросеть: ИИ-агент для 1С на LangChain, LangGraph, Qwen3, Ollama, FastAPI

На этом вебинаре я показываю весь цикл разработки ИИ-агента на Python с нуля: с выбора локальной модели (беру Qwen3 14B через Ollama) и настройки бесплатного self-hosted поиска до сборки агента на LangChain и LangGraph, который по названию товара сам находит информацию в интернете и формирует готовое описание для карточки номенклатуры. Отдельно разбираю архитектурные детали: узлы графа supervisor/tools/formatter, структурированный вывод через Pydantic-схемы и работу с контекстом (RAG) вместо мифического «дообучения» модели на лету. Готового агента оборачиваю в FastAPI-сервис и подключаю к расширению 1С Управление торговлей 11.5, где по кнопке на форме номенклатуры автоматически заполняется поле «Описание». Смотреть стоит 1С-программистам и специалистам по автоматизации, которые хотят перейти от использования готовых чат-ботов к самостоятельной разработке ИИ-агентов на Python и их интеграции с 1С. В конце расширяю агента SEO-узлом на Wordstat и отвечаю на вопросы про монетизацию таких проектов, работу с конфиденциальными данными и выбор размера локальной модели под железо.

Вступление: выбор модели и миф про «дообучение»

Пока зрители собираются, отвечаю на вопрос подписчика про выбор модели и «дообучение» под конкретные данные. Сразу скажу: универсальной «лучшей» модели не бывает, выбор всегда зависит от задачи, и показываю это на примерах из своих прошлых проектов, заодно называю модель (Qwen 14B, локально), с которой будем работать в этом вебинаре. Отдельно развенчиваю миф про «дообучение»: по факту модель ничего не дообучается на лету, она просто получает релевантный контекст через RAG.

Выбор модели под конкретную задачу

Универсальной «лучшей» модели не существует, выбор всегда зависит от конкретной задачи.

Я всегда говорю: нельзя ответить на вопрос «какую модель выбрать», не зная задачи. Задачи бывают разные, например для подбора марок автомобилей по произвольному вводу пользователя (в том числе на русском, который нужно сопоставить со справочником в 1С) нужна одна модель, а для других задач подойдёт совсем другая. Поэтому подход «есть одна лучшая модель на все случаи» не работает: сначала определяете задачу и данные, а потом уже экспериментально подбираете под них конкретную модель.

  • Без описания задачи вопрос «какую модель выбрать» не имеет ответа.
  • Пример: подбор марок автомобилей по произвольному вводу пользователя (в т.ч. на русском) под справочник 1С.
  • В моей практике для разных задач подходили разные модели, универсального решения нет.
модель выбирается конкретно под какую-то задачу

Подбор embedding-модели для коротких аббревиатур (пример Granite)

Для коротких строк и транслитерации (например, БМВ/BMW) лучше сработала embedding-модель линейки Granite.

В задаче подбора марок автомобилей по вводу пользователя (например, «бмв» на русском, которое нужно сопоставить с «BMW» в справочнике) я тестировал разные модели эмбеддингов для векторного поиска. Лучший результат на коротких аббревиатурах и при сопоставлении русского и английского написания показала модель эмбеддингов из линейки Granite. Это ещё одна иллюстрация того же тезиса: выбор embedding-модели зависит от специфики текста, а не от абстрактного рейтинга моделей.

  • Задача: сопоставить произвольный ввод пользователя (в т.ч. кириллицей) со справочником в 1С.
  • Тестировались разные модели эмбеддингов для векторного поиска.
  • Лучший результат на коротких аббревиатурах и в паре рус/англ показала модель Granite.
тестировал разные модели для имбэдингов для векторного поиска

Мультимодальная модель для распознавания документов

Для сервиса распознавания документов лучше всего сработала мультимодальная модель на базе Llama.

Когда я делал сервис распознавания документов для своего курса, тоже перепробовал кучу моделей. Лучший результат показала мультимодальная модель на базе Llama, заточенная под работу с изображениями. Тот же принцип: модель подбирается экспериментально под конкретный тип задачи и данных, здесь это задача извлечения информации из документов.

  • Отдельная задача: распознавание документов для курса.
  • Перепробовано много моделей, выбор экспериментальный.
  • Лучше всего сработала мультимодальная модель на базе Llama.
перепробовал много разных моделей и там сработала очень неплохо модель лама

Qwen3 14B: локальная модель для этого проекта

Для сервиса описаний товаров выбрана локальная модель Qwen 14B, которая работает от видеокарты с 16 ГБ (комфортнее, от 24 ГБ, например 4090/3090).

Для проекта этого вебинара, сервиса формирования описания товара, я выбрал модель Qwen с 14 миллиардами параметров. В посте у меня по ошибке было написано про 8 миллиардов, но по факту используется именно 14B. Такая модель запускается на видеокарте с 16 ГБ видеопамяти, но комфортнее работает на 24 ГБ, например на RTX 4090 или 3090. Ещё объясню, почему предпочитаю локальные модели: доступ к облачным (GPT-4 mini, Гигачат, Gemini) организационно получить сложнее, поэтому локальный запуск на практике выходит практичнее.

  • Использую модель Qwen 14B (в посте была неточность про 8B).
  • Минимальные требования: видеокарта 16 ГБ, комфортнее, от 24 ГБ (4090/3090).
  • Локальные модели предпочитаю из-за сложностей с доступом к облачным (GPT-4 mini, Гигачат, Gemini).
будет использоваться 14 миллиардная модель ну и такая модель вполне запускается на какой-нибудь видеокарте 16 гигабайтами оперативной памяти

Миф о «дообучении» модели: на самом деле это RAG

Модель не «дообучается» под конкретные данные: ей просто подсовывают релевантный контекст (RAG), из которого она берёт ответ.

Хочу сразу закрыть распространённое заблуждение, что модель «дообучается» под данные пользователя. По факту ничего она не дообучается на лету: нужно подобрать максимально релевантный отрезок данных из своей базы, допустим описание конкретного товара, и подсунуть его в контекст диалога модели. Тогда модель «знает» всё об этом товаре в рамках диалога и отвечает пользователю. Настоящий fine-tuning, отдельная практика, ей занимаются немногие, и обычно смотрят на неё по соотношению цена/качество, а не как на то, что якобы происходит «на лету» в общении с пользователем.

  • Модель не дообучается «на лету» под данные пользователя.
  • Работает подход RAG: релевантные отрезки данных подбираются и подсовываются в контекст диалога.
  • Пример: кондиционеры (около 500 позиций в базе), модель отвечает, опираясь на подсунутые в контекст данные.
  • Настоящий fine-tuning: отдельная практика, которой занимаются немногие, из соображений цена/качество.
подобрать отрезки из своей базы, какого-то товара найти, подсунуть их в контекст модели

Подготовка проекта: Poetry, PyCharm, Ollama и self-hosted поиск

В этой части закладываю техническую базу проекта: поднимаю Python-проект в PyCharm на менеджере пакетов Poetry, ставлю веб-фреймворк FastAPI и локальную LLM Qwen3 14b через Ollama. Отдельно решаю вопрос поиска информации в интернете: вместо платных Tavily и SERP разворачиваю бесплатный self-hosted сервис поиска в Docker.

Настройка проекта на Poetry и PyCharm

Проект создаётся в PyCharm через Poetry с жёстко зафиксированным диапазоном версий Python.

Инициализирую проект в PyCharm через Poetry как менеджер пакетов, версия Python у меня 3.12.10. После создания проекта ограничиваю версию Python в конфигурации диапазоном от 3.12 до 3.13, не включая, потому что некоторые библиотеки требуют строго определённой версии интерпретатора, а между релизами библиотек бывают серьёзные изменения, которые ломают совместимость. Тут прямая аналогия с 1С: конфигурация там тоже указывает, с какой версией платформы она совместима, и это защищает проект от поломки при обновлении зависимостей.

  • Стек: PyCharm + Poetry + Python 3.12.10
  • В конфигурации проекта диапазон версий Python ограничивается (3.12, меньше 3.13), чтобы не ломались зависимости
  • Аналогия с 1С: совместимость конфигурации с определённой платформой
ставим PyCharm, ставим Python 3.12.10 у меня используется, ставим Poetry

FastAPI как веб-фреймворк для сервиса

FastAPI выбран для реализации веб-сервиса, к API которого потом будет обращаться 1С.

В проекте использую фреймворк FastAPI: удобный веб-фреймворк для разработки веб-приложений и, в частности, API. Выбор объясняется дальнейшей интеграцией с 1С: сервис должен отдавать API, к которому 1С будет обращаться ("дёргать") за готовым описанием товара по его наименованию.

  • FastAPI: веб-фреймворк, удобный для разработки API
  • Через API этого сервиса впоследствии будет обращаться 1С
фреймворк FastAPI. Это веб-фреймворк для разработки веб-приложений

Ollama и локальная модель Qwen3 14b

Вместо облачной LLM в проекте используется локальная модель Qwen3 14b, запускаемая через Ollama.

Ollama ставлю с официального сайта как отдельное приложение, через него скачиваю и запускаю локальные модели командой вида «ollama pull» (или «ollama run») с названием модели, список уже скачанных моделей смотрю командой «ollama list» (пошаговую установку Ollama на Windows я разбирал в отдельной статье). Для проекта беру модель Qwen3 14b, 14 миллиардов параметров: в коде создаю объект ChatOllama из пакета langchain-ollama с этой моделью и низкой температурой, около 0–0.1, чтобы модель меньше «креативничала» и точнее следовала входным данным. Кстати, вместо Ollama можно взять LM Studio, подключение там будет чуть другим.

  • Ollama скачивается с сайта ollama.com, модели загружаются командой ollama pull/run
  • Список локально установленных моделей: команда ollama list
  • В проекте используется модель Qwen3 14b с низкой температурой (0–0.1) для минимизации «фантазий» модели
  • Альтернатива Ollama: LM Studio, подключение реализуется немного иначе

Клиент модели (src/llm_client.py): один объект ChatOllama с низкой температурой, который импортируется во все модули проекта.

# src/llm_client.py
from langchain_ollama import ChatOllama

llm = ChatOllama(model="qwen3:4b", temperature=0.1)

Небольшая оговорка про размер модели. По ходу вебинара я обсуждаю именно Qwen3 14b как рабочий вариант и рекомендую держаться диапазона 8B–14B. Но в опубликованных исходниках проекта в llm_client.py модель выставлена в qwen3:4b, это состояние с финального замера, где я сравниваю 4B / 14B / 30B по скорости и потреблению видеопамяти. Смысл в том, что модель здесь, это одна строка конфигурации: меняете model="qwen3:4b" на model="qwen3:14b", и весь остальной код (инструменты, граф, сервис) работает без изменений.

указываем модель Qwen3 14b и температуру

Бесплатный self-hosted поиск вместо Tavily и SERP

Вместо платных сервисов поиска Tavily и SERP в Docker разворачивается бесплатный self-hosted сервис с похожим API.

Для поиска информации в интернете обычно берут платные сервисы Tavily и SERP у Google: покупаете токены, они рано или поздно заканчиваются, и дальше поиск только за деньги. Я по этому поводу принципиально за бесплатные решения, поэтому разворачиваю открытый self-hosted сервис-обёртку над Python-пакетом для поиска: клонирую репозиторий, конфигурирую через docker-compose.yaml и config.yaml (в конфиг нужно вписать сгенерированный секретный ключ), запускаю командой «docker compose up -d». Сервис поднимает API с эндпоинтом /search, который принимает поисковый запрос и число нужных результатов, и по интерфейсу похож на клиент Tavily. К этому API можно обращаться и напрямую из 1С.

  • Платные альтернативы: Tavily и SERP (Google), токены заканчиваются, дальше поиск платный
  • Self-hosted сервис поднимается в Docker через docker-compose.yaml и config.yaml с секретным ключом, запуск: docker compose up -d
  • API-эндпоинт /search принимает запрос и максимальное число результатов, интерфейс похож на Tavily-клиент
  • К API сервиса можно обращаться напрямую, в том числе из 1С

Быстрый старт self-hosted поискового сервиса (README адаптера searxng-docker-tavily-adapter): клонирование репозитория, настройка config.yaml, запуск, тестовый curl-запрос к /search.

# 1. Клонирование
git clone git@github.com:vakovalskii/searxng-docker-tavily-adapter.git
# или HTTPS: git clone https://github.com/vakovalskii/searxng-docker-tavily-adapter.git
cd searxng-docker-tavily-adapter

# 2. Настройка конфигурации
cp config.example.yaml config.yaml
# Поменяйте secret_key в config.yaml (генератор ключа: https://djecrety.ir/)

# 3. Запуск
docker compose up -d

# 4. Тест
curl -X POST "http://localhost:8000/search" \
     -H "Content-Type: application/json" \
     -d '{"query": "цена bitcoin", "max_results": 3}'

Дальше этот сервис работает как drop-in замена Tavily: ставите оригинальный клиент pip install tavily-python, создаёте TavilyClient и меняете только base_url на http://localhost:8000, всё остальное как с обычным Tavily. Под капотом адаптер поднимает SearXNG (мета-поисковик, порт 8999), сам Tavily-совместимый адаптер (порт 8000) и Redis для кэша; при include_raw_content=True он ещё и скрапит страницы, возвращая очищенный текст.

Но мы, ребята, хотим все бесплатно, поэтому будем делать бесплатно.

Установка LangChain, LangGraph, LangSmith и интеграции с Ollama

Проект строится на связке LangChain + LangGraph (фреймворк для агентов) + LangSmith, плюс отдельный пакет для работы с Ollama.

Через Poetry ставлю набор библиотек: LangChain, LangGraph (это фреймворк для разработки агентов), LangSmith, и ещё langchain-ollama, раз для локальной модели используется Ollama. Все команды установки фиксирую в readme-файле проекта. Из документации LangGraph беру готовые вспомогательные функции для красивого вывода в консоль результатов работы агента, разбирать их устройство на вебинаре не буду, не то чтобы это сейчас критично.

  • Устанавливаемый стек: LangChain, LangGraph, LangSmith, langchain-ollama
  • LangGraph описан как специальный фреймворк для разработки агентов
  • Из документации LangGraph взяты функции для красивого вывода результатов в консоль
  • Все команды установки фиксируются в readme проекта

Команды установки из readme проекта: сначала веб-стек, затем библиотеки для агента, модель через Ollama и клиент Tavily.

poetry add fastapi pydantic-settings "uvicorn[standard]" orjson aiofiles python-multipart black

poetry add langchain langgraph langsmith langchain-ollama

ollama pull qwen3:4b   # либо qwen3:14b — рабочий диапазон 8B–14B

poetry add tavily-python

Готовые функции для красивого вывода (pretty_print_messages) лежат в отдельном src/ai/utils.py и используются только при отладке через graph.stream() — в проде вывод идёт через обычный print.

Инструменты агента: поиск в интернете и генерация описания

В этой части делаю первые два инструмента будущего агента: search для поиска информации о товаре в интернете через Tavily и create_description для формирования карточки товара на основе найденного контента. Заодно ввожу Pydantic-схемы, которые заставляют локальную языковую модель Qwen3 возвращать строго структурированный ответ, и промпт-шаблоны LangChain для подстановки данных в запрос к модели. Оба инструмента сразу тестирую прямым запуском файла в консоли.

Инструмент search на базе Tavily-клиента (tools.py)

Функция search обращается к Tavily-клиенту, ищет информацию о товаре в интернете и возвращает объединённый текст найденного контента.

В файле tools.py создаю клиент TavilyClient (api_key можно указать любой при локальном запуске, base_url указывает на локальный сервис на порту 8000). Функция search вызывает его метод search, передавая запрос пользователя, количество результатов, тему general и глубину поиска advanced, плюс список исключаемых доменов (в примере это market.yandex.ru, чтобы выдача не состояла целиком из одного маркетплейса). Из ответа беру только поле content каждого результата, пустые значения заменяю на пустую строку, а все фрагменты склеиваю методом join в одну строку, которая и возвращается функцией.

  • TavilyClient создаётся с любым api_key при локальном запуске и base_url на локальный сервис (порт 8000)
  • Метод search принимает query, число результатов, topic=general, search_depth=advanced и exclude_domains
  • Из ответа через get('results') и get('content') извлекается только текстовый контент каждого результата
  • Все фрагменты контента объединяются через str.join в одну строку, это и есть результат работы инструмента

Инструмент search_tool (src/ai/tools.py): поиск через TavilyClient с исключением домена market.yandex.ru.

from pprint import pprint

from langchain_core.prompts import (
    ChatPromptTemplate,
    SystemMessagePromptTemplate,
    HumanMessagePromptTemplate,
)
from langchain_core.tools import tool
from tavily import TavilyClient

from src.ai.schemas import (
    SearchInputSchema,
    CreateDescriptionInputSchema,
    ProductDescriptionSchema,
)
from src.llm_client import llm


@tool(
    "search",
    args_schema=SearchInputSchema,
    description="Выполняет поиск информации в интернете",
)
def search_tool(question: str) -> str:
    tavily_client = TavilyClient(api_key="123", api_base_url="http://localhost:8000")

    tavily_response = tavily_client.search(
        query=question,
        max_results=5,
        topic="general",
        include_raw_content=False,
        search_depth="advanced",
        exclude_domains=["market.yandex.ru"],
    )

    pprint(tavily_response, indent=4)

    content = []
    for item in tavily_response.get("results", []):
        content.append(item.get("content", ""))

    return "\n".join(content)
мы можем исключить из поиска какие-то домены, я исключаю market яндекс точка ру

Pydantic-схемы для структурированного вывода (schemas.py)

Схемы на основе BaseModel из Pydantic ограничивают ответ модели конкретным набором полей вместо произвольного текста.

Делаю отдельный файл schemas.py. Схема ProductDescriptionSchema наследуется от BaseModel и описывает единственное обязательное поле description типа str, с пояснением через Field, что это описание товара. Такая схема работает как подсказка для модели: она видит, что из всей переданной информации нужно вернуть именно это поле, и не распыляется на лишнее. Кроме итоговой схемы ответа описываю схемы входных параметров инструментов (например, поле question для search), чтобы модель понимала, какие данные и в каком виде передавать в каждый инструмент. Такой подход даёт качественный структурированный результат даже на небольших локальных моделях.

  • ProductDescriptionSchema наследуется от BaseModel и содержит поле description типа str
  • Схема сужает поведение модели, чтобы она гарантированно вернула нужное поле, а не произвольный текст
  • Отдельно описываются схемы входных аргументов инструментов (например, question для search)
  • Такой подход даёт качественный результат даже на небольших локальных моделях

Схемы проекта (src/ai/schemas.py): итоговая схема ответа (structured output) и схемы аргументов инструментов.

from pydantic import BaseModel
from pydantic import Field


# ---------------------- SO ---------------------- #
class ProductDescriptionSchema(BaseModel):
    """Описание товара"""

    description: str = Field(description="Описание товара")


class ResponseSchema(BaseModel):
    """Ответ на вопрос"""

    response: str = Field(description="Итоговый ответ")


# ---------------------- TOOLS ---------------------- #
class SearchInputSchema(BaseModel):
    """Запрос пользователя"""

    question: str = Field(description="Запрос пользователя с наименованием товара")


class CreateDescriptionInputSchema(BaseModel):
    """Контекст с информацией о товаре"""

    question: str = Field(description="Запрос пользователя с наименованием товара")
    context: str = Field(description="Информация о товаре собранная из интернета")
схемы позволяют нам использовать как раз вот эти небольшие локальные модели с очень хорошим качеством

Инструмент create_description для генерации карточки товара

create_description принимает запрос пользователя и собранный из интернета контент, вызывает модель с ограничением по схеме и возвращает готовое описание товара.

Функция create_description принимает вопрос (question) и контекст (context) от инструмента поиска и возвращает строку. Обработку оборачиваю в try/except: если что-то пойдёт не так, печатается ошибка и возвращается пустая строка. Модель вызываю через llm.with_structured_output(ProductDescriptionSchema), это ограничивает вывод конкретной схемой, дальше метод invoke принимает список сформированных сообщений, и из результата (completion) гарантированно можно достать поле description. Функция помечена декоратором @tool со своей схемой входных аргументов и описанием: на основе контекста из интернета создаётся описание товара.

  • Функция принимает question и context и возвращает строку описания товара
  • try/except: в случае исключения ошибка печатается в консоль и возвращается пустая строка
  • llm.with_structured_output(ProductDescriptionSchema) заставляет модель вернуть экземпляр схемы с полем description
  • Функция оформлена декоратором @tool со своей схемой аргументов и описанием инструмента

Инструмент create_description_tool (src/ai/tools.py): декоратор @tool, подробный промпт с примером карточки и вызов модели через with_structured_output.

@tool(
    "create_description",
    args_schema=CreateDescriptionInputSchema,
    description="На основе контекста полученного из интернета создает SEO описание для товара",
)
def create_description_tool(question: str, context: str) -> str:
    try:
        system_prompt = (
            "Ты эксперт по структурированию и обобщению информации о товарах"
        )
        user_prompt = """
        Проанализируй предоставленную информацию о товаре из интернет-источников (описания, характеристики, обзоры, технические данные) и составь подробную карточку товара в следующем формате:

        Название товара — полное наименование, включая бренд, модель, ключевые особенности и цвет (если указан).
        Описание товара — краткий, но ёмкий текст (3–5 предложений), раскрывающий суть товара, его назначение, основные преимущества, целевую аудиторию и ключевые особенности. Описание должно быть написано в маркетинговом стиле, понятном покупателю.
        Характеристики — перечисли все доступные технические и эксплуатационные параметры в виде маркированного списка. Стремись извлечь максимум данных, группируя их по категориям (например: Экран, Производительность, Подключение, Дизайн и т.д.), если это уместно.
        Если точные значения отсутствуют — не вымышляй, указывай только подтверждённую информацию.

        ВАЖНО:
        - Не добавляй комментарии, пояснения или источники.
        - Не используй markdown, кроме как для оформления списка.
        - Если информация неполная — ограничься тем, что удалось найти.
        - Для разных типов товаров (телевизоры, смартфоны, бытовая техника и др.) адаптируй структуру характеристик, сохраняя логичную группировку.
        - Не делай призывы к покупке или шаблонные рекламные слоганы.

        Пример результата:
        32” Телевизор Tuvio Full HD DLED Frameless на платформе YaOS, TD32FFBSV1, черный
        ...

        Теперь проанализируй следующую информацию и создай карточку для товара: {question}
        Информация из интернета:
        {context}
        """
        prompt_template = ChatPromptTemplate(
            [
                SystemMessagePromptTemplate.from_template(system_prompt),
                HumanMessagePromptTemplate.from_template(user_prompt),
            ]
        )
        messages = prompt_template.format_messages(question=question, context=context)

        structured_llm = llm.with_structured_output(schema=ProductDescriptionSchema)

        completion = structured_llm.invoke(messages)

        return completion.description
    except Exception as e:
        print(e)
    return ""
мы вызываем метод invoke нашей модели и передаем просто список сформированных сообщений

Промпт-шаблон через ChatPromptTemplate (System/Human message)

System- и Human-сообщения формируются шаблонами LangChain с подстановкой query и context, а сам текст промпта написан подробно и с примером формата, как для небольшой модели, которая может ошибиться.

Для генерации сообщений использую классы LangChain: SystemMessagePromptTemplate и HumanMessagePromptTemplate, объединённые в ChatPromptTemplate. Важен порядок: первым всегда должно идти системное сообщение, иначе будет ошибка. Метод format_messages подставляет в шаблоны конкретные значения переменных, запрос пользователя и контекст из интернета. Сам текст промпта подробно описывает задачу: проанализировать информацию о товаре и составить карточку по заданному формату (название с брендом, ключевые особенности, цвет, краткое описание), не добавлять комментарии и markdown, плюс пример результата. Такая детализация нужна, потому что локальная модель Qwen небольшая, и без подробных инструкций она может путаться, вставлять иероглифы или вообще нерелевантный текст.

  • ChatPromptTemplate объединяет SystemMessagePromptTemplate и HumanMessagePromptTemplate; система должна идти первым сообщением
  • format_messages подставляет в шаблон переменные (запрос пользователя, контекст из интернета)
  • Промпт явно задаёт формат карточки товара и запрещает комментарии и markdown, приводится пример результата
  • Для небольшой модели промпт нужно писать максимально подробно, иначе она может выдать нерелевантный текст
максимально развернутые промпты как ребенку вот как попробуйте объяснить ребенку

Тестовый запуск инструментов в консоли (if __name__ == "__main__")

Каждый инструмент тестируется отдельным запуском файла: у функции с декоратором @tool появляется метод invoke, а блок if __name__ == "__main__" выполняется только при прямом запуске модуля.

Для проверки search делаю тестовую функцию и переменную с тестовым запросом (например, стиральная машина LG), вызов идёт через метод invoke, который появляется у функции после декоратора @tool, ему передаю структуру с ключом параметра схемы. Результат вывожу через pprint для читаемости, там массив results с полями content, score, title, url. Блок if __name__ == "__main__" гарантирует, что тестовый код не выполняется при обычной работе приложения, а срабатывает только при прямом запуске файла. Так же тестирую create_description: сначала вызываю search, потом результат передаю в create_description, и заодно через диспетчер задач видно, как загружается видеокарта во время обработки запроса моделью.

  • У функции с декоратором @tool появляется метод invoke, которому передаётся структура с ключами по схеме аргументов
  • if __name__ == "__main__" выполняется только при прямом запуске файла, а не при работе всего приложения
  • pprint используется для читаемого вывода ответа (results с полями content, score, title, url)
  • При тестовом запуске create_description видеокарта заняла часть видеопамяти на обработку запроса моделью

Тестовая функция test_tools (src/ai/tools.py): последовательный вызов search_tool и create_description_tool через метод invoke, который появляется у функции после декоратора @tool.

def test_tools():
    test_question = "Телевизор Xiaomi TV MAX 85 2025 QLED, 4K Ultra HD"
    search_result = search_tool.invoke({"question": test_question})

    desk = create_description_tool.invoke(
        {"question": test_question, "context": search_result}
    )

    print(desk)


if __name__ == "__main__":
    test_tools()
каждый модуль можно отдельно запустить протестировать и быстренько посмотреть

Сборка агента на LangGraph

В этой части пишу agent.py и собираю самого агента на LangGraph: объявляю состояние (память), три узла-функции (supervisor, tools, formatter), связываю их рёбрами с условным переходом, компилирую граф с визуализацией в graph.png и тестирую его через stream, попутно донастраивая system-промпт. Это ядро всей архитектуры сервиса, именно тут агент получает возможность сам решать, когда обращаться к инструментам, а когда выдавать финальный ответ. На том же LangGraph я собирал и MCP-сервер для 1С — подход с графом узлов там ровно такой же.

State: память агента

State, это класс с полем messages, где накапливается вся история диалога агента.

Состояние графа (state) объявляю как отдельный класс, по сути это память агента. Название класса не важно, хоть state, хоть agent state, хоть product state, разницы нет. Ключевое поле messages: список сообщений, где хранится весь диалог с пользователем и все ответы от инструментов. Благодаря функции add_messages из LangGraph новые сообщения автоматически добавляются в этот список, а не перезаписывают его. В state можно хранить не только messages, туда же добавляете произвольные переменные, например профиль пользователя или другие данные, доступные агенту в любой части кода.

  • State объявляется как класс с полем messages, списком сообщений диалога.
  • add_messages автоматически приплюсовывает новые сообщения к памяти, не затирая старые.
  • Название класса произвольно; в state можно хранить и другие переменные помимо messages (в проекте рядом лежит поле keys для ключевых слов).

State агента (src/ai/agent.py): TypedDict с messages и дополнительным полем keys; add_messages как аннотация поля.

from typing import Annotated
from langgraph.graph.message import add_messages
from typing_extensions import TypedDict


class State(TypedDict):
    messages: Annotated[list, add_messages]
    keys: list[str]
Состояние - это память, то есть объявляем класс state.

Узлы графа: supervisor, tools, formatter

Граф состоит из трёх узлов-функций: supervisor вызывает модель, tools-нода выполняет инструменты, formatter чистит финальный ответ.

Узлы графа это обычные функции, каждая принимает state и возвращает обновлённую память. Supervisor это точка входа: внутри вызывается модель со списком сообщений из памяти, а результат, ответ модели, дописывается обратно в messages. Важная деталь: супервизор использует не «голую» модель, а её версию с привязанными инструментами, llm.bind_tools([...]). Именно bind_tools даёт модели «знать» о доступных инструментах и самой формировать вызовы к ним. Tools-нода собрана готовой функцией ToolNode из langgraph.prebuilt, которая объединяет все инструменты и обрабатывает их вызовы под капотом. Formater (в коде узел назван именно так, без второй t) стоит в конце: берёт последнее сообщение, извлекает его content и регулярным выражением вырезает блок <think>...</think> с рассуждениями думающей модели, оставляя только чистый текст ответа. Это удобно отключить позже, а на этапе отладки полезно видеть.

  • Supervisor вызывает llm_with_tools.invoke(messages) и добавляет результат в messages.
  • llm_with_tools = llm.bind_tools([...]) и tool_node = ToolNode([...]) объявляются один раз на уровне модуля.
  • ToolNode (langgraph.prebuilt) объединяет все инструменты в один узел, обработка под капотом.
  • Formater регуляркой <think>...</think> удаляет блок с размышлениями модели из последнего сообщения перед выдачей ответа.

Привязка инструментов и узлы графа (src/ai/agent.py): tool_node, llm_with_tools, supervisor и formater.

from langgraph.prebuilt import ToolNode, tools_condition
from src.ai.tools import search_tool, create_description_tool

tool_node = ToolNode([search_tool, create_description_tool])
llm_with_tools = llm.bind_tools([search_tool, create_description_tool])


def supervisor(state: State):
    result = llm_with_tools.invoke(state["messages"])
    return {"messages": [result]}


def formater(state: State):
    last_message = state["messages"][-1].content
    cleaned_text = re.sub(r"<think>[\s\S]*?</think>", "", last_message).strip()
    return {"messages": [cleaned_text]}
И нода форматора - это которая у нас будет стоять в самом конце и форматировать наше исходное итоговое сообщение от модели.

Рёбра графа и условный переход tools_condition

Рёбра задают поток выполнения: START → supervisor → (условие tools_condition) → tools или formatter → END, как блок-схема.

Рёбра описывают потоки выполнения между узлами, я это сравниваю с блок-схемами из школьной информатики. Граф стартует в supervisor, ребро от START. Дальше идёт условное ребро с готовой функцией tools_condition из LangGraph: она решает, нужно ли ещё вызывать инструменты или пора идти к formatter. В итоге получается цикл: supervisor, tools, снова supervisor, пока модель не решит, что задача выполнена, и только потом управление уходит в formatter и в конец. Это называется реактивный паттерн: агент вызывает инструменты несколько раз подряд, пока сам не решит задачу, но под капотом стоит ограничение на число итераций, примерно 4-5, чтобы не было зацикливания.

  • Ребро от START ведёт в supervisor, это точка входа графа при запуске.
  • tools_condition (готовая функция LangGraph) решает: снова к инструментам или дальше к formatter.
  • Получается цикл supervisor ⇄ tools, пока модель не решит задачу, это и есть реактивный паттерн.
  • Есть встроенное ограничение примерно на 4-5 итераций, чтобы не было зацикливания.

Рёбра графа (src/ai/agent.py): add_edge(START, supervisor), add_edge(tools, supervisor), условное ребро add_conditional_edges с tools_condition.

def get_graph():
    graph_builder = StateGraph(State)

    graph_builder.add_node("supervisor", supervisor)
    graph_builder.add_node("tools", tool_node)
    graph_builder.add_node("formater", formater)

    # ---------- 3. Ребра ----------
    graph_builder.add_edge(START, "supervisor")
    graph_builder.add_edge("tools", "supervisor")
    graph_builder.add_conditional_edges(
        "supervisor",
        tools_condition,
        {
            "tools": "tools",
            "__end__": "formater",
        },
    )

    graph = graph_builder.compile()
Это называется реактивный паттерн, по-моему.

Компиляция графа и визуализация в graph.png

Функция get_graph() собирает StateGraph из state, узлов и рёбер, компилирует его и сохраняет схему в graph.png.

Все части графа связываю в отдельной функции get_graph(). Внутри создаю экземпляр StateGraph с переданным классом state, добавляю три узла (supervisor, tools, formatter) и рёбра между ними. После этого граф компилируется методом compile(), результат кладу в переменную graph и возвращаю из функции. Для наглядности скомпилированный граф сохраняю картинкой в файл graph.png, чисто для теста, чтобы визуально увидеть структуру связей между узлами и убедиться, что граф собран правильно, прежде чем запускать его на реальных сообщениях.

  • Функция get_graph() создаёт StateGraph(state), добавляет ноды supervisor/tools/formatter и рёбра.
  • Граф компилируется методом compile(), результат сохраняется в переменную graph.
  • Скомпилированный граф выводится картинкой в graph.png для визуальной проверки структуры.

Компиляция графа (src/ai/agent.py): graph_builder.compile() и сохранение визуализации в graph.png через draw_mermaid_png().

    graph = graph_builder.compile()
    with open("graph.png", "wb") as f:
        f.write(graph.get_graph().draw_mermaid_png())

    return graph


# ---------- 4. Запуск ----------
def stream_graph_updates(graph, user_input: str):
    ...


if __name__ == "__main__":
    product_graph = get_graph()
мы компилируем наш граф и помещаем результат в переменную Graph

Тестовый запуск через stream() и отладка system-промпта

graph.stream() в отличие от invoke() показывает каждый шаг выполнения: это помогает вживую докручивать system-промпт агента.

Для запуска графа с реальными сообщениями пишу функцию streamGraphUpdates: формирую system message с правилами (не вызывать инструмент повторно, не описывать свои шаги, отвечать кратко) и human message с запросом пользователя. Для отладки граф вызываю методом stream(), который, в отличие от invoke(), отдаёт каждый промежуточный этап, видно, как супервизор обращается к инструментам и возвращается обратно. Смотрю на вывод в консоли и вижу, что модель не вызывает инструмент создания описания, значит корректирую промпт, явно прописываю вызвать Create description после Search. В продакшене вместо stream использую invoke(), а из ответа графа достаю последнее, уже очищенное formatter'ом сообщение.

  • System-промпт агента содержит правила против зацикливания: не повторять вызов инструмента, не описывать шаги, отвечать только готовым результатом.
  • graph.stream() показывает каждый шаг цепочки вызовов, в отличие от invoke(), который отдаёт только итог.
  • По логам stream я увидел, что инструмент создания описания не вызывается, и уточнил промпт явной инструкцией.
  • В продакшене используется graph.invoke(), из результата берётся последнее сообщение messages.

Запуск графа (src/ai/agent.py): system-промпт с правилами против зацикливания, human-сообщение с товаром, вызов через invoke (строка со stream закомментирована для отладки).

def stream_graph_updates(graph, user_input: str):
    messages = [
        SystemMessage(
            content="""
            Ты — умный и полезный ассистент. У тебя есть доступ к следующим инструментам:
            поиск в интернете и создание описания товара. После получения информации от инструмента search,
            сформируй описание товара с помощью инструмента create_description.

            Правила:
            1. Если ты уже вызвал инструмент и получил результат, НЕ вызывай его снова.
            2. Проанализируй результат инструмента и дай окончательный ответ пользователю.
            3. Не повторяй одни и те же действия.
            4. Если задача решена — заверши диалог.
            5. Отвечай КРАТКО, ПО ДЕЛУ и ТОЛЬКО ГОТОВЫМ ОТВЕТОМ.
            6. НИ В КОЕМ СЛУЧАЕ не описывай свои шаги, не пиши "Я вызвал инструмент", "Теперь я создаю описание" и т.п.
            7. Финальный ответ должен содержать ТОЛЬКО полезный контент (например, SEO-описание), без пояснений, комментариев или внутренних размышлений.
            """
        ),
        HumanMessage(content=user_input),
    ]

    # for event in graph.stream({"messages": messages}):
    #     pretty_print_messages(event, last_message=True)

    response = graph.invoke({"messages": messages})

    content = response.get("messages")[-1].content
    print(content)

    return content


if __name__ == "__main__":
    product_graph = get_graph()
    stream_graph_updates(
        graph=product_graph,
        user_input="Монитор LCD Samsung 32 S32CG550EI Odyssey G5 VA Curved",
    )
А Stream, он тебе будет выдавать каждый этап.

Здесь мы собрали ядро — агента на LangGraph, который сам ходит в поиск и формирует описание. Весь код проекта целиком (инструменты, граф, схемы, промпты), а не обрывки из видео, я разбираю по шагам в курсе по ИИ в 1С: там же готовые расширения 1С, закрытые вебинары и сопровождение, чтобы вы собрали такой сервис под свою номенклатуру, а не «примерно как на записи».

Вопросы: ИИ-ассистенты для кода, доступ к моделям, RAG по документам

В этой части отвечаю на вопросы слушателей: сравниваю DiffSigCoder, Claude и встроенного помощника 1С как ИИ-ассистентов для кода, объясняю, почему доступ к зарубежным моделям вроде ChatGPT и Gemini из России ограничен и как OpenRouter помогает это обойти. Отдельно разбираю варианты RAG: поиск товара по фотографии через векторную базу, структурированный парсинг веб-страниц по схеме и корпоративный RAG по внутренним документам компании.

ИИ-помощники для написания кода (DiffSigCoder, Claude, помощник 1С)

ИИ-ассистенты ускоряют написание шаблонного кода, но не заменяют разработчика: код всё равно нужно проверять и понимать язык.

DiffSigCoder использую для быстрого наброска шаблонного кода на Python: закидываю задачу в инструмент, он выдаёт «простыню» кода, которую копирую и потом вычищаю лишнее. Все эти помощники, про которых говорят, что заменят программистов, думаю, пока не заменят: без знания языка и понимания, как код должен быть написан по-нормальному, результат превращается в «бредогенератор», в котором потом тяжело искать ошибки. Даже встроенный помощник в 1С не отменяет необходимости знать синтаксис и уметь применять паттерны, ассистент лишь облегчает написание больших шаблонных кусков кода, которые всё равно приходится форматировать, разбирать и вникать, как это работает. Claude мощный агент, но пользоваться им неудобно: для бесплатного доступа нужна сложная регистрация, а платный тариф дорогой.

  • DiffSigCoder удобен для наброска шаблонного кода, но результат нужно чистить и перепроверять вручную.
  • Без знания языка и паттернов помощник может выдать нерабочий или избыточный код («бредогенератор»).
  • Даже встроенный в 1С помощник требует знания синтаксиса и умения применять паттерны.
  • Claude, мощный агент, но труден в бесплатном доступе (сложная регистрация) и дорог в платном.
Все эти помощники, про которых говорят, что заменят программистов, думаю, пока не заменят.

Доступ к зарубежным моделям из России и OpenRouter как прослойка

Не всегда доступ блокирует РКН: часть ограничений вводят сами зарубежные сервисы, поэтому используют VPN или прослойки вроде OpenRouter.

Кстати, РКН не блокирует ChatGPT, доступ к нему из некоторых стран ограничивает сам сервис. У Google для использования Gemini через студию проверка ещё строже: нужен не-российский (и не белорусский) аккаунт, и желательно заходить через VPN, чтобы минимизировать следы захода из России. Из-за этого и появляются сервисы-прослойки, которые помогают работать со всеми моделями сразу, например OpenRouter. Мы используем его в курсе, чтобы подобрать и протестировать более удачную модель в облаке. После того как подходящая модель найдена на тестах, в продакшене уже берём отдельный ключ именно этой модели и работаем с ней напрямую.

  • РКН не блокирует ChatGPT, ограничение доступа для ряда стран вводит сам сервис.
  • Для Gemini через Google Studio нужен не-российский аккаунт и желательно VPN, чтобы скрыть заход из России.
  • OpenRouter используется как прослойка для подбора и тестирования разных моделей в облаке.
  • После выбора модели на тестах в продакшене берут отдельный прямой ключ именно этой модели.
OpenRouter мы используем в курсе для того, чтобы можно было подобрать для теста и протестировать более удачную модель в облаке.

Поиск товара по фотографии через векторную базу

Чтобы находить товар по фото, картинку превращают в вектор и ищут ближайший похожий вектор в собственной базе.

На вопрос про распознавание товара по фотографии объясняю, как это устроено у Яндекса: изображения переводятся в вектора и хранятся в векторных базах. Когда пользователь отправляет свою картинку для поиска, сервис так же превращает её в вектор и ищет максимально близкий, релевантный вектор среди сохранённых, а по совпадению выдаёт связанную с ним информацию. Для своей задачи предлагаю вариант попроще: пишете парсер под конкретный маркетплейс (например, Яндекс.Маркет), парсите картинки и точные названия и описания товаров, набираете свою базу, а дальше сравниваете новую картинку с этой базой и выдаёте информацию по наиболее похожему найденному товару — по сути это та же задача сопоставления номенклатуры в 1С, только вход не текст, а изображение.

  • Изображения переводятся в вектора и хранятся в векторной базе.
  • Поиск по картинке, это поиск ближайшего по смыслу вектора среди сохранённых.
  • Полноценный поиск по фото с определением модели могут делать крупные сервисы вроде Яндекса, у которых уже накоплена база картинок.
  • Для своей задачи можно спарсить каталог маркетплейса с картинками и описаниями и искать наиболее похожий товар в своей базе.
Хранятся векторная база, к картинке приводятся вектора, это все хранится в векторных базах.

Структурированный парсинг веб-страниц по схеме

Есть LLM-библиотеки, которые вытаскивают из страницы только нужные поля по заранее заданной схеме.

На вопрос про распознавание информации на страницах товаров отвечаю, что инструмент уже умеет отдавать сырой контент страницы (rough content = true), и его можно передать в специальные библиотеки на основе больших языковых моделей, которые парсят страницы по заданной схеме. В схеме заранее описываете, какие поля нужны, например description, размер, цвет, мужской или женский. URL для парсинга находите, скажем, через Tavily, передаёте в такую библиотеку, и модель разбирает большой объём текста (пусть даже десять страниц), фильтрует его и возвращает строго те данные, что указаны в схеме, а не весь текст целиком.

  • Инструмент может отдавать сырой контент страницы через параметр rough content.
  • Специальные LLM-библиотеки парсят страницу под заранее заданную схему полей (description, размер, цвет и т.д.).
  • URL для парсинга можно находить через Tavily.
  • Модель фильтрует большой объём текста и возвращает только структурированные данные строго по схеме.
Здесь в схеме можно описать: выдай размер, цвет, мужской, женский, и строго эту схему определить.

Корпоративный RAG по внутренним документам

Скормив модели внутренние инструкции и документацию, можно сделать техподдержку, которая ищет ответ в нужной категории документов.

Если скормить внутреннюю инструкцию и документацию, реально сделать внутреннюю техподдержку пользователей, это называется корпоративный RAG. В курсе мы уже делаем RAG на базе вопрос-ответов и планируем отдельный проект RAG по PDF-документам: сервис разбирает загруженные PDF по категориям (например, отдел кадров, ПТО), и, исходя из вопроса пользователя (допустим, «как оформить отпуск»), определяет нужную категорию, находит соответствующий кусок в PDF этой категории и выдаёт ответ. Тут сразу скажу: парсинг PDF и получение из них корректной, повторяемой информации, задача не такая простая, как кажется. Если просто закидывать PDF в LLM напрямую, результаты по одному и тому же запросу могут отличаться от раза к разу.

  • Корпоративный RAG строится на внутренних инструкциях и документации для техподдержки пользователей.
  • В планах: RAG по PDF, разбор загруженных документов по категориям (отдел кадров, ПТО и т.п.) и поиск ответа в нужной категории.
  • Прямая загрузка PDF в LLM без грамотного парсинга даёт нестабильные, разные ответы на один и тот же запрос.
  • Корпоративный RAG сейчас востребованная («хайповая») задача, которую часто делают компании.
Если скормить внутреннюю инструкцию и документацию, реально сделать внутреннюю техподдержку пользователей, это называется корпоративный RAG.

FastAPI-сервис и интеграция с 1С

На последнем этапе оборачиваю агента в веб-сервис на FastAPI: пишу main.py с роутером, схемой запроса и функцией инициализации графа при старте приложения. Сначала проверяю сервис через встроенную документацию (Swagger UI), а затем показываю готовое расширение для 1С Управление торговлей 11.5 с кнопкой формирования описания прямо на карточке номенклатуры. Сама связка «микросервис на FastAPI + 1С» — это базовый кирпич, на котором держатся почти все мои ИИ-интеграции с 1С.

Lifespan-функция: инициализация графа при старте FastAPI

Граф компилируется один раз при старте приложения и хранится в общей структуре, откуда его вызывают все запросы.

С помощью asynccontextmanager из FastAPI создаю асинхронную функцию, которая срабатывает при старте приложения, по аналогии с обработчиками "при старте системы" в 1С. Она печатает в консоль сообщение о старте, кладёт скомпилированный граф в фиксированную структуру по ключу "граф" (по умолчанию None), и после этого граф доступен из любого места приложения без повторной компиляции. Ключевое слово yield разделяет код запуска и код завершения приложения, после yield обычно закрывают соединения с базой данных или останавливают внешние сервисы вроде Telegram-бота.

  • Функция инициализации регистрируется как lifespan при создании экземпляра FastAPI
  • Граф компилируется один раз при старте и кладётся в структуру с ключом "граф"
  • yield отделяет логику старта от логики корректного завершения приложения

main.py: импорты, bot_manager, lifespan-функция (компиляция графа при старте, запись в bot_manager['graph']), создание FastAPI(lifespan=lifespan), APIRouter, схема ProductDescriptionRequest.

from contextlib import asynccontextmanager
from fastapi import APIRouter
import uvicorn
from fastapi.responses import JSONResponse
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
from pydantic import BaseModel
from src.ai.agent import get_graph, stream_graph_updates

bot_manager = {
    "graph": None,
}


# Создаем асинхронную контекстную менеджер-функцию для обработки старта и завершения работы приложения
@asynccontextmanager
async def lifespan(app: FastAPI):
    print("Старт работы приложения")
    bot_manager["graph"] = get_graph()
    yield
    print("Завершение работы приложения")


main_app = FastAPI(
    default_response_class=ORJSONResponse,
    lifespan=lifespan,
)

router = APIRouter(prefix="/v1")


class ProductDescriptionRequest(BaseModel):
    product_name: str
мы вот создали асинхронную функцию, указали app, это наше приложение FastAPI

Pydantic-схема запроса ProductDescriptionRequest

Тело POST-запроса описывается Pydantic-моделью с единственным полем product_name.

Для валидации тела запроса создаю класс на основе BaseModel из pydantic, ProductDescriptionRequest, с полем product_name типа строка, которое обязательно должно присутствовать в запросе. Тут поясню: в учебных целях весь код (роуты, схемы, обработчики) у меня написан в одном файле main.py, а в реальных приложениях эти части принято разносить по отдельным модулям, маршруты в одном файле, схемы в другом, обработчики запросов в третьем.

  • Схема ProductDescriptionRequest наследуется от pydantic BaseModel
  • Единственное обязательное поле: product_name (строка)
  • В продакшене роуты, схемы и обработчики принято разносить по отдельным модулям, здесь для простоты всё в одном файле

main.py: импорты, bot_manager, lifespan-функция (компиляция графа при старте, запись в bot_manager['graph']), создание FastAPI(lifespan=lifespan), APIRouter, схема ProductDescriptionRequest.

from contextlib import asynccontextmanager
from fastapi import APIRouter
import uvicorn
from fastapi.responses import JSONResponse
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
from pydantic import BaseModel
from src.ai.agent import get_graph, stream_graph_updates

bot_manager = {
    "graph": None,
}


# Создаем асинхронную контекстную менеджер-функцию для обработки старта и завершения работы приложения
@asynccontextmanager
async def lifespan(app: FastAPI):
    print("Старт работы приложения")
    bot_manager["graph"] = get_graph()
    yield
    print("Завершение работы приложения")


main_app = FastAPI(
    default_response_class=ORJSONResponse,
    lifespan=lifespan,
)

router = APIRouter(prefix="/v1")


class ProductDescriptionRequest(BaseModel):
    product_name: str
Внутри product name у нас строка должна прийти в наш запрос.

POST-эндпоинт generate_product_description

Асинхронный обработчик на роутере принимает название товара, запускает граф и возвращает description либо JSON с ошибкой 400.

На APIRouter регистрирую асинхронную функцию generate_product_description с методом POST по URL generate_product_description, она принимает тело запроса по схеме ProductDescriptionRequest. Внутри вызывается граф, инициализированный при старте приложения и хранящийся в общей структуре: обёрткой служит та же функция stream_graph_updates, которая под капотом использует graph.invoke, ей передаётся product_name из запроса, а результатом становится description. Обработку ошибок делаю упрощённо: любое исключение перехватывается, и возвращается JSONResponse со статусом 400 и текстом ошибки в теле. Сразу скажу: в реальном проекте так делать не стоит, надо обрабатывать конкретные типы ошибок, а не всё подряд. Роутер подключаю к приложению с префиксом /api, а сам роутер объявлен с префиксом /v1, итоговый маршрут: /api/v1/generate_product_description.

  • Метод POST, схема запроса: ProductDescriptionRequest, внутри вызывается заранее скомпилированный граф через stream_graph_updates
  • Результат работы графа возвращается в теле ответа как {"description": ...}
  • При исключении возвращается JSONResponse со статусом 400 и телом {"error": str(e)}: упрощённая обработка "всех подряд"
  • Роутер объявлен с prefix="/v1" и подключён с prefix="/api", полный маршрут: /api/v1/generate_product_description

POST-эндпоинт (src/main.py): обработчик на роутере и подключение роутера к приложению; uvicorn слушает host и порт 8000.

@router.post("/generate_product_description")
async def generate_product_description(request: ProductDescriptionRequest):
    try:
        response = stream_graph_updates(
            graph=bot_manager["graph"], user_input=request.product_name
        )

        return {
            "description": response,
        }
    except Exception as e:
        return JSONResponse(status_code=400, content={"error": str(e)})


main_app.include_router(router, prefix="/api")

if __name__ == "__main__":
    uvicorn.run("main:main_app", host="10.10.1.10", port=8000, reload=False)
Статус код будет 400. И у нас вот такой вот json будет написана ошибка и описание этой ошибки.

Тестирование сервиса через Swagger UI

Встроенная документация FastAPI позволяет отправить тестовый POST-запрос и сразу увидеть работу агента.

После запуска main.py (uvicorn слушает указанный host и порт 8000) в консоли появляется сообщение о старте и об инициализации графа. У FastAPI есть встроенный модуль, который сам формирует интерактивную документацию, где виден пост-запрос /api/v1/generate_product_description. Через кнопку Try out ввожу в поле product_name название реального товара (монитор), отправляю запрос на выполнение, и в консоли видно, как агент ищет информацию по товару и формирует итоговое описание, которое и возвращается в ответе.

  • uvicorn запускает приложение по указанным host и порту (8000)
  • Swagger UI формируется FastAPI автоматически и показывает доступные маршруты
  • Через Try out отправлен реальный запрос с названием товара (монитор), в консоли видно, как граф ищет информацию и формирует description
У FastAPI есть встроенный модуль, который формирует документацию.

Расширение 1С УТ 11.5: кнопка «Сформировать описание» и HTTP-запрос к сервису

Команда на карточке номенклатуры отправляет HTTP POST на FastAPI-сервис и записывает полученное описание в реквизит товара.

В расширении для Управление торговлей 11.5 на форму номенклатуры добавляю команду "Сформировать описание": по ссылке на объект формируется наименование товара, из него собирается структура ProductName и сериализуется в JSON, который отправляется HTTP-методом POST на адрес сервиса (IP компьютера и порт 8000) с заголовком Content-Type application/json и кодировкой UTF-8 без BOM. При статусе ответа 200 из полученной JSON-строки читаю ключ description, значение помещаю в реквизит "Описание" номенклатуры, а объект помечаю как модифицированный, чтобы описание сохранилось. Для работы HTTP-запросов в расширении обязательно отключаю безопасный режим, иначе HTTPСоединение не заработает. И да, поскольку запросы из 1С синхронные, интерфейс "подвисает" до получения ответа от сервиса.

  • Для HTTP-запросов из расширения нужно отключить безопасный режим, иначе HTTPСоединение не работает
  • Формируется JSON {ProductName}, отправляется POST на IP:8000/api/v1/generate_product_description с Content-Type application/json, кодировка UTF-8 без BOM
  • При коде 200 из ответа достаётся ключ description и записывается в реквизит "Описание" номенклатуры, объект помечается модифицированным
  • Запрос синхронный: интерфейс 1С блокируется до получения ответа от сервиса

Команда 1С: формирование наименования товара, HTTP-запрос к FastAPI-сервису, запись описания в реквизит номенклатуры.

&НаКлиенте
Процедура ОбработкаКоманды(ПараметрКоманды, ПараметрыВыполненияКоманды)
	СсылкаНаТовар = ПараметрКоманды;
	НаименованиеТовара = Строка(СсылкаНаТовар);

	ДанныеОтСервиса = ПолучитьДанныеОтСервиса(НаименованиеТовара);
	Если ДанныеОтСервиса = Неопределено Тогда
		Сообщить("Ошибка получения данных от сервиса");
	КонецЕсли;

	//РезультатОбновления = ОбновитьОписание(СсылкаНаТовар, ДанныеОтСервиса);
	//Если РезультатОбновления = Истина Тогда
	//	Сообщить("Новое описание установлено");
	//КонецЕсли;

	//ПараметрыВыполненияКоманды.Источник.ОбновитьОтображениеДанных();
	ПараметрыВыполненияКоманды.Источник.Объект.Описание = ДанныеОтСервиса;
	ПараметрыВыполненияКоманды.Источник.Модифицированность = Истина;
КонецПроцедуры

&НаСервере
Функция ОбновитьОписание(СсылкаНаТовар, Описание)

&НаСервере
Функция ПолучитьДанныеОтСервиса(product_name)
Код восстановлен из видео, проверьте перед использованием.
Поскольку у нас запросы с 1С синхронные. У нас тормозит интерфейс.

Если хотите повторить всю цепочку — от локальной модели и агента до кнопки «Сформировать описание» в своей УТ 11.5 — и получить исходники расширения 1С, которых нет в открытом доступе, приходите на полный курс по ИИ в 1С. Там этот и соседние сервисы (распознавание документов, сопоставление номенклатуры, извлечение характеристик) разобраны с нуля и доведены до рабочего кода.

Расширение агента: SEO-описание с учётом Wordstat

В этой части показываю, как усложнить уже собранного агента: в граф добавляю узел Wordstat, который через LLM подбирает ключевые слова для товара и уточняет их частотность в поиске Яндекса, чтобы на выходе получить SEO-оптимизированное описание. Заодно упомяну соседний, отдельно разрабатываемый сервис: извлечение именованных характеристик товара (бренд, модель, диагональ и т.п.) из его названия.

Узел Wordstat в графе для подбора ключевых слов

В граф агента добавляется новая функция-узел: она через LLM генерирует ключевые слова по товару, затем уточняет их частотность через Wordstat, а на основе списка формируется итоговое SEO-описание.

Расширяю существующий супервизор-граф новым узлом, условно назвал его Wordstat. У узла свой отдельный System Prompt: он берёт последнее сообщение пользователя (название товара) и просит модель вернуть 10 ключевых слов. Дальше эти слова уходят в апи Wordstat, откуда возвращаются релевантные для Яндекса ключи с частотностью на каждое слово. Список слов с частотностью кладу в state графа и возвращаю из функции для дальнейшего использования супервизором.

  • Новый узел получает последнее сообщение пользователя и своим System Prompt просит LLM вернуть 10 ключевых слов по товару
  • Ключевые слова отправляются в апи Wordstat, который возвращает релевантные для Яндекса запросы с частотностью
  • Список слов с частотностью сохраняется в state и возвращается из функции узла для использования дальше по графу
Верни нам 10 ключевых слов для вот этого товара.

Форматер описания с учётом SEO-ключей

Функция-форматер делает ещё один LLM-запрос: на основе названия товара, уже готового описания и подобранных ключевых слов составляется SEO-оптимизированное описание под частотные запросы.

После того как супервизор находит описание товара и собирает данные, узел-форматер не просто оформляет текст, а делает ещё один запрос в модель с отдельным System Prompt. В запросе прошу взять полученное описание, название товара и ключевые слова из state, которые раньше собрал узел Wordstat, и на их основе составить SEO-оптимизированное описание с упором на максимально частотные или среднечастотные ключи. В итоге на выходе получается описание товара, уже заточенное под конкретные поисковые запросы.

  • Форматер берёт из state описание, название товара и ключевые слова, собранные узлом Wordstat
  • Делается отдельный System Prompt-запрос в LLM с задачей составить SEO-оптимизированное описание
  • Ориентир: максимально частотные либо среднечастотные ключевые запросы
составь SEO-оптимизированное описание под максимально там частотные ключи

Сервис извлечения именованных характеристик товара

Отдельный, смежный сервис по названию товара извлекает именованные характеристики (бренд, модель, диагональ, разрешение и т.п.) и возвращает готовый JSON для автозаполнения карточки в 1С.

Упомяну ещё один свой сервис: извлечение именованных сущностей из названия товара, о нём записан отдельный закрытый вебинар для курса. Задача частая для 1С: например, из названия «телевизор Xiaomi» нужно заполнить в карточке бренд, модель, диагональ, год, тип матрицы (QLED/OLED), разрешение (4K/Ultra HD) и так далее — эту тему я подробно разбирал в статье про автозаполнение характеристик товаров в 1С через SpaCy и Ollama. Пока сервис извлекает только тип модели, но нейронку можно дообучить на извлечение любой части характеристик. Название товара отправляется в сервис, он возвращает JSON с расписанными по ключам характеристиками, на основе которого автоматически заполняются поля в 1С. Штука актуальная при больших объёмах поступающей номенклатуры.

  • Сервис по названию товара извлекает именованные характеристики: бренд, модель, диагональ, год, тип матрицы, разрешение и др.
  • Пока обучен извлекать только тип модели, но нейронку можно дообучить на любую характеристику
  • На выходе: JSON с расписанными ключами, на основе которого автоматически заполняются характеристики в 1С
Там, где разрабатывается сервис извлечения именованных сущностей.

Монетизация ИИ-проектов в 1С, курс, железо и вопросы

В финальной части перехожу с демонстрации кода на практические и деловые вопросы: как оценивать и продавать такие ИИ-проекты клиентам 1С, зачем нужны локальные модели при работе с конфиденциальными данными и как правильно выбирать размер модели под задачу и видеокарту. Отвечаю на вопросы зрителей о разнице между обучением и файн-тюнингом модели, а ещё делюсь приёмом с намеренным «очеловечиванием» ответов чат-ботов.

Оценка стоимости ИИ-проекта через замещаемую зарплату сотрудника

Если бот заменяет работу человека, цену проекта привязывают к его годовой зарплате, а не к часам разработки.

Расскажу, как я оцениваю стоимость ИИ-проектов для клиентов 1С. Классическую отчётность и аналитику в 1С продавать тяжело: сложно «пощупать» её ценность, даже если отчёт сложный и полезный. Другое дело, когда бот или агент замещает работу конкретного сотрудника, например ведёт первичную классификацию клиентов или отвечает на типовые вопросы. Тут ориентиром цены становится годовая зарплата этого сотрудника до вычета налогов, а не часы разработки. Клиент сравнивает такую стоимость с наймом нескольких менеджеров и связанными издержками (зарплата, больничные, текучка) и охотнее платит за проект, даже если он окупается не сразу.

  • Ценность отчётов и аналитики в 1С продавать тяжело: эффект неосязаем для клиента.
  • Если бот заменяет работу сотрудника, ориентир цены: его годовая зарплата до вычета налогов.
  • Пример: бот для первичной классификации клиентов вместо найма 10 менеджеров.
  • По моим наблюдениям, около 70-80% обращений клиентов типовые, их и автоматизируют в первую очередь.
Мы заменяем работу, автоматизируем работу одного человека, берем его годовую зарплату.

Локальные модели для работы с конфиденциальными данными

Документы вроде паспортов по закону нельзя отправлять в зарубежные облачные модели, поэтому распознавание делается только локально.

На вопрос про конфиденциальные данные объясню, почему блок с распознаванием документов (Compute Vision) у меня построен полностью на локальных моделях. По закону такие документы, как паспорта, нельзя гонять через зарубежные облачные сервисы, хотя по качеству распознавания лучше всего справляются как раз модели Google (Gemini). Поэтому стараюсь максимально работать решениями внутри контура предприятия: модель через Ollama запущена на локальном компьютере, запросы никуда не уходят в интернет, и даже если выдернуть кабель из сети, сервис продолжит работать. Плюс безопасники и админы клиента могут дополнительно закрыть любые внешние подключения, чтобы гарантированно данные не покинули периметр.

  • По законодательству нельзя отправлять документы (паспорта и т.п.) в зарубежные облачные модели.
  • Лучше всего распознают документы модели Google (Gemini), но для таких данных их использовать нельзя.
  • Проект работает на локальной модели через Ollama на компьютере автора, без выхода в интернет.
  • Админы клиента могут дополнительно ограничить любые внешние подключения ради безопасности.
У нас по законодательству нельзя отправлять документы, тем более в зарубежной модели.

Разница между «обучением модели» и работой с контекстом

То, что многие называют «обучением модели», на деле, просто подстановка найденной информации в контекст, а не файн-тюнинг.

Разберу частую путаницу вокруг слова «обучение» применительно к LLM. В нашем сервисе никакого обучения модели не происходит: система находит информацию в интернете, подставляет её модели в контекст, и модель на основании этого контекста формирует описание. Это работа с контекстом, а не обучение. Настоящее дообучение называется файн-тюнингом и стоит очень больших денег, оно оправдано, например, для голосовых агентов, которые отвечают по телефону, там критична скорость: модель дообучают на своём датасете (номенклатуре из тысячи товаров), чтобы она мгновенно узнавала товары без поиска в базе. Для обычных чат-ботов, где скорость не критична, в файн-тюнинге нет никакой необходимости.

  • «Нашли инфу и подсунули в модель», это работа с контекстом, а не обучение.
  • Настоящее обучение модели называется файн-тюнингом и стоит очень больших денег.
  • Файн-тюнинг оправдан для голосовых агентов, где нужна мгновенная скорость ответа по своей номенклатуре.
  • Для обычных ботов-чатов, где скорость не критична, дообучение не требуется.
Никто не обучает модель. Модель можно файн-тюнить.

Намеренное «очеловечивание» ответов чат-бота

Ответы бота специально разбивают на короткие фразы и выдают с задержкой печати человека, чтобы клиент не понял, что общается с ботом.

Поделюсь практическим приёмом для чат-ботов, которые работают с клиентами: намеренно замедляю и упрощаю ответы, чтобы пользователь не догадался, что общается с ботом. Итоговый ответ разбиваю на короткие, упрощённые предложения (иногда со сленгом) и выдаю в чат с задержкой, имитирующей скорость печати человека, например на боте для магазина автозапчастей. Причина простая: стоит пользователю понять, что перед ним бот, он перестаёт доверять ответам и начинает проситься к живому оператору, даже если ответы были правильные и по делу. Поэтому задержки в поиске информации по базе я считаю даже плюсом: они играют на руку иллюзии живого собеседника.

  • Итоговый ответ бота разбивается на короткие простые фразы и печатается с задержкой, имитирующей человека.
  • Если пользователь понимает, что общается с ботом, он чаще отваливается и просит оператора.
  • Задержки в поиске информации по базе не недостаток, а плюс для правдоподобия.
  • Приём показан на примере бота для магазина автозапчастей.
Если вы напишите у себя в чате, что вы общаетесь с ботом, большинство людей отвалится.

Выбор размера локальной модели Qwen3 по видеопамяти и задаче

Чем больше параметров у модели Qwen3, тем она умнее, но тем больше видеопамяти требует; для простых задач хватает совсем маленькой модели.

Покажу на практике, как размер локальной модели Qwen3 (в речи иногда проскакивает как WAN3) влияет на качество и потребление видеопамяти. Версия на 14 миллиардов параметров, это базовый вариант в демонстрации. Версия на 30 миллиардов параметров умнее, но заметно больше отъедает видеопамять. Версия на 4 миллиарда параметров работает почти мгновенно и занимает совсем мало места в памяти, годится даже для видеокарты на 8 Гб, но может недолго держать контекст или зацикливаться. Поэтому для рабочих задач я обычно использую модели уровня 8B-14B, а свои проекты стараюсь укладывать в объём видеопамяти карты 3090 (24 Гб), задачи при этом можно дробить между несколькими моделями разного размера. Как собрать GPU-сервер под локальные нейросети дома, я разбирал отдельно — там же про выбор видеокарт под такие модели.

  • Qwen3 14B, рабочий вариант по умолчанию в демонстрации сервиса.
  • Qwen3 30B «умнее», но заметно больше расходует видеопамять.
  • Qwen3 4B работает почти мгновенно и умещается на видеокарте с 8 Гб, но может зависать или зацикливаться.
  • Обычно использую модели 8B-14B и стараюсь укладывать проекты в 24 Гб видеопамяти (3090).
Вот 4B моделька занимает совсем мало места в памяти.

Глоссарий

RAG (Retrieval-Augmented Generation)
Подход, при котором модель не «дообучается» на лету, а получает релевантный фрагмент данных (например, найденную в интернете информацию о товаре) прямо в контекст диалога и формирует ответ на его основе.
Embedding-модель
Модель, переводящая текст в числовой вектор для смыслового (векторного) поиска; выбирается экспериментально под конкретный тип текста, например для сопоставления коротких аббревиатур на разных языках лучше всего показала себя модель линейки Granite.
Qwen3 14B
Локальная языковая модель с 14 миллиардами параметров, выбранная для сервиса описания товара; запускается на видеокарте от 16 ГБ видеопамяти, комфортнее, от 24 ГБ.
Ollama
Приложение для локального запуска и управления LLM-моделями (скачивание командой ollama pull, запуск командой ollama run); в проекте подключается через пакет langchain-ollama и класс ChatOllama.
FastAPI
Python-фреймворк для разработки веб-приложений и API; используется, чтобы обернуть ИИ-агента в сервис, к которому обращается 1С по HTTP.
Self-hosted поиск
Бесплатная альтернатива платным поисковым сервисам Tavily и SERP, открытый сервис-обёртка над Python-пакетом для поиска, разворачивается через docker-compose и отдаёт API с эндпоинтом /search.
LangGraph
Фреймворк поверх LangChain для разработки агентов в виде графа состояний с узлами и рёбрами (supervisor, tools, formatter), который управляет циклом вызовов инструментов до завершения задачи.
Pydantic-схема (structured output)
Класс на основе BaseModel, описывающий, какие поля и в каком формате модель должна вернуть (например, единственное поле description); используется через llm.with_structured_output, чтобы даже небольшая локальная модель давала чистый структурированный результат.
State (память агента)
Класс, хранящий состояние графа LangGraph, в первую очередь список сообщений messages, который благодаря функции add_messages пополняется, а не перезаписывается; туда же можно добавлять произвольные данные, доступные агенту.
ToolNode и tools_condition
Готовые функции LangGraph: ToolNode объединяет и вызывает все инструменты агента под капотом, а условное ребро tools_condition решает, нужно ли ещё раз обращаться к инструментам или пора двигаться к узлу-форматеру.
Lifespan-функция
Асинхронный обработчик FastAPI (через asynccontextmanager), который выполняется при старте приложения, в проекте на этом этапе компилируется граф агента и кладётся в общую структуру, доступную всем запросам без повторной сборки.
Swagger UI
Встроенная в FastAPI интерактивная документация API, через которую можно вручную протестировать эндпоинт generate_product_description кнопкой Try out, не выходя в 1С или Postman.
Wordstat
API Яндекса для получения частотности поисковых запросов; в расширенной версии агента отдельный узел графа отправляет туда ключевые слова товара и получает частотные ключи для SEO-оптимизации описания.
Fine-tuning (файн-тюнинг)
Настоящее дообучение модели на собственном датасете, дорогая и редкая практика, оправданная, например, для голосовых агентов, где критична скорость; в отличие от работы с контекстом (RAG), которая используется в этом проекте.

Частые вопросы