openai SDK pointing at a local Ollama server. View Claude version → · OS Track Index →
M08: Conversation Management
Master the art of managing multi-turn conversations — from stateless API calls to production-grade context management with tokenThe basic unit of text that language models process. A token is roughly 3–4 characters in English. Both input (what you send) and output (what the model generates) are measured in tokens, and you pay per token. budgets, pruning, and summarization.
Prerequisites: M01–M04 (Foundations track) · Ollama running with Mistral: ollama run mistral
Learning Objectives
- Explain why the OpenAI-compatible API (and all LLM APIs) are stateless and how the illusion of memory is created by replaying message history
- Implement three conversation history management strategies: full history, sliding windowA strategy that keeps only the most recent N messages in the conversation history, discarding older ones. Like a window that slides forward over time, always showing only the latest portion., and summarization
- Calculate token budget allocation across system promptA special instruction message with role "system" sent as the first message in the array. It sets the assistant's behavior, personality, and constraints., history, current message, and reserved response tokensInput tokens are what you send to the model (system message + conversation messages). Output tokens are what the model generates in response. Both count against the context window.
- Apply message pruning strategies that preserve information density while staying within token limits
- Build a production-grade ConversationManager class with automatic pruning, summarization, and serializationConverting an in-memory data structure (like a conversation history object) into a format that can be stored in a file or transmitted over a network — typically JSON. Deserialization is the reverse process.
The Stateless Reality: The Model Has No Memory
BEFORE: Imagine you could hire a world-class expert for advice, but they have perfect amnesia — every time you walk into the room, they have zero memory of any previous conversation you have had with them.
PAIN: If you just said "so what do you think about option B?" they would stare blankly, because they have no idea what option A was, who you are, or what problem you are solving. Every interaction starts from absolute zero.
MAPPING: This is exactly how the OpenAI-compatible API (and Ollama's Mistral) works. Each API call is a fresh room with a fresh expert. The only way to give the model "memory" is to hand it a written transcript of every previous exchange — your code replays the entire conversation from scratch on every single request.
What this actually looks like in code: On your third message to Mistral, the API request does not just send message 3. It sends all previous messages again from scratch:
Notice: the system prompt and all previous messages are resent verbatim. Mistral does not "remember" them — your code replays them every time.
First, each request is completely independent. There is no session ID. There is no server-side conversation state. There is no implicit memory of any kind.
Second, what users perceive as a continuous conversation is actually the client re-sending the entire message history in the messages array with every single request — including the system prompt as {"role": "system", ...}.
In other words, the assistant's "memory" of earlier messages exists only because the developer explicitly includes those messages in the next API call. If you leave a message out, it is gone — the model will never know it happened.
Concrete examples: A customer support agent handling 10,000 concurrent sessions does not need 10,000 server-side session stores — each request is self-contained. A healthcare agent can surgically remove PHI from history before sending it to a logging service. A debugging agent can "rewind" a conversation to turn 5 and try a different approach.
Understanding statelessness is the foundation of everything else in this module.
"The model remembers our previous conversation." — No. Each API call is completely stateless. There is no session, no server-side storage, no memory of any kind. If you do not include previous messages in the messages array, they do not exist as far as the model is concerned.
"Context window = memory." — No. The context window is temporary — it exists only for the duration of a single API request. Memory persists across requests. The context window is more like a whiteboard that gets erased after every meeting.
"Longer context = better results." — Not necessarily. Research shows that recall degrades with context length — a phenomenon called the "lost in the middle" effect. Information buried in the middle of a very long context gets lower attention than information at the start or end.
"Summarization is lossless." — No. Summarization is a lossy compression. It preserves the gist but loses specifics: exact names, account numbers, dates, dollar amounts, and nuanced phrasing. That is why production systems use "pinned facts" blocks that are never summarized.
Conversation History Management Patterns
BEFORE: Imagine packing for a two-week trip, but your airline only allows one carry-on bag with a strict 7 kg weight limit.
PAIN: If you try to bring everything, the bag overflows and the airline rejects it at the gate. But if you only bring what fits right now, you might arrive at a formal dinner with nothing but hiking clothes because you dropped the dress shoes three days ago.
MAPPING: Your context window is that carry-on bag. Full history means cramming everything in (works for short trips). Sliding window means only packing the last few days of clothes (cheap but you lose early essentials). Summarization means writing a packing list of what you left at home plus bringing the current essentials — the best balance for long journeys.
What this actually looks like in practice: Suppose a 20-turn conversation has accumulated 20 messages. Here is how each strategy handles sending turn 21:
- Full History — send all messages every time. Simple but hits token limitsThe maximum number of tokens a model can process in a single request. Ollama/Mistral context window defaults to 32K but varies by model and configuration. quickly. Best for short conversations (< 20 turns).
- Sliding Window — keep only the last N messages. Maintains recency but loses early context.
- Summarization — periodically compress older messages into a summary, prepend it to the history, and drop the originals. Preserves key information while staying within token budgets.
Summarization: The Strategy That Deserves a Closer Look
Summarization is the most powerful of the three strategies. When your conversation manager detects that token usage has crossed a threshold (say 10,000 tokens for a local Mistral setup), it splits the message history into "old" messages and "recent" messages (the last 4 turns, kept verbatim). It sends the old messages to the model with a special instruction: "Summarize this conversation. Preserve key decisions, user preferences, important facts. Skip greetings and filler." The model returns a compact summary, which gets injected as a synthetic user/assistant message pair, and the original old messages are discarded.
If you are thinking "this sounds a lot like a sliding window, just smarter" — you are right, and that is the key difference. A sliding window throws away old messages and loses their information forever. Summarization compresses them instead, preserving the meaning while discarding the verbatim text. The trade-off is that summarization costs an extra API call (to the local Ollama server), and some specific details may be paraphrased away. That is why production systems often combine summarization with "pinned facts" that are never summarized.
Token Budget Allocation
BEFORE: Imagine you have a fixed-size suitcase for an international trip — exactly 32,000 units of space, not one more — and everything you need must fit inside it.
PAIN: You must pack a travel guide (system prompt), a photo album of memories (conversation history), the souvenirs you are buying today (current user message), and leave empty space to bring things home (response tokens). If you overstuff the album, there is no room left for today's souvenirs or the space to bring anything home.
MAPPING: Your context windowThe total number of tokens a model can process in one request. Ollama's Mistral defaults to 32K context but can be configured with --num-ctx. The four consumers (system prompt, history, current message, response) compete for the same fixed space. is that suitcase. The four consumers — system prompt, history, current message, and reserved response tokens — compete for the same fixed space.
What this looks like in your code: Here is a typical budget breakdown for a Mistral agent (32K context) at turn 25:
- System message — typically 200–800 tokens (fixed, sent first in the messages array)
- Conversation history — variable, grows per turn
- Current user message — variable
- Response space — what remains after input. With Ollama you don't set
max_tokensas a budget reservation — the model uses whatever context remains after your input. To stay safe, count your input tokens and ensure they leave headroom for the response.
Formula: available_for_response = context_window - system_tokens - history_tokens - current_message_tokens. Monitor this before each API call and prune history when the ratio drops below a comfortable threshold (e.g., less than 4,000 tokens remaining).
One subtlety that surprises beginners: the system message occupies budget space on every single request, not just the first one. A 500-token system prompt quietly consumes 500 × 50 = 25,000 tokens of cumulative input by turn 50. This is why production teams obsess over concise system prompts.
ollama run mistral --num-ctx 65536
"max_tokens limits what the model reads." — No. When set, max_tokens (or max_completion_tokens) only limits how much the model writes (output tokens). It does not affect input processing.
"Token counts are exact and predictable." — Not quite. Token counts depend on the model's tokenizer. Use the API response's usage field for exact counts.
"Local models have unlimited context." — No. Local models have configurable context windows, but larger windows require significantly more GPU/CPU memory. A 32K context window on Mistral 7B requires roughly 8 GB of RAM beyond the base model weight.
The "lost in the middle" effect means information in the middle of long context gets lower recall than information at the start or end. Position critical data at the beginning (case facts) or end (current query).
Practical Context Window Management
Four moving pieces work together: counting tokens before you send, allocating by percentage, handling overflow, and dynamically sizing the sliding window.
1. Count tokens before the request. For Ollama/OpenAI SDK, approximate token count with a rough estimate (4 chars ≈ 1 token) or use a local tokenizer:
2. Slide on tokens, not message count. Walk newest-to-oldest and accumulate until you hit your history budget. Short chitchat keeps many turns, large responses keep fewer:
Message Pruning Strategies
BEFORE: Imagine you are a film editor with 6 hours of raw footage that must become a 90-minute movie — you cannot simply cut from the end or beginning, because key plot points are scattered throughout.
PAIN: If you blindly cut the first 4.5 hours, you lose the character introductions. If you keep everything, the audience loses focus during the filler scenes.
MAPPING: Pruning a conversation works the same way. You score each message by importance: greetings and filler are cut first. Key facts (account numbers, decisions, tool results) are preserved no matter how old. And sometimes you replace a block of scenes with a narrator's summary.
- FIFOFirst In, First Out — drop the oldest messages first. Simple but may lose critical early context. (first in, first out) — drop oldest messages first.
- Importance scoring — tag messages with metadataAdditional data attached to each message beyond its content — such as timestamps, token counts, importance scores, or tags. and drop low-importance messages first.
- Semantic deduplicationIdentifying and removing messages that convey the same meaning, even if worded differently. — remove redundant exchanges.
- Role-based retention — always keep certain message types (tool results, user preferences, key decisions).
- Summarize-and-replace — condense a block of messages into a single summary before dropping originals.
Critical implementation detail: after removing any messages, verify that the remaining array still has valid message alternationThe chat completions API requires messages to alternate between user and assistant roles, and the user role message must follow the system message. Removing a message can break this alternation, causing an API error.. The API requires messages to alternate between user and assistant roles, and the first non-system message must be from the user.
Building a Production Conversation Manager
BEFORE: Imagine a CEO who takes every meeting without a personal assistant — carrying a growing stack of every note from every past meeting, verbatim, unfiltered.
PAIN: By month three, the stack is so tall they spend more time flipping through old notes than actually engaging. Critical decisions from week one are buried under pages of "sounds good" and "let's circle back."
MAPPING: A conversation manager is that personal assistant. It sits between your application and the Ollama API, recording every exchange, scoring messages by importance, compressing old meetings into executive summaries, and ensuring the model walks into every meeting with exactly the right briefing.
Progressive summarization loses critical specifics: names, IDs, amounts, dates. For production systems, use immutable "case facts" blocks positioned at the START of context (high-recall position). These are never summarized.
Code Walkthrough
Step 1: Basic Conversation Manager
Let's start with the simplest version: a class that stores messages, prepends the system prompt on each send, calls Mistral via Ollama, and keeps a running count of token usage. This is the "full history" strategy. With the OpenAI SDK, the system prompt is passed as the first element of the messages list rather than a top-level kwarg. Token usage is available in response.usage.prompt_tokens and response.usage.completion_tokens.
from openai import OpenAI
# pip install openai
from dataclasses import dataclass, field
from typing import Optional
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
@dataclass
class ConversationManager:
"""Manages multi-turn conversations with Mistral via Ollama."""
system_prompt: str = "You are a helpful assistant."
model: str = "mistral"
messages: list = field(default_factory=list)
def __post_init__(self):
self.total_input_tokens = 0
self.total_output_tokens = 0
def add_user_message(self, content: str) -> None:
"""Add a user message to the conversation history."""
self.messages.append({"role": "user", "content": content})
def add_assistant_message(self, content: str) -> None:
"""Add an assistant message to the conversation history."""
self.messages.append({"role": "assistant", "content": content})
def get_messages(self) -> list:
"""Return messages with system prompt prepended."""
return [{"role": "system", "content": self.system_prompt}] + list(self.messages)
def send(self, user_message: str) -> str:
"""Send a message and get the model's response."""
self.add_user_message(user_message)
try:
response = client.chat.completions.create(
model=self.model,
messages=self.get_messages(),
)
# Track token usage
if response.usage:
self.total_input_tokens += response.usage.prompt_tokens
self.total_output_tokens += response.usage.completion_tokens
assistant_text = response.choices[0].message.content
self.add_assistant_message(assistant_text)
return assistant_text
except Exception as e:
# Remove the user message we just added on failure
self.messages.pop()
raise RuntimeError(f"API call failed: {e}") from e
def get_token_usage(self) -> dict:
"""Return cumulative token usage."""
return {
"total_input_tokens": self.total_input_tokens,
"total_output_tokens": self.total_output_tokens,
"message_count": len(self.messages),
}
# Usage
manager = ConversationManager(
system_prompt="You are a coding tutor. Be concise."
)
reply1 = manager.send("What is a list comprehension in Python?")
print(reply1)
reply2 = manager.send("Show me an example with filtering.")
print(reply2)
print(manager.get_token_usage())
import OpenAI from 'openai';
// npm install openai
const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
class ConversationManager {
constructor({
systemPrompt = "You are a helpful assistant.",
model = "mistral",
} = {}) {
this.systemPrompt = systemPrompt;
this.model = model;
this.messages = [];
this.totalInputTokens = 0;
this.totalOutputTokens = 0;
}
addUserMessage(content) {
this.messages.push({ role: "user", content });
}
addAssistantMessage(content) {
this.messages.push({ role: "assistant", content });
}
getMessages() {
return [{ role: "system", content: this.systemPrompt }, ...this.messages];
}
async send(userMessage) {
this.addUserMessage(userMessage);
try {
const response = await client.chat.completions.create({
model: this.model,
messages: this.getMessages(),
});
if (response.usage) {
this.totalInputTokens += response.usage.prompt_tokens;
this.totalOutputTokens += response.usage.completion_tokens;
}
const assistantText = response.choices[0].message.content;
this.addAssistantMessage(assistantText);
return assistantText;
} catch (error) {
this.messages.pop();
throw new Error(`API call failed: ${error.message}`);
}
}
getTokenUsage() {
return {
totalInputTokens: this.totalInputTokens,
totalOutputTokens: this.totalOutputTokens,
messageCount: this.messages.length,
};
}
}
// Usage
const manager = new ConversationManager({
systemPrompt: "You are a coding tutor. Be concise.",
});
const reply1 = await manager.send(
"What is a list comprehension in Python?"
);
console.log(reply1);
const reply2 = await manager.send(
"Show me an example with filtering."
);
console.log(reply2);
console.log(manager.getTokenUsage());
ConversationManager for Ollama/Mistral. The key difference from the model version: the system prompt lives in getMessages() as {"role": "system", ...} prepended to the messages array, rather than a top-level kwarg. Token usage comes from response.usage.prompt_tokens and response.usage.completion_tokens. Safety detail: if the API call fails, the manager pops the user message it just added, so your history stays clean.
Step 2: Sliding Window Mode
Now extend the manager with a sliding window. Instead of sending all messages, override get_messages() to return only the last N. The key subtlety: if the slice starts on an assistant message, drop it to maintain the required user-first alternation. The system prompt is always included regardless.
class SlidingWindowManager(ConversationManager):
"""Extends ConversationManager with a sliding window."""
def __init__(self, window_size: int = 10, **kwargs):
super().__init__(**kwargs)
self.window_size = window_size
def get_messages(self) -> list:
"""Return system prompt + only the most recent N messages."""
if len(self.messages) <= self.window_size:
windowed = list(self.messages)
else:
windowed = self.messages[-self.window_size:]
# Ensure we start with a user message (valid alternation)
if windowed and windowed[0]["role"] == "assistant":
windowed = windowed[1:]
return [{"role": "system", "content": self.system_prompt}] + windowed
# Usage — only last 6 messages sent per call
manager = SlidingWindowManager(
window_size=6,
system_prompt="You are a concise coding tutor.",
)
for q in [
"What is Python?",
"What are variables?",
"Explain loops.",
"What are functions?",
"Explain classes.",
"What is inheritance?",
]:
print(f"Q: {q}")
print(f"A: {manager.send(q)}\n")
# All 12 messages stored, but only last 6 sent to API
print(f"Stored: {len(manager.messages)} messages")
print(f"Sent (excl. system): {len(manager.get_messages()) - 1} messages")
class SlidingWindowManager extends ConversationManager {
constructor({ windowSize = 10, ...opts } = {}) {
super(opts);
this.windowSize = windowSize;
}
getMessages() {
let windowed = this.messages.length <= this.windowSize
? [...this.messages]
: this.messages.slice(-this.windowSize);
// Ensure we start with a user message
if (windowed[0]?.role === "assistant") {
windowed = windowed.slice(1);
}
return [{ role: "system", content: this.systemPrompt }, ...windowed];
}
}
// Usage — only last 6 messages sent per call
const manager = new SlidingWindowManager({
windowSize: 6,
systemPrompt: "You are a concise coding tutor.",
});
for (const q of [
"What is Python?",
"What are variables?",
"Explain loops.",
"What are functions?",
"Explain classes.",
"What is inheritance?",
]) {
console.log(`Q: ${q}`);
console.log(`A: ${await manager.send(q)}\n`);
}
console.log(`Stored: ${manager.messages.length} messages`);
console.log(`Sent (excl. system): ${manager.getMessages().length - 1} messages`);
get_messages(). The system prompt is always included as the first message. Trade-off: cheap and fast, but loses early context permanently.
Step 3: Auto-Summarization & Persistence
The production-grade version. Instead of just dropping old messages, we ask Mistral to summarize them first. The result is a much shorter history that still carries essential context. The save() and load() methods serialize full state to JSON. Note: the summarization call also goes through Ollama — no external API needed.
import json
import time
from pathlib import Path
class SmartConversationManager(ConversationManager):
"""Full-featured manager with summarization and persistence."""
def __init__(
self,
token_threshold: int = 8_000, # Lower for Mistral's 32K context
recent_turns_to_keep: int = 4,
**kwargs
):
super().__init__(**kwargs)
self.token_threshold = token_threshold
self.recent_turns_to_keep = recent_turns_to_keep
self.summary: Optional[str] = None
self.summary_history: list = []
self.last_input_tokens = 0
def _should_summarize(self) -> bool:
return self.last_input_tokens > self.token_threshold
def _summarize_old_messages(self) -> None:
"""Use Mistral to summarize older messages."""
keep_count = self.recent_turns_to_keep * 2 # user+assistant pairs
if len(self.messages) <= keep_count:
return
old_messages = self.messages[:-keep_count]
recent_messages = self.messages[-keep_count:]
summary_prompt = (
"Summarize this conversation concisely. "
"Preserve: key decisions, user preferences, "
"important facts. Skip: greetings, filler.\n\n"
)
for msg in old_messages:
summary_prompt += f"{msg['role']}: {msg['content']}\n"
try:
response = client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a conversation summarizer. Be concise."},
{"role": "user", "content": summary_prompt}
],
)
new_summary = response.choices[0].message.content
if self.summary:
new_summary = f"Previous context: {self.summary}\n\nRecent: {new_summary}"
self.summary = new_summary
self.summary_history.append({
"timestamp": time.time(),
"messages_summarized": len(old_messages),
})
# Replace old messages with a summary message pair
self.messages = [
{"role": "user", "content": f"[Conversation summary: {self.summary}]"},
{"role": "assistant", "content": "Understood. I have the conversation context."},
*recent_messages,
]
except Exception:
# If summarization fails, fall back to sliding window
self.messages = recent_messages
def send(self, user_message: str) -> str:
"""Send with automatic summarization when needed."""
self.add_user_message(user_message)
try:
msgs_to_send = [{"role": "system", "content": self.system_prompt}] + self.messages
response = client.chat.completions.create(
model=self.model,
messages=msgs_to_send,
)
if response.usage:
self.last_input_tokens = response.usage.prompt_tokens
self.total_input_tokens += response.usage.prompt_tokens
self.total_output_tokens += response.usage.completion_tokens
assistant_text = response.choices[0].message.content
self.add_assistant_message(assistant_text)
# Check if we need to summarize
if self._should_summarize():
self._summarize_old_messages()
return assistant_text
except Exception as e:
self.messages.pop()
raise RuntimeError(f"API call failed: {e}") from e
def save(self, filepath: str) -> None:
"""Persist conversation state to a JSON file."""
state = {
"system_prompt": self.system_prompt,
"model": self.model,
"messages": self.messages,
"summary": self.summary,
"summary_history": self.summary_history,
"total_input_tokens": self.total_input_tokens,
"total_output_tokens": self.total_output_tokens,
"saved_at": time.time(),
}
Path(filepath).write_text(json.dumps(state, indent=2))
@classmethod
def load(cls, filepath: str) -> "SmartConversationManager":
"""Load conversation state from a JSON file."""
data = json.loads(Path(filepath).read_text())
mgr = cls(
system_prompt=data["system_prompt"],
model=data["model"],
)
mgr.messages = data["messages"]
mgr.summary = data.get("summary")
mgr.summary_history = data.get("summary_history", [])
mgr.total_input_tokens = data.get("total_input_tokens", 0)
mgr.total_output_tokens = data.get("total_output_tokens", 0)
return mgr
# Usage
manager = SmartConversationManager(
token_threshold=3_000, # Low threshold for testing
recent_turns_to_keep=4,
system_prompt="You are a helpful coding assistant.",
)
reply = manager.send("Help me build a REST API with FastAPI.")
print(reply)
# Save and restore across sessions
manager.save("conversation_state.json")
restored = SmartConversationManager.load("conversation_state.json")
reply2 = restored.send("Where were we?")
print(reply2)
import OpenAI from 'openai';
import { readFileSync, writeFileSync } from "node:fs";
const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
class SmartConversationManager extends ConversationManager {
constructor({
tokenThreshold = 8_000,
recentTurnsToKeep = 4,
...opts
} = {}) {
super(opts);
this.tokenThreshold = tokenThreshold;
this.recentTurnsToKeep = recentTurnsToKeep;
this.summary = null;
this.summaryHistory = [];
this.lastInputTokens = 0;
}
_shouldSummarize() {
return this.lastInputTokens > this.tokenThreshold;
}
async _summarizeOldMessages() {
const keepCount = this.recentTurnsToKeep * 2;
if (this.messages.length <= keepCount) return;
const oldMessages = this.messages.slice(0, -keepCount);
const recentMessages = this.messages.slice(-keepCount);
let summaryPrompt =
"Summarize this conversation concisely. " +
"Preserve: key decisions, user preferences, important facts. " +
"Skip: greetings, filler.\n\n";
for (const msg of oldMessages) {
summaryPrompt += `${msg.role}: ${msg.content}\n`;
}
try {
const response = await client.chat.completions.create({
model: this.model,
messages: [
{ role: "system", content: "You are a conversation summarizer. Be concise." },
{ role: "user", content: summaryPrompt }
],
});
let newSummary = response.choices[0].message.content;
if (this.summary) {
newSummary = `Previous context: ${this.summary}\n\nRecent: ${newSummary}`;
}
this.summary = newSummary;
this.summaryHistory.push({
timestamp: Date.now(),
messagesSummarized: oldMessages.length,
});
this.messages = [
{ role: "user", content: `[Conversation summary: ${this.summary}]` },
{ role: "assistant", content: "Understood. I have the conversation context." },
...recentMessages,
];
} catch {
this.messages = recentMessages;
}
}
async send(userMessage) {
this.addUserMessage(userMessage);
try {
const response = await client.chat.completions.create({
model: this.model,
messages: [{ role: 'system', content: this.systemPrompt }, ...this.messages],
});
if (response.usage) {
this.lastInputTokens = response.usage.prompt_tokens;
this.totalInputTokens += response.usage.prompt_tokens;
this.totalOutputTokens += response.usage.completion_tokens;
}
const assistantText = response.choices[0].message.content;
this.addAssistantMessage(assistantText);
if (this._shouldSummarize()) {
await this._summarizeOldMessages();
}
return assistantText;
} catch (error) {
this.messages.pop();
throw new Error(`API call failed: ${error.message}`);
}
}
save(filepath) {
const state = {
systemPrompt: this.systemPrompt,
model: this.model,
messages: this.messages,
summary: this.summary,
summaryHistory: this.summaryHistory,
totalInputTokens: this.totalInputTokens,
totalOutputTokens: this.totalOutputTokens,
savedAt: Date.now(),
};
writeFileSync(filepath, JSON.stringify(state, null, 2));
}
static load(filepath) {
const data = JSON.parse(readFileSync(filepath, "utf-8"));
const mgr = new SmartConversationManager({
systemPrompt: data.systemPrompt,
model: data.model,
});
mgr.messages = data.messages;
mgr.summary = data.summary ?? null;
mgr.summaryHistory = data.summaryHistory ?? [];
mgr.totalInputTokens = data.totalInputTokens ?? 0;
mgr.totalOutputTokens = data.totalOutputTokens ?? 0;
return mgr;
}
}
// Usage
const manager = new SmartConversationManager({
tokenThreshold: 3_000,
recentTurnsToKeep: 4,
systemPrompt: "You are a helpful coding assistant.",
});
const reply = await manager.send("Help me build a REST API.");
console.log(reply);
manager.save("conversation_state.json");
const restored = SmartConversationManager.load("conversation_state.json");
const reply2 = await restored.send("Where were we?");
console.log(reply2);
SmartConversationManager for Ollama/Mistral. The manager watches last_input_tokens after every API response. When tokens cross your threshold, it calls Mistral again (locally, no extra cost) to compress old messages into a summary, then replaces those messages with a summary/acknowledgment pair. The summarization prompt passes the system message inline. save() and load() serialize full state to JSON. Graceful fallback: if summarization fails, it slides to recent messages instead of crashing.
token_threshold comfortably below your model's context limit to leave room for the summarization prompt itself.
Hands-On Exercise
What You'll Build
A conversation manager that progresses through three strategies: full history, sliding window, and auto-summarization with JSON persistence. You'll see the token usage difference between each approach in real time against your local Ollama instance.
Time Estimate: 30–45 minutes
Prerequisites: Python 3.10+ (or Node.js 18+), Ollama installed and running (ollama run mistral)
Files You'll Create: conversation_manager.py
Environment Setup
mkdir conversation-lab && cd conversation-lab
python -m venv venv && source venv/bin/activate # Windows: venv\Scripts\activate
pip install openai
# Make sure Ollama is running: ollama serve && ollama run mistral
Step 1: Basic ConversationManager with Token Tracking
What: Create a ConversationManager class that stores messages, prepends the system prompt as a role:"system" message, sends them to Mistral via Ollama, and tracks token usage.
Why: This implements the "full history" strategy. You will see input tokens grow with each turn.
Create a new file called conversation_manager.py and add the following:
from openai import OpenAI
import json
import time
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional
# pip install openai
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
# ── Step 1: Basic ConversationManager ────────────────────────
@dataclass
class ConversationManager:
"""Full-history conversation manager with token tracking."""
system_prompt: str = "You are a helpful assistant."
model: str = "mistral"
messages: list = field(default_factory=list)
def __post_init__(self):
self.total_input_tokens = 0
self.total_output_tokens = 0
def add_user_message(self, content: str) -> None:
self.messages.append({"role": "user", "content": content})
def add_assistant_message(self, content: str) -> None:
self.messages.append({"role": "assistant", "content": content})
def get_messages(self) -> list:
return [{"role": "system", "content": self.system_prompt}] + list(self.messages)
def send(self, user_message: str) -> str:
self.add_user_message(user_message)
try:
response = client.chat.completions.create(
model=self.model,
messages=self.get_messages(),
)
if response.usage:
self.total_input_tokens += response.usage.prompt_tokens
self.total_output_tokens += response.usage.completion_tokens
assistant_text = response.choices[0].message.content
self.add_assistant_message(assistant_text)
return assistant_text
except Exception as e:
self.messages.pop() # remove failed user message
raise RuntimeError(f"API call failed: {e}") from e
def get_token_usage(self) -> dict:
return {
"total_input": self.total_input_tokens,
"total_output": self.total_output_tokens,
"messages": len(self.messages),
}
# ── Test Step 1 ──────────────────────────────────────────────
if __name__ == "__main__":
print("═" * 50)
print("TEST 1: Basic ConversationManager (Full History)")
print("═" * 50)
mgr = ConversationManager(system_prompt="You are a concise coding tutor. Answer in 1-2 sentences.")
questions = [
"What is a list in Python?",
"How do I add an item to it?",
"What about removing items?",
]
for i, q in enumerate(questions, 1):
print(f"\n Turn {i}: {q}")
reply = mgr.send(q)
print(f" Mistral: {reply[:100]}...")
usage = mgr.get_token_usage()
print(f" [Messages: {usage['messages']} | Input tokens so far: {usage['total_input']}]")
print(f"\n ✓ Final: {usage['messages']} messages, {usage['total_input']} total input tokens")
print(f" Note: Input tokens grow each turn because ALL messages are resent!")
Run it:
Watch the input token count grow with each turn. This is the stateless reality in action — and exactly why we need strategies in the next steps.
- Connection refused → Make sure Ollama is running:
ollama serveandollama run mistral ModuleNotFoundError: No module named 'openai'→ Runpip install openai- Token usage shows as 0 → Some Ollama builds may not populate
response.usage. Update Ollama to the latest version.
Step 2: Add Sliding Window & Smart Summarization
What: Add two more manager classes: a SlidingWindowManager and a SmartConversationManager that auto-summarizes old turns and persists state to JSON.
Add the following to the bottom of conversation_manager.py, replacing the if __name__ == "__main__" block:
# ── Step 2a: Sliding Window Manager ──────────────────────────
class SlidingWindowManager(ConversationManager):
"""Sends only the last N messages to the API."""
def __init__(self, window_size: int = 6, **kwargs):
super().__init__(**kwargs)
self.window_size = window_size
def get_messages(self) -> list:
if len(self.messages) <= self.window_size:
windowed = list(self.messages)
else:
windowed = self.messages[-self.window_size:]
# Ensure we start with a user message (valid alternation)
if windowed and windowed[0]["role"] == "assistant":
windowed = windowed[1:]
return [{"role": "system", "content": self.system_prompt}] + windowed
# ── Step 2b: Smart Manager with Summarization + Persistence ──
class SmartConversationManager(ConversationManager):
"""Auto-summarizes old turns and saves/loads state."""
def __init__(self, token_threshold: int = 3_000, recent_turns: int = 4, **kwargs):
super().__init__(**kwargs)
self.token_threshold = token_threshold
self.recent_turns = recent_turns
self.summary: Optional[str] = None
self.last_input_tokens = 0
def send(self, user_message: str) -> str:
self.add_user_message(user_message)
try:
msgs_to_send = [{"role": "system", "content": self.system_prompt}] + self.messages
response = client.chat.completions.create(
model=self.model,
messages=msgs_to_send,
)
if response.usage:
self.last_input_tokens = response.usage.prompt_tokens
self.total_input_tokens += response.usage.prompt_tokens
self.total_output_tokens += response.usage.completion_tokens
assistant_text = response.choices[0].message.content
self.add_assistant_message(assistant_text)
# Auto-summarize if threshold exceeded
if self.last_input_tokens > self.token_threshold:
self._summarize()
return assistant_text
except Exception as e:
self.messages.pop()
raise RuntimeError(f"API call failed: {e}") from e
def _summarize(self) -> None:
keep_count = self.recent_turns * 2 # user+assistant pairs
if len(self.messages) <= keep_count:
return
old_msgs = self.messages[:-keep_count]
recent_msgs = self.messages[-keep_count:]
prompt = "Summarize this conversation concisely. Preserve: key decisions, facts. Skip: greetings, filler.\n\n"
for m in old_msgs:
prompt += f"{m['role']}: {m['content']}\n"
try:
resp = client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": "You are a conversation summarizer."},
{"role": "user", "content": prompt}
],
)
summary_text = resp.choices[0].message.content
if self.summary:
summary_text = f"Previous: {self.summary}\n\nRecent: {summary_text}"
self.summary = summary_text
self.messages = [
{"role": "user", "content": f"[Context summary: {self.summary}]"},
{"role": "assistant", "content": "Understood. I have the context."},
*recent_msgs,
]
print(f" ⚡ Summarized {len(old_msgs)} old messages → {len(self.messages)} total")
except Exception:
self.messages = recent_msgs # fallback
def save(self, filepath: str) -> None:
state = {
"system_prompt": self.system_prompt,
"model": self.model,
"messages": self.messages,
"summary": self.summary,
"total_input_tokens": self.total_input_tokens,
"total_output_tokens": self.total_output_tokens,
}
Path(filepath).write_text(json.dumps(state, indent=2))
print(f" 💾 Saved to {filepath}")
@classmethod
def load(cls, filepath: str) -> "SmartConversationManager":
data = json.loads(Path(filepath).read_text())
mgr = cls(system_prompt=data["system_prompt"], model=data["model"])
mgr.messages = data["messages"]
mgr.summary = data.get("summary")
mgr.total_input_tokens = data.get("total_input_tokens", 0)
mgr.total_output_tokens = data.get("total_output_tokens", 0)
return mgr
# ── Test All Three Strategies ────────────────────────────────
if __name__ == "__main__":
questions = [
"What is a Python list?",
"How do I sort a list?",
"What about list comprehensions?",
"How do dictionaries differ from lists?",
"What are sets?",
"When should I use tuples?",
]
# --- Test 1: Full History ---
print("═" * 55)
print("TEST 1: Full History (all messages sent every time)")
print("═" * 55)
mgr1 = ConversationManager(system_prompt="Answer in 1 sentence.")
for i, q in enumerate(questions, 1):
mgr1.send(q)
u = mgr1.get_token_usage()
print(f" Turn {i}: stored={u['messages']} sent={u['messages']+1} input_tokens={u['total_input']}")
# --- Test 2: Sliding Window ---
print(f"\n{'═' * 55}")
print("TEST 2: Sliding Window (last 4 messages only)")
print("═" * 55)
mgr2 = SlidingWindowManager(window_size=4, system_prompt="Answer in 1 sentence.")
for i, q in enumerate(questions, 1):
mgr2.send(q)
u = mgr2.get_token_usage()
sent = len(mgr2.get_messages()) - 1 # exclude system
print(f" Turn {i}: stored={u['messages']} sent={sent} input_tokens={u['total_input']}")
# --- Test 3: Smart Manager + Persistence ---
print(f"\n{'═' * 55}")
print("TEST 3: Smart Manager (auto-summarization + save/load)")
print("═" * 55)
mgr3 = SmartConversationManager(
token_threshold=200, # very low to force summarization in test
recent_turns=2,
system_prompt="Answer in 1 sentence.",
)
for i, q in enumerate(questions, 1):
mgr3.send(q)
u = mgr3.get_token_usage()
print(f" Turn {i}: messages={u['messages']} input_tokens={u['total_input']}")
mgr3.save("test_session.json")
restored = SmartConversationManager.load("test_session.json")
reply = restored.send("What did we discuss?")
print(f"\n Restored reply: {reply[:120]}...")
# --- Comparison ---
print(f"\n{'═' * 55}")
print("COMPARISON (total input tokens after 6 turns):")
print(f" Full History: {mgr1.total_input_tokens:,} tokens")
print(f" Sliding Window: {mgr2.total_input_tokens:,} tokens")
print(f" Smart Manager: {mgr3.total_input_tokens:,} tokens")
print("═" * 55)
Run the full test suite:
Look for these key behaviors:
- Test 1: Input tokens should grow significantly each turn (full history resends everything)
- Test 2: The
sentcolumn should cap at 4 after turn 2 - Test 3: You should see
⚡ Summarizedmessages and the restored manager should know what was discussed
- Summarization never triggers → Lower
token_thresholdfurther, or check that Ollama is returningusagedata (update Ollama if not) FileNotFoundErroron load → Make suresave()ran successfully first- Slow responses → Local inference takes time. For testing, keep prompts short. Mistral 7B typically runs 10-50 tokens/sec depending on hardware.
Verify Everything Works
Run the complete file end-to-end. All 3 tests should complete and the COMPARISON table should appear.
You've built three conversation management strategies and measured their real token usage side-by-side using a fully local, offline Ollama/Mistral stack. The SmartConversationManager pattern works identically with any OpenAI-compatible endpoint.
- Importance-based pruning: Add an
importancefield to each message and drop lowest-scored messages first when pruning - Pinned facts: Add a
pinned_factslist that is always prepended to the context and never summarized - Token estimation: Compare rough char-based estimates vs. calling Ollama's tokenizer endpoint for more accurate counts
Knowledge Check
1. What happens if the client sends an API request to the model without including previous messages?
2. With a 32K context window: system message = 500 tokens, history = 20,000 tokens, current message = 300 tokens. How many tokens are available for the response?
3. A customer support bot needs to remember the user's account number from message 1, but conversations can go 50+ turns. Which strategy is best?
4. When summarizing a 10-turn technical conversation about debugging a database query, which information is MOST likely to be lost?
5. Which of these message arrays would cause an API error when sent to the OpenAI-compatible endpoint?
[{role:"system",...}, {role:"user",...}, {role:"assistant",...}, {role:"user",...}]
[{role:"assistant",...}, {role:"user",...}, {role:"assistant",...}]
[{role:"system",...}, {role:"user",...}] (single user message)
[{role:"user",...}] (single message, no system)
Your Score
Module Summary
Key Concepts Recap
- Statelessness — The API has no memory. You replay the full conversation every call. System prompt goes as
{"role": "system", ...}in the messages array. - Full History — Send everything. Simple, but hits limits fast. Good for <20 turns.
- Sliding Window — Keep last N messages. Cheap but loses early context.
- Summarization — Compress old turns into a summary. Best balance of cost and context. Works with local Ollama inference.
- Token Budget — System + History + Message must fit in the context window. Monitor and prune proactively.
- Pruning — FIFO, importance scoring, semantic dedup, and summarize-and-replace. Always maintain valid message alternation.
- ConversationManager — A production class that encapsulates storage, counting, pruning, summarization, and persistence. Works identically with any OpenAI-compatible endpoint.
What We Built
A full ConversationManager class (Python & Node.js) using the OpenAI SDK pointed at Ollama/Mistral that handles multi-turn conversations with automatic token tracking, sliding window mode, local-model-powered summarization, and JSON serialization for cross-session persistence.
Next Module Preview
M09: RAG — Retrieval-Augmented Generation takes conversation management further by connecting your agent to external knowledge bases. Instead of relying solely on conversation history, RAG lets your agent search documents, databases, and APIs to ground its responses in real data.