277 AI Engineer Interview Questions & Answers

124 top • 14 Amazon • 15 Anthropic • 14 Cohere • 15 Google DeepMind • 13 Meta • 14 Microsoft AI • 13 Mistral AI • 14 NVIDIA • 15 OpenAI • 11 Perplexity • 15 xAI

AI Engineer icon

Questions with Detailed ExplanationsWith Detailed Explanations

(Last Updated: August 28, 2026)

121. Implement a retry mechanism with exponential backoff for LLM API calls.CodingEasy

Question Details

Assess correctness and production boundaries; the code must retry only transient failures such as rate limits or server errors, use exponential backoff with jitter, cap attempts, honor retry hints, and preserve idempotency.

Short Interview Answer (30-60 seconds)

I would wrap the async LLM request in a bounded retry loop. I retry only transient failures such as HTTP 429, HTTP 5xx, timeouts, and connection errors. If the server provides Retry-After, I honor it within the delay cap. Otherwise, I use capped exponential backoff with full jitter. I reuse the same idempotency key on every attempt, enforce a timeout, and stop at the attempt limit. The retry-control work is O(A) time and O(1) auxiliary space.

Detailed Explanation

See the Code while reading this explanation.

The goal is to make an LLM request recover safely from short-lived failures. A rate limit, server error, timeout, or connection problem may succeed later, so those failures can be retried. Most client errors should fail immediately. The solution limits how many requests can be attempted, waits between retries, adds randomness to spread retry traffic, respects a valid server retry hint, applies a timeout to every request, and passes the same idempotency key on every attempt so repeated requests can remain safe when the provider supports idempotency.

Useful Questions to Ask the Interviewer
  1. Which errors should be treated as transient and safe to retry?
  2. Should a valid Retry-After value take precedence over exponential backoff?
  3. What limits should we use for attempts, timeout, and maximum delay?
  4. Does the LLM provider support idempotency keys for this request?
Implement a retry mechanism with exponential backoff for LLM API calls. diagram
How to Explain It in an Interview
1. Define the input and output

The function receives an async callable, a RetryConfig, and one idempotency key. The callable represents one LLM API attempt and receives that key. The function returns the first successful response. If an error is permanent, it raises that error immediately. If all allowed attempts fail with transient errors, it raises the final error.

2. Classify the failure before retrying

The retry decision is strict. HTTP 429 is transient because it represents rate limiting. HTTP 500 through 599 are transient server failures. Timeouts and connection or network errors are also treated as transient. Other 4xx errors fail fast because retrying the same invalid request usually will not help.

3. Run a bounded async attempt loop

The diagram uses max_attempts = 6, base_delay = 0.5 seconds, max_delay = 30 seconds, and a 30-second timeout for each attempt. Each request runs through asyncio.wait_for. If asyncio.CancelledError occurs, the function re-raises it immediately. Cancellation is control flow, not a retryable API failure.

4. Choose the delay only when another attempt is allowed

After a transient failure, the code first checks whether the current attempt was the last allowed attempt. If so, it raises immediately without sleeping. Otherwise, it reads Retry-After. A valid Retry-After value is capped by max_delay. If no valid hint exists, the code calculates backoff_cap = min(base_delay * 2 ** (attempt - 1), max_delay). It then chooses a random wait between 0 and that cap. This is full jitter.

5. Walk through the diagram example

The first request receives HTTP 429 with Retry-After: 2. The implementation follows the diagram's retry-hint branch and uses the bounded server hint. The second attempt receives HTTP 503. For attempt 2, base_delay is 0.5, so the exponential cap is min(0.5 * 2, 30) = 1.0 second. Full jitter chooses a wait from 0 to 1.0 seconds. The third attempt succeeds with HTTP 200, so the response is returned and no later attempts run.

6. Preserve idempotency across attempts

One idempotency key represents one logical request. The same key is passed to every attempt. When the provider supports idempotent requests, this lets repeated attempts be recognized as the same operation and helps prevent duplicate side effects.

7. Explain the production boundary and complexity

Retries do not make every request safe automatically. The operation must be suitable for retrying, and the provider must support the idempotency behavior being used. Let A be max_attempts. The helper makes at most A API attempts, so its retry-control work is O(A). It stores only a few counters, exceptions, and delay values, so its auxiliary space is O(1).

Key Insight / Why This Solution Works

The key idea is to classify the failure before deciding to retry. The invariant is that every new attempt happens only after a transient failure, while the same idempotency key is reused for the whole logical request. Permanent errors fail immediately. A transient error continues only when attempts remain. The next wait comes from a bounded Retry-After hint when available. Otherwise, the code uses capped exponential backoff with full jitter. This keeps retries bounded, avoids an unnecessary final sleep, and reduces synchronized retry spikes across clients.

Code
from __future__ import annotations

import asyncio
import random
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Optional


@dataclass
class RetryConfig:
    # Bound the retry loop so one logical request cannot retry forever.
    max_attempts: int = 6
    base_delay: float = 0.5
    max_delay: float = 30.0
    timeout: float = 30.0


class APIError(Exception):
    # Provider-neutral exception used by the runnable example.
    def __init__(
        self,
        message: str,
        *,
        status_code: Optional[int] = None,
        retry_after: Optional[float] = None,
    ) -> None:
        super().__init__(message)
        self.status_code = status_code
        self.retry_after = retry_after


def is_transient(exc: BaseException) -> bool:
    # Timeouts and connection failures may succeed when tried again later.
    if isinstance(exc, (asyncio.TimeoutError, ConnectionError, OSError)):
        return True

    # Retry rate limits and server errors, but fail fast for other 4xx errors.
    status = getattr(exc, "status_code", None)
    if status is not None:
        if status == 429 or 500 <= status < 600:
            return True
        return False

    # Unknown failures are not retried by default.
    return False


def _parse_retry_after(exc: Exception) -> Optional[float]:
    # Read a provider retry hint without depending on a specific SDK type.
    raw = getattr(exc, "retry_after", None)
    if raw is None:
        return None

    try:
        value = float(raw)
        # Ignore negative hints because a wait cannot be negative.
        return value if value >= 0 else None
    except (TypeError, ValueError):
        # Invalid hints fall back to exponential backoff with jitter.
        return None


async def call_llm_with_retry(
    call: Callable[[str], Awaitable[Any]],
    cfg: RetryConfig,
    idempotency_key: str,
) -> Any:
    """Call an LLM API with bounded retries and one stable idempotency key."""

    last_exc: Optional[BaseException] = None

    # Try at most max_attempts times and return immediately on success.
    for attempt in range(1, cfg.max_attempts + 1):
        try:
            # Reuse the same key for every attempt of this logical request.
            return await asyncio.wait_for(
                call(idempotency_key),
                timeout=cfg.timeout,
            )
        except asyncio.CancelledError:
            # Cancellation must stop immediately and must never become a retry.
            raise
        except Exception as exc:
            # Remember the current failure in case defensive fallback is needed.
            last_exc = exc

            # Permanent failures should fail fast instead of repeating the request.
            if not is_transient(exc):
                raise

            # Do not calculate or sleep after the final allowed failed attempt.
            if attempt >= cfg.max_attempts:
                raise

            retry_after = _parse_retry_after(exc)
            if retry_after is not None:
                # Honor the server hint, but keep it inside the configured cap.
                wait = min(retry_after, cfg.max_delay)
            else:
                # Increase the possible delay exponentially, then cap it.
                backoff_cap = min(
                    cfg.base_delay * (2 ** (attempt - 1)),
                    cfg.max_delay,
                )

                # Full jitter spreads clients across the whole allowed range.
                wait = random.uniform(0.0, backoff_cap)

            # Sleep only when the failure is transient and another attempt remains.
            await asyncio.sleep(max(0.0, wait))

    # Defensive fallback. Normal execution returns or raises inside the loop.
    if last_exc is not None:
        raise last_exc
    raise RuntimeError("Retry loop ended without a result")


