Going deeper into prompts and chat models: PromptTemplate versus ChatPromptTemplate, system and human messages, few-shot prompting, input variables, per-provider model profiles, and content-block centric v2/v3 streaming with version="v3" in stream_events.

In episode 3, you successfully ran a model and mastered all the invocation modes. The quality of an LLM's output isn't only determined by the model — it's actually determined to a large degree by how we talk to it. Episode 4 focuses on two things: composing prompts correctly and making the most of modern streaming.
We'll dissect PromptTemplate and ChatPromptTemplate, understand system and human messages, apply few-shot prompting, then move into the more cutting-edge features of v1.x: per-provider model profiles and v2/v3 content-block streaming.
In episode 2 we already touched on ChatPromptTemplate. Now let's dissect it further, including the simpler version: PromptTemplate.
| Template | Best for |
|---|---|
PromptTemplate | A single block of text without message structure |
ChatPromptTemplate | A system/human/assistant message sequence in chat-API style |
PromptTemplate is enough for simple tasks — one instruction with a few variables:
from langchain_core.prompts import PromptTemplate
template = PromptTemplate.from_template(
"Buat paragraf promosi {produk} untuk audiens {audiens}."
)
pesan = template.invoke({"produk": "kopi robusta", "audiens": "anak muda"})
print(pesan.text)For chat applications, message structure matters much more. ChatPromptTemplate arranges a sequence of roles: system defines global behavior and context, human is the user's input, and assistant is the model's answer. Modern models follow system instructions extremely well — this is where the personality and constraints of your application are defined:
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "Kamu adalah asisten keuangan yang selalu menanggapi dengan nada profesional dan menolak memberi saran investasi berisiko."),
("human", "Bolehkah saya menaruh seluruh tabungan ke kripto?"),
])When invoke is called on this template, LangChain assembles a list of role-based messages sent to the model. The example above doesn't use variables — to make it dynamic, use placeholders inside the strings as you saw in episode 2.
Sometimes instructions alone aren't enough — the model needs examples. Few-shot prompting inserts several input-output examples so the model understands the desired pattern. In ChatPromptTemplate, examples are inserted as paired human/assistant messages:
contoh = [
("human", "Produk ini 'bagus'."),
("assistant", "Tone: netral. Peringkat: 3/5"),
("human", "Produk ini 'luar biasa'!"),
("assistant", "Tone: antusias. Peringkat: 5/5"),
]
prompt = ChatPromptTemplate.from_messages([
("system", "Klasifikasikan sentimen ulasan produk."),
*contoh,
("human", "Produk ini 'mengecewakan'."),
])
llm = ChatOpenAI(model="gpt-4o-mini")
print(llm.invoke(prompt.invoke({})).content)Notice how the example list is unpacked with the * operator — each pair becomes its own message. The model now has a "benchmark" of the expected answer format, making the output far more consistent than a plain text instruction alone.
Placeholders inside a template are input variables — values that must be supplied at invoke time. LangChain validates that all variables are filled in; if any are missing, it throws an error. You can also provide default values with partial, so that variable doesn't need to be sent again every time:
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "Kamu adalah {peran} yang bekerja di perusahaan {perusahaan}."),
("human", "{pertanyaan}"),
])
prompt_parsial = prompt.partial(perusahaan="TechNusa")
pesan = prompt_parsial.invoke({"peran": "data engineer", "pertanyaan": "Apa itu pipeline?"})
print(pesan.to_messages())With partial, the perusahaan value is bound to the template, and only peran plus pertanyaan need to be supplied at invoke time. Useful for templates reused in the same context.
One of the interesting features in v1.x is model profiles — centralized profiles that describe models from various providers (name, tool calling capabilities, context, and so on). Instead of memorizing each model's parameters, you use standardized profiles:
from langchain_hub import create_model_profile
from langchain_openai import ChatOpenAI
profil = create_model_profile("openai:gpt-4o")
llm = ChatOpenAI(llm_profile=profil, temperature=0)The advantages: your application code stays clean, and moving between providers only requires changing the profile string — for example to anthropic:claude-3-5-sonnet — without rewriting logic. In episode 16 we'll use profiles for cross-model fallback strategies.
In episode 3 we saw basic llm.stream streaming, which flows text chunks. But the latest generation of models doesn't just emit text — they also emit content blocks: text, tool calls, and reasoning that occur within a single response. Handling these blocks well is the focus of v2/v3 streaming.
Version v3 introduces block-centric event streaming through stream_events. A real example — monitoring the flowing text blocks:
async def pantau_chain():
chain = prompt | ChatOpenAI(model="gpt-4o")
async for event in chain.astream_events({"pertanyaan": "Jelaskan RAG."}, version="v3"):
if event["event"] == "on_chat_model_stream_block":
blok = event["data"]["block"]
if blok["type"] == "text":
print(blok.get("text", ""), end="", flush=True)
asyncio.run(pantau_chain())What happens here: each on_chat_model_stream_block event carries one block; we filter for blocks of type text and display them. For blocks of type tool_call, you can extract the tool arguments the model is preparing — a pattern we'll use fully when building agents in episodes 8 and 13.
Info
Explicitly setting version="v3" keeps your event streaming contract stable against future changes. Without this argument, the default behavior can change depending on the installed langchain-core version — something that makes debugging in production harder.
Episode 4 honed your ability to communicate with models: choosing PromptTemplate or ChatPromptTemplate, composing system and human messages, applying few-shot prompting, binding variables with partial, using per-provider model profiles, and handling v2/v3 content-block streaming with version="v3".
Key takeaways:
ChatPromptTemplate composes a sequence of role-based messages; the system prompt determines model behavior.partial binds default values to the template.astream_events(version="v3").In episode 5 we move into very important material — LCEL: chains and composition. You'll build real pipelines with RunnableSequence, RunnableParallel, RunnablePassthrough, RunnableLambda, and RunnableMap, plus the invoke/batch/stream lifecycle, event streaming, error handling, and retries. See you there!