learn-langchain-with-phoebe / Builder session 2 of 10
Learn LangChain with Phoebe · Builder track · Session 2 of 10

Models, messages, tools

The three primitives under every agent you will ever build. Tonight you learn the one interface that makes Claude and a local model interchangeable, X-ray a conversation down to its typed blocks, and teach DataDesk to turn a messy stakeholder request into a validated form - with zero extra LLM calls.

🟢 Builder track Practitioners: DA · DE · DS · engineers Both engines from b1 ready · DataDesk v0.1 on disk 45 minutes
0-3 · Recap 3-18 · Concepts: interface, messages, tools 18-42 · Build-along: X-ray + structured output 42-45 · Q&A
Part 0

Where DataDesk stands

In b1 you built the agent loop by hand, then rebuilt it as DataDesk v0.1: one csv_stats tool inside create_agent. Tonight we open the hood on the three things that loop was juggling - the model interface, the message list, and the tool contract - because every later session (middleware, graphs, memory, evals) manipulates exactly these three objects.

Live - presented in session Self-study - read after class ★ Try it now prompt Official docs + Academy covered
★ What you walk out with today The ability to read any LangChain conversation as a typed message stack, a tool whose schema writes itself from your docstring, and DataDesk v0.2: it now parses "hey can u pull avg order value for the shop data, last quarter-ish?" into a validated Pydantic object your pipeline can actually consume.
Part 1 · covers the models + messages docs

One interface, every model 8 min live

LangChain's oldest promise is also its most defensible: one calling convention across providers. But the promise has fine print - the interface is standardized, the capabilities are not. Knowing exactly where the seam sits is what makes the one-line swap safe instead of naive.

system DataDesk's standing orders - set once, never leaves human the user's question, this turn ai content_blocks: text · reasoning · tool_call tool the result, tied back by tool_call_id ai final answer - the loop exits here the whole stack is ONE list, replayed to the model on every single turn - models are stateless; the list IS the conversation Every LangChain feature you will meet is a transformation of this stack. Learn to read it raw.
🔍 Click to zoom - message flow anatomy: a conversation is a typed message stack
Liveinit_chat_model and the provider-swap contract3 min

init_chat_model("claude-haiku-4-5-20251001") and init_chat_model("ollama:llama3.1") return objects with the same surface. What is actually in the contract:

  • Standardized: invoke (one call, full reply), stream (chunks as they generate), bind_tools (attach tool schemas), the message types in and out. Your agent code never changes.
  • Provider-specific, on purpose: what the model can DO. Strict tool schemas, extended thinking, prompt caching, PDF input - those are Claude features. Zero cost and total privacy - those are Ollama features. The swap changes capability, never syntax.
  • The mental model: LangChain standardizes the plug, not the appliance. Design your agent against the plug; choose the appliance per environment - capability in dev, privacy in the confidential-data path.
★ Try it now (any chat AI)I use two LLM engines behind one interface: a hosted frontier model and a local 8B model. List 5 concrete behaviors that might silently differ when I swap engines even though my code does not change. Rank by how likely each is to bite a data team.
The swap, one more time init_chat_model("claude-haiku-4-5-20251001")init_chat_model("ollama:llama3.1"). Every demo tonight ends by running on the other engine. That habit is the course's spine.
LiveMessages anatomy and content_blocks3 min

Four message roles carry every conversation: system (standing orders), human (the user), ai (the model - text, but also tool calls and reasoning), and tool (results going back). The 1.0 upgrade that matters is message.content_blocks:

  • The problem it solves: providers disagree about payload shape. Claude returns thinking blocks and citations one way, other providers another way. Pre-1.0 code that reached into raw content broke on every provider swap.
  • The fix: content_blocks is a provider-agnostic view - a list of typed blocks (text, reasoning, tool_call, citations) with the same shape regardless of engine. Iterate it and you never write provider-specific parsing again.
  • Why a data person should care: reasoning traces and tool calls are exactly what you log, audit, and eval later (b10). A stable schema for them is infrastructure, not sugar.
Real world

The dashboard that broke on a model upgrade. A team parsed Claude's raw response payload to display tool calls in their internal UI. A provider-side format tweak broke the display for a week. The rewrite to content_blocks was 20 lines - and survived the next three model swaps untouched.

Self-studyThe engine capability envelope3 min read

Same plug, different appliance. Know each engine's envelope before you promise features to stakeholders:

Claude via langchain-anthropicLocal via langchain-ollama
Tool callingExcellent, with strict schema modeWorks ONLY on tool-tuned models (llama3.1 yes; many models no)
Extended thinkingYes - visible as reasoning blocksModel-dependent, often absent
Prompt cachingYes - big savings on long system promptsNot applicable (no per-token bill)
Image / PDF inputYesModel-dependent, usually no
Token-usage metadataFull usage returned per callNone - budget dashboards go blind
Cost / privacyPer token · Anthropic ToSFree · nothing leaves the machine

Design rule for DataDesk: features may light up on Claude and degrade on Ollama, but the code path stays identical. If a feature is envelope-dependent (say, PDF ingestion), guard it, do not fork the codebase.

Part 2 · covers the tools + structured output docs

Tools and structured output 7 min live

Tools are how the model reaches into your world; structured output is how its answers reach into your pipelines. Both are contracts, and both are written in the same place: your function signatures and Pydantic models.

@tool csv_stats signature + docstring in your code JSON schema generated - never hand-written model emits tool_call a typed block: name + args your Python runs the model never executes code tool message back result, tied by tool_call_id final answer grounded in the real result The docstring IS the model's manual for your tool. Write it like prompt engineering, because it is.
🔍 Click to zoom - the tool-call round trip, with the schema generated from your code
LiveThe @tool decorator - schema from signature3 min

In b1 you hand-wrote a JSON schema for csv_stats. The @tool decorator from langchain.tools generates it: parameter names and types from the signature, the description from the docstring. Three consequences worth money:

  • Good docstrings ARE prompt engineering. "Row count, columns and date range of a CSV file" tells the model exactly when to reach for this tool. A lazy docstring like "gets stats" produces wrong tool choices you will misdiagnose as model stupidity.
  • Type hints are enforced upstream. path: str means the model is told to send a string. Bad inputs get caught at the schema boundary, not deep in your pandas code.
  • One decorator, both engines. The same decorated function binds to Claude and to a tool-tuned Ollama model. Capability envelope applies: on a non-tool-tuned local model, bind_tools simply cannot work.
The docstring that causes 2am pages@tool def csv_stats(path: str) -> dict: """gets stats""" # which stats? of what? when should the model call it?
LiveStructured output - in the main loop, no second call3 min

Free text is for humans; your scripts need fields. Pass a Pydantic model as response_format to create_agent and the final answer arrives as a validated object:

  • The 1.x win: the structured response is generated in the agent's main loop. The 0.x pattern - run the agent, then fire a second LLM call to reformat the answer - is gone. One loop, fewer tokens, no drift between the answer and its structured version.
  • Validation is Pydantic's, not the model's: if the model emits a field that fails your types, you find out immediately, at the boundary, with a Python exception you can catch.
  • Read it from result["structured_response"] - it sits alongside the normal message list, so you keep the conversational trace too.
Self-studyWhen structured output beats free text2 min read

The decision rule is one sentence: if anything other than a human reads the answer, structure it.

Consumer of the answerFormatExample
A human, reading in chatFree text"Explain why signups dipped in March"
A script, a pipeline, a dashboardPydantic via response_formatDataRequest(dataset, metric, date_range)
A downstream tool callStructured - alwaysParsed request feeding csv_stats
An audit log or eval suiteStructured - alwaysVerdicts with reason codes, b10

Heuristic for data teams: every "can you pull me..." message from a stakeholder is secretly a form. Tonight's Demo 2 makes that literal.

Demo 1 of 2

X-ray a conversation ★ 12 min · everyone builds

You will send one message, bind one tool, and read the raw typed blocks the model actually returns - on both engines. After this, no LangChain response is ever opaque to you again.

In your DataDesk folder from b1, create xray.py with the script below. It uses your existing csv_stats function body.

Run it on the Claude engine. First X-ray: a plain reply - expect a single text block (and, if extended thinking is on, a reasoning block before it).

Second X-ray: the tool-armed call. Find the tool_call block - name and arguments, exactly the schema your docstring generated. This block is what the while-loop from b1 was pattern-matching on.

Now the swap: change one line to init_chat_model("ollama:llama3.1") and re-run. Diff what changes: block shapes identical, capability different - likely no reasoning block, and no token-usage metadata at all.

Say the conclusion out loud: content_blocks is the same language on both engines; the engines just have different vocabularies.