class FakeLLMAPI:
    """Deterministic fake that follows the diagram's 429 -> 503 -> 200 flow."""

    def __init__(self) -> None:
        self.attempt: int = 0

    async def __call__(self, idempotency_key: str) -> str:
        # Count each API attempt while accepting the same idempotency key.
        self.attempt += 1
        print(f"attempt={self.attempt}, key={idempotency_key}")

        if self.attempt == 1:
            # Attempt 1: rate limited with Retry-After: 2 seconds.
            raise APIError(
                "Too Many Requests",
                status_code=429,
                retry_after=2.0,
            )

        if self.attempt == 2:
            # Attempt 2: temporary server failure, so normal backoff is used.
            raise APIError("Service Unavailable", status_code=503)

        # Attempt 3: success stops the retry loop immediately.
        return "200 OK"


async def main() -> None:
    # One stable key represents this one logical LLM request.
    idempotency_key = "48e2f9b7-c71a-4c44-8e48-a8c7c2b5a3e1"
    cfg = RetryConfig()
    fake_api = FakeLLMAPI()

    # Seed randomness only so the local demonstration is repeatable.
    random.seed(7)

    response = await call_llm_with_retry(
        fake_api,
        cfg,
        idempotency_key,
    )
    print("response:", response)


if __name__ == "__main__":
    asyncio.run(main())
Time & Space Complexity

Let A be max_attempts. The loop can perform at most A LLM API calls, so the retry-control work is O(A). Actual wall-clock time also includes API latency, per-attempt timeouts, and backoff waits. The helper stores only the current attempt number, the latest exception, and a few delay values. That extra memory does not grow with A, so auxiliary space is O(1).

Where it is used

This retry pattern is useful for LLM and other remote API calls that can fail for short-lived reasons. Typical examples are rate limits, temporary server failures, request timeouts, and connection resets. It is appropriate when the operation is safe to retry and idempotency is handled correctly.

Why Interviewers Ask This

The interviewer is testing whether you can turn a basic retry loop into safe production behavior. They want to see correct transient-error classification, bounded attempts, timeout handling, exponential backoff, jitter, Retry-After support, cancellation propagation, and idempotency reasoning. They are also checking whether your control flow is precise, especially around permanent failures and the final allowed attempt.

Common interview mistakes

A common mistake is retrying every exception, including permanent 4xx errors. Another is forgetting to cap max_attempts or sleeping after the final allowed failure. Candidates may use exponential backoff without jitter, which can make many clients retry together. They may ignore Retry-After or allow a retry hint to exceed the configured delay cap. Another important mistake is generating a new idempotency key for each attempt instead of reusing one key for the same logical request.

Interview tip

Explain the decision order clearly: make the call, classify the failure, fail fast if it is permanent, stop if no attempts remain, then use either bounded Retry-After or bounded exponential backoff with full jitter. Also state that every attempt receives the same idempotency key.

Interviewer may ask next
How should cancellation behave while the request is retrying?

asyncio.CancelledError should be propagated immediately. It is not a transient API failure, so the retry helper must not convert it into another attempt. This lets shutdown or user cancellation stop the operation promptly. The maximum work is still O(A) attempts with O(1) auxiliary space, but cancellation can stop the loop earlier.

What should the retry helper do when the server sends Retry-After?

It should use a valid Retry-After value instead of the normal exponential-backoff calculation, because the server is providing a retry hint. The value should still be capped by max_delay. If the hint is missing, invalid, or negative, the helper falls back to capped exponential backoff with full jitter. The attempt bound and O(1) auxiliary space stay the same.

122. Implement semantic search using embeddings and cosine similarity.CodingMedium

Question Details

The submitted solution should batch or normalize embeddings, compute cosine scores, return stable top-k results with IDs and scores, and handle empty indexes or dimension mismatches.

Short Interview Answer (30-60 seconds)

I would store each document ID with an L2-normalized embedding vector. I would normalize the query in the same way. Because both vectors have length 1, cosine similarity becomes a dot product. I score every stored document and keep only the best top-k candidates in a bounded min-heap. I return IDs and scores in stable order: higher score first, then smaller ID on a tie. Building the index uses O(N*d) local vector work. Exact search uses O(N*d + N log k + k log k) time and O(k) temporary search space.

Detailed Explanation

See the Code while reading this explanation.

The goal is to search text by meaning instead of matching only the same words. Each document has an ID and a vector that represents its meaning. We normalize each document vector before storing it. We also normalize the query vector. Then we compare the query with every stored document. A larger cosine score means the document is more similar. The result contains the best top-k document IDs and scores in a stable order. The implementation also handles an empty index and rejects vectors with the wrong dimension.

Useful Questions to Ask the Interviewer
  1. Should each result contain both the document ID and cosine score?
  2. How should equal cosine scores be ordered? I would use ascending document ID so the result is stable.
  3. Should searching an empty index return an empty list? The approved design returns an empty list.
  4. Should a query with the wrong embedding dimension raise an error? The approved design raises ValueError.
Implement semantic search using embeddings and cosine similarity. diagram
How to Explain It in an Interview
1. Understand the input and required output

The index receives document ID and text pairs. An embedding provider converts the text into vectors. The search method receives a query string and top_k. It returns structured results containing a document ID and cosine score.

The diagram uses the query "async in Python". Its shown top-3 result is D2 with 0.86, D1 with 0.72, and D5 with 0.45.

2. Create and store normalized document embeddings

Documents are sent to the embedding provider in batches. This avoids one provider call for every document. Each returned vector is L2-normalized. L2 normalization means dividing each value by the vector length so the resulting nonzero vector has length 1.

The in-memory index stores pairs of document ID and normalized vector. The first stored vector sets the expected embedding dimension. Every later vector must have the same number of values. If the dimension is different, the code raises ValueError.

3. Embed and normalize the query

For a search, the provider creates one embedding for the query. The code normalizes it with the same L2 rule used for documents. The query dimension must match the index dimension. A mismatch raises ValueError.

This shared normalization matters because cosine similarity is A dot B divided by the lengths of A and B. When both nonzero vectors already have length 1, the cosine score is simply their dot product.

4. Score every document and keep the top-k

The code processes each stored document vector. It multiplies matching vector values and adds the products. This dot product is the cosine score because the vectors are normalized.

A bounded min-heap stores at most k candidates. The heap keeps the current worst selected candidate at its root. When a new document is better, it replaces that root. For equal scores, a smaller document ID is better. This produces deterministic tie handling.

After all indexed documents are scored, the selected candidates are sorted by score from highest to lowest and then by ID from smallest to largest. The diagram's final result is D2 at 0.86, D1 at 0.72, and D5 at 0.45.

5. Explain why the result is correct

Every stored document vector and the query vector use the same normalization rule. Therefore the dot product gives cosine similarity for nonzero normalized vectors. Every indexed document is scored. After each processed document, the bounded heap contains the best candidates seen so far, up to size k. A worse candidate cannot remove a better one. At the end, the heap therefore contains the best top-k candidates. The final sort gives the required stable output order.

6. Explain the Python implementation

EmbeddingProvider is an async provider-neutral interface. SemanticSearch does not depend on a specific model vendor. add_texts embeds documents in batches, validates the number of returned vectors, normalizes them, checks dimensions, and stores ID-vector pairs.

search first handles an empty index or non-positive top_k. It embeds and normalizes the query, validates its dimension, calculates the dot product with every stored vector, and maintains a bounded heap. It finally sorts the selected candidates into stable output order and returns SearchResult objects.

