Orchestration: LangChain & LangGraph
When a framework earns its keep — and the LangGraph core loop: state, nodes, edges, checkpoints, human-in-the-loop.
Decide if you need a framework at all
The stack: LangChain provides integrations and abstractions (models, retrievers, tools); LangGraph — the part that matters for agents — models your app as a graph of nodes sharing typed state, with persistence built in. Both hit 1.0 in late 2025. The honest decision rule: a raw tool-loop (aa-03) for simple agents; LangGraph when you need durable state, branching, resumability, or human approval mid-run; alternatives (OpenAI Agents SDK, Pydantic AI, CrewAI) are real — the concepts here transfer to all of them.
VerifyYou can name the three features that justify a graph framework: persistence, branching control flow, human-in-the-loop.Model the app as state + nodes + edges
Nodes are functions that read and update shared typed state; edges (including conditional ones) decide what runs next. This makes control flow explicit and testable:
from langgraph.graph import StateGraph, START, END from typing import TypedDict class State(TypedDict): question: str draft: str approved: bool def research(state: State) -> dict: ... def write(state: State) -> dict: ... def review(state: State) -> dict: ... # sets approved g = StateGraph(State) g.add_node("research", research); g.add_node("write", write); g.add_node("review", review) g.add_edge(START, "research"); g.add_edge("research", "write"); g.add_edge("write", "review") g.add_conditional_edges("review", lambda s: END if s["approved"] else "write") app = g.compile()VerifyYou can trace the write→review→write loop in the code — the evaluator-optimizer pattern from aa-11, now explicit and inspectable.Add checkpointing and a human gate
Compile with a checkpointer and every step persists — runs survive crashes, and you can interrupt for approval and resume later:
from langgraph.checkpoint.sqlite import SqliteSaver app = g.compile( checkpointer=SqliteSaver.from_conn_string("state.db"), interrupt_before=["publish"], # pause for human approval ) cfg = {"configurable": {"thread_id": "run-42"}} app.invoke({"question": q}, cfg) # runs until the gate # human approves out-of-band, then: app.invoke(None, cfg) # resumes exactly where it pausedVerifyKill the process mid-run and invoke again with the same thread_id — it resumes from the checkpoint instead of restarting.Keep the framework honest
Framework failure modes: abstraction layers that hide which prompt actually ran (log assembled prompts anyway — aa-06), version churn (pin versions; test upgrades against your evals), and graph spaghetti (if your graph needs a diagram to survive review, simplify it). The framework structures your control flow; quality still comes from the context you assemble and the evals you run.
VerifyYour traces show the exact prompt each node sent — the framework never became a black box.