NovuSpark
All articles
AISeptember 26, 2025 · NovuSpark Team

LangChain 101: Chains, Prompts, and Models

This is the first post in our LangChain fundamentals series. Later posts cover RAG applications, memory, agents and tools, and debugging.

A single call to a model's chat completions API, covered directly in our OpenAI series, handles a huge share of real use cases on its own — plenty of applications never need anything more. LangChain earns its place once an application needs to compose several steps together: format a prompt from a template, call a model, parse the structured result, feed it into a second call — as one coherent, reusable pipeline, rather than hand-wiring that sequence with custom glue code every time.

Prompt templates: parameterizing prompts properly

from langchain_core.prompts import ChatPromptTemplate
 
prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a technical writer explaining {topic} to {audience}."),
    ("user", "{question}"),
])
 
formatted = prompt.invoke({
    "topic": "Kubernetes",
    "audience": "engineers new to containers",
    "question": "What's the difference between a Pod and a container?",
})

This is the same instinct as a Jinja2 template in Ansible, or a parameterized Terraform module, applied to prompts specifically: the structure of the prompt is defined once, and the actual variable content is supplied per call — rather than string-concatenating a prompt by hand throughout an application's code, with the risk of subtle formatting inconsistencies creeping in at each call site.

Models: a consistent interface across providers

from langchain_openai import ChatOpenAI
 
model = ChatOpenAI(model="gpt-4o", temperature=0.2)
response = model.invoke(formatted)
print(response.content)

LangChain wraps a given provider's actual API (OpenAI, Anthropic, and many others each have their own integration package) behind a consistent .invoke() interface. The genuine value here isn't hiding complexity that wasn't there — it's that swapping the underlying model provider, or comparing two providers on the same task, means changing one line (ChatOpenAI to, say, ChatAnthropic) rather than rewriting every call site's provider-specific API calls throughout an application.

Output parsers: turning a response into a usable value

from langchain_core.output_parsers import StrOutputParser
 
chain = prompt | model | StrOutputParser()
result = chain.invoke({
    "topic": "Kubernetes",
    "audience": "engineers new to containers",
    "question": "What's the difference between a Pod and a container?",
})
print(result)
A Pod is Kubernetes' smallest deployable unit — a thin wrapper around
one or more containers that are always scheduled together and share a
network namespace...

That | operator is LangChain's LCEL (LangChain Expression Language) — chaining a prompt template, a model, and an output parser into a single callable pipeline. Each stage's output becomes the next stage's input automatically: the prompt template produces formatted messages, the model consumes them and produces a response object, StrOutputParser extracts just the plain text string from that response object. Three separate concerns, composed into one reusable, callable chain.

ChatPromptTemplate→ formatted messagesmodel (ChatOpenAI)→ response objectStrOutputParser→ plain string||
Fig. 1 — LCEL's | operator composes three stages into one callable chain, each output feeding the next automatically

Structured output parsing

from langchain_core.output_parsers import JsonOutputParser
from pydantic import BaseModel
 
class CourseRecommendation(BaseModel):
    course_name: str
    reason: str
    urgency: str
 
prompt = ChatPromptTemplate.from_messages([
    ("system", "Recommend a course based on the user's stated need. "
               "Respond with JSON matching: course_name, reason, urgency."),
    ("user", "{request}"),
])
 
chain = prompt | model | JsonOutputParser(pydantic_object=CourseRecommendation)
result = chain.invoke({"request": "My team needs to learn container orchestration fast"})
print(result)
{'course_name': 'Kubernetes Fundamentals', 'reason': 'Directly addresses container orchestration', 'urgency': 'high'}

This is the same structured-output guarantee covered in our OpenAI series, expressed through LangChain's chain composition instead of calling the provider's structured-output parameter directly — useful specifically when this step is one part of a larger, multi-stage chain, rather than a single standalone call.

Runnables: the shared interface behind everything

Every piece in a chain — prompt templates, models, parsers, and (as later posts in this series cover) retrievers and tools — implements the same Runnable interface: .invoke() for a single call, .batch() for many inputs at once, .stream() for incremental output. This uniformity is what makes the | composition operator work at all — LCEL doesn't need special-case logic for "a prompt template followed by a model" versus "a retriever followed by a parser," because both sides of every | are just Runnables with the same interface.

