Головна / Статті / Skills у LangChain: експертиза агента на вимогу, а не в одному промпті

Skills у LangChain: експертиза агента на вимогу, а не в одному промпті

Годі запихати всі правила в один системний промпт. Як skills у LangChain за допомогою інструмента load_skill, стану й middleware підставляють інструкції лише тоді, коли вони потрібні.

2088 слів

Рано чи пізно кожен, хто будує агентів, натрапляє на ту саму проблему. Асистент має добре вміти багато чого: писати SQL, складати листи, налагоджувати код, і це лише початок. Де зберігати всі ці інструкції? Спокуслива відповідь — запакувати їх у системний промпт, і ця відповідь починає боліти, коли агент зростає. Нижче ви побачите, що ламається першим, а потім як патерн skills у LangChain це виправляє, на невеликому прикладі, який можна запустити.

Проблема одного гігантського промпту

Припустімо, вам потрібен один асистент, який пише SQL, складає листи й допомагає з налагодженням. Наївний спосіб — один довгий системний промпт:

You are a helpful assistant.

When writing SQL:
- Write queries using only SELECT, INSERT, UPDATE, DELETE
- Always use parameterized queries to prevent SQL injection
- Format SQL keywords in UPPERCASE
- Include brief comments for complex queries
When writing emails:
- Keep emails concise and professional
- Use clear subject lines
- Structure: greeting -> purpose -> details -> call to action -> sign-off
- Adjust tone based on context
When debugging:
- First reproduce the error
- Check logs and error messages
- Isolate the root cause
- Suggest a fix with explanation
...

Із трьома галузями ще можна впоратися. Додайте юридичну перевірку, аналіз даних, скрипти підтримки й стандарти код-ревʼю, і зʼявляться конкретні проблеми.

Роздування промпту. За кожен запит списуються токени за правила всіх галузей, зокрема ті, яких він ніколи не використовує, навіть якщо користувачеві потрібен був лише SQL. Виклики стають повільнішими й дорожчими без жодної користі.

Розмиті інструкції. Коли правила SQL, листів і налагодження ділять один блок тексту, моделі доводиться щоразу продиратися крізь усе одразу. Правила однієї галузі просочуються в іншу, або модель губить нитку, яке правило де застосовувати.

Це не масштабується. Кожна нова спеціалізація означає правку одного довгого й дедалі крихкішого промпту. Тестувати теж складніше: зміна, націлена на розділ налагодження, може непомітно змінити те, як пишуться листи.

Майже все нерелевантне. Коли модель пише лист, ваші правила проти SQL-інʼєкцій їй ні до чого, але вони їдуть із кожним викликом мертвим вантажем.

Коротше, один великий промпт вважає всі знання завжди релевантними, тоді як будь-якому конкретному запиту потрібен лише їхній шматочок.

Рішення: підвантажувати експертизу на вимогу

Патерн skills запозичує ідею з доброго дизайну ПЗ під назвою progressive disclosure (поступове розкриття): не показувати все одразу й розкривати деталі лише тоді, коли вони знадобилися.

Замість одного промпту з правилами всіх галузей кожна галузь живе окремо як skill. Агент стартує з легким базовим промптом і одним інструментом, load_skill. Коли запиту потрібні спеціальні знання, агент викликає цей інструмент, який дістає відповідні інструкції й підставляє їх у промпт лише для цього ходу й цієї галузі.

Ось як це влаштовано в прикладі.

1. Skills — це збережені промпти

SKILLS = {
    "sql_expert": """
You are a SQL expert. Follow these rules:
- Write queries using only SELECT, INSERT, UPDATE, DELETE
- Always use parameterized queries to prevent SQL injection
- Format SQL keywords in UPPERCASE
- Include brief comments for complex queries
""",
    "email_writer": """
You are an email writing specialist. Follow these rules:
...
""",
    "debugger": """
You are a debugging specialist. Follow these rules:
...
""",
}

Це звичайний словник від імені skill до блоку інструкцій, і кожен запис самодостатній. Базовому агенту цей вміст заздалегідь не потрібен. Йому треба лише знати, що skills існують і приблизно для чого кожен.

2. Стан памʼятає активний skill

class SkillState(AgentState):
    loaded_skill: str = ""

Якщо ви бачили патерн handoffs, це здасться знайомим. Це одне поле стану, яке спочатку порожнє й отримує значення лише тоді, коли агент вирішує, що потрібна спеціалізація.

3. Інструмент, який дістає skill і записує його в стан