The runnable example uses deterministic fake embeddings. This means the example produces the diagram's known scores without inventing results from a real external embedding service.

7. Explain complexity and edge cases

Let N be the number of indexed documents, d be the embedding dimension, and k be the requested result count. Normalizing all document vectors requires O(N*d) local work after embeddings are returned. Exact search performs N dot products, which costs O(N*d). Maintaining a heap of at most k entries adds O(N log k), and sorting the final selected results adds O(k log k). The stored index uses O(N*d) space, while the search heap uses O(k) temporary space.

Relevant edge cases are an empty index, top_k less than or equal to zero, an embedding provider returning no query vector, a zero vector, an unexpected vector count from a batch, and an embedding dimension mismatch.

Key Insight / Why This Solution Works

The main idea is to compare meaning with normalized embedding vectors. The index stores each document ID beside its normalized vector. The central invariant is that every stored vector has the same dimension and has already passed through the same L2-normalization step. The query is normalized in the same way. For nonzero unit vectors, cosine similarity becomes a dot product. The algorithm scores every indexed document and keeps only the best top-k candidates in a bounded min-heap. It then sorts those candidates by score descending and ID ascending so ties are stable and deterministic.

Code
from __future__ import annotations

import asyncio
from dataclasses import dataclass
from functools import total_ordering
import heapq
import math
from typing import Protocol, Sequence


Vector = list[float]


class EmbeddingProvider(Protocol):
    async def embed(self, texts: Sequence[str]) -> list[Vector]:
        """Return one embedding vector for each supplied text."""
        ...


@dataclass(frozen=True)
class SearchResult:
    id: str
    score: float


@total_ordering
class _ReverseId:
    """Reverse ID order so a larger ID is worse when scores tie."""

    def __init__(self, value: str) -> None:
        self.value = value

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, _ReverseId):
            return NotImplemented
        return self.value == other.value

    def __lt__(self, other: _ReverseId) -> bool:
        # Reverse normal string order inside the min-heap.
        # This makes the larger ID reach the root first on an equal score.
        return self.value > other.value


class SemanticSearch:
    def __init__(self, provider: EmbeddingProvider) -> None:
        self._provider = provider

        # Each stored entry is: (document ID, normalized embedding vector).
        self._docs: list[tuple[str, Vector]] = []

        # The first valid vector sets the index dimension.
        self._dim: int | None = None

    @staticmethod
    def _l2_normalize(vector: Sequence[float]) -> Vector:
        # Calculate the Euclidean length used by L2 normalization.
        norm = math.sqrt(sum(value * value for value in vector))

        # Avoid division by zero for an all-zero embedding.
        if norm == 0.0:
            return [0.0 for _ in vector]

        # Unit-length vectors let cosine similarity use a dot product.
        return [value / norm for value in vector]

    async def add_texts(
        self,
        items: Sequence[tuple[str, str]],
        batch: int = 32,
    ) -> None:
        """Embed documents in batches and store normalized vectors."""
        # Empty input does not change the index.
        if not items:
            return

        # A non-positive batch size would make batching invalid.
        if batch <= 0:
            raise ValueError("batch must be greater than zero")

        texts = [text for _, text in items]

        # Send several texts per async provider call.
        for start in range(0, len(texts), batch):
            chunk = texts[start : start + batch]
            vectors = await self._provider.embed(chunk)

            # The provider must return one vector for every text in this batch.
            if len(vectors) != len(chunk):
                raise ValueError("Embedding provider returned the wrong vector count")

            # Pair every returned vector with its original document ID.
            for (doc_id, _), vector in zip(
                items[start : start + batch],
                vectors,
            ):
                normalized = self._l2_normalize(vector)

                # The first vector defines the shared embedding dimension.
                if self._dim is None:
                    self._dim = len(normalized)

                # Later vectors must have the same dimension.
                elif len(normalized) != self._dim:
                    raise ValueError(
                        f"Dimension mismatch: expected {self._dim}, got {len(normalized)}"
                    )

                # Store the ID beside its normalized embedding.
                self._docs.append((doc_id, normalized))

    async def search(
        self,
        query: str,
        top_k: int = 5,
    ) -> list[SearchResult]:
        """Return stable top-k document IDs and cosine scores."""
        # An empty index has no possible matches.
        if not self._docs:
            return []

        # Asking for zero or fewer results also returns nothing.
        if top_k <= 0:
            return []

        # Embed the query through the same provider boundary.
        query_vectors = await self._provider.embed([query])

        # Defensive handling if the provider returns no query vector.
        if not query_vectors:
            return []

        # The query request should produce exactly one vector.
        if len(query_vectors) != 1:
            raise ValueError("Embedding provider returned the wrong query vector count")

        # Normalize the query with the same rule used for documents.
        query_vector = self._l2_normalize(query_vectors[0])

        # Dot products require equal vector dimensions.
        if self._dim is None or len(query_vector) != self._dim:
            raise ValueError(f"Query dimension {len(query_vector)} != index dimension {self._dim}")

        limit = min(top_k, len(self._docs))

        # The heap stores at most k candidates.
        # The root is the current worst selected candidate.
        heap: list[tuple[float, _ReverseId, str]] = []

        for doc_id, doc_vector in self._docs:
            # Both vectors are normalized.
            # Their dot product is therefore the cosine score for nonzero vectors.
            score = sum(
                query_value * doc_value
                for query_value, doc_value in zip(
                    query_vector,
                    doc_vector,
                )
            )

            # A smaller document ID is better when two scores are equal.
            entry = (score, _ReverseId(doc_id), doc_id)

            # Fill the heap until it contains k candidates.
            if len(heap) < limit:
                heapq.heappush(heap, entry)
                continue

            # Replace the current worst candidate only when this one is better.
            if entry > heap[0]:
                heapq.heapreplace(heap, entry)

        # Convert the heap into the stable output order shown in the diagram:
        # score descending, then document ID ascending.
        selected = [(score, doc_id) for score, _, doc_id in heap]
        selected.sort(key=lambda item: (-item[0], item[1]))

        # Return structured IDs and scores.
        return [SearchResult(id=doc_id, score=round(score, 6)) for score, doc_id in selected]


class FakeEmbeddingProvider:
    """Deterministic provider used only for the runnable example."""

    def __init__(self) -> None:
        # The query vector is [1, 0].
        # Every document vector below has length 1.
        # Its first value is therefore its cosine score with the query.
        self._vectors: dict[str, Vector] = {
            "async in Python": [1.0, 0.0],
            "Python async tips": self._unit_vector(0.72),
            "Vector databases explained": self._unit_vector(0.86),
            "What is cosine similarity?": self._unit_vector(0.20),
            "D5 example document": self._unit_vector(0.45),
            "Guide to LLMs": self._unit_vector(-0.12),
        }

    @staticmethod
    def _unit_vector(score: float) -> Vector:
        # Build a unit vector [score, y] where score^2 + y^2 = 1.
        second = math.sqrt(max(0.0, 1.0 - score * score))
        return [score, second]

    async def embed(self, texts: Sequence[str]) -> list[Vector]:
        # Return known vectors so the example is deterministic.
        # No external model call is made.
        return [list(self._vectors[text]) for text in texts]


async def main() -> None:
    provider = FakeEmbeddingProvider()
    search = SemanticSearch(provider)

    # Build the small in-memory document index used by the diagram example.
    await search.add_texts(
        [
            ("D1", "Python async tips"),
            ("D2", "Vector databases explained"),
            ("D3", "What is cosine similarity?"),
            ("D5", "D5 example document"),
            ("D7", "Guide to LLMs"),
        ]
    )

    # Run the exact query and top-3 request shown in the diagram.
    results = await search.search("async in Python", top_k=3)

    # Expected stable output:
    # D2: 0.86
    # D1: 0.72
    # D5: 0.45
    for result in results:
        print(f"{result.id}: {result.score:.2f}")


