Skills в LangChain: экспертиза агента по требованию, а не в одном промпте
Хватит запихивать все правила в один системный промпт. Как skills в LangChain с помощью инструмента load_skill, состояния и middleware подставляют инструкции только тогда, когда они нужны.
Рано или поздно каждый, кто строит агентов, упирается в одну и ту же проблему. Ассистент должен хорошо уметь многое: писать 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."
}],
})
Вот что происходит:
- Модель получает базовый промпт, в котором нет знаний ни об одной области.
- Она читает запрос, распознаёт работу с SQL и вызывает
load_skill(skill_name="sql_expert"). - Внутри инструмента поиск
"sql_expert"вSKILLSуспешен, и возвращённыйCommandзаписывает весь блок инструкций вloaded_skill. - Агент снова идёт к модели. Теперь middleware видит, что
loaded_skillне пуст, и переписывает системный промпт правилами SQL-эксперта. - Только теперь модель знает, что нужно писать ключевые слова заглавными, избегать паттернов, уязвимых к инъекциям, и комментировать сложные запросы.
- Она пишет запрос по этим правилам и отвечает пользователю.
Допустим, следующее сообщение пользователя касается письма. Модель вызывает 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, когда активен больше чем один.
Это небольшое изменение позволяет сочетать специализации, а не только переключаться между ними, а основной паттерн остаётся прежним.