Главная / Статьи / Первый инструмент для агента поддержки на LangGraph: поиск по документации по ключевым словам

Первый инструмент для агента поддержки на LangGraph: поиск по документации по ключевым словам

Как агент поддержки на LangGraph получает один инструмент поиска по Markdown-документации: почему он обходится без RAG, как разделены инструмент и реализация и как состояние доносит найденное до ответа.

975 слов

Граф агента поддержки LEPA уже умеет ветвиться в зависимости от того, насколько понятен вопрос:

vague question  → ask for clarification
clear question  → continue

Но «продолжить» ничего не значит, если агенту не с чем продолжать. Следующее дополнение даёт ему конкретное дело: искать в документации поддержки школы и строить каждый ответ на том, что нашлось.

Зачем вообще инструменты

Языковая модель может уверенно рассуждать о чём угодно, а агент поддержки LEPA не имеет права выдумывать школьные процедуры. Ответ — инструменты. Инструмент — это действие, которое агент или отдельный узел графа может выполнить за пределами замороженных весов модели. Представьте администратора, который умеет только говорить, и того, кто может открыть справочник для сотрудников; пользу приносит второй. В ИИ-процессе этот шаблон выглядит так:

User question
     ↓
Decide what is needed
     ↓
Use a tool (search docs, call an API, …)
     ↓
Read the tool result
     ↓
Answer with evidence

Тот же шаблон встречался в прошлых проектах. В TAM инструментами были обычные функции Python, а DevAssist открывал их клиентам через MCP. В этом проекте инструмент живёт внутри приложения на LangGraph и пишет в состояние. Идея та же, обвязка другая.

Единственный инструмент

Он ровно один:

search_knowledge(query, category?)

Он ищет по Markdown-файлам в папке knowledge/:

knowledge/
  authentication.md
  students.md
  attendance.md
  grades.md
  reports.md

В каждом файле реалистичные материалы поддержки LEPA: проблемы со входом, зачисление, листы посещаемости, публикация оценок и отчёты.

Чем он намеренно не является

Ни векторной базы данных, ни эмбеддингов, ни полноценной архитектуры RAG. Поиск специально оставлен простым:

  • Документы разбиваются по заголовкам ##.
  • Фрагменты оцениваются по совпадению ключевых слов.
  • Возвращается до 4 фрагментов, обрезанных по длине.
  • Если классификация уже знает тему, предпочтение отдаётся соответствующему файлу {category}.md.

Причина в том, что проект посвящён LangGraph, а не исследованиям поиска. README говорит об этом прямо: урок — это связка инструмента и графа, а качество поиска здесь не главное.

Два слоя в коде

Логика существует в двух формах. search_knowledge_impl(...) — обычный Python, который возвращает список фрагментов, а @tool search_knowledge(...) оборачивает ту же логику для LangChain. Узел знаний в графе обходит обёртку и вызывает реализацию напрямую:

def run_knowledge(state: SupportState) -> dict:
    query = last_user_text(state)
    category = state.get("issue_category") or "unknown"
    snippets = search_knowledge_impl(query, category=category)
    return {"retrieved_documents": snippets}

У этого выбора есть цель. Поиск остаётся на виду, тесты работают офлайн, а обёртка @tool остаётся доступной для будущих экспериментов с вызовом инструментов через LLM. Так граф использует возможность в форме инструмента, хотя вызов выбирает не LLM.

Как меняется поток

До появления инструментов ветка «продолжить» была заглушкой. С инструментом она выглядит так:

classify → continue
              ↓
          knowledge   (search_knowledge_impl → retrieved_documents)
              ↓
          support     (turn snippets into final_answer)
              ↓
             END

Пример из LEPA

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

User: "Why aren't grades showing?"
        ↓
classify → grades
        ↓
knowledge searches grades.md
        ↓
snippets mention Draft vs Publish
        ↓
support answers using those snippets

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

Поддержка зависит от того, что вернул инструмент

Узел поддержки не заводит вторую базу знаний. Он читает retrieved_documents из состояния. Если документов нет, он просит пользователя назвать раздел LEPA. Если есть, собирает ответ из них. Если настроен ключ API, он может ещё попросить LLM переписать те же фрагменты аккуратнее, а при сбое LLM всё равно откатывается к шаблону.

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

Урок, перенесённый из работы с MCP

Прошлый проект на MCP научил, что инструмент нужно проектировать под модель, которая будет читать его вывод, и это верно даже для поиска по ключевым словам. Фрагменты из search_knowledge несут метку вроде [grades.md], а за ней обрезанный текст раздела, и следующий шаг или будущая LLM видят, откуда взяты сведения. Размер фрагментов тоже ограничен. Завалить следующий шаг целым справочником значило бы снова получить проблему избытка контекста, с которой столкнулись при проектировании search_code в DevAssist. Проект другой, но давление на дизайн то же.

Чему научил один инструмент

  1. Граф без инструментов умеет маршрутизировать, но не может опереть ответы на факты.
  2. Одного скучного инструмента достаточно, чтобы изменить продукт.
  3. Вызывать инструмент из узла — нормальный способ учиться, прежде чем переходить к полноценному вызову инструментов через LLM.
  4. Для учебника по LangGraph «пока без RAG» — это достоинство, а не провал.
  5. Состояние работает как шина: вывод инструмента становится retrieved_documents, а поддержка его читает.

Коротко

С инструментом агент LEPA перестаёт быть блок-схемой и может доставать настоящие школьные процедуры. Конкретно это search_knowledge и search_knowledge_impl — простой поиск по Markdown, который вызывается из узла knowledge и передаёт результаты узлу support через состояние. Вопрос посложнее впереди: помнит ли агент разговор при следующем сообщении? Тут в игру вступают чекпойнты, thread_id и краткосрочное состояние разговора.