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
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)
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
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)
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="claude-3-5-sonnet-20241022",
max_tokens=512,
messages=[{"role": "user", "content": prompt}],
)
return response.choices[0].message.content
3. Caching Decorator
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
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 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="claude-3-5-sonnet-20241022",
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…