You’ve been using HumanMessage, SystemMessage, and AIMessage since Module 1, always in small doses, always in service of some other point. This module is where we finally slow all the way down and give them the proper, dedicated attention they deserve — because a real, working conversation is really just a carefully built list of these objects, and understanding that list properly will make almost everything in the rest of this course easier.
Why a conversation isn’t just a list of strings
Here’s a genuinely reasonable question to start with: why can’t a conversation just be a Python list of plain strings, like ["What is RAG?", "RAG stands for...", "Explain simpler"]?
The problem is that a list like that tells you what was said, but not who said it. Was “Explain simpler” from the human, or did the model say it? A model reading that list has no reliable way to know. And a real conversation needs a third kind of voice too — your own instructions, as the developer, telling the model how to behave overall, which the user never even sees.
This is exactly the problem the role concept, briefly introduced back in Module 1, exists to solve. Every message needs a clear, unambiguous label saying who it came from. LangChain represents this not as a raw dictionary with a "role" key, but as a proper set of distinct message classes — one class per role. Let’s meet each one properly.
Example 1: HumanMessage
from langchain.messages import HumanMessage
msg = HumanMessage(content="What is the tallest mountain in the world?")
print(msg.content)
print(type(msg))
A HumanMessage represents exactly what it sounds like: something a real person typed. When you pass a plain string to .invoke(), as you did back in Module 4, LangChain is quietly constructing one of these for you behind the scenes.
Example 2: SystemMessage
from langchain.chat_models import init_chat_model
from langchain.messages import SystemMessage, HumanMessage
model = init_chat_model("openai:gpt-4o-mini")
messages = [
SystemMessage(content="You are a pirate. Answer every question while staying fully in character."),
HumanMessage(content="What is the tallest mountain in the world?"),
]
response = model.invoke(messages)
print(response.content)
A SystemMessage is fundamentally different from a HumanMessage — it’s never something a real user typed, and the user never sees it in the interface. It’s your own instruction, as the developer, shaping how the model behaves for the entire conversation. Run this example, and notice the model’s answer comes back in full pirate voice, even though the actual question had nothing to do with pirates at all. That’s the SystemMessage doing its job.
A practical rule worth remembering: a SystemMessage, when you use one, almost always belongs first in your message list — it sets the stage before anything else happens.
Example 3: AIMessage — and why you’d ever construct one yourself
You already know AIMessage is what comes back from .invoke(). What’s new here is realizing you can also construct one yourself, by hand, to pre-load a conversation’s history — useful when you’re restoring a saved conversation, or writing a test.
from langchain.chat_models import init_chat_model
from langchain.messages import SystemMessage, HumanMessage, AIMessage
model = init_chat_model("openai:gpt-4o-mini")
# this history didn't come from a real .invoke() call —
# we're constructing it by hand, as if this conversation already happened
messages = [
SystemMessage(content="You are a friendly, encouraging math tutor."),
HumanMessage(content="What's 7 times 8?"),
AIMessage(content="7 times 8 is 56! Nice question."),
HumanMessage(content="What about 7 times 9?"),
]
response = model.invoke(messages)
print(response.content)
The model has no way of knowing that third message wasn’t a genuine reply it gave a moment ago — from its perspective, an AIMessage is an AIMessage, whether it was generated moments ago or written by your own code. This is a genuinely useful thing to know: you can hand a model a fully invented backstory of a conversation, and it will simply continue from there.
Example 4: building a real, growing conversation
Let’s slow down and build a full, multi-turn conversation properly, the way a real chat application actually needs to — appending each new message as the conversation genuinely progresses.
from langchain.chat_models import init_chat_model
from langchain.messages import SystemMessage, HumanMessage
model = init_chat_model("openai:gpt-4o-mini")
conversation = [SystemMessage(content="You are a concise, friendly assistant.")]
def ask(question: str) -> str:
conversation.append(HumanMessage(content=question))
reply = model.invoke(conversation)
conversation.append(reply) # reply is already an AIMessage — no conversion needed
return reply.content
print(ask("What's the capital of Italy?"))
print(ask("What's a famous dish from there?"))
print(ask("How many messages have we exchanged so far?"))
Try running this and asking that last question. The model correctly answers it, because the entire real conversation — every question and every reply — is genuinely present in conversation by that point. This is the actual mechanism behind every chat app’s “memory” you’ve ever used. There’s no separate memory system running in the background; it’s this same growing list, sent in full, every single time.
Example 5: ToolMessage — a message type you haven’t met yet
Back in Module 4, you saw a model respond with response.tool_calls instead of plain text. Once your code actually runs that tool and gets a real result, that result needs to go back into the conversation, using a message type built specifically for this purpose: ToolMessage.
from langchain.messages import ToolMessage
# imagine the model asked to call get_temperature(city="Tokyo"),
# and our own code actually ran it and got a real result
tool_result = ToolMessage(
content="It's 21°C in Tokyo right now.",
tool_call_id="call_abc123", # must match the model's original request
)
print(tool_result.content)
Notice the tool_call_id. This is genuinely important, and easy to overlook: when a model requests several tool calls at once, each ToolMessage you send back needs to reference which specific request it’s answering, using that matching ID. We’ll build this full cycle properly, end to end, in the upcoming Tool Calling module — for now, just recognize this message type and understand its one job: carrying a real tool’s result back into the conversation.
Example 6: what’s actually inside a message, beyond .content
Just like the AIMessage you inspected back in Module 4, every message type carries more than just its text. Let’s look properly.
from langchain.messages import HumanMessage
msg = HumanMessage(
content="What's the weather like today?",
name="alex", # optional — useful in multi-user conversations
additional_kwargs={"source": "mobile_app"}, # your own custom metadata
)
print("Content:", msg.content)
print("Sender name:", msg.name)
print("Custom metadata:", msg.additional_kwargs)
print("Unique ID:", msg.id)
name is genuinely useful in an application where several different real users share one conversation, letting the model (and your own code) distinguish who said what beyond the generic “human” role. additional_kwargs is a free space for any extra information you want to carry alongside a message — LangChain doesn’t interpret it; it’s yours to use however your application needs.
Example 7: multimodal messages — sending an image alongside text
Every message type you’ve seen so far carried plain text in .content. But .content doesn’t have to be a single string — it can also be a list of separate content pieces, called content blocks, which is exactly how you send an image and text together in a single message.
from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage
model = init_chat_model("openai:gpt-4o-mini")
message = HumanMessage(
content=[
{"type": "text", "text": "What's happening in this image?"},
{"type": "image_url", "image_url": "https://picsum.photos/id/237/300/200"},
]
)
response = model.invoke([message])
print(response.content)
Notice content is a list here, not a plain string — each item in that list is one content block, tagged with a "type", so the model knows to interpret one piece as text and the other as an image. This same pattern extends to audio and other media, on models that support it. The specific keys expected inside each block can vary slightly by provider, which is exactly the kind of provider-specific detail Module 5 warned you to actually check, rather than assume, before building a real feature around it.
Common mistakes worth avoiding
Forgetting to append the model’s reply back into the conversation list. This is the single easiest mistake to make with messages, and you actually saw the fix for it back in Example 4’s ask() function. If you call .invoke() again without adding the previous AIMessage back into your list, the model genuinely has no memory of what it just said — even though you remember, because you were looking at the output a moment ago. The model only knows what’s actually in the list you send it, every single time.
Mismatching, or forgetting, tool_call_id on a ToolMessage. Recall Example 5 — when a model requests multiple tool calls at once, each ToolMessage you send back has to reference the exact ID of the request it’s answering. Get this wrong, and the model can’t correctly match your tool’s result to its original request, which typically produces a confusing error rather than a silently wrong answer.
Mixing up .content as a string versus .content as a list of content blocks. Recall Example 7 — a plain-text message has .content as a simple string, but a multimodal message has .content as a list of typed blocks. Code that assumes .content is always a string (for example, calling ordinary string methods on it) will break the moment it receives a multimodal message instead. If your application might handle both, check the type before assuming its shape.
What you should take away from this module
- A conversation is a list of message objects, each labeled with a role, not a list of plain strings — because “who said this” genuinely matters to a model.
SystemMessageshapes overall behavior and belongs first in the list;HumanMessageis real user input;AIMessageis the model’s own reply — and you can construct any of these by hand, not just receive them.ToolMessagecarries a real tool’s result back into the conversation, matched to the model’s original request viatool_call_id.- Every message carries more than
.content—name,additional_kwargs, and a uniqueidare all genuinely available and useful. .contentcan be a plain string, or a list of content blocks, which is how multimodal input — text plus an image, for instance — gets represented in a single message.
Where this goes next
The next module turns to Prompt Templates — taking the raw message-building you just did by hand and turning it into clean, reusable components you can parameterize and reuse across your entire application, rather than retyping message lists every time.
- Author
- TechByteByByte Editorial Team
- Reviewed by
- TechByteByByte Admin
- Published
- Last reviewed