if __name__ == "__main__":
    asyncio.run(main())
Time & Space Complexity

Let N be the number of documents, d be the number of values in one embedding, and k be top_k. Building the stored normalized vectors needs O(N*d) local vector work after the provider returns the embeddings. During search, comparing the query with all N documents costs O(N*d). Updating a heap of size at most k can cost O(log k) for each document, so heap maintenance is O(N log k). Sorting the final k selected results costs O(k log k). The exact search bound is therefore O(N*d + N log k + k log k). The index stores O(N*d) vector data. The bounded heap uses O(k) extra search memory.

Where it is used

This pattern is useful when users want results with similar meaning instead of only exact word matches. It appears in document search, help-center search, knowledge retrieval, recommendation-like matching, and retrieval before an AI assistant generates an answer. The provider-neutral boundary also lets the application change embedding providers without changing the core cosine-ranking logic.

Why Interviewers Ask This

The interviewer is testing whether you can turn an AI idea into correct code. They want to see that you understand embeddings, normalization, cosine similarity, vector dimensions, and top-k ranking. They also want clean async Python, a provider-neutral model boundary, batching, structured output, deterministic testing, and safe handling of empty data or bad dimensions. A strong answer also explains the real complexity of the exact implementation instead of repeating an inaccurate simplified bound.

Common interview mistakes

One mistake is using raw embeddings but still calling their dot product cosine similarity. Normalize both sides first, or divide by both vector lengths during scoring. Another mistake is forgetting to check vector dimensions. Candidates may also return only scores and lose the document IDs. Stable tie handling is easy to miss. If two scores are equal, this design returns the smaller ID first. Another mistake is keeping or sorting all results when only top-k values are needed. Finally, the code should handle an empty index instead of assuming at least one document exists.

Interview tip

Explain normalization before the ranking data structure. First say that document and query embeddings are normalized to length 1. Then the interviewer can immediately see why cosine similarity becomes a dot product. After that, explain that the bounded heap keeps only the best k candidates and that the final sort gives stable score-descending, ID-ascending output.

Interviewer may ask next
How would this change if the index had millions of documents?

The exact full scan would become expensive because every query compares against all N stored vectors. I would keep the same normalized-embedding and cosine-similarity meaning, but replace the full in-memory scan with an approximate nearest-neighbor vector index. What changes is the retrieval structure, not the meaning of the score. Exact search here costs O(N*d + N log k + k log k). An approximate index can make queries much faster in practice, but its exact cost depends on the index type and it may trade some recall for speed. It also needs extra index memory.

Why normalize embeddings before computing the dot product?

Cosine similarity is the dot product divided by the length of both vectors. After L2 normalization, each nonzero vector has length 1. The denominator therefore becomes 1, so cosine similarity is just the dot product. Document vectors are normalized once when they enter the index. The query is normalized once per search. This keeps the score calculation simple while preserving the same cosine ranking.

123. Implement a basic RAG pipeline using an embedding model and a vector database.CodingMedium

Question Details

The practical exercise should ingest documents, chunk with provenance, embed and index them, retrieve top candidates, build bounded context, return an answer with sources, and handle no-evidence cases.

Short Interview Answer (30-60 seconds)

I would build the RAG pipeline in two phases. First, I ingest documents, split them into chunks with provenance, embed each chunk, and store the vectors with metadata in a vector database. For a query, I embed it with the same model, retrieve the top-k candidates, check that the evidence is sufficient, and build bounded context. Then I generate a source-backed answer. If evidence is weak, I return no evidence. Exact scan is O(N·d) per query, with O(N·d) vector storage plus chunk text and metadata.

Detailed Explanation

See the Code while reading this explanation.

This task asks me to build a small system that answers a question from supplied documents. I first load the documents and split them into smaller pieces. I keep where each piece came from. Then I turn each piece into a numeric vector and store it. When a question arrives, I find the most related pieces, check that they are useful enough, and place only a limited amount of them into the answer context. If the evidence is weak or empty, I return a no-evidence response instead of making up an answer.

Useful Questions to Ask the Interviewer
  1. What document formats should the loader support?
  2. What configured relevance policy should decide whether retrieved evidence is good enough?
  3. What token budget should I use for the bounded context?
  4. Should the vector store use exact search or an approximate nearest-neighbor index?
Implement a basic RAG pipeline using an embedding model and a vector database. diagram
How to Explain It in an Interview
1. Ingest and chunk with provenance

The input is a set of documents. I split each document into fixed-size chunks with overlap. Every chunk keeps provenance. Provenance means where the text came from. The diagram keeps fields such as source, page, chunk_id, start_char, and end_char. This lets the final answer point back to its evidence.

2. Embed and index the chunks

I send each chunk text to one embedding model. An embedding is a fixed-length numeric vector that represents the text for similarity search. I store each vector with the chunk text and provenance in a provider-neutral vector database or vector index.

The document chunks and the user query use the same embedding model. This puts them in the same vector space, so their similarity scores are meaningful.

3. Retrieve the top-k candidates

For the diagram's example query, What is RAG?, I embed the query with the same model. I run vector similarity search and request the top k candidates. The diagram uses k = 5.

The retrieved list is ordered by relevance. The diagram shows illustrative scores of 0.92, 0.87, and 0.73 for c1, c2, and ck. These are example values in the audited diagram, not measurements from an external provider.

4. Check evidence before building context

The evidence gate runs after top-k retrieval. It asks, SUFFICIENT RELEVANT EVIDENCE?

If the answer is NO, the pipeline stops before factual generation. It returns: I don't have enough information in the provided sources to answer this question. The source list is empty.

If the answer is YES, the pipeline continues to context construction. The exact relevance rule is configurable. The diagram does not require one fixed numeric threshold.

5. Build bounded context

I order the accepted chunks by relevance, remove duplicates, and fit them inside a token budget. The diagram uses an example budget of 800 tokens. This prevents the prompt from growing without a limit.

Each context entry keeps provenance. The diagram shows c1 with score 0.92, c2 with score 0.87, and ck with score 0.73.

6. Generate an answer with sources

The generator receives the user question and only the bounded retrieved context. The diagram's example answer is: RAG uses retrieved, relevant documents to ground the model and improve answer quality.

The shown sources are guide.pdf, page 2, chunk c1, and notes.md, page 5, chunk c2. The structured output also carries a no_evidence flag.

7. Explain correctness, complexity, and operational behavior

The central invariant is simple: factual generation only happens after retrieved evidence passes the configured evidence policy. The context is built only from retrieved chunks, and provenance stays attached to those chunks.

Let N be the number of indexed chunks and d the vector dimension. Producing the stored vectors has O(N·d) vector-output work, excluding the embedding model's internal cost. An exact vector scan costs O(N·d) per query. ANN index build and query cost depend on the vector database and its configuration. Context assembly processes up to K chunks plus the copied text.

The implementation also keeps per-stage timeouts, bounded retries for transient failures, cancellation propagation, deterministic fakes for tests, and unit plus integration tests.

Key Insight / Why This Solution Works

The key idea is to separate retrieval from generation.

First, ingest documents and split them into overlapping chunks while keeping provenance. Embed each chunk and index its vector with the chunk metadata. At query time, embed the question with the same model and retrieve the top-k candidates.

The central invariant is: factual generation only happens after the retrieved set passes the configured evidence policy. If the set is empty or fails that policy, return the no-evidence response. If it passes, deduplicate the retrieved chunks, build bounded context, and generate an answer with sources.

