The most common mistake when you go from one agent to a team of agents is giving each agent its own memory. The memory that matters is the one they share, and in this post I want to show that with running code, not just with assertions.

We are going to build the same multi-agent system twice. In the first version, workers report back to an orchestrator by handing over their full transcripts, a common default in orchestrator-worker stacks. In the second, workers write typed claims into a shared graph and hand back a one-line receipt. Then we measure what happens to the orchestrator’s context as the team grows, we watch one of the two versions quietly give a wrong answer when a fact changes mid-run, and at the end we replay the whole thing against real models running locally.

Everything below is reproducible with no API key, including the real-model runs, which use local Qwen models through llama.cpp. The full code is a single Python file in my dojo repo.

Step 1: a team with a concrete job

The task is deliberately mundane. A team of worker agents researches vendors, one worker per vendor, and the orchestrator has to answer a procurement question:

Which SOC2-compliant vendor with an EU data region has the cheapest price per seat?

Each worker “browses” its vendor’s pricing page and keeps the kind of transcript real agents keep: tool calls, retries, notes to self, and somewhere in the middle, the actual facts. Two definitions before the code so nothing appears out of thin air: FILLER is a fixed ~230-character block of simulated operational noise (“Tool call: open_page(url). Cookie banner dismissed. Retried once after a 429…”), and source_doc(vendor, revision) returns the vendor’s pricing page as it exists at that moment. The revision argument matters in Step 5.

def run_worker(vendor, revision=0, verbosity=6):
    doc = source_doc(vendor, revision)          # the pricing page, one paragraph
    transcript = (
        f"--- worker[{vendor['name']}] transcript ---\n"
        + FILLER * verbosity                    # noise before...
        + doc                                   # ...the facts...
        + FILLER * verbosity                    # ...noise after
        + "Conclusion: recorded price, region, SLA and SOC2 status.\n"
    )
    price = extract_price(doc)                  # what the page says right now
    source_id = f"src:{vendor['name']}:rev{revision}"
    claims = [
        {"subject": vendor["name"], "predicate": "price_per_seat", "value": price, "source": source_id},
        {"subject": vendor["name"], "predicate": "region", "value": vendor["region"], "source": source_id},
        {"subject": vendor["name"], "predicate": "soc2", "value": vendor["soc2"], "source": source_id},
    ]
    return {"transcript": transcript, "claims": claims}

Notice the worker produces both things: the verbose transcript and three small typed claims. The whole experiment is about which of the two the orchestrator gets to consume.

Step 2: the mailbox version

The obvious wiring is to concatenate every worker’s transcript into the orchestrator’s context and let it answer from the pile. TASK is the procurement question from Step 1, verbatim:

def context_transcript_mode(results):
    return TASK + "\n\n" + "\n".join(r["transcript"] for r in results)

With 6 workers this context is already 20,194 characters (about 5,000 tokens), and the three facts the question actually needs per vendor are buried in operational noise. That noise is not a strawman; open any real agent trace and count how many lines are cookie banners and retries.

Step 3: scale the team and watch the inbox

Running the sweep from 2 to 12 workers gives the picture that matters:

Orchestrator context size against team size: transcript mode grows linearly to 37,381 characters at 12 workers while graph mode stays near-flat at 796 characters, 47 times smaller.

Transcript mode grows about 2,900 characters per worker, linearly, forever. Every worker you add makes the orchestrator’s job harder, which is exactly backwards: you added the worker to make things easier. This is the context-growth failure that Anthropic describes in its engineering notes on multi-agent systems (context expanding until the model loses coherence), reproduced in miniature.

The orange line is the second version of the system. Let’s build it.

Step 4: move the state out of the agents

Two architectures side by side: in transcript mode, N workers send full transcripts of about 2.9k characters each into the orchestrator, whose context grows with every worker; in graph mode, the same workers write 3 typed claims each into a shared graph holding Claim and Source nodes with supersedes and derived_from edges, and the orchestrator reads one bounded query of live claims, one line per vendor.

The shared memory is a graph, and for this PoC a plain JSON structure is enough: g = {"nodes": [], "edges": []} with Claim nodes, Source nodes, and typed edges. If graphs are not your thing, a Claim is just a row in a table keyed by subject and predicate; nothing here requires a graph database.

Two rules do all the work, and they fit in one function. Workers write claims, not prose. And writes are additive: a revised fact is a new claim linked to the old one, never an overwrite. This is condensed from experiment.py:

def write_claim(g, c, version):
    cid = f"claim:{c['subject']}:{c['predicate']}:v{version}"
    prev = latest_claim(g, c["subject"], c["predicate"])   # live claim to supersede, if any
    g["nodes"].append({"id": cid, "type": "Claim", **c})
    g["edges"].append({"from": cid, "to": c["source"], "type": "derived_from"})
    if prev is not None:
        g["edges"].append({"from": cid, "to": prev["id"], "type": "supersedes"})

latest_claim is the read side: it returns the claim for that subject and predicate that no supersedes edge points to. It is a ten-line filter in the repo, and it is the only query the orchestrator ever runs.

The orchestrator no longer reads transcripts. It gets one live row per vendor, where “live” means no other claim supersedes it:

Task: pick the SOC2-compliant vendor with an EU data region and the
cheapest price per seat.

Current claims (superseded versions excluded):
- Brimsole: price_per_seat=$66 region=EU soc2=True
- Klarveld: price_per_seat=$89 region=EU soc2=True
...

That context is 490 characters at 6 workers, and adding a worker adds one line, not one transcript. The state stopped traveling in the messages; it lives in the graph and any agent reads the subgraph it needs.

Step 5: the fact that changed mid-run

Smaller context is nice, but here is the practical case where the two architectures actually diverge in what they answer.

