惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Hacker News: Ask HN
Hacker News: Ask HN
WordPress大学
WordPress大学
T
The Blog of Author Tim Ferriss
The GitHub Blog
The GitHub Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 聂微东
A
About on SuperTechFans
Stack Overflow Blog
Stack Overflow Blog
雷峰网
雷峰网
Microsoft Azure Blog
Microsoft Azure Blog
腾讯CDC
爱范儿
爱范儿
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 【当耐特】
V
Visual Studio Blog
有赞技术团队
有赞技术团队
U
Unit 42
D
Docker
小众软件
小众软件
F
Full Disclosure
I
Intezer
Scott Helme
Scott Helme
P
Privacy International News Feed
P
Proofpoint News Feed
Engineering at Meta
Engineering at Meta
Google DeepMind News
Google DeepMind News
B
Blog
Martin Fowler
Martin Fowler
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Vercel News
Vercel News
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Spread Privacy
Spread Privacy
宝玉的分享
宝玉的分享
S
Security Affairs
www.infosecurity-magazine.com
www.infosecurity-magazine.com
月光博客
月光博客
C
Cisco Blogs
云风的 BLOG
云风的 BLOG
Schneier on Security
Schneier on Security
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
T
Threat Research - Cisco Blogs
量子位
Hacker News - Newest:
Hacker News - Newest: "LLM"
H
Heimdal Security Blog
N
Netflix TechBlog - Medium
H
Hacker News: Front Page
P
Proofpoint News Feed
G
GRAHAM CLULEY
V
Vulnerabilities – Threatpost
S
Schneier on Security

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Мой личный джуниор. Часть 2. Даём агенту немножечко мозгов
Владимир · 2026-06-20 · via Все публикации подряд на Хабре

Средний

11 мин

79

Привет, Хабр! Меня зовут Владимир и это продолжение статьи про разработку локального кодер-агента на LangGraph с доступом к MCP-инструментам.

В первой части мы создали инфраструктуру, написали простейшего агента и дали ему доступ к MCP-серверам для работы с файлами, кодом Git и документацией. В этой половинке мы добавим агенту немного мозгов. Но для начала немного истории, которая появилась при написании этой статьи.

Немного истории о том, как глючил GigaChat

Я не сторонник нагнетания интриги. По-этому, к моменту публикации первой части, вторая часть была почти готова. Выбирая примеры работы агента для скринов я заметил, что иногда не сохраняется файл, хотя агент пишет, что файл сохранён. Проанализировав трейс я понял, что используемый мной для чата ai-sage/GigaChat3-10B-A1.8B поместил сообщение вызова инструмента в поле content. Так и не поняв, проблема это модели, SGLang, парсера ответа или всего вместе, я принял судьбоносное решение - а скажу-ка я модели, что она ошиблась. Для этого я сделал простую вещь - если в метаданных ответа указано, что завершение произошло по причине вызова инструментов (tool_calls), но при этом в самом сообщении отсутствует список этих вызовов инструментов, то я добавлял в историю сообщение вида “Ты попытался вызвать инструмент, но сгенерировал невалидный JSON в текстовом поле”. Выглядело это как-то так:

if message.response_metadata.get('finish_reason') == 'tool_calls' and not message.tool_calls:
    return {'messages': [HumanMessage(load_prompt_from_langfuse('mcp_agent_error_tool_call_prompt'))]}

С этого и начались мои проблемы. GigaChat3-10B на это сообщение реагировал вообще неадекватно. Обычно вызову инструмента write_file предшествовал вызов генератора кода. Так вот - на моё сообщение узел-агент начинал бесконечно вызывать инструмент генерации кода с одним и тем-же запросом. Продолжал он до тех пор, пока не кончался контекст. Для решения этой проблемы я ввёл счётчик в узел-агент, который прерывал бесконечный цикл. Проблему появления зацикливания это не решило, но хоть ждать теперь было не надо. Следующим шагом стало изменение архитектуры. Тут модель вообще перестала вести себя адекватно, поэтому пришлось переехать на другую, отказавшись от двух моделей. Так как 32 ГБ памяти - это не так много для LLM, что-то стильное, модное, молодёжное и квантованное. Изначально это была cyankiwi/Qwen3.6-35B-A3B-AWQ-4bit. Но в SGLang она мне не генерировала ответы - либо пустое поле, либо просто строчки из восклицательных знаков. Далее была модель nvidia/Qwen3.6-35B-A3B-NVFP4 и переезд на vLLM.