This design works because retrieval supplies external evidence, while provenance lets the system show where the answer came from.

Code
from __future__ import annotations

import asyncio
import math
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol, TypeVar


T = TypeVar("T")


@dataclass(frozen=True)
class Document:
    source: str
    page: int | None
    text: str


@dataclass(frozen=True)
class Chunk:
    chunk_id: str
    text: str
    source: str
    page: int | None
    start_char: int
    end_char: int


@dataclass(frozen=True)
class RetrievedChunk(Chunk):
    score: float


@dataclass(frozen=True)
class Source:
    source: str
    page: int | None
    chunk_id: str
    score: float


@dataclass(frozen=True)
class Answer:
    answer: str
    sources: list[Source]
    no_evidence: bool


class DocumentLoader(Protocol):
    async def load(self, paths: list[Path]) -> list[Document]: ...


class EmbeddingModel(Protocol):
    async def embed(self, texts: list[str]) -> list[list[float]]: ...


class VectorDatabase(Protocol):
    async def upsert(
        self,
        chunks: list[Chunk],
        vectors: list[list[float]],
    ) -> None: ...

    async def search(
        self,
        query_vector: list[float],
        k: int,
    ) -> list[RetrievedChunk]: ...


class Generator(Protocol):
    async def generate(self, query: str, context: str) -> str: ...


class EvidencePolicy(Protocol):
    def accepts(self, results: list[RetrievedChunk]) -> bool: ...


async def call_with_timeout_and_retries(
    operation: Callable[[], Awaitable[T]],
    *,
    timeout_seconds: float = 2.0,
    max_attempts: int = 3,
) -> T:
    # Retry only transient timeout/connection failures. The retry count is bounded.
    for attempt in range(1, max_attempts + 1):
        try:
            # asyncio.timeout cancels an operation that exceeds the per-call limit.
            async with asyncio.timeout(timeout_seconds):
                return await operation()
        except (TimeoutError, ConnectionError):
            if attempt == max_attempts:
                raise

            # Back off briefly before the next bounded retry.
            await asyncio.sleep(0.05 * (2 ** (attempt - 1)))

    raise RuntimeError("unreachable")


class FakeDocumentLoader:
    """Deterministic loader used only by the runnable example."""

    async def load(self, paths: list[Path]) -> list[Document]:
        # Map the example file names to fixed source text and page provenance.
        fixtures = {
            "guide.pdf": Document(
                source="guide.pdf",
                page=2,
                text=(
                    "RAG uses retrieval to bring relevant documents into the "
                    "model context before generation."
                ),
            ),
            "notes.md": Document(
                source="notes.md",
                page=5,
                text=("Retrieved evidence grounds the answer and lets the system return sources."),
            ),
            "handbook.md": Document(
                source="handbook.md",
                page=7,
                text=(
                    "A bounded context keeps only a limited amount of retrieved "
                    "evidence in the prompt."
                ),
            ),
        }

        # Fail clearly if the caller asks for a source outside this deterministic demo.
        return [fixtures[path.name] for path in paths]


class DeterministicEmbeddingModel:
    """Deterministic fake that creates the diagram's illustrative similarity scores."""

    _vectors = {
        "What is RAG?": [1.0, 0.0],
        (
            "RAG uses retrieval to bring relevant documents into the "
            "model context before generation."
        ): [0.92, math.sqrt(1.0 - 0.92**2)],
        ("Retrieved evidence grounds the answer and lets the system return sources."): [
            0.87,
            math.sqrt(1.0 - 0.87**2),
        ],
        ("A bounded context keeps only a limited amount of retrieved evidence in the prompt."): [
            0.73,
            math.sqrt(1.0 - 0.73**2),
        ],
    }

    async def embed(self, texts: list[str]) -> list[list[float]]:
        # Return fixed normalized vectors so the runnable example is repeatable.
        try:
            return [self._vectors[text] for text in texts]
        except KeyError as exc:
            raise ValueError("No deterministic embedding fixture for this text.") from exc


class InMemoryVectorDatabase:
    """Exact-scan vector database used only for the runnable example."""

    def __init__(self) -> None:
        self._rows: list[tuple[Chunk, list[float]]] = []

    async def upsert(
        self,
        chunks: list[Chunk],
        vectors: list[list[float]],
    ) -> None:
        # Keep each vector beside the chunk text and provenance metadata.
        self._rows = list(zip(chunks, vectors, strict=True))

    async def search(
        self,
        query_vector: list[float],
        k: int,
    ) -> list[RetrievedChunk]:
        scored: list[RetrievedChunk] = []

        for chunk, vector in self._rows:
            # The demo vectors are normalized, so dot product equals cosine similarity.
            score = sum(left * right for left, right in zip(query_vector, vector, strict=True))
            scored.append(
                RetrievedChunk(
                    chunk_id=chunk.chunk_id,
                    text=chunk.text,
                    source=chunk.source,
                    page=chunk.page,
                    start_char=chunk.start_char,
                    end_char=chunk.end_char,
                    score=score,
                )
            )

        # Return the top-k candidates from highest to lowest similarity.
        scored.sort(key=lambda item: item.score, reverse=True)
        return scored[:k]


class DeterministicGenerator:
    """Deterministic fake used only to keep the example provider-neutral and testable."""

    async def generate(self, query: str, context: str) -> str:
        # A real generator would receive the same query and bounded context.
        if not context.strip():
            raise ValueError("Context must not be empty on the evidence-present path.")

        # Keep the fake grounded in the deterministic retrieved evidence.
        if "RAG uses retrieval" not in context:
            raise ValueError("Expected supporting RAG evidence in the bounded context.")

        return (
            "RAG uses retrieved, relevant documents to ground the model and improve answer quality."
        )


class AcceptNonEmptyEvidence:
    """Deterministic test policy; production can apply any configured relevance rule."""

    def accepts(self, results: list[RetrievedChunk]) -> bool:
        # The real policy may use scores or other retrieval signals.
        return bool(results)


