Головна / Статті / Перший інструмент для агента підтримки на 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 і короткостроковий стан розмови.