Pydantic AI keeps one growing message list per run — and re-sends the whole thing every step

Pydantic AI's run graph keeps one message_history list, appends request+response each step, and re-sends the full copy every model call — O(n^2) cumulative tokens. Measure it with tokenscope; gate it with ci-guardrail.

wartzar-bee

22. August 2026August 22, 2026 · 3 Min. Lesezeit3 min read

Pydantic AI gives you a clean, typed agent: define an Agent, hand it tools, call agent.run(...), and it loops — model call, tool call, model call — until it returns a validated result. The typed ergonomics are great. What the quickstart doesn't spell out is what the model receives on each pass of that loop.

I read the run graph (pydantic_ai_slim/pydantic_ai/_agent_graph.py on main) to find out.

One list, appended twice per turn#

Each run holds a single mutable conversation list on its state:

message_history: list[_messages.ModelMessage] = dataclasses.field(...)

On every model step the graph appends to it — first the outgoing request, then the response:

ctx.state.message_history.append(self.request)
ctx.state.message_history.append(response)

Nothing is removed. The list only grows: request, response, request, response — with tool calls and, crucially, tool outputs riding inside those messages.

The full list is re-sent every step#

When the graph builds the input for the next model call, it takes the entire accumulated history:

messages = ctx.state.message_history[:]

So step 1 sends your prompt; step 2 sends prompt + step 1's request + response; step 5 sends all of that plus steps 2–4. The payload you pay for grows every step, and the heaviest passengers are usually the tool outputs — the search results and file contents you least want re-uploaded five times.

A run of n steps sends roughly 1 + 2 + … + n copies of history — O(n²) cumulative tokens in the step count. The run still succeeds, your tests still pass; the only artifact is a bigger number on the invoice.

The knob you actually have#

Pydantic AI hands the full transcript back (result.all_messages()) and every run method takes a message_history parameter — "History of the conversation so far." That's the lever: across a multi-turn conversation you decide what prior history to replay, so you can pass a trimmed or summarized history into the next run. Inside a single deep tool-loop the re-send is inherent to tool-calling (true of every framework) — which is exactly why the move is to measure it, not assume it's free.

Measure it before you argue about it#

Put a number on it — the token cost of a run, priced in dollars. That's what @wartzar-bee/tokenscope does (npm i @wartzar-bee/tokenscope): it prices each bucket — input, output, cache-write (~1.25×), cache-read (~0.1×) — into an actual per-run dollar figure.

And if it runs in CI, gate it: wartzar-bee/ci-guardrail is an Apache-2.0 GitHub Action (built on tokenscope) that fails the check when a run crosses an absolute max-usd ceiling — so the quadratic step doesn't reach production as a silent 4×.

Full write-up with the code excerpts: https://dev.to/wartzarbee/pydantic-ai-keeps-one-growing-message-list-per-run-and-re-sends-the-whole-thing-every-step-4o7b

#agents#ai#llm#python

Written by

Comments

No comments yet. Start the conversation.

Contents

4 sections

  1. One list, appended twice per turn
  2. The full list is re-sent every step
  3. The knob you actually have
  4. Measure it before you argue about it