class BasicRAG:
    def __init__(
        self,
        *,
        loader: DocumentLoader,
        embedding_model: EmbeddingModel,
        vector_db: VectorDatabase,
        generator: Generator,
        evidence_policy: EvidencePolicy,
    ) -> None:
        self.loader = loader
        self.embedding_model = embedding_model
        self.vector_db = vector_db
        self.generator = generator
        self.evidence_policy = evidence_policy

    async def ingest(self, paths: list[Path]) -> list[Document]:
        # Keep file loading behind an async boundary with timeout and bounded retries.
        return await call_with_timeout_and_retries(lambda: self.loader.load(paths))

    def chunk(
        self,
        documents: list[Document],
        *,
        size: int = 800,
        overlap: int = 120,
    ) -> list[Chunk]:
        # Validate the overlap so every loop iteration moves forward.
        if size <= 0 or overlap < 0 or overlap >= size:
            raise ValueError("Require size > 0 and 0 <= overlap < size.")

        chunks: list[Chunk] = []
        next_id = 1

        for document in documents:
            start = 0

            while start < len(document.text):
                end = min(start + size, len(document.text))

                # Keep source, page, and character offsets with every chunk.
                chunk_id = "ck" if next_id == 3 else f"c{next_id}"
                chunks.append(
                    Chunk(
                        chunk_id=chunk_id,
                        text=document.text[start:end],
                        source=document.source,
                        page=document.page,
                        start_char=start,
                        end_char=end,
                    )
                )
                next_id += 1

                if end == len(document.text):
                    break

                # Move forward while keeping the requested overlap.
                start = end - overlap

        return chunks

    async def embed(self, texts: list[str]) -> list[list[float]]:
        # Keep the embedding provider behind the same timeout/retry wrapper.
        return await call_with_timeout_and_retries(lambda: self.embedding_model.embed(texts))

    async def index(
        self,
        chunks: list[Chunk],
        vectors: list[list[float]],
    ) -> None:
        # Store vectors together with chunk text and provenance.
        await call_with_timeout_and_retries(lambda: self.vector_db.upsert(chunks, vectors))

    async def retrieve(
        self,
        query: str,
        *,
        k: int = 5,
    ) -> list[RetrievedChunk]:
        # Embed the query with the same model used for document chunks.
        query_vector = (await self.embed([query]))[0]

        # Retrieve the top-k candidates from the vector database.
        return await call_with_timeout_and_retries(lambda: self.vector_db.search(query_vector, k))

    def has_sufficient_evidence(
        self,
        results: list[RetrievedChunk],
    ) -> bool:
        # Keep the evidence rule configurable instead of inventing one fixed threshold.
        return self.evidence_policy.accepts(results)

    def build_context(
        self,
        results: list[RetrievedChunk],
        *,
        max_tokens: int = 800,
    ) -> tuple[str, list[RetrievedChunk]]:
        # This deterministic demo counts whitespace-separated words as test tokens.
        # Production code should use the generator model's real tokenizer.
        context_parts: list[str] = []
        used_results: list[RetrievedChunk] = []
        seen_chunk_ids: set[str] = set()
        used_tokens = 0

        for result in results:
            # Deduplicate by chunk ID before adding retrieved evidence.
            if result.chunk_id in seen_chunk_ids:
                continue

            words = result.text.split()
            remaining = max_tokens - used_tokens
            if remaining <= 0:
                break

            selected_words = words[:remaining]
            if not selected_words:
                continue

            # Carry provenance into the bounded context for later source reporting.
            context_parts.append(
                f"[source={result.source}, page={result.page}, "
                f"chunk_id={result.chunk_id}, score={result.score:.2f}]\n"
                + " ".join(selected_words)
            )
            used_results.append(result)
            seen_chunk_ids.add(result.chunk_id)
            used_tokens += len(selected_words)

        return "\n\n".join(context_parts), used_results

    async def generate(
        self,
        query: str,
        context: str,
        used_results: list[RetrievedChunk],
    ) -> Answer:
        # Generate only on the evidence-present path and only from bounded context.
        answer_text = await call_with_timeout_and_retries(
            lambda: self.generator.generate(query, context)
        )

        # The example answer cites the first two supporting chunks shown in the diagram.
        cited_results = used_results[:2]
        sources = [
            Source(
                source=result.source,
                page=result.page,
                chunk_id=result.chunk_id,
                score=result.score,
            )
            for result in cited_results
        ]

        return Answer(
            answer=answer_text,
            sources=sources,
            no_evidence=False,
        )

    def no_evidence_answer(self) -> Answer:
        # Do not call the generator when retrieved evidence fails the configured policy.
        return Answer(
            answer=(
                "I don't have enough information in the provided sources to answer this question."
            ),
            sources=[],
            no_evidence=True,
        )

    async def answer(
        self,
        query: str,
        *,
        k: int = 5,
        max_tokens: int = 800,
    ) -> Answer:
        # Retrieval happens first. Evidence gating happens only after top-k retrieval.
        results = await self.retrieve(query, k=k)

        if not self.has_sufficient_evidence(results):
            return self.no_evidence_answer()

        # Only accepted evidence is assembled into bounded context.
        context, used_results = self.build_context(
            results,
            max_tokens=max_tokens,
        )

        if not context:
            return self.no_evidence_answer()

        return await self.generate(query, context, used_results)


async def main() -> None:
    rag = BasicRAG(
        loader=FakeDocumentLoader(),
        embedding_model=DeterministicEmbeddingModel(),
        vector_db=InMemoryVectorDatabase(),
        generator=DeterministicGenerator(),
        evidence_policy=AcceptNonEmptyEvidence(),
    )

    # Ingest the three deterministic example sources.
    paths = [Path("guide.pdf"), Path("notes.md"), Path("handbook.md")]
    documents = await rag.ingest(paths)

    # Chunk with provenance, embed every chunk, and index vectors plus metadata.
    chunks = rag.chunk(documents, size=800, overlap=120)
    vectors = await rag.embed([chunk.text for chunk in chunks])
    await rag.index(chunks, vectors)

    # Run the same query and retrieval settings shown in the diagram.
    answer = await rag.answer(
        "What is RAG?",
        k=5,
        max_tokens=800,
    )

    print(answer.answer)
    for source in answer.sources:
        print(
            f"- {source.source}, p. {source.page}, "
            f"chunk {source.chunk_id}, score {source.score:.2f}"
        )


if __name__ == "__main__":
    asyncio.run(main())
Time & Space Complexity

Let N be the number of indexed chunks, d the embedding dimension, and K the number of retrieved candidates.

Embedding N chunks produces N vectors of length d, so the vector-output work is O(N·d). This excludes the embedding model's internal compute.

With the exact vector scan shown by the runnable example, one query compares its vector with all N stored vectors. That costs O(N·d) per query.

If a production vector database uses an approximate nearest-neighbor index, its build and query costs depend on the database and configuration. The diagram therefore does not claim one fixed Big-O bound for ANN search.

Context assembly processes up to K retrieved chunks plus the text copied into the bounded prompt.

The vector index stores O(N·d) vector values, plus the chunk text and provenance metadata. The query path also keeps the top-k results and bounded context in memory.

Where it is used

This pattern is useful when an AI system must answer from external documents instead of relying only on model memory. Examples include internal knowledge assistants, document question answering, support tools, policy search, technical documentation assistants, and enterprise search systems that need citations and a safe no-evidence path.

Why Interviewers Ask This

This question checks whether the candidate can connect several AI engineering pieces into one correct flow. The interviewer can see whether you understand provenance-aware chunking, a shared embedding space, vector retrieval, evidence gating, bounded context, structured sources, and no-evidence behavior. It also tests whether you can keep model and vector-database boundaries provider-neutral, write clear async Python, reason about exact versus ANN search, and use deterministic fakes instead of depending on live external services during tests.

Common interview mistakes

A common mistake is applying the evidence decision before top-k retrieval. The diagram retrieves candidates first, then checks whether the retrieved evidence is sufficient.

Another mistake is embedding document chunks with one model and the user query with a different model. The vectors must come from the same embedding space.

Candidates also forget provenance. A vector alone is not enough when the final answer must return sources.

Another mistake is sending every retrieved chunk without a limit. The context should be deduplicated and bounded by a token budget.

It is also wrong to call the generator for a factual answer when the evidence policy fails. The correct path returns the no-evidence response with no sources.

Finally, do not claim every vector database query is O(log N). Exact scan is O(N·d), while ANN behavior depends on the chosen index and configuration.

Interview tip

Explain the query path before showing code: embed the query, retrieve top-k candidates, apply the evidence gate, build bounded context, then generate an answer with sources. This makes the most important control-flow rule clear and shows that the no-evidence branch happens before factual generation.

Interviewer may ask next
How would you change this pipeline for a very large document collection?

I would keep the same high-level flow, but use an approximate nearest-neighbor, or ANN, index instead of an exact scan. ANN avoids comparing the query with every stored vector. The evidence gate, bounded context, source handling, and generation steps stay the same. Index build and query complexity become database- and configuration-dependent. The main tradeoff is faster retrieval and better scale in exchange for approximate search and more index tuning.

How would you handle weak or empty retrieval results?

I would keep the evidence gate directly after top-k retrieval. The gate applies the configured relevance policy to the retrieved set. If the set is empty or does not pass that policy, I would skip factual generation and return the no-evidence response with no sources. If it passes, I would build bounded context and generate normally. This preserves the diagram's control flow and avoids unsupported factual answers.