Mid-run, the current winner (Klarveld, $44/seat) updates its pricing page to $89. A late worker re-checks it and reports back, in both modes. Now look at what each orchestrator is holding.

In transcript mode the pile contains two pricing-page excerpts for Klarveld, one saying $44 and one saying $89. Both look equally authoritative, and nothing in the text marks which price is current. You might object that the newer report sits later in the pile, so a careful reader could prefer the last occurrence. In this toy run that would work, but position is not a recency signal you can rely on: workers run in parallel and finish in arbitrary order, contexts get assembled from parts, and sessions get resumed with old segments re-included. The simulated orchestrator here reads top-down and anchors on the first price it finds, one plausible policy among several, and every policy based on position is a guess. It answers Klarveld at $44, which is stale:

"transcript_mode": { "answer": ["Klarveld", 44], "correct": false }
"graph_mode":      { "answer": ["Brimsole", 66], "correct": true }

In graph mode the late worker’s write created a supersedes edge, the query excludes superseded claims, and the orchestrator answers Brimsole at $66, the correct winner after the price change. Same workers, same sources, same late report. The only difference is where the state lives.

And because writes were additive, the graph can explain itself. This audit trail is printed straight from the experiment with --show-graph:

claim:Klarveld:price_per_seat:v2  (price_per_seat = 89)
  derived_from src:Klarveld:rev1  "[Klarveld pricing page, revision 1]"
  supersedes:
    claim:Klarveld:price_per_seat:v1  (price_per_seat = 44)
      derived_from src:Klarveld:rev0  "[Klarveld pricing page, revision 0]"

Answer, claim, source, and the full history of the revision, each hop an explicit edge. When someone asks why the recommendation changed since yesterday, you print this trail and point at the supersedes edge.

Step 6: the same run against a real model

Everything above used a simulated orchestrator, so I owed you the obvious check: what does an actual LLM do with each context? I ran the same script with real models as the orchestrator, served locally with llama.cpp and wired in through the OpenAI-compatible API (the script accepts OPENAI_BASE_URL, so any llama.cpp, vLLM, or Ollama endpoint works with no key). Greedy decoding, temperature 0, so every result below is deterministic and repeats run after run.

Model (greedy)Transcript mode, 20,194 charsGraph mode, 490 chars
Qwen2.5-7B-InstructSorbeck at $29, wrongBrimsole at $66, correct
Qwen2.5-1.5B-InstructKlarveld at $89, wrongBrimsole at $66, correct

Transcript mode did not survive contact with a real model either, and it failed in more ways than the simulation predicted. Counting sampled trials at temperature 0.8, it went 0 for 14 across both models. The stale $44 showed up as expected, but the noise cost more than recency: the 7B dropped the SOC2 constraint entirely and picked Sorbeck, the cheapest vendor overall, and the 1.5B anchored on Klarveld, the most-mentioned vendor in the pile, quoting the updated price for the wrong winner. A bloated context does not just hide which fact is current; it degrades the model’s ability to hold the question’s constraints at all.

The graph side sharpened the claim beyond what I expected. With the 490-character bounded context, even the 1.5B answers correctly. Read those two columns together: a 1.5B model with clean context beats a 7B model with a noisy one, on the same question and the same underlying facts. (With sampling at temperature 0.8, the 7B was right 3 of 5 times in graph mode against 0 of 5 in transcript mode; the 1.5B needs greedy decoding to hold three constraints even over clean rows. Measured runs are in results/live-runs-2026-07-30.md in the repo.)

brew install llama.cpp
llama-server -m qwen2.5-7b-instruct-q4_k_m-00001-of-00002.gguf -c 8192 --port 8083
OPENAI_BASE_URL=http://127.0.0.1:8083/v1 python3 experiment.py \
  --backend openai --model qwen2.5-7b-instruct

An honest note on the simulation

The default backend is still a deterministic model of the mechanism, in the same spirit as my rate-distortion post: it needs no key and reproduces exactly. Its first-occurrence reading rule models the structural hazard described in Step 5: once two revisions of a fact sit in one flat context with no marker of currency, any answer depends on an ordering assumption the text cannot justify. Step 6 shows real models do not escape that hazard; they just fail in richer ways.

git clone https://github.com/moisesvw/dojo
cd dojo/agents/shared-graph-memory

python3 experiment.py                    # sim backend, no key needed
python3 experiment.py --show-graph       # prints the graph + audit trails
python3 experiment.py --sweep            # the chart's data, 2..12 workers
python3 experiment.py --backend openai   # real LLM; add OPENAI_BASE_URL for a local server

Without the mid-run revision, both modes answer correctly at every team size in the simulation. The experiment is not rigged against transcripts; they fail only when the world changes while the team is working, and in any long-running system the world always changes while the team is working. What Step 6 adds is that for real small models even that concession is generous, since the noisy pile made them drop constraints before staleness ever entered the picture.

The takeaway

Start with the mailbox, honestly: for two workers and a short task it is fine. The moment your team grows or your tasks span sessions, move the state out of the agents. A shared JSON file with claims and supersedes is a one-afternoon change; the write side is the one function in Step 4 and the read side is a ten-line filter. What you get for that afternoon is an orchestrator context that stays flat as the team scales, and revisions that are explicit edges you can query instead of ambient text you have to guess about. It also changes which model you need: in the live runs, the bounded context let a 1.5B local model answer correctly while a 7B failed on the pile, so the cheap architecture buys you cheaper inference too. Graduate the JSON to a real graph database when agents need to chain facts across sessions, since the graph outlives the process that wrote it and transcripts do not.

The memory of a team of agents is infrastructure, not a property of each agent.

References: Andrew Ng, How Agents Can Improve LLM Performance (The Batch); Anthropic, Building Effective Agents and How we built our multi-agent research system.