M22: Cost Optimization
"Free" open source models are not actually free — they consume hardware, electricity, and your users' patience. This module teaches you to measure real costs, choose the right model for each task, and apply five practical optimizations that cut response time by 3–10x without sacrificing quality.
Learning Objectives
- Understand the true cost equation for local inference: compute + latency + electricity
- Navigate the Ollama model tier ladder and choose the right model for a given task
- Pick the right quantization level using the Q4_K_M sweet-spot rule
- Apply five prompt efficiency techniques that reduce tokens per agent step
- Build async batch inference pipelines with concurrency controls
- Implement a two-layer response cache (exact-match + semantic) with TTL
- Run a benchmark harness and produce a p50/p95 latency report across models
- Know which hardware to provision for a given model size and throughput target
Understanding Open Source Model Costs
Imagine you own a bakery and decide to stop buying bread from a wholesale supplier. "Making our own bread is free!" you announce. But baking in-house requires a commercial oven, electricity 24/7, a baker's time, flour storage, and cleaning. You are not paying per loaf anymore — you are running a factory. The per-unit cost can be lower at scale, but the upfront commitment and ongoing overhead are very real.
Open source LLMs work exactly the same way. There is no per-token invoice from Anthropic or OpenAI. But a 70B parameter model running on CPU can take 10 minutes per query on a laptop — which means your agent loop is effectively a 10-minute wall-clock wait per step. At that latency, no real user will tolerate the product.
The correct cost equation is: total_cost = inference_time × compute_cost_per_second + latency_impact. On a developer's MacBook, compute_cost_per_second is nearly zero, but latency_impact (user frustration, SLA violations, sequential-step compounding) is enormous. On a rented GPU, compute_cost_per_second is real. You must optimize for both.
The Real Cost Equation
Three cost dimensions that every local inference deployment must track:
- Hardware cost — amortized purchase cost or hourly rental rate of the machine running the model
- Electricity cost — a GPU server pulling 300W for 8 hours/day ≈ $0.07–$0.15/day at US rates
- Latency impact — slower tokens-per-second means longer agent loops, worse UX, and fewer requests served per hour
When you optimize for speed with open source models, you are not just shaving a cost line item — you are directly shortening the wall-clock time your users and agent loops must wait. A 2x speed improvement feels like a completely different product. A 10x improvement (e.g., moving from CPU to GPU) makes an unusable prototype into a shippable feature.
Comparison: Mistral-7B Across Environments
| Environment | Tokens/sec | Latency 500-tok response | Hardware cost | Best for |
|---|---|---|---|---|
| CPU only (laptop, 16 GB RAM) | 3–6 tok/s | 83–166 s | $0 (own hardware) | Dev / testing only |
| Apple Silicon M2 Pro 16 GB | 15–25 tok/s | 20–33 s | ~$0.01/hr (electricity) | Developer workstation |
| Apple Silicon M3 Max 64 GB | 35–50 tok/s | 10–14 s | ~$0.02/hr | Team dev server |
| AWS g4dn.xlarge (T4 GPU 16 GB) | 55–80 tok/s | 6–9 s | $0.526/hr on-demand | Production light |
| RTX 4090 (24 GB VRAM) | 80–120 tok/s | 4–6 s | $0.35/hr (Lambda Labs) | High-throughput prod |
| Cloud API (Anthropic Haiku) | 80–120 tok/s | ~4–8 s | $0.00025/1K in tokens | Pay-per-use baseline |
Model Selection & Tradeoffs
Model selection is a three-way optimization between task complexity (what reasoning depth does this task actually require?), model size (how much VRAM/RAM does the model consume?), and latency budget (what is the maximum acceptable response time for this use case?). The single most common optimization opportunity in any system we have audited: teams are running 13B–70B models on tasks that a 7B model handles at 97% quality — wasting 2–5x compute for no measurable gain.
Decision Matrix
| Task Type | Complexity | Latency Budget | Recommended Model |
|---|---|---|---|
| Classification / routing | Low | <1s | phi3:mini, gemma2:2b |
| Summarization, extraction | Low–Medium | <5s | mistral:7b Q4 |
| ReAct agent loop (most agents) | Medium | <10s | mistral:7b or llama3.1:8b |
| Multi-step planning, code gen | Medium–High | <20s | deepseek-r1:7b |
| Complex reasoning, legal/medical | High | <60s | mixtral:8x7b (GPU required) |
| Research-grade tasks | Very High | Async OK | llama3.1:70b (GPU required) |
Benchmark Script — Measure Before You Decide
Never pick a model by intuition alone. Run 20 representative prompts from your actual workload and measure p50/p95 inference latencyInference latency is the wall-clock time from the first token request to the last token received. For agents, latency compounds across steps: a 10-step loop at 8s/step = 80s total. plus an automatic quality score from a judge model.
"""
model_selector_benchmark.py
Run: python model_selector_benchmark.py
Requires: ollama running locally
"""
import time, statistics, json
from ollama import Client
client = Client(host="http://localhost:11434")
TEST_PROMPTS = [
"Extract the company name and filing date from: 'UCC-1 filed by Acme LLC on 2024-03-15 in Delaware'",
"Is this address a PO Box? '123 Main St, Springfield IL 62701'",
"Summarize in one sentence: the debtor is Riverside Corp, secured party is First National Bank, collateral is all business assets.",
"Generate a JSON object with keys: debtor, secured_party, collateral. Values: 'Smith Inc', 'Chase Bank', 'equipment'",
"What is the difference between a UCC-1 and a UCC-3 amendment?",
] * 4 # 20 total prompts
MODELS = ["phi3:mini", "mistral:7b", "llama3.1:8b"]
JUDGE_MODEL = "mistral:7b"
def score_response(prompt: str, response: str) -> float:
"""Ask a local judge model to score quality 1-10."""
judge_prompt = f"""Score this LLM response 1-10 for correctness and completeness.
Prompt: {prompt[:200]}
Response: {response[:300]}
Reply with ONLY a number 1-10."""
try:
result = client.generate(model=JUDGE_MODEL, prompt=judge_prompt)
score = float(result["response"].strip().split()[0])
return min(max(score, 1.0), 10.0)
except Exception:
return 5.0 # neutral on parse failure
def benchmark_model(model: str) -> dict:
latencies, scores = [], []
for prompt in TEST_PROMPTS:
t0 = time.perf_counter()
try:
result = client.generate(model=model, prompt=prompt)
elapsed = time.perf_counter() - t0
latencies.append(elapsed)
scores.append(score_response(prompt, result["response"]))
except Exception as e:
print(f" [ERROR] {model}: {e}")
latencies.append(999.0)
scores.append(0.0)
latencies_sorted = sorted(latencies)
return {
"model": model,
"p50_s": round(statistics.median(latencies), 2),
"p95_s": round(latencies_sorted[int(len(latencies_sorted) * 0.95)], 2),
"avg_quality": round(statistics.mean(scores), 1),
"n": len(latencies),
}
def main():
print("Running model selection benchmark (20 prompts per model)...\n")
results = []
for model in MODELS:
print(f" Testing {model}...")
r = benchmark_model(model)
results.append(r)
print(f" p50={r['p50_s']}s p95={r['p95_s']}s quality={r['avg_quality']}/10")
results.sort(key=lambda x: (-(x["avg_quality"]), x["p50_s"]))
print("\n=== Ranked Results ===")
print(f"{'Model':<25} {'p50 (s)':<10} {'p95 (s)':<10} {'Quality':<10}")
print("-" * 55)
for r in results:
print(f"{r['model']:<25} {r['p50_s']:<10} {r['p95_s']:<10} {r['avg_quality']}/10")
with open("model_benchmark.json", "w") as f:
json.dump(results, f, indent=2)
print("\nSaved: model_benchmark.json")
if __name__ == "__main__":
main()
// model_selector_benchmark.ts
// Run: npx ts-node model_selector_benchmark.ts
// Requires: ollama running locally, npm install ollama
import Ollama from "ollama";
import * as fs from "fs/promises";
const client = new Ollama.Ollama({ host: "http://localhost:11434" });
const TEST_PROMPTS_BASE = [
"Extract the company name and filing date from: 'UCC-1 filed by Acme LLC on 2024-03-15 in Delaware'",
"Is this address a PO Box? '123 Main St, Springfield IL 62701'",
"Summarize in one sentence: debtor is Riverside Corp, secured party is First National Bank, collateral is all business assets.",
"Generate a JSON object with keys: debtor, secured_party, collateral.",
"What is the difference between a UCC-1 and a UCC-3 amendment?",
];
const TEST_PROMPTS = [...Array(4)].flatMap(() => TEST_PROMPTS_BASE); // 20 prompts
const MODELS = ["phi3:mini", "mistral:7b", "llama3.1:8b"];
const JUDGE_MODEL = "mistral:7b";
async function scoreResponse(prompt: string, response: string): Promise {
const judgePrompt = `Score this LLM response 1-10 for correctness and completeness.
Prompt: ${prompt.slice(0, 200)}
Response: ${response.slice(0, 300)}
Reply with ONLY a number 1-10.`;
try {
const result = await client.generate({ model: JUDGE_MODEL, prompt: judgePrompt });
const score = parseFloat(result.response.trim().split(/\s+/)[0]);
return isNaN(score) ? 5 : Math.min(Math.max(score, 1), 10);
} catch {
return 5;
}
}
async function benchmarkModel(model: string) {
const latencies: number[] = [];
const scores: number[] = [];
for (const prompt of TEST_PROMPTS) {
const t0 = performance.now();
try {
const result = await client.generate({ model, prompt });
latencies.push((performance.now() - t0) / 1000);
scores.push(await scoreResponse(prompt, result.response));
} catch (e) {
console.error(` [ERROR] ${model}:`, e);
latencies.push(999);
scores.push(0);
}
}
const sorted = [...latencies].sort((a, b) => a - b);
const p50 = sorted[Math.floor(sorted.length * 0.5)];
const p95 = sorted[Math.floor(sorted.length * 0.95)];
const avgQ = scores.reduce((a, b) => a + b, 0) / scores.length;
return { model, p50_s: +p50.toFixed(2), p95_s: +p95.toFixed(2), avg_quality: +avgQ.toFixed(1), n: latencies.length };
}
async function main() {
console.log("Running model selection benchmark (20 prompts per model)...\n");
const results = [];
for (const model of MODELS) {
process.stdout.write(` Testing ${model}...`);
const r = await benchmarkModel(model);
results.push(r);
console.log(` p50=${r.p50_s}s p95=${r.p95_s}s quality=${r.avg_quality}/10`);
}
results.sort((a, b) => b.avg_quality - a.avg_quality || a.p50_s - b.p50_s);
console.log("\n=== Ranked Results ===");
console.log(`${"Model".padEnd(25)} ${"p50 (s)".padEnd(10)} ${"p95 (s)".padEnd(10)} Quality`);
console.log("-".repeat(55));
for (const r of results) {
console.log(`${r.model.padEnd(25)} ${String(r.p50_s).padEnd(10)} ${String(r.p95_s).padEnd(10)} ${r.avg_quality}/10`);
}
await fs.writeFile("model_benchmark.json", JSON.stringify(results, null, 2));
console.log("\nSaved: model_benchmark.json");
}
main();
The benchmark script runs every test prompt against every model and collects wall-clock latency at the OS level (not self-reported). The judge model adds a quality dimension so you are not just picking the fastest model — you are picking the fastest model that meets your quality floor. The p95 column matters more than p50 for production: occasional slow queries at the 95th percentile are the ones that cause timeouts.
Quantization: Q4 vs Q8 vs F16
Before: A professional photographer shoots in RAW format — each photo is 40 MB of lossless data. The image is perfect in every detail, but a 10,000-photo trip fills a 400 GB hard drive and takes 30 seconds to load in Lightroom.
Pain: You want to share the photos on a website that loads in under a second. The 40 MB RAW file is unusable. You need a compressed format — but JPEG compression at 20% quality turns faces into muddy blurs. The trick is finding the sweet spot where the compression is invisible to the human eye but the file is 30x smaller.
Mapping: QuantizationQuantization is the process of reducing the numerical precision of a model's weight parameters — from 32-bit or 16-bit floating point down to 8-bit or 4-bit integers. This shrinks the model file and speeds up inference, at the cost of slight quality degradation. is JPEG compression for model weights. F16 (16-bit float) is the RAW file — perfect quality, huge size. Q4 (4-bit integer) is the aggressively compressed JPEG. Q4_K_MQ4_K_M is a specific 4-bit quantization scheme where the "K" means key layers use mixed precision and "M" means medium size. It achieves ~98% of F16 quality at ~30% of the memory and 2x the speed — the sweet spot for most deployments. is the 80%-quality JPEG that looks indistinguishable to the eye but loads instantly. The sweet spot is not 100% fidelity — it is the minimum fidelity your use case actually requires.
Quality vs Speed Tradeoff Table
| Format | Bits | Mistral-7B VRAM | Tokens/sec (RTX 4090) | Quality vs F16 | Use when |
|---|---|---|---|---|---|
| Q2_K | 2 | ~2.5 GB | ~140 tok/s | ~85% (noticeable degradation) | Extreme memory constraint only |
| Q3_K_M | 3 | ~3.1 GB | ~120 tok/s | ~93% | 4 GB RAM machines |
| Q4_K_M ★ | 4 | ~4.1 GB | ~105 tok/s | ~98% (imperceptible) | Default for all deployments |
| Q6_K | 6 | ~5.5 GB | ~90 tok/s | ~99% | Code gen, complex reasoning |
| Q8_0 | 8 | ~7.7 GB | ~75 tok/s | ~99.5% | Benchmark / evaluation baseline |
| F16 | 16 | ~14 GB | ~50 tok/s | 100% (reference) | Fine-tuning, research only |
Unless you have a specific reason to go higher (fine-tuning, accuracy-critical medical tasks, rigorous benchmarking), always pull the Q4_K_M variant. It fits in 4 GB of VRAM (runnable on most machines), delivers ~98% of F16 quality in benchmarks, and runs 2x faster than F16. The "K" and "M" suffixes mean the quantization is applied more carefully to the most sensitive layers, which is why quality is so well preserved.
# Pull the Q4_K_M sweet-spot variant (recommended default)
ollama pull mistral:7b-instruct-q4_K_M
# Pull Q8 for evaluation/benchmarking baseline
ollama pull mistral:7b-instruct-q8_0
# Check what is installed and VRAM usage
ollama list
# Inspect model details including quantization level
ollama show mistral:7b-instruct-q4_K_M --modelfile
140 tok/s
120 tok/s
105 tok/s
90 tok/s
75 tok/s
50 tok/s
Orange circle = sweet spot. Q4_K_M delivers near-F16 quality at 2x the speed.
Prompt Efficiency
In a cloud API context, reducing tokens saves money directly. With local inference, reducing tokens saves time — which is effectively the same thing. A ReAct agent that appends the full tool response to every iteration compounds quickly: 5 steps × 2,000 token context = 10,000 tokens processed. Trim each step to 500 tokens and you process 2,500 total — a 4x improvement in throughput.
Technique 1: System Prompt Prefix Caching
Ollama automatically caches the KV state for identical prompt prefixes across requests. If your system prompt is 500 tokens and 100 requests share it, Ollama processes those 500 tokens once and reuses the cached state. This costs nothing and requires zero code changes — just keep your system prompt identical across requests.
Ollama's KV cache is in-memory and model-scoped. As long as the model stays loaded (it unloads after 5 minutes of inactivity by default), prefix cache hits are essentially free. Set OLLAMA_KEEP_ALIVE=24h in production to keep the model warm.
Techniques 2–5: Token Budget, Truncation, Pruning, and Structured Outputs
"""
prompt_efficiency.py — Five token-reduction techniques for Ollama agents
"""
import json
from ollama import Client
client = Client(host="http://localhost:11434")
MODEL = "mistral:7b-instruct-q4_K_M"
# -----------------------------------------------------------------
# TECHNIQUE 2: max_tokens budget per step
# Without a budget, each step can consume up to context_window tokens
# (4096 for Mistral). Budget to 256 for extraction tasks.
# -----------------------------------------------------------------
def step_with_budget(prompt: str, max_tokens: int = 256) -> str:
result = client.generate(
model=MODEL,
prompt=prompt,
options={"num_predict": max_tokens}, # Ollama option = max_tokens
)
return result["response"]
# -----------------------------------------------------------------
# TECHNIQUE 3: Tool result truncation before appending to messages
# A search tool may return 10 KB of JSON. Truncate to essential fields
# before appending to the conversation context.
# -----------------------------------------------------------------
def truncate_tool_result(result: dict, max_chars: int = 500) -> str:
"""Serialize and truncate tool output to stay within token budget."""
serialized = json.dumps(result, separators=(",", ":"))
if len(serialized) <= max_chars:
return serialized
# Keep first max_chars and add truncation marker
return serialized[:max_chars] + "...[truncated]"
# -----------------------------------------------------------------
# TECHNIQUE 4: Sliding window conversation pruning
# Keep system prompt + last N turns. Drop oldest turns first.
# (Adapted from M11: Multi-Layer Memory)
# -----------------------------------------------------------------
class SlidingWindowConversation:
def __init__(self, system_prompt: str, max_turns: int = 6):
self.system = system_prompt
self.max_turns = max_turns
self.turns: list[dict] = [] # each turn = {"role": ..., "content": ...}
def add_turn(self, role: str, content: str) -> None:
self.turns.append({"role": role, "content": content})
# Prune oldest turns when over budget (keep in pairs to avoid orphans)
while len(self.turns) > self.max_turns * 2:
self.turns.pop(0)
if self.turns and self.turns[0]["role"] == "assistant":
self.turns.pop(0) # remove dangling assistant turn
def build_prompt(self) -> str:
"""Build a single string prompt from system + sliding window turns."""
parts = [f"System: {self.system}"]
for turn in self.turns:
prefix = "User" if turn["role"] == "user" else "Assistant"
parts.append(f"{prefix}: {turn['content']}")
parts.append("Assistant:")
return "\n\n".join(parts)
def token_estimate(self) -> int:
"""Rough estimate: ~4 chars per token."""
return len(self.build_prompt()) // 4
# -----------------------------------------------------------------
# TECHNIQUE 5: Structured JSON responses are denser than prose
# Asking for JSON instead of prose can cut response tokens by 40-60%.
# -----------------------------------------------------------------
EXTRACTION_PROMPT = """Extract filing metadata from the text below.
Respond ONLY with a JSON object — no prose, no explanation, no markdown.
Required keys: debtor_name, secured_party, filing_date, collateral_summary.
Text: Acme Corp has granted First Capital Bank a security interest in all current
and future inventory and receivables. Filing recorded 2024-06-01 in Delaware."""
def extract_structured(prompt: str) -> dict:
"""Get structured JSON output — typically 40-60% fewer tokens than prose."""
response = client.generate(
model=MODEL,
prompt=prompt,
options={"num_predict": 200}, # JSON is compact, 200 is plenty
format="json", # Ollama JSON mode enforces valid JSON output
)
try:
return json.loads(response["response"])
except json.JSONDecodeError as e:
raise ValueError(f"Model returned invalid JSON: {e}") from e
# -----------------------------------------------------------------
# DEMO: Before/after token count comparison
# -----------------------------------------------------------------
def count_tokens_approx(text: str) -> int:
"""Approximation: ~4 chars per token (good enough for comparison)."""
return len(text) // 4
def demo_token_savings():
prose_prompt = "Describe the UCC filing: Acme Corp granted First Capital Bank a security interest in inventory. Filing 2024-06-01 Delaware."
json_prompt = EXTRACTION_PROMPT
prose_response = step_with_budget(prose_prompt, max_tokens=300)
json_response = step_with_budget(json_prompt + "\nRespond in JSON only.", max_tokens=150)
prose_tokens = count_tokens_approx(prose_response)
json_tokens = count_tokens_approx(json_response)
print(f"Prose response: ~{prose_tokens} tokens")
print(f"JSON response: ~{json_tokens} tokens")
print(f"Savings: {prose_tokens - json_tokens} tokens ({(1 - json_tokens/max(prose_tokens,1))*100:.0f}%)")
if __name__ == "__main__":
demo_token_savings()
// prompt_efficiency.ts — Five token-reduction techniques for Ollama agents
// Run: npx ts-node prompt_efficiency.ts
import Ollama from "ollama";
const client = new Ollama.Ollama({ host: "http://localhost:11434" });
const MODEL = "mistral:7b-instruct-q4_K_M";
// -----------------------------------------------------------------
// TECHNIQUE 2: max_tokens budget per step
// -----------------------------------------------------------------
async function stepWithBudget(prompt: string, maxTokens = 256): Promise {
const result = await client.generate({
model: MODEL,
prompt,
options: { num_predict: maxTokens },
});
return result.response;
}
// -----------------------------------------------------------------
// TECHNIQUE 3: Tool result truncation
// -----------------------------------------------------------------
function truncateToolResult(result: unknown, maxChars = 500): string {
const serialized = JSON.stringify(result);
if (serialized.length <= maxChars) return serialized;
return serialized.slice(0, maxChars) + "...[truncated]";
}
// -----------------------------------------------------------------
// TECHNIQUE 4: Sliding window conversation pruning
// -----------------------------------------------------------------
class SlidingWindowConversation {
private turns: Array<{ role: string; content: string }> = [];
constructor(private systemPrompt: string, private maxTurns = 6) {}
addTurn(role: string, content: string): void {
this.turns.push({ role, content });
while (this.turns.length > this.maxTurns * 2) {
this.turns.shift();
if (this.turns[0]?.role === "assistant") this.turns.shift();
}
}
buildPrompt(): string {
const parts = [`System: ${this.systemPrompt}`];
for (const t of this.turns) {
parts.push(`${t.role === "user" ? "User" : "Assistant"}: ${t.content}`);
}
parts.push("Assistant:");
return parts.join("\n\n");
}
tokenEstimate(): number {
return Math.floor(this.buildPrompt().length / 4);
}
}
// -----------------------------------------------------------------
// TECHNIQUE 5: Structured JSON responses
// -----------------------------------------------------------------
const EXTRACTION_PROMPT = `Extract filing metadata from the text below.
Respond ONLY with a JSON object — no prose, no explanation, no markdown.
Required keys: debtor_name, secured_party, filing_date, collateral_summary.
Text: Acme Corp has granted First Capital Bank a security interest in all current
and future inventory and receivables. Filing recorded 2024-06-01 in Delaware.`;
async function extractStructured(prompt: string): Promise> {
const response = await client.generate({
model: MODEL,
prompt,
options: { num_predict: 200 },
format: "json",
});
try {
return JSON.parse(response.response);
} catch (e) {
throw new Error(`Model returned invalid JSON: ${e}`);
}
}
// -----------------------------------------------------------------
// DEMO: token savings comparison
// -----------------------------------------------------------------
function countTokensApprox(text: string): number {
return Math.floor(text.length / 4);
}
async function demoTokenSavings() {
const prosePrompt = "Describe the UCC filing: Acme Corp granted First Capital Bank a security interest in inventory. Filing 2024-06-01 Delaware.";
const [proseResponse, jsonResponse] = await Promise.all([
stepWithBudget(prosePrompt, 300),
stepWithBudget(EXTRACTION_PROMPT + "\nRespond in JSON only.", 150),
]);
const proseTokens = countTokensApprox(proseResponse);
const jsonTokens = countTokensApprox(jsonResponse);
console.log(`Prose response: ~${proseTokens} tokens`);
console.log(`JSON response: ~${jsonTokens} tokens`);
const savings = Math.round((1 - jsonTokens / Math.max(proseTokens, 1)) * 100);
console.log(`Savings: ${proseTokens - jsonTokens} tokens (${savings}%)`);
}
demoTokenSavings().catch(console.error);
You applied five stacked optimizations: identical system prompt (free KV cache reuse), num_predict cap (prevents runaway generation), tool result truncation (keeps context window small), sliding window pruning (prevents context bloat across turns), and JSON output mode (40–60% denser than prose). Stacked together on a 10-step agent loop, this commonly delivers 3–4x throughput improvement on the same hardware.
Batch Inference
For async workloads — document processing pipelines, nightly enrichment jobs, bulk entity resolution — you do not need to wait for each inference to complete before starting the next. Ollama handles concurrent requests internally (it queues them against the loaded model). Sending 4–8 requests simultaneously gives you near-linear throughput gains up to the model's concurrency limit.
Ollama processes requests concurrently but serializes them against the GPU/CPU at a hardware level. Sending 100 simultaneous requests does not give you 100x throughput — it gives you queue congestion and high memory pressure. The sweet spot for most Mistral-7B deployments is batch=4 to batch=8. Use asyncio.Semaphore (Python) or a p-limit pool (Node.js) to enforce this cap.
| Batch size | Total time (20 prompts) | Throughput | Memory pressure |
|---|---|---|---|
| Serial (batch=1) | ~200s | 1x baseline | Minimal |
| batch=4 | ~60s | 3.3x | Moderate |
| batch=8 | ~32s | 6.25x | Moderate-High |
| batch=16 | ~28s | 7x (diminishing returns) | High — risk of OOM |
"""
batch_inference.py — Async batch processing with Ollama
"""
import asyncio, time, httpx, json
from dataclasses import dataclass
MODEL = "mistral:7b-instruct-q4_K_M"
OLLAMA_URL = "http://localhost:11434/api/generate"
CONCURRENCY_LIMIT = 8 # Safe cap for Mistral-7B on 16 GB VRAM
@dataclass
class BatchResult:
index: int
prompt: str
response: str
elapsed_s: float
error: str | None = None
async def single_inference(
client: httpx.AsyncClient,
semaphore: asyncio.Semaphore,
index: int,
prompt: str,
) -> BatchResult:
"""Run one inference, respecting the concurrency semaphore."""
async with semaphore:
t0 = time.perf_counter()
try:
resp = await client.post(
OLLAMA_URL,
json={"model": MODEL, "prompt": prompt, "stream": False,
"options": {"num_predict": 256}},
timeout=120.0,
)
resp.raise_for_status()
data = resp.json()
return BatchResult(
index=index,
prompt=prompt[:60],
response=data["response"],
elapsed_s=round(time.perf_counter() - t0, 2),
)
except Exception as e:
return BatchResult(
index=index, prompt=prompt[:60], response="",
elapsed_s=round(time.perf_counter() - t0, 2), error=str(e),
)
async def batch_infer(prompts: list[str], concurrency: int = CONCURRENCY_LIMIT) -> list[BatchResult]:
"""Process a list of prompts concurrently, capped at `concurrency`."""
semaphore = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient() as client:
tasks = [
single_inference(client, semaphore, i, p)
for i, p in enumerate(prompts)
]
return await asyncio.gather(*tasks)
async def throughput_comparison(prompts: list[str]) -> None:
# Serial baseline
t0 = time.perf_counter()
await batch_infer(prompts, concurrency=1)
serial_s = time.perf_counter() - t0
# Batch=4
t0 = time.perf_counter()
await batch_infer(prompts, concurrency=4)
batch4_s = time.perf_counter() - t0
# Batch=8
t0 = time.perf_counter()
await batch_infer(prompts, concurrency=8)
batch8_s = time.perf_counter() - t0
print(f"Serial (concurrency=1): {serial_s:.1f}s 1.0x")
print(f"Batch=4: {batch4_s:.1f}s {serial_s/batch4_s:.1f}x speedup")
print(f"Batch=8: {batch8_s:.1f}s {serial_s/batch8_s:.1f}x speedup")
if __name__ == "__main__":
test_prompts = [f"Classify this text as business or personal: sample text #{i}" for i in range(20)]
asyncio.run(throughput_comparison(test_prompts))
// batch_inference.ts — Async batch processing with Ollama
// Run: npx ts-node batch_inference.ts
// npm install p-limit
import pLimit from "p-limit";
const MODEL = "mistral:7b-instruct-q4_K_M";
const OLLAMA_URL = "http://localhost:11434/api/generate";
interface BatchResult {
index: number;
prompt: string;
response: string;
elapsed_s: number;
error?: string;
}
async function singleInference(index: number, prompt: string): Promise {
const t0 = performance.now();
try {
const resp = await fetch(OLLAMA_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: MODEL, prompt, stream: false, options: { num_predict: 256 } }),
signal: AbortSignal.timeout(120_000),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json() as { response: string };
return { index, prompt: prompt.slice(0, 60), response: data.response, elapsed_s: +((performance.now() - t0) / 1000).toFixed(2) };
} catch (e) {
return { index, prompt: prompt.slice(0, 60), response: "", elapsed_s: +((performance.now() - t0) / 1000).toFixed(2), error: String(e) };
}
}
async function batchInfer(prompts: string[], concurrency: number): Promise {
const limit = pLimit(concurrency);
return Promise.all(prompts.map((p, i) => limit(() => singleInference(i, p))));
}
async function throughputComparison(prompts: string[]) {
const time = async (fn: () => Promise) => { const t = performance.now(); await fn(); return (performance.now() - t) / 1000; };
const serial = await time(() => batchInfer(prompts, 1));
const batch4 = await time(() => batchInfer(prompts, 4));
const batch8 = await time(() => batchInfer(prompts, 8));
console.log(`Serial (concurrency=1): ${serial.toFixed(1)}s 1.0x`);
console.log(`Batch=4: ${batch4.toFixed(1)}s ${(serial / batch4).toFixed(1)}x speedup`);
console.log(`Batch=8: ${batch8.toFixed(1)}s ${(serial / batch8).toFixed(1)}x speedup`);
}
const testPrompts = Array.from({ length: 20 }, (_, i) => `Classify this text as business or personal: sample text #${i}`);
throughputComparison(testPrompts).catch(console.error);
Response Caching
The fastest inference is no inference at all. A two-layer cache handles two distinct cases: identical queries (exact-match on a hash key) and semantically equivalent queries (same meaning, different wording). Together they can eliminate 20–60% of inference calls in workloads with repetitive or near-repetitive queries — common in document processing pipelines and customer-facing chatbots.
A semantic cacheA semantic cache embeds incoming queries as dense vectors and compares them against previously cached query vectors using cosine similarity. If the similarity exceeds a threshold (typically 0.95), the cached response is returned without running inference. embeds each incoming query as a dense vector and checks its cosine similarityCosine similarity measures the angle between two vectors in high-dimensional space. A score of 1.0 means identical direction (semantically identical queries). A score above 0.95 usually indicates the queries are asking for the same thing with different words. against all previously cached query embeddings. If a cached query has similarity > 0.95 to the incoming query, the cached response is returned instantly — the model never runs. This handles paraphrasing, typo variants, and near-duplicate questions.
Complete AgentCache Class — Two Layers with TTL
"""
agent_cache.py — Two-layer response cache with TTL
pip install chromadb sentence-transformers ollama
"""
import hashlib, time
from dataclasses import dataclass, field
from typing import Optional
import chromadb
from chromadb.utils.embedding_functions import SentenceTransformerEmbeddingFunction
from ollama import Client
@dataclass
class CacheEntry:
response: str
created_at: float
ttl_s: float
def is_expired(self) -> bool:
return time.time() > self.created_at + self.ttl_s
class AgentCache:
"""
Layer 1: Exact-match dict cache (hash of query+model).
Layer 2: Semantic vector cache via ChromaDB (cosine sim > threshold).
Both layers support TTL-based expiration.
"""
def __init__(
self,
model: str = "mistral:7b-instruct-q4_K_M",
embed_model: str = "all-MiniLM-L6-v2",
similarity_threshold: float = 0.95,
default_ttl_s: float = 3600.0, # 1 hour
):
self.model = model
self.threshold = similarity_threshold
self.default_ttl = default_ttl_s
self._exact: dict[str, CacheEntry] = {} # Layer 1
# Layer 2: ChromaDB with local sentence-transformer embeddings
self._chroma = chromadb.Client()
embed_fn = SentenceTransformerEmbeddingFunction(model_name=embed_model)
self._collection = self._chroma.get_or_create_collection(
name="agent_cache",
embedding_function=embed_fn,
metadata={"hnsw:space": "cosine"},
)
self._ollama = Client(host="http://localhost:11434")
self._stats = {"hits": 0, "misses": 0}
def _hash_key(self, query: str) -> str:
return hashlib.sha256(f"{self.model}:{query}".encode()).hexdigest()[:16]
def _purge_expired(self) -> None:
expired = [k for k, v in self._exact.items() if v.is_expired()]
for k in expired:
del self._exact[k]
def get(self, query: str) -> Optional[str]:
self._purge_expired()
# Layer 1: exact match
key = self._hash_key(query)
if key in self._exact:
entry = self._exact[key]
if not entry.is_expired():
self._stats["hits"] += 1
return entry.response
# Layer 2: semantic similarity
try:
results = self._collection.query(
query_texts=[query],
n_results=1,
include=["documents", "distances", "metadatas"],
)
if results["documents"] and results["documents"][0]:
distance = results["distances"][0][0]
similarity = 1 - distance # ChromaDB returns distance, not similarity
metadata = results["metadatas"][0][0]
expires_at = metadata.get("expires_at", 0)
if similarity >= self.threshold and time.time() < expires_at:
self._stats["hits"] += 1
return results["documents"][0][0]
except Exception:
pass # Degrade gracefully to live inference on cache errors
return None
def set(self, query: str, response: str, ttl_s: Optional[float] = None) -> None:
ttl = ttl_s or self.default_ttl
key = self._hash_key(query)
# Layer 1
self._exact[key] = CacheEntry(response=response, created_at=time.time(), ttl_s=ttl)
# Layer 2
try:
self._collection.upsert(
ids=[key],
documents=[response],
metadatas=[{"query": query[:200], "expires_at": time.time() + ttl}],
)
except Exception:
pass # Layer 1 is sufficient fallback
def infer(self, query: str, ttl_s: Optional[float] = None) -> str:
"""Full cache-aware inference: check cache, fall back to Ollama."""
cached = self.get(query)
if cached is not None:
return cached
self._stats["misses"] += 1
result = self._ollama.generate(
model=self.model,
prompt=query,
options={"num_predict": 256},
)
response = result["response"]
self.set(query, response, ttl_s)
return response
@property
def stats(self) -> dict:
total = self._stats["hits"] + self._stats["misses"]
return {
**self._stats,
"hit_rate": f"{self._stats['hits'] / max(total, 1) * 100:.1f}%",
}
# Usage example
if __name__ == "__main__":
cache = AgentCache()
q1 = "What is the debtor name in UCC filing #12345?"
q2 = "Who is the debtor listed on UCC-1 number 12345?" # semantic near-duplicate
r1 = cache.infer(q1) # Miss — runs Ollama
r2 = cache.infer(q1) # Hit — exact match
r3 = cache.infer(q2) # Likely hit — semantic similarity > 0.95
print(f"Stats: {cache.stats}")
// agent_cache.ts — Two-layer response cache with TTL
// npm install chromadb @xenova/transformers ollama
import { ChromaClient } from "chromadb";
import Ollama from "ollama";
import * as crypto from "crypto";
interface CacheEntry { response: string; expiresAt: number; }
export class AgentCache {
private exact = new Map();
private chroma = new ChromaClient();
private collection: Awaited> | null = null;
private ollama = new Ollama.Ollama({ host: "http://localhost:11434" });
private stats = { hits: 0, misses: 0 };
constructor(
private model = "mistral:7b-instruct-q4_K_M",
private threshold = 0.95,
private defaultTtlS = 3600,
) {}
async init(): Promise {
this.collection = await this.chroma.getOrCreateCollection({
name: "agent_cache",
metadata: { "hnsw:space": "cosine" },
});
}
private hashKey(query: string): string {
return crypto.createHash("sha256").update(`${this.model}:${query}`).digest("hex").slice(0, 16);
}
private purgeExpired(): void {
const now = Date.now();
for (const [k, v] of this.exact) {
if (v.expiresAt < now) this.exact.delete(k);
}
}
async get(query: string): Promise {
this.purgeExpired();
// Layer 1: exact match
const key = this.hashKey(query);
const entry = this.exact.get(key);
if (entry && entry.expiresAt > Date.now()) { this.stats.hits++; return entry.response; }
// Layer 2: semantic similarity
if (this.collection) {
try {
const results = await this.collection.query({ queryTexts: [query], nResults: 1, include: ["documents", "distances", "metadatas"] as any });
if (results.documents?.[0]?.[0]) {
const distance = (results.distances as number[][])[0][0];
const similarity = 1 - distance;
const meta = (results.metadatas as any[][])[0][0];
if (similarity >= this.threshold && Date.now() < (meta?.expires_at ?? 0) * 1000) {
this.stats.hits++;
return results.documents[0][0]!;
}
}
} catch { /* degrade gracefully */ }
}
return null;
}
async set(query: string, response: string, ttlS = this.defaultTtlS): Promise {
const key = this.hashKey(query);
this.exact.set(key, { response, expiresAt: Date.now() + ttlS * 1000 });
if (this.collection) {
try {
await this.collection.upsert({ ids: [key], documents: [response], metadatas: [{ query: query.slice(0, 200), expires_at: Math.floor(Date.now() / 1000) + ttlS }] });
} catch { /* layer 1 is sufficient fallback */ }
}
}
async infer(query: string, ttlS?: number): Promise {
const cached = await this.get(query);
if (cached !== null) return cached;
this.stats.misses++;
const result = await this.ollama.generate({ model: this.model, prompt: query, options: { num_predict: 256 } });
await this.set(query, result.response, ttlS);
return result.response;
}
getStats() {
const total = this.stats.hits + this.stats.misses;
return { ...this.stats, hit_rate: `${(this.stats.hits / Math.max(total, 1) * 100).toFixed(1)}%` };
}
}
// Usage
(async () => {
const cache = new AgentCache();
await cache.init();
const q1 = "What is the debtor name in UCC filing #12345?";
const q2 = "Who is the debtor listed on UCC-1 number 12345?";
await cache.infer(q1); // Miss
await cache.infer(q1); // Hit — exact
await cache.infer(q2); // Likely Hit — semantic
console.log("Stats:", cache.getStats());
})();
Layer 1 (exact hash match) adds near-zero overhead — a dictionary lookup. Layer 2 (ChromaDB vector search) adds ~10–30ms of embedding + similarity search overhead, but saves 2–60 seconds of inference time on a cache hit. The TTL prevents stale responses from persisting indefinitely. In a document pipeline that repeatedly asks similar extraction questions, a 40–60% cache hit rate is realistic, cutting inference calls — and wall-clock time — roughly in half.
Benchmarking Your Setup
A benchmark is not a one-time event — it is a regular measurement ritual. Run it before and after any model change, hardware change, quantization change, or major prompt change. The output tells you whether a change made things better, worse, or neutral across the three axes that actually matter: latency, tokens-per-second, and output quality.
"""
agent_benchmark.py — Full benchmark harness for Ollama models
Run: python agent_benchmark.py
Output: benchmark_results.csv + benchmark_results.md
"""
import asyncio, csv, json, statistics, time
from pathlib import Path
import httpx
OLLAMA_URL = "http://localhost:11434/api/generate"
JUDGE_MODEL = "mistral:7b" # Judge runs separately
MODELS = ["phi3:mini", "mistral:7b", "llama3.1:8b"]
BENCHMARK_PROMPTS = [
# Entity extraction
"Extract: debtor=?, secured_party=? from: 'TechCorp Inc granted Wells Fargo a lien on all software assets'",
"Parse this UCC filing date: 'filed on the fifteenth day of March two thousand twenty-four'",
"Is 'ACME LLC' and 'Acme Limited Liability Company' the same entity? Answer yes or no with brief reason.",
# Summarization
"Summarize in 20 words: Debtor Riverside Corp grants secured party First Bank interest in all machinery and equipment now owned or hereafter acquired.",
"One-sentence summary: The amendment extends the maturity date of the original UCC-1 financing statement by 5 years from the original lapse date.",
# Classification
"Classify as high-risk or low-risk: secured party is a non-bank fintech, collateral is future receivables, filed in a state with no central registry.",
"Is this a continuation, amendment, or termination? 'The secured party hereby terminates all security interests in the collateral described in the original filing.'",
# JSON generation
"Return JSON with keys: entity_type, risk_level, jurisdiction for: 'Delaware LLC with a federal tax lien from IRS'",
"Generate a JSON summary of: 'UCC-1 #2024-003-456, debtor: Smith Farms LLC, secured party: AgriCredit Corp, collateral: livestock and crops'",
# Reasoning
"If a UCC-1 was filed in 2019 and not renewed, is it still valid in 2025? Explain in 2 sentences.",
"Which takes priority: a UCC-1 filed today or a mortgage filed 3 years ago? Brief answer.",
# Code assistance
"Write a one-liner Python to extract all 10-digit UCC filing numbers from a string using regex.",
"What HTTP status code does the Illinois SOS lien search API return for a not-found entity?",
# Math/logic
"A UCC-1 lapses after 5 years. Filed 2020-04-01. Lapse date?",
"If processing 10,000 filings at 3 seconds each with batch=8, estimate total runtime in hours.",
# Long context (tests KV pressure)
"Given this partial data: entity_id=E001, name=Acme Corp, address=123 Main St, state=IL, filing_count=3, risk_score=0.72 — generate a brief risk summary.",
"Compare: debtor_a='First National Leasing LLC' vs debtor_b='1st Natl. Leasing, LLC'. Likely same entity? Confidence?",
# Edge cases
"What should an agent do if the Ollama API returns a 503 error mid-response?",
"An entity resolution agent gets conflicting addresses for the same debtor. What disambiguation strategy minimizes false merges?",
"List 3 signs that a local LLM agent is suffering from context window overflow.",
]
async def run_single(client: httpx.AsyncClient, model: str, prompt: str) -> dict:
t0 = time.perf_counter()
try:
resp = await client.post(
OLLAMA_URL,
json={"model": model, "prompt": prompt, "stream": False, "options": {"num_predict": 300}},
timeout=180.0,
)
resp.raise_for_status()
data = resp.json()
elapsed = time.perf_counter() - t0
eval_count = data.get("eval_count", 1)
eval_duration_ns = data.get("eval_duration", elapsed * 1e9)
tps = eval_count / (eval_duration_ns / 1e9) if eval_duration_ns > 0 else 0
return {"prompt": prompt[:80], "response": data["response"], "elapsed_s": elapsed, "tps": tps, "error": None}
except Exception as e:
return {"prompt": prompt[:80], "response": "", "elapsed_s": time.perf_counter() - t0, "tps": 0, "error": str(e)}
async def score_batch(client: httpx.AsyncClient, prompts_responses: list[tuple[str, str]]) -> list[float]:
scores = []
for prompt, response in prompts_responses:
judge_prompt = f"Score 1-10 for correctness. Prompt: {prompt[:150]} Response: {response[:250]}\nReply: number only."
try:
r = await client.post(OLLAMA_URL, json={"model": JUDGE_MODEL, "prompt": judge_prompt, "stream": False, "options": {"num_predict": 5}}, timeout=60.0)
score = float(r.json()["response"].strip().split()[0])
scores.append(min(max(score, 1.0), 10.0))
except Exception:
scores.append(5.0)
return scores
async def benchmark_model(model: str) -> dict:
print(f" Benchmarking {model} ({len(BENCHMARK_PROMPTS)} prompts)...")
async with httpx.AsyncClient() as client:
results = [await run_single(client, model, p) for p in BENCHMARK_PROMPTS]
scores = await score_batch(client, [(r["prompt"], r["response"]) for r in results])
latencies = [r["elapsed_s"] for r in results if r["error"] is None]
tps_vals = [r["tps"] for r in results if r["tps"] > 0]
lat_sorted = sorted(latencies)
return {
"model": model,
"n": len(BENCHMARK_PROMPTS),
"errors": sum(1 for r in results if r["error"]),
"p50_s": round(statistics.median(latencies), 2) if latencies else 0,
"p95_s": round(lat_sorted[int(len(lat_sorted) * 0.95)], 2) if latencies else 0,
"avg_tps": round(statistics.mean(tps_vals), 1) if tps_vals else 0,
"avg_quality": round(statistics.mean(scores), 1),
}
def write_csv(results: list[dict], path: str = "benchmark_results.csv") -> None:
with open(path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=results[0].keys())
writer.writeheader()
writer.writerows(results)
def write_markdown(results: list[dict], path: str = "benchmark_results.md") -> None:
lines = ["# Agent Benchmark Results\n",
"| Model | p50 (s) | p95 (s) | Avg tok/s | Quality/10 | Errors |",
"|---|---|---|---|---|---|"]
for r in results:
lines.append(f"| {r['model']} | {r['p50_s']} | {r['p95_s']} | {r['avg_tps']} | {r['avg_quality']} | {r['errors']} |")
Path(path).write_text("\n".join(lines))
async def main():
print(f"Running benchmark across {len(MODELS)} models × {len(BENCHMARK_PROMPTS)} prompts\n")
results = [await benchmark_model(m) for m in MODELS]
results.sort(key=lambda x: (-(x["avg_quality"]), x["p50_s"]))
print("\n=== Benchmark Results ===")
print(f"{'Model':<28} {'p50':>6} {'p95':>6} {'tok/s':>7} {'Quality':>9}")
print("-" * 60)
for r in results:
print(f"{r['model']:<28} {r['p50_s']:>6.1f} {r['p95_s']:>6.1f} {r['avg_tps']:>7.0f} {r['avg_quality']:>8.1f}/10")
write_csv(results)
write_markdown(results)
print("\nSaved: benchmark_results.csv, benchmark_results.md")
if __name__ == "__main__":
asyncio.run(main())
// agent_benchmark.ts — Full benchmark harness
// npx ts-node agent_benchmark.ts
// npm install
import * as fs from "fs/promises";
const OLLAMA_URL = "http://localhost:11434/api/generate";
const JUDGE_MODEL = "mistral:7b";
const MODELS = ["phi3:mini", "mistral:7b", "llama3.1:8b"];
const BENCHMARK_PROMPTS = [
"Extract: debtor=?, secured_party=? from: 'TechCorp Inc granted Wells Fargo a lien on all software assets'",
"Parse this UCC filing date: 'filed on the fifteenth day of March two thousand twenty-four'",
"Is 'ACME LLC' and 'Acme Limited Liability Company' the same entity? Answer yes or no.",
"Summarize in 20 words: Debtor Riverside Corp grants secured party First Bank interest in all machinery.",
"Classify as high-risk or low-risk: secured party is a non-bank fintech, collateral is future receivables.",
"Is this a continuation, amendment, or termination? 'The secured party hereby terminates all security interests.'",
"Return JSON: entity_type, risk_level, jurisdiction for: 'Delaware LLC with a federal tax lien from IRS'",
"If a UCC-1 was filed in 2019 and not renewed, is it still valid in 2025? 2 sentences.",
"Write a one-liner Python to extract 10-digit UCC filing numbers from a string using regex.",
"A UCC-1 lapses after 5 years. Filed 2020-04-01. Lapse date?",
"List 3 signs that a local LLM agent is suffering from context window overflow.",
"What should an agent do if the Ollama API returns a 503 error mid-response?",
"An entity resolution agent gets conflicting addresses. What disambiguation strategy minimizes false merges?",
"Compare: debtor_a='First National Leasing LLC' vs debtor_b='1st Natl. Leasing, LLC'. Same entity?",
"Generate a risk summary for: entity_id=E001, risk_score=0.72, filing_count=3.",
"Which takes priority: a UCC-1 filed today or a mortgage filed 3 years ago?",
"One-sentence summary: The amendment extends the maturity date by 5 years from the original lapse date.",
"If processing 10,000 filings at 3 seconds each with batch=8, estimate total runtime in hours.",
"What HTTP status code does the Illinois SOS lien search API return for a not-found entity?",
"Generate JSON summary of: 'UCC-1 #2024-003-456, debtor: Smith Farms LLC, secured party: AgriCredit Corp'",
];
interface OllamaResp { response: string; eval_count?: number; eval_duration?: number; }
async function runSingle(model: string, prompt: string) {
const t0 = performance.now();
try {
const resp = await fetch(OLLAMA_URL, { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, prompt, stream: false, options: { num_predict: 300 } }), signal: AbortSignal.timeout(180_000) });
const data = await resp.json() as OllamaResp;
const elapsed = (performance.now() - t0) / 1000;
const tps = data.eval_count && data.eval_duration ? data.eval_count / (data.eval_duration / 1e9) : 0;
return { prompt: prompt.slice(0, 80), response: data.response, elapsed_s: elapsed, tps, error: null as string | null };
} catch (e) {
return { prompt: prompt.slice(0, 80), response: "", elapsed_s: (performance.now() - t0) / 1000, tps: 0, error: String(e) };
}
}
async function scoreOne(prompt: string, response: string): Promise {
try {
const resp = await fetch(OLLAMA_URL, { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: JUDGE_MODEL, prompt: `Score 1-10: Prompt: ${prompt.slice(0,150)} Response: ${response.slice(0,250)}\nReply: number only.`, stream: false, options: { num_predict: 5 } }), signal: AbortSignal.timeout(60_000) });
const d = await resp.json() as OllamaResp;
const s = parseFloat(d.response.trim().split(/\s+/)[0]);
return isNaN(s) ? 5 : Math.min(Math.max(s, 1), 10);
} catch { return 5; }
}
async function benchmarkModel(model: string) {
process.stdout.write(` Benchmarking ${model} (${BENCHMARK_PROMPTS.length} prompts)...`);
const results = [];
for (const p of BENCHMARK_PROMPTS) results.push(await runSingle(model, p));
const scores = await Promise.all(results.map(r => scoreOne(r.prompt, r.response)));
const latencies = results.filter(r => !r.error).map(r => r.elapsed_s).sort((a, b) => a - b);
const tpsVals = results.filter(r => r.tps > 0).map(r => r.tps);
const p50 = latencies[Math.floor(latencies.length * 0.5)] ?? 0;
const p95 = latencies[Math.floor(latencies.length * 0.95)] ?? 0;
const avgTps = tpsVals.length ? tpsVals.reduce((a, b) => a + b, 0) / tpsVals.length : 0;
const avgQ = scores.reduce((a, b) => a + b, 0) / scores.length;
console.log(` p50=${p50.toFixed(1)}s p95=${p95.toFixed(1)}s tps=${avgTps.toFixed(0)} q=${avgQ.toFixed(1)}`);
return { model, n: BENCHMARK_PROMPTS.length, errors: results.filter(r => r.error).length,
p50_s: +p50.toFixed(2), p95_s: +p95.toFixed(2), avg_tps: +avgTps.toFixed(1), avg_quality: +avgQ.toFixed(1) };
}
async function main() {
console.log(`Running benchmark: ${MODELS.length} models × ${BENCHMARK_PROMPTS.length} prompts\n`);
const results = [];
for (const m of MODELS) results.push(await benchmarkModel(m));
results.sort((a, b) => b.avg_quality - a.avg_quality || a.p50_s - b.p50_s);
await fs.writeFile("benchmark_results.json", JSON.stringify(results, null, 2));
const md = ["# Agent Benchmark Results\n",
"| Model | p50 (s) | p95 (s) | tok/s | Quality/10 |",
"|---|---|---|---|---|",
...results.map(r => `| ${r.model} | ${r.p50_s} | ${r.p95_s} | ${r.avg_tps} | ${r.avg_quality} |`)
].join("\n");
await fs.writeFile("benchmark_results.md", md);
console.log("\nSaved: benchmark_results.json, benchmark_results.md");
}
main().catch(console.error);
Hardware Guide
The single most impactful optimization you can make is matching your model size to your hardware. Running Mistral-7B Q4 on hardware with 16 GB RAM and no GPU is 10–20x slower than running the same model on a machine with even a modest GPU. This table will save you from weeks of suffering on the wrong hardware.
| Hardware | Best Model Fit | Approx Tokens/sec | Cost | Notes |
|---|---|---|---|---|
| MacBook Pro M3 16 GB (base) | Mistral-7B Q4 | ~20 tok/s | Own hardware | Excellent for dev. Unified memory means no VRAM/RAM split. |
| MacBook Pro M3 Max 64 GB | Mixtral-8x7B Q4, Llama3.1:70B Q2 | 35–45 tok/s | Own hardware | Best bang for zero GPU budget. Can run 70B models slowly. |
| Hetzner CX52 (32 GB RAM, no GPU) | Mistral-7B Q4 | ~3 tok/s | €16/mo (~$18) | Fine for async pipelines. Not suitable for interactive use. |
| AWS g4dn.xlarge (T4 16 GB VRAM) | Mistral-7B Q8 | ~55 tok/s | $0.526/hr on-demand ($190/mo if 24/7) | Good production entry point. T4 is older but widely available. |
| AWS g4dn.2xlarge (T4 16 GB + 32 GB RAM) | Mistral-7B Q8 + embeddings | ~60 tok/s | $0.752/hr | Comfortable headroom for model + embedding model simultaneously. |
| Lambda Labs RTX 4090 (24 GB VRAM) | Mistral-7B Q8 or Mixtral-8x7B Q4 | ~100 tok/s | $0.50/hr | Best price/performance for production. 24 GB fits most 7–13B models at Q8. |
| 4× RTX 4090 (96 GB VRAM total) | Llama3.1:70B Q4 | ~30 tok/s | $2.00/hr | Near GPT-4 quality locally. Requires tensor parallelism via llama.cpp or vLLM. |
For typical agent workloads (5–50 requests/minute), an AWS g4dn.xlarge at $0.526/hr running 8 hours/day costs ~$127/month. An equivalent cloud API volume (assume 2,000 tokens/request × 25 req/min × 480 min = 24M tokens/day) at Anthropic Haiku pricing ($0.25/1M input tokens) costs ~$5.76/day = ~$173/month. The GPU instance wins — and you keep your data private.
If the model does not fit in VRAM, it overflows to RAM (CPU offloading). CPU offloading cuts throughput by 5–20x depending on the overflow ratio. The practical rule: model_size_GB × 1.1 ≤ VRAM_GB. For Mistral-7B Q4_K_M (~4.1 GB), any 8 GB VRAM GPU works. For Mixtral-8x7B (~26 GB), you need an A100 80 GB, 4× RTX 4090, or Apple Silicon M2 Ultra 192 GB.
Knowledge Check
Test your understanding of cost optimization for local open source LLM agents.
1. You are deploying Mistral-7B for an async document pipeline that runs overnight. Your server has 32 GB RAM but no GPU. What is the practical tokens/sec rate you should plan for?
2. Which quantization format is recommended as the default for most Ollama deployments?
3. Your ReAct agent processes 10 steps per query. Each step appends the full tool response (~2,000 tokens). After implementing tool result truncation to ~200 tokens per step, what is the approximate reduction in total tokens processed?
4. Your AgentCache shows a cosine similarity of 0.94 between a new query and a cached query with a 0.95 threshold. What happens?
5. You set batch=32 concurrency on an Ollama instance running Mistral-7B on a single RTX 4090 (24 GB VRAM). What is the most likely outcome?
6. What is the primary difference between p50 and p95 latency in a benchmark report?
OS Track Complete!
You have completed all 20 modules of the Open Source Track — ~45 hours of hands-on learning. You can now build, evaluate, monitor, and deploy production AI agents — all without spending a dollar on API fees.
From dev setup and LLM mental models through ReAct agents, multi-agent systems, RAG pipelines, guardrails, evaluation, tracing, monitoring, deployment, and now cost optimization — you have the complete production stack.