А теперь вернёмся к разработке агента.

Архитектура

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

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

Схема автономного агента

Схема автономного агента

Наверное такая архитектура подойдёт для большой модели. С моей же (GigaChat на то время) началась магия - простейшее задание “Напиши функцию…” модель разбивала на 4-5 шагов, сама же потом путалась в плане. Иногда генерила код, а следующим шагом писала в файл заново нагенерированный код. Плюс вскрывшиеся вышеописанные проблемы с зацикливанием. В итоге было решено перейти на “полуавтономный” вариант - результат каждого шага требует вмешательства пользователя. Выглядеть это стало как-то так:

Human-in-the-Loop агент

Human-in-the-Loop агент

На основании этой архитектуры и будем строить агент.

Реализация агента

Для реализации нашей архитектуры нам понадобится некоторое количество новых узлов и рёбер:

  • узел планировщик

  • узлы оценщики

  • узел инъекции шага плана

  • узел сжатия контекста

  • узел финализации

Ну и Стейт также надо доработать. С него и начнём

AgentState

class AgentState(MessagesState):
    user_request: str
    user_input: str
    plan: list[dict]
    current_step: int
    step_iteration: int
    history: list[str]
    phase: Literal['planning', 'executing', 'done']
    is_approved: bool
    trace_id: str

В стейт мы добавили:

  • Запрос пользователя - чтобы всегда был под рукой

  • Поле для ввода пользователя - поле для промежуточного общения

  • Место для плана от планировщика

  • Индекс текущего шага плана

  • Счетчик неудачных попыток для защиты от зацикливания

  • История выполнения (сжатая)

  • Фаза работы агента - планирует, выполняет, закончил

  • Подтверждение, что шаг принят пользователем.

  • Идентификатор сессии (для организации памяти)

Для счетчика попыток в настройки агента также надо добавить поле - порог попыток

class ServiceConfig(BaseModel):
    # старый код
    max_step_iterations: int = 30

Перейдём к узлам

Узел Планировщик

Узел старого агента был простой функцией. Раз мы модернизируем архитектуру агента, то и архитектуру узлов углубим и расширим - сделаем узлы классами:

from langchain.chat_models import BaseChatModel
from langchain_core.output_parsers import PydanticOutputParser

class PlanerNode:
    def __init__(
            self,
            llm: BaseChatModel,
            prompt_name: str = 'mcp_agent_planer_prompt',
            prompt_label: str = 'production'
    ):
        self.parser = PydanticOutputParser(pydantic_object=Workflow)
        self.llm = llm
        self.prompt = load_prompt_from_langfuse(
            prompt_name=prompt_name, prompt_label=prompt_label
        ).format(format_instructions=self.parser.get_format_instructions())

На выходе узла мы хотим получить список пунктов. Для достижения результата ма используем PydanticOutputParser - данный класс позволяет на основе Pydantic модели сформировать дополнение к промпту, которое подскажет модели, что мы хотим на выходе. А метод PydanticOutputParser.parse вернёт нам заданный Pydantic-объект. В итоге вместо ответа вида “Хорошо, вот что я могу предложить в качестве плана на твой запрос…” мы получим просто список пунктов. Для этого опишем простую модель:

from typing import Annotated
from pydantic import BaseModel, Field

class Workflow(BaseModel):
    plan: Annotated[list[str], Field(description='Список шагов для выполнения задачи')]

Для загрузки промптов из LangFuse используется следующая функция:

def load_prompt_from_langfuse(prompt_name: str, prompt_label: str = 'production') -> str:
    try:
        return (
            settings.langfuse.client
            .get_prompt(prompt_name, label=prompt_label)
            .get_langchain_prompt())

    except Exception as e:
        logger.error(f'Не удалось загрузить промпт "{prompt_name}" из Langfuse: {e}')
        raise RuntimeError(f'Не удалось загрузить промпт "{prompt_name}" из Langfuse')

