Appearance
1.1 — Python for AI Engineers
1. What is it?
This chapter is not "Python from scratch." It's the specific subset of Python engineering that shows up constantly in AI systems and that most tutorials skip: asyncio (because every LLM call is I/O-bound and production AI services live or die on concurrency), typing and pydantic (because structured LLM output, tool schemas, and API contracts all run through them), packaging/dependency management with uv, and the failure-handling patterns you need when a chunk of your "business logic" is a network call to a non-deterministic third-party model.
2. Why does it exist?
Python won AI engineering not because it's fast (it isn't) but because its ecosystem (NumPy → PyTorch → Hugging Face → LangChain) accumulated first, and because its dynamic typing and REPL-driven style made it the fastest language to prototype ML code in. The cost of that history: Python's concurrency model, packaging story, and type system were all bolted on after the fact, and an AI engineer who doesn't understand why they work the way they do will write code that quietly serializes what should be concurrent, or silently passes malformed data through an untyped boundary until it hits an LLM and produces garbage three services downstream.
3. What problem does it solve?
An AI application spends most of its wall-clock time waiting: waiting for an LLM API to respond, waiting for a vector DB query, waiting for a database round trip. If your code handles that waiting badly, your AI product will feel slow and cost more than it should, regardless of how good your prompts are. The tools in this chapter — asyncio, connection pooling, typed boundaries — exist to make an I/O-bound system fast, correct, and debuggable.
4. How does it work internally?
The GIL and why it doesn't matter for LLM apps
Python's Global Interpreter Lock (GIL) prevents two threads from executing Python bytecode at the same time in one process. This makes Python bad at CPU-bound parallelism (e.g., running two tokenizers on two cores inside one process gains you little without multiprocessing). It does not make Python bad at I/O-bound concurrency: a thread or an asyncio task waiting on a network response releases the GIL while it waits, so hundreds of concurrent LLM calls can be in flight from one Python process. This is why asyncio — not multiprocessing — is the default concurrency model for AI backends.
asyncio's execution model
Event loop (single thread)
┌─────────────────────────────────────────────────────┐
│ while True: │
│ ready = tasks that can make progress right now │
│ run each ready task until it hits `await` │
│ (control returns to the loop; task is parked) │
│ poll: has any I/O completed? → mark that task ready │
└─────────────────────────────────────────────────────┘await is the handoff point: "I'm blocked on I/O, run something else until this resolves." Calling a blocking function (e.g., the synchronous requests.get, or CPU-heavy work like local reranking) from inside an async def without await freezes the entire event loop — every other in-flight request in your FastAPI process stalls. This is the single most common production bug in AI backends: someone imports the sync SDK for a vector DB or calls a synchronous tokenizer inside an async route handler, and the whole service's p99 latency spikes.
Finding the blocking call: profiling, including on a live process
Section 11 claims Python's per-call overhead matters and that a blocking call stalls the whole event loop — but claiming a bottleneck exists and finding it empirically are different skills.
For a standalone script or a suspected CPU-bound hot path, cProfile gives a function-level breakdown:
bash
python -m cProfile -o profile.out -m myapp.batch_embed
python -c "import pstats; pstats.Stats('profile.out').sort_stats('cumulative').print_stats(15)"This works fine for offline scripts, but it requires running the code under the profiler from the start — not useful for "our production Uvicorn worker is stalling right now and we can't restart it without dropping in-flight requests." For that, use py-spy, which attaches to an already-running process from the outside (reading its stack via the OS, not by injecting code into it), so a live production process never needs to be modified, restarted, or paused for more than a few milliseconds:
bash
py-spy top --pid 4821 # live, refreshing view of which functions are hot right now
py-spy dump --pid 4821 # one-shot stack trace of every thread at this instantThis is the production-relevant tool specifically because you cannot attach a normal debugger (pdb) to a running production process the way you would locally — pdb expects to pause execution and hand you an interactive prompt inline in that process's own terminal, which isn't available for a backgrounded Uvicorn worker serving live traffic, and pausing it that way would itself cause the outage you're trying to diagnose. py-spy dump on a hung worker will typically show a stack frame sitting inside a synchronous call (a sync DB driver, requests.get, blocking file I/O) instead of an await — which is the smoking gun for the exact failure mode described in the asyncio section above: one blocking call, caught in the act, freezing everything else on that event loop.
A complementary, always-on safety net: set PYTHONASYNCIODEBUG=1 in a staging/pre-production environment. It makes asyncio log a warning whenever a callback (including a route handler) runs for longer than its slow-callback threshold (100ms by default) without yielding — surfacing the exact "someone called a blocking function inside async def" bug from section 4 in a log line during testing, before it becomes the Monday-morning-rush incident described in section 20.
Typed boundaries with pydantic
Pydantic models validate and coerce data at runtime, which matters enormously at the boundaries where an AI system talks to something non-deterministic:
python
from pydantic import BaseModel, Field, field_validator
class ExtractedInvoice(BaseModel):
"""Represents structured fields extracted from an invoice document by an LLM."""
vendor_name: str
invoice_number: str
total_amount_usd: float = Field(gt=0)
line_items: list[str] = Field(default_factory=list)
@field_validator("invoice_number")
@classmethod
def not_empty(cls, v: str) -> str:
"""Rejects an empty invoice number rather than letting it flow downstream silently."""
if not v.strip():
raise ValueError("invoice_number must not be empty")
return vWhen an LLM's structured output is parsed into this model, malformed output raises immediately and loudly, at the boundary — instead of an empty string quietly reaching a billing system three function calls later.
Typed configuration with pydantic-settings
The same "validate at the boundary, fail loudly" principle applies to configuration, not just LLM output. An app that reads os.environ["DATABASE_URL"] deep inside some function only discovers a missing variable the first time that code path runs — which in production might be hours after deploy, mid-request, for a customer. pydantic_settings.BaseSettings turns configuration into a typed, validated boundary that's checked once, at startup:
python
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Typed application configuration, loaded from environment variables and .env files."""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
environment: Literal["dev", "staging", "prod"] = "dev"
anthropic_api_key: str
database_url: str
max_concurrent_llm_calls: int = Field(default=20, gt=0)
settings = Settings() # instantiated once, at import time — this is the fail-fast pointConstructing Settings() at module import time (typically in a small config.py imported by main.py before the app object is even built) means a missing anthropic_api_key or a malformed max_concurrent_llm_calls raises a pydantic.ValidationError and crashes the process at boot, before it starts accepting traffic — not at the first request that happens to touch that field. Crashing loudly at startup is strictly better than a mysterious KeyError three hours into production traffic.
For dev/staging/prod layering, point env_file at the right file per environment rather than hand-editing .env between deploys:
python
import os
env_name = os.environ.get("APP_ENV", "dev")
settings = Settings(_env_file=f".env.{env_name}")This chapter covers only the app-level typed-config pattern; where the underlying secret values actually live (env vars injected by the platform, a secrets manager, etc.) is Part 9.5's job, not this one.
Packaging and dependency management with uv
uv is a Rust-based Python package/project manager that replaces the slow, fragmented pip + venv + pip-tools workflow with one fast tool and one lockfile.
bash
uv init my-ai-service # creates pyproject.toml, a src layout, and a .python-version
cd my-ai-service
uv add fastapi httpx anthropic pydantic-settings # adds to [project.dependencies], updates uv.lock
uv add --dev pytest pytest-asyncio ruff # dev-only tools, a separate dependency group
uv add --optional docs mkdocs # an optional extra, installed only on request
uv run uvicorn main:app --reload # runs inside the project's managed venvuv add writes two things: the human-edited constraint in pyproject.toml (e.g. "fastapi>=0.115,<1.0") and an exact, fully-resolved snapshot of every dependency (direct and transitive) with pinned versions and hashes in uv.lock. This distinction is the entire point of a lockfile: pyproject.toml says what's acceptable, uv.lock says what was actually installed and tested — commit both, so a teammate or a CI runner or a production build three months from now resolves to the exact same dependency graph you tested against, instead of picking up a transitive dependency's breaking minor release.
toml
# pyproject.toml (excerpt)
[project]
dependencies = [
"fastapi>=0.115,<1.0",
"httpx>=0.27",
]
[dependency-groups]
dev = ["pytest>=8.0", "pytest-asyncio", "ruff"]
[project.optional-dependencies]
docs = ["mkdocs"]Pinning strategy is situational, not universal: for a production Docker build, use uv sync --frozen --no-dev — --frozen refuses to re-resolve or touch uv.lock even if pyproject.toml changed (fail the build loudly rather than silently drift), and --no-dev excludes the dev dependency group so test tooling never ships in the production image. For a library other teams import (an internal SDK, a shared tool package), pin looser ranges in pyproject.toml (>=2.1,<3.0, not ==2.1.4) so it doesn't force a specific transitive version on every consumer — the lockfile discipline above is for applications you deploy, not packages you publish for others to depend on.
5. Simple mental model
Think of an AI backend as a restaurant kitchen with one chef and many slow-cooking dishes. The chef (your single-threaded event loop) doesn't stand and stare at the oven (the LLM API) waiting for one dish to finish — they start dish A, put it in the oven, immediately start prepping dish B while A cooks, get interrupted by dish A's timer, plate it, move to dish C. One chef, many dishes in flight, because the slow part (the oven / the LLM) isn't the chef's attention — it's external. The moment the chef ignores this and stands staring at one oven (a blocking call), every other order backs up.
6. Real-world example
A customer support AI backend receives 200 concurrent chat requests. Each request needs: an embedding call (~50ms), a vector DB query (~30ms), an LLM completion (~2000ms). Done synchronously, 200 requests would serialize to 200 × 2080ms ≈ 7 minutes for the batch. Done with asyncio and a connection pool, all 200 can be in flight concurrently (bounded by the LLM provider's rate limit), so the batch finishes in roughly the time of the slowest single request plus queuing — often under 10 seconds.
7. Architecture diagram
FastAPI process · 1 event loop
Request 1
Request 2
Request 3
FastAPI process · 1 event loop
await embed()
FastAPI process · 1 event loop
await vector_search()
FastAPI process · 1 event loop
await llm()
FastAPI process · 1 event loop
Response
8. Production considerations
- Use async clients for every I/O dependency in an async app:
httpx.AsyncClient,asyncpg, an async Redis client, and the async variants of LLM SDKs (AsyncOpenAI,AsyncAnthropic). Mixing a sync client into an async route is the most common way to silently kill throughput. - Connection pooling matters as much as async itself — opening a new TCP/TLS connection per LLM call or per DB call adds latency that compounds under load. Reuse a single client instance across requests (constructed once at app startup, not per-request).
- If you must run CPU-bound or blocking-library code inside an async app, offload it:
await asyncio.to_thread(blocking_fn, ...)for blocking I/O you can't avoid, or a separate worker process for genuinely CPU-heavy work (Part 7 covers this with Celery/RQ). - Set timeouts on every external call (LLM API, vector DB, any HTTP dependency). An AI backend with no timeouts will eventually have one slow upstream call hold a request (and its connection-pool slot) open indefinitely.
9. Common mistakes
- Calling a synchronous SDK method inside
async defand not noticing until load testing (or a customer) reveals the whole service stalls under concurrency. - Using
asyncio.gatherwithoutreturn_exceptions=Truewhen one failed call shouldn't crash the whole batch — one failed embedding call kills 50 concurrent RAG lookups instead of degrading gracefully. - Trusting LLM output as if it were validated input — parsing "structured" LLM JSON without a pydantic (or equivalent) boundary, then having a downstream system choke on an unexpected
Noneor type mismatch. - Global mutable state (a shared dict used as a cache) accessed from concurrent async tasks without realizing async concurrency can still interleave unexpectedly at
awaitpoints, producing race conditions.
10. Security considerations
- Never construct SQL, shell commands, or file paths by string-interpolating LLM output directly — treat LLM output as untrusted input, exactly like user input, because in agentic systems it often is effectively user input laundered through a model (see Part 9 on prompt injection).
- Validate and cap LLM output length/shape before using it to drive control flow (e.g., a tool-calling loop) — a malformed or adversarial response should not be able to trigger unbounded recursion or unexpected code paths.
11. Performance considerations
asyncio.gather()runs concurrently; sequentialawaitcalls in a loop do not — this is one of the most common accidental performance bugs (writingfor item in items: result = await call(item)instead of gathering).- Beware unbounded concurrency: firing 10,000 concurrent LLM calls via
gatherwill hit provider rate limits and exhaust your own connection pool. Bound concurrency with aasyncio.Semaphore. - Python's per-call overhead is non-trivial for CPU-bound work (tokenization at scale, embedding post-processing) — for genuinely hot paths, this is where NumPy/vectorized operations or moving the work to a compiled dependency matters.
- Don't guess at the bottleneck — measure it. Use
cProfilefor an offline script, andpy-spy top/py-spy dump --pid <pid>to profile a live production process without restarting it (see section 4).
12. Cost considerations
- Concurrency doesn't reduce the number of LLM tokens you pay for — it reduces wall-clock latency. Don't confuse "faster" with "cheaper"; cost optimization is a separate axis (Part 7.10).
- Badly bounded concurrency can increase cost indirectly: retry storms from timeouts under high concurrency can multiply the number of LLM calls made for the same logical request.
13. When to use it
Async Python is the right default for any AI backend serving concurrent requests to LLMs, vector DBs, or other network services — which is nearly every production AI application.
14. When NOT to use it
- A one-off script or notebook doing sequential, exploratory work with a single LLM call at a time gains nothing from async and adds cognitive overhead — write it synchronously.
- CPU-bound batch processing (e.g., bulk-embedding millions of documents with a local model) is often better served by multiprocessing or a batch-oriented framework than by asyncio, since the bottleneck isn't I/O wait.
15. Alternatives and trade-offs
| Approach | Good for | Weak point |
|---|---|---|
asyncio | High-concurrency I/O-bound services (the default for AI backends) | Harder to debug; one blocking call poisons the whole loop |
Threading (ThreadPoolExecutor) | Simpler mental model, works with sync SDKs unmodified | GIL limits raw CPU parallelism; more memory per worker than async tasks |
| Multiprocessing | Genuine CPU parallelism (local embedding/reranking models) | Heavy process overhead; no shared memory by default |
| Sync, single-threaded | Simplicity for scripts/notebooks | Doesn't scale to concurrent request serving |
16. Practical Python/code example
python
import asyncio
import httpx
from pydantic import BaseModel
class SupportQuery(BaseModel):
"""A single customer support question to be answered by the AI backend."""
query_id: str
text: str
async def fetch_answer(
client: httpx.AsyncClient, query: SupportQuery, semaphore: asyncio.Semaphore
) -> dict[str, str]:
"""
Sends one support query to the LLM backend, bounded by a concurrency semaphore.
Args:
client (httpx.AsyncClient): Shared, reusable HTTP client with connection pooling.
query (SupportQuery): The support question to answer.
semaphore (asyncio.Semaphore): Bounds concurrent in-flight requests to avoid
overwhelming the downstream LLM provider's rate limit.
Returns:
dict[str, str]: The query id and the model's answer, or an error marker.
"""
async with semaphore:
try:
response = await client.post(
"/v1/answer",
json={"query": query.text},
timeout=10.0,
)
response.raise_for_status()
return {"query_id": query.query_id, "answer": response.json()["answer"]}
except httpx.HTTPError as exc:
return {"query_id": query.query_id, "answer": f"error: {exc}"}
async def answer_batch(queries: list[SupportQuery]) -> list[dict[str, str]]:
"""
Answers a batch of support queries concurrently, bounded to 20 in-flight requests.
Args:
queries (list[SupportQuery]): Queries to answer.
Returns:
list[dict[str, str]]: One result per query, in the same order.
"""
semaphore = asyncio.Semaphore(20)
async with httpx.AsyncClient(base_url="http://llm-gateway.internal") as client:
tasks = [fetch_answer(client, q, semaphore) for q in queries]
return await asyncio.gather(*tasks)Note the pattern: one shared AsyncClient (connection pooling), a Semaphore (bounded concurrency), raise_for_status plus a narrow except (explicit failure handling per item instead of one failure killing the batch), and an explicit timeout.
17. Production-quality example
A production version adds structured logging, retries with backoff for transient failures, and returns typed results instead of loosely-typed dicts:
python
import asyncio
import logging
from typing import Literal
import httpx
from pydantic import BaseModel
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
logger = logging.getLogger("support_backend")
class AnswerResult(BaseModel):
"""The outcome of attempting to answer a single support query."""
query_id: str
status: Literal["ok", "error"]
answer: str | None = None
error: str | None = None
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=0.5, max=4),
retry=retry_if_exception_type(httpx.TransportError),
)
async def _call_llm_gateway(client: httpx.AsyncClient, text: str) -> str:
"""
Calls the internal LLM gateway with retry on transient transport errors.
Args:
client (httpx.AsyncClient): Shared HTTP client.
text (str): The query text to send.
Returns:
str: The model's answer text.
"""
response = await client.post("/v1/answer", json={"query": text}, timeout=10.0)
response.raise_for_status()
return response.json()["answer"]
async def fetch_answer(
client: httpx.AsyncClient, query_id: str, text: str, semaphore: asyncio.Semaphore
) -> AnswerResult:
"""
Answers one query, converting any failure into a typed error result instead of raising.
Args:
client (httpx.AsyncClient): Shared, reusable HTTP client.
query_id (str): Identifier for correlating this result back to the request.
text (str): The query text.
semaphore (asyncio.Semaphore): Bounds concurrent in-flight requests.
Returns:
AnswerResult: A typed success or failure result.
"""
async with semaphore:
try:
answer = await _call_llm_gateway(client, text)
return AnswerResult(query_id=query_id, status="ok", answer=answer)
except httpx.HTTPError as exc:
logger.warning("query_id=%s failed after retries: %s", query_id, exc)
return AnswerResult(query_id=query_id, status="error", error=str(exc))The key production difference from the "practical" example: retries are scoped to transient errors only (a 4xx from a malformed request should not be retried), failures are typed and logged with correlation IDs rather than swallowed, and the function signature makes the possibility of failure explicit in the return type instead of hiding it in a dict.
18. Short exercise
Take the answer_batch function above and modify it so that: (a) concurrency is bounded to 5 instead of 20, (b) a query that fails 3 retries in a row is logged at ERROR level with the query text included, and (c) the function returns as soon as the first error occurs, cancelling the rest of the batch (hint: asyncio.gather isn't the right primitive for this — look at asyncio.wait with FIRST_EXCEPTION, or asyncio.TaskGroup in Python 3.11+).
19. Interview questions
- Explain why
asynciohelps an LLM-calling service even though Python has a GIL. - What happens if you call a synchronous, blocking function from inside an
async defroute handler in FastAPI? How would you detect this in a running service? - Why would you bound concurrency with a semaphore even though
asyncio.gathercould technically run unlimited coroutines at once? - Design a typed boundary (pydantic model) for validating an LLM's structured output before it reaches a billing system. What should happen when validation fails?
- Why is
py-spythe right tool (andpdbthe wrong one) for diagnosing a stalled process in production, and what would you expect its output to show if a blocking call were the cause? - Why should a required config value's absence crash the app at startup instead of raising the first time that value is used mid-request?
20. FDE/customer scenario
Customer: "Our AI assistant works fine in testing but during our Monday morning rush (50+ employees hitting it at once) it becomes unusably slow, sometimes for everyone at once, not just the new requests."
This is a classic single-blocking-call-freezes-the-event-loop symptom. Before touching any code, you'd ask: is this service built with async def routes? If so, is there any synchronous SDK call, synchronous file I/O, or CPU-heavy step (e.g., local re-ranking, PDF parsing) inside those routes? Rather than guessing, attach py-spy dump --pid <worker_pid> to the live Uvicorn worker during the next slow period — the stack trace will show exactly which call is sitting there synchronously instead of at an await. The fix is rarely "add more servers" first — it's usually finding the one blocking call that's serializing an otherwise-concurrent system, since scaling out horizontally without fixing that just delays the same symptom.
Key takeaways
- Python's GIL limits CPU parallelism, not I/O concurrency —
asynciois the right default for AI backends because they're I/O-bound. - A single blocking call inside an async route stalls the entire process, not just that request.
- Typed boundaries (pydantic) are essential wherever non-deterministic LLM output meets deterministic downstream systems — including configuration, via
pydantic-settings. uvplus its lockfile makes dependency resolution reproducible across dev, CI, and production;py-spylets you diagnose a stalled process without restarting it.
Things you should be able to explain
- Why the GIL doesn't prevent high-throughput LLM-calling services.
- The failure mode of mixing sync and async code in one service.
- Why
uv.lockmatters for reproducible builds, and whypy-spy(notpdb) is the right tool for a live production stall.
Things you should be able to build
- An async batch-processing function with bounded concurrency, typed results, and scoped retries.
- A
uv-managed project with pinned production dependencies and a fail-fastSettingsclass.
Common mistakes
- Using a sync SDK inside an async route.
- Unbounded concurrency exhausting rate limits or connection pools.
- Treating LLM output as trusted, validated data.
- Not committing
uv.lock, so "works on my machine" resurfaces at the dependency-resolution level. - A required config value that's missing only fails the first time a request happens to touch it, instead of at startup.
Recommended next chapter
02-rest-apis.md