Reducing Model Calls

Ma Mahalakshmi V Updated 19 Sep 2026
8 min read ·Lesson 177 of 224

Reducing unnecessary model calls

The cheapest model call is the one that never happens. Before optimizing what a model call costs (Lessons 2 and 5) or which model handles it (Lesson 6), it is worth eliminating calls that provide no additional value at all. This lesson covers three concrete techniques: short-circuiting obvious cases, deduplicating identical or near-identical requests, and caching results outside the model so repeated work is never resent.

Why This Comes Before Prompt and Cost Optimization

Reducing token count or switching to a cheaper model both reduce the cost of a call that still happens. Eliminating the call entirely reduces cost to zero for that request, and also removes its latency, its contribution to rate limits, and its exposure to model failure modes. In a system under load, cutting the number of calls is often more impactful than cutting the cost of each one, because it also protects capacity: fewer calls means more headroom before you hit provider rate limits.

This does not mean every call is avoidable — a request that genuinely needs the model's reasoning or generation ability must go through. The goal is to identify the subset of calls that are redundant, predictable, or unnecessary, and remove exactly those.

Short-Circuiting Obvious Cases

Many applications route every request through the model, even when a simple rule could resolve a meaningful fraction of cases without one. A classification feature that routes customer messages, for example, might not need a model call at all for a message that is obviously spam, or one that exactly matches a known FAQ question.

import re

KNOWN_SPAM_PATTERNS = [
    re.compile(r"\bfree\s+crypto\b", re.IGNORECASE),
    re.compile(r"\bclick\s+here\s+now\b", re.IGNORECASE),
]

FAQ_EXACT_MATCHES = {
    "what are your business hours": "We are open Monday to Friday, 9am to 6pm.",
    "how do i reset my password": "Go to Settings > Security > Reset Password.",
}


def classify_message(message: str) -> str | None:
    """Return a short-circuit classification, or None if a model call is needed."""
    normalized = message.strip().lower()

    if normalized in FAQ_EXACT_MATCHES:
        return FAQ_EXACT_MATCHES[normalized]

    for pattern in KNOWN_SPAM_PATTERNS:
        if pattern.search(message):
            return "[spam - filtered]"

    return None


def handle_message(message: str) -> str:
    short_circuit_result = classify_message(message)
    if short_circuit_result is not None:
        return short_circuit_result
    return call_model_for_response(message)

classify_message returns None specifically to signal "no rule applied, fall through to the model" — using None as a sentinel rather than an empty string keeps the two outcomes (a real short-circuit answer versus "no match") unambiguous, which matters because a legitimate response could itself be an empty string in some edge cases. This pattern only pays off when the rules are cheap to evaluate (regex matching and dictionary lookups run in microseconds) and reliably correct — a rule that misclassifies real user intent creates a worse experience than simply calling the model, so short-circuit rules should be conservative and only fire on genuinely unambiguous cases, with everything else falling through.

Deduplicating Identical Requests

In many applications, especially those handling bursts of traffic (batch imports, retries, or multiple users asking similar questions), the exact same request can arrive more than once within a short window. Deduplication catches these before they reach the model.

import hashlib
import time


class RequestDeduplicator:
    def __init__(self, window_seconds: float = 5.0):
        self._window_seconds = window_seconds
        self._recent: dict[str, tuple[float, str]] = {}

    def _key(self, feature: str, prompt: str) -> str:
        raw = f"{feature}:{prompt}"
        return hashlib.sha256(raw.encode("utf-8")).hexdigest()

    def get_cached_result(self, feature: str, prompt: str) -> str | None:
        key = self._key(feature, prompt)
        entry = self._recent.get(key)
        if entry is None:
            return None
        timestamp, result = entry
        if time.time() - timestamp > self._window_seconds:
            del self._recent[key]
            return None
        return result

    def store_result(self, feature: str, prompt: str, result: str) -> None:
        key = self._key(feature, prompt)
        self._recent[key] = (time.time(), result)

Hashing the combination of feature and prompt with sha256 produces a fixed-size key regardless of prompt length, which keeps the lookup dictionary's memory usage predictable even if individual prompts are long. The window_seconds expiry is essential: without it, _recent would keep every distinct request forever, defeating deduplication's purpose of catching near-simultaneous duplicates (a double-submitted form, a retried request after a timeout) rather than being used as a general-purpose long-term cache, which is a distinct technique covered next.

Note the deliberate scope limitation here: this deduplicator is appropriate for catching accidental, near-term repeats — not for caching results the application expects to reuse over hours or days. Conflating short-term deduplication with long-term caching leads to either serving stale results (window too long) or missing genuine duplicates (window too short).

Caching Results Outside the Model

A distinct and often higher-impact technique is caching the result of a model call at the application layer, keyed by a normalized version of the input, so that a repeated request — potentially from a different user, hours or days later — never reaches the model at all. This is different from the prompt-prefix caching covered in Unit 12, Lesson 4, which speeds up and discounts the input processing of a request that still executes; application-level result caching skips the model call entirely when the answer is already known.

