In the last module, you built conversations by hand — constructing SystemMessage and HumanMessage objects, one at a time, typing the actual words directly into your code. That was the right way to learn messages. It is not, however, how a real application should keep working, and this module is about exactly why.
The problem, in plain Python first
Imagine you’re building the tutoring feature from your earlier courses — something that explains any topic, at any difficulty level, for any learner. Your first instinct, using nothing but plain Python, might look like this:
topic = "RAG"
level = "beginner"
prompt_text = f"Explain {topic} to a {level} learner in two sentences."
print(prompt_text)
This works. But look at what happens the moment this idea needs to scale to a real feature, used in many places across your app:
# in one file...
prompt_text = f"Explain {topic} to a {level} learner in two sentences."
# in another file, written by a teammate, slightly differently...
prompt_text = f"Please explain the topic '{topic}' for someone at {level} level."
# in a third file, a typo slips in and nobody notices for weeks...
prompt_text = f"Explain {tpoic} to a {level} learner."
Three different files, three subtly different versions of what should be the same prompt. No shared place to fix wording once and have it apply everywhere. No protection against a simple typo silently breaking things. This is a genuinely real problem in real codebases — prompts, unlike ordinary strings, directly shape how well your entire application performs, and yet they’re often the least disciplined part of the code.
Prompt templates exist to fix exactly this. Let’s build them properly, one example at a time.
Example 1: a single-variable template
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_template("Explain {topic} in two sentences.")
formatted = prompt.invoke({"topic": "RAG"})
print(formatted)
Look closely at what {topic} is doing here. It’s a placeholder — a named gap in the template that gets filled in later, when you actually use it, rather than when you first write it. Notice we called .invoke() on the prompt itself, passing a dictionary. That’s not a coincidence: a ChatPromptTemplate, like the chat model you already know, is a LangChain component with the same .invoke() interface. You’ll see exactly why that consistency matters in a few examples, once we start connecting prompts and models directly together.
Example 2: multiple variables
Real prompts almost always need more than one piece of filled-in information.
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_template(
"Explain {topic} to a {level} learner in {sentence_count} sentences."
)
formatted = prompt.invoke({
"topic": "RAG",
"level": "beginner",
"sentence_count": 2,
})
print(formatted)
Every placeholder in the template string needs a matching key in the dictionary you pass to .invoke(). Forget one, and the template will raise a clear error, telling you exactly which variable is missing — a small but genuinely useful safety net the plain f-string version from the very start of this module never gave you.
Example 3: a proper system + human prompt template
Real applications almost always need both a system instruction and a human question, not just one plain string. Prompt templates handle this directly.
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "You are a patient, encouraging tutor who explains things simply."),
("human", "Explain {topic} to a {level} learner."),
])
formatted = prompt.invoke({"topic": "RAG", "level": "beginner"})
print(formatted)
Notice the shape here: .from_messages() takes a list of (role, text) pairs, where role is one of the same roles you learned properly in the last module — "system", "human", and so on. Placeholders work exactly the same way inside either part; you’re not limited to filling in variables only in the human message.
Example 4: a genuinely reusable tutoring prompt
Let’s make Example 3 into something you’d actually keep and reuse across a real application, rather than rebuild each time.
from langchain_core.prompts import ChatPromptTemplate
tutoring_prompt = ChatPromptTemplate.from_messages([
("system", "You are a patient tutor. Always explain with a simple, real-world analogy."),
("human", "Explain {topic} to a {level} learner in {sentence_count} sentences."),
])
# reused three separate times, with three genuinely different fillings
for topic, level in [("RAG", "beginner"), ("RAG", "expert"), ("embeddings", "beginner")]:
formatted = tutoring_prompt.invoke({"topic": topic, "level": level, "sentence_count": 2})
print(formatted, "\n---")
This is the actual point of a prompt template: you write the careful, tuned wording exactly once, and reuse that single, tested definition everywhere your application needs it — instead of three subtly different copies scattered across three files, the exact problem we opened this module with.
Example 5: a classification prompt
Prompt templates aren’t only for open-ended tutoring — they’re just as natural for tightly scoped, structured tasks like classification.
from langchain_core.prompts import ChatPromptTemplate
classification_prompt = ChatPromptTemplate.from_messages([
("system", "Classify the sentiment of the given text as exactly one word: positive, negative, or neutral."),
("human", "{text}"),
])
formatted = classification_prompt.invoke({"text": "This laptop completely changed how I work — I love it."})
print(formatted)
Notice the system message here is doing real, deliberate work — constraining the model to reply with exactly one of three specific words, rather than a free-form sentence. This kind of tightly worded, constraint-focused system instruction is a pattern you’ll use constantly for classification-style tasks.
Example 6: an extraction prompt
Extraction — pulling specific pieces of information out of unstructured text — follows a similar shape, with the system message defining precisely what should come out.
from langchain_core.prompts import ChatPromptTemplate
extraction_prompt = ChatPromptTemplate.from_messages([
("system", "Extract the person's name and the amount of money mentioned, if any, from the text."),
("human", "{text}"),
])
formatted = extraction_prompt.invoke({
"text": "Rahul mentioned he'd spent about $45 on the new keyboard."
})
print(formatted)
Right now, this just formats a prompt — the actual model hasn’t run yet, and the reply would still come back as plain, unstructured text. That’s a real limitation worth noticing honestly: a well-written extraction prompt improves your odds of a clean answer, but it doesn’t guarantee one. You’ll fix that gap properly and completely in the upcoming Structured Output module, using .with_structured_output() from Module 4 together with a prompt like this one.
Example 7: connecting a prompt directly to a model
So far, every example has stopped at .invoke() on the prompt itself — you’ve only ever seen the formatted messages, never an actual model reply. Let’s close that gap.
OpenAI:
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "You are a patient tutor. Always explain with a simple, real-world analogy."),
("human", "Explain {topic} to a {level} learner in {sentence_count} sentences."),
])
model = init_chat_model("openai:gpt-4o-mini")
formatted = prompt.invoke({"topic": "RAG", "level": "beginner", "sentence_count": 2})
response = model.invoke(formatted)
print(response.content)
Gemini:
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "You are a patient tutor. Always explain with a simple, real-world analogy."),
("human", "Explain {topic} to a {level} learner in {sentence_count} sentences."),
])
model = init_chat_model("google_genai:gemini-2.0-flash")
formatted = prompt.invoke({"topic": "RAG", "level": "beginner", "sentence_count": 2})
response = model.invoke(formatted)
print(response.content)
Look at the shape of what just happened: prompt.invoke(...) produced a formatted set of messages, and that result was handed directly into model.invoke(...) as its input. Two separate components, chained together by simply handing the output of one straight into the other. That specific handoff — one component’s output becoming the next component’s input — is exactly the idea the very next module, on Runnables and LCEL, is going to give a proper name and a much cleaner syntax for. Keep this two-step version in mind; you’re about to see it collapse into something genuinely elegant.
Example 8: prompt, model, and structured output, together
Let’s bring back the extraction prompt from Example 6, and this time, actually close the loop with the structured output you first previewed in Module 4.
from pydantic import BaseModel
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
class ExtractedInfo(BaseModel):
name: str
amount_mentioned: float | None
extraction_prompt = ChatPromptTemplate.from_messages([
("system", "Extract the person's name and the amount of money mentioned, if any, from the text."),
("human", "{text}"),
])
model = init_chat_model("openai:gpt-4o-mini")
structured_model = model.with_structured_output(ExtractedInfo)
formatted = extraction_prompt.invoke({
"text": "Rahul mentioned he'd spent about $45 on the new keyboard."
})
result = structured_model.invoke(formatted)
print(result)
Run this, and instead of a plain sentence, you get back a genuine, typed ExtractedInfo(name='Rahul', amount_mentioned=45.0) — real data your application can use immediately, built by combining three separate pieces you now understand individually: a reusable prompt template, a chat model, and a structured output wrapper.
Example 9: handling a growing conversation inside a template, with MessagesPlaceholder
Every template so far has had a fixed shape — one system message, one human message, done. But recall the growing conversation you built by hand back in Module 6’s ask() function. What if you want that — a whole, variable-length conversation history — to slot into a reusable template, rather than being built entirely by hand each time?
This is exactly what MessagesPlaceholder is for.
from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage, AIMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
prompt = ChatPromptTemplate.from_messages([
("system", "You are a concise, friendly assistant."),
MessagesPlaceholder("conversation_history"),
("human", "{new_question}"),
])
history = [
HumanMessage(content="What's the capital of Italy?"),
AIMessage(content="The capital of Italy is Rome."),
]
formatted = prompt.invoke({
"conversation_history": history,
"new_question": "What's a famous dish from there?",
})
model = init_chat_model("openai:gpt-4o-mini")
response = model.invoke(formatted)
print(response.content)
Notice MessagesPlaceholder("conversation_history") isn’t filled in with a single string, the way {topic} was — it’s filled in with an entire list of message objects, inserted into the template at exactly that position. This is the real, reusable version of the conversation-building pattern you did by hand in Module 6: instead of writing [SystemMessage(...), HumanMessage(...), AIMessage(...), HumanMessage(...)] from scratch every time, your template now has one fixed slot — conversation_history — where any real, growing conversation can be dropped in. This is exactly the technique that later, full agent and memory modules build on to give an agent access to everything said so far in a conversation.
Common mistakes worth avoiding
Hardcoding a value directly into the template string instead of making it a variable. It’s tempting, once a prompt is “working,” to leave a value like a difficulty level typed directly into the template rather than turning it into a proper {level} placeholder. This quietly defeats the entire point of this module — the moment you need that value to vary, you’re back to copy-pasting slightly different versions of the same template, the exact problem this module opened with.
Forgetting a required placeholder when calling .invoke(). Recall Example 2 — every {placeholder} in your template needs a matching key in the dictionary you pass in. LangChain will raise a clear error naming the missing variable, which is genuinely helpful — but only if you actually read the error, rather than assuming a silent failure happened somewhere else in your code.
Treating a well-written prompt as a substitute for structured output. Recall the honest limitation named in Example 6 — a carefully worded extraction prompt improves your odds of a clean, parseable answer, but doesn’t guarantee one. If your application code needs to reliably parse the result, use .with_structured_output(), as shown in Example 8, rather than trusting a plain-text prompt alone and hoping the formatting stays consistent.
What you should take away from this module
- Prompt templates solve a genuinely real problem: the same prompt, written slightly differently in three different places, with no shared, single source of truth to fix or improve.
{placeholders}get filled in at.invoke()time, with a dictionary — and a missing variable raises a clear error, rather than silently producing a broken prompt.ChatPromptTemplate.from_messages()builds a full system+human (or longer) prompt, using the same roles you learned properly in the Messages module.- A prompt’s
.invoke()output can be handed directly into a model’s.invoke()— one component’s output becoming another’s input, a pattern that’s about to get a proper name in the next module. - Prompt templates, chat models, and structured output all combine cleanly, because every one of them shares that same
.invoke()interface. MessagesPlaceholderlets a template accommodate a whole, variable-length conversation history, not just fixed, single-value placeholders — the reusable version of the message-list-building you did by hand in Module 6.
Where this goes next
The next module gives that “output of one thing becomes input of the next thing” pattern its real name: Runnables and LCEL. You’ll see exactly why LangChain gave every component — models, prompts, retrievers, parsers — the same shared interface, and how the | pipe operator turns the two-step chaining you just did by hand into one clean, readable line.
- Author
- TechByteByByte Editorial Team
- Reviewed by
- TechByteByByte Admin
- Published
- Last reviewed