Overview
When I added a conversational assistant to the Dream Futures Tech site, the biggest constraint was the 9‑second timeout that AWS Lambda (and most other serverless platforms) enforce. A slow response feels like a broken site, and a timeout aborts the request entirely. The solution I settled on combines a very small, free‑to‑use language model with a fallback to a hosted API for the harder queries. The result is a bot that usually stays well inside the timeout window while keeping monthly costs low.
Architecture at a glance
┌─────────────────────┐ ┌───────────────────────┐
│ Front‑end (JS) │─────▶│ API Gateway (HTTPS) │
└─────────────────────┘ └─────────────┬─────────┘
│
┌───────▼───────┐
│ Lambda fn │
│ (Python) │
└───────┬───────┘
│
┌────────────────────────┼───────────────────────┐
│ │ │
┌─────────▼───────┐ ┌─────────▼───────┐ ┌───────▼───────┐
│ Local tiny model│ │ OpenAI API │ │ Cache (Redis)│
└─────────────────┘ └─────────────────┘ └─────────────────�n```
{% endraw %}
1. **Front‑end** sends the user message to an API endpoint.
2. **API Gateway** forwards the request to a **Lambda** function.
3. The Lambda first tries the **local tiny model** (e.g., a distilled GPT‑2 or a 7‑B quantized model). If the confidence is low, it falls back to the **OpenAI API** (or any other paid endpoint).
4. Responses are optionally cached in **Redis** (Elasticache) to avoid repeated calls for identical queries.
## Choosing a fast, free model
I experimented with a few open‑source checkpoints and settled on a **distilled 125M‑parameter GPT‑2** that I quantized to 4‑bit using {% raw %}`bitsandbytes`{% endraw %}. The model loads in roughly 200 MiB of RAM and can generate a short answer in under a second on the Lambda's allocated 2 GiB memory.
{% raw %}
```python
# model_loader.py
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_NAME = "distilgpt2"
DEVICE = "cpu" # Lambda does not have GPU
# Load once at cold start
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
torch_dtype=torch.float16,
low_cpu_mem_usage=True,
)
model.eval()
The quantization step is done locally before packaging the model into the Lambda deployment zip. By keeping the model small, the cold‑start time stays reasonable, and the runtime memory footprint stays within the free tier for most small‑business sites.
Implementing the fallback logic
The key is to decide when to call the external API. I use a simple heuristic based on the log‑probability of the generated tokens. If the average log‑probability falls below a threshold, the answer is considered low‑confidence.
# handler.py
import json, os, time
from model_loader import model, tokenizer
import redis
import requests
# Optional Redis cache
redis_client = redis.StrictRedis(host=os.getenv("REDIS_HOST"), port=6379, db=0)
API_URL = "https://api.openai.com/v1/chat/completions"
API_KEY = os.getenv("OPENAI_API_KEY")
CONFIDENCE_THRESHOLD = -5.0 # heuristic, tuned in testing
def generate_local(prompt: str) -> tuple[str, float]:
inputs = tokenizer(prompt, return_tensors="pt")
with torch.no_grad():
output = model.generate(**inputs, max_new_tokens=50, do_sample=False)
text = tokenizer.decode(output[0], skip_special_tokens=True)
# Compute average log‑probability (simplified)
probs = torch.log_softmax(model(**inputs).logits, dim=-1)
avg_logprob = probs.mean().item()
return text, avg_logprob
def call_external(prompt: str) -> str:
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
payload = {"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": prompt}]}
resp = requests.post(API_URL, json=payload, headers=headers, timeout=5)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
def lambda_handler(event, context):
body = json.loads(event["body"])
user_msg = body.get("message", "")
cache_key = f"chat:{hash(user_msg)}"
cached = redis_client.get(cache_key)
if cached:
return {"statusCode": 200, "body": json.dumps({"reply": cached.decode()})}
# Try local model first
reply, confidence = generate_local(user_msg)
if confidence < CONFIDENCE_THRESHOLD:
# Fallback to external API
reply = call_external(user_msg)
# Store in cache for 10 minutes
redis_client.setex(cache_key, 600, reply)
return {"statusCode": 200, "body": json.dumps({"reply": reply})}
The code does three things that help stay inside the timeout:
- Cache – identical queries hit Redis instantly.
- Early exit – if the local model is confident, we never make a network call.
-
Timeout on external request – the
requestscall is limited to 5 seconds, leaving headroom for the rest of the Lambda execution.
Serverless configuration tips
- Memory allocation – I start with 2 GiB; this gives the model enough RAM to stay in memory and reduces the number of CPU cycles needed for inference. In my experience, the function rarely exceeds 3 seconds when the local model succeeds.
- Provisioned concurrency – for a site that sees a steady trickle of visitors, enabling a small amount of provisioned concurrency (e.g., 5) eliminates most cold‑starts. The extra cost is modest and predictable.
- Environment variables – keep secrets (API keys, Redis endpoint) out of the code and inject them via the Lambda console or Terraform.
Monitoring and tuning
I use CloudWatch Logs to capture the latency of each step:
START RequestId: ... Version: $LATEST
2024-10-19T12:34:56.789Z INFO local_latency=1.2s confidence=-4.3
2024-10-19T12:34:58.012Z INFO fallback_used=False
END RequestId: ...
REPORT RequestId: ... Duration: 1245.67 ms ...
By filtering on fallback_used=True I can see how often the external API is hit. If that proportion climbs, I either tighten the confidence threshold or consider a slightly larger local model.
Cost considerations (qualitative)
- Lambda execution – with the memory setting above, the per‑invocation cost stays in the sub‑cent range. Even with a few hundred requests per day, the bill is well within the free tier.
- External API – the fallback is only used for the more ambiguous queries. In my experience the extra calls amount to a few dollars per month, which is acceptable for a small‑business site.
-
Redis – a tiny Elasticache instance (e.g.,
cache.t3.micro) covers the cache needs without adding noticeable expense.
Wrapping up
Keeping a chatbot responsive in a serverless environment boils down to three practical ideas:
- Start with a tiny, free model that can answer the majority of straightforward questions.
- Add a confidence‑based fallback to a paid service for the edge cases.
- Cache results and tune the Lambda configuration so that the overall latency stays comfortably below the platform’s timeout.
The approach lets me run a useful conversational front‑door on my own site without worrying about unpredictable spikes or runaway costs.
I build sites and automations for small businesses at dreamfuturestech.com.
Top comments (1)
The log line with
confidence=-4.3made me look at how that number is computed, and unless I'm misreading the handler, the fallback is deciding on something other than the answer.generate_localrunsmodel(**inputs)on the prompt only, then takes the mean oflog_softmaxacross every position and every vocabulary entry. That's an average over all ~50k tokens the model could have produced, most of them close to impossible, so it should land well below -5 for nearly any input and has no link to the 50 tokens it actually generated. If that holds, almost every request goes to the paid API after already spending a second on the local model. Passingoutput_scores=Trueandreturn_dict_in_generate=Truetogenerate()and averaging the log probability of the tokens it picked gives you a per-answer number that actually moves between easy and hard questions.Two smaller things in the same handler.
hash()on a string is salted per Python process, so each Lambda container computes a different cache key for the same question and Redis only hits inside one warm container.hashlib.sha256of the lowercased, trimmed message fixes that. And the worst case is serial: cold start, local generation, then up to 5 seconds on the external call, and if that call times out requests raises with nothing catching it, so the visitor gets a 500 instead of a reply. I'd size the external timeout fromcontext.get_remaining_time_in_millis()and return a short canned answer with a way to reach you when time runs out.What share of requests shows
fallback_used=Truein CloudWatch so far?