Hello World, LangChain

A first LangChain chain, from prompt template to LCEL pipe, with a local model swap, response inspection, tests, type-checking, and tracing added around it.

Author Avatar

Fernando

  ·  9 min read

Hello World, LangChain #

A first LangChain chain, and what it teaches about prompt templates, the Runnable interface, and testing LLM code.

I spend most of my working hours thinking about agentic workflows from the outside: how MCP servers expose tools, how an agent harness decides what to call next, how a durable function keeps an AI-driven process alive across retries. This post is about the layer underneath all of that: the code that actually talks to the model. I’m working through a LangChain learning path, building as I go, in a repo I’m keeping public on purpose: langchain-course.

This covers the first project in that repo: a single chain that summarizes a short biography, plus what building it in public taught me about five things: composing steps with LCEL, swapping the underlying model, inspecting what a chat model actually returns, testing a chain without calling a real model, and keeping the whole thing type-checked and linted as it grows.

What LangChain actually is #

Stripped down, LangChain is three ideas layered on top of each other.

Prompt templates are parameterized strings, filled in at call time instead of f-string’d by hand. ChatPromptTemplate.from_template("...") takes a template string, extracts its {variable} placeholders, and wraps the whole thing as a single human message. Calling it with .from_messages([("system", ...), ("human", ...)]) instead lets a template carry multiple roles instead of just one.

Chat models are wrapped behind a common interface, BaseChatModel. ChatOpenAI, ChatOllama, ChatGoogleGenerativeAI, and every other provider integration implement the same methods, so the code that calls a model doesn’t need to know or care which provider is behind it.

Runnables and LCEL are the composition layer. Every chain component, prompt templates, chat models, output parsers, custom functions, implements the Runnable interface: a fixed set of methods (invoke, batch, stream, and their async counterparts ainvoke, abatch, astream) that every step supports uniformly. The | operator, LangChain’s LCEL (LangChain Expression Language), composes two Runnables into a RunnableSequence, which is itself a Runnable. That’s why prompt | llm can be invoked, streamed, or batched exactly like either of its parts alone, and why a three-step chain composes the same way as a two-step one. It also means retry and fallback behavior (.with_retry(), .with_fallbacks()) and LangSmith tracing apply to the composed chain automatically, without touching how the individual steps are written.

The hello-world project doesn’t need retries, fallbacks, or streaming yet. It’s still a good place to see the three primitives in isolation, before a chain starts doing more.

The chain itself #

The project setup is unremarkable and that’s the point: Python 3.12, dependencies managed with uv, provider credentials loaded from .env via python-dotenv. No infrastructure to fight before seeing a chain run.

The chain takes a block of text about a person and asks the model for a short summary plus two interesting facts. It started as a single main.py, and has since been refactored into examples/ch01_hello_chain.py, with the chain logic pulled into its own function so it can be tested independently of main():

 1from dotenv import load_dotenv
 2from langchain_core.language_models.chat_models import BaseChatModel
 3from langchain_core.prompts import ChatPromptTemplate
 4from langchain_ollama import ChatOllama
 5
 6load_dotenv()
 7
 8INFORMATION = "..."  # a long-form bio, in my case Messi's Wikipedia intro
 9
10SUMMARY_TEMPLATE = """
11    Given the information {information} about a person, I want you to create:
12    1. A short summary
13    2. Two interesting facts about them
14"""
15
16
17def summarize_person(llm: BaseChatModel, information: str) -> str:
18    prompt = ChatPromptTemplate.from_template(SUMMARY_TEMPLATE)
19    chain = prompt | llm
20    response = chain.invoke(input={"information": information})
21    return str(response.content)
22
23
24def main() -> None:
25    llm = ChatOllama(model="gemma3:270m", temperature=0)
26    print(summarize_person(llm, INFORMATION))

Four lines inside summarize_person do all the actual work. ChatPromptTemplate.from_template turns the template string into an object that knows it has one variable to fill in ({information}). prompt | llm composes the template and the model into a RunnableSequence. chain.invoke(...) fills in the template, sends the resulting message to the model, and returns an AIMessage. summarize_person takes the model as a parameter, llm: BaseChatModel, rather than constructing it internally, which is what makes the function testable: any object implementing the BaseChatModel interface, a real provider or a fake one, can be passed in.

Testing a chain without a real model call #

Once the chain logic lived in its own function, it could be tested without a network call or a running Ollama instance. langchain_core ships FakeListChatModel, a BaseChatModel implementation that returns a fixed list of canned responses instead of calling out to a provider:

 1import pytest
 2from langchain_core.language_models.fake_chat_models import FakeListChatModel
 3
 4from examples.ch01_hello_chain import ChatOllama, summarize_person
 5
 6
 7def test_summarize_person_invokes_chain_and_returns_content():
 8    fake_llm = FakeListChatModel(responses=["1. Summary. 2. Fact one. Fact two."])
 9
10    result = summarize_person(fake_llm, information="Some short bio text.")
11
12    assert result == "1. Summary. 2. Fact one. Fact two."
13
14
15@pytest.mark.integration
16def test_summarize_person_with_real_ollama():
17    llm = ChatOllama(model="gemma3:270m", temperature=0)
18
19    result = summarize_person(llm, information="Ada Lovelace was a mathematician.")
20
21    assert isinstance(result, str)
22    assert len(result) > 0

