Skip to content

Model Inputs in LangChain: Messages, Prompt Templates and Few-Shot Examples

Site Console Site Console
27 min read Updated Oct 12, 2026 AI & Tools 0 comments

The First Abstraction That Pays for Itself

Post 4 called the OpenAI API directly and ended with four gaps. This post closes the first one, and it is the one you feel soonest: nothing in that code was reusable.

The sarcastic instructions were a string literal sitting inside a function call. Used in a second place, they get copy-pasted. Needing a Vietnamese variant, they get wrapped in an f-string. Needing a test, there is nothing to test — the prompt was never a thing, it was a fragment of an argument. And the conversation was a list of dictionaries with string keys, where "assistnat" is a typo your editor cannot see and your type checker will not catch.

LangChain's answer to all of that is to make inputs into objects. Messages become classes. Prompts become templates with a declared interface. Both become values you can pass around, inspect, version and test without calling a model at all. That is the whole subject of this post, and it is where the framework starts earning its place rather than merely wrapping a call you could have made yourself.


The LangChain Framework

Before the classes, the shape of the thing they live in — because the single most common reason a LangChain snippet fails is that it was written for a different arrangement of packages than the one you installed.

LangChain in v1 is not one library. It is a small core with satellites, and knowing which satellite owns what saves you most of the import errors you would otherwise collect.

langchain-core is the foundation. It defines the base abstractions and holds no provider code: the message classes, the prompt templates, the output parsers, the Runnable protocol that post 7 is about. It has deliberately few dependencies, because everything else in the ecosystem depends on it and a heavy core would poison every install downstream.

langchain is the main package, and in v1 it is deliberately thin. It is a curated namespace that re-exports the parts of core most applications need, plus the agent harness. Its whole surface is langchain.agents, langchain.chat_models, langchain.messages, langchain.tools and langchain.embeddings — which is why the list fits in a sentence, and why anything you see imported from langchain.chains is pre-v1 code.

Provider integrations live in their own packages: langchain-openai, langchain-anthropic, langchain-google-genai, and so on. They are versioned independently and released on the provider's schedule rather than LangChain's, which matters more than it sounds like — it means the package you upgrade most often is not the one you think of as "LangChain".

langchain-classic holds the pre-v1 machinery that v1 moved out: the legacy chains, the older retrievers, the hub client. Nothing was deleted; it simply lives behind a different import and must be installed on purpose. Post 2 covers that split in full.

Install is through extras, so the provider comes down with the framework:

pip install -U "langchain[openai]"

The practical consequence for this post is one import rule that catches almost everyone. The chat model helpers and the message classes are re-exported onto the langchain namespace, and the prompt templates are not:

from langchain.chat_models import init_chat_model          # works
from langchain.messages import SystemMessage, HumanMessage # works
from langchain.prompts import ChatPromptTemplate           # ModuleNotFoundError

That third line fails, verbatim:

ModuleNotFoundError: No module named 'langchain.prompts'

Prompt templates come from core directly:

from langchain_core.prompts import ChatPromptTemplate

There is no deeper reason to learn here; it is a fact about which names got curated into the thin package and which did not. But it is the fact that breaks the first cell of a pasted tutorial, so it is worth having at the front of your memory rather than discovering it four times.


Note on LangChain Updates and Saved Notebooks

This section would be boilerplate in a post about almost any other library. It is not boilerplate here, and I can prove it with something that happened while this series was being written.

The earlier posts in this series pin their versions: langchain 1.4.3, langchain-core 1.6.7, langchain-openai 1.6.7, verified on 8 October 2026. When I reopened the same environment three days later to expand this post, I checked the installed versions before touching anything:

pip list | grep -E "^langchain|^openai"
langchain                 1.4.3
langchain-core            1.6.9
langchain-openai          1.7.0
langchain-classic         1.0.8
langchain-text-splitters  1.1.3
openai                    3.26.0

