Module 1 · RAG Foundations

Prompt Templates and LCEL Chains

Lesson 4 of 23 9 min read Updated

A prompt template is a reusable text recipe with blanks. You fill the blanks with real values at run time, and the result is sent to a language model. LCEL (LangChain Expression Language) is the way LangChain connects steps such as a prompt, a model and an output parser using the | (pipe) operator. Every RAG application is built from these two ideas, so this lesson is the base for everything that follows.

Overview: how it works

A language model does not understand variables. It only receives text or a list of messages. Your job is to build that input from your data. A prompt template does this safely: you write the fixed instructions once, mark the changing parts with {curly_braces}, and supply values later.

LangChain has several template classes. The two you will use most are:

  • PromptTemplate – builds one plain string.
  • ChatPromptTemplate – builds a list of messages with roles such as system (rules for the model), human (the user) and ai (the model). Modern chat models expect this form.

Both are Runnables. A Runnable is any LangChain component that follows one common interface. That interface has these methods:

  • invoke(input) – run once and return the result.
  • batch([inputs]) – run on many inputs, in parallel where possible.
  • stream(input) – yield the result in pieces as it is produced.
  • ainvoke, abatch, astream – the async versions.

Because prompts, chat models, output parsers, retrievers and plain Python functions (once wrapped) all share this interface, they can be chained. The pipe operator a | b creates a new Runnable called a RunnableSequence. When you call invoke on it, the output of a becomes the input of b.

A typical chain has three parts:

  1. Prompt – takes a dictionary such as {'topic': 'RAG'} and returns a prompt value (messages).
  2. Model – takes the messages and returns an AIMessage.
  3. Output parser – takes the AIMessage and returns something convenient, for example a plain string with StrOutputParser.

The big benefit is that the composed chain is itself a Runnable. You get batch, stream and async support for free, and you can put a chain inside another chain.

Syntax and key concepts

chain = prompt | model | parser
result = chain.invoke({'variable': 'value'})
Part Meaning
ChatPromptTemplate.from_messages([...]) Builds a template from a list of (role, text) pairs.
ChatPromptTemplate.from_template(text) Shortcut for a single human message.
{name} A placeholder. Its name becomes a required input key.
MessagesPlaceholder A slot that accepts a whole list of messages, such as chat history.
StrOutputParser() Turns an AIMessage into its text content.
RunnablePassthrough() Returns its input unchanged. Useful to forward the question.
RunnableLambda(fn) Wraps a normal Python function as a Runnable.
RunnableParallel / a dict Runs several branches on the same input and returns a dictionary of results.

Type coercion

Inside a chain, LangChain automatically converts some Python objects into Runnables. A plain function becomes a RunnableLambda. A dictionary whose values are Runnables becomes a RunnableParallel. This is why you can write {'context': retriever, 'question': RunnablePassthrough()} | prompt and it just works.

Examples

Example 1: a chat prompt template

from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ('system', 'You are a helpful {role}.'),
    ('human', 'Explain {topic} in one sentence.'),
])

value = prompt.invoke({'role': 'teacher', 'topic': 'embeddings'})
for message in value.to_messages():
    print(message.type, '|', message.content)
print(prompt.input_variables)

Output:

system | You are a helpful teacher.
human | Explain embeddings in one sentence.
['role', 'topic']

The template found two variables, role and topic. Invoking it with a dictionary produced a prompt value holding two messages. No model was called yet; a template only builds input.

Example 2: prompt | model | parser (offline)

Real models need an API key. To follow along without one, LangChain ships a fake chat model that returns canned replies. The chain code is identical to what you would write with a real model.

from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ('system', 'You are a helpful {role}.'),
    ('human', 'Explain {topic} in one sentence.'),
])
model = FakeListChatModel(responses=['Embeddings turn text into vectors.'])
chain = prompt | model | StrOutputParser()

print(chain.invoke({'role': 'teacher', 'topic': 'embeddings'}))

Output:

Embeddings turn text into vectors.

The data flows left to right: dictionary, prompt value, AIMessage, string. To use a real provider, replace model with a chat model from a partner package, for example ChatOpenAI from langchain_openai, and read its key from an environment variable such as OPENAI_API_KEY. The rest of the chain stays the same. Model names change often, so check your provider’s documentation for current ones.

Example 3: a RAG-style chain with a dictionary step

In RAG, the prompt needs the user’s question and some retrieved text. Here a small function plays the role of a retriever, so the example runs offline.

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda, RunnablePassthrough


def fake_retriever(question):
    return [
        'LCEL uses the pipe operator to compose runnables.',
        'Runnables share invoke, batch and stream.',
    ]


def format_docs(docs):
    return '\n\n'.join(docs)