@tool
def load_skill(
    skill_name: str,
    runtime: ToolRuntime[None, SkillState],
) -> Command:
    """Load a specialized skill prompt.
      Available skills:
          - sql_expert: SQL query writing and optimization
          - email_writer: Professional email composition
          - debugger: Troubleshooting and debugging
          """
          skill = SKILLS.get(skill_name)
          if not skill:
              return Command(
                  update={
                      "messages": [
                          ToolMessage(
                              content=f"Skill '{skill_name}' not found. Available: {', '.join(SKILLS.keys())}",
                              tool_call_id=runtime.tool_call_id,
                          )
                      ],
                      "loaded_skill": "",
                  },
              )
          return Command(
              update={
                  "messages": [
                      ToolMessage(
                          content=f"Loaded skill: {skill_name}",
                          tool_call_id=runtime.tool_call_id,
                      )
                  ],
                  "loaded_skill": skill,
              },
          )

Три деталі заслуговують уваги:

  • Модель вирішує, коли й який skill завантажити, читаючи docstring інструмента. У ньому перелічено доступні skills із коротким описом кожного. Це єдиний покажчик спеціалізацій, який несе базовий промпт, і повних інструкцій у ньому немає.
  • Якщо модель запросить неіснуючий skill, інструмент не падає. Він відповідає повідомленням зі списком доступного, щоб модель могла спробувати знову.
  • У разі успіху текст інструкцій зберігається в loaded_skill. Саме в цю мить входять спеціальні знання.

4. Middleware кладе skill у промпт