langchain itself had not moved. langchain-core had gone from 1.6.7 to 1.6.9, and langchain-openai had gone from 1.6.7 to 1.7.0 — a minor-version bump on the package that owns ChatOpenAI, in three days, in an ecosystem whose maintainers have committed to no breaking changes before 2.0.

Nothing in this post broke as a result. That is the point worth taking, not a warning that everything is unstable. The commitment holds, the code still runs, and the churn that made the internet's LangChain content unreliable was the v1 correction rather than the normal state of affairs. But the version you ran a snippet against is genuinely not the version a reader will have, and the package that drifts fastest is the provider integration — the one most tutorials do not pin at all.

Which leads to the habit this series follows, and that I would suggest for anything you save for yourself.

Pin and date every notebook. A cell at the top that prints its own environment costs nothing and turns a mysterious future failure into a two-second diagnosis:

from importlib.metadata import version

for pkg in ("langchain", "langchain-core", "langchain-openai"):
    print(f"{pkg:20s} {version(pkg)}")

Run that first, record what it printed next to the date, and your saved examples stop being undated artifacts. When one of them stops working a year from now you will know immediately whether you are looking at a real break or at a snippet from a different era — and post 2's guide to dating a snippet by its imports does the rest.

The second habit is to distrust generated code more than documentation. Model training data is weighted toward two years of pre-v1 material, so asking an assistant for LangChain code reliably produces LLMChain, initialize_agent and from langchain.prompts import ... — fluent, confident, and from the wrong era. The current documentation is the authority; a plausible snippet is a hypothesis.

Everything below was executed on 11 October 2026 against the versions printed above, including every error message quoted, which are copied from real output rather than reconstructed from memory.


ChatOpenAI

There are two ways to get a model object, and they differ in intent rather than capability.

The provider-agnostic path takes a model string and works out which integration to load:

from langchain.chat_models import init_chat_model

model = init_chat_model("gpt-5.6-luna")
response = model.invoke("Explain retrieval in one sentence.")
print(response.text)

Switching provider is the string and nothing else in the file:

model = init_chat_model("claude-sonnet-4-6")   # pip install -U "langchain[anthropic]"

The other path names the provider class directly:

from langchain_openai import ChatOpenAI

model = ChatOpenAI(model="gpt-5.6-luna", temperature=0)

Use init_chat_model when the model should be configuration — read from an environment variable, swapped per environment, chosen per task, compared across an evaluation set. Use ChatOpenAI when you are deliberately committing to one provider and want its constructor arguments and type hints in front of you while you work. Under the hood init_chat_model("gpt-...") constructs a ChatOpenAI for you, so this is not a capability trade.

Now the detail that costs people money. ChatOpenAI has a default model, and in langchain-openai 1.7.0 it is not the one you would hope for:

model = ChatOpenAI()
print(model.model_name)
# gpt-3.5-turbo

A constructor with no arguments silently gives you a legacy model from 2023. Nothing warns. The code runs, the answers come back, and they are worse than they should be while you debug your prompt. Always name the model.

The constructor takes a little under sixty parameters, which is not a list anyone should read. The ones that earn their place in your working memory divide into three groups.

The sampling controls are the ones post 4 covered through the raw API: temperature, top_p, presence_penalty, frequency_penalty, seed, and stop_sequences. They mean exactly what they meant there, because LangChain is passing them through.

The output-size control is where the naming has moved. There is a max_tokens attribute on the object, but the parameter the provider now takes is max_completion_tokens, and reasoning tokens count against it — which post 3 showed billing as output at the expensive rate. If you set a ceiling expecting it to bound the visible answer, a reasoning model will spend your budget thinking and get truncated before it speaks.

The operational controls are the ones that distinguish a script from a service. timeout and max_retries both default to None, meaning you inherit the client's behaviour rather than choosing it; naming them is how a hung call becomes a handled error. rate_limiter accepts a limiter object so that a batch job throttles itself instead of collecting 429s. cache turns on response caching for identical inputs, which is worth real money during development when you run the same prompt forty times. stream_usage asks for token counts to arrive with streamed responses, which you otherwise lose. And model_kwargs is the escape hatch for a provider parameter the integration does not model yet — useful, and a thing to grep for later, because it bypasses the typed surface entirely.