rag_prompt = ChatPromptTemplate.from_template(
    'Answer using only the context.\n\nContext:\n{context}\n\nQuestion: {question}'
)

chain = {
    'context': RunnableLambda(fake_retriever) | format_docs,
    'question': RunnablePassthrough(),
} | rag_prompt

print(chain.invoke('What is LCEL?').to_string())

Output:

Human: Answer using only the context.

Context:
LCEL uses the pipe operator to compose runnables.

Runnables share invoke, batch and stream.

Question: What is LCEL?

The input is a plain string. The dictionary sends that same string to two branches. The context branch calls the retriever and joins the results into one block. The question branch passes the string through unchanged. The resulting dictionary {'context': ..., 'question': ...} matches the two placeholders in the prompt. Add | model | StrOutputParser() at the end to complete a real RAG chain.

Example 4: batch on a chain

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda

prompt = ChatPromptTemplate.from_template('Hi {name}')
chain = prompt | RunnableLambda(lambda value: value.to_string().upper())

print(chain.batch([{'name': 'Asha'}, {'name': 'Ravi'}]))

Output:

['HUMAN: HI ASHA', 'HUMAN: HI RAVI']

batch returns results in the same order as the inputs, even when it runs them in parallel. With a real model this is a simple way to process many questions at once.

Under the hood: step by step

When you call chain.invoke(x) on prompt | model | parser:

  1. The RunnableSequence passes x to the first step, the prompt.
  2. The prompt checks that every placeholder has a value. It formats the messages and returns a ChatPromptValue.
  3. The model receives that value, converts it to the provider’s message format, calls the API and returns an AIMessage.
  4. The parser reads AIMessage.content and returns a string.

With stream, the same chain yields pieces as soon as the model produces them. StrOutputParser can pass these pieces through, so your user sees text appear gradually. Steps that need the full output (for example a JSON parser) wait or handle partial data in their own way.

Common Mistakes

1. Missing or misspelled input keys

prompt = ChatPromptTemplate.from_template('Explain {topic} to a {audience}.')
prompt.invoke({'topic': 'RAG'})   # wrong: 'audience' is missing

This raises a KeyError because the template needs both topic and audience. Fix: pass every variable, and check prompt.input_variables when unsure.

prompt.invoke({'topic': 'RAG', 'audience': 'beginner'})

2. Literal curly braces in the template

prompt = ChatPromptTemplate.from_template('Reply as JSON like {"answer": "..."}')  # wrong

The template treats {"answer": ...} as a variable name and fails. To write a literal brace, double it:

prompt = ChatPromptTemplate.from_template('Reply as JSON like {{"answer": "..."}}')

3. Piping into something that is not a Runnable

Writing prompt | 'text' or prompt | some_object fails because the right side cannot be converted into a Runnable. Wrap normal functions with RunnableLambda, or use a dictionary of Runnables. Also remember that the output type of one step must match the input type of the next: a model cannot receive the dictionary that went into the prompt.

4. Forgetting the parser

Without StrOutputParser(), a chain returns an AIMessage object, not a string. Printing it shows metadata as well as text. Add the parser when you only need the text.

Best Practices

  • Keep prompts in one place (a module or a file) so you can review and version them like code.
  • Put stable rules in the system message and the changing data in the human message.
  • In RAG prompts, tell the model to answer only from the context and to say when the answer is not there. This reduces made-up answers.
  • Use MessagesPlaceholder for chat history instead of joining old messages into one string.
  • Build chains from small parts (prompt, model, parser) so you can test each part alone. Use fake models in unit tests so tests are fast and free.
  • Read API keys from environment variables. Never write them in source code.
  • Use batch for many inputs and stream for interactive user interfaces.
  • Check prompt.input_variables in tests to catch renamed or missing placeholders early.

Practice Exercises

  1. Create a ChatPromptTemplate with a system message that sets a language variable and a human message that asks for a summary of a text variable. Print the formatted messages for one example.
  2. Build prompt | FakeListChatModel(...) | StrOutputParser() and call batch with two inputs. Observe how the fake model’s responses are used. Hint: give it a list with two responses.
  3. Extend Example 3 so the dictionary has a third key, language, which is always 'English'. Hint: use RunnableLambda(lambda _: 'English') and add {language} to the template.

Summary

  • A prompt template turns variables into the messages a model needs.
  • Prompts, models, parsers, retrievers and wrapped functions are all Runnables with invoke, batch and stream.
  • The | operator builds a RunnableSequence; the output of one step is the input of the next.
  • A dictionary of Runnables becomes a RunnableParallel, which is how RAG chains supply both context and question.
  • Double your braces for literal { and }, and always supply every input variable.
  • Fake models let you build and test chains offline; swapping in a real model changes only one line.

Progress is saved in this browser.