results = chain.batch([
    {"topic": "Kubernetes", "audience": "beginners", "question": "What is a Pod?"},
    {"topic": "Terraform", "audience": "beginners", "question": "What is a provider?"},
])

.batch() runs a chain across multiple inputs, with LangChain handling concurrency underneath — genuinely useful for processing many independent requests (a batch of support tickets, a set of documents to summarize) without writing your own threading or async logic to parallelize the calls.

Few-shot prompting inside a template

Beyond simple variable substitution, prompt templates support genuinely structured few-shot examples — the same technique covered as an alternative to fine-tuning in our OpenAI series, expressed as a reusable template component rather than a hand-assembled string:

from langchain_core.prompts import FewShotChatMessagePromptTemplate
 
examples = [
    {"input": "What's a Pod?", "output": "A Pod is Kubernetes' smallest deployable unit."},
    {"input": "What's a Service?", "output": "A Service provides a stable address for a set of Pods."},
]
 
example_prompt = ChatPromptTemplate.from_messages([
    ("human", "{input}"),
    ("ai", "{output}"),
])
 
few_shot_prompt = FewShotChatMessagePromptTemplate(
    example_prompt=example_prompt,
    examples=examples,
)
 
final_prompt = ChatPromptTemplate.from_messages([
    ("system", "Answer concisely, in the style of these examples."),
    few_shot_prompt,
    ("human", "{question}"),
])

FewShotChatMessagePromptTemplate formats the entire examples list into properly-structured conversation turns automatically, inserted before the actual user question — genuinely more maintainable than hand-concatenating example strings into a system prompt, especially once an application has more than a couple of examples or needs to swap in a different example set for a different context.

Partial variables: binding some values ahead of time

A template sometimes has values known well before the actual call — a fixed persona, a constant configuration value — that shouldn't need to be re-supplied on every single invocation:

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a technical writer at NovuSpark, explaining {topic} to {audience}."),
    ("user", "{question}"),
]).partial(audience="engineers new to the topic")
 
formatted = prompt.invoke({"topic": "Kubernetes", "question": "What's a Pod?"})

.partial() pre-fills audience once, leaving only topic and question to be supplied per actual call — a small but genuinely useful pattern once a template has several variables and some of them are effectively constant for a given deployment or use case, rather than something every call site needs to remember to pass explicitly.

Fallback chains: handling a provider outage gracefully

.with_fallbacks() lets a chain automatically retry against a different model or provider if the primary one fails or times out:

primary = ChatOpenAI(model="gpt-4o")
fallback = ChatAnthropic(model="claude-3-5-sonnet-20241022")
 
resilient_model = primary.with_fallbacks([fallback])

A request that fails against OpenAI (a rate limit, an outage) automatically retries against Anthropic instead — genuinely useful for a production application where the consistent-interface benefit of LangChain's model abstraction pays off directly: swapping providers on failure is a configuration detail, not a rewrite.

Where a chain actually earns its complexity

A single prompt-model-parser chain, on its own, is genuinely not much simpler than calling the OpenAI SDK directly — and for a single call, calling the SDK directly is often the more transparent, more debuggable choice. LangChain's real value shows up in composing multiple chains together — the output of one becomes the input to constructing the next, conditionally, with retrieved context injected in the middle. That's exactly the shape RAG pipelines and multi-step agents take, covered in the next posts in this series.

What to actually remember from this post

  • A single LLM call rarely needs LangChain — it earns its complexity once you're composing multiple steps into a pipeline.
  • Prompt templates parameterize prompts the same way a Terraform module or Ansible role parameterizes infrastructure — structure defined once, values supplied per call.
  • LCEL's | operator chains prompt → model → parser into one reusable, callable pipeline, each stage's output feeding the next automatically.
  • Every stage shares the same Runnable interface (.invoke(), .batch(), .stream()) — that uniformity is what makes arbitrary composition via | work at all.
  • The consistent model interface is what makes swapping providers cheap — a genuine advantage once an application might need to compare or switch between them.

Next in the series: Building RAG Applications with LangChain, where this chain-composition pattern becomes a full retrieval-augmented pipeline.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.