★ xray.py - the whole scriptfrom langchain.chat_models import init_chat_model from langchain.tools import tool @tool def csv_stats(path: str) -> dict: """Row count, columns and date range of a CSV file.""" ... # your b1 function body, unchanged model = init_chat_model("claude-haiku-4-5-20251001") # swap: "ollama:llama3.1" # X-ray 1: plain reply reply = model.invoke("In two sentences: why do data teams version their datasets?") for block in reply.content_blocks: print("[1]", block["type"], "·", str(block)[:90]) # X-ray 2: tool-armed reply armed = model.bind_tools([csv_stats]) ask = armed.invoke("How many rows does data.csv have?") for block in ask.content_blocks: print("[2]", block["type"], "·", str(block)[:90]) # expect a tool_call block
If the Ollama run has no tool_call block Check the model: bind_tools only works on tool-tuned models. llama3.1 qualifies; many pulls do not. This is the capability envelope from Part 1, live.
Demo 2 of 2

DataDesk learns to fill forms ★ 10 min · build your own

A stakeholder writes "hey can u pull avg order value for the shop data, last quarter-ish? thx". DataDesk v0.2 turns that into a validated DataRequest object - dataset, metric, date_range - ready to feed a real pipeline.

Define the form: a Pydantic DataRequest model with dataset, metric, date_range. This class IS the contract between vague humans and exact pipelines.

Pass it to create_agent as response_format=DataRequest. No parsing prompt, no second LLM call - the structured answer is produced inside the main loop.

Invoke with the messy request. Print result["structured_response"] - three clean validated fields out of one sloppy sentence.

Validate the win: feed req.dataset into csv_stats directly. The agent's answer just became an argument to your code - that handoff is the whole point of structured output.

Engine-swap test: same file on ollama:llama3.1. Local models are sloppier at edge cases - watch whether "last quarter-ish" survives. That gap is eval material for b10.

★ DataDesk v0.2 - the whole filefrom pydantic import BaseModel from langchain.agents import create_agent from langchain.chat_models import init_chat_model from langchain.tools import tool @tool def csv_stats(path: str) -> dict: """Row count, columns and date range of a CSV file.""" ... # unchanged from b1 class DataRequest(BaseModel): dataset: str # which dataset the stakeholder means metric: str # what they want computed date_range: str # the window, normalized agent = create_agent( model=init_chat_model("claude-haiku-4-5-20251001"), # or "ollama:llama3.1" tools=[csv_stats], system_prompt="You are DataDesk, a data team assistant. Be precise.", response_format=DataRequest, ) result = agent.invoke({"messages": [{"role": "user", "content": "hey can u pull avg order value for the shop data, last quarter-ish? thx"}]}) req = result["structured_response"] print(req.dataset, "·", req.metric, "·", req.date_range)
Real world

The intake queue that stopped being a queue. A data team's #data-requests channel averaged two clarification round trips per ask. They put a structured-output parser in front: every request became a typed object or a single auto-generated question ("which dataset - orders or web analytics?"). Clarification round trips dropped by half before anyone touched a model bigger than Haiku.

Homework

Try it yourself - this week ◐ 30-45 min total

Source material

Official sources covered

This track teaches from the official docs and the free LangChain Academy curricula (login required for lesson content; certificates stay with the Academy - all free). This page covers:

LangChain docs: models, messages, toolsParts 1-2 · init_chat_model, content_blocks, @tool, response_format
Academy: Intro to LangChain (M1)Model + tool foundations covered; the module's agent project completes in b3
langchain-anthropic + langchain-ollama integration docsCapability envelope table + both engine paths in every demo
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What exactly does init_chat_model standardize across Claude and Ollama?

LangChain standardizes the plug, not the appliance. The swap changes capability, never syntax - which is why you test on both engines every session.

2 · Why read message.content_blocks instead of the raw provider payload?

Reasoning traces and tool calls differ per provider in the raw payload. content_blocks gives them one stable schema - which is also what you log and eval later.

3 · How does structured output work in LangChain 1.x agents?

The extra-call pattern is the 0.x museum. One loop, validated at the boundary by Pydantic, no drift between answer and structure.

Builder session 2 cheat sheet · pin this

The contractinit_chat_model standardizes invoke / stream / bind_tools + message types. Capabilities stay per-provider.
Message stacksystem → human → ai → tool → ai. One list, replayed every turn. The model is stateless; the list is the conversation.
content_blocksProvider-agnostic typed view: text, reasoning, tool_call. Iterate this, never the raw payload.
@tool ruleSchema from signature + docstring. The docstring is the model's manual - write it like prompt engineering.
Structured outputresponse_format=YourPydanticModel on create_agent · read result["structured_response"] · main loop, no second call.
When to structureIf anything other than a human reads the answer, structure it. Stakeholder asks are secretly forms.
Envelope watchOllama: tools only on tool-tuned models, no usage metadata. Claude: strict tools, thinking, caching, PDFs.
Running projectDataDesk v0.2 parses messy asks into DataRequest. Next: b3 gives it more tools and a real system prompt.