Your prompts are part of your codebase
Almost everyone starts by building prompts with f-strings:
text = "Hello world"
prompt = f"Translate the following into French: {text}"
print(prompt) # Translate the following into French: Hello world
That works, and for a script you run once it's fine. It stops being fine fast:
- Prompt text ends up scattered across a dozen files.
- There's no clean way to reuse a system message across several call sites.
- A user who types a
{in their input can break your formatting. - Changing one sentence means grepping the whole project.
ChatPromptTemplate turns a prompt into an object you can reuse, parameterize, and test.
Two ways to build one
from langchain_core.prompts import ChatPromptTemplate
# A single user message
p1 = ChatPromptTemplate.from_template("Translate this into {lang}:\n{text}")
# A full conversation shape with a system message — this is what you'll use most
p2 = ChatPromptTemplate.from_messages([
("system", "You are a professional translator. Output only the translation. No explanations, no quotes."),
("user", "Translate this into {lang}:\n{text}"),
])
print(p2.invoke({"lang": "French", "text": "The weather is nice today."}))
That last line prints the filled-in messages, roughly:
messages=[SystemMessage(content='You are a professional translator. ...'),
HumanMessage(content='Translate this into French:\nThe weather is nice today.')]
Notice it needs no API key. A prompt template is pure string work.
Invoking the template alone and printing the result is the single most effective debugging move in this whole field. A large fraction of "the model won't follow my instructions" turns out to be "my instructions never made it into the prompt." Look at the real text before you blame the model.
Five rules for a system prompt that works
- Give it a role. "You are a Python interviewer with ten years of experience" constrains behavior far more than "You are a helpful assistant."
- Give it rules, including negative ones. "Do not explain." "Do not wrap the output in a markdown code block." Models love adding friendly preamble. You have to forbid it explicitly.
- Give it a format. If you want a particular shape, describe the shape. Want JSON? List the fields and their types.
- Give it examples. One or two worked examples beat ten sentences of description. Best effort-to-payoff ratio available.
- Give it an escape hatch. "If the answer isn't in the provided material, say 'I don't know.' Do not guess." Without this, a model will fill the gap with something plausible and wrong.
Few-shot: teaching by example
from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate
examples = [
{"input": "I want to return this order", "output": "support"},
{"input": "How do I get an invoice?", "output": "billing"},
{"input": "The app won't open", "output": "technical"},
]
example_prompt = ChatPromptTemplate.from_messages([
("user", "{input}"),
("assistant", "{output}"),
])
few_shot = FewShotChatMessagePromptTemplate(
examples=examples,
example_prompt=example_prompt,
)
final = ChatPromptTemplate.from_messages([
("system", "You classify support tickets. Output the category name and nothing else."),
few_shot,
("user", "{input}"),
])
print(final.invoke({"input": "My payment failed"}))
Again, no key needed — this just prints the assembled messages. And that printout is the whole point, because it shows you what few-shot really is:
SystemMessage('You classify support tickets...')
HumanMessage('I want to return this order')
AIMessage('support')
HumanMessage('How do I get an invoice?')
AIMessage('billing')
HumanMessage("The app won't open")
AIMessage('technical')
HumanMessage('My payment failed')
Your examples were inserted as fake earlier turns of the conversation. The model sees a transcript in which it has already answered three times with a single lowercase word, and continues the pattern. Pipe this into a model (final | llm | StrOutputParser()) and you get back billing.
Nothing about the model changed. You changed its context.
For classification, extraction, and format conversion, adding few-shot examples almost always improves consistency, because those tasks are about shape, and an example communicates shape better than a paragraph ever will.
The tradeoff: examples cost input tokens on every single call. Three short ones are cheap. Thirty long ones are not.
The curly brace trap
Inside a template, {} marks a variable. So what happens when your prompt needs literal braces — say, when you're describing a JSON format?
from langchain_core.prompts import ChatPromptTemplate
t = ChatPromptTemplate.from_template(
'Reply in this format: {{"name": "...", "age": 0}}\nInput: {text}'
)
print(t.invoke({"text": "Maya is 30"}))
This prints a message containing the literal text Reply in this format: {"name": "...", "age": 0}. The doubled braces collapse to single ones.
Write them singly and you get KeyError: '"name"' — the template tried to find a variable named "name". You will hit this the first time you ask a model for JSON. Now you'll recognize it.
Keep prompts out of your business logic
Once a project grows past a few files, move prompts into their own module:
# prompts.py
CLASSIFY_SYSTEM = """You classify support tickets.
Categories: support / billing / technical / other.
Output the category name only. No punctuation, no explanation.
If you cannot tell, output: other"""
Now changing the wording never touches the code that calls the model, and a diff on prompts.py reads as a meaningful change rather than noise inside a function.
Manage prompts the way you manage code. Every edit should have a reason, and you should be able to tell whether it made things better. Which leads to the uncomfortable part: without a small set of test inputs and expected outputs, "better" is just a feeling. Even ten saved examples in a text file puts you ahead of most teams.
Ten-second check
You want the model to see the literal text {"ok": true}. How do you write it in a template?
Double the braces: {{"ok": true}}. Single braces are read as a variable slot.