liveBook · Manning

Semaphore

As explained in Build an AI Agent (From Scratch)

A semaphore is a synchronization primitive that limits how many concurrent operations may enter a protected region.

Concurrent LLM requests can reduce waiting time, but sending too many at once can trigger provider rate limits. A semaphore puts a ceiling on requests in progress without requiring tasks to run sequentially: tasks beyond the ceiling wait until a slot is available. section 2.2.6

Create an `asyncio.Semaphore(10)` and place `await acompletion(...)` inside `async with semaphore`. The block acquires a slot before the API call and releases it afterward. Even if `asyncio.gather` runs 100 tasks, no more than 10 enter the protected call at once. section 2.2.6

Listing 2.9 Rate-limited concurrent requests
import asyncio
from litellm import acompletion

# Limit to 10 concurrent requests
semaphore = asyncio.Semaphore(10)

async def call_llm(prompt: str) -> str:
    """LLM call with rate limiting and automatic retry."""
    async with semaphore:
        response = await acompletion(
            model="gpt-5-mini",
            messages=[{"role": "user", "content": prompt}],
            num_retries=3  # Automatic retry with exponential backoff
        )
        return response.choices[0].message.content

# Even with 100 concurrent tasks, only 10 API calls run at a time
prompts = [f"What is {i} + {i}?" for i in range(100)]
tasks = [call_llm(p) for p in prompts]
results = await asyncio.gather(*tasks, return_exceptions=True)

The request wrapper keeps the concurrency limit around the call itself, while LiteLLM retries transient failures and `gather` returns individual exceptions for inspection. section 2.2.6

For evaluations spanning providers, use a separate semaphore per provider. A single shared limit could make a burst of requests to one provider hold up work destined for another. Provider limits of 30 for OpenAI and 10 for Anthropic are starting values, not universal settings; lower them when rate-limit errors occur or raise them when the applicable tier permits more concurrency. section 2.4.2

Listing 2.12 Provider-specific rate limiting
PROVIDER_SEMAPHORES = {
    "openai": asyncio.Semaphore(30),
    "anthropic": asyncio.Semaphore(10),
}

def get_provider(model: str) -> str:
    """Extract provider name from model string."""
    return "anthropic" if model.startswith("anthropic/") else "openai"

The provider-to-semaphore mapping selects a concurrency limit from the model name. section 2.4.2

Keep retry and result handling alongside this limit. LiteLLM’s `num_retries` handles transient failures with backoff; `asyncio.gather(..., return_exceptions=True)` lets successful results survive individual failures. A semaphore controls simultaneous calls, while those other mechanisms address failures. section 2.2.6

How Build an AI Agent (From Scratch) covers it

How deep it goes
Implements `asyncio.Semaphore` around `acompletion` and extends it to provider-specific limits; does not examine semaphore internals.
Examples
Shows 100 tasks capped at 10 API calls, then separate OpenAI and Anthropic limits of 30 and 10.
What it assumes
Builds on asynchronous calls using `await` and `asyncio.gather`, which are explained alongside the semaphore pattern.
What you can do after
Operate/configure it by wrapping LiteLLM calls in `async with` and adjusting provider-specific limits when rate-limit errors occur.
How it connects
Connects semaphores to `asyncio.gather`, API rate limits, retries, and parallel model evaluation.

Also explained in

Compare how 5 books explain semaphore →

Related concepts

This explainer was generated by Manning's knowledge graph from the text of Build an AI Agent (From Scratch). The section links point to the parts of the book it draws on.