Quick Takeaways
What you'll learn in this article
- 1
category: one of "preferences", "projects", "contacts", "skills", "goals"
- 2
confidence: 0.0-1.0 how confident you are this is a real fact
- 3
Be concise and technical. Skip pleasantries.
- 4
Use tools proactively when they would help answer the question.
- 5
Always cite sources when using knowledge base information.
Keep reading for detailed implementation, code examples, and real-world results
Why I Built My Own AI Assistant
After spending two decades building software systems and the last several years deep in the AI infrastructure trenches, I reached a breaking point with off-the-shelf AI assistants in late 2024. ChatGPT was impressive but generic. Copilot was useful but limited to code completion. Every commercial assistant I tried felt like wearing someone else's glasses -- close enough to be tantalizing, but never quite right for how I actually work.
So I did what any stubborn engineer would do: I built my own.
What started as a weekend experiment with the OpenAI API turned into a production system that now handles my email triage, researches topics for articles, manages my knowledge base, schedules tasks based on context, and even drafts initial code reviews for pull requests. It knows my preferences, understands my projects, and improves with every interaction. The total monthly cost sits around $45 in API calls -- less than most SaaS subscriptions I have canceled.
This guide distills everything I learned building that system. Not the theoretical hand-waving you find in most AI tutorials, but the actual architecture decisions, code patterns, failure modes, and production realities of building a personal AI assistant that works reliably day after day. We will cover LLM API integration, RAG architecture, vector databases, agent frameworks, memory systems, tool use, deployment, and monitoring -- everything you need to go from concept to a production system you actually rely on.
Projected growth by 2027
Personal AI Assistant Market
The Architecture That Actually Works
Before writing a single line of code, you need an architecture that accounts for the messy reality of AI systems. I have seen dozens of developers jump straight into LangChain tutorials and end up with spaghetti code they cannot debug or extend. The architecture I settled on after multiple rewrites follows a layered approach that separates concerns cleanly.
User Interface (CLI / Web / API)
|
Conversation Manager
|
Agent Orchestrator
/ | \
LLM Tools Memory
Layer Layer Layer
| | |
OpenAI Custom Vector DB
Anthropic APIs + Redis
Local Web + SQLite
File
The key insight is that your AI assistant is not a monolithic application -- it is an orchestration layer that coordinates between language models, tools, and memory systems. Each layer can be swapped independently. When I migrated from GPT-4 to Claude 3.5 Sonnet for certain tasks, only the LLM layer changed. When I added Notion integration, only the tools layer expanded. When I switched from Chroma to Pinecone for my vector store, the memory layer swapped without touching anything else.
AI Assistant Architecture Tradeoffs
Monolithic Architecture
Layered Architecture
Step 1: LLM API Integration -- The Foundation
Everything starts with reliable LLM communication. You need an abstraction layer that handles multiple providers, manages rate limits, implements retry logic, and normalizes responses. Here is the foundation I use:
import os
import time
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional, Generator
from openai import OpenAI
from anthropic import Anthropic
logger = logging.getLogger(__name__)
@dataclass
class Message:
role: str # "system", "user", "assistant", "tool"
content: str
tool_call_id: Optional[str] = None
tool_calls: Optional[list] = None
metadata: dict = field(default_factory=dict)
@dataclass
class LLMResponse:
content: str
model: str
usage: dict
tool_calls: Optional[list] = None
finish_reason: str = "stop"
latency_ms: float = 0.0
class LLMProvider(ABC):
@abstractmethod
def chat(self, messages: list[Message], **kwargs) -> LLMResponse:
pass
@abstractmethod
def stream(self, messages: list[Message], **kwargs) -> Generator:
pass
class OpenAIProvider(LLMProvider):
def __init__(self, model: str = "gpt-4o", temperature: float = 0.7):
self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
self.model = model
self.temperature = temperature
self.max_retries = 3
self.base_delay = 1.0
def chat(self, messages: list[Message], **kwargs) -> LLMResponse:
formatted = [{"role": m.role, "content": m.content} for m in messages]
for attempt in range(self.max_retries):
try:
start = time.time()
response = self.client.chat.completions.create(
model=kwargs.get("model", self.model),
messages=formatted,
temperature=kwargs.get("temperature", self.temperature),
tools=kwargs.get("tools"),
tool_choice=kwargs.get("tool_choice"),
)
latency = (time.time() - start) * 1000
choice = response.choices[0]
return LLMResponse(
content=choice.message.content or "",
model=response.model,
usage={
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
},
tool_calls=choice.message.tool_calls,
finish_reason=choice.finish_reason,
latency_ms=latency,
)
except Exception as e:
if attempt == self.max_retries - 1:
raise
delay = self.base_delay * (2 ** attempt)
logger.warning(f"LLM call failed (attempt {attempt + 1}): {e}")
time.sleep(delay)
def stream(self, messages: list[Message], **kwargs) -> Generator:
formatted = [{"role": m.role, "content": m.content} for m in messages]
stream = self.client.chat.completions.create(
model=kwargs.get("model", self.model),
messages=formatted,
temperature=kwargs.get("temperature", self.temperature),
stream=True,
)
for chunk in stream:
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
class AnthropicProvider(LLMProvider):
def __init__(self, model: str = "claude-3-5-sonnet-20241022"):
self.client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
self.model = model
def chat(self, messages: list[Message], **kwargs) -> LLMResponse:
system_msg = ""
chat_messages = []
for m in messages:
if m.role == "system":
system_msg = m.content
else:
chat_messages.append({"role": m.role, "content": m.content})
start = time.time()
response = self.client.messages.create(
model=self.model,
system=system_msg,
messages=chat_messages,
max_tokens=4096,
)
latency = (time.time() - start) * 1000
return LLMResponse(
content=response.content[0].text,
model=self.model,
usage={
"prompt_tokens": response.usage.input_tokens,
"completion_tokens": response.usage.output_tokens,
"total_tokens": response.usage.input_tokens + response.usage.output_tokens,
},
latency_ms=latency,
)
def stream(self, messages: list[Message], **kwargs) -> Generator:
# Anthropic streaming implementation
pass
The critical piece most tutorials skip is the retry logic with exponential backoff. In production, you will hit rate limits, experience transient failures, and encounter timeouts. Without robust retry handling, your assistant becomes unreliable at the worst moments -- exactly when you need it most.
| provider | latency |
|---|---|
| GPT-4o | 820 |
| GPT-4o-mini | 340 |
| Claude 3.5 Sonnet | 650 |
| Claude 3 Haiku | 280 |
| Llama 3.1 70B | 450 |
Choosing the Right Model for Each Task
One of the most impactful optimizations I made was routing different tasks to different models. Not every request needs GPT-4o. My assistant uses a model router that selects based on task complexity:
class ModelRouter:
"""Routes tasks to optimal models based on complexity and cost."""
ROUTING_TABLE = {
"simple_qa": {"model": "gpt-4o-mini", "provider": "openai"},
"code_generation": {"model": "claude-3-5-sonnet-20241022", "provider": "anthropic"},
"analysis": {"model": "gpt-4o", "provider": "openai"},
"summarization": {"model": "gpt-4o-mini", "provider": "openai"},
"creative_writing": {"model": "claude-3-5-sonnet-20241022", "provider": "anthropic"},
"tool_use": {"model": "gpt-4o", "provider": "openai"},
"embedding": {"model": "text-embedding-3-small", "provider": "openai"},
}
def __init__(self, providers: dict[str, LLMProvider]):
self.providers = providers
self.classifier = providers.get("openai") # Use cheap model for classification
def classify_task(self, user_input: str) -> str:
response = self.classifier.chat([
Message(role="system", content=(
"Classify the user request into one category: "
"simple_qa, code_generation, analysis, summarization, "
"creative_writing, tool_use. Reply with only the category."
)),
Message(role="user", content=user_input),
], model="gpt-4o-mini")
return response.content.strip().lower()
def route(self, user_input: str) -> tuple[LLMProvider, str]:
task_type = self.classify_task(user_input)
config = self.ROUTING_TABLE.get(task_type, self.ROUTING_TABLE["analysis"])
provider = self.providers[config["provider"]]
return provider, config["model"]
This approach cut my API costs by roughly 60 percent without any noticeable quality degradation. Simple questions that previously consumed GPT-4o tokens now resolve on GPT-4o-mini in a fraction of the time and cost.
| Name | Value |
|---|---|
| GPT-4o (Complex) | 25 |
| GPT-4o-mini (Simple) | 45 |
| Claude 3.5 Sonnet (Code) | 20 |
| Local Llama (Private) | 10 |
Step 2: Building the RAG Pipeline -- Your Assistant's Knowledge Base
A personal AI assistant without access to your knowledge is just a fancy chatbot. Retrieval-Augmented Generation (RAG) is what transforms it into something genuinely useful -- an assistant that knows your projects, remembers your documentation, and can reference your notes and files. I covered the foundational vector database architecture in my vector database deep dive, but here I will focus specifically on building a RAG pipeline for a personal assistant.
Document Ingestion Pipeline
The first challenge is getting your documents into the system. You need to handle multiple formats (Markdown, PDF, code files, emails, web pages), chunk them intelligently, generate embeddings, and store them in a vector database. Here is my ingestion pipeline:
import hashlib
from pathlib import Path
from dataclasses import dataclass
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import (
TextLoader, PyPDFLoader, UnstructuredMarkdownLoader
)
@dataclass
class DocumentChunk:
content: str
metadata: dict
embedding: list[float] = None
chunk_id: str = ""
def __post_init__(self):
if not self.chunk_id:
self.chunk_id = hashlib.sha256(
self.content.encode()
).hexdigest()[:16]
class DocumentIngester:
LOADER_MAP = {
".md": UnstructuredMarkdownLoader,
".txt": TextLoader,
".pdf": PyPDFLoader,
".py": TextLoader,
".ts": TextLoader,
".js": TextLoader,
}
def __init__(self, embedding_provider: LLMProvider):
self.embedding_provider = embedding_provider
self.splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
separators=["\n\n", "\n", ". ", " ", ""],
length_function=len,
)
def ingest_file(self, file_path: str) -> list[DocumentChunk]:
path = Path(file_path)
loader_class = self.LOADER_MAP.get(path.suffix)
if not loader_class:
raise ValueError(f"Unsupported file type: {path.suffix}")
loader = loader_class(str(path))
documents = loader.load()
chunks = []
for doc in documents:
splits = self.splitter.split_text(doc.page_content)
for i, split in enumerate(splits):
chunk = DocumentChunk(
content=split,
metadata={
"source": str(path),
"filename": path.name,
"chunk_index": i,
"total_chunks": len(splits),
"file_type": path.suffix,
"ingested_at": time.time(),
},
)
chunks.append(chunk)
return chunks
def ingest_directory(self, dir_path: str, recursive: bool = True) -> list[DocumentChunk]:
path = Path(dir_path)
all_chunks = []
pattern = "**/*" if recursive else "*"
for file_path in path.glob(pattern):
if file_path.suffix in self.LOADER_MAP:
try:
chunks = self.ingest_file(str(file_path))
all_chunks.extend(chunks)
logger.info(f"Ingested {file_path}: {len(chunks)} chunks")
except Exception as e:
logger.error(f"Failed to ingest {file_path}: {e}")
return all_chunks
Chunk Size Matters More Than You Think
I spent an embarrassing amount of time debugging poor retrieval quality before realizing the problem was my chunk size. Too large, and you retrieve irrelevant context that dilutes the useful information. Too small, and you lose critical context that makes the information meaningful.
| size | precision | recall |
|---|---|---|
| 128 tokens | 82 | 58 |
| 256 tokens | 78 | 71 |
| 512 tokens | 74 | 84 |
| 1024 tokens | 65 | 89 |
| 2048 tokens | 52 | 92 |
After extensive testing across my own document types, I found that 512 tokens with 50 token overlap hits the sweet spot for most personal knowledge bases. Code files benefit from slightly larger chunks (768-1024 tokens) because function boundaries matter. Meeting notes and emails work better at 256-384 tokens because individual points are more self-contained.
Vector Store Integration
For the vector database layer, I recommend starting with ChromaDB for local development and evaluating Pinecone or Weaviate for production. Here is a unified interface that supports multiple backends -- an approach I detailed further in my production vector database tutorial:
import chromadb
from chromadb.config import Settings
class VectorStore:
def __init__(self, collection_name: str = "personal_kb", persist_dir: str = "./vectordb"):
self.client = chromadb.PersistentClient(
path=persist_dir,
settings=Settings(anonymized_telemetry=False),
)
self.collection = self.client.get_or_create_collection(
name=collection_name,
metadata={"hnsw:space": "cosine"},
)
def add_chunks(self, chunks: list[DocumentChunk], embeddings: list[list[float]]):
self.collection.add(
ids=[c.chunk_id for c in chunks],
embeddings=embeddings,
documents=[c.content for c in chunks],
metadatas=[c.metadata for c in chunks],
)
def query(self, query_embedding: list[float], n_results: int = 5,
where: dict = None) -> list[dict]:
results = self.collection.query(
query_embeddings=[query_embedding],
n_results=n_results,
where=where,
include=["documents", "metadatas", "distances"],
)
return [
{
"content": doc,
"metadata": meta,
"distance": dist,
"relevance_score": 1 - dist,
}
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0],
)
]
def delete_by_source(self, source: str):
self.collection.delete(where={"source": source})
@property
def count(self) -> int:
return self.collection.count()
The Complete RAG Query Flow
With ingestion and storage in place, the retrieval query flow connects everything:
class RAGPipeline:
def __init__(self, vector_store: VectorStore, llm: LLMProvider,
embedding_model: str = "text-embedding-3-small"):
self.vector_store = vector_store
self.llm = llm
self.embedding_client = OpenAI()
self.embedding_model = embedding_model
def get_embedding(self, text: str) -> list[float]:
response = self.embedding_client.embeddings.create(
model=self.embedding_model,
input=text,
)
return response.data[0].embedding
def query(self, question: str, n_results: int = 5,
min_relevance: float = 0.7) -> str:
# 1. Generate query embedding
query_embedding = self.get_embedding(question)
# 2. Retrieve relevant chunks
results = self.vector_store.query(query_embedding, n_results=n_results)
# 3. Filter by relevance threshold
relevant = [r for r in results if r["relevance_score"] >= min_relevance]
if not relevant:
return self._fallback_response(question)
# 4. Build context from retrieved chunks
context = "\n\n---\n\n".join([
f"Source: {r['metadata']['filename']}\n{r['content']}"
for r in relevant
])
# 5. Generate response with context
response = self.llm.chat([
Message(role="system", content=(
"You are a personal AI assistant with access to the user's "
"knowledge base. Answer questions using the provided context. "
"If the context doesn't contain relevant information, say so "
"clearly rather than making things up. Cite sources when possible."
)),
Message(role="user", content=(
f"Context from knowledge base:\n\n{context}\n\n"
f"Question: {question}"
)),
])
return response.content
def _fallback_response(self, question: str) -> str:
return self.llm.chat([
Message(role="system", content=(
"You are a personal AI assistant. The user's knowledge base "
"did not contain relevant information for this question. "
"Answer using your general knowledge and note that this "
"answer is not based on the user's documents."
)),
Message(role="user", content=question),
]).content
Step 3: Memory Systems -- Short-Term, Long-Term, and Episodic
Memory is what separates a useful assistant from a stateless chatbot. You need three types of memory working together, and getting the balance right between them is one of the hardest challenges in building personal AI systems. I explored the broader challenges facing memory systems in production in my AI agent memory systems analysis, but here I will focus on practical implementation patterns.
Conversation Memory (Short-Term)
This is the simplest form: maintaining context within a single conversation. Most LLM APIs handle this through message history, but you need to manage the context window carefully.
from collections import deque
class ConversationMemory:
def __init__(self, max_tokens: int = 8000, summary_threshold: int = 6000):
self.messages: deque[Message] = deque()
self.max_tokens = max_tokens
self.summary_threshold = summary_threshold
self.system_message: Optional[Message] = None
self._token_count = 0
def add_message(self, message: Message):
if message.role == "system":
self.system_message = message
return
self.messages.append(message)
self._token_count += self._estimate_tokens(message.content)
if self._token_count > self.summary_threshold:
self._compress()
def get_messages(self) -> list[Message]:
result = []
if self.system_message:
result.append(self.system_message)
result.extend(self.messages)
return result
def _compress(self):
"""Summarize older messages to free up context window."""
older = list(self.messages)[:len(self.messages) // 2]
recent = list(self.messages)[len(self.messages) // 2:]
summary_text = "\n".join([f"{m.role}: {m.content}" for m in older])
# In production, use an LLM to generate this summary
summary = Message(
role="system",
content=f"Previous conversation summary: {summary_text[:500]}...",
metadata={"type": "summary", "compressed_count": len(older)},
)
self.messages = deque([summary] + recent)
self._token_count = sum(
self._estimate_tokens(m.content) for m in self.messages
)
def _estimate_tokens(self, text: str) -> int:
return len(text) // 4 # Rough approximation
Persistent Memory (Long-Term)
Long-term memory stores facts, preferences, and learned patterns that persist across conversations. I use a hybrid approach: SQLite for structured facts and the vector store for semantic search across memories.
import sqlite3
import json
class LongTermMemory:
def __init__(self, db_path: str = "./memory.db", vector_store: VectorStore = None):
self.db_path = db_path
self.vector_store = vector_store
self._init_db()
def _init_db(self):
conn = sqlite3.connect(self.db_path)
conn.execute("""
CREATE TABLE IF NOT EXISTS facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
confidence REAL DEFAULT 1.0,
source TEXT,
created_at REAL,
updated_at REAL,
access_count INTEGER DEFAULT 0,
UNIQUE(category, key)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS episodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
summary TEXT NOT NULL,
context TEXT,
importance REAL DEFAULT 0.5,
created_at REAL,
embedding_id TEXT
)
""")
conn.commit()
conn.close()
def store_fact(self, category: str, key: str, value: str,
confidence: float = 1.0, source: str = "conversation"):
conn = sqlite3.connect(self.db_path)
conn.execute("""
INSERT INTO facts (category, key, value, confidence, source,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(category, key) DO UPDATE SET
value = excluded.value,
confidence = excluded.confidence,
updated_at = excluded.updated_at
""", (category, key, value, confidence, source, time.time(), time.time()))
conn.commit()
conn.close()
def get_facts(self, category: str = None) -> list[dict]:
conn = sqlite3.connect(self.db_path)
if category:
cursor = conn.execute(
"SELECT category, key, value, confidence FROM facts WHERE category = ?",
(category,)
)
else:
cursor = conn.execute(
"SELECT category, key, value, confidence FROM facts"
)
facts = [
{"category": row[0], "key": row[1], "value": row[2], "confidence": row[3]}
for row in cursor.fetchall()
]
conn.close()
return facts
def store_episode(self, summary: str, context: str, importance: float = 0.5):
conn = sqlite3.connect(self.db_path)
conn.execute("""
INSERT INTO episodes (summary, context, importance, created_at)
VALUES (?, ?, ?, ?)
""", (summary, context, importance, time.time()))
conn.commit()
conn.close()
| month | facts | episodes | queries |
|---|---|---|---|
| Month 1 | 45 | 12 | 180 |
| Month 2 | 156 | 38 | 420 |
| Month 3 | 312 | 67 | 890 |
| Month 4 | 478 | 98 | 1450 |
| Month 5 | 623 | 134 | 2100 |
| Month 6 | 801 | 172 | 3200 |
Memory Extraction from Conversations
The most powerful aspect of long-term memory is automatic extraction. After each conversation, my assistant identifies and stores new facts:
class MemoryExtractor:
def __init__(self, llm: LLMProvider, memory: LongTermMemory):
self.llm = llm
self.memory = memory
def extract_from_conversation(self, messages: list[Message]):
conversation_text = "\n".join([
f"{m.role}: {m.content}" for m in messages if m.role != "system"
])
response = self.llm.chat([
Message(role="system", content="""Analyze this conversation and extract
any new facts about the user. Return a JSON array of objects with:
- category: one of "preferences", "projects", "contacts", "skills", "goals"
- key: a descriptive key for the fact
- value: the fact itself
- confidence: 0.0-1.0 how confident you are this is a real fact
Only extract facts that are clearly stated or strongly implied.
Return empty array [] if no new facts found."""),
Message(role="user", content=conversation_text),
], model="gpt-4o-mini")
try:
facts = json.loads(response.content)
for fact in facts:
self.memory.store_fact(**fact)
logger.info(f"Stored fact: {fact['category']}/{fact['key']}")
except json.JSONDecodeError:
logger.warning("Failed to parse memory extraction response")
Step 4: Tool Integration -- Making Your Assistant Actually Do Things
An AI assistant that can only talk is severely limited. The real power comes from tools -- capabilities that let your assistant interact with the world. The tool system in my assistant follows a registry pattern that makes adding new tools trivial.
from typing import Callable
import inspect
class Tool:
def __init__(self, name: str, description: str, function: Callable,
parameters: dict):
self.name = name
self.description = description
self.function = function
self.parameters = parameters
def to_openai_schema(self) -> dict:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}
def execute(self, **kwargs) -> str:
try:
result = self.function(**kwargs)
return json.dumps(result) if not isinstance(result, str) else result
except Exception as e:
return json.dumps({"error": str(e)})
class ToolRegistry:
def __init__(self):
self.tools: dict[str, Tool] = {}
def register(self, tool: Tool):
self.tools[tool.name] = tool
logger.info(f"Registered tool: {tool.name}")
def get_tool(self, name: str) -> Optional[Tool]:
return self.tools.get(name)
def get_schemas(self) -> list[dict]:
return [tool.to_openai_schema() for tool in self.tools.values()]
def execute_tool(self, name: str, arguments: dict) -> str:
tool = self.get_tool(name)
if not tool:
return json.dumps({"error": f"Unknown tool: {name}"})
return tool.execute(**arguments)
Essential Tools I Use Daily
Here are the tools that transformed my assistant from a novelty into a daily driver:
import subprocess
import requests
from datetime import datetime
# File system operations
def read_file(path: str) -> dict:
"""Read a file and return its contents."""
with open(path, "r") as f:
content = f.read()
return {"content": content, "path": path, "size": len(content)}
def write_file(path: str, content: str) -> dict:
"""Write content to a file."""
with open(path, "w") as f:
f.write(content)
return {"status": "written", "path": path, "size": len(content)}
def search_files(directory: str, pattern: str) -> dict:
"""Search for files matching a pattern."""
result = subprocess.run(
["rg", "--files", "--glob", pattern, directory],
capture_output=True, text=True
)
files = result.stdout.strip().split("\n") if result.stdout else []
return {"files": files, "count": len(files)}
# Web research
def web_search(query: str, num_results: int = 5) -> dict:
"""Search the web using SerpAPI."""
params = {
"q": query,
"num": num_results,
"api_key": os.getenv("SERPAPI_KEY"),
}
response = requests.get("https://serpapi.com/search", params=params)
results = response.json().get("organic_results", [])
return {
"results": [
{"title": r["title"], "url": r["link"], "snippet": r.get("snippet", "")}
for r in results
]
}
# Calendar and scheduling
def get_calendar_events(date: str = None) -> dict:
"""Get calendar events for a given date."""
if date is None:
date = datetime.now().strftime("%Y-%m-%d")
# Integration with Google Calendar API
# Simplified for brevity
return {"date": date, "events": []}
def create_reminder(title: str, due_date: str, priority: str = "medium") -> dict:
"""Create a reminder/task."""
return {
"status": "created",
"title": title,
"due_date": due_date,
"priority": priority,
}
# Git operations
def git_status(repo_path: str) -> dict:
"""Get git status of a repository."""
result = subprocess.run(
["git", "status", "--porcelain"],
cwd=repo_path, capture_output=True, text=True
)
return {"status": result.stdout, "repo": repo_path}
def git_diff(repo_path: str, staged: bool = False) -> dict:
"""Get git diff of a repository."""
cmd = ["git", "diff"]
if staged:
cmd.append("--staged")
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True)
return {"diff": result.stdout[:5000], "repo": repo_path}
| tool | usage |
|---|---|
| Knowledge Query | 340 |
| File Operations | 285 |
| Web Search | 210 |
| Git Operations | 175 |
| Calendar | 95 |
| Code Analysis | 145 |
| Email Triage | 120 |
The Agent Loop: Connecting LLM to Tools
The agent loop is the core orchestration that lets the LLM decide which tools to use and how to chain them together:
class AgentExecutor:
def __init__(self, llm: LLMProvider, tool_registry: ToolRegistry,
memory: ConversationMemory, rag: RAGPipeline,
max_iterations: int = 10):
self.llm = llm
self.tools = tool_registry
self.memory = memory
self.rag = rag
self.max_iterations = max_iterations
def run(self, user_input: str) -> str:
# Add user message to memory
self.memory.add_message(Message(role="user", content=user_input))
# Check if RAG context would help
rag_context = self._get_rag_context(user_input)
# Build system message with context
system_content = self._build_system_prompt(rag_context)
messages = [Message(role="system", content=system_content)]
messages.extend(self.memory.get_messages())
for iteration in range(self.max_iterations):
response = self.llm.chat(
messages,
tools=self.tools.get_schemas(),
tool_choice="auto",
)
if response.tool_calls:
# Execute each tool call
for tool_call in response.tool_calls:
func = tool_call.function
tool_name = func.name
arguments = json.loads(func.arguments)
logger.info(f"Executing tool: {tool_name}({arguments})")
result = self.tools.execute_tool(tool_name, arguments)
# Add tool result to messages
messages.append(Message(
role="assistant",
content="",
tool_calls=[tool_call],
))
messages.append(Message(
role="tool",
content=result,
tool_call_id=tool_call.id,
))
else:
# No more tool calls -- we have our final response
self.memory.add_message(
Message(role="assistant", content=response.content)
)
return response.content
return "I reached the maximum number of steps. Here is what I found so far..."
def _get_rag_context(self, query: str) -> Optional[str]:
try:
results = self.rag.vector_store.query(
self.rag.get_embedding(query), n_results=3
)
relevant = [r for r in results if r["relevance_score"] > 0.75]
if relevant:
return "\n\n".join([r["content"] for r in relevant])
except Exception:
pass
return None
def _build_system_prompt(self, rag_context: Optional[str] = None) -> str:
prompt = """You are a personal AI assistant for a software engineer.
You have access to tools for file operations, web search, git operations,
calendar management, and a personal knowledge base.
Key behaviors:
- Be concise and technical. Skip pleasantries.
- Use tools proactively when they would help answer the question.
- Always cite sources when using knowledge base information.
- If unsure, say so rather than guessing.
- For code questions, provide working examples.
"""
if rag_context:
prompt += f"\n\nRelevant context from knowledge base:\n{rag_context}"
return prompt
Step 5: Agent Frameworks -- LangChain vs. Building Custom
A question I get asked constantly: should you use LangChain, LlamaIndex, CrewAI, or build your own agent framework? After extensive experience with all three approaches, my answer is nuanced.
Framework vs Custom Build
Use LangChain/CrewAI When
Build Custom When
For my personal assistant, I started with LangChain and eventually migrated to a custom implementation. The reasons were specific to my use case: I needed precise control over token usage, wanted to eliminate dependency bloat, and found myself fighting LangChain's abstractions more than benefiting from them. But if you are starting fresh and want to move fast, LangChain's ecosystem is genuinely valuable.
Here is what a LangChain-based version looks like for comparison:
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain.agents import AgentExecutor, create_openai_tools_agent
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_community.vectorstores import Chroma
from langchain.tools import tool
# Define tools using LangChain decorators
@tool
def search_knowledge_base(query: str) -> str:
"""Search the personal knowledge base for relevant information."""
vectorstore = Chroma(
persist_directory="./vectordb",
embedding_function=OpenAIEmbeddings(model="text-embedding-3-small"),
)
results = vectorstore.similarity_search(query, k=3)
return "\n\n".join([doc.page_content for doc in results])
@tool
def run_shell_command(command: str) -> str:
"""Run a shell command and return the output. Use carefully."""
result = subprocess.run(
command.split(), capture_output=True, text=True, timeout=30
)
return result.stdout[:2000] if result.stdout else result.stderr[:2000]
# Create the agent
llm = ChatOpenAI(model="gpt-4o", temperature=0.3)
prompt = ChatPromptTemplate.from_messages([
("system", "You are a personal AI assistant for a software engineer. "
"Use your tools to help answer questions and complete tasks."),
MessagesPlaceholder(variable_name="chat_history"),
("human", "{input}"),
MessagesPlaceholder(variable_name="agent_scratchpad"),
])
tools = [search_knowledge_base, run_shell_command]
agent = create_openai_tools_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True, max_iterations=10)
# Run
result = executor.invoke({
"input": "What did I write about vector databases last week?",
"chat_history": [],
})
Multi-Agent Orchestration with CrewAI
For more complex workflows, I use CrewAI to orchestrate multiple specialized agents. This is particularly useful when a task requires different expertise areas. For a deeper exploration of multi-agent patterns at enterprise scale, see my coverage of agent orchestration patterns:
from crewai import Agent, Task, Crew, Process
researcher = Agent(
role="Research Analyst",
goal="Find and synthesize relevant information",
backstory="Expert at finding and analyzing technical information",
tools=[search_knowledge_base, web_search_tool],
llm="gpt-4o",
verbose=True,
)
writer = Agent(
role="Technical Writer",
goal="Create clear, actionable technical content",
backstory="Experienced technical writer who values clarity and precision",
tools=[read_file_tool, write_file_tool],
llm="claude-3-5-sonnet",
verbose=True,
)
reviewer = Agent(
role="Code Reviewer",
goal="Review code for quality, security, and best practices",
backstory="Senior engineer focused on code quality and security",
tools=[git_diff_tool, search_files_tool],
llm="gpt-4o",
verbose=True,
)
# Define a multi-step research and writing task
research_task = Task(
description="Research the latest developments in {topic}",
expected_output="Comprehensive research summary with sources",
agent=researcher,
)
writing_task = Task(
description="Write a technical article based on the research",
expected_output="Complete article draft in Markdown format",
agent=writer,
context=[research_task],
)
crew = Crew(
agents=[researcher, writer],
tasks=[research_task, writing_task],
process=Process.sequential,
verbose=True,
)
result = crew.kickoff(inputs={"topic": "WebAssembly in production"})
Foundation
LLM abstraction layer, basic chat interface, model router
RAG Pipeline
Document ingestion, embedding generation, vector store integration
Memory Systems
Conversation memory, long-term fact storage, memory extraction
Tool Integration
File ops, web search, git tools, calendar, agent loop
Multi-Agent
CrewAI integration, specialized agents, complex workflows
Production Hardening
Error handling, monitoring, rate limiting, deployment
Step 6: Production Deployment -- From Laptop to Reliable Service
Getting your assistant running locally is maybe 30 percent of the work. Making it reliable, observable, and deployable is the remaining 70 percent. Here is the production configuration I use.
FastAPI Service Layer
from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from contextlib import asynccontextmanager
import uvicorn
class ChatRequest(BaseModel):
message: str
conversation_id: Optional[str] = None
model_override: Optional[str] = None
class ChatResponse(BaseModel):
response: str
conversation_id: str
model_used: str
tokens_used: int
latency_ms: float
sources: list[str] = []
@asynccontextmanager
async def lifespan(app: FastAPI):
# Initialize components on startup
app.state.agent = initialize_agent()
app.state.sessions = {}
yield
# Cleanup on shutdown
app = FastAPI(title="Personal AI Assistant", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
agent = app.state.agent
try:
start = time.time()
response = agent.run(request.message)
latency = (time.time() - start) * 1000
return ChatResponse(
response=response,
conversation_id=request.conversation_id or "default",
model_used=agent.last_model_used,
tokens_used=agent.last_token_count,
latency_ms=latency,
)
except Exception as e:
logger.error(f"Chat error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.post("/ingest")
async def ingest_documents(directory: str):
ingester = app.state.agent.ingester
chunks = ingester.ingest_directory(directory)
return {"status": "ingested", "chunks": len(chunks)}
@app.get("/health")
async def health():
return {
"status": "healthy",
"vector_store_count": app.state.agent.rag.vector_store.count,
"uptime_seconds": time.time() - app.state.start_time,
}
Docker Configuration
# docker-compose.yml
version: '3.8'
services:
assistant:
build:
context: .
dockerfile: Dockerfile
ports:
- '8000:8000'
volumes:
- ./data/vectordb:/app/vectordb
- ./data/memory:/app/memory
- ./knowledge:/app/knowledge:ro
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- SERPAPI_KEY=${SERPAPI_KEY}
- LOG_LEVEL=INFO
restart: unless-stopped
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8000/health']
interval: 30s
timeout: 10s
retries: 3
redis:
image: redis:7-alpine
ports:
- '6379:6379'
volumes:
- redis_data:/data
volumes:
redis_data:
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl git ripgrep \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
After deployment hardening
Production Uptime
Step 7: Monitoring and Observability
You cannot improve what you cannot measure. My assistant tracks every interaction, and the insights from monitoring have driven every major improvement. Here is a practical monitoring setup:
import time
from dataclasses import dataclass, field
from collections import defaultdict
@dataclass
class MetricsCollector:
request_count: int = 0
total_tokens: int = 0
total_cost: float = 0.0
total_latency_ms: float = 0.0
error_count: int = 0
tool_usage: dict = field(default_factory=lambda: defaultdict(int))
model_usage: dict = field(default_factory=lambda: defaultdict(int))
rag_hit_rate: list = field(default_factory=list)
# Cost per 1M tokens (approximate)
COST_TABLE = {
"gpt-4o": {"input": 2.50, "output": 10.00},
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
"claude-3-5-sonnet-20241022": {"input": 3.00, "output": 15.00},
"text-embedding-3-small": {"input": 0.02, "output": 0.0},
}
def record_request(self, model: str, input_tokens: int,
output_tokens: int, latency_ms: float,
tools_used: list[str] = None, rag_hit: bool = False):
self.request_count += 1
self.total_tokens += input_tokens + output_tokens
self.total_latency_ms += latency_ms
self.model_usage[model] += 1
# Calculate cost
if model in self.COST_TABLE:
costs = self.COST_TABLE[model]
cost = (input_tokens * costs["input"] +
output_tokens * costs["output"]) / 1_000_000
self.total_cost += cost
if tools_used:
for tool in tools_used:
self.tool_usage[tool] += 1
self.rag_hit_rate.append(1.0 if rag_hit else 0.0)
def record_error(self):
self.error_count += 1
def get_summary(self) -> dict:
avg_latency = (self.total_latency_ms / self.request_count
if self.request_count else 0)
rag_rate = (sum(self.rag_hit_rate) / len(self.rag_hit_rate) * 100
if self.rag_hit_rate else 0)
return {
"total_requests": self.request_count,
"total_tokens": self.total_tokens,
"total_cost_usd": round(self.total_cost, 4),
"avg_latency_ms": round(avg_latency, 1),
"error_rate": round(self.error_count / max(self.request_count, 1) * 100, 2),
"rag_hit_rate": round(rag_rate, 1),
"top_tools": dict(sorted(
self.tool_usage.items(), key=lambda x: x[1], reverse=True
)[:5]),
"model_distribution": dict(self.model_usage),
}
| week | cost | accuracy |
|---|---|---|
| Week 1 | 28.5 | 72 |
| Week 2 | 22.1 | 78 |
| Week 3 | 18.4 | 83 |
| Week 4 | 15.2 | 86 |
| Week 5 | 14.8 | 88 |
| Week 6 | 13.9 | 91 |
| Week 7 | 12.5 | 89 |
| Week 8 | 11.8 | 92 |
The chart above shows my actual cost trajectory over the first two months. Notice how costs dropped significantly while accuracy improved -- that is the model router optimization kicking in. Routing simple queries to cheaper models while reserving GPT-4o and Claude for complex reasoning tasks makes a massive difference at scale.
Step 8: Advanced Patterns That Make the Difference
Prompt Template Management
Hard-coding prompts is a maintainability nightmare. I use a template system that version-controls prompts separately from code:
from pathlib import Path
from string import Template
class PromptManager:
def __init__(self, prompts_dir: str = "./prompts"):
self.prompts_dir = Path(prompts_dir)
self._cache: dict[str, str] = {}
def get(self, name: str, **variables) -> str:
if name not in self._cache:
path = self.prompts_dir / f"{name}.txt"
if not path.exists():
raise FileNotFoundError(f"Prompt not found: {name}")
self._cache[name] = path.read_text()
template = Template(self._cache[name])
return template.safe_substitute(**variables)
def reload(self):
self._cache.clear()
# prompts/system_default.txt You are a personal AI assistant for $user_name, a $user_role. Current date: $current_date Active projects: $active_projects Key behaviors: - Be concise and technical - Use tools proactively when helpful - Cite sources from the knowledge base - If uncertain, say so explicitly - Match the user's communication style The user prefers $communication_style responses.
Streaming Responses for Better UX
Nobody wants to stare at a blank screen for 5 seconds waiting for GPT-4o to finish generating. Streaming makes the experience feel instantaneous:
// Frontend streaming handler (React/Next.js)
async function streamChat(message: string) {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
})
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let fullResponse = ''
while (reader) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
fullResponse += chunk
// Update UI with each chunk
setResponse(fullResponse)
}
return fullResponse
}
Guardrails and Safety
Even for a personal assistant, you need guardrails. My system prevents accidental destructive operations and validates tool inputs:
class SafetyGuard:
BLOCKED_COMMANDS = [
"rm -rf /", "rm -rf ~", "DROP TABLE", "DELETE FROM",
"format", "mkfs", "dd if=",
]
SENSITIVE_PATHS = [
"/etc", "/usr", "/bin", "/sbin", "/var",
os.path.expanduser("~/.ssh"),
os.path.expanduser("~/.aws"),
]
def validate_command(self, command: str) -> tuple[bool, str]:
for blocked in self.BLOCKED_COMMANDS:
if blocked.lower() in command.lower():
return False, f"Blocked dangerous command pattern: {blocked}"
return True, "OK"
def validate_file_access(self, path: str, write: bool = False) -> tuple[bool, str]:
abs_path = os.path.abspath(path)
for sensitive in self.SENSITIVE_PATHS:
if abs_path.startswith(sensitive) and write:
return False, f"Write access blocked for sensitive path: {sensitive}"
return True, "OK"
Cost Optimization Strategies
Running a personal AI assistant can get expensive quickly if you are not careful. After months of optimizing my setup, here are the strategies that had the biggest impact on cost reduction.
1. Aggressive Caching
The same questions often come up repeatedly. I cache responses at multiple levels:
import hashlib
from functools import lru_cache
class ResponseCache:
def __init__(self, ttl_seconds: int = 3600):
self.cache = {}
self.ttl = ttl_seconds
def _cache_key(self, messages: list[Message], model: str) -> str:
content = json.dumps([
{"role": m.role, "content": m.content} for m in messages
]) + model
return hashlib.sha256(content.encode()).hexdigest()
def get(self, messages: list[Message], model: str) -> Optional[LLMResponse]:
key = self._cache_key(messages, model)
if key in self.cache:
entry = self.cache[key]
if time.time() - entry["timestamp"] < self.ttl:
return entry["response"]
del self.cache[key]
return None
def set(self, messages: list[Message], model: str, response: LLMResponse):
key = self._cache_key(messages, model)
self.cache[key] = {
"response": response,
"timestamp": time.time(),
}
2. Embedding Caching
Embedding generation costs add up when you are doing frequent RAG queries. Cache embeddings for queries you have seen before:
class EmbeddingCache:
def __init__(self, cache_path: str = "./embedding_cache.db"):
self.conn = sqlite3.connect(cache_path)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS embeddings (
text_hash TEXT PRIMARY KEY,
embedding BLOB,
model TEXT,
created_at REAL
)
""")
def get_or_compute(self, text: str, model: str, compute_fn) -> list[float]:
text_hash = hashlib.sha256(text.encode()).hexdigest()
cursor = self.conn.execute(
"SELECT embedding FROM embeddings WHERE text_hash = ? AND model = ?",
(text_hash, model)
)
row = cursor.fetchone()
if row:
return json.loads(row[0])
embedding = compute_fn(text)
self.conn.execute(
"INSERT INTO embeddings (text_hash, embedding, model, created_at) VALUES (?, ?, ?, ?)",
(text_hash, json.dumps(embedding), model, time.time())
)
self.conn.commit()
return embedding
| Name | Value |
|---|---|
| LLM API Calls | 55 |
| Embedding Generation | 20 |
| Vector DB Hosting | 10 |
| Compute (Docker) | 10 |
| External APIs | 5 |
3. Monthly Cost Breakdown
Here is a realistic breakdown of what running a personal AI assistant costs at moderate daily usage (roughly 50-80 queries per day):
| category | monthly |
|---|---|
| GPT-4o calls | 18.5 |
| GPT-4o-mini calls | 2.8 |
| Claude calls | 8.4 |
| Embeddings | 1.2 |
| SerpAPI | 5 |
| Infrastructure | 8 |
The total monthly cost of roughly $44 is less than a single SaaS subscription like GitHub Copilot Business ($39/month for the enterprise tier) and delivers significantly more capability. The key is the model router -- without it, running everything through GPT-4o would cost closer to $120-150 per month.
Common Pitfalls and How I Solved Them
After building and iterating on this system for months, I have encountered (and solved) nearly every common failure mode. Here are the ones that cost me the most time.
Pitfall 1: Context Window Overflow
When your assistant has a long conversation, accumulates RAG context, and is using tools, the context window fills up fast. The symptom is either a hard API error or, worse, the model silently drops early context and starts giving incoherent responses.
Solution: Implement rolling summarization (shown in the ConversationMemory class above) and set hard limits on RAG context inclusion. I cap RAG context at 2000 tokens and conversation history at 6000 tokens, leaving room for the system prompt and the model's response.
Pitfall 2: RAG Hallucination
Even with retrieved context, models sometimes hallucinate details that are not in your documents. This is especially dangerous when the model confidently cites "your notes" with fabricated information.
Solution: Implement source attribution verification. After the model generates a response, I run a second, cheaper pass that checks whether claimed citations actually appear in the retrieved chunks. This adds latency but catches hallucinations before they reach the user.
Pitfall 3: Tool Execution Timeouts
Web searches hang. File operations on large directories take forever. API calls to external services time out. Without proper timeout handling, your assistant becomes unresponsive.
Solution: Wrap every tool execution in a timeout context manager and provide the model with timeout information in the error response so it can adapt:
import signal
from contextlib import contextmanager
@contextmanager
def timeout(seconds: int):
def handler(signum, frame):
raise TimeoutError(f"Operation timed out after {seconds} seconds")
old_handler = signal.signal(signal.SIGALRM, handler)
signal.alarm(seconds)
try:
yield
finally:
signal.alarm(0)
signal.signal(signal.SIGALRM, old_handler)
# In tool execution
def safe_execute(self, tool_name: str, arguments: dict,
timeout_seconds: int = 30) -> str:
try:
with timeout(timeout_seconds):
return self.execute_tool(tool_name, arguments)
except TimeoutError as e:
return json.dumps({
"error": str(e),
"suggestion": "Try a more specific query or smaller scope"
})
Pitfall 4: Memory Pollution
Over time, your long-term memory accumulates contradictory or outdated facts. The user says "I am working on Project Atlas" in January and "I have finished Project Atlas and moved to Project Beacon" in March. If the memory system does not handle updates properly, the assistant gets confused.
Solution: Use the ON CONFLICT upsert pattern (shown in the LongTermMemory class) and implement confidence decay. Facts that have not been accessed or reinforced in 90 days have their confidence score reduced automatically.
Pitfall 5: Dependency on External Services
Your assistant should not become completely useless when an external API is down. I learned this the hard way when SerpAPI had an outage and my assistant could not answer any research-oriented questions.
Solution: Implement graceful degradation. When a tool fails, the assistant should explain what it tried, why it failed, and offer an alternative approach using available tools. Also consider running a local LLM (like Llama 3.1 via Ollama) as a fallback for when cloud APIs are unreachable. For monitoring your AI infrastructure's reliability, the patterns I described in the AI observability piece apply directly.
After implementing graceful degradation
Mean Recovery Time
Testing Your AI Assistant
Testing AI systems is fundamentally different from testing deterministic software. You cannot assert exact outputs. Instead, you need a layered testing strategy:
import pytest
from unittest.mock import MagicMock, patch
class TestRAGPipeline:
def test_retrieval_returns_relevant_chunks(self, rag_pipeline, sample_documents):
"""Test that retrieval finds semantically relevant documents."""
# Ingest test documents about Python
rag_pipeline.ingest(sample_documents)
results = rag_pipeline.query("How do I handle exceptions in Python?")
# At least one result should be about error handling
assert any("exception" in r["content"].lower() or
"error" in r["content"].lower()
for r in results)
def test_relevance_threshold_filters_noise(self, rag_pipeline):
"""Test that low-relevance results are filtered out."""
results = rag_pipeline.query(
"quantum physics equations", # Unlikely to match dev docs
min_relevance=0.8
)
assert len(results) == 0
def test_empty_knowledge_base_returns_fallback(self, rag_pipeline):
"""Test graceful handling of empty vector store."""
response = rag_pipeline.query("What is my deployment strategy?")
assert "knowledge base" in response.lower() or "not found" in response.lower()
class TestToolExecution:
def test_file_read_returns_content(self, tool_registry, tmp_path):
"""Test that file read tool works correctly."""
test_file = tmp_path / "test.txt"
test_file.write_text("Hello, World!")
result = tool_registry.execute_tool(
"read_file", {"path": str(test_file)}
)
parsed = json.loads(result)
assert parsed["content"] == "Hello, World!"
def test_dangerous_command_blocked(self, safety_guard):
"""Test that dangerous commands are caught."""
is_safe, reason = safety_guard.validate_command("rm -rf /")
assert not is_safe
assert "Blocked" in reason
class TestModelRouter:
def test_simple_questions_route_to_mini(self, router):
"""Simple questions should use the cheaper model."""
_, model = router.route("What time is it?")
assert "mini" in model
def test_code_generation_routes_to_sonnet(self, router):
"""Code tasks should use Claude Sonnet."""
_, model = router.route("Write a Python function to sort a linked list")
assert "claude" in model.lower() or "sonnet" in model.lower()
Evaluation Framework
Beyond unit tests, I run weekly evaluations using a golden dataset of question-answer pairs:
class AssistantEvaluator:
def __init__(self, agent: AgentExecutor, judge_llm: LLMProvider):
self.agent = agent
self.judge = judge_llm
def evaluate(self, test_cases: list[dict]) -> dict:
results = []
for case in test_cases:
response = self.agent.run(case["question"])
# Use LLM-as-judge for quality assessment
score = self.judge.chat([
Message(role="system", content="""Rate the response quality 1-5:
5 = Perfect, accurate, well-sourced
4 = Good, minor issues
3 = Acceptable, some gaps
2 = Poor, significant issues
1 = Wrong or harmful
Respond with only the number."""),
Message(role="user", content=(
f"Question: {case['question']}\n"
f"Expected: {case['expected_answer']}\n"
f"Actual: {response}"
)),
], model="gpt-4o-mini")
results.append({
"question": case["question"],
"score": int(score.content.strip()),
"response_length": len(response),
})
avg_score = sum(r["score"] for r in results) / len(results)
return {
"average_score": round(avg_score, 2),
"total_cases": len(results),
"pass_rate": sum(1 for r in results if r["score"] >= 4) / len(results),
"results": results,
}
| metric | score |
|---|---|
| RAG Accuracy | 91 |
| Tool Reliability | 96 |
| Response Quality | 87 |
| Latency p95 | 82 |
| Cost Efficiency | 94 |
| Memory Recall | 78 |
What I Would Do Differently
If I were starting from scratch today with what I know now, here is what I would change:
Start with the memory system, not the chat interface. The quality of your assistant is directly proportional to the quality of its memory. I spent weeks building a polished chat UI before I had proper memory, and the assistant felt useless despite looking great.
Use structured outputs from day one. OpenAI's structured output mode and JSON mode save enormous debugging time. Parsing free-form LLM text for tool calls and memory extraction is fragile and frustrating.
Invest in evaluation early. I flew blind for the first month, making changes without measuring whether they actually improved quality. Building an evaluation suite in week one would have saved weeks of wasted effort.
Do not over-abstract. My first implementation had so many abstraction layers that adding a simple tool required changes in six files. The version I use now is pragmatic about abstractions -- layers where they help, direct code where they do not.
Plan for model migration. The LLM landscape shifts rapidly. The abstraction layer I described at the beginning is not just nice engineering -- it is a survival strategy. When Claude 3.5 Sonnet launched and outperformed GPT-4 for my code generation tasks, I swapped providers in under an hour.
Where This Is Heading
The personal AI assistant space is evolving at breakneck speed. The patterns in this guide will serve you well for the next 12-18 months, but several trends are worth watching:
Local models are getting good enough. Llama 3.1 70B running on a Mac with 128GB RAM is surprisingly capable for many assistant tasks. The privacy and cost benefits of running local models for sensitive queries while using cloud APIs for complex reasoning is increasingly practical.
MCP (Model Context Protocol) is standardizing how AI assistants connect to tools and data sources. Anthropic's open standard is gaining rapid adoption, and building your tool layer to be MCP-compatible will pay dividends as the ecosystem matures.
Multimodal capabilities are becoming table stakes. The next version of my assistant will handle image analysis (screenshots of error messages, architecture diagrams), audio processing (meeting recordings), and video understanding. The APIs now support this; the integration patterns are catching up.
Agent-to-agent communication is the frontier. Today, my CrewAI workflows involve agents I defined. Tomorrow, my personal assistant will coordinate with other people's agents -- scheduling meetings by having my agent negotiate with their agent, or collaborating on documents through agent-mediated workflows.
The most exciting part about building your own AI assistant is that it gets better with every interaction. Every document you ingest, every preference it learns, every tool you add makes it more uniquely yours. No commercial product will ever match that kind of personalization, because no commercial product has access to your complete professional context.
Start building. Start small. Ship something that works for one task, and expand from there. The code in this guide is production-tested and ready to adapt. The best AI assistant is the one you actually use every day -- and the one you built yourself is the one you will trust the most.
If you found this guide useful, also check out my production LLM guardrails tutorial for hardening your assistant against adversarial inputs, and my piece on enterprise AI agents for scaling these patterns across teams.
