name: langgraph-agent-architecture
description: Build LangGraph agents: StateGraph with Annotation.Root, multi-tier orchestration (main/sub-agents), node patterns (pure/async/injectable), routers (intent/mode/error-aware/confidence), error-resilient nodes, streaming with async generators, batch processing, LLM fallbacks. Use when designing or implementing AI agents.
LangGraph Agent Architecture Patterns
Architectural patterns for LangGraph-based AI Agent Systems.
Core Architecture Pattern: LangGraph StateGraph
import { StateGraph, Annotation, START, END } from '@langchain/langgraph';
export const StateAnnotation = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: (x, y) => (x || []).concat(y || []),
}),
});
const graph = new StateGraph(StateAnnotation);
graph.addNode('node_name', async (state) => {
return { field: value };
});
graph.addEdge('node1', 'node2');
graph.addConditionalEdges('router', (state) => nextNode);
graph.addEdge(START, 'first_node');
graph.addEdge('last_node', END);
const compiledGraph = graph.compile();
Key Principles
- Immutable State Updates: Nodes return partial state, LangGraph merges
- Declarative Flow: Graph topology declared upfront, not in node logic
- Type Safety: TypeScript types generated from
Annotation.Root
- Reducer-Based Updates: Custom reducers for complex state merging
- Streaming Support: Native support for async generators
Multi-Tier Agent Architecture
Three-Tier Orchestration
Main Agent (Tier 1) - Orchestrator
├── Sub-Agent A (Tier 2) - Specialized Domain
│ └── Sub-Sub-Agent (Tier 3) - Pipeline
└── Sub-Agent B (Tier 2) - Specialized Domain
Tier 1: Main Agent (Orchestrator)
Responsibilities:
- High-level orchestration
- User intent routing
- Sub-agent coordination
- Response aggregation
- Error escalation
export class AgentService {
async *executeAgent(input: AgentInput): AsyncGenerator<StreamEvent> {
const initialState: Partial<OverallState> = {
messages: [new HumanMessage(input.userMessage)],
userId: input.userId,
mode: input.mode,
};
const stream = await this.agentGraph.stream(initialState);
for await (const event of stream) {
yield this.formatStreamEvent(event);
}
}
}
Tier 2: Sub-Agents (Specialized)
Characteristics:
- Domain-specific expertise
- Independent state management
- Can be invoked standalone or from main agent
- Return structured output to parent
async function researchNode(state: OverallState): Promise<Partial<OverallState>> {
const subAgentInput = {
query: state.currentQuery,
mode: state.mode,
userId: state.userId,
};
const output = await subAgentService.execute(subAgentInput);
return {
researchResults: output.research,
researchConfidence: output.confidence,
};
}
Communication Patterns
- Parent to Child: Convert parent state to child input, call child service
- Child to Parent: Return structured output, parent maps to parent state
- No Direct Sub-Agent Communication: Always through parent orchestrator
Node Development Patterns
Pattern 1: Pure Node Function
Use for simple transformations, no async operations.
function transformNode(state: State): Partial<State> {
return {
processedData: state.rawData.map(transform),
};
}
Pattern 2: Async Node with Service
Use for API calls, database queries, LLM invocations.
async function llmNode(state: State): Promise<Partial<State>> {
const response = await llmService.generate({
prompt: state.prompt,
temperature: 0.2,
});
return { llmResponse: response.content };
}
Pattern 3: Injectable Node (NestJS)
Use for production agents with dependency injection.
export class MyAgentGraph {
constructor(
private readonly llmService: LLMService,
private readonly embeddings: EmbeddingsService,
) {}
private createGraph(): StateGraph {
const graph = new StateGraph(StateAnnotation);
graph.addNode('node_name', this.nodeMethod.bind(this));
return graph.compile();
}
private async nodeMethod(state: State): Promise<Partial<State>> {
const result = await this.llmService.generate(...);
return { result };
}
}
Pattern 4: Error-Resilient Node
Use for production nodes that must not crash graph.
async function resilientNode(state: State): Promise<Partial<State>> {
const startTime = Date.now();
try {
const result = await riskyOperation(state);
return { result, error: undefined };
} catch (error) {
logger.error('Node failed:', error);
return {
error: {
nodeName: 'resilient_node',
message: error.message,
timestamp: Date.now(),
executionTime: Date.now() - startTime,
recoverable: isRecoverableError(error),
},
};
}
}
Pattern 5: Batch Processing Node
Use for processing multiple items with LLM rate limits.
async function batchNode(state: State): Promise<Partial<State>> {
const items = state.items;
const batchSize = 10;
const results = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await llmService.processBatch(batch);
results.push(...batchResults);
if (state.streaming) {
yield { partialResults: results };
}
}
return { results };
}
Router Patterns
Pattern 1: Intent-Based Router (Router-First)
Use for user intent classification before processing.
function routerNode(state: OverallState): string {
const lastMessage = state.messages[state.messages.length - 1];
if (containsResearchIntent(lastMessage)) return 'research';
if (containsContentCreationIntent(lastMessage)) return 'create_content';
if (containsQuestionIntent(lastMessage)) return 'answer_question';
return 'clarify';
}
graph.addConditionalEdges('router', routerNode, {
research: 'research_node',
create_content: 'content_node',
answer_question: 'qa_node',
clarify: 'clarify_node',
});
Pattern 2: Mode-Based Router
Use for different execution paths based on mode parameter.
function modeRouter(state: AriadneState): string {
return state.mode === 'fast' ? 'fast_path' : 'deep_path';
}
Pattern 3: Error-Aware Router
Use for conditional routing with error handling.
function routeWithErrorCheck(nextNode: string) {
return (state: State): string => {
if (state.error) return 'error_handler';
return nextNode;
};
}
Pattern 4: Confidence-Based Router
Use for quality-driven conditional flow.
function qualityRouter(state: MnemonaState): string {
const quality = state.currentQuality;
const iteration = state.iterationCount;
if (quality >= state.qualityThreshold) return 'finalize';
if (iteration >= state.maxIterations) return 'escalate';
return 'refine';
}
Error Handling Patterns
Error State in Annotation
export const StateAnnotation = Annotation.Root({
error: Annotation<ErrorState | undefined>({
reducer: (x, y) => y || x,
}),
});
type ErrorState = {
nodeName: string;
message: string;
timestamp: number;
executionTime: number;
recoverable: boolean;
};
Node Error Factory
function createNodeErrorResponse(error: Error, nodeName: string, executionTime: number, message: string): Partial<State> {
logger.error(`[${nodeName}] ${message}:`, error);
return {
error: {
nodeName,
message: error.message,
timestamp: Date.now(),
executionTime,
recoverable: isRecoverableError(error),
},
};
}
Error Handler Node
async function errorHandlerNode(state: State): Promise<Partial<State>> {
const error = state.error;
if (!error) return {};
if (error.recoverable) {
const recovered = await attemptRecovery(state, error);
if (recovered) {
return { error: undefined, recoveryAttempted: true };
}
}
return {
messages: [
new AIMessage({
content: `I encountered an error: ${error.message}. Please try again.`,
}),
],
};
}
Fallback Chain
async function rerankWithFallback(sources: Source[], config: RerankConfig, attempt: number = 0): Promise<Source[]> {
try {
return await rerankService.rerank(sources, config);
} catch (error) {
if (attempt >= 3) {
logger.error('All rerank attempts failed, returning original order');
return sources;
}
if (attempt === 0) config.provider = 'alternative_provider';
if (attempt === 1) config.threshold = Math.max(0, config.threshold - 0.1);
if (attempt === 2) delete config.threshold;
return rerankWithFallback(sources, config, attempt + 1);
}
}
Streaming Patterns
Async Generator Streaming
export class AgentService {
async *executeAgent(input: AgentInput): AsyncGenerator<StreamEvent> {
const stream = await this.agentGraph.stream(initialState);
for await (const event of stream) {
yield {
type: 'node_update',
nodeName: event.node,
data: event.state,
timestamp: Date.now(),
};
}
yield { type: 'complete', data: finalState, timestamp: Date.now() };
}
}
Event Types
type StreamEvent = { type: 'start'; data: { mode: string; userId: number } } | { type: 'node_start'; nodeName: string; timestamp: number } | { type: 'node_complete'; nodeName: string; duration: number } | { type: 'partial_result'; data: any } | { type: 'error'; error: ErrorState } | { type: 'complete'; data: FinalOutput; timestamp: number };
LLM Integration Patterns
Model Selection by Task
const MODEL_ASSIGNMENTS = {
simple_classification: { model: 'gemini-1.5-flash', temperature: 0.1 },
content_generation: { model: 'gemini-2.0-flash', temperature: 0.2 },
quality_assessment: { model: 'gemini-1.5-pro', temperature: 0.1 },
brainstorming: { model: 'gpt-4o', temperature: 0.7 },
};
Structured Output with Zod
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
const OutputSchema = z.object({
facts: z.array(
z.object({
claim: z.string(),
evidence: z.array(z.string()),
confidence: z.number().min(0).max(1),
}),
),
summary: z.string(),
});
async function llmNodeWithStructuredOutput(state: State): Promise<Partial<State>> {
const response = await llmService.generate({
prompt: state.prompt,
responseFormat: {
type: 'json_schema',
schema: zodToJsonSchema(OutputSchema),
},
});
const parsed = OutputSchema.parse(JSON.parse(response.content));
return { structuredOutput: parsed };
}
Multi-Provider Fallback
async function llmWithFallback(prompt: string): Promise<string> {
const providers = ['gemini', 'openai', 'claude'];
for (const provider of providers) {
try {
const response = await llmService.generate({ prompt, provider, timeout: 30000 });
return response.content;
} catch (error) {
logger.warn(`Provider ${provider} failed:`, error);
}
}
throw new Error('All LLM providers failed');
}
Performance Optimization
Parallel Node Execution
graph.addEdge('start', 'node1');
graph.addEdge('start', 'node2');
graph.addConditionalEdges('node1', () => 'merge');
graph.addConditionalEdges('node2', () => 'merge');
Early Termination
function shouldContinue(state: State): string {
if (state.qualityScore >= state.threshold) return 'finalize';
if (state.iterationCount >= state.maxIterations) return 'finalize';
return 'refine';
}
Caching
private cache = new Map<string, CachedResult>();
async expensiveNode(state: State): Promise<Partial<State>> {
const cacheKey = this.getCacheKey(state);
if (this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey);
if (Date.now() - cached.timestamp < 300000) {
return { result: cached.result, cached: true };
}
}
const result = await performExpensiveOperation(state);
this.cache.set(cacheKey, { result, timestamp: Date.now() });
return { result, cached: false };
}
Common Anti-Patterns to Avoid
Anti-Pattern 1: Throwing Errors in Nodes
async function badNode(state: State) {
throw new Error('Operation failed');
}
async function goodNode(state: State) {
try {
const result = await riskyOperation();
return { result, error: undefined };
} catch (error) {
return { error: { nodeName: 'good_node', message: error.message, recoverable: true } };
}
}
Anti-Pattern 2: Mutating State
function badNode(state: State) {
state.items.push(newItem);
return { items: state.items };
}
function goodNode(state: State) {
return { items: [...state.items, newItem] };
}
Anti-Pattern 3: Complex Logic in Routers
function badRouter(state: State) {
}
async function analysisNode(state: State) {
const analysis = await performComplexAnalysis(state);
return { analysis, nextNode: determineNextNode(analysis) };
}
function goodRouter(state: State) {
return state.nextNode || 'default';
}
Anti-Pattern 4: Ignoring Error States
Always check for state.error and route to error handlers.
Testing Patterns
Unit Test Individual Nodes
describe('factExtractionNode', () => {
it('should extract facts from context', async () => {
const mockState = {
chunks: [{ text: 'Paris is the capital of France.' }],
targetFactCount: 5,
};
const result = await factExtractionNode(mockState as State);
expect(result.facts).toHaveLength(1);
expect(result.facts[0].claim).toContain('Paris');
});
});
Integration Test Graph Flow
describe('ResearchGraph', () => {
it('should complete fast mode research', async () => {
const input = { query: 'What is LangGraph?', mode: 'fast', userId: 1 };
const events = [];
for await (const event of agentService.executeResearch(input)) {
events.push(event);
}
const finalEvent = events[events.length - 1];
expect(finalEvent.type).toBe('complete');
}, 60000);
});
Best Practices Summary
Design Phase
- Define State First with Annotation.Root
- Draw Graph Topology before coding
- Plan Error Paths upfront
- Consider Streaming requirements
Implementation Phase
- Use TypeScript for full type safety
- Test Nodes Independently
- Log at node boundaries
- Never throw, return error state
- Track execution times
Production Phase
- Monitor latency, success rate, error rate per node
- Cache expensive operations
- Profile and optimize slow nodes
- Version control state for migrations