Two more exist for compatibility rather than features. use_responses_api selects the newer OpenAI endpoint explicitly, and output_version controls the shape of what lands in content. Post 2 explains why output_version="v0" is sometimes needed to restore the older content shape, and it is the single most likely cause of code that runs but hands your string handling something it did not expect.


System and Human Messages

A conversation in post 4 was a list of dictionaries. In LangChain each role is a class:

from langchain.messages import SystemMessage, HumanMessage, AIMessage

messages = [
    SystemMessage("You are a support assistant who is technically correct and "
                  "relentlessly sarcastic. Never be cruel."),
    HumanMessage("How do I reset my password?"),
]

response = model.invoke(messages)
print(response.text)

The SystemMessage is the instruction channel: who the model is, what it may and may not do, what format you expect. It is persistent in a way the rest of the conversation is not — it stays at the front of every call while the turns accumulate behind it, which is both why the model keeps behaving and why that position is the one that earns the cached-input discount from post 3.

The HumanMessage is the user's turn. It carries whatever arrived from outside your system, which is also why it is the one to be careful with: instructions that appear inside a human message are user input, not configuration, and a model that cannot tell the difference will happily follow them.

Each message exposes a .type discriminator, which is what provider integrations switch on when they serialise your list into whatever wire format the API wants:

for m in [SystemMessage("s"), HumanMessage("h"), AIMessage("a")]:
    print(type(m).__name__, "|", m.type, "|", repr(m.text), "|", m.content_blocks)
SystemMessage | system | 's' | [{'type': 'text', 'text': 's'}]
HumanMessage  | human  | 'h' | [{'type': 'text', 'text': 'h'}]
AIMessage     | ai     | 'a' | [{'type': 'text', 'text': 'a'}]

Note content_blocks alongside .text. A message's content is not necessarily a string — it can be a list of typed blocks covering text, images, audio, files, citations and reasoning traces. .text is the convenience view that flattens the text parts out of whatever is actually there, and content_blocks is the structured view. For a plain string message they say the same thing, which is why you can ignore the distinction until the day you send an image and the string view goes quiet.

Messages also take a name and an id:

h = HumanMessage("Deploy is broken", name="dang")
print(h.name, "|", h.id)
# dang | None

name is for conversations with more than one participant, where "who said this" is part of the content rather than metadata. id is None unless something sets it, and messages that come back from a provider arrive with one.

What the classes buy you over dictionaries is modest and real. Three things, concretely: your editor completes SystemMessage and cannot complete "systen"; your type checker knows a list of messages from a list of dicts; and because they are objects rather than literals, the same value can be constructed in one place and consumed in several. None of that is exciting. All of it is the difference between a script and code someone else can change safely.


AI Messages

AIMessage is the one of the three you will mostly receive rather than send, and it is worth looking at from both directions, because the asymmetry is the interesting part.

Sending one means putting words in the model's mouth. That is how you supply conversation history, and it is also a technique in its own right:

messages = [
    SystemMessage("You are a support assistant who is technically correct and "
                  "relentlessly sarcastic. Never be cruel."),
    HumanMessage("How do I reset my password?"),
    AIMessage("Click 'Forgot password'. Revolutionary, I know."),
    HumanMessage("It didn't send the email."),
]

response = model.invoke(messages)

The model has no memory between calls — post 4 made that point and it does not stop being true here. What looks like a continuing conversation is this list, rebuilt and resent in full on every request. The AIMessage in the middle is not a record the model is consulting; it is evidence you are presenting about what it supposedly said. It will take your word for it, which is a sentence worth sitting with: an AIMessage you construct is a claim about the past that the model treats as fact, and that behaviour is both how history works and how it can be abused.