124. Build a simple AI agent with tool use (e.g., calculator, web search).CodingHard

Question Details

A complete solution needs to implement a bounded loop with an allowlisted tool registry, typed arguments, state, maximum steps, tool errors, and a final response when the stop condition is met.

Short Interview Answer (30-60 seconds)

I would build the agent as a bounded async loop. I keep explicit state and give the model only allowlisted tools with typed schemas. Each tool call is validated, run with a timeout, and recorded as an observation. The loop stops when the model returns a final answer or max_steps is reached. With k bounded iterations, controller loop work is O(k), excluding model and external tool runtime, and the stored step history uses O(k) extra entries.

Detailed Explanation

See the Code while reading this explanation.

The task is to build a small agent that can either answer the user or call a tool. The agent must not run arbitrary functions. It can only use tools from a fixed allowlist. Each tool call must have valid arguments. The agent keeps the conversation, earlier actions, and tool results in state. It also has max_steps, so the loop cannot continue forever. Tool failures become observations that the model can see. In the diagram example, the user asks, "What is 15% of 240?" and the agent returns "15% of 240 is 36."

Useful Questions to Ask the Interviewer
  1. Should unknown tools and invalid arguments be returned to the model as observations, or should they stop the agent?
  2. What max_steps value and per-tool timeout should I use?
  3. Should the model and web-search implementation be injected so the agent stays provider-neutral and easy to test?
Build a simple AI agent with tool use (e.g., calculator, web search). diagram
How to Explain It in an Interview
1. Define the input and output

The input is one user message, an injected async model, an allowlisted tool registry, and max_steps. The output is a final string. For the diagram example, the input is "What is 15% of 240?" and the final response is "15% of 240 is 36."

2. Initialize explicit state

The state stores messages, observable steps, the tool registry, the current step count, max_steps, and a start time. Each step stores only a structured tool action and its observation. The agent does not need to store hidden chain-of-thought.

3. Ask the model for one structured action

The agent builds the model input from the current messages and the available tool schemas. The model returns one structured action. It is either a final answer or a tool call with a tool name and arguments.

4. Validate and execute an allowlisted tool

The agent checks the tool name in the registry first. It then validates the arguments against that tool's schema. If validation passes, the tool runs with a timeout. A tool result or a tool error becomes an observation.

For the example, the model chooses the calculator with {"expr": "0.15 * 240"}. The calculator parses this arithmetic expression with an allowlist of AST nodes and operators. It does not use eval() or exec(). The calculator returns {"result": "36"}.

5. Update state and repeat

After the tool call, the agent appends the structured tool request and tool observation to the message history. It also records the same observable action and observation in steps, then increments the step count. The next model call can therefore see the tool result.

6. Stop safely

The normal stop condition is a model action of type final. The other hard stop is reaching max_steps. This prevents an infinite loop. Unknown tools, schema errors, timeouts, and tool exceptions are returned as observations so a later model step can recover or explain the failure.

7. Match the implementation to the diagram

The Python code uses typed dictionaries for messages, tool calls, tools, steps, and state. The model and tool functions are injected async callables. The tool registry is the allowlist. The main loop uses range(max_steps), so it performs at most max_steps model turns and at most max_steps tool executions.

Key Insight / Why This Solution Works

The key idea is to separate model choice from tool execution. The model may request a tool, but the controller is the only part allowed to execute one. The controller checks the allowlisted registry, validates typed arguments, applies a timeout, and records the result or error. The central invariant is: every executed tool call has a registered name and validated arguments, and every completed call produces an observable state update. The bounded loop repeats until a final answer appears or max_steps is reached.

Code
from __future__ import annotations

import ast
import asyncio
import json
import operator
import time
from collections.abc import Awaitable, Callable
from typing import Any, Literal, TypedDict


class ToolCall(TypedDict):
    name: str
    args: dict[str, Any]


class Step(TypedDict, total=False):
    action: ToolCall
    observation: str


class Message(TypedDict):
    role: Literal["system", "user", "assistant", "tool"]
    content: str


class Tool(TypedDict):
    name: str
    description: str
    input_schema: dict[str, Any]
    func: Callable[[dict[str, Any]], Awaitable[Any]]
    timeout: float


class State(TypedDict):
    messages: list[Message]
    steps: list[Step]
    tools: dict[str, Tool]
    step: int
    max_steps: int
    start_time: float


class FinalAction(TypedDict):
    type: Literal["final"]
    answer: str


class ToolAction(TypedDict):
    type: Literal["tool_call"]
    name: str
    args: dict[str, Any]


ModelAction = FinalAction | ToolAction
Model = Callable[[list[Message]], Awaitable[ModelAction]]


_ALLOWED_BIN_OPS: dict[type[ast.operator], Callable[[float, float], float]] = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
}

_ALLOWED_UNARY_OPS: dict[type[ast.unaryop], Callable[[float], float]] = {
    ast.UAdd: operator.pos,
    ast.USub: operator.neg,
}


def safe_eval(expression: str) -> float:
    """Evaluate only simple numeric arithmetic. Never use eval() or exec()."""
    # Parse text into a syntax tree without executing the expression.
    tree = ast.parse(expression, mode="eval")

    def visit(node: ast.AST) -> float:
        # Numeric constants are safe leaf values. Booleans are rejected.
        if (
            isinstance(node, ast.Constant)
            and isinstance(node.value, (int, float))
            and not isinstance(node.value, bool)
        ):
            return float(node.value)

        # Only explicitly allowlisted binary operators may run.
        if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_BIN_OPS:
            left = visit(node.left)
            right = visit(node.right)
            return _ALLOWED_BIN_OPS[type(node.op)](left, right)

        # Allow only unary plus and unary minus.
        if isinstance(node, ast.UnaryOp) and type(node.op) in _ALLOWED_UNARY_OPS:
            return _ALLOWED_UNARY_OPS[type(node.op)](visit(node.operand))

        # Reject calls, names, attributes, and every other AST node.
        raise ValueError("Unsupported calculator expression")

    return visit(tree.body)


def validate_args(schema: dict[str, Any], args: dict[str, Any]) -> None:
    """Validate the small object schemas used by this example."""
    # Tool arguments must be a JSON-like object.
    if not isinstance(args, dict):
        raise ValueError("Tool arguments must be an object")

    required = schema.get("required", [])
    properties = schema.get("properties", {})

    # Every required field must be present before the tool runs.
    for name in required:
        if name not in args:
            raise ValueError(f"Missing required argument: {name}")

    # Reject unknown fields when additionalProperties is false.
    if schema.get("additionalProperties") is False:
        unknown = set(args) - set(properties)
        if unknown:
            raise ValueError(f"Unknown arguments: {sorted(unknown)}")

    # Check the string and integer types used by the tool schemas.
    for name, value in args.items():
        expected = properties.get(name, {}).get("type")
        if expected == "string" and not isinstance(value, str):
            raise ValueError(f"{name} must be a string")
        if expected == "integer" and (not isinstance(value, int) or isinstance(value, bool)):
            raise ValueError(f"{name} must be an integer")


async def calculator_tool(args: dict[str, Any]) -> dict[str, str]:
    # Arguments were validated before this function is called.
    result = safe_eval(args["expr"])

    # Match the diagram example by displaying 36 instead of 36.0.
    text = str(int(result)) if result.is_integer() else str(result)
    return {"result": text}


