Context Window Management
Even with 200k-token context windows, naively dumping all recalled memories into every prompt is expensive, slow, and degrades response quality. The right approach is selective loading: score each candidate memory by a composite of relevance, recency, and importance, then fill the memory budget starting from the top — cutting off when the budget is exhausted. This pattern gives you maximum signal in minimum tokens.
Start Building Free →- Dakera instance running (quickstart)
- SDK installed:
pip install dakera/npm i @dakera-ai/dakera - Understanding of your LLM's token limits and cost structure
- A token counting strategy — either a dedicated tokenizer library or character-based approximation (1 token ≈ 4 characters)
The Problem
Production agents with persistent memory face a compounding pressure: memory stores grow over time, but context windows don't. Three concrete failure modes emerge:
- Prompt cost explosion: An agent with 200 stored memories that naively injects all of them into every prompt spends 40,000+ tokens per call on context alone, and pays for them on every call before the actual user message and response.
- Context dilution: When 200 memories are packed into a prompt, the LLM's effective attention on any individual memory drops. High-quality, critical memories compete for attention with low-relevance noise, and response quality drops as the context fills up.
- Latency amplification: Every extra token in the prompt adds to the model's time to first token (TTFT), so an unmanaged memory injection of tens of thousands of tokens makes every response slower.
Architecture
This pattern implements a priority queue with a token budget cap. Dakera recalls a broad candidate set (top_k=50), ranked by its own score (smart_score), which already combines relevance, importance and recency (weights 0.55, 0.20 and 0.13 on the default route). Your application then re-scores each candidate with its own composite, composite = (score × W_r) + (importance × W_i) + (recency_factor × W_rc), and adds memories to the context in that order until the memory token budget is exhausted. The scoring, token counting and trimming are application logic; Dakera supplies the candidates and their fields. Critical memories (lessons, active goals) are pinned to always be included.
Implementation Steps
-
Define your token budget allocationBefore writing any code, map out your context window allocation. For a 128k model this page uses: system prompt 8k tokens, pinned memories (goals and lessons) 12k, scored memories 20k, conversation history 18k, user message 3k, and 67k reserved for the response. Your scored memory budget = total window - all other allocations. Document this and keep it as a named constant.
-
Implement the composite scoring functionDakera returns a
scorewith each memory: itssmart_score, already a blend of relevance, importance and recency. Re-weight it with the memory'simportancefield and a recency factor computed fromcreated_at:recency = max(0, 1 - hours_since_created / decay_hours). Default weights in this pattern: score 50%, importance 30%, recency 20%. Tune these for your use case — long-document analysis agents may want to reduce recency weight. -
Add budget-aware trimming with token countingSort memories by composite score descending. Iterate through the list, adding each memory's token estimate to a running total. Stop adding when the total exceeds your memory budget. Use
len(content) / 4as a conservative character-to-token approximation for fast local counting, or integrate thetiktokenlibrary for precise counting in production. -
Pin critical memories outside the scored budgetSome memories must always be included regardless of relevance score: active goals with near deadlines, behavioral lessons from corrections, identity facts (who the user is). Recall these with separate queries using
min_importance=0.90and add them to the prompt first — they don't count against the scored memory budget. Keep pinned memory allocation tight (under 15k tokens total). -
Monitor hit rates and tune regularlyTrack what percentage of your recalled memories actually make it into the final context (the hit rate). A hit rate below 60% means your broad recall is returning too many irrelevant results — increase
min_importanceto 0.5 or reducetop_k. A hit rate above 95% means you may be leaving useful memories on the table — increase your memory budget or lowermin_importance.
A common mistake is maximizing memory injection at the expense of response quality. For a 128k context model, reserve at least 30k tokens (23%) for the expected response — for long-form content generation, reserve 40–60k. Stuffing memories to 90% of context hurts output coherence and invites hallucination. More memory is not always better.
Implementation
# Step 1: broad recall for the candidate set
curl -X POST http://localhost:3000/v1/memory/recall \
-H "Authorization: Bearer dk-..." \
-H "Content-Type: application/json" \
-d '{"agent_id": "doc-analysis-agent", "query": "document analysis context user preferences project", "top_k": 50, "min_importance": 0.3}'
# Step 2: separate recall for pinned memories (high-importance critical context)
curl -X POST http://localhost:3000/v1/memory/recall \
-H "Authorization: Bearer dk-..." \
-H "Content-Type: application/json" \
-d '{"agent_id": "doc-analysis-agent", "query": "active goals lessons user identity critical context", "top_k": 10, "min_importance": 0.9}'
# Each result carries content, importance, created_at and score (Dakera's smart_score).
# Scoring, token counting and trimming happen client-side.
# Budget allocation example for a 128k model:
# system_prompt: 8,000 tokens
# pinned_memories: 12,000 tokens
# scored_memories: 20,000 tokens
# conversation: 18,000 tokens
# user_message: 3,000 tokens
# response_reserved: 67,000 tokens
# TOTAL: 128,000 tokensfrom dakera import DakeraClient
from datetime import datetime, timezone
from typing import NamedTuple
client = DakeraClient(base_url="http://localhost:3000", api_key="dk-...")
# Token budget for a 128k context model
TOKEN_BUDGET = {
"system_prompt": 8_000,
"pinned_memories": 12_000,
"scored_memories": 20_000, # the budget this pattern manages
"conversation": 18_000,
"user_message": 3_000,
"response_reserved": 67_000,
}
# Composite weights (application logic): tune for your use case
SCORING_WEIGHTS = {
"relevance": 0.50, # Dakera's recall score (smart_score)
"importance": 0.30, # the memory's importance, 0.0-1.0
"recency": 0.20, # from created_at
}
RECENCY_DECAY_HOURS = 168 # 7 days: older memories get near-zero recency
class ScoredMemory(NamedTuple):
content: str
composite_score: float
token_estimate: int
memory_id: str
def estimate_tokens(text: str) -> int:
"""Conservative character-based token estimate. Use tiktoken for precision."""
return max(1, len(text) // 4)
def compute_recency_factor(created_at) -> float:
"""1.0 for fresh memories, decaying toward 0.0 over RECENCY_DECAY_HOURS."""
if created_at is None:
return 0.5 # unknown age: neutral recency
try:
created = datetime.fromtimestamp(float(created_at), timezone.utc) # unix seconds
except (TypeError, ValueError):
try:
created = datetime.fromisoformat(str(created_at).replace('Z', '+00:00'))
except ValueError:
return 0.5
hours_old = (datetime.now(timezone.utc) - created).total_seconds() / 3600
return max(0.0, 1.0 - hours_old / RECENCY_DECAY_HOURS)
def score_memory(mem) -> ScoredMemory:
"""Composite score for one recalled memory (a RecalledMemory)."""
composite = (
mem.score * SCORING_WEIGHTS["relevance"]
+ mem.importance * SCORING_WEIGHTS["importance"]
+ compute_recency_factor(mem.created_at) * SCORING_WEIGHTS["recency"]
)
return ScoredMemory(
content=mem.content,
composite_score=round(composite, 4),
token_estimate=estimate_tokens(mem.content),
memory_id=mem.id
)
def build_memory_context(
agent_id: str,
query: str,
memory_budget_tokens: int = TOKEN_BUDGET["scored_memories"]
) -> dict:
"""Recall, score, and trim memories to fit the token budget."""
# Step 1: pinned memories (goals, lessons, identity), outside the scored budget
pinned = client.recall(
agent_id=agent_id,
query="active goals lessons critical identity constraints",
top_k=10,
min_importance=0.90
).memories
pinned_ids = {m.id for m in pinned}
pinned_tokens = sum(estimate_tokens(m.content) for m in pinned)
# Step 2: broad recall for scored candidates
candidates = client.recall(
agent_id=agent_id,
query=query,
top_k=50,
min_importance=0.30
).memories
# Step 3: score every candidate not already pinned, highest first
scored = sorted(
(score_memory(m) for m in candidates if m.id not in pinned_ids),
key=lambda m: m.composite_score,
reverse=True,
)
# Step 4: fill the budget greedily
selected: list[ScoredMemory] = []
tokens_used = 0
trimmed_count = 0
for mem in scored:
if tokens_used + mem.token_estimate > memory_budget_tokens:
trimmed_count += 1
continue
selected.append(mem)
tokens_used += mem.token_estimate
# Step 5: build the context block, pinned first
pinned_context = "\n".join(m.content for m in pinned)
scored_context = "\n".join(m.content for m in selected)
full_context = ""
if pinned_context:
full_context += f"[Critical context]\n{pinned_context}\n\n"
if scored_context:
full_context += f"[Relevant context]\n{scored_context}"
return {
"context": full_context,
"stats": {
"pinned_memories": len(pinned),
"pinned_tokens": pinned_tokens,
"scored_selected": len(selected),
"scored_trimmed": trimmed_count,
"scored_tokens": tokens_used,
"total_tokens": pinned_tokens + tokens_used,
"budget_utilization": round(tokens_used / memory_budget_tokens, 3),
"hit_rate": round(len(selected) / max(len(scored), 1), 3),
}
}
# --- Usage in a long document analysis agent ---
def analyze_document(agent_id: str, document_text: str, question: str) -> str:
context = build_memory_context(agent_id, query=question)
stats = context["stats"]
print(f"Memory context: {stats['scored_selected']} memories, "
f"{stats['total_tokens']:,} tokens, "
f"{stats['budget_utilization']:.0%} budget used, "
f"{stats['scored_trimmed']} memories trimmed")
system_prompt = f"""You are a document analysis assistant.
{context['context']}
Document excerpt:
{document_text[:10000]}"""
# return llm.complete(system=system_prompt, user=question)
return f"[Context loaded: {stats['total_tokens']} tokens] Ready to answer: {question}"
print(analyze_document("doc-analysis-agent", "...", "What are the key risk factors?"))import { DakeraClient } from '@dakera-ai/dakera';
import type { RecalledMemory } from '@dakera-ai/dakera';
const client = new DakeraClient({ baseUrl: 'http://localhost:3000', apiKey: 'dk-...' });
const TOKEN_BUDGET = {
systemPrompt: 8_000,
pinnedMemories: 12_000,
scoredMemories: 20_000,
conversation: 18_000,
userMessage: 3_000,
responseReserved: 67_000,
};
// Application logic: relevance is Dakera's recall score (smart_score)
const SCORING_WEIGHTS = { relevance: 0.5, importance: 0.3, recency: 0.2 };
const RECENCY_DECAY_HOURS = 168; // 7 days
function estimateTokens(text: string): number {
return Math.max(1, Math.floor(text.length / 4));
}
function computeRecencyFactor(createdAt?: string | number): number {
if (createdAt === undefined) return 0.5;
const n = Number(createdAt);
const ms = Number.isNaN(n) ? Date.parse(String(createdAt)) : n * 1000; // unix seconds or ISO
if (Number.isNaN(ms)) return 0.5;
const hoursOld = (Date.now() - ms) / 3_600_000;
return Math.max(0, 1 - hoursOld / RECENCY_DECAY_HOURS);
}
interface ScoredMemory {
content: string;
compositeScore: number;
tokenEstimate: number;
}
function scoreMemory(mem: RecalledMemory): ScoredMemory {
const composite =
mem.score * SCORING_WEIGHTS.relevance +
mem.importance * SCORING_WEIGHTS.importance +
computeRecencyFactor(mem.created_at) * SCORING_WEIGHTS.recency;
return {
content: mem.content,
compositeScore: Math.round(composite * 10000) / 10000,
tokenEstimate: estimateTokens(mem.content),
};
}
async function buildMemoryContext(agentId: string, query: string, memoryBudget = TOKEN_BUDGET.scoredMemories) {
// Pinned memories (always included, outside the scored budget)
const pinned = (await client.recall(agentId, 'active goals lessons critical identity', {
top_k: 10,
min_importance: 0.9,
})).memories;
const pinnedIds = new Set(pinned.map(m => m.id));
const pinnedTokens = pinned.reduce((sum, m) => sum + estimateTokens(m.content), 0);
// Broad candidate recall, minus anything already pinned
const candidates = (await client.recall(agentId, query, { top_k: 50, min_importance: 0.3 }))
.memories.filter(m => !pinnedIds.has(m.id));
// Score and sort
const scored = candidates.map(scoreMemory).sort((a, b) => b.compositeScore - a.compositeScore);
// Budget fill
const selected: ScoredMemory[] = [];
let tokensUsed = 0;
let trimmedCount = 0;
for (const mem of scored) {
if (tokensUsed + mem.tokenEstimate > memoryBudget) { trimmedCount++; continue; }
selected.push(mem);
tokensUsed += mem.tokenEstimate;
}
const pinnedBlock = pinned.map(m => m.content).join('\n');
const scoredBlock = selected.map(m => m.content).join('\n');
const context = [
pinnedBlock ? `[Critical context]\n${pinnedBlock}` : '',
scoredBlock ? `[Relevant context]\n${scoredBlock}` : '',
].filter(Boolean).join('\n\n');
return {
context,
stats: {
pinnedCount: pinned.length,
selectedCount: selected.length,
trimmedCount,
totalTokens: pinnedTokens + tokensUsed,
budgetUtilization: Math.round((tokensUsed / memoryBudget) * 1000) / 1000,
},
};
}
// Usage
const { context, stats } = await buildMemoryContext('doc-analysis-agent', 'key risk factors in contract');
console.log(`Loaded ${stats.selectedCount} memories, ${stats.totalTokens} tokens (${(stats.budgetUtilization * 100).toFixed(0)}% of budget)`);use dakera_client::{DakeraClient, RecallRequest};
use std::time::{SystemTime, UNIX_EPOCH};
let client = DakeraClient::builder("http://localhost:3000").api_key("dk-...").build()?;
const MEMORY_BUDGET_TOKENS: usize = 20_000;
const RECENCY_DECAY_HOURS: f64 = 168.0;
fn estimate_tokens(text: &str) -> usize {
(text.len() / 4).max(1)
}
fn recency(created_at: u64) -> f64 {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let hours_old = now.saturating_sub(created_at) as f64 / 3600.0;
(1.0 - hours_old / RECENCY_DECAY_HOURS).max(0.0)
}
// Broad candidate recall
let candidates = client.recall(
RecallRequest::new("doc-analysis-agent", "document analysis context user preferences")
.with_top_k(50)
.with_min_importance(0.3),
).await?;
// Composite score (application logic): Dakera's score, importance, recency
let mut scored: Vec<(f64, &str, usize)> = candidates.memories.iter()
.map(|mem| {
let score = mem.score as f64 * 0.5
+ mem.importance as f64 * 0.3
+ recency(mem.created_at) * 0.2;
(score, mem.content.as_str(), estimate_tokens(&mem.content))
})
.collect();
scored.sort_by(|a, b| b.0.total_cmp(&a.0));
// Budget fill
let mut context_parts: Vec<&str> = Vec::new();
let mut tokens_used = 0usize;
let mut trimmed = 0usize;
for (_, content, tokens) in &scored {
if tokens_used + tokens > MEMORY_BUDGET_TOKENS {
trimmed += 1;
continue;
}
context_parts.push(content);
tokens_used += tokens;
}
let memory_context = context_parts.join("\n");
println!("Selected {} memories, {} tokens, {} trimmed", context_parts.len(), tokens_used, trimmed);client := dakera.NewClientWithOptions(dakera.ClientOptions{
BaseURL: "http://localhost:3000",
APIKey: "dk-...",
})
ctx := context.Background()
const memoryBudgetTokens = 20_000
const recencyDecayHours = 168.0
func estimateTokens(text string) int {
t := len(text) / 4
if t < 1 {
return 1
}
return t
}
// Broad recall
minImp := float32(0.3)
candidates, err := client.Recall(ctx, "doc-analysis-agent", dakera.RecallRequest{
Query: "document analysis context user preferences",
TopK: 50,
MinImportance: &minImp,
})
if err != nil {
log.Fatal(err)
}
type scoredMem struct {
content string
score float64
tokens int
}
// Composite score (application logic): Dakera's score, importance, recency
scored := make([]scoredMem, len(candidates.Memories))
for i, mem := range candidates.Memories {
hoursOld := time.Since(time.Unix(mem.CreatedAt, 0)).Hours()
recency := math.Max(0, 1-hoursOld/recencyDecayHours)
composite := float64(mem.Score)*0.5 + float64(mem.Importance)*0.3 + recency*0.2
scored[i] = scoredMem{mem.Content, composite, estimateTokens(mem.Content)}
}
sort.Slice(scored, func(i, j int) bool { return scored[i].score > scored[j].score })
// Budget fill
var selected []string
tokensUsed, trimmed := 0, 0
for _, m := range scored {
if tokensUsed+m.tokens > memoryBudgetTokens {
trimmed++
continue
}
selected = append(selected, m.content)
tokensUsed += m.tokens
}
memoryContext := strings.Join(selected, "\n")
fmt.Printf("Selected %d memories, %d tokens, %d trimmed\n", len(selected), tokensUsed, trimmed)Stop paying for context you don't use
Budget-managed memory loading keeps prompt size and cost predictable while improving response quality.
Real-World Scenario: Long Document Analysis Agent
A legal research agent analyzes contracts and regulatory documents. It serves 50 attorneys who each have months of interaction history. Naively loading all memories per request was costing $0.18/call and adding 3.2 seconds of latency. After implementing context window management:
- Memory budget defined: 20k tokens for scored memories, 10k for pinned (active matters, user preferences), 20k for conversation history, 40k reserved for long-form output.
- Scoring weights: Relevance 60%, importance 30%, recency 10%. Legal context is less time-sensitive — earlier case notes remain highly relevant.
- Cost reduction: Average tokens per call dropped from 87k to 41k — a 53% reduction. Monthly API costs fell from $8,200 to $3,900.
- Quality improvement: Attorney satisfaction scores improved from 3.8 to 4.5/5 because irrelevant context from unrelated matters no longer polluted responses.
- Hit rate monitoring: The team tracks what percentage of pinned memories are always included (target: 100%) and scored memory hit rate (target: 70–85%). When hit rate drops below 60%, they increase min_importance.
Before / After Memory State
// All 180 memories injected:
// Total: 87,400 tokens
// Cost per call: $0.18
// TTFT: 4.1 seconds
// Response quality: 3.8/5
// Context is 68% memories:
// LLM can't focus on the
// actual question
// High hallucination rate
// on complex queries
// Budget-managed context:
// Pinned: 8 memories, 9.2k tokens
// Scored: 14 memories, 16.2k tokens
// Total memory: 25.4k tokens
// Cost per call: $0.084 (-53%)
// TTFT: 1.9 seconds (-54%)
// Response quality: 4.5/5
// Budget utilization: 81%
SDK Method Reference
| Method | SDK | Purpose in this pattern |
|---|---|---|
recall(agent_id, query, top_k, min_importance) | Python | Broad candidate recall + pinned memory retrieval |
batch_recall(BatchRecallRequest(agent_id, filter)) | Python | Alternative for pinned memories: list them by tag and min_importance, with no query |
recall(agentId, query, {top_k, min_importance}) | TypeScript | Candidate and pinned recall |
batchRecall({agent_id, filter}) | TypeScript | The same filter-based listing in TypeScript |
client.recall(RecallRequest::new(agent_id, query)).await? | Rust | Async broad candidate recall |
client.Recall(ctx, agentID, dakera.RecallRequest{...}) | Go | Recall with min_importance filter |
Edge Cases and Gotchas
- Recency weight traps: Setting recency weight too high (above 0.4) causes older but highly relevant memories to be trimmed in favor of recent but less relevant ones. For domains with stable knowledge (legal facts, technical specifications), keep recency below 0.2.
- Token budget creep: If your pinned memory set grows unbounded (every goal, every lesson, forever), the pinned allocation will eventually crowd out scored memories. Implement a maximum for pinned memories — cap at 10–12 entries. Archive or compress lower-priority pinned memories using the memory compression pattern.
- Pinned and scored overlap: The pinned and the broad recall can return the same memory, especially if you run them in parallel (for example with
AsyncDakeraClient). Deduplicate by memory ID before building the final context string to avoid doubling token consumption. - Token counting mismatch: The character-to-token ratio varies significantly across languages (Chinese, Japanese, and Korean use approximately 1 character per token, not 4). If your users interact in multiple languages, use a dedicated tokenizer rather than the character approximation to avoid budget overflows.
- High-importance low-relevance memories: A memory with importance=0.95 but near-zero relevance score to the current query will rank highly due to the importance weight. For memories that should only surface in specific contexts (e.g., a medical allergy note for a healthcare agent), add domain tags and pass a
tagsfilter torecallso they only come back when they belong.
It's tempting to skip the scoring step and just inject top_k=10 results directly. This works at small scale but breaks under load: as your memory store grows, Dakera returns increasingly diverse results for the same query. Without scoring and trimming, you lose control over what enters the context window. Always score, always budget — even in development.
Performance Considerations
Advanced Configuration: Dynamic weight adjustment by query type
Different query types benefit from different scoring weights. Implement a query classifier that selects the right weight configuration:
WEIGHT_PROFILES = {
"factual": {"relevance": 0.70, "importance": 0.20, "recency": 0.10},
"temporal": {"relevance": 0.40, "importance": 0.20, "recency": 0.40},
"preference": {"relevance": 0.50, "importance": 0.40, "recency": 0.10},
"emotional": {"relevance": 0.40, "importance": 0.30, "recency": 0.30},
"default": {"relevance": 0.50, "importance": 0.30, "recency": 0.20},
}
def classify_query_type(query: str) -> str:
"""Simple keyword-based query classifier."""
q = query.lower()
if any(w in q for w in ["when", "date", "recently", "latest", "yesterday"]):
return "temporal"
if any(w in q for w in ["prefer", "like", "want", "style", "format"]):
return "preference"
if any(w in q for w in ["feel", "emotion", "frustrated", "happy", "stress"]):
return "emotional"
if any(w in q for w in ["what", "who", "how many", "define", "explain"]):
return "factual"
return "default"
def build_memory_context_dynamic(agent_id: str, query: str, budget: int) -> dict:
query_type = classify_query_type(query)
weights = WEIGHT_PROFILES[query_type]
return build_memory_context_with_weights(agent_id, query, budget, weights)
Run this pattern against a real Dakera.
The Playground executes live API calls in your browser. No account, no install. Then run it on your own server in five minutes.