A hand-built AIMessage is mostly empty beyond its content:

a = AIMessage("Click 'Forgot password'. Revolutionary, I know.")
print(a.usage_metadata)     # None
print(a.response_metadata)  # {}
print(a.tool_calls)         # []
print(a.id)                 # None

The one that comes back from invoke is the same class with those fields filled in, and that is where its real value sits. usage_metadata carries the token counts you are billed on, broken down far enough to show reasoning tokens and cache reads — the structure post 3 used to find a bill that made no sense. response_metadata carries the provider's own envelope: the model that actually served the request, the finish reason, system fingerprints. tool_calls carries structured requests to call your functions, which is the entire basis of agents. And content_blocks carries reasoning traces and citations in a provider-agnostic shape, for the providers that emit them.

So the same class is a plain container on the way in and a rich result object on the way back. That symmetry is the design: whatever a model returns, you can feed straight back as history without translating it, and nothing is lost in the round trip.

The field most worth reading today is response_metadata, because it answers a question the response text never will — which model served this, and why did it stop. A truncated answer and a complete answer look identical as prose. They do not look identical in the metadata.


Prompt Templates and Prompt Values

Messages fix the conversation. Templates fix the prompt, and they are the abstraction with the highest return in this post.

A template is a prompt with holes in it. The string-only variant is PromptTemplate:

from langchain_core.prompts import PromptTemplate

prompt = PromptTemplate.from_template("Summarise this in {n} sentences:\n\n{text}")
print(prompt.input_variables)   # ['n', 'text']
print(prompt.template_format)   # f-string

Two methods render it, and the difference between them matters more than it looks:

prompt.format(n="two", text="Hello world.")
# 'Summarise this in two sentences:\n\nHello world.'

value = prompt.invoke({"n": "two", "text": "Hello world."})
# StringPromptValue

format gives you a string. invoke gives you a prompt value, which is the small piece of indirection that makes everything in post 7 possible.

A prompt value is an object that knows how to render itself for whatever is about to consume it. It has two faces:

value.to_string()
# 'Summarise this in two sentences:\n\nHello world.'

value.to_messages()
# [HumanMessage(content='Summarise this in two sentences:\n\nHello world.', ...)]

Read the second one carefully, because it answers a question people usually guess at. A plain string prompt sent to a chat model does not arrive as some third kind of input — it is wrapped in a HumanMessage. That is the whole mechanism by which model.invoke("Explain retrieval") works at all, and knowing it explains why a bare string can never carry a system prompt.

The point of the prompt value is that the template does not need to know whether a completion model or a chat model is downstream. It hands over an object; the consumer asks for the face it wants. Without that, every prompt would need to be built differently for every kind of model, and composing them into a pipeline would mean type-matching every join by hand.

So why is any of this better than an f-string? Four reasons, and the middle two are things I verified rather than assumed.

A template knows what it needs. input_variables returns ['n', 'text']. The prompt is introspectable, which is how you validate it in a test, document it for a teammate, or build a form for it.

A template fails loudly. Forget a variable and you get told precisely what and where:

KeyError: "Input to ChatPromptTemplate is missing variables {'language'}.
Expected: ['language', 'question'] Received: ['question']
Note: if you intended {language} to be part of the string and not a variable,
please escape it with double curly braces like: '{{language}}'.
For troubleshooting, visit: https://docs.langchain.com/oss/python/langchain/errors/INVALID_PROMPT_INPUT"

An f-string with a missing variable does not fail. It quietly produces a prompt with the word None in it, the model answers something plausible, and you ship it. That failure mode is the real argument here, and it is the kind of bug that survives code review because the code looks correct.

A template can be partially filled. partial binds some variables now and leaves the rest for later, returning a new template with a smaller interface:

base = PromptTemplate.from_template("Summarise this in {n} sentences:\n\n{text}")
two_sentences = base.partial(n="two")

print(two_sentences.input_variables)     # ['text']
print(two_sentences.partial_variables)   # {'n': 'two'}
two_sentences.invoke({"text": "Hi."})