def tool_schema_message(tools: dict[str, Tool]) -> Message:
    # Give the model only the allowlisted tool names and their input contracts.
    schemas = {
        name: {
            "description": tool["description"],
            "input_schema": tool["input_schema"],
        }
        for name, tool in tools.items()
    }
    return {
        "role": "system",
        "content": (
            "Available tools and schemas: "
            + json.dumps(schemas, ensure_ascii=False)
            + ". Return one structured action. Use type=final with an answer, "
            "or type=tool_call with a tool name and args."
        ),
    }


def build_prompt(state: State) -> list[Message]:
    # State already contains the system tool schemas and full conversation.
    return state["messages"]


async def execute_tool(
    action: ToolCall,
    tools: dict[str, Tool],
) -> dict[str, Any]:
    # The registry is the allowlist. Unknown tool names never execute.
    tool = tools.get(action["name"])
    if tool is None:
        return {"ok": False, "error": "Unknown tool"}

    try:
        # Validate typed arguments before calling the tool implementation.
        validate_args(tool["input_schema"], action["args"])

        # Bound each async tool call with its configured timeout.
        result = await asyncio.wait_for(
            tool["func"](action["args"]),
            timeout=tool["timeout"],
        )
        return {"ok": True, "result": result}
    except TimeoutError:
        return {"ok": False, "error": "Tool timeout"}
    except Exception as exc:
        # Validation and tool failures become observations for the next turn.
        return {"ok": False, "error": str(exc)}


async def run_agent(
    user_input: str,
    model: Model,
    tools: dict[str, Tool],
    max_steps: int = 8,
) -> str:
    # Initialize explicit state before the first model turn.
    state: State = {
        "messages": [
            tool_schema_message(tools),
            {"role": "user", "content": user_input},
        ],
        "steps": [],
        "tools": tools,
        "step": 0,
        "max_steps": max_steps,
        "start_time": time.monotonic(),
    }

    # The bounded loop permits at most max_steps model turns.
    for _ in range(max_steps):
        prompt = build_prompt(state)
        action = await model(prompt)

        # A final action is the normal successful stop condition.
        if action["type"] == "final":
            return action["answer"]

        # Build the structured tool call requested by the model.
        tool_call: ToolCall = {
            "name": action["name"],
            "args": action["args"],
        }

        # Record the requested action in the observable message history.
        state["messages"].append(
            {
                "role": "assistant",
                "content": json.dumps(
                    {"type": "tool_call", **tool_call},
                    ensure_ascii=False,
                ),
            }
        )

        # Execute the allowlisted tool and turn success or failure into an observation.
        result = await execute_tool(tool_call, tools)
        observation = json.dumps(result, ensure_ascii=False)

        # Update both message history and the explicit observable step log.
        state["messages"].append({"role": "tool", "content": observation})
        state["steps"].append({"action": tool_call, "observation": observation})
        state["step"] += 1

    # Do not make another model or tool call after max_steps is reached.
    return "Stop: reached max steps without a final answer."


class FakeModel:
    """Deterministic model used only to run the diagram example."""

    def __init__(self) -> None:
        self.calls = 0

    async def __call__(self, messages: list[Message]) -> ModelAction:
        self.calls += 1

        # First request the exact calculator call shown in the diagram.
        if self.calls == 1:
            return {
                "type": "tool_call",
                "name": "calculator",
                "args": {"expr": "0.15 * 240"},
            }

        # Then return the final response after the calculator observation exists.
        return {
            "type": "final",
            "answer": "15% of 240 is 36.",
        }


async def fake_web_search(args: dict[str, Any]) -> list[dict[str, str]]:
    # This deterministic fake is for tests. It is not presented as live web data.
    query = args["query"]
    max_results = args.get("max_results", 3)
    results = [
        {
            "title": "TEST ONLY",
            "url": "https://example.test/",
            "snippet": f"Deterministic fake result for query: {query}",
        }
    ]
    return results[:max_results]


def make_tools() -> dict[str, Tool]:
    # Only these two registered names can be executed by the controller.
    return {
        "calculator": {
            "name": "calculator",
            "description": "Evaluate a safe arithmetic expression.",
            "input_schema": {
                "type": "object",
                "properties": {"expr": {"type": "string"}},
                "required": ["expr"],
                "additionalProperties": False,
            },
            "func": calculator_tool,
            "timeout": 10.0,
        },
        "web_search": {
            "name": "web_search",
            "description": "Search through an injected async function.",
            "input_schema": {
                "type": "object",
                "properties": {"query": {"type": "string"}, "max_results": {"type": "integer"}},
                "required": ["query"],
                "additionalProperties": False,
            },
            "func": fake_web_search,
            "timeout": 10.0,
        },
    }


async def main() -> None:
    # Run the same deterministic example shown in the diagram.
    answer = await run_agent(
        user_input="What is 15% of 240?", model=FakeModel(), tools=make_tools(), max_steps=8
    )
    print(answer)


if __name__ == "__main__":
    asyncio.run(main())
Time & Space Complexity

Let k be the number of agent iterations, and k is at most max_steps. The controller performs O(k) loop iterations. The code keeps the current message history and passes that history to the injected model instead of rebuilding the history from scratch. Model inference, web search, and other external tool runtimes are separate costs and cannot be reduced to one fixed Big-O value here. The steps list uses O(k) entries, plus the memory needed for stored messages and tool results.

Where it is used

This pattern is useful when an AI assistant needs controlled access to external capabilities. Examples include a support assistant that can query an internal service, a research assistant that can call web search, or a workflow agent that can use a calculator. The important pattern is the boundary around tool names, argument schemas, timeouts, state updates, errors, and a hard step limit.

Why Interviewers Ask This

The interviewer is checking whether you can turn an LLM into a controlled software component instead of an unrestricted function caller. They want to see explicit state, structured model output, an allowlisted tool boundary, typed arguments, bounded execution, timeout and error handling, and a final stop condition. They are also checking whether your async Python design is provider-neutral and testable, and whether the next model turn receives the tool result or error correctly.

Common interview mistakes

Common mistakes are allowing the model to call any function name, skipping argument validation, using eval() for calculator input, forgetting a tool timeout, and writing an unbounded loop. Another mistake is dropping tool errors instead of returning them as observations. Candidates also forget to append tool calls and observations to state, so the next model turn cannot see what happened. Hard-coding one model or search provider also makes the agent harder to test.

Interview tip

Explain the execution boundary before showing code. Say that the model may propose a tool call, but only the controller can execute it after the tool name and arguments pass validation. Then walk through the exact calculator example. Finish by showing that range(max_steps) prevents an infinite agent loop.

Interviewer may ask next
How would you test this agent without calling a real model or web-search service?

Inject deterministic async fakes. A fake model can return a known sequence of structured actions, and a fake web-search tool can return clearly labeled test data. Then assert the tool call, validation path, observation, state update, final answer, timeout behavior, and max-step behavior. The controller logic does not change, and the test does not depend on an external provider.

What should happen if the model keeps choosing tools and never returns a final answer?

The bounded loop stops after max_steps. The agent returns a clear stop response such as Stop: reached max steps without a final answer. It must not make another model or tool call after the limit. The controller still performs at most max_steps iterations, and the explicit step history grows to at most max_steps entries.

Disclaimer: This interview guide is for educational and informational purposes only. It is designed to help readers prepare, but it does not guarantee any interview result, hiring decision, offer, or outcome. Interview questions, hiring criteria, and preferred answers can vary by employer, interviewer, industry, location, and time. The examples and explanations reflect the authors' research and judgment, are provided without warranties of any kind, and should not be treated as the only correct approach. Diagrams are simplified illustrations intended to highlight the main components and their interactions; actual systems and implementations may be more complex. Alternative approaches may be equally valid or better suited to a particular question, context, or interviewer. To the fullest extent permitted by applicable law, the author, contributors, and publisher are not liable for decisions made, actions taken, or losses incurred based on this guide.