How LLMs Actually Work
Before calling an API, understand what you're calling. Tokens, context windows, temperature, and the concepts that determine whether your AI integration works well or fails in production.
LLMs don't process words or characters β they process tokens. A token is roughly 4 characters or ΒΎ of a word in English. Every API call costs tokens and is limited by a context window measured in tokens.
Hello, I am learning Python!
# 7 tokens. Code tokenizes differently β more tokens per line.
# Rule of thumb: 1000 tokens β 750 words β 1-2 paragraphs
| What you're counting | Approx tokens |
|---|---|
| A short sentence ("Hello, how are you?") | ~5 tokens |
| A paragraph (150 words) | ~200 tokens |
| A full page of text | ~500β700 tokens |
| A 10-page document | ~5,000β7,000 tokens |
| A small codebase (20 files) | ~50,000β100,000 tokens |
Everything the model can "see" at once
The context window is the total number of tokens a model can process in one call β both input and output combined. If your context window is 200,000 tokens and your prompt uses 150,000, you have 50,000 tokens left for the response.
This is the model's entire working memory. It has no memory between separate API calls unless you explicitly include previous conversation history in each new request. Every call starts fresh β your code is responsible for maintaining conversation state.
- Claude claude-sonnet-4-6: 200,000 token context window
- GPT-4o: 128,000 token context window
- Practical implication: you can fit entire codebases, books, or long documents in a single prompt
| Parameter | What It Does | Practical Range |
|---|---|---|
temperature | Randomness / creativity. 0 = deterministic, 1 = creative | 0 for factual/code, 0.7 for creative writing |
max_tokens | Maximum length of the response | 256 for short answers, 4096 for long outputs |
system | Instructions that shape all responses β the model's "persona" | Set once per session, not per message |
top_p | Nucleus sampling β alternative to temperature | Leave at default unless you know what you're doing |
stop | Token sequences that stop generation early | Useful for structured output like JSON |
Every LLM API uses a conversation structure: a list of messages with roles. Understanding this is the foundation of everything else in this phase.
# Three roles β you'll use all three messages = [ { "role": "system", # Instructions for the model's behavior "content": "You are a helpful Python tutor. Be concise and use examples." }, { "role": "user", # What the human said "content": "What is a list comprehension?" }, { "role": "assistant", # What the model said (for multi-turn) "content": "A list comprehension creates a list from an iterable..." }, { "role": "user", "content": "Show me an example with filtering." } # The model responds to the last user message, # with full context of everything above it. ]
The Anthropic SDK
Call Claude directly from Python β no wrappers, no abstraction layers. The raw SDK gives you full control and is what you'll use in any production system.
pip install anthropic
ANTHROPIC_API_KEY=sk-ant-api03-...
ANTHROPIC_API_KEY is set in your environment, the SDK finds it without any extra code. You never need to pass the key explicitly β just client = anthropic.Anthropic() and it works.import anthropic from dotenv import load_dotenv load_dotenv() client = anthropic.Anthropic() response = client.messages.create( model="claude-sonnet-4-5", max_tokens=1024, system="You are a concise Python tutor. Answer in 2-3 sentences max.", messages=[ {"role": "user", "content": "What is a generator in Python?"} ] ) # The response object β know its structure print(response.content[0].text) # the actual text response print(response.model) # which model responded print(response.stop_reason) # "end_turn" or "max_tokens" print(response.usage.input_tokens) # tokens you sent print(response.usage.output_tokens) # tokens in the response
yield to produce values one at a time, pausing execution between each value. Unlike a list, it doesn't compute all values upfront β it generates them lazily on demand, making it memory-efficient for large sequences.
model: claude-sonnet-4-5
stop_reason: end_turn
input_tokens: 32
output_tokens: 47The SDK has no built-in memory. You maintain conversation history yourself by appending messages and passing the full list each time.
import anthropic from dataclasses import dataclass, field @dataclass class Conversation: """Manages a stateful conversation with Claude.""" system: str = "You are a helpful assistant." model: str = "claude-sonnet-4-5" history: list = field(default_factory=list) _client: anthropic.Anthropic = field( default_factory=anthropic.Anthropic, init=False, repr=False ) def chat(self, message: str) -> str: # Add the user message to history self.history.append({"role": "user", "content": message}) response = self._client.messages.create( model=self.model, max_tokens=2048, system=self.system, messages=self.history, # send FULL history every time ) reply = response.content[0].text # Add Claude's reply to history for the next turn self.history.append({"role": "assistant", "content": reply}) return reply def reset(self) -> None: self.history.clear() def token_count(self) -> int: # Rough estimate: 4 chars per token total_chars = sum(len(m["content"]) for m in self.history) return total_chars // 4 # Usage conv = Conversation(system="You are a Python expert. Be concise.") print(conv.chat("What's the difference between a list and a tuple?")) print(conv.chat("When would I use one over the other?")) print(conv.chat("Give me a real example from a web API.")) # All three questions share context β Claude remembers the conversation
claude-opus-4-5
Most capable. Best reasoning, analysis, complex tasks. Highest cost. Use when quality matters most.
claude-sonnet-4-5
Best balance of capability and speed. The everyday workhorse. Use for most production tasks.
claude-haiku-4-5
Fastest and cheapest. Great for high-volume simple tasks β classification, extraction, short answers.
The OpenAI SDK
OpenAI's SDK is structurally similar to Anthropic's β same concepts, slightly different syntax. Learn both so you can switch providers or compare outputs.
pip install openai
from openai import OpenAI from dotenv import load_dotenv load_dotenv() # Reads OPENAI_API_KEY from environment automatically client = OpenAI() response = client.chat.completions.create( model="gpt-4o", max_tokens=1024, messages=[ {"role": "system", "content": "You are a concise Python tutor."}, {"role": "user", "content": "What is a generator?"} ] ) # Different attribute path than Anthropic text = response.choices[0].message.content reason = response.choices[0].finish_reason # "stop" or "length" tokens = response.usage.total_tokens
In production, you often want to switch providers without rewriting all your calling code. A thin abstraction layer makes this trivial.
from dataclasses import dataclass from enum import Enum from typing import Literal class Provider(Enum): ANTHROPIC = "anthropic" OPENAI = "openai" def chat( prompt: str, system: str = "You are a helpful assistant.", provider: Provider = Provider.ANTHROPIC, model: str = None, max_tokens: int = 1024, temperature: float = 0.7, ) -> str: """Single function to call any LLM provider.""" if provider == Provider.ANTHROPIC: import anthropic model = model or "claude-sonnet-4-5" client = anthropic.Anthropic() response = client.messages.create( model=model, max_tokens=max_tokens, system=system, messages=[{"role": "user", "content": prompt}] ) return response.content[0].text elif provider == Provider.OPENAI: from openai import OpenAI model = model or "gpt-4o" client = OpenAI() response = client.chat.completions.create( model=model, max_tokens=max_tokens, temperature=temperature, messages=[ {"role": "system", "content": system}, {"role": "user", "content": prompt} ] ) return response.choices[0].message.content # Use the same function regardless of provider result = chat("Explain list comprehensions", provider=Provider.ANTHROPIC) result = chat("Explain list comprehensions", provider=Provider.OPENAI) # Swap the provider line to compare quality and cost
Streaming Responses
Waiting 10 seconds for a complete response makes your app feel broken. Streaming delivers tokens as they're generated β users see output immediately, just like Claude.ai itself.
Without streaming: your code sends a request, waits (could be 5β30 seconds for long responses), then gets everything at once. To a user, the app looks frozen.
With streaming: the first token arrives in ~200ms. The user sees text appearing immediately, reads as it generates, and the interaction feels alive and responsive β even if the total generation time is the same.
import anthropic client = anthropic.Anthropic() # ββ Method 1: stream context manager (recommended) ββ with client.messages.stream( model="claude-sonnet-4-5", max_tokens=1024, messages=[{"role": "user", "content": "Write a haiku about Python."}] ) as stream: for text in stream.text_stream: print(text, end="", flush=True) # print each token as it arrives # After the loop, get final usage stats message = stream.get_final_message() print(f"\n\nTokens: {message.usage.input_tokens} in, {message.usage.output_tokens} out") # ββ Method 2: collect the full streamed text ββ with client.messages.stream( model="claude-sonnet-4-5", max_tokens=2048, system="You are a code reviewer.", messages=[{"role": "user", "content": "Review this function: def add(a,b): return a+b"}] ) as stream: full_text = stream.get_final_text() # waits for completion, returns string print(full_text)
Combining FastAPI's StreamingResponse with an LLM stream gives you a real-time API β your frontend receives tokens as they're generated, exactly like ChatGPT's interface.
from fastapi import FastAPI from fastapi.responses import StreamingResponse from pydantic import BaseModel import anthropic app = FastAPI() client = anthropic.Anthropic() class ChatRequest(BaseModel): message: str system: str = "You are a helpful assistant." def token_generator(message: str, system: str): """Generator that yields tokens from Claude as they arrive.""" with client.messages.stream( model="claude-sonnet-4-5", max_tokens=2048, system=system, messages=[{"role": "user", "content": message}] ) as stream: for text in stream.text_stream: yield text # yield each token to the HTTP response @app.post("/chat/stream") def stream_chat(req: ChatRequest): return StreamingResponse( token_generator(req.message, req.system), media_type="text/plain" ) # The client receives a stream of text chunks: # "A" β " list" β " comprehension" β " is" β " a" β " concise" β ... # In a React frontend: fetch with ReadableStream to display token-by-token
media_type="text/event-stream" with SSE format (data: {token}\n\n). This is the standard that browsers and clients like EventSource support natively. Plain text streaming works for testing but SSE is more robust.Prompt Engineering in Code
Building prompts dynamically β templates, few-shot examples, chain-of-thought, and structured output extraction. The difference between a toy demo and a production AI feature.
from string import Template from pathlib import Path # ββ Simple f-string templates ββ def summarize_prompt(text: str, max_words: int = 100) -> str: return f"""Summarize the following text in {max_words} words or fewer. Focus on the key points. Use plain language. TEXT: {text} SUMMARY:""" def classify_prompt(text: str, categories: list[str]) -> str: cats = ", ".join(categories) return f"""Classify the following text into exactly one of these categories: {cats} Respond with ONLY the category name. No explanation. TEXT: {text} CATEGORY:""" # ββ Few-shot template β examples teach the format ββ def extract_tasks_prompt(text: str) -> str: return f"""Extract action items from the text as a JSON array. Each item has: "task" (string), "owner" (string or null), "due" (date string or null). EXAMPLE INPUT: "John needs to finish the report by Friday. Sarah will review it next week." EXAMPLE OUTPUT: [ {{"task": "Finish the report", "owner": "John", "due": "Friday"}}, {{"task": "Review the report", "owner": "Sarah", "due": "next week"}} ] INPUT: {text} OUTPUT:""" # Usage import anthropic, json client = anthropic.Anthropic() def run_prompt(prompt: str, system: str = "You are a helpful assistant.", temperature: float = 0) -> str: response = client.messages.create( model="claude-sonnet-4-5", max_tokens=1024, temperature=temperature, system=system, messages=[{"role": "user", "content": prompt}] ) return response.content[0].text # Extract tasks from a meeting transcript transcript = "Jude needs to push the new feature by Thursday. The team will demo it on Friday." result = run_prompt(extract_tasks_prompt(transcript)) tasks = json.loads(result) print(tasks) # [{"task": "Push the new feature", "owner": "Jude", "due": "Thursday"}, ...]
For complex reasoning tasks, asking the model to "think step by step" dramatically improves accuracy. This is called chain-of-thought prompting.
def analyze_code_prompt(code: str) -> str: return f"""Analyze the following Python code for bugs and improvements. Think through this step by step: 1. First, understand what the code is trying to do 2. Identify any bugs or errors 3. Note any performance issues 4. Suggest improvements to readability or Pythonic style 5. Give a final summary with a priority-ordered list of changes CODE: ```python {code} ``` Begin your analysis:""" # Chain-of-thought works because the model "reasons aloud" before answering # This forces it to consider each aspect rather than jumping to a conclusion # Use it for: code review, debugging, complex decisions, data analysis # ββ Structured output β ask for JSON explicitly ββ def extract_structured(text: str) -> dict: prompt = f"""Extract information from the text below. Respond with ONLY valid JSON. No markdown, no explanation, no backticks. Required fields: - "name": string - "email": string or null - "phone": string or null - "company": string or null TEXT: {text}""" response = run_prompt(prompt, temperature=0) # Strip any accidental markdown code fences clean = response.strip().removeprefix("```json").removeprefix("```").removesuffix("```") return json.loads(clean)
Tool Use & Function Calling
Tool use lets the model call your Python functions. Instead of just generating text, Claude can search the web, query your database, run calculations, or interact with any API β and incorporate the results into its response.
The flow has three steps: you define tools β the model decides to use one β you run the function and send the result back.
# Step 1: You define available tools as JSON schemas tools = [{ "name": "get_weather", "description": "Get the current weather for a city.", "input_schema": { "type": "object", "properties": { "city": {"type": "string", "description": "City name"}, }, "required": ["city"] } }] # Step 2: The model returns a tool_use block instead of text # response.content[0].type == "tool_use" # response.content[0].name == "get_weather" # response.content[0].input == {"city": "Bossier City"} # Step 3: You run the function and send the result back # The model uses the result to form its final response
import anthropic, json, requests client = anthropic.Anthropic() # ββ Define your actual Python functions ββ def get_weather(city: str) -> dict: # Open-Meteo: free, no API key needed geo = requests.get( "https://geocoding-api.open-meteo.com/v1/search", params={"name": city, "count": 1}, timeout=5 ).json() lat = geo["results"][0]["latitude"] lon = geo["results"][0]["longitude"] weather = requests.get( "https://api.open-meteo.com/v1/forecast", params={"latitude": lat, "longitude": lon, "current": "temperature_2m,wind_speed_10m"}, timeout=5 ).json() current = weather["current"] return {"city": city, "temp_c": current["temperature_2m"], "wind_kmh": current["wind_speed_10m"]} def calculate(expression: str) -> dict: try: result = eval(expression, {"__builtins__": {}}) return {"expression": expression, "result": result} except Exception as e: return {"error": str(e)} # ββ Tool registry β maps tool names to functions ββ TOOLS_REGISTRY = { "get_weather": get_weather, "calculate": calculate, } # ββ Tool schemas β what the model needs to know ββ TOOLS = [ { "name": "get_weather", "description": "Get current temperature and wind speed for a city.", "input_schema": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"] } }, { "name": "calculate", "description": "Evaluate a mathematical expression. Returns the numeric result.", "input_schema": { "type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"] } } ] def run_agent(user_message: str) -> str: """Run the full tool use loop until the model gives a final response.""" messages = [{"role": "user", "content": user_message}] while True: response = client.messages.create( model="claude-sonnet-4-5", max_tokens=1024, tools=TOOLS, messages=messages ) # If the model is done, return the text response if response.stop_reason == "end_turn": return response.content[0].text # The model wants to use a tool if response.stop_reason == "tool_use": # Add model's response (including tool use request) to history messages.append({"role": "assistant", "content": response.content}) # Process each tool call tool_results = [] for block in response.content: if block.type == "tool_use": func = TOOLS_REGISTRY[block.name] result = func(**block.input) print(f"π§ Called {block.name}({block.input}) β {result}") tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result) }) # Send the tool results back to the model messages.append({"role": "user", "content": tool_results}) # Try it answer = run_agent("What's the weather in Bossier City, and what's 15% of 87?") print(answer) # π§ Called get_weather({'city': 'Bossier City'}) β {'city': 'Bossier City', 'temp_c': 32.1, ...} # π§ Called calculate({'expression': '0.15 * 87'}) β {'expression': '0.15 * 87', 'result': 13.05} # "In Bossier City it's currently 32.1Β°C with wind at 14.2 km/h. 15% of 87 is 13.05."
RAG β Retrieval Augmented Generation
RAG makes models answer questions about your own data β documents, databases, codebases β without fine-tuning. It's the most practical AI pattern for real products.
The problem: LLMs are trained on public data up to a cutoff date. They know nothing about your company's documents, your codebase, your users' history, or anything private.
The naive solution: dump all your documents into the context window. This works for small amounts of data but becomes expensive and slow for large datasets.
RAG: when a question arrives, search your documents for the most relevant chunks first, then send only those chunks to the model. The model answers based on retrieved context.
Four stages that happen in sequence
- Ingest: load documents, split into chunks (500β1000 tokens each), generate an embedding vector for each chunk, store vectors in a vector database.
- Query: user asks a question β generate an embedding for the question β find the N most similar chunk vectors (semantic search) β retrieve those chunks.
- Augment: build a prompt that includes the retrieved chunks as context.
- Generate: send the augmented prompt to the LLM β it answers based on the provided context, not just training data.
For small datasets (<1000 documents), you don't need a full vector database. Use OpenAI or Anthropic embeddings with a simple cosine similarity search in Python.
pip install numpy # for cosine similarity
import anthropic, json, numpy as np from pathlib import Path client = anthropic.Anthropic() # ββ Step 1: Chunk your documents ββ def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]: """Split text into overlapping chunks.""" words = text.split() chunks = [] for i in range(0, len(words), chunk_size - overlap): chunk = " ".join(words[i : i + chunk_size]) if chunk: chunks.append(chunk) return chunks # ββ Step 2: Generate embeddings ββ def get_embedding(text: str) -> list[float]: """Get embedding vector for a text string using OpenAI.""" from openai import OpenAI oai = OpenAI() response = oai.embeddings.create( model="text-embedding-3-small", input=text ) return response.data[0].embedding # ββ Step 3: Build the index ββ def build_index(documents: list[str]) -> dict: """Chunk documents and compute embeddings for each chunk.""" all_chunks = [] all_embeddings = [] for doc in documents: chunks = chunk_text(doc) for chunk in chunks: all_chunks.append(chunk) all_embeddings.append(get_embedding(chunk)) print(f"Indexed chunk {len(all_chunks)}") return {"chunks": all_chunks, "embeddings": np.array(all_embeddings)} # ββ Step 4: Semantic search ββ def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float: return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)) def search(query: str, index: dict, top_k: int = 3) -> list[str]: """Find the most relevant chunks for a query.""" query_emb = np.array(get_embedding(query)) scores = [ cosine_similarity(query_emb, emb) for emb in index["embeddings"] ] top_indices = np.argsort(scores)[:-top_k-1:-1] return [index["chunks"][i] for i in top_indices] # ββ Step 5: RAG query ββ def rag_query(question: str, index: dict) -> str: """Retrieve relevant chunks and ask Claude to answer.""" chunks = search(question, index, top_k=3) context = "\n\n---\n\n".join(chunks) prompt = f"""Answer the question using ONLY the provided context. If the answer is not in the context, say "I don't have that information." CONTEXT: {context} QUESTION: {question} ANSWER:""" response = client.messages.create( model="claude-sonnet-4-5", max_tokens=1024, messages=[{"role": "user", "content": prompt}] ) return response.content[0].text # ββ Put it all together ββ docs = [ Path("docs/readme.md").read_text(), Path("docs/api_guide.md").read_text(), ] index = build_index(docs) answer = rag_query("How do I authenticate with the API?", index) print(answer)
Cost, Rate Limits & Reliability
AI APIs fail in ways regular APIs don't β rate limits, timeouts, flapping responses, unexpected costs. Handle all of it like a professional before it bites you in production.
LLM APIs charge per token β both input and output. Output tokens typically cost more than input. A single poorly designed prompt can cost 100x more than a well-designed one.
import anthropic client = anthropic.Anthropic() # ββ Count tokens BEFORE sending (saves money during development) ββ response = client.messages.count_tokens( model="claude-sonnet-4-5", system="You are a helpful assistant.", messages=[{"role": "user", "content": "Summarize the history of Python."}] ) print(f"Input tokens: {response.input_tokens}") # ββ Cost tracker ββ # claude-sonnet-4-5 pricing (check anthropic.com for current rates) INPUT_COST_PER_1K = 0.003 # $0.003 per 1K input tokens OUTPUT_COST_PER_1K = 0.015 # $0.015 per 1K output tokens def estimate_cost(input_tokens: int, output_tokens: int) -> float: return ( (input_tokens / 1000) * INPUT_COST_PER_1K + (output_tokens / 1000) * OUTPUT_COST_PER_1K ) # Track costs across your app total_cost = 0.0 def tracked_message(**kwargs) -> anthropic.types.Message: global total_cost response = client.messages.create(**kwargs) cost = estimate_cost( response.usage.input_tokens, response.usage.output_tokens ) total_cost += cost print(f"[cost] ${cost:.5f} | total: ${total_cost:.4f}") return response
import anthropic, time, logging from functools import wraps log = logging.getLogger(__name__) def with_retry(max_retries: int = 3, base_delay: float = 1.0): """Decorator for retrying LLM calls with exponential backoff.""" def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except anthropic.RateLimitError as e: # Rate limited β wait and retry wait = base_delay * (2 ** attempt) log.warning(f"Rate limited. Waiting {wait}s (attempt {attempt+1}/{max_retries})") time.sleep(wait) except anthropic.APIStatusError as e: if e.status_code >= 500: # Server error β retry wait = base_delay * (2 ** attempt) log.warning(f"Server error {e.status_code}. Retry in {wait}s") time.sleep(wait) else: # Client error (400, 401, 403) β don't retry log.error(f"Client error {e.status_code}: {e.message}") raise except anthropic.APIConnectionError: # Network issue β retry wait = base_delay * (2 ** attempt) log.warning(f"Connection error. Retry in {wait}s") time.sleep(wait) raise RuntimeError(f"API call failed after {max_retries} attempts") return wrapper return decorator @with_retry(max_retries=3) def safe_chat(message: str) -> str: client = anthropic.Anthropic() response = client.messages.create( model="claude-sonnet-4-5", max_tokens=1024, messages=[{"role": "user", "content": message}] ) return response.content[0].text
| Concern | Solution |
|---|---|
| Rate limits | Exponential backoff retry decorator |
| Runaway costs | Set max_tokens always, count tokens before dev calls |
| Unexpected outputs | Validate/parse response before using it, handle JSON parse failures |
| Slow responses | Streaming for user-facing calls, async for batch processing |
| Context length exceeded | Track conversation token count, summarize or trim history |
| Prompt injection | Sanitize user input, never interpolate raw user text into privileged system prompts |
| No observability | Log every call: model, tokens, latency, cost, truncated prompt |
Command Center β AI Layer
Add a full AI layer to the Command Center from Phase 3. Natural language task creation, smart summaries, document Q&A, and a tool-using assistant β all wired into the CLI and the FastAPI backend from Phase 5.
β Natural language task creation β
cc ai add "remind jude to finish the api by thursday" extracts title, due date, priority and creates the task
β Daily summary β
cc ai summary sends pending tasks to Claude and gets a prioritized plain-English summary
β Smart complete β
cc ai complete "finish the api" uses fuzzy natural language matching to find and complete the right task
β Tool-using assistant β
cc ai ask "what's the weather and do I have anything due today?" uses tool use to answer with real data
β Document Q&A β
cc ai ask-docs "how do I deploy this?" runs RAG over your project's markdown files
β Streaming output β long responses stream to the terminal token-by-token
β Cost tracking β every AI call logs estimated cost, running total shown at end of session
β Retry logic β all API calls use the
@with_retry decorator
β Prompts in templates β no inline prompt strings in business logic
After the baseline works, push further:
- Add a
cc ai weekly-reviewcommand that analyzes completion patterns and suggests what to tackle next week - Add a conversation mode β
cc ai chatdrops into an interactive REPL where you can have a back-and-forth with context about your tasks - Add a FastAPI endpoint
POST /ai/chatthat accepts a message and streams the response using Server-Sent Events - Add a RAG index over your task history β "what tasks did I complete last month related to Python?"
- Add cost budget enforcement β if daily AI spend exceeds $0.50, stop making calls and log a warning