本文以英文发布。
LangChain Skills: Load Agent Expertise on Demand, Not in One Prompt
Stop stuffing every rule into one system prompt. See how LangChain skills use a load_skill tool, state and middleware to inject instructions only when needed.
Sooner or later every agent builder hits the same snag. The assistant has to be good at lots of things, such as writing SQL, composing emails and debugging code, with more to come. Where do all those instructions live? The tempting answer is to pack them into the system prompt, and that answer starts hurting as the agent grows. Below you'll see what goes wrong first, then how the skills pattern in LangChain fixes it, using a small runnable example.
The trouble with one giant prompt
Say you want a single assistant that writes SQL, drafts emails and helps debug. The naive way is one long system prompt:
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
...
Three domains are manageable. Add legal review, data analysis, support scripts and code review standards, and concrete problems appear.
Prompt bloat. Each request is charged tokens for every domain's rules, including the ones it never uses, even if the user only wanted SQL. You get slower and costlier calls with no upside.
Blurred instructions. With SQL, email and debugging rules sharing one block of text, the model must wade through all of it each time. Rules from one area leak into another, or the model loses track of which rule applies where.
It doesn't scale. Each new specialty means editing one long, increasingly brittle prompt. Testing gets harder as well, since a tweak aimed at the debugging section can quietly change how emails are written.
Mostly irrelevant. While drafting an email, the model has no use for your SQL injection rules, yet they ride along on every call as dead weight.
In short, one big prompt treats all knowledge as always relevant, when any given request needs only a slice of it.
The fix: bring in expertise on demand
The skills pattern borrows an idea from good software design called progressive disclosure: don't show everything up front, and reveal detail only once it's needed.
Rather than one prompt holding every domain's rules, each domain lives separately as a skill. The agent starts with a light base prompt and a single tool, load_skill. When a request calls for specialized knowledge, the agent calls that tool, which fetches the matching instructions and injects them into the prompt for that turn and that domain only.
Here's how the example builds it.
1. Skills are stored prompts
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:
...
""",
}
It's a plain dictionary from skill name to a block of instructions, and each entry stands alone. The base agent doesn't need that content in advance. It only needs to know the skills exist and roughly what each is for.
2. State remembers the active skill
class SkillState(AgentState):
loaded_skill: str = ""
If you've seen the handoffs pattern, this will look familiar. It's one field of state that begins empty and only gets a value after the agent decides a specialty is needed.
3. A tool that fetches the skill and writes it to state
@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,
},
)
Three details deserve attention:
- The model decides when and which skill to load by reading the tool's docstring. It lists the available skills with a short description of each. That's the only index of specialties the base prompt carries, and it doesn't include the full instructions.
- If the model requests a skill that doesn't exist, the tool doesn't blow up. It replies with a message listing what is available so the model can retry.
- On success, the instruction text is stored in
loaded_skill. That's the moment specialized knowledge enters.
4. Middleware puts the skill into the prompt
@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)
This runs ahead of every model call, as in the handoffs example. When a skill is loaded, it rewrites the system prompt to contain those instructions and nothing else. When none is loaded, the plain base prompt stays as is.
Here's where progressive disclosure is delivered. SQL rules only enter the prompt once sql_expert is loaded, and email rules never appear unless email_writer was requested. The model never carries instructions for a domain it isn't working in.
5. Putting it together
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"
),
)
Look at how short the base system_prompt is. It contains no SQL rule, no email format and no debugging step. It just says that skills exist and how to fetch them. That's the point: the base prompt stays small and stable however many skills you add later.
A run, step by step
response = agent.invoke({
"messages": [{
"role": "user",
"content": "Write a SQL query to find the top 5 customers by total purchase amount."
}],
})
Here's what happens:
- The model receives the base prompt, which holds no domain knowledge.
- It reads the request, recognizes SQL work and invokes
load_skill(skill_name="sql_expert"). - Inside the tool, a
SKILLSlookup for"sql_expert"succeeds, and theCommandit returns writes the whole instruction block intoloaded_skill. - The agent loops back to the model. The middleware now sees that
loaded_skillisn't empty and rewrites the system prompt with the SQL expert rules. - Only now does the model know to use uppercase keywords, avoid injection-prone patterns and comment complex queries.
- It writes the query by those rules and answers the user.
Suppose the user's next message is about an email. The model calls load_skill("email_writer"), and the email rules replace the SQL ones completely. Nothing carries over.
What you gain
Go back to the original complaints.
- No bloat. The base prompt is a few lines, and specialized text only appears when relevant, so most requests don't pay for knowledge they don't use.
- No blur. Each skill is isolated, so SQL formatting rules can't quietly shape an email, since the two are never in the prompt together.
- It scales. A new
code_reviewerskill takes one new entry inSKILLSand one line in the tool's docstring. The base agent, the middleware and the existing skills stay as they are. - Relevant knowledge, at the right time. This is the core benefit. The model's attention isn't spread over a dozen unrelated rule sets but focused on the one that matters for the task at hand.
A mental picture
Picture a reference library instead of one textbook glued together from every subject. Nobody gets the entire library with each question. They get the catalog, which here means skill names and short blurbs, and borrow the single book that answers the question. All other volumes remain on the shelf until needed.
That's the whole idea of skills in LangChain: keep the base prompt lean, describe what expertise exists, and let the agent pull in details only when a request calls for them.
The complete script
"""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']})")
Extend it yourself
A good next step is combining skills, for instance keeping debugger available alongside sql_expert for fixing a broken query. You'd need to do two things:
- Decide whether
loaded_skillholds one skill or a list of active ones. - Update
inject_skillto merge several loaded skills into the prompt when more than one is active.
That small change lets you combine specialties instead of only switching between them, and the core pattern stays the same.