The first test asserts on the prompt/chain wiring itself, that summarize_person builds the chain correctly and returns the model’s content, without asserting anything about model quality. It runs in milliseconds and needs no credentials. The second test, marked @pytest.mark.integration and registered as a custom marker in pyproject.toml, exercises the real Ollama model and is excluded from the default test run (pytest -m "not integration") so CI doesn’t need a local model server. Full file: tests/test_ch01_hello_chain.py.

Swapping the model without touching the pipeline #

With the chain working against ChatOpenAI, the next step was to see if LangChain’s provider-swapping claim held up in practice. I pulled a small open-weights model, Gemma 3 270M, through Ollama and ran it locally:

1ollama pull gemma3:270m
2ollama run gemma3:270m   # quick sanity check in the terminal first

Back in the code, the only change was the constructor and the import:

1from langchain_ollama import ChatOllama
2
3llm = ChatOllama(model="gemma3:270m", temperature=0)

The prompt template, the prompt | llm composition, and the .invoke() call all stayed identical. That part of the claim is real: the BaseChatModel interface doesn’t distinguish between a hosted API and a model running locally, so the code that builds and invokes the chain doesn’t need to change when the provider does.

What the interface doesn’t guarantee is output parity. Gemma 3 270M responded quickly, no network round trip, no metering, but only returned the summary; the “two interesting facts” half of the instruction went unaddressed. Swapping the client is a one-line change; matching the output quality of the previous model is a separate problem the interface doesn’t solve. LangChain also has a larger model in the same open-weights family, GPT-OSS, positioned as better suited to function calling and agentic tool use, but it doesn’t fit on this machine, so that comparison isn’t run yet.

What’s actually inside the response #

Every .invoke() call against a chat model returns an AIMessage, the same class regardless of which provider served the request. .content is the field most code reaches for, but the object carries two other fields worth knowing about:

  • response_metadata: provider-specific details about the call. For ChatOpenAI, that’s token_usage (with prompt_tokens, completion_tokens, total_tokens), model_name, system_fingerprint, and finish_reason. Anthropic’s models populate a different shape (stop_reason, stop_sequence, usage) under the same field name, since this metadata is provider-specific and not normalized.
  • usage_metadata: a provider-agnostic token count, with input_tokens, output_tokens, and total_tokens in the same shape regardless of which model answered. This is the field to read when comparing cost or usage across providers, rather than parsing each provider’s own response_metadata shape.

Neither field is visible if the only thing done with a chain’s output is print(chain.invoke(...).content).

The .content field itself is typed more loosely than it looks: BaseChatModel’s return type declares content as str | list[str | dict], not plain str, since some providers can return structured content blocks. Running pyright over this project caught exactly that mismatch: summarize_person declares a -> str return type but was passing response.content through unchanged, which pyright correctly flagged as a type error. The fix in the code above, return str(response.content), makes the coercion explicit instead of relying on it happening to be a string at runtime.

Tracing with LangSmith #

Setting up tracing was three environment variables, no code changes, documented in .env.example:

LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=<your key>
LANGCHAIN_PROJECT=langchain-course

The detail that actually mattered: LangSmith defaults to a US endpoint, and I’m in Dublin, not the US. Getting traces to authenticate required also setting LANGSMITH_ENDPOINT to LangSmith’s EU endpoint, a variable not covered by .env.example since it’s region-dependent rather than universal. Skip that step outside the US and the first run fails with an authentication error that has nothing obviously to do with a wrong endpoint. Once the env vars were in place, the regular ChatOllama/ChatOpenAI objects traced automatically, no wrapper or decorator added to the chain code.

Adding .env.example also surfaced an unrelated bug in .gitignore: the existing .env.* pattern was silently ignoring .env.example along with real secret files, so the example file wouldn’t have been committed without an explicit !.env.example negation added alongside it.

Running the chain and refreshing the LangSmith dashboard showed a runnable sequence with two steps: the prompt template formatting the input, then the model call, logged as a HumanMessage in and an AIMessage out, alongside start and end time, time to first token, a success/failure status, and total tokens for the run.

The most useful comparison was two runs of the same chain shape against different models: ChatOllama with Gemma 3 270M, and ChatOpenAI(model="gpt-4o"). Same prompt, same input, different latency and token profile between the two traces, viewable side by side instead of scattered across terminal output that would otherwise need to be captured manually.

Takeaways #

The pipe operator held up under an actual test, not just a read of the docs. For anyone who’s written functional composition before (f . g in Haskell, pipe(f, g) in a JS utility library), prompt | llm reads the way you’d expect: output of the left becomes input of the right. That composition works because every step implements the same Runnable interface, so combining two Runnables produces a third one with the same shape, swappable for a fourth without touching the rest of the pipeline. The same three-line chain has now run against two different providers, one hosted, one local, with a one-line change between them.

Interface parity and output parity are separate claims. Gemma 3 270M is a drop-in replacement at the code level and a different tool at the capability level: it answered faster, with no network round trip, but silently dropped part of the instruction. LangSmith made that difference concrete rather than impressionistic, latency and token counts, side by side, per run. Testing with FakeListChatModel made a related point from a different angle: because summarize_person only depends on the BaseChatModel interface, the same function that runs against a real model in production runs against a scripted fake in CI, with no branching logic for “test mode.” The abstraction is doing real work; it just isn’t the same work as guaranteeing output quality across providers.