Промпт получаем по имени и тегу (на случай проверки разных вариантов) и следующим шагом преобразуем его в строку, совместимую с Langchain.PromptTemplate.

Дальше код самого узла:

class PlanerNode:
    # предыдущий код
    async def node(self, state: AgentState) -> dict:
        user_input = state.get('user_input', '')
        messages = [SystemMessage(content=self.prompt)] + state['messages']
        if user_input:
            messages += [HumanMessage(content=user_input)]
        response = await self.llm.ainvoke(messages)

        plan = self.parser.parse(response.content).plan
        message = 'Проверьте план действий. Подтвердите план словом **"Продолжить"** либо внесите корректировки'
        for i, item in enumerate(plan, start=1):
            message += f'\n* Шаг {i}: {item}'

        return {
            'messages': [
                HumanMessage(content=user_input), AIMessage(content=message)
            ] if user_input else [AIMessage(content=message)],
            'plan': plan, 'current_step': 0, 'phase': 'planning', 'is_approved': False,
        }

Основа - в промпте. В нём надо добиться, чтобы LLM разбила запрос пользователя на отдельные операции (типа написать код, сохранить файл, добавить докстринг и т.п.) и выдать их в виде списка. Конкретные промпты приводить тут не буду, но к репозиторию приложу свой дамп с LangFuse.

Дальше, с помощью PydanticOutputParser, парсим ответ модели и формируем ответ для пользователя - просто перечисляем шаги сформированного плана.

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

На выходе из узла возвращаем план, обнуляем счётчик шагов, устанавливаем фазу работы агента, сбрасываем флаг подтверждения.

Инъектор

Данный узел - способ сообщить текущий шаг агенту. Одно время в узле чистил поле state['messages'] и заполнял суммаризацией. Но на модели Qwen3.6 от Nvidia суммаризация только всё портила, поэтому оставил только добавление шага плана:

class StepInjectorNode:
    def node(self, state: AgentState) -> dict:
        step_text = state['plan'][state['current_step']]
        return {'messages': HumanMessage(f'ТЕКУЩИЙ ШАГ, КОТОРЫЙ НУЖНО ВЫПОЛНИТЬ: {step_text}'),}

Узел-агент

Конструктор узла стандартный (для моих узлов):

class AgentNode:
    def __init__(
            self,
            llm: BaseChatModel,
            prompt_name: str = 'mcp_agent_prompt',
            prompt_label: str = 'production'):
        self.llm = llm
        self.prompt = load_prompt_from_langfuse(prompt_name=prompt_name, prompt_label=prompt_label)

Дальше сам узел. И начинается он с проверки зацикливания модели:

from langchain_core.messages import AIMessage, RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES

class AgentNode:
    # предыдущий код
    async def node(self, state: AgentState) -> dict:
        if state.get('step_iteration', 0) > settings.service.max_step_iterations:
            msg = 'КРИТИЧЕСКАЯ ОШИБКА! Агент зациклился и был сброшен в начальное состояние!'
            logger.error(msg)
            return {
                'messages': [RemoveMessage(id=REMOVE_ALL_MESSAGES), AIMessage(msg)],
                'history': [], 'step_iteration': 0, 'current_step': 0,}

В случае превышения порога повторов чистим историю сообщений и оставляем одно единственное сообщение об ошибке.

Очистить историю нельзя просто передав [] - она объявлена как messages: Annotated[list[AnyMessage], add_messages], и пустой список просто добавится к истории. Для очистки надо использовать специальный тип сообщения RemoveMessage. В поле id можно передать идентификатор конкретного сообщения либо специальную константу langgraph.graph.message.REMOVE_ALL_MESSAGES для очистки всей истории.

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

class AgentNode:
    # предыдущий код
    async def node(self, state: AgentState) -> dict:
        # предыдущий код
        messages = [SystemMessage(self.prompt)] + state['messages']
        if state['user_input']:
            messages += [HumanMessage(state['user_input'])]
        response = await self.llm.ainvoke(messages)

        return {
            'messages': [HumanMessage(state['user_request']), response] if state['user_input'] else [response],
            'step_iteration': state.get('step_iteration', 0) + 1, 'phase': 'executing', 'is_approved': False, }

