{"slug": "learning-langgraph-a-journey-through-agents-blackboards-and-bottlenecks", "title": "Learning LangGraph : A Journey Through Agents, Blackboards, and Bottlenecks", "summary": "A developer's journey building a two-agent LangGraph pipeline reveals that data transfer between agents can happen either through the model (re-typing via ToolMessage) or through a typed blackboard state in a StateGraph, where data passes directly as Python values without LLM involvement. The minimum agent requires four pieces: a model, tools, a system prompt, and create_agent, with LangChain handling the think-call-observe loop automatically.", "body_md": "I set out to learn LangChain/LangGraph agents by building something small — a two-agent pipeline where one agent researches a topic and another writes it up. What follows is the path that took, in the order I actually hit each wall, not a cleaned-up curriculum. If you’re starting the same climb, the confusion points below are probably yours too.\n\nBefore any of the interesting problems, I needed the smallest thing that could be called an “agent.” It turns out to be four pieces: a model, some tools, a system prompt, and create_agent to wire them together.\n\n``` python\nfrom langchain.agents import create_agent          # langchain 1.x (NOT langgraph.prebuilt)from langchain.tools import toolfrom langchain_anthropic import ChatAnthropic# 1. Modelllm = ChatAnthropic(model=\"claude-haiku-4-5\", max_tokens=1024)# 2. Tool — a plain function + @tool.#    - type hints (drug_name: str) become the input schema#    - the docstring is what the LLM reads to decide WHEN to call it#    The model only \"sees\" the signature + docstring, so write the docstring#    like an API description.@tooldef get_drug_targets(drug_name: str) -> str:    \"\"\"Return the protein targets for a given drug name.\"\"\"    return \"EGFR, BRAF\"   # real data-source call goes here# 3. System prompt — a plain stringSYSTEM_PROMPT = \"You are a research assistant. Use the tools to gather evidence.\"# 4. Build the agentagent = create_agent(model=llm, tools=[get_drug_targets], system_prompt=SYSTEM_PROMPT)# 5. Run it — pass in {\"messages\": [...]}, get the full message list backresult = agent.invoke({\"messages\": [{\"role\": \"user\", \"content\": \"What does metformin target?\"}]})print(result[\"messages\"][-1].content)   # last message = final answer\n```\n\nLangChain runs the think → call tool → observe → repeat loop for you — you never write that loop yourself. The mental model that stuck: **you write tools, LangChain runs the loop.** Input and output are both a {\"messages\": [...]} dict; the agent appends its tool calls, tool results, and final answer to that list as it works.\n\nA few things bit me early, in case they bite you too:\n\nTo prove to myself I actually understood the four pieces — not just copy-pasted them — I wrote a throwaway calculator tool and forced the agent to use it:\n\n``` php\n@tooldef calculator(expression: str) -> str:    \"\"\"Evaluate a basic arithmetic expression like '2 + 2 * 3'.\"\"\"    return str(eval(expression))   # fine for learning; never eval untrusted input in prod\n```\n\nThat exercises all four pieces without touching the loop — a good gut check before moving on.\n\nOne agent was fine, but my actual goal was a pipeline: a supervisor that coordinates a research agent and a writing agent. The moment I had two agents, a new question showed up that nothing in the “minimum agent” tutorial prepared me for: **how does the research result actually reach the writer?**\n\nThere turned out to be two genuinely different answers.\n\nThe simplest option keeps the supervisor as a single ReAct agent, with the sub-agents exposed as @tool functions. The research tool returns a **string**, which becomes a ToolMessage in the supervisor's message list. The supervisor LLM then *reads that text* and re-types it into the write tool's input.\n\nSo the data crosses agents *through the model* — it gets re-typed, and potentially paraphrased, along the way.\n\nThe alternative drops the supervisor down from an agent to a **graph**. I define a typed state object — the “blackboard” — and each node reads and writes named fields on it directly. research_node writes state[\"research\"]; write_node reads state[\"research\"] straight out of state. The data never passes through the LLM at all — it's a plain Python value in shared state, deterministic and exact.\n\nThe reason StateGraph is *required* for a blackboard, and not just a nicer way to write one, is that create_agent only gives you one kind of memory: the message list. It doesn't let you define your own state fields. A blackboard is my own typed state that steps read and write directly, and only StateGraph exposes that.\n\nThe trade-off, compressed to one line: **Messages** = the LLM routes and carries the data (simple, lossy). **Blackboard** = I wire the flow, state carries the data (more setup, exact).\n\nI ended up building both versions side by side to actually feel the difference (agents/messages/ and agents/blackboard/ in the repo).\n\nThe blackboard version raised a new problem almost immediately. An agent returns *one* thing — its final message. But I wanted the research agent’s result split across several blackboard fields (research, city, built_year, and so on). One string can't cleanly become several fields.\n\nThe fix is structured output: make the agent return a typed Pydantic object instead of a string, then let the node unpack it into state.\n\n``` python\nfrom pydantic import BaseModel, Fieldclass ResearchOutput(BaseModel):    attraction: str = Field(description=\"2 sentences about the attraction\")    city: str = Field(description=\"1 sentence about the city it is in\")agent = create_agent(    model=llm,    tools=[attraction_info, city_info],    system_prompt=SYSTEM_PROMPT,    response_format=ResearchOutput,   # <- agent emits this typed object)\n```\n\nThe agent still calls its tools normally; only its *final* answer is forced to match the schema. That result doesn’t show up in messages — it lands in a separate key:\n\n```\nresult = agent.invoke({\"messages\": [...]})out = result[\"structured_response\"]   # a ResearchOutput instanceout.attraction   # strout.city         # str\n```\n\nOnly two things actually make this work, and it’s worth being precise about them because it’s easy to think there’s more magic involved than there is:\n\nSo response_format declares the shape going in; structured_response is where the validated object comes out, with the agent's normal tool loop running in between. The node itself stays dumb plumbing:\n\n``` python\ndef research_node(state):    out = research(state[\"topic\"])           # ResearchOutput    return {\"research\": out.attraction, \"city\": out.city}\n```\n\nOne agent, one run, two typed fields out — no string parsing, no second agent, no guessing. The layering, in short: **tool → agent → (structured object) → node → blackboard.** The tool gathers, the agent produces a typed object, the node files it into state.\n\nOnce research was typed, adding or removing a tool suddenly touched four places instead of one: the @tool definition, the tools=[...] list, the system-prompt prose describing the tools, and the ResearchOutput schema field (plus whatever downstream code reads that field).\n\nTwo of those four are removable coupling. A TOOLS registry lets the prompt be *generated* from each tool's name and docstring instead of hand-written:\n\n```\nSYSTEM_PROMPT = \"...\\n\" + \"\\n\".join(f\"- {t.name}: {t.description}\" for t in TOOLS)\n```\n\nThe schema is different — it’s inherent coupling if you want typed output at all. Any code reading built_year has to change if that field stops being produced. You can soften it with Optional[...] + a default so a missing tool degrades to None instead of erroring (which also matches a rule I try to hold to: never fabricate a default for data you don't actually have), but you can't make that coupling free.\n\nThat gave me a rule of thumb I now apply by default: **type the fields your code reasons about; leave everything else as text.** A field only earns a schema slot if some code branches on it — a routing if, a DB write, a filter or sort. Fields that only get concatenated into a prompt or shown to a user can stay as text and collapse into one summary field. Then removing a tool touches its definition, the registry line, and *maybe* one schema field — not four.\n\nI built an unstructured variant to feel this directly: one research: str blob, no Pydantic, prompt derived from the tool registry. Dropping a tool there was just deleting its definition and its registry entry — zero downstream edits, at the cost of losing individually addressable fields.\n\nBy this point I had enough variations floating around that it was worth naming them as points on a small set of axes, all solving the same research → write pipeline:\n\nWith the pipeline working, the next problem was speed. My first fan-out experiment ran research in about 3.7 seconds; the ReAct version of the same work took 13–15 seconds on the same model. That gap was big enough to be worth understanding properly rather than shrugging off.\n\nTo dig in, I added two deliberately slow tools to the ReAct research agent: weather_info, which just awaits a 3-second sleep (I/O-bound), and crowd_score, a blocking busy loop of about 60 million iterations (CPU-bound). Then I tried every lever I could think of and measured what actually moved the needle.\n\nThe trap that caught me: marking the busy-loop tool async def changed nothing. A blocking CPU loop inside an async tool freezes the *entire* event loop — every other async tool sits and waits for it, because Python's GIL means only one thread runs Python at a time and a busy loop never hits an await to yield control. Async is only good for I/O — waiting on a network call, a sleep, an LLM response — not for raw computation.\n\nThe fix for the CPU-bound tool was asyncio.to_thread, which moves the blocking function onto a worker thread, off the event loop:\n\n``` php\ndef _crowd_score_sync(attraction: str) -> str:   # plain blocking function    total = 0    for i in range(60_000_000):        total += i    return f\"... {total % 100} ...\"@toolasync def crowd_score(attraction: str) -> str:    return await asyncio.to_thread(_crowd_score_sync, attraction)\n```\n\nMeasured effect on the research node: about 24 seconds down to about 13. Same agent, same tools, same output — just no longer blocking the loop.\n\nThe nuance worth sitting with: the CPU thread still holds the GIL while it computes, but the async I/O legs (network calls, sleeps) *release* the GIL while they wait, so they make progress alongside the thread. to_thread stops a busy loop from freezing async I/O — it does not give you multi-core CPU parallelism. For genuine parallel CPU work across cores, you need processes, not threads.\n\nTimings across the whole progression, same model (Sonnet), same research node:\n\nEven with every async lever pulled, the ReAct agent still pays for its **decide-turns**: think → batch tool calls → think again → structured-output step. Async only parallelizes the tool-*execution* slice of that; the fan-out workflow was roughly four times faster mainly because it has no decide-turns at all, not purely because of async.\n\nThat’s really the big lesson under all of this: there is no single “make it faster” lever — you have to match the fix to the actual bottleneck.\n\nMeanwhile, I was also working on a much bigger project of mine — [IndicationScout](https://github.com/dami-gupta-git/indication_scout), a multi-agent real-world-evidence tool for drug repurposing, live at [indicationscout-production.up.railway.app](https://indicationscout-production.up.railway.app/). I implemented these methods there, and runtime dropped from 260 seconds to 118 seconds. The win there wasn’t speeding up any single agent — it was running about six full sub-agent invocations *concurrently* via nested gather. The agents themselves were unchanged. The parallelism lived entirely at the level of \"how many agents are in flight at once,\" not inside any one of them.\n\nWhich leads to the restatement I keep coming back to: collapsing one agent’s tools into a single combiner call kills the agent, because there’s nothing left for it to decide. The real win comes from parallelizing multiple *independent units of agent work* — if there’s only one unit of work, there’s no fan-out win available without giving up the agent entirely. An agent’s value *is* its decision-making, and decision-making *is* the serial round-trips that make it slow. You can’t fully have both at once. Keep the agent where the LLM genuinely needs to choose something; drop to a plain workflow where the steps are fixed.\n\nA smaller but related discovery: asyncio.gather(supervise(a), supervise(b), ...) runs several independent pipeline runs on a *single* event loop. While one run awaits an LLM or I/O call, the others make progress, so total wall-clock time approaches the slowest single run, not the sum of all of them. The catch is that this only works inside one asyncio.run(main()) wrapping the gather — two separate asyncio.run() calls do *not* overlap, because each one spins up and tears down its own event loop, so the second only starts once the first has fully finished. Each run keeps its own independent state (no shared blackboard between them), so they don't interfere with each other either.\n\nOnce I had multiple runs going, cost started to matter, which led me into prompt caching — and this is the part where I initially got the mental model wrong.\n\nCaching is a **prefix match**. The API hashes the rendered prompt — tools, then system, then messages, in that order — up to each cache_control breakpoint. That means stable content (a frozen system prompt, fixed tool definitions) needs to go first, and volatile content (the topic, a growing message history) after it. Any byte-level change anywhere in that prefix invalidates the cache from that point forward.\n\nInput tokens actually split into three price tiers: cache_creation (written to cache, 1.25× cost), cache_read (served from cache on reuse, 0.1× cost — this is the actual savings), and plain input (the uncached, new part, full price).\n\nThe first surprise was the **cache floor**: prefixes below the model’s minimum token count silently don’t cache at all — 1,024 tokens for Sonnet 4.6, the model used here (the floor varies by model, not just by tier name, so check the current docs rather than assume). No error, just cache_creation: 0. My first prefix was around 1.3K tokens, so nothing was caching and I had no idea why until I checked the raw numbers. The fix was padding the system prompt past the floor.\n\nThe second surprise was realizing what a breakpoint actually *is*: a marker on one content block meaning “cache everything from the start of the prompt up to and including this block” — not just that block in isolation. Put the breakpoint on the last system content block and you cache the tools *and* the system prompt together, since tools render first in the prefix. {\"type\": \"ephemeral\"} is the only breakpoint type (5-minute TTL by default; add \"ttl\": \"1h\" for an hour), and you get a maximum of four breakpoints per request.\n\nThe third thing that tripped me up: a plain Python string has nowhere to attach cache_control, because cache_control is a key on a content-block *dict*, and a string has no keys at all.\n\n```\nsystem_prompt = \"You are a travel research agent...\"   # string: no keyscontent = [{                                            # dict: has keys    \"type\": \"text\",    \"text\": \"You are a travel research agent...\",    \"cache_control\": {\"type\": \"ephemeral\"},            # <- attaches here}]\n```\n\nSo the fix is upgrading the bare string into a {\"type\": \"text\", \"text\": ...} block, which gives cache_control somewhere to live. None of this happens automatically through LangChain — you have to pass the system prompt as a SystemMessage whose content is that block, and langchain-anthropic forwards it as the breakpoint. The cached prefix then gets re-read on every ReAct turn within one call, and — with concurrent gather — by later runs that start after the first one has already written the cache.\n\nThe way I convinced myself it was actually working was watching cache_read: if it's always zero on requests with an identical prefix, something is silently invalidating the cache — a timestamp or UUID baked into the prompt, a sub-floor prefix, or tool definitions rendering in a different order between calls. My own trace eventually hit cache_read: 1868 on a later turn, proving the ~2K prefix was being reused at 0.1× cost. (LangChain folds the very first write into plain input, so you can't see the write directly — but a later cache_read proves it happened, since you can't read from a cache that was never written.)\n\nThere’s a second layer to this if the message history is growing turn over turn, which is a real defense I saw matter on IndicationScout as context grew: **two breakpoints in one request.** The first sits on the system block (the fixed tools+system prefix); the second sits on the *last message* each turn (the history tail). Each turn, everything up to message N-1 gets read from cache, only the new message N is billed at full price, and then the extended prefix is written for the next turn. Because the cache is a prefix, each turn’s longer entry shares its start with the previous turn’s entry, so that shared part is the cheap read. The breakpoint has to be applied to a *copy* of the last message, not the stored one, or stale interior breakpoints drift through history — and the history has to stay byte-identical, since editing anything earlier breaks the read from that point on.\n\nHaving built the manual version, I’ll say plainly: in practice, use the built-in middleware instead of hand-rolling any of this.\n\n``` python\nfrom langchain_anthropic.middleware import AnthropicPromptCachingMiddlewareagent = create_agent(    model=llm, tools=[...], system_prompt=SYSTEM_PROMPT,  # plain string    middleware=[AnthropicPromptCachingMiddleware()],)\n```\n\nIt injects both breakpoints — the system+tools prefix and the per-turn history tail — and manages the transient tail marker itself, with no SystemMessage-block wrapping and no pre_model_hook needed. The one caveat that survives regardless of the middleware: it still can't beat the model's token floor. If your prefix is under that floor (1,024 tokens on Sonnet 4.6), the marker gets set but the API still won't cache, so prompt-padding is still sometimes necessary even with the middleware doing the rest.\n\nThe last piece I wanted to understand was what happens to all this state if the process dies mid-run — which is where LangGraph’s checkpointing comes in.\n\nbuilder.compile(checkpointer=...) saves every node's write, keyed by a thread_id. You read state back via graph.aget_state(config) for the latest snapshot, or aget_state_history(config) for all of them, newest first — each snapshot carries .values, .next, and a resumable checkpoint_id. (Don't try to read the underlying .sqlite file directly; it's binary msgpack, not something you can eyeball.)\n\nTwo checkpointer options matter for different situations. MemorySaver lives entirely in RAM — compile it once at import time, and it survives for the life of the process but is gone on restart. A baseline run through my pipeline produced four checkpoints: __start__ → research → write → done. AsyncSqliteSaver is the durable option — it owns an actual DB connection, so it has to live inside async with ...from_conn_string(path) as cp:, and it writes a real binary .sqlite file (plus -wal/-shm companions).\n\nOne thing worth getting right from the start: compile once, not per call. Putting the saver’s async with block inside the function that runs the graph re-opens the database and re-compiles the graph on every single invocation. Instead, open the saver and compile the graph once for the whole application's lifetime — a lifespan() context manager mapping onto server startup/shutdown (FastAPI, for example) — and reuse that one compiled graph everywhere. MemorySaver sidesteps this problem entirely since it's just in-RAM state behind a module-level singleton.\n\nThe gotcha that actually caught me: with durable storage and the *same* thread_id, each run **appends** rather than replaces. History ends up showing every past run's full __start__ → ... → done family stacked on top of each other — I once found eight checkpoints sitting there, which turned out to be two separate runs with two different final stories, not one run gone wrong. The newest (top) snapshot is always the true current state; you tell separate run families apart by the timestamp prefix baked into each checkpoint_id. For a genuinely clean single run, use a fresh thread_id each time, or delete the .sqlite file between runs.\n\nThat’s the climb so far — four blackboard variants, two ways of passing data between agents, a working mental model of when async actually helps, and prompt caching and checkpointing understood from first principles rather than copy-pasted. None of it required deep LangGraph internals knowledge going in; it required hitting each wall once, slowing down to find out *why*, and writing down the answer before moving to the next one.\n\nGitHub Repo: [https://github.com/dami-gupta-git/assistant_agents](https://github.com/dami-gupta-git/assistant_agents)\n\ndami.gupta@gmail.com\n\n*Written with the help of Claude*\n\n[Learning LangGraph : A Journey Through Agents, Blackboards, and Bottlenecks](https://pub.towardsai.net/learning-langgraph-the-hard-way-a-journey-through-agents-blackboards-and-bottlenecks-7a480fca3eb9) was originally published in [Towards AI](https://pub.towardsai.net) on Medium, where people are continuing the conversation by highlighting and responding to this story.", "url": "https://wpnews.pro/news/learning-langgraph-a-journey-through-agents-blackboards-and-bottlenecks", "canonical_source": "https://pub.towardsai.net/learning-langgraph-the-hard-way-a-journey-through-agents-blackboards-and-bottlenecks-7a480fca3eb9?source=rss----98111c9905da---4", "published_at": "2026-07-24 03:59:36+00:00", "updated_at": "2026-07-24 04:03:07.015129+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "large-language-models"], "entities": ["LangGraph", "LangChain", "ChatAnthropic", "Claude Haiku"], "alternates": {"html": "https://wpnews.pro/news/learning-langgraph-a-journey-through-agents-blackboards-and-bottlenecks", "markdown": "https://wpnews.pro/news/learning-langgraph-a-journey-through-agents-blackboards-and-bottlenecks.md", "text": "https://wpnews.pro/news/learning-langgraph-a-journey-through-agents-blackboards-and-bottlenecks.txt", "jsonld": "https://wpnews.pro/news/learning-langgraph-a-journey-through-agents-blackboards-and-bottlenecks.jsonld"}}