Lab 11: LangChain¶
What You'll Learn¶
- How the hand-written agent from Lab 2 maps onto LangChain 1.x
- How to define tools with
@tooland build an agent withcreate_agent - How LangChain middleware replaces the step limit and approval gate
- What a framework gives you, and what it costs
The Same Agent, Different Code¶
You already know what an agent does. This lab rebuilds the Lab 2 agent in LangChain so you can see which parts the framework takes over:
| Lab 2 (hand-written) | LangChain 1.x |
|---|---|
TOOLS list of JSON Schemas |
@tool functions. The schema comes from type hints and the docstring. |
client.messages.create(...) |
ChatAnthropic(model=...) |
The for step in range(...) loop |
create_agent(...), which runs a LangGraph graph |
MAX_STEPS |
ModelCallLimitMiddleware(run_limit=15) |
approve() callback |
HumanInTheLoopMiddleware plus a checkpointer to pause and resume |
messages list |
Graph state, saved by the checkpointer per thread_id |
Step 1: Write the Agent¶
Save this as langchain_agent.py, next to tools.py and agent.py from Lab 2:
"""Lab 11: the same agent in LangChain, with the same guardrails as middleware."""
import os
import sys
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware, ModelCallLimitMiddleware
from langchain.tools import tool
from langchain_anthropic import ChatAnthropic
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
import tools
from agent import SYSTEM
def safe(fn, *args):
# Same rule as Lab 2: a tool failure goes back to the model as text, not as a crash.
try:
return fn(*args)
except tools.ToolError as exc:
return f"ERROR: {exc}"
@tool
def list_files(path: str = ".") -> str:
"""List every file under a directory of the incident workspace, with sizes."""
return safe(tools.list_files, path)
@tool
def read_file(path: str, start_line: int = 1, max_lines: int = 200) -> str:
"""Read a text file from the incident workspace with numbered lines. At most 500 lines per call."""
return safe(tools.read_file, path, start_line, max_lines)
@tool
def search_files(pattern: str, path: str = ".") -> str:
"""Search files for a case-insensitive regular expression. Returns file:line: text, at most 50 matches."""
return safe(tools.search_files, pattern, path)
@tool
def restart_service(service: str, reason: str) -> str:
"""Restart a production service. A human must approve every call, and may decline."""
return tools.restart_service(service, reason)
model = ChatAnthropic(
model=os.environ.get("AGENT_MODEL", "claude-opus-5-5"),
max_tokens=16000,
output_config={"effort": "medium"},
# No temperature: current Claude models reject it. See Lab 3.
)
agent = create_agent(
model,
tools=[list_files, read_file, search_files, restart_service],
system_prompt=SYSTEM,
middleware=[
ModelCallLimitMiddleware(run_limit=15, exit_behavior="end"), # Lab 2: MAX_STEPS
HumanInTheLoopMiddleware(interrupt_on={"restart_service": True}), # Lab 2: approve()
],
checkpointer=InMemorySaver(), # needed to pause for approval and resume
)
if __name__ == "__main__":
task = " ".join(sys.argv[1:]) or "Investigate the checkout alert. What is the root cause?"
config = {"configurable": {"thread_id": "checkout-incident"}}
result = agent.invoke({"messages": [{"role": "user", "content": task}]}, config, version="v2")
while result.interrupts: # paused on restart_service
decisions = []
for action in result.interrupts[0].value["action_requests"]:
answer = input(f"\nApprove {action['name']}({action['args']})? [y/N] ")
if answer.strip().lower() == "y":
decisions.append({"type": "approve"})
else:
decisions.append({"type": "reject", "message": "A human declined. Do not retry."})
result = agent.invoke(Command(resume={"decisions": decisions}), config, version="v2")
for message in result.value["messages"]:
for call in getattr(message, "tool_calls", []):
print(f"[tool] {call['name']} {call['args']}")
print("\n" + result.value["messages"][-1].text)
The parts that matter:
@toolfunctions wrap the Lab 2 tools. The docstring becomes the description the model reads, so write it as carefully as you did the JSON descriptions.safe()keeps the Lab 2 rule: an expected failure goes back to the model as text instead of stopping the run.ChatAnthropictakesoutput_config={"effort": "medium"}like the raw SDK. There's notemperature. Lab 3 explains why setting one makes the request fail.HumanInTheLoopMiddlewarepauses the graph beforerestart_serviceruns. Pausing needs a checkpointer, which saves the graph's state soCommand(resume=...)can continue it.version="v2"makesinvoke()return aGraphOutputwith.valueand.interrupts, instead of a dict with a special__interrupt__key.
Step 2: Run It¶
A typical run:
[tool] list_files {'path': '.'}
[tool] search_files {'pattern': 'error|timed out', 'path': 'logs'}
[tool] read_file {'path': 'deploys.json', 'start_line': 1, 'max_lines': 200}
The checkout alert fired because release 2.14.0 reduced DB_POOL_SIZE from 20 to 5...
Now ask for an action:
When the model calls restart_service, the graph stops and your loop asks you:
If you answer n, the model gets this as an error result:
If you answer y, the tool runs and actions.log gets a line. That's the same behaviour as Lab 2, in about the same amount of code.
Step 3: Add More Middleware¶
Middleware is where LangChain saves you real work. Each of these is a one-line addition to the middleware list:
| Middleware | Does | Lab equivalent |
|---|---|---|
ToolCallLimitMiddleware |
Caps calls to one tool or all tools | Per-tool budgets |
ModelFallbackMiddleware |
Retries on another model when a call fails | Not built in the labs |
ToolRetryMiddleware, ModelRetryMiddleware |
Retries failed calls with backoff | The SDK's built-in retries |
PIIMiddleware |
Redacts or blocks patterns such as emails | Not built in the labs |
SummarizationMiddleware |
Replaces old messages with a summary | Lab 3, Part 3 |
Test history-rewriting middleware on your model
SummarizationMiddleware and similar middleware rewrite earlier messages. Lab 3 explains why current Claude models can reject a request whose earlier turns were edited. Before you rely on it, run a long conversation with it enabled on the exact model you'll use, and check for 400 errors. Server-side context editing is the safer way to shrink context on Claude.
Framework or Hand-Written?¶
| Hand-written loop (Lab 2) | LangChain | |
|---|---|---|
Lines of code, not counting tools.py |
About 95 (agent.py) |
About 85 (langchain_agent.py) |
| Pause, persist, and resume a run | You build it | Checkpointers do it |
| Switch model provider | Rewrite the API calls | Change the model class |
| Debug "why did it do that?" | Read your own loop | Read the graph, the middleware, and the provider adapter |
| Upgrades | Only the Anthropic SDK | LangChain, LangGraph, and langchain-anthropic together |
| New Claude API features | Available immediately | Available when the adapter supports them |
Choose LangChain when you need what it adds: durable runs that survive restarts, LangGraph workflows with branches and human steps, LangSmith tracing, or several providers behind one interface. For one focused agent, a hand-written loop is often easier to own.
What Most Engineers Miss¶
- LangChain 1.x is a different library from the one in most tutorials.
AgentExecutor,initialize_agent, and LangGraph'screate_react_agentare replaced bylangchain.agents.create_agentand middleware. Check the date on any example you copy. - The framework still calls the same API. Every limit from Lab 2 still applies: step limits, output caps, read-only tools, error results. Middleware makes them easier to add, not optional.
- Adapters pass through what you configure. If a shared config sets
temperature=0for every model,ChatAnthropicsends it, and the Claude API rejects it. - Checkpointers store everything. The saved state holds every tool result, including log lines and anything sensitive in them. Treat the checkpointer's database like a log store.
Human in the loop
Why does HumanInTheLoopMiddleware need a checkpointer?
- To count how many times the tool was called
- To save the paused run's state so it can continue after a human decides
- To store the approval decisions for auditing
The graph stops at the interrupt. The checkpointer keeps the messages and position, so Command(resume=...) can pick up where it stopped.
Mapping
Which LangChain piece replaces Lab 2's MAX_STEPS?
-
ToolCallLimitMiddleware -
ModelCallLimitMiddleware -
InMemorySaver
MAX_STEPS limits model calls per run. ToolCallLimitMiddleware limits tool calls, which is a different budget.
Common Mistakes¶
- Copying LangChain 0.x examples with
AgentExecutorinto a 1.x project. - Setting
temperatureinChatAnthropicfor a current Claude model. - Using
HumanInTheLoopMiddlewarewithout a checkpointer, or reusing onethread_idfor unrelated runs. - Adding summarization or trimming middleware without testing it against the model's history rules.
- Assuming the framework adds safety. Read-only tools and credentials are still your job.
Interview Questions¶
- What does an agent framework like LangChain do for you? What does it cost?
- How does LangChain implement human-in-the-loop approval?
- When would you choose a hand-written loop over a framework?
- How would you upgrade an agent from LangChain 0.x to 1.x safely?
Next¶
Continue to Lab 12: LiteLLM.