Functional Python: Lambdas & Decorators
Python is a multi-paradigm language, and functional programming is one of its strong suits. If you've used C# LINQ, delegates, or Func<T>, you'll find Python's functional features immediately familiar — just with different syntax and more expressive runtime behavior.
Decorators are particularly important for AI engineering. The retry logic, logging, caching, and timing wrappers you need for robust AI applications are all idiomatically implemented as decorators in Python.
Lambdas: One-Line Functions
Python's lambda creates an anonymous function — equivalent to C#'s lambda expressions or Func<T, TResult>. The restriction: a Python lambda can only contain a single expression, not a statement block.
Func<string, int> countWords =
s => s.Split(' ').Length;
// LINQ with lambda
var sorted = responses
.OrderByDescending(r => r.Tokens)
.ToList();
count_words = lambda s: len(s.split())
# sorted() with key lambda
sorted_responses = sorted(
responses,
key=lambda r: r["tokens"],
reverse=True,
)
Where Lambdas Shine in AI Code
A lambda is just an anonymous, single-expression function — the equivalent of a C# lambda like r => r.Score. Their sweet spot is supplying a throwaway key or predicate to built-ins such as sorted(), filter(), map(), max(), and min(). The block below ranks, filters, and transforms a list of model responses — each a one-liner you'd otherwise write as a full method.
responses = [
{"id": "r1", "text": "Short answer.", "tokens": 12, "score": 0.91},
{"id": "r2", "text": "A longer, more detailed response.", "tokens": 38, "score": 0.85},
{"id": "r3", "text": "Medium length.", "tokens": 22, "score": 0.95},
]
# Sorting — lambda as sort key
by_score = sorted(responses, key=lambda r: r["score"], reverse=True)
by_tokens = sorted(responses, key=lambda r: r["tokens"])
# Filtering — lambda in filter()
high_quality = list(filter(lambda r: r["score"] >= 0.9, responses))
# Transformation — lambda in map()
texts_only = list(map(lambda r: r["text"].strip(), responses))
# Min/max with key
best = max(responses, key=lambda r: r["score"])
shortest = min(responses, key=lambda r: r["tokens"])
print(f"Best: {best['id']} (score={best['score']})")
Use lambdas for simple, inline key functions. If the logic is more than a single expression — or if you'll reuse it — define a proper named function. PEP 8 specifically discourages assigning lambdas to variables (f = lambda x: x) when you could just write def f(x): return x.
Higher-Order Functions
Python functions are first-class objects. You can pass them as arguments, return them from functions, and store them in data structures — just like C# delegates or Func types.
from typing import Callable
def apply_to_responses(
responses: list[dict],
transform: Callable[[dict], dict],
) -> list[dict]:
"""Apply any transformation function to each response."""
return [transform(r) for r in responses]
# Pass different functions to the same pipeline
def normalize_score(r: dict) -> dict:
return {**r, "score": round(r["score"], 2)}
def add_word_count(r: dict) -> dict:
return {**r, "word_count": len(r["text"].split())}
normalized = apply_to_responses(responses, normalize_score)
with_counts = apply_to_responses(responses, add_word_count)
# Compose transformations with a pipeline runner
def pipeline(responses: list[dict], *steps: Callable) -> list[dict]:
result = responses
for step in steps:
result = apply_to_responses(result, step)
return result
final = pipeline(responses, normalize_score, add_word_count)
print(final[0]) # includes both score and word_count
Decorators: Python's Most Powerful Pattern
A decorator wraps a function to add behavior before, after, or around it — without modifying the original function. If you've used C# attributes like [Authorize], [Cache], or custom action filters in ASP.NET, you already understand the concept. Python decorators are more powerful because they're regular functions, applied at runtime.
How Decorators Work (Under the Hood)
Before using decorators, it helps to see that there's no magic: a decorator is simply a function that takes a function and returns a replacement that wraps it. The @my_decorator line is pure syntactic sugar for call_llm = my_decorator(call_llm). The @functools.wraps call is important — it copies the original function's name and docstring onto the wrapper so debugging and logging still show the real function.
import functools
# A decorator is a function that takes a function and returns a function
def my_decorator(func):
@functools.wraps(func) # preserves the original function's __name__ and __doc__
def wrapper(*args, **kwargs):
print(f"Before {func.__name__}")
result = func(*args, **kwargs) # call the original
print(f"After {func.__name__}")
return result
return wrapper
@my_decorator
def call_llm(prompt: str) -> str:
return f"Response to: {prompt}"
# @my_decorator is syntactic sugar for:
# call_llm = my_decorator(call_llm)
result = call_llm("Hello")
# Output:
# Before call_llm
# After call_llm
Production Decorators for AI Workloads
1. Timing Decorator
The most common decorator in AI code measures how long a call takes — embedding batches and LLM requests are the slow parts of any pipeline, and you want that latency logged automatically. Note the try/finally: the elapsed time is recorded even if the wrapped function raises, so failures don't vanish from your timing logs.
import time
import functools
import logging
logger = logging.getLogger(__name__)
def timed(func):
"""Log execution time of any function."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.monotonic()
try:
result = func(*args, **kwargs)
return result
finally:
elapsed = (time.monotonic() - start) * 1000
logger.info(f"{func.__name__} completed in {elapsed:.1f}ms")
return wrapper
@timed
def embed_documents(texts: list[str]) -> list[list[float]]:
"""Embed multiple texts — timing tells us if the embedding model is slow."""
# ... implementation
return [[0.1, 0.2, 0.3]] * len(texts)
docs = embed_documents(["Hello", "World"]) # logs timing automatically
2. Retry Decorator (Parameterized)
This one adds a layer: it's a decorator that takes arguments (how many retries, how long to wait). That requires an extra level of nesting — a function that returns a decorator that returns a wrapper. It reads awkwardly at first, but the payoff is a reusable @retry(times=3) you can apply to any flaky API call without duplicating the retry loop.
import time
import functools
from typing import Type
def retry(
exceptions: tuple[Type[Exception], ...] = (Exception,),
max_attempts: int = 3,
delay: float = 1.0,
backoff: float = 2.0,
):
"""
Parameterized retry decorator.
Usage: @retry(exceptions=(RateLimitError,), max_attempts=5)
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
current_delay = delay
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except exceptions as e:
if attempt == max_attempts - 1:
raise
logger.warning(
f"{func.__name__} attempt {attempt+1} failed: {e}. "
f"Retrying in {current_delay:.1f}s..."
)
time.sleep(current_delay)
current_delay *= backoff
return wrapper
return decorator
from openai import OpenAI
@retry(
exceptions=(openai.RateLimitError, openai.InternalServerError),
max_attempts=5,
delay=1.0,
backoff=2.0,
)
def call_claude(client: OpenAI, prompt: str) -> str:
response = client.chat.completions.create(
model="gpt-4o",
max_tokens=512,
messages=[{"role": "user", "content": prompt}],
)
return response.choices[0].message.content
3. Caching Decorator
Identical prompts and repeated embedding requests are pure waste — you pay the API cost and latency for an answer you already have. A caching decorator stores results keyed by the function's arguments and returns the stored value on a repeat call, the same idea as [MemoryCache] in .NET. Here it wraps functools.lru_cache with awareness of the AI-specific gotchas.
import functools
import hashlib
import json
def cached_llm_call(func):
"""Simple in-memory cache for LLM responses — avoid re-querying identical prompts."""
cache: dict[str, str] = {}
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Create a stable cache key from arguments
key_data = {"args": str(args), "kwargs": sorted(kwargs.items())}
cache_key = hashlib.md5(json.dumps(key_data).encode()).hexdigest()
if cache_key in cache:
logger.debug(f"Cache hit for {func.__name__}")
return cache[cache_key]
result = func(*args, **kwargs)
cache[cache_key] = result
return result
wrapper.cache = cache # expose cache for inspection/clearing
return wrapper
@cached_llm_call
def summarize(client, text: str, max_sentences: int = 3) -> str:
return client.complete(f"Summarize in {max_sentences} sentences:\n{text}")
# Or use Python's built-in lru_cache for pure functions:
from functools import lru_cache
@lru_cache(maxsize=256)
def get_embedding(text: str) -> tuple[float, ...]:
"""Cache embeddings — identical texts should return identical vectors."""
# embedding API call
return tuple([0.1, 0.2, 0.3]) # placeholder
4. Validator Decorator
Guarding inputs before they reach an expensive API call is cheaper than letting a bad request fail remotely. This validator decorator checks the prompt (for example, rejecting empty or over-length input) and raises early — the decorator equivalent of an ASP.NET action filter that validates a request before the controller runs.
def validate_prompt(max_length: int = 50_000):
"""Validate prompt inputs before they hit the API."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Find the prompt argument
prompt = kwargs.get("prompt") or (args[1] if len(args) > 1 else None)
if prompt is None:
raise ValueError("No prompt argument found")
if not isinstance(prompt, str):
raise TypeError(f"prompt must be str, got {type(prompt).__name__}")
if not prompt.strip():
raise ValueError("prompt cannot be empty or whitespace")
if len(prompt) > max_length:
raise ValueError(f"prompt too long: {len(prompt)} > {max_length} chars")
return func(*args, **kwargs)
return wrapper
return decorator
@validate_prompt(max_length=10_000)
def analyze_code(client, prompt: str) -> str:
return client.complete(prompt)
Stacking Decorators
Decorators compose. Stack several on one function and they apply bottom-up — the one closest to the def wraps first, and the outermost runs first at call time. Order matters: you typically want validation outermost (fail fast), then caching, then timing/retry closest to the real call. The example shows the ordering and how to reason about it.
# Decorators are applied bottom-up (closest to the function first)
@timed
@retry(exceptions=(openai.RateLimitError,), max_attempts=3)
@validate_prompt(max_length=10_000)
def production_call(client: OpenAI, prompt: str) -> str:
"""A fully hardened LLM call: validated, retried, and timed."""
response = client.chat.completions.create(
model="gpt-4o",
max_tokens=512,
messages=[{"role": "user", "content": prompt}],
)
return response.choices[0].message.content
# Execution order: validate → retry → time → actual call
Without @functools.wraps(func), your wrapped function loses its __name__, __doc__, and type signature. This breaks logging, debugging, and tools like pytest that rely on function names. Always include it in every decorator you write.
Key Takeaways
- Python
lambdais equivalent to C# inline lambdas — use for simple sort keys, filter predicates, and map transformations - Functions are first-class objects: store them in lists, pass as arguments, return from functions — enables powerful pipeline composition
- Decorators wrap functions to add cross-cutting behavior (timing, retry, caching, validation) without modifying the original code
- Parameterized decorators use a three-level function nesting:
decorator_factory() → decorator() → wrapper() - Always use
@functools.wraps(func)in decorators to preserve the wrapped function's metadata - Stack decorators to compose behaviors — they apply bottom-up, closest to the function definition first

Comments
Loading comments…