DEV Community

Cover image for Agentic Data Pipelines: LLM Tool-Use for Ingestion, Cleaning & Reconciliation
Gowtham Potureddi
Gowtham Potureddi

Posted on

Agentic Data Pipelines: LLM Tool-Use for Ingestion, Cleaning & Reconciliation

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.

PipeCode blog header for agentic data pipelines — bold white headline 'Agentic Data Pipelines' over a hero composition where an LLM agent hub sits in an observe-decide-act loop, calling typed tools that fan out to ingestion, cleaning, and reconciliation stages, with a human-in-the-loop badge, on a dark gradient.

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


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 orders contract") 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 DELETE or 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
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 finish tool, 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
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. TOOLS is the entire surface the agent can touch — an allow-list of typed functions. The model can request load(...), but it cannot invent drop_table(...); anything not in the registry is rejected before execution.
  2. 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 be eval-ed. Structured output is what makes the loop safe.
  3. 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.
  4. The finish tool lets the agent declare the goal met and return a typed result, so termination is explicit — but the max_steps cap guarantees the loop ends even if the agent never calls finish.
  5. 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.
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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)
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode
# 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 }
Enter fullscreen mode Exit fullscreen mode

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

Practice →

Data validation Topic — data-validation Data validation problems on gating untrusted proposals

Practice →


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.

Iconographic agentic ingestion diagram — an LLM agent calling typed tools (inspect_source, propose_mapping, extract, validate, load), inspecting a new source whose schema has drifted, and proposing a source-to-target column mapping that is gated by a validation check and a human-approval fork before load.

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. load can 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: extract pulls the source into a staging frame; load performs 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_source compared 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: validate against the contract on a sample is mandatory before load; 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: load is 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_source surfaces the drift, the agent proposes a mapping delta, validate gates it, a human approves risky changes.
  • "What stops a bad mapping from loading?" — validate against the target contract on a sample before load; 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 Mapping is a list of (source_field, target_field, cast, confidence); a ValidationReport is ok plus 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)
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. FieldMap and Mapping are the typed shapes the model must produce. The cast field is constrained to an allow-list pattern, so the model cannot invent an arbitrary cast — the type system bounds the proposal space.
  2. 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.
  3. 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.
  4. 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.
  5. 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
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. inspect_source(...) returns a profile — sampled rows and inferred types — so the model reasons over Ord Date and amt (USD) from a handful of examples, never the whole file. Context stays small and relevant.
  2. propose_mapping(...) returns a typed Mapping: Ord Date → order_date as a date at 0.98 confidence, amt (USD) → total_cents with a cents cast at 0.93, and crucially ccy = "US$" → currency_code at only 0.71 — the model flags its own uncertainty on the messy currency field.
  3. extract(...) deterministically pulls a bounded sample into staging; the LLM is already out of the loop for the actual data movement.
  4. validate(mapping, sample) runs the contract's checks — is order_date a valid date after the cast, is total_cents a non-negative integer, is currency_code one of the allowed codes after normalising US$ → USD? A failing check means nothing loads.
  5. Only a passing report reaches load(mapping, key="order_id"), an idempotent upsert. The low-confidence ccy mapping 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. cust renamed to customer_name; new column tax_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
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. 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.
  2. propose_mapping_delta(...) re-maps customer_name → customer_id at high confidence (a rename is easy) and makes an explicit, typed decision about the new tax_cents column — proposing to add it to the contract, but at only 0.62 confidence, flagging its own uncertainty.
  3. 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.
  4. 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.
  5. 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)
}
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode
# 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
    )
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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 load writes; 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

Practice →

Data processing Topic — data-processing Data processing problems on typed extract and load

Practice →


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.

