Run records for LLM agents
Why this matters
Give an agent the same request today and tomorrow and you can get two different answers. That's fine until one of them is wrong. If you can't re-run the bad one exactly as it happened, you can't debug it, and you can't prove you fixed it.
The incident: green dashboards, wrong rows deleted
We had an internal data-ops agent. You'd type something like “clean up the test accounts from last sprint”, and it would write the SQL, check it against a few guardrails, then run it. One request deleted the wrong rows. The data was recoverable. What we needed was the why.
Monitoring showed nothing unusual. Latency was normal, the error rate was zero and token spend was inside budget. The trace was complete: every span, every model call, the exact SQL statement that ran. So we did the obvious thing and re-ran the same request. For two days the agent wrote the correct query every time we tried. We had a full record of what happened and still couldn't make it happen again. So what was missing?
The gap, in two sentences
Logs, metrics and traces (Days 24–26) answer “what happened?” With agents, the question that costs you is “can I make it happen again?”, and you can't debug what you can't reproduce.
Why agents break the classic observability stack
In a deterministic service, the code version plus the input mostly decides the output. A trace and a git SHA are usually enough to reproduce a bug. An agent's output depends on several more inputs than that, and most of them change over time. For each one below, your trace recorded a pointer but didn't freeze the value:
- ▹Model version drift: you called an alias like “latest”, or the provider updated the model behind it. The trace tells you which model name you called, not which weights answered.
- ▹Sampling: with temperature above 0, the same prompt gives different completions by design. Even at temperature 0, hosted inference isn't guaranteed to be bit-for-bit deterministic, because of batching and floating-point effects. Some providers accept a seed, but only on a best-effort basis. Many don't accept one at all.
- ▹Retrieved context that has changed since: the vector index got re-embedded, a document was edited, or the ranking shifted. Run the query today and you retrieve different context.
- ▹Tool results from live systems: databases, APIs, search results and the current time have all moved on. Your re-run sees today's world, not the world at the time of the incident.
- ▹Prompt template edits: the trace says the run used template “sql_planner”. Someone has edited that template three times since.
- ▹Hidden state: conversation memory, feature flags, the tool schemas shown to the model, and the agent code itself.
Traces also tend to sample, truncate large payloads and keep only metadata. That's a sensible default when you're chasing latency. It's the wrong one for reasoning bugs, where the evidence that matters is the full text the model actually saw.
The fix: a run record as an architectural component
A run record is an append-only store, keyed by run ID, that writes an entry at every boundary where non-determinism gets in: every model call, every tool call, every retrieval. It belongs on your architecture diagram as a component, in the same family as an event store or a flight recorder. It is not a logging setting. Design it before you design the dashboards. You can derive the dashboards from the record, but you can't derive the record from the dashboards.
Recording happens at three choke points you probably already have, or should add: a model gateway, a tool layer and a retriever. The agent's code never talks to the outside world directly, so all recording and all replay stubbing live in one place. Put the run_id on every span as an attribute, and you can jump from a trace to its record and back. The trace tells you where the time went. The record lets you run it again.
What goes in it: a concrete schema
The rule for what goes in: if a value could change the output when it changes, record it. Fields marked REQUIRED are the minimum for deterministic replay. The rest make debugging and analysis faster.
// REQUIRED = needed for deterministic replay. Everything else is nice to have.
interface RunRecord {
run_id: string; // REQUIRED. Also set as a span attribute on every trace
parent_run_id?: string; // REQUIRED for sub-agents, retries, handoffs: builds the run tree
started_at: string; // REQUIRED. Doubles as the frozen 'now' the agent saw
user_input: string; // REQUIRED. Verbatim, before any rewriting or summarising
caller: { tenant_id: string; user_ref: string }; // REQUIRED. Pseudonymous ref, not raw identity
agent_version: string; // REQUIRED. Git SHA of agent code + tool definitions
events: RunEvent[]; // REQUIRED. Append-only, ordered by seq
final_output?: string; // REQUIRED once the run completes
outcome?: 'ok' | 'error' | 'aborted' | 'user_flagged';
env?: Record<string, string>; // nice to have: feature flags, region, config values
}
type RunEvent = ModelCallEvent | ToolCallEvent | RetrievalEvent;
interface ModelCallEvent {
seq: number; kind: 'model_call';
model_id: string; // REQUIRED. Exact snapshot ID, never an alias like 'latest'
resolved_messages: object[]; // REQUIRED. The prompt AFTER templating, not the template name
tool_definitions: object[]; // REQUIRED. The tool schemas the model was shown
params: { // REQUIRED
temperature: number; top_p?: number; max_tokens: number;
seed?: number; // only if the provider supports it
};
response: object; // REQUIRED. Raw provider response: text, tool calls, stop reason
prompt_template?: { name: string; version: string }; // nice to have: for grouping and analysis
usage?: { input_tokens: number; output_tokens: number };
latency_ms?: number;
}
interface ToolCallEvent {
seq: number; kind: 'tool_call';
tool: string;
args: object; // REQUIRED. Exactly as the model emitted them
response: object | string; // REQUIRED. Verbatim and untruncated (masked at write time)
side_effect: boolean; // REQUIRED. Drives sync-vs-async writes and sampling
error?: string;
tool_version?: string; // nice to have
}
interface RetrievalEvent {
seq: number; kind: 'retrieval';
query: string; // REQUIRED
docs: {
doc_id: string;
content_hash: string; // REQUIRED. Proves exactly which version was seen
content?: string; // nice to have, or resolve the hash from a content-addressed store
doc_updated_at?: string; // nice to have: makes stale-context bugs obvious
score?: number;
}[];
index_version?: string; // nice to have: embedding model + index snapshot
}Teams often drop two of these fields, and both come back to bite. The first is resolved_messages. Storing the template name plus its variables looks equivalent. It isn't, because templates change. The second is the raw tool response. Plenty of teams truncate it to a few kilobytes to save space, and the line that sent the agent the wrong way is often in the part that got cut.
How replay works
Replay runs the agent's own code again while the record supplies everything that came from outside. Three modes are worth building, and each answers a different question:
- ▹Full replay: tool responses, retrievals, the clock and model responses all come from the record, and nothing makes a live call. The run reproduces exactly, so you can step through your orchestration code, parsers and guardrails in a debugger. It still works after the model snapshot has been retired.
- ▹Model-live replay: tools and retrieval are stubbed from the record, and the model is called live, pinned to the recorded snapshot and parameters. Use this to check whether a new prompt or a new model fixes the bad run, given the exact same view of the world.
- ▹Counterfactual replay: change one recorded input (the retrieved document, a tool response, the user input) and replay the rest. This is how you confirm a root cause rather than guess at one.
class Divergence(Exception):
pass
class ReplayTools:
# Serves recorded tool responses in order. Fails loudly if the agent goes off-script.
def __init__(self, record):
self.calls = [e for e in record['events'] if e['kind'] == 'tool_call']
self.i = 0
def call(self, tool, args):
expected = self.calls[self.i]
self.i += 1
if expected['tool'] != tool or expected['args'] != args:
raise Divergence(f'step {self.i}: agent called {tool} with {args}, record differs')
return expected['response']
def replay(run_id, mode='full'):
record = run_store.load(run_id)
if mode == 'full':
model = RecordedModel(record) # returns recorded responses, no live call
else:
model = LiveModel(model_id=record_model_id(record), # exact snapshot, never an alias
params=record_params(record)) # temperature, seed, max_tokens
result = agent.run(
record['user_input'],
model=model,
tools=ReplayTools(record),
retriever=RecordedRetriever(record),
clock=FrozenClock(record['started_at']),
)
return diff_runs(record, result) # step-by-step: where did the new run diverge?Don't skip the Divergence exception. If a replay fails at step 4, you know exactly where the new behaviour departs from the old one. Compare two runs step by step, prompt against prompt and tool call against tool call, and you find the first point where they split, much as git bisect finds the first bad commit.
Back to the deleted rows. Once we'd added recording at the gateway, tool layer and retriever, the next bad run reproduced on the first replay. The retrieval step had returned a stale schema document that described an older meaning of one of the columns. The agent's reasoning was fine. The context it reasoned from was wrong, so it wrote a WHERE clause that was valid against a schema that no longer existed. Our live re-runs never hit the bug because the index had been refreshed in the meantime. The fix was a freshness check in the indexing pipeline. Without the record, we'd probably have spent another week tweaking the prompt, and the prompt was never the problem.
Common pitfalls
- ▹Recording the template name instead of the resolved prompt.
- ▹Calling model aliases (“latest”) instead of exact snapshot IDs, so you can't tell which model answered.
- ▹Truncating tool and retrieval payloads to save space.
- ▹Writing the record after a side-effecting tool call has already run. If the process crashes, you lose the one run you needed.
- ▹Leaving the non-determinism in your own code unrecorded: wall-clock time, random IDs, unordered iteration over sets or maps.
- ▹Letting agent code call external systems directly, around the gateway or tool layer. Then there's nothing to record and nothing to stub.
- ▹Treating the record like a log: mutable, sampled the same way as traces, readable by everyone.
You don't always need the full version. A single-shot, stateless call with no tools and no retrieval (a classifier, say) can usually get by with the resolved prompt, parameters, model ID and output stored together. Once an agent can act on systems, or retrieves context that changes, you need the full record.
You already know this pattern
- ▹Event sourcing: state is the fold over an append-only log of events, and you rebuild or debug by replaying the log. A run record is an event-sourced agent run.
- ▹Aircraft flight recorders: they record all the time and are built to survive the crash. Investigators depend on them after an incident, and airlines analyse flight data in bulk afterwards, but nobody flies the plane by watching the recorder. It's evidence, not a dashboard.
- ▹Record/replay testing: VCR cassettes (Ruby VCR, vcrpy, Polly.js) record real HTTP interactions once and replay them in tests. Replay mode for tool calls is the same idea.
- ▹Deterministic simulation testing: you control every source of non-determinism (time, I/O, randomness), so any failure can be replayed.
The vocabulary and the patterns are old. The reason to use them is new: the most important component in the system, the model, is also the least reproducible.
The trade-offs a solution architect has to own
Storage cost and retention tiers. Full payloads are big, especially resolved prompts with the retrieved context included. Tier the storage:
- ▹Hot: full payloads for a short window, long enough to cover your usual support and incident turnaround.
- ▹Warm: compressed payloads in object storage for runs you've flagged or that matter (errors, side effects, user complaints).
- ▹Cold: metadata plus content hashes only. You can still prove what the agent saw, but this tier alone won't let you replay.
- ▹Deduplicate with content-addressed storage. System prompts, tool schemas and popular documents repeat across thousands of runs. Store each one once and reference it by hash.
PII and secrets. A run record holds everything a user typed and everything the agent retrieved, which makes it more sensitive than your logs:
- ▹Mask at write time, not read time. Detect secrets, tokens and personal data before the payload is persisted. If you need it reversible, tokenise and keep the mapping in a separate vault.
- ▹Lock down access more tightly than you do for logs. Replay access should be role-based and audited.
- ▹Set a retention policy, and make sure deletion requests reach the record store, including derived copies such as eval datasets. Your privacy obligations (for example the Australian Privacy Act or GDPR) cover this store too.
- ▹Accept the cost: masked content can make some bugs unreproducible. Decide up front which fields you'll mask and which you'll protect with access controls instead.
Synchronous versus asynchronous writes. Synchronous writes are guaranteed, but they add latency at every boundary and put the store on your critical path. Asynchronous writes are fast, but a crash can lose the last few events, and those are usually the ones you need. A practical middle ground: buffer events locally, ship them asynchronously, and write synchronously before any side-effecting tool call. Destructive actions should be write-ahead.
Sampling, when you can't keep everything. You can't sample run records at random the way you sample traces, because you don't know in advance which run a customer will complain about. Keep 100% of runs that called side-effecting tools, errored, were flagged by a user or scored low on an online evaluation. For the rest, keep metadata and hashes and sample the full payloads. Make tail-based decisions (after the run finishes) wherever you can.
Build or buy: what to check in LLM observability vendors
There are plenty of tools in this space, including Langfuse, LangSmith, Arize Phoenix, Braintrust and W&B Weave, and their features change fast. Judge them against what you need, not against a feature matrix:
- ▹Can I export a single run in an open format, with full untruncated payloads?
- ▹Can I re-execute that run outside your UI, from my own code and CI, with tools stubbed?
- ▹Do you capture the resolved prompt, exact model snapshot, sampling parameters and verbatim tool responses, or only summaries?
- ▹Do you store retrieved content, or at least content hashes?
- ▹Can masking run at ingestion, before the data leaves my boundary? Can I self-host or pick data residency?
- ▹Do you accept OpenTelemetry GenAI semantic conventions, so I'm not locked into your SDK?
- ▹Can I set retention per tier and delete by user or tenant?
If you can look at a run but can't replay it, you've bought a nicer dashboard. Building makes sense if you already have a model gateway and a tool layer. Wrapping them to write events to an append-only table or object store doesn't take much code. Most of the work is replay stubbing and masking. Storage is the easy part.
Where this fits in the course
Tomorrow (Day 28) is evaluation. Every replayable run record is already a test case: a real input, the real context and a known output that was either good or bad. Flagged runs become regression tests, and model or prompt changes get checked against recorded runs before they ship. Observability feeds quality directly, so don't let it become a separate silo owned by a different team.
Seven questions to put to your architecture this week
- ▹1. Does every agent run have a run ID that shows up on every trace span and every record entry?
- ▹2. Do we store the resolved prompt, or just a template name?
- ▹3. Do we call exact model snapshot IDs, and record them along with temperature, seed and the other parameters?
- ▹4. Are tool responses and retrieved documents stored verbatim, or at least as content hashes?
- ▹5. Is the record written before side-effecting tool calls, not after?
- ▹6. Is personal data masked at write time, with access controls and a retention policy that match our privacy obligations?
- ▹7. If a customer reports a bad agent run today, can we replay it before lunch?
Extend your knowledge
- ▹Read Martin Fowler's article “Event Sourcing” and map each of its concepts (event log, replay, snapshots) onto a run record.
- ▹Read the OpenTelemetry GenAI semantic conventions and decide which attributes belong on spans and which payloads belong in the run record.
- ▹Try vcrpy (or Polly.js) on one tool your agent calls. Record a cassette, then replay a run with that tool fully stubbed.
- ▹Pick one real failed agent run from the past month and check whether you could replay it today. List every missing field. That list is your backlog.
Discussion
Chat with Chi Cong (AI) about this article. Your conversation is private to you — you can publish a summary for others when you're done.