Building AI apps
Shipping an AI feature is shipping a system with a non-deterministic component, a per-request bill, and an external dependency that can degrade without an outage. Plan for all three from day one: evals gate changes, cost is tracked per request, and every AI feature has an off switch.
Build vs buy, per layer: framework, evals, observability, prompt management. Own only what differentiates your product.
Token optimization
Prompt caching
Caching reuses a stable prompt prefix across requests. Cached reads cost about a tenth of normal input tokens. Cache writes cost about a quarter more. It pays off after a couple of requests with the same prefix.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const response = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 16000,
cache_control: { type: "ephemeral" }, // cache the stable prefix
system: longSystemPrompt, // keep this byte-for-byte stable
messages: [{ role: "user", content: userQuery }],
});
console.log(response.usage.cache_creation_input_tokens); // written to cache
console.log(response.usage.cache_read_input_tokens); // served from cacheCaching is a prefix match. Any byte change invalidates everything after it. Short prefixes (below a model-dependent minimum) don't cache at all.
| Do | Don't |
|---|---|
| Static content first (system prompt, tools, examples) | Dynamic content at the start |
| Keep the system prompt stable | Put timestamps or request IDs in it |
| Deterministic tool order | Build the tool list differently per request |
Check cache_read_input_tokens | Assume it's working |
If cache reads stay at zero across repeated requests, something in the prefix is changing. Find it.
Model routing
Route by task, not by habit. Most traffic doesn't need the most capable model.
// Keep model IDs in config so upgrades are one change
const MODELS = {
fast: "claude-haiku-4-5", // classification, extraction, summaries
default: "claude-sonnet-5", // most product traffic
deep: "claude-opus-5", // hard reasoning, agentic work
};
function selectModel(task: "classify" | "answer" | "agent"): string {
if (task === "classify") return MODELS.fast;
if (task === "agent") return MODELS.deep;
return MODELS.default;
}Before building a cascade of models, measure the simpler option: one strong model at lower effort. Newer models at low effort often match older ones at high effort, and one model means one cache.
Routing strategies:
| Strategy | When |
|---|---|
| Task-based | Different request types have clearly different difficulty |
| Cost-based | Low-stakes, high-volume traffic |
| Latency-based | Real-time UX vs batch jobs |
| Capability-based | Vision, long context, tool use |
Track cost per request and per user from the first release. Model spend grows with success, and finance should not learn about it from the invoice.
Agent frameworks
| Framework | Best for | Complexity |
|---|---|---|
| Claude Agent SDK | Agents that work like Claude Code: files, shell, subagents | Medium |
| Anthropic SDK tool runner | Custom-tool agents without writing the loop | Simple |
| LangGraph | Stateful workflows with branching and human checkpoints | Advanced |
| LangChain | Chains, RAG, document processing | Medium |
| Inngest AgentKit | Agents inside background jobs and queues | Simple |
Start with the thinnest layer that works. A framework is a dependency your team has to understand, upgrade, and debug. Reach for one when it clearly saves work, not by default.
Claude Agent SDK: Claude Code's harness as a library, with built-in tools (read, edit, bash, search), context management, hooks, subagents, and permissions.
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const message of query({
prompt: "Refactor the auth module. Done when tests pass.",
options: { allowedTools: ["Read", "Edit", "Bash"] },
})) {
if (message.type === "result") console.log(message.result);
}bun add @anthropic-ai/claude-agent-sdk # TypeScript
pip install claude-agent-sdk # PythonDocs: https://code.claude.com/docs/en/agent-sdk
LangGraph fits when the workflow is a graph with state: conditional branches on model output, human-in-the-loop checkpoints, and resumable runs.
LangChain fits when you want its loaders, splitters, and integrations for RAG and document processing.
Tools for AI
Follow the provider's tool spec. Keep tool descriptions short and precise. They are prompts too. Design tools narrow and least-privilege (see Tool Design Best Practices).
Prompt management
Prompts are code. Version them, review changes, and don't hardcode them where only a deploy can change them. Hosted tools (LangSmith, LangFuse, Vellum) add versioning and A/B testing. Every prompt change goes through evals, like a code change goes through tests.
Observability
Trace every model call: inputs, outputs, latency, tokens, cost, and the user feedback it got. PostHog LLM tracing is the simple option. LangSmith and LangFuse go further. Collect thumbs-up and thumbs-down ratings from day one. They are the cheapest eval data you'll ever get.
Eval (testing AI)
Evals are tests for AI behavior, and they gate every prompt and model change.
What to test: correctness, groundedness, PII leakage, hallucination, toxicity, and tone. Tools: LangFuse, LangSmith, PromptFoo.
LangFuse (open source): traces, datasets, prompt versioning, feedback.
# Self-hosted with its docker compose file, or use https://cloud.langfuse.com
docker compose up -dLangSmith: automated eval runs, human labeling, prompt regression tests, production monitoring.
Eval types:
| Type | What it tests | Example |
|---|---|---|
| Factuality | Correct information | Product facts match the catalog |
| Groundedness | Answers come from context | Only cites provided docs |
| Relevance | Answers the question asked | Addresses the actual query |
| Toxicity | Harmful content | No abuse or threats |
| PII leakage | Exposes private data | No card numbers or IDs in output |
| Hallucination | Made-up information | No invented citations or products |
Example eval setup:
from langfuse import Langfuse
langfuse = Langfuse()
dataset = langfuse.create_dataset("customer-support-eval")
dataset.create_item(
input={"query": "What's your refund policy?"},
expected_output="We offer 30-day refunds...",
)
for item in dataset.items:
response = my_llm_app(item.input)
langfuse.score(
trace_id=response.trace_id,
name="correctness",
value=1 if matches_expected(response, item.expected_output) else 0,
)Where eval data comes from: real traffic (with PII scrubbed), past incidents, and adversarial cases your team writes. Synthetic data fills gaps. It doesn't replace real examples.
Fine tuning
Avoid it unless you've exhausted prompting, retrieval, and model choice, and have the budget to redo it. A fine-tuned model can't move to next quarter's better base model without repeating the work.
Example prompts for building AI apps:
Tools:
"Create a read-only tool for querying our Postgres database. Anthropic tool spec,
JSON results, table schema in the description, parameterized queries only."
RAG:
"Implement docs search: Docling chunks, Voyage embeddings, PGVector with HNSW,
top 5 chunks with sources, ACL filter per user."
Evals:
"Eval suite for the support bot: product facts against the catalog, no PII leakage
with fake customer data, refuses questions about products we don't sell."Plan for degradation. Model outages, rate limits, and quietly worse answers all happen. Every AI feature needs a fallback path and a kill switch.