Iconographic self-healing cleaning-loop diagram — a five-stage cycle of detect, propose, validate, apply, verify, where a failed data-quality expectation triggers an LLM to emit a typed FixProposal that must pass a dry-run validation gate, with a human-approval fork on destructive fixes before an idempotent apply.

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 FixProposal is 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_id unique, order_date parseable.
  • 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)]
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. EXPECTATIONS is 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.
  2. detect(...) runs each expectation and, only on failure, builds a compact Trigger carrying the column, the human-readable rule, a small sample of offending rows, and the fail count.
  3. The offending sample is deliberately tiny (20 rows), so the propose stage gets enough context to reason — US$ and usd are clearly currency-format issues — without paying to feed the LLM the whole batch.
  4. 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.
  5. 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. normalize on currency_code mapping US$ → 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
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. propose_fix(...) returns a typed FixProposal selecting the normalize op 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.
  2. validate_fix(...) dry-runs the fix on a copy of the sample and re-runs failing_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.
  3. 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.
  4. changed_fraction(...) > 0.5 is the magnitude guard: even a validating fix that rewrites most of the column is suspicious and rejected, catching an over-broad normalise map.
  5. 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. dedupe on order_id keeping 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)
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. 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.
  2. 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.
  3. proposal.risk in ("destructive", "high_magnitude") routes the dedupe to a human_queue with 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.
  4. Only low-risk, reversible fixes reach apply_fix(...), which applies idempotently and records an undo_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.
  5. 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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode
# 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()
Enter fullscreen mode Exit fullscreen mode
# 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]
Enter fullscreen mode Exit fullscreen mode

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

Practice →

Data transformation Topic — data-transformation Data transformation problems on normalise, dedupe, and impute

Practice →


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.

Iconographic agentic reconciliation diagram — two systems' record sets reduced by deterministic blocking and exact matching for the bulk, with only the ambiguous residual pairs sent to an LLM tie-breaker that emits a typed match decision with a confidence score and a human-readable reason, plus a discrepancy report card.

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 Discrepancy list: {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 MatchDecision carries 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
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. 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.
  2. Iterating blocks_a.keys() & blocks_b.keys() considers only blocks present in both systems, so empty comparisons are skipped entirely.
  3. exact_join(...) on email, then tax_id, resolves the clear matches deterministically — reproducible, cheap, and correct wherever a hard identifier agrees. This is ~95% of the records.
  4. 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.
  5. 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)
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. MatchDecision forces 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.
  2. tie_break(...) sends only the two records' relevant fields as context and parses the response into the typed decision; a malformed answer fails at model_validate, never downstream.
  3. 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.
  4. 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.
  5. high_value=residual_pair.amount > 100_000 forces 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
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. 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.
  2. Only the differing fields plus both records go to the model, which returns a typed Discrepancy per field; the schema constrains it to {field, system_a, system_b, likely_cause}, so the output is structured and aggregatable.
  3. 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.
  4. 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.
  5. Because each Discrepancy is 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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode
# 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]
Enter fullscreen mode Exit fullscreen mode

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 MatchDecision with 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

Practice →

Data processing Topic — data-processing Data processing problems on blocking and candidate generation

Practice →


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.

Iconographic production agentic-pipeline architecture diagram — a deterministic-first router sending only the hard cases to a small model then an LLM agent with typed tools, through a validation gate into an idempotent apply, all wrapped in determinism, token-budget, eval-harness, and audit-log ribbons.

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"
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. 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.
  2. 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.
  3. 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.
  4. deploy_gate(...) raises DeployBlocked if precision falls below 0.98, so the CI pipeline fails the build — a prompt tweak or model bump that quietly worsens matching cannot ship.
  5. 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}")
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. apply_idempotent(...) checks a ledger for 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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}
Enter fullscreen mode Exit fullscreen mode

Step-by-step explanation.

  1. 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.
  2. The decision_cache returns an identical prior decision for a repeated input at no cost and deterministically — the same messy value seen twice is paid for once.
  3. used >= budget enforces 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.
  4. 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.
  5. 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
Enter fullscreen mode Exit fullscreen mode
# 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"
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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

Practice →

ETL
Topic — etl
ETL problems on idempotent, retry-safe pipeline writes

Practice →


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 FixProposal from 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.

Practice ETL problems →
Practice data validation problems →

Top comments (0)