That is how a generic prompt becomes a configured one — bind the tenant's tone at startup, bind the language per request, and hand downstream code a template that only asks for the part it actually has.

A partial can also be a callable, evaluated at render time rather than bind time:

from datetime import date

prompt = PromptTemplate.from_template(
    "Today is {today}. Question: {q}",
    partial_variables={"today": lambda: date.today().isoformat()},
)

print(prompt.input_variables)   # ['q']
prompt.invoke({"q": "What day is it?"}).to_string()
# 'Today is 2026-10-11. Question: What day is it?'

The date is not baked in when the template is built. It is computed on every render, which is exactly what you want for anything time-dependent and exactly what a module-level f-string gets wrong — a prompt built at import time and reused for three weeks will cheerfully tell your model it is still the day you deployed.

And a template is a value. It can live in a config file, be loaded per tenant, be swapped in a test, be diffed in a pull request and be versioned alongside your code. A string literal inside a function call can do none of those things.


Chat Prompt Templates and Chat Prompt Values

Chat models take a list of messages, so chat templates produce one. The workhorse is ChatPromptTemplate.from_messages:

from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a support assistant who is technically correct and "
               "relentlessly sarcastic. Never be cruel. Answer in {language}."),
    ("human", "{question}"),
])

value = prompt.invoke({"language": "English", "question": "How do I reset my password?"})

The tuple shorthand is the readable form: a role and a string, where {braces} mark variables. It is sugar over real classes, which you can see by looking at what the template is holding:

print([type(m).__name__ for m in prompt.messages])
# ['SystemMessagePromptTemplate', 'HumanMessagePromptTemplate']

What invoke returns is a ChatPromptValue, and like its string cousin it has two faces:

for message in value.to_messages():
    print(type(message).__name__, "|", message.content)
# SystemMessage | You are a support assistant who is technically correct and ...
# HumanMessage  | How do I reset my password?

print(value.to_string())
# System: You are a support assistant who is technically correct and ...
# Human: How do I reset my password?

to_messages is what the model consumes. to_string is what you want in a log file, and it is the fastest way to answer "what did we actually send" without reconstructing it in your head. Getting into the habit of logging it costs one line and saves entire afternoons, because the overwhelming majority of bad model output is a bad prompt that nobody looked at.

The two template types line up like this:

PromptTemplate

ChatPromptTemplate

Builds

one string

a list of messages

invoke returns

StringPromptValue

ChatPromptValue

to_messages()

one HumanMessage

the messages, in order

to_string()

the raw string

roles prefixed, one per line

Can carry a system prompt

no

yes

from_template exists on the chat class too, and it is a shortcut for the single-human-turn case:

prompt = ChatPromptTemplate.from_template("Explain {topic} in one sentence.")
prompt.invoke({"topic": "retrieval"}).to_messages()
# [HumanMessage(content='Explain retrieval in one sentence.')]

Composition. Chat templates add. This is more useful than it first appears, because it lets a shared preamble live in one place:

house_style = ChatPromptTemplate.from_messages([
    ("system", "You are a support assistant. Be technically correct. Answer in {language}."),
])

ticket_reply = house_style + ChatPromptTemplate.from_messages([("human", "{question}")])

print(ticket_reply.input_variables)   # ['language', 'question']

The combined template is a ChatPromptTemplate with the union of both interfaces. You can also add a finished message object rather than a template, for a turn with nothing to fill in:

from langchain.messages import HumanMessage

probe = ChatPromptTemplate.from_messages([("system", "Be {tone}.")]) + HumanMessage("Summarise the above.")
print(probe.input_variables)   # ['tone']

Partials work here too, with the same semantics as the string case:

sarcastic = prompt.partial(language="English")
print(sarcastic.input_variables)   # ['question']

A slot for conversation history. Post 4 showed a conversation as a list that grows. A template handles that with a placeholder that accepts messages instead of a string:

from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.messages import HumanMessage, AIMessage

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a sarcastic but accurate support assistant."),
    MessagesPlaceholder("history"),
    ("human", "{question}"),
])

history = [
    HumanMessage("How do I reset my password?"),
    AIMessage("Click 'Forgot password'. Revolutionary, I know."),
]

value = prompt.invoke({"history": history, "question": "It didn't send the email."})

That renders to the system message, both history turns in order, then the new question. The structural win is that your instructions stay pinned at the front while history accumulates behind them — which is also the arrangement that lets the provider serve your prefix from cache, the ten-times discount from post 3.

A placeholder is required by default, so a first turn with no history raises. Mark it optional and it disappears when absent:

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a sarcastic but accurate support assistant."),
    MessagesPlaceholder("history", optional=True),
    ("human", "{question}"),
])

print(prompt.input_variables)   # ['question']
prompt.invoke({"question": "Why is the build failing?"})
# [SystemMessage, HumanMessage]

Note that history is gone from input_variables entirely — an optional placeholder is not part of the declared interface, which is both the convenience and the thing to remember when you are wondering why a typo in the key silently produced no history.

The brace trap. Braces mean variables, so a template that asks a model for JSON breaks in a confusing way:

# WRONG: {"label": "..."} is parsed as a variable named '"label"'
ChatPromptTemplate.from_messages([
    ("human", 'Return JSON like {"label": "..."} for: {topic}')
]).invoke({"topic": "a support ticket"})
KeyError: 'Input to ChatPromptTemplate is missing variables {\'"label"\'}.
Expected: [\'"label"\', \'topic\'] Received: [\'topic\']'

The error is complaining about a variable called "label" that you never wrote. Double the braces to mean a literal brace:

prompt = ChatPromptTemplate.from_messages([
    ("human", 'Return JSON like {{"label": "..."}} for: {topic}')
])
prompt.invoke({"topic": "a support ticket"})
# Return JSON like {"label": "..."} for: a support ticket

This catches almost everyone exactly once. It catches everyone who writes a prompt containing JSON, a code sample, or a CSS rule — and post 6 removes most of the reason to describe a JSON shape in a prompt at all.


Few-Shot Chat Message Prompt Templates

Some things are easier to demonstrate than to describe. Tone is one. Output format is another. Edge-case handling is the third and most underrated. You can write three paragraphs specifying exactly how terse and how sarcastic to be, or you can show two examples and let the model infer the pattern — and the examples usually win, because continuing a pattern is the thing these models are best at.

FewShotChatMessagePromptTemplate builds examples into the conversation. It takes two pieces: a template for what one example looks like, and the examples themselves.

from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate

examples = [
    {"question": "Is the server down?", "answer": "No. Have you tried looking at it?"},
    {"question": "Where are the logs?", "answer": "Exactly where you left them: /var/log."},
]

example_prompt = ChatPromptTemplate.from_messages([
    ("human", "{question}"),
    ("ai", "{answer}"),
])

few_shot = FewShotChatMessagePromptTemplate(
    example_prompt=example_prompt,
    examples=examples,
)

The few-shot block is itself a template, and it declares no variables of its own:

print(few_shot.input_variables)   # []
print([type(m).__name__ for m in few_shot.invoke({}).to_messages()])
# ['HumanMessage', 'AIMessage', 'HumanMessage', 'AIMessage']

That is what makes it composable — it contributes messages without adding anything to the surrounding template's interface. It nests directly inside the outer prompt:

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a sarcastic but accurate support assistant."),
    few_shot,
    ("human", "{question}"),
])

print(prompt.input_variables)   # ['question']
print([type(m).__name__ for m in prompt.messages])
# ['SystemMessagePromptTemplate', 'FewShotChatMessagePromptTemplate', 'HumanMessagePromptTemplate']

And it expands into real alternating turns:

SystemMessage | You are a sarcastic but accurate support assistant.
HumanMessage  | Is the server down?
AIMessage     | No. Have you tried looking at it?
HumanMessage  | Where are the logs?
AIMessage     | Exactly where you left them: /var/log.
HumanMessage  | Why is the build failing?

Read that transcript and the mechanism is obvious. The model is not being told what sarcasm means; it is being shown a conversation it is apparently already having, and asked to continue. Those AIMessage turns are the technique from earlier in this post put to deliberate use — fabricated history, presented as fact, shaping what comes next.

Which is also why the examples have to be good. A few-shot prompt does not teach the rule you had in mind, it teaches the pattern actually present in what you showed. If both your examples happen to be one sentence long, you have specified length. If both end in a period and none in a question mark, you have specified that too. Inconsistency between examples is worse than fewer examples, because the model will find some pattern, and it may not be yours.

The cost is the other thing that transcript makes obvious. Those examples are sent on every single call. Five examples at eighty tokens each is four hundred tokens of input on every request forever — which is fine, and which is precisely the constant prefix that belongs at the front of your prompt where caching can reach it. Few-shot examples are one of the strongest arguments for understanding cached input before you need it.

Choosing examples per request. When your example library grows past what you want to send every time, the static examples list becomes an example_selector and the choice is made per call:

from langchain_core.prompts import PromptTemplate
from langchain_core.example_selectors import LengthBasedExampleSelector

selector = LengthBasedExampleSelector(
    examples=examples,
    example_prompt=PromptTemplate.from_template("{question}\n{answer}"),
    max_length=25,
)

few_shot = FewShotChatMessagePromptTemplate(
    example_prompt=example_prompt,
    example_selector=selector,
    input_variables=["question"],
)

Core ships three selectors. LengthBasedExampleSelector fits as many examples as a budget allows. SemanticSimilarityExampleSelector embeds the incoming question and picks the examples closest to it, which needs a vector store and is the subject of post 8. MaxMarginalRelevanceExampleSelector does the same while penalising examples that duplicate each other, so you get coverage rather than three near-identical demonstrations.

The length-based one has a behaviour worth measuring, because it is not what the parameter name suggests. With three examples and max_length=25, the number selected depends on the question being asked:

selector.select_examples({"question": "Why?"})
# 2 examples

selector.select_examples({"question":
    "Why is the build failing on the release branch after the dependency bump?"})
# 1 example

The budget covers the whole prompt, not just the examples, so a long question crowds examples out. That is sensible — it is protecting your context window — and it is also a quiet behaviour change in your prompt driven by user input. A verbose user gets fewer demonstrations and therefore slightly different behaviour, with nothing in your code to indicate it happened.

Set the budget too low and the failure is quieter still. At max_length=10 the same selector returns zero examples: no error, no warning, just a few-shot prompt with no examples in it, behaving like a zero-shot prompt while your code says few-shot. If you use a selector, log how many examples it chose.


What You Have Now, and What Is Still Missing

The sarcastic chatbot is no longer a string in a function call. It is a template with a declared interface, a slot for history, demonstrated behaviour, and a provider chosen by configuration. It can be loaded from a file, varied per language, partially bound per tenant, composed from a shared house style, diffed in review, and tested without spending a token.

What it still cannot do is give you anything but prose. response.text is a paragraph, and a paragraph is where a pipeline stops and manual parsing begins. If the classifier from post 3 is going to write a label and an urgency score into a database, something has to turn that text into a validated object — and "parse the model's prose with a regex" is the wrong answer in a way that takes a production incident to appreciate.

That is the next gap, and it has a good answer.

Every example above was executed on 11 October 2026 against langchain 1.4.3, langchain-core 1.6.9 and langchain-openai 1.7.0, including the error messages and the measured selector counts, which are copied from real output rather than reconstructed.


🧭 What's Next

  • Post 6: Structured Output — how to get a validated Pydantic object back instead of a paragraph, why with_structured_output is the primary path in v1, and the narrower cases where an output parser is still the right tool.

Related

Leave a comment

Sign in to leave a comment.

Comments