@wrap_model_call
def inject_skill(
    request: ModelRequest,
    handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
    skill = request.state.get("loaded_skill", "")
    if skill:
        request = request.override(
            system_prompt=(
                f"You have loaded the following skill. Follow these instructions:\n\n{skill}\n\n"
                f"(If you need a different skill, call load_skill again.)"
            ),
        )
    return handler(request)

Це виконується перед кожним викликом моделі, як у прикладі з handoffs. Коли skill завантажено, middleware переписує системний промпт так, щоб у ньому були ці інструкції й нічого більше. Коли нічого не завантажено, простий базовий промпт лишається як є.

Ось де реалізується поступове розкриття. Правила SQL потрапляють у промпт лише тоді, коли завантажено sql_expert, а правила листів не зʼявляються, доки не запитано email_writer. Модель ніколи не несе інструкцій для галузі, у якій зараз не працює.

5. Збираємо все докупи

agent = create_agent(
    model=free_llm,
    tools=[load_skill],
    state_schema=SkillState,
    middleware=[inject_skill],
    system_prompt=(
        "You are a helpful assistant with access to specialized skills.\n"
        "When a user asks something in a specific domain:\n"
        "1. Call load_skill to get specialized instructions\n"
        "2. Follow those instructions to answer\n\n"
        "Available skills: sql_expert, email_writer, debugger"
    ),
)

Подивіться, який короткий базовий system_prompt. У ньому немає ні правил SQL, ні формату листів, ні кроків налагодження. Він лише каже, що skills існують і як їх отримати. У цьому й сенс: базовий промпт лишається маленьким і стабільним, скільки б skills ви згодом не додали.

Прогін крок за кроком

response = agent.invoke({
    "messages": [{
        "role": "user",
        "content": "Write a SQL query to find the top 5 customers by total purchase amount."
    }],
})

Ось що відбувається:

  1. Модель отримує базовий промпт, у якому немає знань про жодну галузь.
  2. Вона читає запит, розпізнає роботу з SQL і викликає load_skill(skill_name="sql_expert").
  3. Усередині інструмента пошук "sql_expert" у SKILLS успішний, і повернений Command записує весь блок інструкцій у loaded_skill.
  4. Агент знову йде до моделі. Тепер middleware бачить, що loaded_skill не порожній, і переписує системний промпт правилами SQL-експерта.
  5. Лише тепер модель знає, що треба писати ключові слова великими літерами, уникати патернів, вразливих до інʼєкцій, і коментувати складні запити.
  6. Вона пише запит за цими правилами й відповідає користувачеві.

Припустімо, наступне повідомлення користувача стосується листа. Модель викликає load_skill("email_writer"), і правила листів повністю замінюють правила SQL. Нічого не переноситься.

Що ви виграєте

Повернімося до початкових претензій.

  • Немає роздування. Базовий промпт — кілька рядків, а спеціальний текст зʼявляється лише тоді, коли він релевантний, тож більшість запитів не платить за знання, якими не користується.
  • Немає розмиття. Кожен skill ізольований, тому правила форматування SQL не можуть непомітно вплинути на лист: у промпті вони ніколи не опиняються разом.
  • Це масштабується. Новий skill code_reviewer потребує одного нового запису в SKILLS і одного рядка в docstring інструмента. Базовий агент, middleware й наявні skills лишаються як є.
  • Релевантні знання в потрібну мить. Це головна перевага. Увага моделі не розмазана по десятку непоʼязаних наборів правил, а зосереджена на тому, що важливий для поточного завдання.

Уявна картинка

Уявіть довідкову бібліотеку замість одного підручника, склеєного з усіх предметів. Ніхто не отримує всю бібліотеку з кожним питанням. Людина отримує каталог, тобто тут імена skills і короткі описи, і бере одну книжку, яка відповідає на питання. Усі інші томи лишаються на полиці, доки не знадобляться.

У цьому вся ідея skills у LangChain: тримати базовий промпт компактним, описувати, яка експертиза існує, і дозволяти агенту підтягувати деталі лише тоді, коли запит цього потребує.

Повний скрипт

"""Skills Demo
===========
Skills are specialized prompts loaded on-demand by the agent (progressive disclosure).
Instead of bloating the main prompt with every possible specialization,
the agent uses a load_skill tool to fetch the right instructions when needed.

Flow:
  Agent gets a request -> calls load_skill("domain")
                      -> skill instructions are saved into state
                      -> middleware injects them into the system prompt
                      -> agent now has specialized knowledge to answer
"""

import os
from dotenv import load_dotenv

from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
from langchain.messages import ToolMessage
from langchain.tools import tool, ToolRuntime
from langchain_openai import ChatOpenAI
from langgraph.types import Command
from typing import Callable

load_dotenv()


class SkillState(AgentState):
    loaded_skill: str = ""


SKILLS = {
    "sql_expert": """
You are a SQL expert. Follow these rules:
- Write queries using only SELECT, INSERT, UPDATE, DELETE
- Always use parameterized queries to prevent SQL injection
- Format SQL keywords in UPPERCASE
- Include brief comments for complex queries
""",
    "email_writer": """
You are an email writing specialist. Follow these rules:
- Keep emails concise and professional
- Use clear subject lines
- Structure: greeting -> purpose -> details -> call to action -> sign-off
- Adjust tone based on context: formal for clients, casual for team
""",
    "debugger": """
You are a debugging specialist. Follow these rules:
- First reproduce the error
- Check logs and error messages
- Isolate the root cause
- Suggest a fix with explanation
""",
}


@tool
def load_skill(
    skill_name: str,
    runtime: ToolRuntime[None, SkillState],
) -> Command:
    """Load a specialized skill prompt.

    Available skills:
    - sql_expert: SQL query writing and optimization
    - email_writer: Professional email composition
    - debugger: Troubleshooting and debugging
    """
    skill = SKILLS.get(skill_name)
    if not skill:
        return Command(
            update={
                "messages": [
                    ToolMessage(
                        content=f"Skill '{skill_name}' not found. Available: {', '.join(SKILLS.keys())}",
                        tool_call_id=runtime.tool_call_id,
                    )
                ],
                "loaded_skill": "",
            },
        )
    return Command(
        update={
            "messages": [
                ToolMessage(
                    content=f"Loaded skill: {skill_name}",
                    tool_call_id=runtime.tool_call_id,
                )
            ],
            "loaded_skill": skill,
        },
    )


@wrap_model_call
def inject_skill(
    request: ModelRequest,
    handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
    skill = request.state.get("loaded_skill", "")
    if skill:
        request = request.override(
            system_prompt=(
                f"You have loaded the following skill. Follow these instructions:\n\n{skill}\n\n"
                f"(If you need a different skill, call load_skill again.)"
            ),
        )
    return handler(request)


free_llm = ChatOpenAI(
    model="nvidia/nemotron-nano-9b-v2:free",
    base_url="https://openrouter.ai/api/v1",
    api_key=os.environ.get("OPENROUTER_API_KEY"),
)

agent = create_agent(
    model=free_llm,
    tools=[load_skill],
    state_schema=SkillState,
    middleware=[inject_skill],
    system_prompt=(
        "You are a helpful assistant with access to specialized skills.\n"
        "When a user asks something in a specific domain:\n"
        "1. Call load_skill to get specialized instructions\n"
        "2. Follow those instructions to answer\n\n"
        "Available skills: sql_expert, email_writer, debugger"
    ),
)


print("=" * 60)
print("Skills Demo - Progressive Disclosure")
print("=" * 60)

response = agent.invoke({
    "messages": [{
        "role": "user",
        "content": "Write a SQL query to find the top 5 customers by total purchase amount."
    }],
})

print("\nFull conversation:")
for msg in response["messages"]:
    role = msg.__class__.__name__.replace("Message", "")
    if hasattr(msg, "content") and msg.content:
        print(f"  [{role}] {msg.content[:300]}")
    elif hasattr(msg, "tool_calls") and msg.tool_calls:
        for tc in msg.tool_calls:
            print(f"  [{role}] Tool: {tc['name']}({tc['args']})")

Розширте самі

Добрий наступний крок — комбінувати skills, наприклад тримати debugger доступним разом із sql_expert, щоб лагодити зламаний запит. Для цього треба зробити дві речі:

  • Вирішити, чи зберігає loaded_skill один skill чи список активних.
  • Оновити inject_skill, щоб він обʼєднував у промпті кілька завантажених skills, коли активний більш ніж один.

Ця невелика зміна дає змогу поєднувати спеціалізації, а не лише перемикатися між ними, а основний патерн лишається тим самим.