Async Fundamentals
Basic Async LLM Calls
Parallel Requests with gather
asyncio.gather() is the workhorse for firing multiple LLM calls simultaneously. It takes a list of coroutines, runs them all concurrently, and returns results in the same order as the input. This is the difference between “ask 4 questions one at a time” and “ask all 4 at once.”
Critical production warning: gather() with no concurrency limit will fire ALL requests simultaneously. If you have 1,000 prompts, that’s 1,000 concurrent API calls — and you’ll hit rate limits instantly. Always pair gather() with a semaphore (covered below) for production workloads.
Rate Limiting
Without rate limiting, your async code will happily fire thousands of concurrent requests in milliseconds — and the API provider will just as happily reject most of them with 429 (Too Many Requests) errors. Rate limiting is not optional; it’s the difference between “10x faster batch processing” and “10x faster at generating error responses.”Token Bucket Rate Limiter
The token bucket is the gold standard algorithm for API rate limiting. The analogy: imagine a bucket that holds tokens. Tokens drip in at a steady rate (say, 1 per second). Each API call costs one token. If the bucket is empty, you wait. If it’s full, you can burst up to the bucket’s capacity. This is better than a simple “N requests per second” limit because it allows natural bursting: a user who was idle for 10 seconds has accumulated tokens and can fire a quick burst — which matches how humans actually use APIs.Sliding Window Rate Limiter
The sliding window approach tracks actual request timestamps rather than maintaining abstract token counts. It’s conceptually simpler and gives you exact enforcement: “no more than N requests in any rolling T-second window.” The tradeoff versus token bucket is that it doesn’t allow bursting — the 11th request in a 10-request window always waits, even if the previous 10 requests happened over a long period.Backoff Strategies
When an LLM API returns a rate limit error or a transient server error, the worst thing you can do is immediately retry. If 100 of your concurrent requests all get rate-limited at the same time and all retry after exactly 1 second, you’ve just created a “thundering herd” that hits the API with 100 simultaneous requests again — and gets rate-limited again. The cycle repeats. Exponential backoff with jitter solves this by spreading retries over time. Each successive retry waits longer (exponential), and each wait is randomized within a range (jitter). The result: your 100 retries spread themselves naturally across a 30-second window instead of slamming the API all at once.Exponential Backoff with Jitter
Adaptive Rate Limiting
Static rate limits are a guess. Adaptive rate limiting observes how the API actually responds and adjusts in real-time. When requests succeed, it cautiously increases the rate. When it gets rate-limited, it aggressively backs off. Think of it like a driver adjusting speed based on traffic conditions rather than always driving at exactly 60mph. This pattern is especially valuable when you don’t know the exact rate limit (many providers don’t publish them), when limits change based on server load, or when you’re sharing a rate limit pool with other tenants in your organization.Semaphore-Based Concurrency Control
A semaphore is the simplest and most reliable way to cap concurrency. If rate limiting controls “how fast” you make requests, semaphores control “how many at once.” Even with perfect rate limiting, having 500 concurrent HTTP connections can exhaust memory, file descriptors, or connection pool limits. The rule of thumb for LLM APIs: start withmax_concurrent=10 and increase until you start seeing rate limit errors or degraded latency. Most providers handle 10-50 concurrent connections per API key without issue.
Request Queue with Priority
Not all LLM requests are created equal. A real-time chat response should jump ahead of a background summarization job. A paying customer’s request should take priority over a free tier user’s batch job. Priority queues let you enforce these business rules at the infrastructure level. The pattern is simple: instead of a FIFO queue, use a min-heap where lower priority numbers go first. Within the same priority level, requests are processed in FIFO order (using a monotonic sequence number as a tiebreaker).Async Context Manager for Sessions
For production batch processing, you want session-level resource management and statistics. The context manager pattern (async with) guarantees cleanup happens even when exceptions occur — no leaked connections, no orphaned tasks.
This is the pattern to use when you need to answer questions like “how many tokens did this batch consume?” or “what was the p99 latency for this run?” — questions that matter when your monthly LLM bill has commas in it.
Async Best Practices for LLMs
- Always use connection pooling with async clients
- Implement proper timeout handling for all requests
- Use semaphores to control maximum concurrent requests
- Add jitter to retry delays to prevent thundering herd
- Monitor and adapt rate limits based on API responses
Practice Exercise
Build an async batch processor with:- Priority queue for request ordering
- Adaptive rate limiting based on API responses
- Exponential backoff with jitter for retries
- Progress tracking and cancellation support
- Comprehensive statistics collection
- Proper resource cleanup on failures
- Graceful shutdown handling
- Memory-efficient batch processing
- Real-time progress reporting
Interview Deep-Dive
You have 50,000 prompts to process through an LLM API with a rate limit of 500 RPM and 200K TPM. Walk through your architecture.
You have 50,000 prompts to process through an LLM API with a rate limit of 500 RPM and 200K TPM. Walk through your architecture.
What interviewers are testing: Whether you can design a real batch processing pipeline with concrete numbers, not just describe patterns abstractly.Strong answer: Start with the math. At 500 RPM, sequential processing takes 100 minutes minimum. With an average of 400 tokens per request (prompt + completion), the token limit allows 500 requests/minute too, so requests-per-minute is the binding constraint.Architecture: Use a producer-consumer pattern with an async queue. The producer reads prompts from a file or database in chunks (not all 50K into memory). Workers (20-50 concurrent coroutines gated by a semaphore) pull from the queue, make rate-limited API calls, and write results to an output queue.Rate limiting: Use a token bucket with capacity=500 and refill rate of ~8.3/second. Add a semaphore at 30-50 concurrent connections. The dual limiter ensures you respect both RPM and connection limits.Resilience: Implement checkpointing every 500 items — write (index, result) pairs to a JSONL file so you can resume from the last checkpoint after a crash. Add exponential backoff with jitter for 429 and 5xx errors. After 5 retries, move failed items to a dead letter list for manual inspection.Cost control: Before starting, estimate total cost using tiktoken. For 50K prompts at ~400 tokens each, that’s 20M tokens. At gpt-4o-mini pricing (0.60/1M output), that’s roughly $3-12 depending on output length. Consider the Batch API for 50% savings if 24-hour turnaround is acceptable.Monitoring: Log throughput (requests/sec), error rate, p50/p99 latency, and cumulative cost in real-time. Set an alert if error rate exceeds 5% — that usually indicates a systemic issue, not random failures.
What is the thundering herd problem in the context of LLM API retries, and how do you solve it?
What is the thundering herd problem in the context of LLM API retries, and how do you solve it?
What interviewers are testing: Understanding of distributed systems failure modes and the specific amplification risks with expensive API calls.Strong answer: The thundering herd occurs when many clients experience a failure simultaneously and all retry at the same moment, creating a spike that’s worse than the original load. With LLM APIs, this is especially dangerous because each retry costs real money.Scenario: Your application sends 100 concurrent requests. The provider returns 429 for all of them. If all 100 retry after exactly 1 second, you hit the API with 100 requests again — plus any new organic traffic. This creates a feedback loop where retries cause more rate limits which cause more retries.Solution 1 — Jitter: Add randomness to retry delays. Instead of “retry after 2^n seconds,” use “retry after 2^n * random(0.5, 1.5) seconds.” This spreads retries across the window. Full jitter (random between 0 and max delay) is even more effective than decorrelated jitter.Solution 2 — Circuit breaker: If error rate exceeds a threshold (e.g., 50% of requests failing), stop sending new requests entirely for a cooldown period. This prevents the retry storm from growing. After the cooldown, send a single probe request; if it succeeds, gradually ramp traffic back up.Solution 3 — Adaptive rate limiting with AIMD: On success, increase rate by a small constant. On failure, halve the rate immediately. This converges to the actual available capacity without oscillation — the same algorithm TCP uses for congestion control, and for the same reason.The key insight is that jitter alone isn’t enough at scale. You need all three layers working together: jitter smooths individual retries, circuit breaking prevents cascade, and adaptive limiting finds the new sustainable rate.
How would you implement graceful shutdown for an async LLM processing pipeline that has in-flight requests?
How would you implement graceful shutdown for an async LLM processing pipeline that has in-flight requests?
What interviewers are testing: Production engineering maturity around data loss prevention and clean shutdown semantics.Strong answer: Graceful shutdown means: stop accepting new work, finish in-flight work, save state for incomplete work, and exit cleanly. The goal is zero data loss — every prompt should either be fully processed with its result saved, or clearly marked as unprocessed for the next run.Implementation: Register a signal handler for SIGTERM/SIGINT that sets a shutdown flag. The producer stops feeding new items to the queue. Workers finish their current request (you cannot cancel an in-flight LLM call without losing the result and the cost). Set a shutdown timeout (e.g., 60 seconds) — if workers haven’t finished by then, log unfinished items and force exit.For the checkpointing layer, flush the current checkpoint immediately on shutdown signal. Any items that were dequeued but not yet processed go back to “pending” in the checkpoint file. On the next run, the checkpoint loader picks them up automatically.The tricky part is the race condition between “worker finishes request” and “checkpoint flushes.” Use an atomic counter for in-flight requests. The shutdown sequence waits until in-flight count reaches zero (with timeout), then flushes the final checkpoint, then exits.In Kubernetes, configure
terminationGracePeriodSeconds to be longer than your longest expected LLM call (60-120 seconds). SIGTERM arrives first; SIGKILL follows after the grace period.Compare asyncio.gather, asyncio.as_completed, and asyncio.TaskGroup for batch LLM processing. When would you choose each?
Compare asyncio.gather, asyncio.as_completed, and asyncio.TaskGroup for batch LLM processing. When would you choose each?
What interviewers are testing: Depth of understanding of Python’s async primitives beyond the basic tutorial level.Strong answer: These three serve different use cases for concurrent LLM requests.
asyncio.gather(*tasks) runs all tasks concurrently and returns results in input order. Use it when you need results aligned with inputs (e.g., processing a CSV where row N’s result must go in output row N). Downside: you get no results until ALL tasks complete, so one slow response blocks everything.asyncio.as_completed(tasks) yields futures as they finish, regardless of input order. Use it when you want to process results as they arrive — for example, streaming partial progress to a UI, or writing results to a database as soon as each is ready. This is better for user-facing progress bars and for reducing peak memory (you can free each result immediately).asyncio.TaskGroup (Python 3.11+) is the modern replacement for gather() with better error handling. If any task raises an exception, it cancels all remaining tasks and raises an ExceptionGroup. This is the right choice when tasks are interdependent — if one fails, continuing the others is pointless (e.g., a multi-step RAG pipeline where step 2 depends on step 1).For batch LLM processing specifically: use as_completed with a semaphore for large batches (thousands of items) because you can checkpoint results incrementally. Use gather for small parallel operations (3-10 items) where simplicity matters. Use TaskGroup for orchestration workflows where partial completion is meaningless.