Part 1 got a local model answering questions and showed how SystemMessage, HumanMessage, and AIMessage carry a conversation. Everything there was hand-assembled. You built the message list yourself, and you got a blob of text back.
That is fine for one question. It falls apart the moment you want to run the same prompt against a thousand support tickets and store the results in a database. This part covers the two pieces that fix it: prompt templates, which turn a prompt into a function, and output parsers, which turn a reply into a Python object.
Same setup as before. Ollama with llama3.2:3b, and langchain 1.3.16.
Why Templates
The obvious way to parameterize a prompt is an f-string:
topic = "BGP"
audience = "network intern"
prompt = f"Explain {topic} to a {audience} in one sentence."
This works, and for a small script it is the right answer. It breaks down for three reasons.
There is no validation. Misspell a variable and you get a NameError at the call site if you are lucky, or a prompt with a literal {topic} in it if you built the string another way. There is no reuse, because the prompt and its inputs are welded together at the point of use. Most importantly, an f-string produces a str, and a chat model wants a list of typed messages with roles attached.
Prompt templates solve all three.
PromptTemplate
PromptTemplate is the simplest one. String in, string out. This is the class people are usually reaching for when they search for something called PromptMessage, which does not exist in LangChain.
from langchain_core.prompts import PromptTemplate
template = PromptTemplate.from_template(
"Explain {topic} to a {audience} in one sentence."
)
print(template.input_variables)
print(template.format(topic="BGP", audience="network intern"))
['audience', 'topic']
Explain BGP to a network intern in one sentence.
from_template() parses the {variable} placeholders and records them on .input_variables. That list is the template’s signature. If you call .format() and leave one out, you get a KeyError immediately instead of sending a broken prompt to the model.
PromptTemplate targets the older completion-style models that take one string. For chat models, which is nearly everything now, you want the next class.
ChatPromptTemplate
ChatPromptTemplate produces a list of role-tagged messages instead of a string. Build one from a list of (role, template) tuples:
from langchain_core.prompts import ChatPromptTemplate
chat_template = ChatPromptTemplate.from_messages([
("system", "You are a terse {role}. Answer in one sentence."),
("human", "Explain {topic}."),
])
value = chat_template.invoke({"role": "network engineer", "topic": "OSPF areas"})
print(type(value).__name__)
for message in value.to_messages():
print(" ", type(message).__name__, "|", message.content)
ChatPromptValue
SystemMessage | You are a terse network engineer. Answer in one sentence.
HumanMessage | Explain OSPF areas.
Two things to notice. Placeholders work in the system message as well as the human message, so you can parameterize the model’s role. And .invoke() returns a ChatPromptValue that holds real SystemMessage and HumanMessage objects, which is exactly what ChatOllama expects.
There is a longer form using per-role template classes:
from langchain_core.prompts import (
ChatPromptTemplate,
SystemMessagePromptTemplate,
HumanMessagePromptTemplate,
)
chat_template = ChatPromptTemplate.from_messages([
SystemMessagePromptTemplate.from_template("You are a terse {role}. Answer in one sentence."),
HumanMessagePromptTemplate.from_template("Explain {topic}."),
])
This produces an identical template. The tuple form is shorter and is what you will see in most current code. Reach for the explicit classes when you need to configure something on an individual message, and use tuples the rest of the time.
Chains and the pipe operator
Now the payoff. LangChain overloads the | operator so components compose into a chain:
from langchain_ollama import ChatOllama
from langchain_core.output_parsers import StrOutputParser
model = ChatOllama(model="llama3.2:3b", temperature=0)
chain = chat_template | model | StrOutputParser()
print(chain.invoke({"role": "network engineer", "topic": "OSPF areas"}))
OSPF areas are topologically independent segments of the network, each with its own set of routers and links, allowing for efficient routing and scalability within the network.
This is LangChain Expression Language, usually shortened to LCEL. Each component takes the previous component’s output as its input, and the resulting chain is itself a component with the same .invoke(), .stream(), and .batch() interface. That last one matters: chain.batch([...]) runs a list of inputs and handles the concurrency for you.
MessagesPlaceholder
A template with fixed slots cannot hold a conversation, because the number of prior messages changes on every turn. MessagesPlaceholder is a named slot that accepts a whole list of messages at invoke time.
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
chat_template = ChatPromptTemplate.from_messages([
("system", "You are a terse assistant. Answer in one short sentence."),
MessagesPlaceholder("history"),
("human", "{input}"),
])
The template now takes two inputs. input is a plain string, and history is a list of message objects that gets spliced into position. Note the ordering: the system message stays pinned at the front, the history sits in the middle, and the current question goes last. That structure survives however long the conversation runs.
Here is a two-turn exchange that maintains the history list:
from langchain_ollama import ChatOllama
from langchain_core.messages import HumanMessage, AIMessage
from langchain_core.output_parsers import StrOutputParser
chain = chat_template | ChatOllama(model="llama3.2:3b", temperature=0) | StrOutputParser()
history = []
for question in ["My name is James and I run a network lab.", "What is my name?"]:
answer = chain.invoke({"history": history, "input": question})
print(f"> {question}\n{answer}")
history.extend([HumanMessage(question), AIMessage(answer)])
print("history length:", len(history))
> My name is James and I run a network lab.
What can I assist you with, James?
> What is my name?
Your name is James.
history length: 4
The second answer is correct because the first exchange was in history. You are still doing the memory management by hand, exactly as in Part 1, but now the template handles the assembly.
One useful option: MessagesPlaceholder("history", optional=True) lets you omit the key entirely on the first turn instead of passing an empty list.
A practical warning about history. Every turn resends everything, so an unbounded list eventually exceeds the model’s context window and the call fails or silently truncates. In production you trim the list, keep a rolling summary, or both. trim_messages from langchain_core.messages handles the first approach.
Output Parsers
A chain that ends at the model gives you an AIMessage. A chain that ends at a parser gives you whatever your code actually needs. All of these import from langchain_core.output_parsers unless noted.
StrOutputParser
The one you will use most. It pulls .content off the message and returns a plain string. It is the parser in every example above.
chain = prompt | model | StrOutputParser()
CommaSeparatedListOutputParser
Returns a Python list. Its value is get_format_instructions(), which produces text you inject into the prompt so the model knows what shape to reply in:
from langchain_core.output_parsers import CommaSeparatedListOutputParser
parser = CommaSeparatedListOutputParser()
print(parser.get_format_instructions())
chain = ChatPromptTemplate.from_messages([
("system", "{format_instructions}"),
("human", "List 5 routing protocols."),
]) | model | parser
print(chain.invoke({"format_instructions": parser.get_format_instructions()}))
Your response should be a list of comma separated values, eg: `foo, bar, baz` or `foo,bar,baz`
['OSPF', 'EIGRP', 'BGP', 'RIP', 'IGRP']
That pattern of asking the parser for its own instructions and feeding them into the prompt is the core idea behind every structured parser.
JsonOutputParser
Parses the reply as JSON and returns a dict. Give it a Pydantic model and the format instructions include the full schema.
PydanticOutputParser
The same, but it validates against the Pydantic model and returns a typed object rather than a dict. This is the one to reach for when the output feeds other code.
Caution: A 3B model does not reliably produce valid JSON. Before you run the next example, set
temperature=0, put the format instructions in the system message, and expect to handle parse failures. The mitigations are covered right after.
from typing import List
from pydantic import BaseModel, Field
from langchain_ollama import ChatOllama
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import PydanticOutputParser
class Device(BaseModel):
hostname: str = Field(description="the device hostname")
vendor: str = Field(description="the hardware vendor")
site: str = Field(description="the site code")
interfaces: List[str] = Field(description="interface names mentioned")
model = ChatOllama(model="llama3.2:3b", temperature=0)
parser = PydanticOutputParser(pydantic_object=Device)
prompt = ChatPromptTemplate.from_messages([
("system", "Extract device facts from the text. {format_instructions}"),
("human", "{text}"),
]).partial(format_instructions=parser.get_format_instructions())
text = (
"Ticket 4412: core switch dfw-core-01 is a Juniper QFX5120 at site DFW1. "
"Ports et-0/0/48 and et-0/0/49 are flapping."
)
result = (prompt | model | parser).invoke({"text": text})
print(result)
print(result.hostname)
hostname='dfw-core-01' vendor='Juniper' site='DFW1' interfaces=['et-0/0/48', 'et-0/0/49']
dfw-core-01
A Device instance, not a string. Attribute access, type checking, and a ValidationError if the model returns something wrong.
.partial() is worth noting. It pre-fills a template variable so callers only supply what changes. The format instructions are the same on every call, so binding them once keeps the invoke payload clean.
StructuredOutputParser
Defines a schema from ResponseSchema objects instead of Pydantic. It lives in langchain_classic.output_parsers since version 1.0.
from langchain_classic.output_parsers import StructuredOutputParser, ResponseSchema
parser = StructuredOutputParser.from_response_schemas([
ResponseSchema(name="hostname", description="the device hostname"),
ResponseSchema(name="vendor", description="the hardware vendor"),
])
Running that against the same ticket text returns a dict, and it shows the tradeoff clearly:
{'hostname': 'dfw-core-01', 'vendor': 'Juniper', 'model': 'QFX5120', 'site': 'DFW1', 'issue': 'Port flapping on et-0/0/48 and et-0/0/49'}
The model volunteered three fields nobody asked for, and the parser passed them straight through. PydanticOutputParser would have rejected the extras. Use StructuredOutputParser when you want a quick dict and do not care about strictness.
The rest
XMLOutputParser parses XML into nested dicts, and is useful with models that were tuned to emit tags. DatetimeOutputParser, also in langchain_classic.output_parsers, coerces a reply into a datetime.
When parsing fails
Two mitigations, in order of preference.
with_structured_output() is a method on the model rather than a separate parser. It uses the provider’s native structured output support instead of instructions in the prompt, which is more reliable when the provider offers it:
structured_model = model.with_structured_output(Device)
print(structured_model.invoke("Extract device facts: " + text))
hostname='dfw-core-01' vendor='Juniper' site='DFW1' interfaces=['et-0/0/48', 'et-0/0/49']
Same result, less prompt engineering. Prefer this when your model supports it.
OutputFixingParser wraps another parser. When parsing throws, it sends the broken output back to a model and asks it to repair the format:
from langchain_classic.output_parsers import OutputFixingParser
safe_parser = OutputFixingParser.from_llm(parser=parser, llm=model)
That costs a second model call on every failure, so treat it as a safety net rather than a design.
Putting It Together
The complete extraction pipeline is short. A template, a model, and a parser:
from typing import List
from pydantic import BaseModel, Field
from langchain_ollama import ChatOllama
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import PydanticOutputParser
class Device(BaseModel):
hostname: str = Field(description="the device hostname")
vendor: str = Field(description="the hardware vendor")
site: str = Field(description="the site code")
interfaces: List[str] = Field(description="interface names mentioned")
parser = PydanticOutputParser(pydantic_object=Device)
prompt = ChatPromptTemplate.from_messages([
("system", "Extract device facts from the text. {format_instructions}"),
("human", "{text}"),
]).partial(format_instructions=parser.get_format_instructions())
chain = prompt | ChatOllama(model="llama3.2:3b", temperature=0) | parser
tickets = [
"Ticket 4412: core switch dfw-core-01 is a Juniper QFX5120 at site DFW1. "
"Ports et-0/0/48 and et-0/0/49 are flapping.",
"Ticket 4415: iad-edge-02, an Arista 7050X3 in IAD2, dropped Ethernet1/1.",
]
for device in chain.batch([{"text": t} for t in tickets]):
print(device.hostname, "|", device.vendor, "|", device.site, "|", device.interfaces)
dfw-core-01 | Juniper | DFW1 | ['et-0/0/48', 'et-0/0/49']
iad-edge-02 | Arista | IAD2 | ['Ethernet1/1']
Note chain.batch() rather than a loop. Because the chain is itself a runnable, it gets batching, streaming, and async for free.
That is a real pipeline. Free text goes in, validated objects come out, and the whole thing is four components you can test in isolation.
What it still cannot do is answer a question about information the model was never trained on. Point it at your runbooks, your ticket history, or last week’s incident report and it will confidently invent an answer. Part 3 will fix that with document loaders, embedding models, vector stores, and retrievers, and ends with a complete retrieval augmented generation (RAG) chain running entirely on your laptop.