Ответ никак не форматирую - квену оказалось достаточно фразы в промпте “После выполнения текущего шага сообщи, что шаг выполнен, и укажи результат”. В случае чего - можно попросить уточнить ответ и он это сделает. Возвращает узел, кроме сообщений, инкремент шага, фазу “Исполнение” и сброшенный флаг подтверждения.

Узлы Оценщики

Тут я решил не перегружать агента, поэтому данные узлы работают по простому принципу - ожидают слово продолжить. А два их только из-за того, что маршрутизации графа проще было сделать

class BaseSolver:
    @staticmethod
    async def node(state: AgentState) -> dict:
        if 'продолжить' in state['user_input'].lower():
            return {'is_approved': True, 'user_input': ''}
        return {'is_approved': False}

class PlanSolverNode(BaseSolver):
    pass
class AgentSolverNode(BaseSolver):
    pass

Узел Суммаризатор

Данный узел изначально использовался для уменьшения длины контекста. Но, как говорится, “Что русскому хорошо - немцу смерть”. В нашем случае с Qwen, читая суммаризованные сообщения модель начала давать ответ в таком-же формате. По-этому узел есть, но толку от него в данный момент нет - используется только при подготовке финального ответа.

Много букв с кодом не очень нужного узла

Основная идея узла была следующая - теоретически, узел-агент может совершить несколько циклов вызовов инструментов и за один цикл узел-агент может вызвать несколько инструментов. Каждый вызов инструмента маркируется собственным id. Таким образом мы можем сгруппировать ToolMessage для отчета.

Код узла начнем со своеобразной защиты - узлу предстоит работать с сообщениями AIMessage и ToolMessage, поэтому надо предусмотреть универсальный метод извлечения контента из них:

class ContextCompressorNode:
    @staticmethod
    def _extract_text(content) -> str:
        if isinstance(content, str):
            return content.strip()
        if isinstance(content, list):
            parts = []
            for block in content:
                if isinstance(block, dict) and block.get('type') == 'text':
                    parts.append(block.get('text', ''))
                elif isinstance(block, str):
                    parts.append(block)
            return '\n'.join(p for p in parts if p).strip()
        return str(content).strip()

Дальше метод суммаризации. В текущей реализации он просто формирует отчет в одно сообщение:

class ContextCompressorNode:
    def _build_steps_summary(self, messages: list) -> str:
        tool_index = {
            m.tool_call_id: m
            for m in messages
            if getattr(m, 'type', None) == 'tool' and getattr(m, 'tool_call_id', None)
        }

        lines = []
        for msg in messages:
            if getattr(msg, 'type', None) != 'ai':
                continue

            text = self._extract_text(msg.content)
            if text:
                lines.append(f'[ОТВЕТ АГЕНТА]: {text}')
                continue

            tool_calls = getattr(msg, 'tool_calls', None) or []
            for tc in tool_calls:
                tool_msg = tool_index.get(tc.get('id'))
                if tool_msg is not None:
                    result = self._extract_text(tool_msg.content)
                    lines.append(f'[ИНСТРУМЕНТ] "{tc.get("name", "unknown")}". Результат: {result}')
        return '\n\n'.join(lines) if lines else '(история выполнения пуста)'

Код использует описанный ранее механизм LangGraph: ToolMessage и вызовы инструментов связаны через уникальный tool_call_id.

Первым делом мы проходим по всем сообщениям и собираем все результаты работы инструментов (ToolMessage) в словарь. В дальнейшем, конкретный вызов инструмента будем искать в этом словаре, вместо того чтобы заново сканировать весь список.

Далее начинаем перебирать сообщения, обращяя внимание только на сообщения агента. Если сообщение агента содержит обычный текст (ответ модели), мы добавляем это в итоговую строку. Если текста у сообщения ИИ нет, значит, в нем содержатся запросы на вызов инструментов (поле tool_calls). Для каждого id из списка вызовов мы получаем результат инструмента и добавляем его в итоговую строку.

В узле формируем из извлеченного богатства AI сообщение:

class ContextCompressorNode:
    def node(self, state: AgentState) -> dict:
        steps_summary = self._build_steps_summary(state['messages'])
        step = state['current_step']
        history = state.get('history', []) + [
            f"[РЕЗУЛЬТАТ ВЫПОЛНЕНИЯ ШАГА {step + 1}] {state['plan'][step]}.\n\n{steps_summary}"]
        return {'history': history, 'current_step': step + 1}

Узел Финализатор

Данный узел подводит итоги проделанной работы. В промпте мы показываем ему план, суммаризованные шаги из истории и просим подвести итог:

class FinalizerNode:
    def __init__(
            self,
            llm: BaseChatModel,
            prompt_name: str = 'mcp_agent_finalize_prompt',
            prompt_label: str = 'production'):
        self.llm = llm
        self.prompt = load_prompt_from_langfuse(prompt_name=prompt_name, prompt_label=prompt_label)

    async def node(self, state: AgentState) -> dict:
        plan = '\n'.join(f"{i}. {s}" for i, s in enumerate(state.get('plan', []), start=1))
        steps_summary = '\n\n'.join(state.get('history', []))

        msg = (
            f'План: \n{plan}\n\n'
            f'Результаты выполнения:\n{steps_summary}\n\n'
            f'Сформируй итоговый ответ для пользователя.')

        response = await self.llm.ainvoke([SystemMessage(content=self.prompt), HumanMessage(content=msg),])

        message = f'Выполнение задачи завершено:\n\n{response.content}'
        return {'messages': [message], 'phase': 'done', 'is_approved': False}

С узлами закончили. Но перед тем как перейти к сборке графа, рассмотрим две сущности LangGraph, которые позволят реализовать общение с пользователем: чекпоинтер и прерывания.

Чекпоинтер

Чекпоинтер нужен для реализации у графа кратковременной памяти, позволяя приостанавливать, возобновлять и воспроизводить состояние графа из любой точки. Для работы чекпоинтера нужно обязательно передавать в граф идентификатор сессии:

config = {'configurable': {'thread_id': 'my-thread'}}
graph.invoke(inputs, config)

Чекпоинтер можно сделать локально, а можно на базе Postgres (нужна библиотека langgraph-checkpoint-postgres). Для прода LangGraph рекомендует именно Postgres вариант, но нам хватит и langgraph.checkpoint.memory.InMemorySaver

Использовать чекпоинтер достаточно просто - создаём экземпляр и подключаем его к графу на этапе компиляции.

checkpointer = InMemorySaver()
graph = workflow.compile(checkpointer=checkpointer)

Прерывания

Прерывания нужны для прерывания работы графа

Могут быть до, внутри и после узла. Первый и третий тип задаются на этапе компиляции графа, а второй реализуется в теле узла с помощью оператора langgraph.types.interrupt.

Для первого и третьего варианта передаём списки имён узлов в компилятор:

graph = workflow.compile(
    checkpointer=checkpointer,
    interrupt_before=['before_node'],
    interrupt_after=['after_node'],)

Для второго типа реализуем что-то подобное:

def ask_user(state: State):
    answer = interrupt('Как вас зовут?')  # Граф останавливается здесь и ждет ответа
    return {'user_answer': answer}  # Когда пользователь ответит, выполнение продолжится

В любом случае, для возобновления работы графа надо передать в граф специальное сообщение типа langgraph.types.Command с параметром update для обновления стейта или resume для возврата значения. Без этого граф начнет своё выполнение заново.

Важное уточнение: не понял, баг это или фича, но если после узла идёт условное ребро, то сначала выполнится оно, а уже потом будет останов. Таким образом можно намертво зациклить свой граф, если ребро решает задачу “Повтор/Продолжить”

Разрыв лонгрида

У нас готовы все компоненты агента:

  • Планировщик - разбивает задачу на шаги

  • Инжектор - сообщает агенту текущий шаг

  • Исполнитель - выполняет шаги

  • Оценщики - проверяют подтверждение пользователя

  • Суммаризатор - сжимает историю выполнения

  • Финализатор - подводит итоги

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

В следующей части мы превратим этот набор узлов в работающий граф.