Agentic data pipelines put a language model inside the control loop of a pipeline — letting it observe the state of the data, decide which tool to call next, and act — for exactly the messy, open-ended work that deterministic code handles badly: a source whose schema drifted overnight, a column full of half-formatted values no regex ever fully tamed, two systems that each swear their customer record is the correct one. The hard problem was never running a clean, well-specified transform; deterministic code is unbeatable at that. The hard problem is the long tail of ambiguity — the cases where the right action depends on reading the data the way a human would, and where hand-coding one more branch is a losing game because the next weird input is already on its way.
This guide is the senior-data-engineering walkthrough for putting an LLM to work through tool-use without turning your pipeline into an unauditable, expensive black box — framed the way interviewers actually probe it: what "agentic" really means and where an agent earns its place versus where deterministic code must stay in charge, how an agent does ingestion by inspecting a new source and proposing a schema mapping before calling typed extract and load tools, how a self-healing data cleaning loop detects an anomaly, proposes a fix, validates it, and applies it with a human on the risky edits, how agentic reconciliation matches records across systems and explains the discrepancies, and how the production concerns — determinism, cost, evaluation, guardrails, and idempotency — keep the whole thing safe. Each section pairs a teaching block with a Solution-Tail interview answer — code, a step-by-step trace, an output table, then a concept-by-concept breakdown of why it works.
When you want hands-on reps immediately after reading, drill the ETL practice library →, harden your checks on the data validation practice library →, and sharpen the architecture axis with the system design practice library →.
On this page
- Why agentic data pipelines — where they help and where they're risky
- Tool-use for ingestion — inspect, propose a mapping, extract/load
- The cleaning loop — detect, propose, validate, apply
- Reconciliation across systems
- Production concerns — determinism, cost, eval, guardrails
- Cheat sheet — agentic pipeline recipes
- Frequently asked questions
- Practice on PipeCode
1. Why agentic data pipelines
The observe-decide-act loop — an LLM as the router for ambiguity, not the executor of data
The one-sentence invariant: an agentic data pipeline is a pipeline where an LLM runs an observe → decide → act loop over a set of typed tools — it reads the current state, chooses which tool to call with which arguments, and inspects the result before deciding again — so the model acts as a router and planner over deterministic building blocks rather than touching the data itself, which means an agent earns its place only where the input space is open-ended enough that hand-coding every branch loses (schema drift, messy sources, fuzzy reconciliation) and is a liability everywhere reproducibility, per-row cost, or unaudited writes matter. The moment you can write a correct rule, write the rule; the agent is for the cases where you cannot enumerate the rules in advance.
What "agentic" actually means for a pipeline.
- The loop, not the model. "Agentic" is not "we called an LLM once." It is a loop: observe the data/pipeline state, decide the next action, act via a tool, observe the result, repeat until a goal is met or a budget is hit. The autonomy is in the loop choosing its own next step.
- Tools are typed functions. The agent never runs free-form code against your warehouse. It emits a structured tool call — a function name plus typed arguments — and your runtime executes the real function (extract, validate, upsert) and hands back a typed observation.
-
The LLM routes; code executes. The language model's job is the fuzzy decision — "this column looks like a currency, map it to
currency_code," "these two records are probably the same entity." The deterministic tool does the actual work and enforces the guarantees. -
Goals over scripts. You give the agent a goal ("ingest this source into the
orderscontract") and a tool belt, not a fixed script. That is the whole point: the sequence of steps is decided at runtime from what the data turns out to be.
Where an agent beats deterministic code.
- Open-ended, long-tail inputs. Hundreds of vendor CSVs, each slightly different; a rule set that grows a branch per vendor forever. An agent generalises across the tail instead of enumerating it.
- Schema drift. A source adds, renames, or retypes a column and a rigid loader breaks. An agent can notice the drift, propose how the new shape maps to the target contract, and route it through validation.
- Messy, semi-structured sources. Free-text addresses, inconsistent units, mixed date formats — the cases where "parse it perfectly with a regex" is a myth and reading-in-context wins.
- Fuzzy matching and explanation. Deciding whether two records are the same entity, and explaining why they differ, is exactly the judgement-plus-language task LLMs are good at and rule engines are brittle at.
Where an agent is risky — keep deterministic code in charge.
- Hot-path, high-volume transforms. A per-row LLM call on a billion-row table is slow, expensive, and non-deterministic. Deterministic SQL/Spark owns the bulk; the agent touches only the residual.
- Bit-exact reproducibility. If a regulator or a downstream reconciliation needs the same input to always produce the same output, an LLM's stochasticity is a hazard unless pinned, cached, and gated.
- Cost-sensitive per-record work. Tokens are not free. If a deterministic check answers the question, spending an LLM call on it is pure waste.
-
Unaudited or destructive writes. An agent that can
DELETEor overwrite without a validation gate and a human review is an incident waiting to happen. Every write must be reviewable and reversible.
The division of labour senior engineers insist on.
- Agent proposes, code disposes. The LLM output is an untrusted proposal — a mapping, a fix, a match decision. Deterministic code validates it against the contract before anything lands.
- Human-in-the-loop for the risky class. Reversible, low-blast-radius actions can auto-apply; destructive or high-value ones queue for a human. Risk-tiering is a design decision, not an afterthought.
- Everything logged. Every observation, decision, tool call, and applied change is recorded with its rationale, so the pipeline is auditable and every agent action can be explained and undone.
What interviewers listen for.
- Do you say "agent proposes, deterministic code disposes" unprompted, instead of letting the LLM write to the warehouse directly? — required answer.
- Do you scope the agent to the ambiguous long tail and keep the bulk deterministic for cost and reproducibility? — senior signal.
- Do you name schema drift, messy sources, and fuzzy reconciliation as the specific places an agent helps, rather than "AI everywhere"? — senior signal.
- Do you insist on a validation gate, human-in-the-loop for destructive writes, and an audit log? — required answer.
- Do you treat LLM output as untrusted and force typed, tool-call output instead of free-form SQL? — senior signal.
Worked example — the agent-vs-deterministic decision table
Detailed explanation. The single most useful artifact for an agentic-pipeline interview is a memorised mapping of task → who should own it, the agent or deterministic code. Every senior discussion converges on it: given a step, is the input space bounded (write the rule) or open-ended and ambiguous (route it through an agent, then validate)? Walk through building the table for a vendor-onboarding pipeline.
-
The spectrum. On one end, a well-specified cast (
total_cents = round(total * 100)); on the other, "is this free-text company name the same entity as that one?" - The tension. Agents generalise over ambiguity but cost tokens and are non-deterministic; deterministic code is cheap and exact but brittle on the long tail.
- The rule. Deterministic code owns anything you can specify; the agent owns the residual you cannot, and its output is always validated.
Question. For each step in a vendor-onboarding pipeline, name the owner (agent or deterministic code) and why.
Input.
| Step | Input space | Owner |
|---|---|---|
Cast total to cents |
bounded, specified | deterministic |
| Map a drifted vendor column | open-ended per vendor | agent proposes, code validates |
| Dedupe on exact email | bounded key | deterministic |
| Match "same customer" fuzzy | ambiguous | agent tie-break + threshold |
| Load into target contract | bounded (typed upsert) | deterministic |
Code.
# The boundary as code: a router that keeps the bulk deterministic and
# spends the agent ONLY on the ambiguous residual.
def onboard_row(row, contract):
# 1. Deterministic first — anything specifiable is a plain function.
if fully_specified(row, contract):
clean = deterministic_transform(row, contract) # cheap, exact, reproducible
return load(clean) # typed upsert
# 2. Ambiguous residual only — the agent PROPOSES, it does not apply.
proposal = agent.decide(row, contract) # -> typed proposal, untrusted
if not validate(proposal, contract): # deterministic gate
return quarantine(row, reason="proposal failed validation")
if proposal.risk == "destructive":
return queue_for_human(proposal) # human-in-the-loop
return load(apply(proposal)) # reversible + logged
Step-by-step explanation.
-
fully_specified(...)is the fork that keeps costs sane: if the row matches a shape you can transform with a plain function, it never touches the LLM. The overwhelming majority of rows take this path — deterministic, cheap, and reproducible. - Only the residual — the rows the rules cannot handle — reaches
agent.decide(...), and even then the agent returns a proposal, not a committed change. The model's stochasticity is contained to a suggestion. -
validate(proposal, contract)is the non-negotiable gate: the proposal must satisfy the same target-contract checks any deterministic write would, or it is quarantined. No LLM output is trusted on its face. -
proposal.risk == "destructive"routes high-blast-radius actions to a human queue instead of auto-applying — the risk-tiering that keeps an agent from silently deleting or overwriting. - The mistake is the inverse of this table: sending every row to the agent (slow, expensive, non-deterministic) or hand-coding the ambiguous tail forever (brittle). The boundary is the design — deterministic owns the specifiable, the agent owns the ambiguous, code validates both.
Output.
| Task shape | Right owner | Wrong owner (common mistake) |
|---|---|---|
| Specifiable transform | deterministic function | per-row LLM call |
| Long-tail schema drift | agent proposes + validate | one more hard-coded branch |
| Exact-key dedupe | deterministic | LLM "does it look duplicate" |
| Fuzzy entity match | agent + confidence threshold | brittle rule cascade |
Rule of thumb. Draw the boundary first: if you can write the rule, write the rule; reserve the agent for the ambiguous residual, and always validate its proposal with deterministic code. The agent generalises over the tail — it is not a replacement for the cheap, exact, reproducible bulk.
Worked example — the agent loop skeleton with typed tools
Detailed explanation. Every agentic pipeline, under the framework noise, is the same small loop: build a prompt from the goal plus current state, ask the model for the next tool call, execute the tool, feed the observation back, and repeat until done or out of budget. Writing it once by hand demystifies it. Build a minimal loop with a typed tool registry and a budget.
- The registry. A dict of tool name → typed function, each with a JSON schema the model sees.
- The loop. decide (model returns a tool call) → act (dispatch) → observe (append result) → repeat.
-
The stops. A
finishtool, a step cap, and a token budget so the loop always terminates.
Question. Implement a bounded agent loop that calls only registered typed tools and always terminates.
Input.
| Element | Role |
|---|---|
TOOLS |
name → typed function registry |
decide() |
model picks the next tool call |
dispatch() |
executes only allow-listed tools |
| budget | max steps / tokens → forced stop |
Code.
# A minimal agent loop. The model only ever emits a tool CALL (name + args);
# our runtime executes the real, typed function. No free-form code runs.
TOOLS = {
"inspect_source": inspect_source, # each is a typed, allow-listed function
"propose_mapping": propose_mapping,
"validate": validate,
"load": load,
"finish": finish,
}
def run_agent(goal, state, max_steps=8):
transcript = [system_prompt(goal, tool_schemas(TOOLS))]
for step in range(max_steps): # HARD cap → always terminates
call = decide(transcript, state) # -> {"tool": "...", "args": {...}}
if call["tool"] not in TOOLS: # allow-list guard
transcript.append(observe("error", "unknown tool"))
continue
if call["tool"] == "finish":
return call["args"] # goal reached
result = dispatch(TOOLS, call, state) # execute the REAL typed function
transcript.append(observe(call["tool"], result)) # feed result back
return {"status": "budget_exhausted"} # safe default, no partial write
Step-by-step explanation.
-
TOOLSis the entire surface the agent can touch — an allow-list of typed functions. The model can requestload(...), but it cannot inventdrop_table(...); anything not in the registry is rejected before execution. -
decide(...)sends the goal, the tool schemas, and the running transcript to the model and gets back a structured tool call — a name and typed arguments — never prose to beeval-ed. Structured output is what makes the loop safe. -
dispatch(...)runs the real function and returns a typed result, which is appended as an observation. The agent's next decision is informed by what actually happened, which is the "act then observe" that makes it a loop rather than a one-shot. - The
finishtool lets the agent declare the goal met and return a typed result, so termination is explicit — but themax_stepscap guarantees the loop ends even if the agent never callsfinish. - On budget exhaustion the loop returns a safe status with no partial write, because every actual side effect happened inside a tool that validated its own inputs — the loop control never writes data directly.
Output.
| Step | Model decides | Runtime executes |
|---|---|---|
| 1 | inspect_source(uri) |
sample + inferred types |
| 2 | propose_mapping(sample) |
typed mapping proposal |
| 3 | validate(mapping) |
pass / fail vs contract |
| 4 | finish(mapping) |
loop returns result |
Rule of thumb. An agent is just a bounded observe-decide-act loop over an allow-listed registry of typed tools, with a hard step/token cap so it always terminates. Keep the loop control free of side effects — every write lives inside a typed tool that validates its own inputs.
Worked example — when NOT to reach for an agent
Detailed explanation. The most senior thing you can do in an agentic-pipeline interview is to decline to use an agent where deterministic code wins. The failure mode of the moment is "AI everywhere," and it is expensive, slow, and non-deterministic. Reason through three steps and reject the agent where it does not belong.
- The candidates. A per-row currency cast, a nightly full-table dedupe on an exact key, and a free-text company-name match.
- The test. Is the input space bounded and specifiable? Is per-row cost/latency critical? Does it need bit-exact reproducibility?
- The verdict. Only the free-text match survives as an agent task; the other two are deterministic.
Question. For each step decide agent or deterministic, and quantify why the wrong choice hurts.
Input.
| Step | Volume | Specifiable? | Verdict |
|---|---|---|---|
| Currency cast | 1e9 rows | yes | deterministic |
| Exact-key dedupe | 1e8 rows | yes | deterministic |
| Free-text name match | 1e4 residual pairs | no | agent (tie-break) |
Code.
Cost / determinism reasoning — why "agent everywhere" fails.
Currency cast on 1e9 rows
deterministic: ~one SQL expression, exact, reproducible, ~free
agent (per row): 1e9 LLM calls -> $$$$, slow, non-deterministic. REJECT.
Exact-key dedupe on 1e8 rows
deterministic: GROUP BY key / window dedupe, exact, reproducible
agent: asking "is this a dup?" when a key answers it -> waste. REJECT.
Free-text company-name match, 1e4 AMBIGUOUS residual pairs
deterministic: brittle rule cascade, endless edge cases, still wrong on the tail
agent: judge the residual with a confidence + reason, human-review mid-band. USE.
Rule: spend an LLM call ONLY where a deterministic check cannot answer the question
AND the volume is the small ambiguous residual, not the bulk.
Step-by-step explanation.
- The currency cast is one deterministic expression over a billion rows; routing it through an agent would mean a billion non-deterministic, billed calls to answer a question arithmetic already answers — the clearest possible reject.
- Exact-key dedupe is answered by a key: a
GROUP BY/window is exact and reproducible. Asking an LLM "does this look like a duplicate" when equality settles it adds cost and removes determinism for nothing. - The free-text match is the only real agent task: no key settles it, a rule cascade is brittle and still wrong on the tail, and the volume is a small residual of ambiguous pairs — precisely where an LLM's judgement plus a confidence threshold pays off.
- The volume qualifier is essential: even a genuinely fuzzy task should not go to the agent at full-table scale. Deterministic blocking shrinks it to the ambiguous residual first (section 4), and only that residual is worth tokens.
- The senior move is to quantify the reject: naming "1e9 non-deterministic billed calls" is far more convincing than a hand-wave, and it demonstrates you treat the LLM as a scarce, expensive tool spent deliberately.
Output.
| Step | If you use an agent | If you use deterministic code |
|---|---|---|
| Currency cast | slow, costly, non-deterministic | exact, ~free |
| Exact dedupe | wasted tokens | exact, reproducible |
| Fuzzy name match | judged with a reason | brittle, wrong on tail |
Rule of thumb. Reach for an agent only when a deterministic check cannot answer the question and the volume is the small ambiguous residual. Quantify the cost of the wrong choice — "a billion non-deterministic billed calls" — because declining to over-use the LLM is itself the senior signal.
Senior interview question on the agent-versus-deterministic boundary
A senior interviewer often opens with: "Your team wants to 'add AI' to a vendor-onboarding pipeline that ingests hundreds of slightly different sources, cleans them, and reconciles customers against a master. Design where an LLM agent belongs and where it must not — what the agent decides, what deterministic code must guarantee, how you keep cost and reproducibility under control, and how a bad agent decision is prevented from reaching production data."
Solution Using a deterministic-first boundary, typed tools, a validation gate, and human-in-the-loop
# 1. Deterministic-first router: the bulk never touches the LLM.
def process(record, contract):
if fully_specified(record, contract):
return load(deterministic_transform(record, contract)) # cheap, exact, reproducible
return handle_ambiguous(record, contract) # residual only
# 2. The agent PROPOSES via typed tools; it never writes directly.
def handle_ambiguous(record, contract):
proposal = agent.run( # observe-decide-act loop
goal=f"map/clean this record into {contract.name}",
tools=[inspect, propose_mapping, propose_fix, match], # allow-listed, typed
record=record,
)
return gate(proposal, contract)
# 3. Deterministic gate + risk-tiered human-in-the-loop + audit.
def gate(proposal, contract):
audit_log.write(proposal) # everything is logged
if not validate(proposal, contract): # untrusted -> must pass the contract
return quarantine(proposal, reason="failed validation")
if proposal.risk in ("destructive", "high_value"):
return human_queue.enqueue(proposal) # reversible review before apply
applied = apply(proposal, idempotency_key=proposal.key) # idempotent, reversible
audit_log.write(applied)
return applied
# 4. Cost + reproducibility controls wrapping the whole thing.
controls:
deterministic_first: true # LLM only on the ambiguous residual
model: { version: pinned, temperature: 0, cache: on } # reproducible-ish, cheap
budget: { max_llm_calls_per_run: 500, on_exceed: quarantine_rest }
eval_gate: { golden_cases: 300, min_precision: 0.98, block_deploy_below: true }
Step-by-step trace.
| Decision | Before ("AI everywhere") | After (bounded agent) |
|---|---|---|
| Who handles the bulk | LLM per row | deterministic transform |
| What the LLM does | writes to the warehouse | proposes a typed action |
| Bad-proposal reach | lands in prod | quarantined by the gate |
| Destructive writes | auto-applied | human-queued, reversible |
| Cost | tokens per row | tokens on the residual only |
| Reproducibility | none | pinned + cached + eval-gated |
After the design lands, the deterministic router handles the specifiable majority with cheap, exact transforms; only the ambiguous residual enters the agent's observe-decide-act loop, and even there the agent emits typed proposals through an allow-listed tool belt. Every proposal is logged, validated against the target contract, and either auto-applied (reversible, idempotent) or queued for a human when it is destructive or high-value. A pinned, temperature-0, cached model plus a per-run call budget keeps cost and reproducibility bounded, and an eval gate blocks any deploy that regresses proposal precision.
Output:
| Metric | "AI everywhere" | Bounded agentic pipeline |
|---|---|---|
| LLM calls per run | per row (millions) | per residual (hundreds) |
| Bad write reaching prod | possible | blocked by validation gate |
| Destructive edits | silent | human-reviewed, reversible |
| Reproducibility | none | pinned + cached + eval-gated |
| Cost | unbounded | budget-capped |
Why this works — concept by concept:
- Deterministic-first boundary — the specifiable bulk is handled by cheap, exact, reproducible functions, so the LLM is spent only on the ambiguous residual where it actually adds value. Cost and determinism are protected by not calling the model, not by calling it carefully.
- Agent proposes, code disposes — the model emits typed proposals through an allow-listed tool belt and never writes directly, so its stochastic output is always an untrusted suggestion that deterministic code validates before anything lands.
- Validation gate + risk-tiered human-in-the-loop — every proposal must pass the target contract, and destructive or high-value actions queue for a reversible human review, so a bad decision is caught before it touches production data.
- Pinned, cached, eval-gated model — a fixed version at temperature 0 with a decision cache makes runs as reproducible as an LLM allows, and an offline eval gate blocks deploys that regress precision, so quality is measured, not hoped.
- Cost — hundreds of LLM calls on the residual versus millions per row, plus a hard per-run budget, versus an unbounded token bill. The eliminated cost is the price and non-determinism of putting a stochastic model on the hot path — O(residual) agent calls instead of O(rows), with deterministic code carrying the bulk.
Design
Topic — design
Design problems on agent boundaries and pipeline orchestration
2. Tool-use for ingestion — inspect, propose a mapping, extract/load
The agent proposes a mapping; typed tools do the extract, validate, and load
The mental model in one line: tool-use for ingestion gives the agent a typed tool belt — inspect_source, propose_mapping, extract, validate, load — and a goal ("land this source in the target contract"), and the agent inspects a sample, proposes how the source fields map to the target schema, then calls extract → validate → load, but the LLM never parses a byte itself: it only emits typed tool calls with structured arguments, so every side effect is a reviewable, idempotent function invocation and schema drift becomes "propose a new mapping and validate it" instead of "the loader crashed at 3am". The agent supplies the judgement about what maps to what; the tools supply the guarantees about what actually happens.
Typed tools are the contract between the LLM and your data.
- A tool is a typed function plus a schema. Each tool has a name, a typed signature (validated arguments), and a JSON schema the model sees. The model chooses the call; your runtime enforces the types before executing.
-
Structured output, never free-form. The agent returns
{"tool": "propose_mapping", "args": {...}}, not a paragraph or a raw SQL string to run. Anything the model emits is parsed and type-checked before it can act. - Observations close the loop. Each tool returns a typed result — a sample, a validation report, a row count — that the agent reads to decide its next step, so ingestion is a short deterministic dance the agent conducts.
-
Least privilege per tool.
loadcan upsert into one target; it cannot drop tables. The tool belt is the blast radius, so you design it as the smallest set that accomplishes the goal.
The ingestion tool belt.
-
inspect_source. Reads a small sample and returns inferred column names, types, and a few example values — enough for the model to reason about the shape without ever seeing the whole dataset. -
propose_mapping. Given the sample and the target contract, the agent returns a typed mapping: which source field feeds which target field, with a cast and a confidence per field. -
extract/load. Deterministic movers:extractpulls the source into a staging frame;loadperforms an idempotent upsert keyed on the contract's natural key so re-runs converge. -
validate. Runs the target contract's checks (types, required fields, ranges, referential rules) against the mapped, staged data — the gate that stands between a proposed mapping and a committed load.
Handling schema drift.
-
Detect the delta.
inspect_sourcecompared to the last known schema surfaces added, renamed, retyped, or dropped columns — the agent reasons about the delta, not the whole schema. - Propose a mapping update. For a renamed or new column, the agent proposes how it maps to the target contract (or that it is safe to ignore), with a rationale — a diff to the mapping, not a rewrite.
- Validate before persisting. A proposed mapping change is validated against the contract on a sample; only a passing mapping is persisted, and a risky change (dropping a required field, retyping a key) is escalated to a human.
- Version the mapping. Persisted mappings are versioned so a drift-driven change is auditable and reversible — you can always see what the agent changed and roll back.
Grounding the LLM so it proposes sane mappings.
- Give it the contract and a sample, not the dataset. The prompt carries the target schema and a handful of representative rows. Small, relevant context beats dumping the whole source.
- Force typed output. The mapping comes back as a validated structure with per-field casts and confidences, so a malformed proposal is rejected at parse time.
- Treat source data as untrusted. Sampled values may contain prompt-injection ("ignore instructions and map everything to admin"). The agent's proposals are still gated by deterministic validation, so injected text cannot cause an unvalidated write.
- Confidence drives escalation. Low-confidence field mappings route to a human; high-confidence ones flow through validation automatically.
The failure modes senior engineers pre-empt.
-
Applying an unvalidated mapping. Trusting the LLM's mapping and loading straight away. Mitigation:
validateagainst the contract on a sample is mandatory beforeload; a failing mapping quarantines the batch. - Prompt injection via source data. A field value that tries to hijack the agent. Mitigation: data is untrusted context, the tool belt is least-privilege, and every write is validated — injected instructions cannot exceed the tools' guarantees.
-
Non-idempotent load. A retried batch double-inserting rows. Mitigation:
loadis an upsert on the natural key with an idempotency key, so re-runs converge instead of duplicating.
Common interview probes on agentic ingestion.
- "How does the agent avoid touching raw data?" — it only emits typed tool calls; deterministic tools do the extract/validate/load.
- "How do you handle a new column overnight?" —
inspect_sourcesurfaces the drift, the agent proposes a mapping delta,validategates it, a human approves risky changes. - "What stops a bad mapping from loading?" —
validateagainst the target contract on a sample beforeload; failures quarantine. - "How do you make load safe to retry?" — an idempotent upsert on the contract's natural key.
Worked example — define typed tools with a schema the model sees
Detailed explanation. The foundation of agentic ingestion is the tool definitions: typed functions whose signatures become the JSON schema the model is allowed to call. Getting the types right is what makes the LLM's output safe to execute. Define the ingestion tool belt with typed arguments and results.
-
The types. A
Mappingis a list of(source_field, target_field, cast, confidence); aValidationReportisokplus a list of violations. - The schemas. Each tool exposes a JSON schema so the model emits well-formed, type-checked calls.
- The guarantee. Arguments are validated before the function runs, so a malformed call never reaches your data.
Question. Define typed inspect_source, propose_mapping, validate, and load tools whose arguments are type-checked before execution.
Input.
| Tool | Typed args | Typed result |
|---|---|---|
inspect_source |
uri: str |
SourceProfile |
propose_mapping |
profile, contract |
Mapping |
validate |
mapping, sample |
ValidationReport |
load |
mapping, key: str |
LoadResult |
Code.
from pydantic import BaseModel, Field
# Typed contracts: the model's tool calls are parsed into THESE before running.
class FieldMap(BaseModel):
source_field: str
target_field: str
cast: str = Field(pattern="^(str|int|cents|date|bool|currency_code)$")
confidence: float = Field(ge=0, le=1)
class Mapping(BaseModel):
fields: list[FieldMap]
class ValidationReport(BaseModel):
ok: bool
violations: list[str] = []
def propose_mapping(profile: "SourceProfile", contract: "Contract") -> Mapping:
raw = llm_tool_call( # model returns JSON, parsed into Mapping
goal="map source fields to the target contract",
schema=Mapping.model_json_schema(), # the model SEES the typed schema
context={"profile": profile, "contract": contract},
)
return Mapping.model_validate(raw) # malformed proposals raise HERE, not in prod
def validate(mapping: Mapping, sample: list[dict]) -> ValidationReport:
violations = contract_checks(mapping, sample) # deterministic checks
return ValidationReport(ok=not violations, violations=violations)
Step-by-step explanation.
-
FieldMapandMappingare the typed shapes the model must produce. Thecastfield is constrained to an allow-list pattern, so the model cannot invent an arbitrary cast — the type system bounds the proposal space. -
Mapping.model_json_schema()is handed to the model as the tool's contract, so it emits JSON that fits the schema; the flexibility is in which fields map where, not in the shape of the answer. -
Mapping.model_validate(raw)parses the model's raw output into the typed object and raises on anything malformed — a hallucinated field, a bad cast, an out-of-range confidence — so bad proposals fail at the boundary, never inside a load. -
validate(...)is pure deterministic code: it runs the contract's checks against the mapped sample and returns a typed report. The LLM proposes the mapping; this function decides whether it is acceptable. - The result is a hard separation: the model's creativity is confined to a typed, allow-listed structure, and every actual guarantee (types, casts, contract checks) is enforced by code that the model cannot bypass.
Output.
| Model emits | Parsed to | Outcome |
|---|---|---|
| well-formed mapping JSON | Mapping |
proceeds to validate |
unknown cast value |
parse error | rejected at boundary |
confidence 1.7
|
range error | rejected at boundary |
| prose instead of JSON | parse error | rejected at boundary |
Rule of thumb. Define every tool as a typed function whose schema the model sees and whose output you parse-and-validate before executing. The type system is your first guardrail — it turns "the LLM said something weird" from a production incident into a caught parse error at the boundary.
Worked example — propose a source-to-target mapping from a sample
Detailed explanation. The core ingestion decision is the mapping: given a sampled source and a target contract, which source field feeds which target field, and with what cast? This is exactly the judgement an LLM is good at and a rule engine is brittle at. Walk a messy vendor CSV into a clean orders contract.
-
The source. Columns like
Ord Date,amt (USD),cust,ccy— human-named, inconsistent. -
The target.
order_date DATE,total_cents INT,customer_id TEXT,currency_code TEXT. - The proposal. A typed mapping with a cast and a confidence per field, ready to validate.
Question. Produce a typed mapping from the vendor sample to the orders contract, then gate it on validation before load.
Input.
| Source field | Sample value | Target field | Cast |
|---|---|---|---|
Ord Date |
2026/08/01 |
order_date |
date |
amt (USD) |
42.00 |
total_cents |
cents |
ccy |
US$ |
currency_code |
currency_code |
cust |
Acme Retail |
customer_id |
str |
Code.
# The agent inspects a SAMPLE (not the whole file), then proposes a typed mapping.
profile = inspect_source("s3://vendor/acme/2026-08-01.csv") # sample + inferred types
mapping = propose_mapping(profile, contract=ORDERS_CONTRACT)
# -> Mapping(fields=[
# FieldMap(source_field="Ord Date", target_field="order_date", cast="date", confidence=0.98),
# FieldMap(source_field="amt (USD)", target_field="total_cents", cast="cents", confidence=0.93),
# FieldMap(source_field="ccy", target_field="currency_code", cast="currency_code", confidence=0.71),
# FieldMap(source_field="cust", target_field="customer_id", cast="str", confidence=0.88),
# ])
sample = extract(profile, limit=1000) # deterministic pull into staging
report = validate(mapping, sample) # contract checks on the SAMPLE
if report.ok:
load(mapping, key="order_id") # idempotent upsert
else:
quarantine(profile, report.violations) # nothing lands on failure
Step-by-step explanation.
-
inspect_source(...)returns a profile — sampled rows and inferred types — so the model reasons overOrd Dateandamt (USD)from a handful of examples, never the whole file. Context stays small and relevant. -
propose_mapping(...)returns a typedMapping:Ord Date → order_dateas a date at 0.98 confidence,amt (USD) → total_centswith acentscast at 0.93, and cruciallyccy = "US$" → currency_codeat only 0.71 — the model flags its own uncertainty on the messy currency field. -
extract(...)deterministically pulls a bounded sample into staging; the LLM is already out of the loop for the actual data movement. -
validate(mapping, sample)runs the contract's checks — isorder_datea valid date after the cast, istotal_centsa non-negative integer, iscurrency_codeone of the allowed codes after normalisingUS$ → USD? A failing check means nothing loads. - Only a passing report reaches
load(mapping, key="order_id"), an idempotent upsert. The low-confidenceccymapping is exactly the kind of field a stricter setup would route to a human before trusting — the confidence is a routing signal, not decoration.
Output.
| Source field | Mapped to | Confidence | Gate outcome |
|---|---|---|---|
Ord Date |
order_date (date) |
0.98 | passes validation |
amt (USD) |
total_cents (cents) |
0.93 | passes validation |
ccy |
currency_code |
0.71 | pass if US$→USD valid |
cust |
customer_id (str) |
0.88 | passes validation |
Rule of thumb. Let the agent propose a typed mapping with a per-field confidence from a small sample, then gate the whole mapping on deterministic contract validation before any load. Confidence is a routing signal — low-confidence fields earn a human look, and no mapping loads until it passes the contract.
Worked example — handling a schema-drift delta
Detailed explanation. The overnight schema change is where rigid loaders die and agents shine. A vendor renames cust to customer_name and adds a tax_cents column; a hard-coded loader breaks, but an agent can reason about the delta and propose an updated mapping. Handle the drift safely.
-
The delta.
custrenamed tocustomer_name; new columntax_cents; everything else unchanged. - The proposal. Re-map the renamed field; decide whether the new column maps to the contract or is ignored.
- The gate. Validate the updated mapping; escalate the risky part; version the change.
Question. Given a drifted source, have the agent propose a mapping delta, validate it, and escalate the risky change instead of guessing.
Input.
| Change | Old | New | Handling |
|---|---|---|---|
| renamed | cust |
customer_name |
re-map (high conf) |
| added | — | tax_cents |
map or ignore (decide) |
| unchanged | others | others | keep prior mapping |
| risky | required key drop | — | escalate to human |
Code.
# Compare the fresh profile to the last known schema -> reason about the DELTA only.
profile = inspect_source(uri)
delta = schema_delta(profile, last_known=load_mapping_version("orders", "latest"))
# delta = {"renamed": {"cust": "customer_name"}, "added": ["tax_cents"], "dropped": []}
proposal = propose_mapping_delta(delta, contract=ORDERS_CONTRACT)
# -> MappingDelta(
# remap=[FieldMap("customer_name", "customer_id", "str", confidence=0.95)],
# new_field_decisions=[NewField("tax_cents", action="add_to_contract",
# target="tax_cents", cast="cents", confidence=0.62)],
# )
report = validate(apply_delta(current_mapping, proposal), sample=extract(profile, 1000))
if not report.ok:
quarantine(profile, report.violations)
elif proposal.max_risk == "schema_change": # adding a contract field is risky
human_queue.enqueue(proposal) # human approves the contract evolution
else:
persist_mapping_version("orders", apply_delta(current_mapping, proposal)) # versioned
Step-by-step explanation.
-
schema_delta(...)diffs the fresh profile against the last persisted mapping version and returns a small structured delta — a rename, an addition, no drops — so the agent reasons about what changed, not the whole schema, keeping the decision focused and cheap. -
propose_mapping_delta(...)re-mapscustomer_name → customer_idat high confidence (a rename is easy) and makes an explicit, typed decision about the newtax_centscolumn — proposing to add it to the contract, but at only 0.62 confidence, flagging its own uncertainty. -
validate(apply_delta(...))checks the updated mapping against the contract on a fresh sample, so a bad remap or an incompatible new field is caught before anything persists. -
proposal.max_risk == "schema_change"routes the contract-evolving part to a human queue: adding a field to the target contract is a governance decision, not something an agent should do silently. The rename, being low-risk, would flow through automatically. -
persist_mapping_version(...)writes a new version of the mapping, so the drift-driven change is auditable and reversible — you can always see that the agent handled the rename on a given night and roll back if it was wrong.
Output.
| Delta item | Agent proposal | Routing |
|---|---|---|
cust → customer_name |
re-map to customer_id (0.95) |
auto (validated) |
new tax_cents
|
add to contract (0.62) | human review |
| unchanged fields | keep prior mapping | auto |
| any dropped required key | flag | human review |
Rule of thumb. Handle schema drift as a delta: inspect, diff against the last known mapping, propose only the changes, validate the updated mapping, and escalate contract-evolving or destructive changes to a human — then persist a new mapping version so every drift decision is auditable and reversible.
Senior interview question on agentic ingestion with tool-use
A senior interviewer might ask: "Build the ingestion stage of an agentic pipeline that onboards hundreds of slightly different vendor sources into one target contract and survives overnight schema drift. Cover the typed tool belt, how the agent proposes a mapping without ever touching raw data, how validation gates a load, how you handle a renamed or new column safely, and how you make the load idempotent and the whole thing auditable."
Solution Using typed tools, sample-grounded mapping, a validation gate, and idempotent versioned loads
# 1. Least-privilege typed tool belt — the agent's entire blast radius.
TOOLS = {
"inspect_source": inspect_source, # sample + inferred types (read-only)
"propose_mapping": propose_mapping, # -> typed Mapping (no side effect)
"validate": validate, # contract checks (read-only)
"load": load, # idempotent upsert (the ONLY writer)
}
# 2. The agent loop: inspect -> propose -> validate -> load, grounded on a sample.
def ingest(uri, contract):
profile = inspect_source(uri) # small sample, not the dataset
delta = schema_delta(profile, load_mapping_version(contract.name, "latest"))
mapping = (propose_mapping(profile, contract) if delta.is_new
else apply_delta(current_mapping(contract), propose_mapping_delta(delta, contract)))
report = validate(mapping, extract(profile, limit=1000)) # gate on a SAMPLE
if not report.ok:
return quarantine(uri, report.violations) # nothing lands on failure
if mapping_risk(mapping) == "schema_change":
return human_queue.enqueue(mapping) # human approves contract evolution
persist_mapping_version(contract.name, mapping) # versioned + auditable
return load(mapping, key=contract.natural_key) # idempotent upsert
# 3. Idempotent load — retries and re-runs CONVERGE, they don't duplicate.
def load(mapping, key):
staged = transform_with(mapping) # deterministic, from the mapping
return upsert(
target=mapping.target,
rows=staged,
conflict_key=key, # natural key -> ON CONFLICT UPDATE
idempotency_key=f"{mapping.target}:{batch_id}", # same batch never double-applies
)
# 4. Guardrails + audit around the stage.
ingestion_guardrails:
source_data: untrusted # sampled values can't escape the tool belt
writer_tools: [load] # only load writes; least privilege
validate_before_load: required # no unvalidated mapping ever loads
audit: [profile, mapping, report, load_result] # full trail per batch
Step-by-step trace.
| Layer | Component | Purpose |
|---|---|---|
| Read | inspect_source |
sample + types, no raw-data exposure to the LLM |
| Decide |
propose_mapping / delta |
typed mapping proposal, drift-aware |
| Gate |
validate on a sample |
no unvalidated mapping loads |
| Escalate |
human_queue on schema change |
governance for contract evolution |
| Write |
load idempotent upsert |
retries converge, natural-key conflict |
| Audit | versioned mapping + logs | every drift decision reversible |
After deployment, the agent inspects only a sample of each source, reasons about the schema delta against the last persisted mapping version, and proposes a typed mapping; validation on a sample gates every load, a renamed column flows through automatically while a contract-evolving new field is queued for a human, and the load is an idempotent upsert on the natural key so retries converge. Every profile, mapping, validation report, and load result is logged, and mappings are versioned — so an overnight drift becomes a reviewable, reversible change instead of a 3am page.
Output:
| Metric | Rigid loader | Agentic ingestion |
|---|---|---|
| New vendor source | new code + deploy | inspect + propose + validate |
| Overnight column rename | pipeline breaks | auto-remapped, validated |
| New column added | breaks or silently drops | human-approved contract change |
| Bad mapping | loads or crashes | quarantined by validation |
| Retried batch | risk of duplicates | idempotent upsert converges |
Why this works — concept by concept:
-
Least-privilege typed tools — the agent can only inspect, propose, validate, and load, and only
loadwrites; the tool belt is the blast radius, so even a hijacked prompt cannot exceed the tools' guarantees. - Sample-grounded mapping — the LLM proposes a typed mapping from a small sample plus the contract, never the whole dataset, so context stays cheap and the model's judgement is applied where it matters (which field maps where) while data movement stays deterministic.
- Validation gate before load — every mapping, including a drift-driven delta, must pass the target contract's checks on a sample before anything commits, so an unvalidated or hallucinated mapping is quarantined, never loaded.
- Idempotent, versioned loads — an upsert on the natural key with an idempotency key means retries and re-runs converge instead of duplicating, and versioned mappings make every drift decision auditable and reversible.
- Cost — one sample inspection and one mapping proposal per source, versus a code-change-and-deploy per vendor and a 3am page per drift. The eliminated cost is the engineering time of hand-coding and maintaining a loader per source — O(sources) proposals instead of O(sources) bespoke pipelines.
ETL
Topic — etl
ETL problems on ingestion, mapping, and schema drift
3. The cleaning loop — detect, propose, validate, apply
Detect an anomaly, propose a typed fix, validate it, apply it — with a human on the risky edits
The mental model in one line: a self-healing data cleaning loop is five deterministic stages wrapped around one LLM decision — detect (a data-quality expectation fails), propose (the LLM emits a typed FixProposal, not arbitrary code), validate (dry-run the fix and confirm it passes the very expectation that failed), apply (an idempotent, reversible change), and verify (re-run the checks) — so the LLM owns only the fuzzy middle of diagnosing and proposing, while deterministic anomaly detection triggers the loop and a deterministic validation gate refuses to apply any fix that does not actually resolve the problem. The agent never edits data on a hunch; it proposes a fix that must prove itself against the same checks that caught the anomaly.
The five stages of the loop.
- Detect. A deterministic expectation suite (types, ranges, uniqueness, referential integrity, distribution) runs on the batch and flags violations — the trigger is code, not the LLM.
-
Propose. For a flagged violation plus a sample of offending rows, the LLM returns a typed
FixProposal: an operation (normalize, dedupe, impute, cast, drop) with parameters and a rationale. - Validate. The proposed fix is dry-run on a sample and the expectations are re-checked; a fix that does not clear the violation (or that breaks another expectation) is rejected before it touches anything.
- Apply and verify. A passing, approved fix is applied idempotently and reversibly, then the full expectation suite re-runs to confirm the batch is now clean — closing the loop.
What the LLM proposes — a typed fix, never free code.
-
A
FixProposalis structured.{op, target_column, params, rationale, risk}— an allow-listed operation with typed parameters, so the model chooses among known fixes, it does not author arbitrary transforms. -
Operations are a fixed menu.
normalize(e.g.US$ → USD),dedupe(on a key),impute(a bounded strategy),cast,trim,drop_row. Each has a deterministic implementation the agent merely selects and parameterises. - The rationale is for humans. The proposal carries a natural-language reason, which is what makes review fast and the audit log meaningful — but the decision is still gated by validation, not by the prose.
- Risk is declared. Reversible, additive fixes are low risk; anything that drops or overwrites is high risk and routes to a human.
The validation gate — the fix must prove itself.
- Re-check the failing expectation. The dry-run applies the fix to a sample and re-runs the exact expectation that fired; if it still fails, the proposal is rejected. A fix that does not fix is not applied.
- Check for collateral damage. The dry-run also re-runs the other expectations, so a fix that fixes one column but breaks another is caught.
- Bound the change. Validate that the fix touches only the expected rows/columns and within expected magnitude — an impute that rewrites 90% of a column is a red flag even if the checks pass.
- Never apply an unvalidated fix. This is the whole discipline: the LLM's proposal is untrusted until deterministic validation confirms it resolves the anomaly without side effects.
Human-in-the-loop — risk-tier the fixes.
- Auto-apply the safe class. Low-risk, reversible, high-confidence fixes (normalise a currency, trim whitespace) apply automatically once validated — the self-healing that removes toil.
- Queue the risky class. Destructive or high-magnitude fixes (drop rows, overwrite a column, impute a large fraction) wait for a human, who sees the rationale, the dry-run diff, and the affected count.
- Everything is reversible and logged. Every applied fix writes an audit record and is undoable, so a wrong auto-fix is a rollback, not a data-loss incident.
- Convergence guard. The loop has a max-iteration cap, so a fix that keeps failing validation escalates to a human instead of spinning forever.
The failure modes senior engineers pre-empt.
- Applying an unvalidated fix. Trusting the LLM's proposal directly. Mitigation: the dry-run validation gate is mandatory; a fix that does not clear the expectation on a sample never applies.
- Silent destructive edits. An agent dropping or overwriting rows without review. Mitigation: risk-tiering routes destructive fixes to a human, and every apply is reversible and logged.
- A loop that never converges. A proposal that fixes one check and breaks another, repeatedly. Mitigation: re-check all expectations in the dry-run, cap iterations, and escalate on non-convergence.
Common interview probes on the cleaning loop.
- "What triggers the loop?" — a deterministic expectation suite, not the LLM; the LLM only proposes fixes for flagged violations.
- "How do you stop a bad fix?" — dry-run the typed fix on a sample and re-check the expectations before applying; reject if it does not clear the violation or breaks another.
- "Which fixes need a human?" — destructive or high-magnitude ones; low-risk reversible fixes auto-apply once validated.
- "How is it self-healing but safe?" — the LLM proposes, deterministic checks gate, applies are idempotent and reversible, everything is logged.
Worked example — an expectation suite triggers the loop
Detailed explanation. The loop starts with deterministic detection: a suite of expectations runs on the batch, and only a failing expectation invokes the LLM. This keeps the trigger cheap, precise, and reproducible. Build the detection stage over an orders batch.
-
The expectations.
currency_code in {USD,EUR,GBP},total_cents >= 0,order_idunique,order_dateparseable. - The trigger. A violation, with a sample of offending rows, is what the LLM sees — never the whole clean batch.
- The economy. Clean batches never touch the LLM; only the anomaly does.
Question. Run an expectation suite and produce, for each failure, a compact trigger the propose stage can consume.
Input.
| Expectation | Rule | Batch result |
|---|---|---|
| currency valid | in allow-list | fails (US$, usd) |
| non-negative total | total_cents >= 0 |
passes |
| unique order id | no duplicates | fails (2 dups) |
| parseable date | valid date | passes |
Code.
# Deterministic detection — the LLM is NOT involved yet.
EXPECTATIONS = [
Expect("currency_code", lambda s: s.isin(["USD", "EUR", "GBP"])),
Expect("total_cents", lambda s: s >= 0),
Expect("order_id", unique),
Expect("order_date", parseable_date),
]
def detect(batch):
triggers = []
for exp in EXPECTATIONS:
result = exp.run(batch) # pure, reproducible check
if not result.ok:
triggers.append(Trigger(
column=exp.column,
rule=exp.describe(),
offending_sample=result.failing_rows.head(20), # small sample only
fail_count=result.fail_count,
))
return triggers # empty => batch is clean, no LLM call
triggers = detect(orders_batch)
# -> [Trigger(column="currency_code", rule="in {USD,EUR,GBP}", sample=[{'US$'},{'usd'}], fail_count=137),
# Trigger(column="order_id", rule="unique", sample=[...dup ids...], fail_count=2)]
Step-by-step explanation.
-
EXPECTATIONSis an ordinary deterministic suite — allow-list membership, a non-negativity check, uniqueness, date parseability. This is the trigger, and it is cheap, exact, and reproducible; no LLM is involved in detecting the problem. -
detect(...)runs each expectation and, only on failure, builds a compactTriggercarrying the column, the human-readable rule, a small sample of offending rows, and the fail count. - The offending sample is deliberately tiny (20 rows), so the propose stage gets enough context to reason —
US$andusdare clearly currency-format issues — without paying to feed the LLM the whole batch. - A clean batch produces an empty trigger list and never calls the LLM — the single most important cost property of the loop is that healthy data is free.
- The two triggers here — an invalid-currency violation over 137 rows and a 2-row duplicate — are exactly the fuzzy-and-clear mix the propose stage will handle differently: normalising currency is a safe auto-fix, while dropping duplicate orders is destructive and will route to a human.
Output.
| Expectation | Outcome | Triggers LLM? |
|---|---|---|
| currency valid | fails (137 rows) | yes — propose a normalise |
| non-negative total | passes | no |
| unique order id | fails (2 dups) | yes — propose a dedupe (risky) |
| parseable date | passes | no |
Rule of thumb. Detect with a deterministic expectation suite and hand the propose stage only a compact trigger — column, rule, a small offending sample, and a count. Clean data must never reach the LLM; the loop only spends tokens where an expectation actually failed.
Worked example — a typed FixProposal validated before apply
Detailed explanation. Given a trigger, the LLM proposes a typed fix — but the fix must survive a dry-run against the very expectation that failed before it is allowed to touch anything. Handle the invalid-currency trigger end to end.
-
The proposal.
normalizeoncurrency_codemappingUS$ → USD,usd → USD, with a rationale. - The dry-run. Apply the normalise to a sample, re-run the currency expectation, confirm it now passes.
- The gate. Only a fix that clears the expectation (and breaks no other) proceeds to apply.
Question. Turn the currency trigger into a typed FixProposal, validate it on a sample, and apply only if it clears the expectation.
Input.
| Stage | Content |
|---|---|
| trigger |
currency_code not in allow-list (137 rows) |
| proposal |
normalize map {US$→USD, usd→USD}
|
| dry-run | apply to sample, re-check expectation |
| gate | apply iff expectation now passes |
Code.
# Propose: the LLM returns a TYPED FixProposal from an allow-listed menu of ops.
proposal = propose_fix(trigger, sample=trigger.offending_sample)
# -> FixProposal(
# op="normalize", target_column="currency_code",
# params={"map": {"US$": "USD", "usd": "USD", "US Dollar": "USD"}},
# rationale="values are currency aliases for USD; normalize to ISO code",
# risk="low", reversible=True)
def validate_fix(proposal, sample, failing_expectation, all_expectations):
fixed = apply_fix(proposal, sample.copy()) # DRY-RUN on a copy
if not failing_expectation.run(fixed).ok:
return Reject("does not clear the failing expectation")
for exp in all_expectations: # collateral-damage check
if not exp.run(fixed).ok:
return Reject(f"breaks {exp.column}")
if changed_fraction(sample, fixed) > 0.5: # magnitude guard
return Reject("rewrites too much of the column")
return Accept()
verdict = validate_fix(proposal, sample, currency_expectation, EXPECTATIONS)
if verdict.ok and proposal.risk == "low":
apply_fix(proposal, batch, idempotency_key=f"{batch_id}:currency_norm") # reversible
audit_log.write(proposal, verdict, applied=True)
else:
route(proposal, verdict) # human review or quarantine
Step-by-step explanation.
-
propose_fix(...)returns a typedFixProposalselecting thenormalizeop from the allow-listed menu, with an explicit alias map and a rationale — the model parameterises a known fix, it does not write a transform from scratch. -
validate_fix(...)dry-runs the fix on a copy of the sample and re-runsfailing_expectation; if the normalised currency still is not in the allow-list, the proposal is rejected. A fix that does not fix is stopped here. - The collateral-damage loop re-runs every expectation on the fixed sample, so a proposal that clears currency but somehow breaks another column is caught — this is what prevents the never-converging ping-pong between checks.
-
changed_fraction(...) > 0.5is the magnitude guard: even a validating fix that rewrites most of the column is suspicious and rejected, catching an over-broad normalise map. - Only a validated, low-risk fix auto-applies — idempotently, with a key, and reversibly — and the whole decision (proposal, verdict, applied) is written to the audit log. A risky or rejected proposal routes to a human instead, never silently applying.
Output.
| Check | Result | Effect |
|---|---|---|
| clears currency expectation | yes | eligible to apply |
| breaks another expectation | no | eligible to apply |
| change magnitude | ~5% of rows | within guard |
| risk tier | low, reversible | auto-apply + log |
Rule of thumb. Make every fix prove itself: dry-run the typed proposal on a sample, re-check the failing expectation and all the others, bound the change magnitude, and only then apply — idempotently, reversibly, and logged. The LLM proposes; the validation gate, not the rationale, decides.
Worked example — risk-tiering and the audit trail
Detailed explanation. Not every validated fix should auto-apply. A dedupe that drops rows is destructive and must wait for a human even if it validates. Risk-tiering plus a complete audit trail is what makes the loop safe to run unattended on the easy cases. Handle the duplicate-order trigger.
-
The proposal.
dedupeonorder_idkeeping the latest — validated, but destructive (drops rows). - The routing. Destructive fixes queue for a human with the diff and the affected count; safe fixes auto-apply.
- The trail. Proposal, verdict, decision, and applier (agent or human) are all logged and reversible.
Question. Route a validated-but-destructive dedupe through human review and record a complete, reversible audit trail.
Input.
| Fix | Validates? | Risk | Routing |
|---|---|---|---|
| normalise currency | yes | low, reversible | auto-apply |
| trim whitespace | yes | low, reversible | auto-apply |
| dedupe (drop rows) | yes | destructive | human review |
| impute 40% of column | yes | high magnitude | human review |
Code.
# Risk-tiering: validation is necessary but NOT sufficient for auto-apply.
def route_fix(proposal, verdict, batch):
record = audit_log.write(proposal=proposal, verdict=verdict) # ALWAYS logged first
if not verdict.ok:
return quarantine(proposal, verdict.reason)
if proposal.risk in ("destructive", "high_magnitude"):
return human_queue.enqueue(HumanTask(
proposal=proposal,
dry_run_diff=diff(batch, apply_fix(proposal, batch.copy())), # show the impact
affected=proposal.affected_count,
audit_ref=record.id,
)) # waits for a human decision
applied = apply_fix(proposal, batch,
idempotency_key=f"{batch_id}:{proposal.op}:{proposal.target_column}")
audit_log.update(record.id, applied=True, undo_token=applied.undo_token) # reversible
return applied
# The duplicate-order dedupe: validates, but drops rows -> HUMAN.
route_fix(dedupe_proposal, Accept(), orders_batch)
Step-by-step explanation.
-
audit_log.write(...)runs first, before any decision, so every proposal and verdict is recorded whether or not it is applied — the trail is complete, including rejected and queued fixes. - A failing verdict quarantines immediately; validation is the first gate. But passing validation is not enough to auto-apply — that is the key insight of risk-tiering.
-
proposal.risk in ("destructive", "high_magnitude")routes the dedupe to ahuman_queuewith a dry-run diff and the affected count, so the reviewer sees exactly which rows would be dropped before approving — informed review, not a rubber stamp. - Only low-risk, reversible fixes reach
apply_fix(...), which applies idempotently and records anundo_token, so even an auto-applied fix can be rolled back — a wrong auto-fix is a one-command reversal, not a data-loss event. - The result is a loop that self-heals the safe majority (currency, whitespace) without human toil, while every destructive or large-magnitude change gets a human plus a full, reversible audit trail — the balance that makes agentic cleaning shippable.
Output.
| Fix | Validated | Applied by | Reversible |
|---|---|---|---|
| normalise currency | yes | agent (auto) | yes (undo token) |
| trim whitespace | yes | agent (auto) | yes (undo token) |
| dedupe (drop rows) | yes | human (approved) | yes (logged) |
| impute 40% column | yes | human (approved) | yes (logged) |
Rule of thumb. Treat validation as necessary but not sufficient: auto-apply only low-risk, reversible fixes, route destructive or high-magnitude ones to a human with a dry-run diff, and log every proposal and decision with an undo token. Self-healing is for the safe class; the risky class always earns a human plus a reversible trail.
Senior interview question on the self-healing cleaning loop
A senior interviewer might ask: "Design a self-healing cleaning stage for a pipeline that keeps hitting messy data — bad currency codes, duplicate keys, malformed dates. Cover what triggers the loop, what exactly the LLM is allowed to propose, how you guarantee a fix actually resolves the problem before it lands, which fixes need a human, and how you keep every change auditable, reversible, and convergent."
Solution Using deterministic detection, typed proposals, a dry-run gate, risk-tiering, and an audit trail
# 1. Deterministic detection triggers the loop; clean data never calls the LLM.
def clean_batch(batch, expectations, max_iters=3):
for _ in range(max_iters): # convergence cap
triggers = detect(batch, expectations)
if not triggers:
return Clean(batch) # loop converged
for trigger in triggers:
handle(trigger, batch, expectations)
return escalate(batch, reason="did not converge") # safe stop
# 2. Propose (typed) -> validate (dry-run) -> route (risk-tier) for each trigger.
def handle(trigger, batch, expectations):
proposal = propose_fix(trigger, trigger.offending_sample) # typed FixProposal
verdict = validate_fix(proposal, trigger.offending_sample,
trigger.expectation, expectations) # must clear the check
audit_log.write(proposal=proposal, verdict=verdict)
if not verdict.ok:
return quarantine(trigger, verdict.reason)
if proposal.risk in ("destructive", "high_magnitude"):
return human_queue.enqueue(proposal, diff=dry_run_diff(proposal, batch))
apply_fix(proposal, batch,
idempotency_key=f"{batch_id}:{proposal.op}:{proposal.target_column}") # reversible
# 3. The validation gate — a fix must clear its check AND break no other.
def validate_fix(proposal, sample, failing_exp, all_exps):
fixed = apply_fix(proposal, sample.copy()) # dry-run on a copy
if not failing_exp.run(fixed).ok: return Reject("did not fix")
if any(not e.run(fixed).ok for e in all_exps): return Reject("collateral damage")
if changed_fraction(sample, fixed) > 0.5: return Reject("too broad")
return Accept()
# 4. Guardrails around the loop.
cleaning_guardrails:
ops_allowlist: [normalize, dedupe, impute, cast, trim, drop_row] # no free-form code
auto_apply_risk: [low] # only low-risk, reversible fixes auto-apply
human_review_risk: [destructive, high_magnitude]
reversible: required # every apply carries an undo token
max_iters: 3 # loop must converge or escalate
audit: [trigger, proposal, verdict, decision, applier]
Step-by-step trace.
| Stage | Owner | Guarantee |
|---|---|---|
| Detect | deterministic expectations | trigger is code, clean data is free |
| Propose | LLM (typed FixProposal) | allow-listed op, never free code |
| Validate | deterministic dry-run | fix must clear the check, no collateral |
| Route | risk-tier | destructive fixes go to a human |
| Apply | idempotent, reversible | retries converge, undo available |
| Loop | max-iters cap | converges or escalates |
After deployment, the deterministic expectation suite detects violations and hands each one — with a small offending sample — to the LLM, which returns a typed FixProposal from an allow-listed menu. Every proposal is dry-run against the failing expectation and all the others before it can apply; low-risk reversible fixes auto-apply idempotently while destructive ones queue for a human with a diff; and a max-iteration cap guarantees the loop converges or escalates. Every trigger, proposal, verdict, and decision is logged with an undo token, so the stage self-heals the safe cases and keeps the risky ones supervised and reversible.
Output:
| Metric | Hand-coded cleaning | Self-healing loop |
|---|---|---|
| New anomaly type | new rule + deploy | proposed, validated, applied |
| Bad fix reaching data | possible | blocked by dry-run gate |
| Destructive edits | scripted, risky | human-reviewed, reversible |
| Convergence | manual reruns | capped loop, auto-escalate |
| Auditability | ad-hoc | full trail + undo tokens |
Why this works — concept by concept:
- Deterministic detection — a code-based expectation suite triggers the loop and clean batches never reach the LLM, so the trigger is cheap, precise, reproducible, and the token spend is confined to real anomalies.
- Typed FixProposal — the LLM selects and parameterises an allow-listed operation instead of authoring arbitrary code, so the space of possible actions is bounded and every fix has a deterministic, reviewable implementation.
- Dry-run validation gate — a fix must clear the failing expectation and break no other on a sample, with a magnitude guard, before it applies, so a proposal that does not actually resolve the anomaly — or that causes collateral damage — is rejected, not landed.
- Risk-tiering + reversible apply — only low-risk reversible fixes auto-apply; destructive or high-magnitude ones get a human plus a dry-run diff, and every apply carries an undo token, so self-healing never means silent data loss.
- Cost — one proposal per detected anomaly, dry-run on a sample, and auto-apply only for the safe class, versus a rule-and-deploy per anomaly type plus manual reruns. The eliminated cost is the endless maintenance of a hand-coded cleaning ruleset — O(anomalies) proposals instead of O(anomaly-types) hard-coded rules, with a human only on the risky tail.
Data validation
Topic — data-validation
Data validation problems on expectations and dry-run gates
4. Reconciliation across systems
Deterministic blocking first; spend the LLM only on the ambiguous tail — and explain the differences
The mental model in one line: agentic reconciliation is deterministic-first and LLM-second — exact keys and blocking rules match the easy 95% of records across two systems cheaply and reproducibly, and the LLM is spent only on the ambiguous residual (fuzzy near-matches and matched-but-differing records), where it emits a typed MatchDecision with a confidence and a human-readable reason, and a discrepancy report explaining why two "same" records disagree — because sending all O(n²) pairs to a model is both ruinously expensive and less accurate than a key join on the bulk. The LLM is a tie-breaker and an explainer, not the matcher of first resort.
Deterministic matching first — never send all pairs to an LLM.
- Blocking shrinks the candidate set. Comparing every record in A to every record in B is O(n²) — impossible at scale. Blocking groups records by a cheap key (email domain, zip, normalised name prefix) so only within-block pairs are compared.
- Exact and rule matches take the bulk. Within a block, an exact key match (email, tax id) or a deterministic rule (normalised name + zip) resolves the overwhelming majority — cheaply, exactly, reproducibly.
- The residual is small. What is left after blocking and exact matching is a small set of ambiguous pairs — the only thing worth an LLM call.
- Cost discipline. This ordering is the whole cost story: the LLM sees hundreds of ambiguous pairs, not millions of all-pairs comparisons.
The LLM as tie-breaker and explainer.
-
A typed
MatchDecision. For each ambiguous pair the model returns{match: bool, confidence: float, reason: str}— a structured decision, not prose, so it can be thresholded and logged. - Judgement on the tail. "Acme Retail Inc" versus "ACME Retail, Incorporated" at the same address is the near-match an LLM resolves well and a rule cascade botches.
- Confidence drives routing. High confidence auto-accepts, mid-band goes to human review, low confidence auto-rejects — the model's uncertainty is a first-class routing signal.
- Evidence, not vibes. The decision carries the fields it relied on, so a human (or an auditor) can see why the model matched, not just that it did.
Explaining discrepancies for matched-but-differing records.
-
The structured report. For records that match but disagree on some fields, the agent produces a
Discrepancylist:{field, system_a, system_b, likely_cause}. - Cause classification. "Address differs — likely a move," "amount differs by FX — likely currency conversion," "name differs — likely a legal-entity rename" turns a raw diff into an actionable reason.
- Human-readable output. The explanation is what a data steward actually consumes; it turns reconciliation from "these 4,000 rows differ" into "here is why, grouped by cause."
- Still typed and logged. The explanation is structured so it can be aggregated ("60% of discrepancies are FX-related") and audited.
Guardrails on agentic reconciliation.
- Confidence thresholds. Two cut-offs define three bands — auto-accept, human-review, auto-reject — tuned from the eval set to hit a target precision.
- A human-review queue. The mid-band and any high-value match (large amounts, legal entities) always sees a human; the agent proposes, the human disposes on the risky matches.
- Deterministic wins ties with the LLM. Where an exact key exists, it overrides any LLM opinion — the model never contradicts a hard identifier.
- Full evidence trail. Every decision — deterministic or LLM — is logged with the evidence and confidence, so the reconciliation is auditable end to end.
The failure modes senior engineers pre-empt.
- Pairwise LLM cost blowup. Sending all-pairs (or even all within-block pairs) to the model. Mitigation: block, exact-match the bulk, and send only the ambiguous residual.
- Hallucinated matches. The model asserting a match with false confidence. Mitigation: confidence thresholds, deterministic keys override, high-value matches always human-reviewed, evidence logged.
-
No evidence trail. A match with no recorded reason. Mitigation: every
MatchDecisioncarries the fields relied on and is logged, so matches are explainable and reversible.
Common interview probes on reconciliation.
- "How do you avoid O(n²)?" — blocking on a cheap key, then exact/rule match within blocks; the LLM only sees the residual.
- "Where does the LLM add value?" — as a tie-breaker on ambiguous near-matches and an explainer of discrepancies, not as the primary matcher.
- "How do you trust an LLM match?" — confidence thresholds, deterministic keys override, human review on the mid-band and high-value matches, evidence logged.
- "What do you do with matched-but-differing records?" — a structured discrepancy report with a likely-cause classification.
Worked example — blocking and exact match to shrink the candidate set
Detailed explanation. Reconciliation cost is decided before any LLM call, in the blocking and exact-match stage. Done right, it turns an O(n²) problem into a small ambiguous residual. Reconcile customers across a CRM and a billing system.
- The scale. 1,000,000 records each side — 1e12 naive pairs, impossible.
- The blocking key. Normalised email domain plus zip — cheap, groups likely matches.
- The exact match. Email or tax id equality resolves the clear majority within a block.
Question. Reduce a two-system, million-record reconciliation to a small residual using blocking and exact matching, and quantify the reduction.
Input.
| Stage | Pairs considered | Resolves |
|---|---|---|
| naive all-pairs | 1e12 | infeasible |
| after blocking | ~2e6 within-block | tractable |
| exact key match | of those | ~95% |
| ambiguous residual | ~1e5 | LLM tie-break |
Code.
# 1. Block: group both systems by a CHEAP key so we never compare all pairs.
def block_key(rec):
return (normalize_email_domain(rec.email), rec.zip5) # cheap, deterministic
blocks_a = group_by(crm_records, block_key)
blocks_b = group_by(billing_records, block_key)
# 2. Exact-match within shared blocks; the bulk resolves with no LLM.
matched, residual = [], []
for key in blocks_a.keys() & blocks_b.keys():
a_rows, b_rows = blocks_a[key], blocks_b[key]
exact = exact_join(a_rows, b_rows, on=["email"]) \
or exact_join(a_rows, b_rows, on=["tax_id"])
matched.extend(exact.pairs) # deterministic, reproducible
residual.extend(exact.unmatched_pairs) # ambiguous -> LLM later
print(len(matched), len(residual)) # e.g. 950_000 exact, 100_000 ambiguous
Step-by-step explanation.
-
block_key(...)groups records by normalised email domain and zip — a cheap, deterministic key. Only records sharing a block are ever compared, which collapses 1e12 naive pairs to a few million within-block pairs. - Iterating
blocks_a.keys() & blocks_b.keys()considers only blocks present in both systems, so empty comparisons are skipped entirely. -
exact_join(...)onemail, thentax_id, resolves the clear matches deterministically — reproducible, cheap, and correct wherever a hard identifier agrees. This is ~95% of the records. - Everything the exact join could not pair becomes the
residual— the small ambiguous set (name variants, missing keys, near-duplicates) that is the only thing worth an LLM call. - The quantified reduction is the point: from 1e12 impossible pairs to ~1e5 ambiguous ones, a factor of ten-million, achieved entirely with deterministic code before a single token is spent.
Output.
| Stage | Count | Cost |
|---|---|---|
| naive pairs | 1e12 | infeasible |
| within-block pairs | ~2e6 | cheap join |
| exact-matched | ~9.5e5 | deterministic |
| ambiguous residual | ~1e5 | LLM candidates |
Rule of thumb. Always block and exact-match before the LLM: group by a cheap key, resolve the bulk with hard identifiers, and hand the model only the ambiguous residual. The cost of reconciliation is decided in this stage — get the O(n²) out with deterministic code, and the LLM sees hundreds of pairs, not billions.
Worked example — an LLM tie-breaker with a typed decision and threshold
Detailed explanation. For the ambiguous residual, the LLM decides — but as a typed, thresholded, evidence-carrying MatchDecision, not a free-text opinion. Resolve the near-match pairs the exact join left behind.
- The pair. "Acme Retail Inc" (CRM) versus "ACME Retail, Incorporated" (billing), same address, no shared email.
-
The decision.
{match, confidence, reason, evidence}— typed and thresholded. - The routing. Auto-accept ≥ 0.90, human-review 0.60–0.90, auto-reject < 0.60.
Question. Resolve an ambiguous pair with a typed MatchDecision and route it by confidence.
Input.
| Band | Confidence | Action |
|---|---|---|
| high | ≥ 0.90 | auto-accept |
| mid | 0.60–0.90 | human review |
| low | < 0.60 | auto-reject |
| any high-value | — | always human |
Code.
from pydantic import BaseModel
class MatchDecision(BaseModel):
match: bool
confidence: float
reason: str
evidence: list[str] # the fields the model relied on
def tie_break(pair):
raw = llm_tool_call(
goal="decide if these two records are the same entity",
schema=MatchDecision.model_json_schema(),
context={"a": pair.a, "b": pair.b},
)
return MatchDecision.model_validate(raw)
def route(pair, decision, high_value):
if high_value or 0.60 <= decision.confidence < 0.90:
return human_queue.enqueue(pair, decision) # mid-band or high-value -> human
if decision.match and decision.confidence >= 0.90:
return accept(pair, decision) # auto-accept, logged with evidence
return reject(pair, decision) # low confidence -> auto-reject
d = tie_break(residual_pair)
# -> MatchDecision(match=True, confidence=0.86,
# reason="same legal name variant + identical address; 'Inc' vs 'Incorporated'",
# evidence=["name", "address_line1", "zip"])
route(residual_pair, d, high_value=residual_pair.amount > 100_000)
Step-by-step explanation.
-
MatchDecisionforces the model into a typed answer — a boolean, a confidence, a reason, and the evidence fields — so the output can be thresholded and logged instead of parsed from prose. -
tie_break(...)sends only the two records' relevant fields as context and parses the response into the typed decision; a malformed answer fails atmodel_validate, never downstream. - The example decision — match at 0.86 with the reason "Inc vs Incorporated, identical address" and evidence
[name, address, zip]— is exactly the near-match a rule cascade fumbles and an LLM resolves, with a stated rationale. -
route(...)sends the 0.86 confidence to the human-review band (0.60–0.90): the model is fairly sure but not sure enough to auto-accept, so a human confirms. High confidence would auto-accept; low would auto-reject. -
high_value=residual_pair.amount > 100_000forces any large-amount pair to a human regardless of confidence — the blast-radius rule that keeps an over-confident model from auto-accepting a costly wrong match.
Output.
| Pair | Confidence | Band | Outcome |
|---|---|---|---|
| Acme Inc / Incorporated | 0.86 | mid | human review |
| exact email (deterministic) | 1.00 | — | already matched |
| unrelated near-name | 0.41 | low | auto-reject |
| $250k pair @ 0.93 | 0.93 | high but high-value | human review |
Rule of thumb. Make the LLM's match a typed MatchDecision with confidence, reason, and evidence, then route by two thresholds into auto-accept, human-review, and auto-reject — and force every high-value pair to a human regardless of confidence. Deterministic keys still win ties; the model only speaks where no hard identifier does.
Worked example — a discrepancy explanation report
Detailed explanation. Matching is half the job; the other half is explaining why matched records differ. A raw field diff is noise to a data steward; a classified, human-readable report is action. Explain the discrepancies for a matched customer pair.
- The match. Same entity, confirmed — but three fields differ.
- The diff. Address, phone, and total-spend disagree between CRM and billing.
- The report. Per field: the two values plus a likely cause, aggregated by cause.
Question. For a matched-but-differing pair, produce a typed discrepancy report with a likely-cause classification per field.
Input.
| Field | System A (CRM) | System B (billing) | Likely cause |
|---|---|---|---|
| address | 12 Old St | 400 New Ave | customer moved |
| phone | +1 415… | +1 628… | area-code change |
| total_spend | 42000 | 38500 | FX / timing diff |
Code.
class Discrepancy(BaseModel):
field: str
system_a: str
system_b: str
likely_cause: str
def explain_discrepancies(pair) -> list[Discrepancy]:
diffs = field_diff(pair.a, pair.b) # deterministic: which fields differ
if not diffs:
return [] # identical -> no LLM call
raw = llm_tool_call(
goal="classify the likely cause of each field discrepancy",
schema={"type": "array", "items": Discrepancy.model_json_schema()},
context={"a": pair.a, "b": pair.b, "differing_fields": diffs},
)
return [Discrepancy.model_validate(d) for d in raw]
report = explain_discrepancies(matched_pair)
# -> [Discrepancy(field="address", system_a="12 Old St", system_b="400 New Ave", likely_cause="customer moved"),
# Discrepancy(field="phone", system_a="+1 415...", system_b="+1 628...", likely_cause="area-code change"),
# Discrepancy(field="total_spend",system_a="42000", system_b="38500", likely_cause="FX/timing difference")]
by_cause = aggregate(report, key="likely_cause") # steward sees causes, not raw rows
Step-by-step explanation.
-
field_diff(...)deterministically finds which fields differ — the LLM is not needed to detect a difference, only to explain it, so identical pairs cost nothing. - Only the differing fields plus both records go to the model, which returns a typed
Discrepancyper field; the schema constrains it to{field, system_a, system_b, likely_cause}, so the output is structured and aggregatable. - The classifications turn raw diffs into causes: an address change becomes "customer moved," a spend gap becomes "FX/timing difference" — the judgement a steward would otherwise apply by hand across thousands of rows.
-
aggregate(report, key="likely_cause")rolls the per-pair explanations into a cause distribution, so the steward sees "60% of discrepancies are address changes, 30% FX" instead of a wall of diffs — reconciliation output that drives action. - Because each
Discrepancyis typed and logged, the whole report is auditable and trend-able over time — a rising share of a given cause is a signal about an upstream system, not just noise.
Output.
| Field | Cause | Steward action |
|---|---|---|
| address | customer moved | update master, keep both |
| phone | area-code change | update master |
| total_spend | FX/timing diff | reconcile via FX rate |
| (aggregate) | causes ranked | fix the top upstream cause |
Rule of thumb. After matching, explain the differences: deterministically diff the fields, then have the LLM classify a likely cause per field into a typed discrepancy report you can aggregate by cause. A steward acts on classified causes, not raw diffs — and the typed, logged output makes discrepancy trends auditable over time.
Senior interview question on agentic reconciliation
A senior interviewer might ask: "Reconcile customer records between a CRM and a billing system with millions of rows each. Cover how you avoid an O(n²) comparison, where an LLM adds value versus where deterministic matching must win, how you decide and route ambiguous matches, how you explain records that match but disagree, and how you keep the whole reconciliation cheap, accurate, and auditable."
Solution Using deterministic blocking, exact match, an LLM tie-breaker with thresholds, and discrepancy explanation
# 1. Deterministic first: block on a cheap key, exact-match the bulk (no LLM).
def reconcile(system_a, system_b):
a = group_by(system_a, block_key) # block_key = (email_domain, zip5)
b = group_by(system_b, block_key)
matched, residual = [], []
for key in a.keys() & b.keys():
ex = exact_join(a[key], b[key], on=["email"]) or exact_join(a[key], b[key], on=["tax_id"])
matched.extend(ex.pairs) # ~95%, deterministic + reproducible
residual.extend(ex.unmatched_pairs) # small ambiguous set
return matched, residual
# 2. LLM tie-break on the residual ONLY, as a typed thresholded decision.
def resolve_residual(residual):
for pair in residual:
d = tie_break(pair) # -> MatchDecision(match, confidence, reason, evidence)
high_value = pair.amount and pair.amount > 100_000
if high_value or 0.60 <= d.confidence < 0.90:
human_queue.enqueue(pair, d) # mid-band / high-value -> human
elif d.match and d.confidence >= 0.90:
accept(pair, d) # auto-accept, evidence logged
else:
reject(pair, d) # low confidence -> auto-reject
# 3. Explain matched-but-differing records into a typed, aggregatable report.
def explain(matched):
report = []
for pair in matched:
report.extend(explain_discrepancies(pair)) # deterministic diff + LLM cause
return aggregate(report, key="likely_cause") # causes, not raw diffs
# 4. Guardrails + audit.
reconciliation_guardrails:
never_all_pairs: true # block + exact-match before any LLM call
deterministic_overrides_llm: true # a hard key beats any model opinion
thresholds: { auto_accept: 0.90, auto_reject: 0.60 }
always_human: [high_value, legal_entity_change]
audit: [decision, confidence, evidence, discrepancy_report]
Step-by-step trace.
| Stage | Owner | Handles |
|---|---|---|
| Block | deterministic | collapse O(n²) to within-block |
| Exact match | deterministic | ~95% of records, reproducible |
| Tie-break | LLM (typed, thresholded) | ambiguous residual only |
| Route | confidence + high-value | auto-accept / human / reject |
| Explain | deterministic diff + LLM cause | matched-but-differing records |
| Audit | evidence + report | every decision reversible |
After deployment, blocking and exact matching resolve the bulk deterministically and cheaply, so the LLM sees only the ambiguous residual; each residual pair becomes a typed MatchDecision routed by confidence, with high-value and mid-band pairs always going to a human and deterministic keys always overriding the model; and matched-but-differing records are explained into a typed discrepancy report aggregated by cause. Every decision carries its evidence and confidence and is logged, so the reconciliation is cheap on the bulk, accurate on the tail, and auditable end to end.
Output:
| Metric | Naive / all-LLM | Agentic reconciliation |
|---|---|---|
| Pairs sent to the LLM | all (1e12) | ambiguous residual (~1e5) |
| Bulk match cost | model per pair | deterministic join |
| Ambiguous near-matches | rule cascade misses | LLM tie-break + threshold |
| High-value wrong match | possible | always human-reviewed |
| Discrepancy output | raw diffs | classified, aggregated report |
Why this works — concept by concept:
- Deterministic blocking and exact match — grouping by a cheap key and joining on hard identifiers resolves the overwhelming majority cheaply, exactly, and reproducibly, collapsing an infeasible O(n²) comparison to a small ambiguous residual before any token is spent.
-
LLM tie-breaker on the residual — the model judges only the near-matches no key resolves, returning a typed
MatchDecisionwith confidence and evidence, so its stochastic judgement is applied exactly where deterministic rules are brittle and nowhere else. - Confidence thresholds and overrides — two cut-offs plus a high-value rule route decisions into auto-accept, human-review, and auto-reject, and deterministic keys always override the model, so a hallucinated or over-confident match cannot land unchecked.
- Typed discrepancy explanation — a deterministic field diff plus an LLM cause classification turns matched-but-differing records into an aggregatable report, so a steward acts on ranked causes instead of raw diffs and discrepancy trends become auditable.
- Cost — a deterministic join on the bulk and hundreds of LLM calls on the residual, versus a model call per pair across billions. The eliminated cost is the price and inaccuracy of treating reconciliation as an all-pairs LLM problem — O(n) blocking plus O(residual) tie-breaks instead of O(n²) model calls.
Data transformation
Topic — data-transformation
Data transformation problems on record linkage and matching
5. Production concerns — determinism, cost, eval, guardrails
Pin, cap, eval, and log — the five disciplines that turn a demo agent into a production pipeline
The mental model in one line: the gap between an agentic-pipeline demo and a production orchestration is five disciplines — determinism (pin the model version, temperature 0, cache decisions, treat output as an untrusted proposal), cost (deterministic-first so tokens hit only the long tail, a small model for routing, a per-run budget cap), evaluation (a golden dataset run offline with a precision/recall regression gate on every deploy), guardrails (typed allow-listed tools, no arbitrary code, human-in-the-loop for destructive writes), and idempotency (every tool call and apply keyed so retries and re-runs converge) — because a stochastic model in a pipeline is only safe when its non-determinism is pinned, its cost is capped, its quality is measured, its actions are bounded, and its writes are replayable. Skip any one and the agent is a liability, not self-healing.
Determinism — pin the stochasticity down.
- Pin the model version. A floating "latest" model silently changes behaviour; pin an exact version so a run today matches a run last week, and upgrade deliberately behind the eval gate.
- Temperature 0 and cache. Temperature 0 makes decisions as repeatable as the model allows; a decision cache keyed on the input makes an identical input return the identical prior decision, for free and deterministically.
- Output is an untrusted proposal. Determinism at the pipeline level comes from code, not the model: the LLM proposes, deterministic validation and typed tools produce the actual, reproducible result.
- Seed and record. Where a seed is available, set it; either way, record the model version, prompt, and decision with each action so any output can be traced and reproduced.
Cost — spend tokens only where they earn it.
- Deterministic-first. The single biggest cost lever is not calling the model: handle the specifiable bulk deterministically and reserve the LLM for the ambiguous residual.
- Route with a small model. Use a cheap, fast model to classify and route, and escalate to a larger model only for the genuinely hard cases — a two-tier spend.
- Cap the budget. A per-run token/call budget with a defined behaviour on exhaustion (quarantine the rest, escalate) means a runaway loop cannot produce a runaway bill.
- Batch and cache. Batch similar decisions into one call where possible, and cache repeated decisions so the same input is never paid for twice.
Evaluation — measure before you ship.
- A golden dataset. A curated set of inputs with known-correct decisions (mappings, fixes, matches) is the ground truth the agent is scored against.
- Offline runs and metrics. Run the agent over the golden set and measure precision and recall of its proposals — how often it is right, and how much it catches — not vibes.
- A regression gate. Every deploy (new prompt, new model version, new tools) must clear a threshold on the golden set or it is blocked — the agent equivalent of a test suite.
- Track drift. Re-run the eval periodically in production shadow mode; a metric drop signals model or data drift before it becomes an incident.
Guardrails and idempotency — bound the blast radius.
- Typed, allow-listed tools. The agent can only call the registered typed tools; no arbitrary code, no unlisted actions — the tool belt is the blast radius.
- Human-in-the-loop for writes that matter. Destructive, high-value, or schema-evolving actions queue for a human; only low-risk reversible actions auto-apply.
- Idempotency keys. Every tool call and apply carries a key, so a retried batch, a replayed run, or a duplicate trigger converges to the same state instead of double-applying.
- Reversibility and audit. Every action is logged with its rationale and an undo token, so any agent decision can be explained and rolled back.
The failure modes senior engineers pre-empt.
- Non-deterministic runs. A floating model and temperature drift making yesterday's result unreproducible. Mitigation: pin the version, temperature 0, cache, record everything.
- Runaway cost. An unbounded loop or per-row calls blowing the budget. Mitigation: deterministic-first, small-model routing, a hard per-run cap.
- Shipping a regression. A prompt tweak that quietly worsens matching. Mitigation: an eval gate on a golden set blocks deploys below threshold.
- Double-applied writes. A retry duplicating rows or re-running a fix. Mitigation: idempotency keys and upserts so replays converge.
Common interview probes on productionising agents.
- "How do you make an agent reproducible?" — pin the version, temperature 0, cache decisions, and let deterministic code produce the actual result.
- "How do you keep cost bounded?" — deterministic-first, small-model routing, and a per-run budget cap.
- "How do you know a change is safe to ship?" — an eval harness with a precision/recall regression gate on a golden set.
- "How do you make it safe to retry?" — idempotency keys and upserts so re-runs converge, plus reversible, logged writes.
Worked example — an eval harness with a regression gate
Detailed explanation. An agent without an eval harness is untested code shipping to production. A golden dataset plus a precision/recall gate is the agent's test suite. Build one for the reconciliation tie-breaker.
- The golden set. Curated ambiguous pairs, each labelled match or no-match by a human.
- The metrics. Precision (of the matches it asserts, how many are right) and recall (of the true matches, how many it caught).
- The gate. Block the deploy if precision drops below target on the golden set.
Question. Score an agent's decisions against a golden set and gate the deploy on a precision threshold.
Input.
| Element | Value |
|---|---|
| golden cases | 300 labelled pairs |
| metric | precision (primary), recall |
| threshold | precision ≥ 0.98 |
| on fail | block deploy |
Code.
# The agent's "test suite": run over a labelled golden set, score, gate the deploy.
def evaluate(agent, golden):
tp = fp = fn = 0
for case in golden: # each: (pair, label ∈ {match, no})
decision = agent.decide(case.pair) # same code path as production
predicted = decision.match and decision.confidence >= 0.90
if predicted and case.label == "match": tp += 1
elif predicted and case.label == "no": fp += 1 # false positive = bad match
elif not predicted and case.label == "match": fn += 1 # missed a real match
precision = tp / (tp + fp) if (tp + fp) else 1.0
recall = tp / (tp + fn) if (tp + fn) else 1.0
return precision, recall
def deploy_gate(agent, golden, min_precision=0.98):
precision, recall = evaluate(agent, golden)
if precision < min_precision:
raise DeployBlocked(f"precision {precision:.3f} < {min_precision}") # CI fails
log_metrics(precision, recall)
return "ok-to-deploy"
Step-by-step explanation.
-
evaluate(...)runs the agent over the golden set through the same decision code path production uses, so the score reflects real behaviour, not a special test mode. - It counts true positives, false positives, and false negatives against the human labels — a false positive is a wrong match asserted, a false negative is a real match missed — the two error types you weigh differently.
- Precision (are the matches it asserts correct?) is the primary metric here because a wrong match is usually costlier than a missed one in reconciliation; recall is tracked but the gate is on precision.
-
deploy_gate(...)raisesDeployBlockedif precision falls below 0.98, so the CI pipeline fails the build — a prompt tweak or model bump that quietly worsens matching cannot ship. - This is the discipline that makes agent changes safe: every change to the prompt, model version, or tools is scored against ground truth and gated, exactly like a unit test suite gates code.
Output.
| Change under test | Precision | Gate |
|---|---|---|
| current prod | 0.991 | pass |
| new prompt v2 | 0.994 | pass (ship) |
| model bump | 0.972 | block (regression) |
| broader thresholds | 0.960 | block |
Rule of thumb. Give every agent a golden dataset and gate deploys on a precision/recall threshold run through the production decision path. An agent change without an eval gate is untested code shipping to prod — the harness is what turns "the new prompt feels better" into a measured, blockable decision.
Worked example — idempotent, keyed tool calls
Detailed explanation. Pipelines retry. An agentic pipeline that is not idempotent will double-apply fixes and duplicate rows on every retry. Idempotency keys plus upserts make replays converge. Make the apply stage safe to retry.
- The hazard. A batch retried after a partial failure re-runs the same fixes and loads.
- The key. A deterministic idempotency key per action (batch + op + target).
- The mechanism. Upsert on a natural key; skip already-applied actions by key.
Question. Make the apply of a fix and a load safe under retries so a replayed run converges instead of duplicating.
Input.
| Action | Idempotency key | Mechanism |
|---|---|---|
| apply fix | batch:op:column |
skip if key seen |
| load rows | target:batch |
upsert on natural key |
| retry | same keys | converges |
| replay | same keys | no double-apply |
Code.
# Idempotency ledger: an action's key is recorded when it succeeds; replays skip it.
def apply_idempotent(action, key):
if ledger.seen(key): # already applied in a prior run
return ledger.result(key) # return the SAME result, no re-apply
result = action() # do the work once
ledger.record(key, result) # mark done
return result
def apply_fix(proposal, batch):
key = f"{batch.id}:{proposal.op}:{proposal.target_column}"
return apply_idempotent(lambda: _do_fix(proposal, batch), key)
def load(mapping, rows, natural_key):
# Upsert -> even without the ledger, a re-load can't duplicate rows.
return upsert(mapping.target, rows, conflict_key=natural_key,
idempotency_key=f"{mapping.target}:{batch_id}")
Step-by-step explanation.
-
apply_idempotent(...)checks aledgerfor the action's key before doing the work; if the key was recorded in a prior (possibly failed-later) run, it returns the stored result and does not re-apply — the core of idempotency. -
apply_fix(...)builds a deterministic key from the batch id, the op, and the target column, so the same fix on the same batch has the same key across retries — the retry recognises itself and skips. -
load(...)uses an upsert on the natural key, which is idempotent by construction: re-loading the same rows updates rather than duplicates, so even a load that runs twice converges to one row per key. - The two mechanisms are complementary: the ledger prevents re-executing expensive or side-effecting actions, and the upsert guarantees convergence at the data level even if an action does slip through twice.
- The result is a pipeline that is safe to retry and replay — a partial failure mid-batch can be re-run from the top and the completed actions are skipped, so the final state is identical whether the batch ran once or five times.
Output.
| Scenario | Without idempotency | With idempotency |
|---|---|---|
| clean run | correct | correct |
| retry after partial fail | double-applies | converges (skips done) |
| full replay | duplicates rows | no-op on applied |
| duplicate trigger | fix runs twice | fix runs once |
Rule of thumb. Key every tool call and apply with a deterministic idempotency key and load via upsert on a natural key, so retries and replays converge instead of duplicating. An agentic pipeline will be retried — design every write to be replayable, or a retry becomes a data-corruption incident.
Worked example — deterministic-first routing with a budget cap
Detailed explanation. Cost and determinism are won by not calling the model and by pinning it when you do. A deterministic-first router with a small-model tier and a hard budget cap is the pattern. Wire the cost controls for a mixed batch.
- The tiers. Deterministic handles the specifiable bulk; a small model routes; a large model only for the hard residual.
- The pins. Fixed version, temperature 0, decision cache.
- The cap. A per-run call budget; on exhaustion, quarantine the rest for the next run.
Question. Route a batch so the LLM is spent only on the hard residual, pinned and cached, under a hard per-run budget.
Input.
| Tier | Handles | Cost |
|---|---|---|
| deterministic | specifiable bulk (~95%) | ~free |
| small model | routing / easy cases | low |
| large model | hard residual | high, capped |
| cache | repeat inputs | free |
Code.
def process_batch(batch, budget=500):
used = 0
for record in batch:
# 1. Deterministic-first: the bulk never calls a model.
if fully_specified(record):
emit(deterministic(record)); continue
# 2. Cache: an identical input returns the identical prior decision, free.
if (hit := decision_cache.get(record.key)) is not None:
emit(hit); continue
# 3. Budget cap: stop spending when the run's budget is exhausted.
if used >= budget:
quarantine(record, reason="run budget exhausted"); continue
# 4. Two-tier: small model routes; escalate to large only if hard.
route = small_model.classify(record) # cheap, pinned, temp 0
used += 1
decision = (large_model.decide(record) if route == "hard" else route_decision(route))
used += (1 if route == "hard" else 0)
decision_cache.put(record.key, decision) # cache for next time
emit(decision)
return {"llm_calls": used}
Step-by-step explanation.
-
fully_specified(record)sends the specifiable bulk down a deterministic path with no model call — the biggest cost lever is this branch, which handles ~95% of records for free. - The
decision_cachereturns an identical prior decision for a repeated input at no cost and deterministically — the same messy value seen twice is paid for once. -
used >= budgetenforces a hard per-run cap: once the run has spent its budget, remaining ambiguous records are quarantined for the next run instead of blowing the bill — a runaway loop cannot run away. - The two-tier model split spends a cheap, pinned, temperature-0 small model on routing and escalates to the expensive large model only for records classified "hard," so the costly model sees the smallest possible slice.
- Every large-model decision is cached, so across batches the hard residual shrinks as repeated inputs become cache hits — cost trends down over time instead of staying flat.
Output.
| Record class | Path | Model calls |
|---|---|---|
| specifiable (~95%) | deterministic | 0 |
| repeat input | cache | 0 |
| easy ambiguous | small model | 1 |
| hard ambiguous | small + large | 2 (until cached) |
Rule of thumb. Win cost and determinism by structure: deterministic-first for the bulk, a decision cache for repeats, a small model to route and a large model only for the hard residual, all under a hard per-run budget. The cheapest, most deterministic LLM call is the one you never make.
Senior interview question on productionising an agentic pipeline
A senior interviewer might ask: "Take an agentic ingestion-cleaning-reconciliation pipeline from demo to production. Cover how you make runs reproducible, how you keep token cost bounded, how you know a prompt or model change is safe to ship, how you bound what the agent can do, and how you make every write safe to retry — and tie each control to the failure it prevents."
Solution Using pinning, deterministic-first cost control, an eval gate, typed guardrails, and idempotency
# 1. Determinism: pin the model, temperature 0, cache; code produces the real result.
AGENT = Agent(model="pinned-v2026-08", temperature=0, decision_cache=RedisCache())
# -> identical input => identical (cached) decision; upgrades go through the eval gate.
# 2. Cost: deterministic-first + small-model routing + hard per-run budget.
def run(batch, budget=500):
used = 0
for rec in batch:
if fully_specified(rec): emit(deterministic(rec)); continue
if used >= budget: quarantine(rec, "budget"); continue
used += AGENT.handle(rec) # spends tokens only on the residual
# 3. Eval gate: block any deploy that regresses on the golden set.
def deploy(agent, golden):
precision, recall = evaluate(agent, golden) # production decision path
if precision < 0.98:
raise DeployBlocked(f"precision {precision:.3f}") # CI fails -> no ship
return "ok"
# 4. Guardrails + idempotency: typed allow-listed tools, HITL, keyed writes.
TOOLS = {"inspect": inspect, "propose": propose, "validate": validate, "apply": apply} # allow-list
def apply(proposal):
audit_log.write(proposal) # everything logged
if not validate(proposal): return quarantine(proposal) # untrusted -> gated
if proposal.risk in ("destructive", "high_value"):
return human_queue.enqueue(proposal) # HITL for risky writes
return apply_idempotent(lambda: _do(proposal),
key=f"{batch_id}:{proposal.op}:{proposal.target}") # replay-safe
Step-by-step trace.
| Discipline | Control | Failure it prevents |
|---|---|---|
| Determinism | pin version, temp 0, cache | unreproducible runs |
| Cost | deterministic-first, small model, budget cap | runaway token bill |
| Evaluation | golden set + precision gate | shipping a regression |
| Guardrails | typed allow-list, HITL | destructive / arbitrary actions |
| Idempotency | keyed apply + upsert | double-applied writes |
| Audit | log every proposal + decision | unexplainable changes |
After the pipeline is production-hardened, runs are reproducible because the model is pinned at temperature 0 with a decision cache and the real result comes from deterministic code; cost is bounded because the specifiable bulk never calls a model, routing uses a small model, and a per-run budget caps spend; deploys are safe because an eval gate blocks any regression on a golden set; the blast radius is bounded because the agent can only call typed allow-listed tools and destructive writes queue for a human; and every write is replay-safe via idempotency keys and upserts. Each control maps to a specific failure it prevents.
Output:
| Metric | Demo agent | Production agentic pipeline |
|---|---|---|
| Reproducibility | none (floating model) | pinned + cached + recorded |
| Token cost | per row, unbounded | residual only, budget-capped |
| Change safety | "feels better" | eval-gated on golden set |
| Blast radius | arbitrary actions | typed tools + HITL |
| Retry safety | double-applies | idempotent, converges |
Why this works — concept by concept:
- Determinism by pinning — a fixed model version at temperature 0 with a decision cache, plus deterministic code producing the actual result, makes runs reproducible despite the model's inherent stochasticity, and upgrades pass through the eval gate rather than landing silently.
- Deterministic-first cost control — handling the specifiable bulk without a model, routing with a small model, and capping the per-run budget confines token spend to the ambiguous residual and makes a runaway loop impossible.
- Eval-gated deploys — scoring every change against a golden set through the production decision path and blocking sub-threshold precision turns "the new prompt feels better" into a measured, blockable gate, exactly like a test suite.
- Typed guardrails and idempotency — an allow-listed typed tool belt with human-in-the-loop on destructive writes bounds what the agent can do, and idempotency keys plus upserts make every write converge under retries and replays.
- Cost — a small-model router, a decision cache, and a budget cap over a deterministic bulk, versus per-row calls on a floating model. The eliminated cost is the unbounded, unreproducible, unaudited spend of a demo agent shipped as-is — O(residual) pinned calls under a hard cap instead of O(rows) uncapped ones.
Design
Topic — design
Design problems on eval gates, guardrails, and idempotency
ETL
Topic — etl
ETL problems on idempotent, retry-safe pipeline writes
Cheat sheet — agentic pipeline recipes
- What "agentic" means. An observe → decide → act loop where an LLM chooses the next typed tool call over deterministic building blocks. It is a loop, not a single call, and the model routes, it does not touch the data. Autonomy lives in the loop picking its own next step toward a goal.
- Agent-vs-deterministic rule. If you can write the rule, write the rule. Reserve the agent for the ambiguous long tail — schema drift, messy sources, fuzzy reconciliation — and keep the specifiable bulk deterministic for cost and reproducibility. Agent proposes, deterministic code disposes.
-
Typed tools are the contract. Every tool is a typed function with a JSON schema the model sees; the model emits
{tool, args}, your runtime parses-validates-executes. The tool belt is the blast radius — make it least-privilege, and only one tool writes. -
Ingestion template.
inspect_source(sample + types) →propose_mapping(typed, per-field confidence, grounded on a sample not the dataset) →validate(contract checks on a sample) →load(idempotent upsert on the natural key). Handle drift as a delta; version the mapping; escalate contract changes to a human. -
Cleaning-loop template. detect (deterministic expectation suite — clean data never calls the LLM) → propose (typed
FixProposalfrom an allow-listed op menu) → validate (dry-run: must clear the failing check and break no other, with a magnitude guard) → apply (idempotent, reversible) → verify. Auto-apply low-risk; human-review destructive; cap iterations. -
Reconciliation template. block on a cheap key → exact-match the bulk (~95%, deterministic) → LLM tie-break the ambiguous residual only, as a typed
MatchDecision(match, confidence, reason, evidence) → threshold (auto-accept ≥ 0.90, human-review mid-band, auto-reject < 0.60; high-value always human; hard keys override the model). Explain matched-but-differing records into a typed, aggregatable discrepancy report. - Never all-pairs to an LLM. O(n²) model calls are ruinous and less accurate than a key join. Deterministic blocking and exact matching decide reconciliation cost before any token is spent.
- Determinism. Pin the model version, temperature 0, cache decisions, record model/prompt/decision per action. The pipeline's determinism comes from code — the LLM output is an untrusted proposal that deterministic validation and typed tools turn into a reproducible result.
- Cost. Deterministic-first (don't call the model), a small model to route and a large model only for the hard residual, a per-run budget cap with a defined exhaustion behaviour, batch, and cache. The cheapest LLM call is the one you never make.
- Evaluation. A golden dataset of inputs → known-correct decisions; run the agent offline through the production path; measure precision/recall; gate every deploy on a regression threshold; shadow-eval in prod to catch drift.
- Guardrails and idempotency. Typed allow-listed tools, no arbitrary code, human-in-the-loop for destructive/high-value/schema-evolving writes; idempotency keys on every tool call and apply plus upserts on natural keys so retries and replays converge. Log every proposal and decision with an undo token.
- Division of labour, restated. Deterministic code owns the bulk and every guarantee; the agent owns the ambiguous residual and every judgement; a human owns the risky tail; and everything the agent proposes is validated, logged, reversible, and eval-gated.
Frequently asked questions
What is an agentic data pipeline?
An agentic data pipeline is a pipeline in which a language model runs an observe → decide → act loop over a set of typed tools — it reads the current state of the data, decides which tool to call with which arguments, executes it through deterministic code, inspects the result, and decides again — so the LLM acts as a router and planner rather than touching the data itself. It is not "we called an LLM once"; the defining feature is the loop choosing its own next step toward a goal you set (for example, "land this source in the target contract"). Crucially, the agent only ever proposes typed actions — a mapping, a fix, a match decision — and deterministic code validates and executes them, so the model's judgement is applied to the ambiguous long tail while the guarantees, the bulk, and the reproducibility stay with ordinary code. Used well, it makes a pipeline more self-healing (it can adapt to schema drift and messy data) without becoming an unauditable black box.
When should I use an LLM agent instead of deterministic code?
Use an agent only where the input space is open-ended enough that hand-coding every branch loses, and where a deterministic check cannot already answer the question. The three canonical fits are schema drift (a source changes shape and a rigid loader breaks), messy semi-structured sources (free-text, inconsistent units and formats), and fuzzy reconciliation (deciding whether two records are the same entity and explaining why they differ). Everywhere else, prefer deterministic code: a specifiable transform, an exact-key dedupe, or any high-volume per-row operation is cheaper, exact, and reproducible without a model, and routing it through an LLM is slow, costly, and non-deterministic. The senior framing is "if you can write the rule, write the rule" — reserve the agent for the ambiguous residual, keep the specifiable bulk deterministic, and always validate the agent's proposal with code before it lands.
How do I stop an agent from making a bad or destructive change?
Three layers, none of which trust the model. First, the agent never writes directly — it emits typed proposals through an allow-listed tool belt, so its output is an untrusted suggestion, not a committed change. Second, a deterministic validation gate checks every proposal against the target contract (and, for a cleaning fix, dry-runs it on a sample to confirm it actually resolves the anomaly and breaks nothing else) before anything applies; a proposal that fails is quarantined. Third, risk-tiering routes destructive, high-value, or schema-evolving actions to a human queue with a dry-run diff, while only low-risk reversible actions auto-apply — and every apply is idempotent, reversible with an undo token, and logged. So a bad decision is caught by the type system, then by validation, then by a human on the risky class, and even an applied mistake is a one-command rollback rather than a data-loss incident.
How do agents handle schema drift in ingestion?
An agent handles drift as a delta, not a rewrite. An inspect_source tool samples the source and infers its shape; comparing that to the last known schema surfaces added, renamed, retyped, or dropped columns. The agent then proposes a mapping change for just the delta — re-mapping a renamed column, deciding whether a new column maps to the target contract or is safely ignored — as a typed proposal with a per-field confidence and a rationale. That updated mapping is validated against the target contract on a sample before anything loads, so a bad or hallucinated mapping is quarantined rather than committed; a low-risk change like a rename flows through automatically, while a contract-evolving change (adding a required field, retyping a key) is escalated to a human. The new mapping is persisted as a version, so every drift-driven change is auditable and reversible — an overnight rename becomes a reviewed diff instead of a 3am pipeline break.
How do you keep an agentic pipeline cheap and deterministic?
Cost and determinism are won mostly by structure, not by clever prompting. For cost, the biggest lever is not calling the model: handle the specifiable bulk deterministically and spend tokens only on the ambiguous residual, use a small cheap model to route and escalate to a large model only for genuinely hard cases, cap the per-run token/call budget with a defined exhaustion behaviour, and cache repeated decisions so an identical input is never paid for twice. For determinism, pin the model to an exact version, run at temperature 0, cache decisions, and record the model version, prompt, and decision with every action — but the real reproducibility comes from letting deterministic code produce the actual result while the LLM output is only an untrusted proposal. Together these make the same input converge to the same output at a bounded, predictable cost, and a runaway loop can never produce a runaway bill.
How do you evaluate an agentic data pipeline before shipping it?
Treat the agent like code with a test suite. Build a golden dataset — curated inputs (mappings, fixes, match pairs) each labelled with the known-correct decision — and run the agent over it through the same decision code path production uses. Score precision and recall of its proposals: precision tells you how often the actions it asserts are correct (usually the metric you weigh most, since a wrong write is costly), recall how much it catches. Then gate every deploy — a new prompt, a model version bump, a changed tool set — on clearing a threshold on the golden set, so a change that quietly regresses matching or mapping fails the build and cannot ship. Finally, re-run the eval periodically in production shadow mode to catch model or data drift before it becomes an incident. The harness is what turns "the new prompt feels better" into a measured, blockable decision.
Practice on PipeCode
- Drill the ETL practice library → for the ingestion, mapping, and idempotent-load problems that agentic tool-use makes concrete.
- Harden your checks on the data validation practice library → for the expectation-suite, dry-run-gate, and proposal-gating scenarios the cleaning loop depends on.
- Sharpen the architecture axis with the system design practice library → for the agent-boundary, eval-gate, guardrail, and idempotency trade-offs a production agentic pipeline must get right.
- Stack the prerequisites against PipeCode's broader 450+ data-engineering catalogue to anchor the ingestion, cleaning, and reconciliation patterns against real graded inputs — typed tools, validation gates, record linkage, and retry-safe writes.
Lock in agentic-pipeline muscle memory
Blog posts explain what an agent can do. PipeCode drills explain the decision — when the LLM must only propose, when a deterministic check beats a model call, when a fix has to prove itself before it lands, and when reconciliation belongs to a key join instead of `tool-use`. Pipecode.ai is Leetcode for Data Engineering — pipeline practice tuned for the production trade-offs senior data engineers actually face.





Top comments (0)