import json
from typing import Callable


class ResponseCache:
    def __init__(self):
        self._store: dict[str, str] = {}

    def _normalize(self, feature: str, params: dict) -> str:
        # Sort keys so equivalent params in a different order produce the same key.
        canonical = json.dumps({"feature": feature, "params": params}, sort_keys=True)
        return canonical

    def get_or_compute(
        self,
        feature: str,
        params: dict,
        compute: Callable[[], str],
    ) -> str:
        key = self._normalize(feature, params)
        if key in self._store:
            return self._store[key]
        result = compute()
        self._store[key] = result
        return result

Sorting dictionary keys with sort_keys=True before hashing or storing is a small detail with an outsized effect: without it, {"lang": "en", "text": "hi"} and {"text": "hi", "lang": "en"} — which represent the identical logical request — would produce different cache keys and never hit each other, silently defeating the cache for a large fraction of otherwise-identical requests. The get_or_compute method takes compute as a callable rather than calling the model directly, which decouples the cache from any specific model client and makes it trivially testable with a fake compute function, as shown below.

This technique is most valuable for requests with a naturally small, repeating space of inputs: translating a fixed set of UI strings, summarizing a document that many users will view, or answering a frequently-asked question that is not an exact string match (unlike the FAQ short-circuit above, this can cache the model's answer to a paraphrased version of a common question, once it has been asked and normalized). It is a poor fit for genuinely unique, personalized requests — caching a customer's individual support ticket response will almost never produce a cache hit, since inputs are rarely repeated verbatim.

Testing Call-Reduction Logic

None of these techniques should be tested against a real model. Each is a pure function or a small stateful object that can be tested with fakes and fixed inputs.

def test_classify_message_short_circuits_faq():
    result = classify_message("What are your business hours")
    assert result == "We are open Monday to Friday, 9am to 6pm."
    print("PASS: FAQ short-circuit matches without a model call")


def test_classify_message_falls_through_for_unknown_input():
    result = classify_message("Can you help me plan a trip to Kyoto?")
    assert result is None
    print("PASS: unrecognized input falls through to the model")


def test_response_cache_avoids_recomputation():
    cache = ResponseCache()
    call_count = {"count": 0}

    def fake_compute() -> str:
        call_count["count"] += 1
        return "translated text"

    first = cache.get_or_compute("translate", {"text": "hi", "lang": "es"}, fake_compute)
    second = cache.get_or_compute("translate", {"lang": "es", "text": "hi"}, fake_compute)

    assert first == "translated text"
    assert second == "translated text"
    assert call_count["count"] == 1, "compute should only run once due to caching"
    print("PASS: response cache serves the second call without recomputing")


test_classify_message_short_circuits_faq()
test_classify_message_falls_through_for_unknown_input()
test_response_cache_avoids_recomputation()

test_response_cache_avoids_recomputation deliberately calls get_or_compute twice with the params dictionary keys in a different order ({"text": "hi", "lang": "es"} versus {"lang": "es", "text": "hi"}) specifically to verify the sort_keys=True normalization works — this is exactly the kind of subtle bug that a test with identically-ordered dictionaries would never catch. The call_count dictionary acts as a simple mutable counter captured by the closure fake_compute, letting the test assert that compute ran exactly once even though get_or_compute was called twice.

Common Mistakes

Deduplicating by exact string match only, missing semantically identical requests. Two prompts that differ only in whitespace, capitalization, or key order in a structured payload are logically the same request but will miss a naive string-equality cache. Normalizing input (trimming, lowercasing where appropriate, sorting structured keys) before keying the cache catches far more genuine duplicates.

Using a single cache for both short-term deduplication and long-term result caching. These have different correctness requirements — deduplication needs a short expiry to avoid serving stale near-duplicate answers, while result caching for stable content can be kept much longer. Conflating them leads to picking a compromise window that serves neither purpose well.

Adding short-circuit rules that are too aggressive. A rule that fires on ambiguous input to avoid a model call can produce a wrong answer with high confidence, which is worse for the user than the latency and cost of an actual model call. Keep short-circuit conditions narrow and easy to verify as correct.

Best Practices

Measure short-circuit and cache hit rates. Track what fraction of requests are resolved without a model call, per technique, using the usage metrics framework from Lesson 3 — this tells you which technique is worth investing further effort in and which is providing negligible benefit.

Make cache and deduplication keys deterministic and normalized. Canonicalize inputs (sorted keys, trimmed whitespace, consistent casing) before hashing, so that logically identical requests reliably produce the same key regardless of superficial formatting differences.

Keep short-circuit and cache logic close to the request entry point. Placing these checks early, before any prompt construction or context assembly happens, avoids doing unnecessary work (like an expensive database lookup to build a prompt) for a request that will not actually reach the model.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.output_text Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Memorizing Names Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
Ask about this post
AI Ask about this post

Ask questions about Reducing Model Calls and get answers drawn from it.

Signed-in readers only.