DEV Community

Said Olano
Said Olano

Posted on

Building Production-Ready AI Agents with Spring AI and LangChain: A Practical Guide

Building Production-Ready AI Agents with Spring AI and LangChain: A Practical Guide

Introduction

The convergence of large language models (LLMs) and microservices architecture presents unprecedented opportunities for building intelligent, autonomous systems. However, moving from a chatbot proof-of-concept to a production-grade AI agent requires deep understanding of resilience patterns, tool integration, memory management, and observability.

In this comprehensive guide, we'll explore how to architect and implement production-ready AI agents using Spring AI and LangChain within the Spring Boot ecosystem. Whether you're building customer service agents, autonomous data analysts, or intelligent document processors, the patterns and implementations we'll discuss form the foundation of enterprise-grade AI systems.

What We'll Cover

This guide spans the complete lifecycle of AI agent development:

  1. Architecture foundations – Understanding the difference between chatbots, assistants, and autonomous agents
  2. Spring AI integration – Leveraging Spring's abstraction layers for LLM interactions
  3. Tool calling and function execution – Enabling agents to interact with external systems
  4. Memory and context management – Implementing stateful agents that learn from interactions
  5. Error handling and resilience – Building fault-tolerant systems that gracefully degrade
  6. Observability and monitoring – Instrumenting agents for production visibility
  7. Deployment patterns – Scaling AI agents in containerized environments

Part 1: Understanding AI Agents vs Chatbots

The Spectrum of AI Interaction

Before diving into implementation, let's clarify terminology that's often conflated in the industry:

Chatbots (Reactive):

  • Respond to user input only
  • No persistent state beyond the current conversation
  • Execute pre-defined workflows
  • Example: Customer support FAQ bots

Assistants (Semi-Autonomous):

  • Maintain conversation history
  • Can retrieve contextual information
  • Guided by prompts but follow scripted paths
  • Example: Personal productivity assistants (scheduling, email)

Agents (Fully Autonomous):

  • Make independent decisions about which actions to take
  • Dynamically select from available tools
  • Iterate through planning → execution → observation cycles
  • Handle novel situations outside their training
  • Example: Customer issue resolution agents that can refund orders, escalate to specialists, or adjust billing

Why This Matters for Architecture

The distinction isn't purely semantic—it fundamentally changes how you architect these systems:

Aspect Chatbot Assistant Agent
Decision Logic Hardcoded rules Prompt-guided LLM-driven reasoning
Tool Selection Fixed pipeline Limited choices Dynamic from toolset
State Management Stateless or simple cache Session storage Complex memory structures
Error Recovery Predefined escalation Retry logic Self-correction loops
Latency SLA <500ms <2s <10s (with retries)
Observability Simple logging Structured traces Full reasoning chains

Most business use cases actually require agent-level capabilities for production value, but the added complexity justifies careful architectural consideration.


Part 2: Spring AI Architecture and Core Concepts

The Spring AI Abstraction Model

Spring AI provides a vendor-agnostic abstraction layer over LLM providers. This separation of concerns mirrors Spring's philosophy across data access, messaging, and cloud services:

┌─────────────────────────────────────────────────────┐
│         Application Layer (Your Code)                │
├─────────────────────────────────────────────────────┤
│  ChatClient / AiClient (Spring AI Abstractions)    │
├─────────────────────────────────────────────────────┤
│  Provider-Specific Implementations                  │
│  (OpenAI, Anthropic, Ollama, etc.)                  │
├─────────────────────────────────────────────────────┤
│  LLM Provider APIs (REST/gRPC)                      │
└─────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key Spring AI Components

ChatClient:
The primary interface for interacting with LLMs. It handles prompt construction, tool binding, and response parsing.

@Service
public class CustomerSupportAgent {

    private final ChatClient chatClient;

    @Autowired
    public CustomerSupportAgent(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder
            .defaultOptions(options())
            .defaultFunctions("tools")
            .build();
    }

    public String resolveCustomerIssue(String customerMessage) {
        return chatClient.prompt()
            .user(customerMessage)
            .functions("getOrderHistory", "processRefund", "createTicket")
            .call()
            .content();
    }

    private ChatClientRequestOptions options() {
        return ChatClientRequestOptions.builder()
            .withTemperature(0.3)
            .withTopP(0.9)
            .build();
    }
}
Enter fullscreen mode Exit fullscreen mode

ToolContext:
Registers the actual Java methods that become available to the LLM as callable tools.

@Service
public class CustomerServiceTools {

    @Tool(description = "Retrieve customer order history")
    public List<Order> getOrderHistory(
        @ToolParam(description = "Customer ID") String customerId) {
        // Query your order database
        return orderService.findByCustomerId(customerId);
    }

    @Tool(description = "Process a refund for an order")
    public RefundResult processRefund(
        @ToolParam(description = "Order ID") String orderId,
        @ToolParam(description = "Refund reason") String reason) {
        // Execute refund logic
        return orderService.refund(orderId, reason);
    }

    @Tool(description = "Create a support ticket for escalation")
    public Ticket createTicket(
        @ToolParam(description = "Customer ID") String customerId,
        @ToolParam(description = "Issue description") String description) {
        // Create ticket in your support system
        return ticketService.create(customerId, description);
    }
}
Enter fullscreen mode Exit fullscreen mode

Integration with LangChain

LangChain provides the orchestration layer that Spring AI abstracts. When you need advanced patterns (complex chains, recursive reasoning, memory strategies), you can integrate LangChain components:

@Service
public class AdvancedReasoningAgent {

    private final ChatModel chatModel;
    private final ToolProvider toolProvider;

    public String executeComplexTask(String query) {
        // Build a ReAct-style loop
        var tools = toolProvider.getAvailableTools();
        var toolDescriptions = formatToolsForPrompt(tools);

        var systemPrompt = """
            You are an expert problem solver. When faced with a complex task:
            1. Break it into sub-goals
            2. Select appropriate tools for each sub-goal
            3. Execute tools and observe results
            4. Adapt your approach based on observations
            5. Report your final solution

            Available tools:
            %s
            """.formatted(toolDescriptions);

        var messages = List.of(
            new SystemMessage(systemPrompt),
            new UserMessage(query)
        );

        return executionLoop(messages, tools);
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 3: Implementing Tool Calling and Function Execution

Tool Definition Strategy

Effective tool design is critical to agent success. Tools that are poorly specified lead to:

  • Incorrect tool selection by the LLM
  • Failed executions due to misunderstood parameters
  • Cascading errors in multi-step workflows

Rule 1: Specificity
Each tool should have a single, well-defined responsibility:

// ❌ TOO BROAD
@Tool(description = "Manage order")
public OrderResult manageOrder(String action, String orderId, Map<String, Object> params) {
    // This tool does too much!
}

// ✅ FOCUSED
@Tool(description = "Retrieve the complete order details including items, pricing, and shipping status")
public OrderDetails getOrderDetails(
    @ToolParam(description = "The unique order ID (format: ORD-XXXXXX)") String orderId) {
    return orderService.getOrderById(orderId);
}

@Tool(description = "Cancel an order and initiate a refund. Only works for orders in PENDING or CONFIRMED status.")
public CancelResult cancelOrder(
    @ToolParam(description = "The order ID to cancel") String orderId,
    @ToolParam(description = "Reason for cancellation") String reason) {
    return orderService.cancel(orderId, reason);
}
Enter fullscreen mode Exit fullscreen mode

Rule 2: Clear Parameter Contracts
Include validation and type information inline:

@Tool(description = "Process a payment for an invoice")
public PaymentResult processPayment(
    @ToolParam(description = "Invoice ID (format: INV-YYMM-XXXXX)", 
               defaultValue = "") String invoiceId,
    @ToolParam(description = "Payment amount in USD, must be >= 0.01", 
               defaultValue = "0") BigDecimal amount,
    @ToolParam(description = "Payment method: CREDIT_CARD, BANK_TRANSFER, ACH", 
               defaultValue = "CREDIT_CARD") String method) {

    validateInvoiceId(invoiceId);
    validateAmount(amount);
    validatePaymentMethod(method);

    return paymentService.process(invoiceId, amount, method);
}
Enter fullscreen mode Exit fullscreen mode

Rule 3: Atomic Operations
Tools should be atomic—they succeed or fail completely without partial states:

// ❌ RISKY: Multiple database operations, failure mid-way leaves inconsistent state
@Tool
public void updateCustomerAndCreateTicket(String customerId, Map<String, Object> updates) {
    customerService.update(customerId, updates);
    ticketService.create(customerId, "Auto-update initiated");
}

// ✅ SAFE: Single responsibility, transaction boundary clear
@Transactional
@Tool
public void updateCustomerStatus(
    @ToolParam String customerId,
    @ToolParam String newStatus) {
    Customer customer = customerService.getById(customerId);
    customer.setStatus(newStatus);
    customerService.save(customer);
}
Enter fullscreen mode Exit fullscreen mode

Tool Execution Error Handling

The agent needs to understand why a tool failed to make intelligent recovery decisions:

@Service
public class ResilientToolExecution {

    @Tool(description = "Transfer funds between accounts")
    public TransferResult transferFunds(
        @ToolParam String fromAccount,
        @ToolParam String toAccount,
        @ToolParam BigDecimal amount) {

        try {
            return accountService.transfer(fromAccount, toAccount, amount);
        } catch (InsufficientFundsException e) {
            // Provide specific error that agent can reason about
            throw new ToolExecutionException(
                "Transfer failed: insufficient funds. Current balance is: " + 
                accountService.getBalance(fromAccount),
                "INSUFFICIENT_FUNDS",
                Map.of("requiredAmount", amount, 
                       "availableBalance", accountService.getBalance(fromAccount)),
                e
            );
        } catch (AccountLockedException e) {
            throw new ToolExecutionException(
                "Cannot transfer: account is locked. Contact support at support@company.com",
                "ACCOUNT_LOCKED",
                Map.of("lockedUntil", e.getLockExpirationTime()),
                e
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 4: Memory and Conversation State Management

Conversation Context vs. Long-Term Memory

Production agents need to distinguish between:

  1. Conversation context (short-term): Current interaction details
  2. Long-term memory (persistent): Historical patterns, preferences, learned facts
@Service
public class StatefulAgent {

    private final ChatClient chatClient;
    private final ConversationStore conversationStore;
    private final MemoryService memoryService;

    public String processUserMessage(String userId, String message) {
        // Load conversation context
        var conversation = conversationStore.getConversation(userId);

        // Load relevant long-term memory
        var relevantMemory = memoryService.getRelevantMemory(userId, message);

        // Build prompt with both contexts
        var systemPrompt = buildSystemPrompt(conversation, relevantMemory);

        var response = chatClient.prompt()
            .system(systemPrompt)
            .user(message)
            .call()
            .content();

        // Update conversation context
        conversation.addExchange(message, response);
        conversationStore.save(conversation);

        // Learn from this interaction
        memoryService.learn(userId, message, response);

        return response;
    }

    private String buildSystemPrompt(Conversation context, List<MemoryItem> memory) {
        return """
            You are an intelligent agent.

            Recent conversation:
            %s

            Known facts about this user:
            %s

            Use this context to provide personalized, coherent responses.
            """.formatted(formatConversation(context), formatMemory(memory));
    }
}
Enter fullscreen mode Exit fullscreen mode

Vector Databases for Semantic Memory

For agents working with large document sets, semantic search enables intelligent retrieval:

@Service
public class SemanticMemoryAgent {

    private final ChatClient chatClient;
    private final VectorStore vectorStore;

    public String answerDocumentQuestion(String question) {
        // Find semantically similar document chunks
        var results = vectorStore.search(question, 5);

        var context = results.stream()
            .map(doc -> doc.getContent())
            .collect(joining("\n\n"));

        var systemPrompt = """
            Use the following documents to answer the question.
            If the documents don't contain relevant information, say so.

            Documents:
            %s
            """.formatted(context);

        return chatClient.prompt()
            .system(systemPrompt)
            .user(question)
            .call()
            .content();
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 5: Error Handling and Resilience Patterns

Circuit Breaker for Tool Invocations

External tools (APIs, databases, services) can fail. Protect your agent:

@Service
public class ResilientAgent {

    private final CircuitBreaker circuitBreaker;
    private final ChatClient chatClient;

    @Bean
    public CircuitBreaker toolCircuitBreaker() {
        return CircuitBreaker.of("toolExecution",
            CircuitBreakerConfig.custom()
                .failureThreshold(5)
                .waitDurationInOpenState(Duration.ofSeconds(30))
                .recordExceptions(ToolException.class)
                .ignoreExceptions(ToolValidationException.class)
                .build());
    }

    public String executeAgentWithFallback(String userQuery) {
        try {
            return Resilience4j.executeSupplier(circuitBreaker, () ->
                chatClient.prompt()
                    .user(userQuery)
                    .call()
                    .content()
            );
        } catch (CallNotPermittedException e) {
            // Circuit open: use degraded mode
            return "I'm experiencing temporary issues. Can you try again in a moment?";
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Retry Strategy with Exponential Backoff

@Service
public class RetryableAgent {

    private final Retry retry;
    private final ChatClient chatClient;

    @Bean
    public Retry agentRetry() {
        return Retry.of("agent",
            RetryConfig.custom()
                .maxAttempts(3)
                .intervalFunction(IntervalFunction.ofExponentialBackoff(
                    1000, // initial interval ms
                    2,    // multiplier
                    1000  // max interval ms
                ))
                .recordExceptions(TemporaryFailureException.class)
                .build());
    }

    public String executeWithRetry(String query) {
        return Resilience4j.executeSupplier(retry, () ->
            chatClient.prompt()
                .user(query)
                .call()
                .content()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 6: Observability and Monitoring

Structured Logging for Agent Decisions

@Service
public class ObservableAgent {

    private static final Logger logger = LoggerFactory.getLogger(ObservableAgent.class);
    private final ChatClient chatClient;

    public String resolveIssue(String userId, String issue) {
        var traceId = UUID.randomUUID().toString();

        logger.info("Agent invoked", Map.of(
            "traceId", traceId,
            "userId", userId,
            "issueCategory", categorizeIssue(issue)
        ));

        try {
            var response = chatClient.prompt()
                .user(issue)
                .call()
                .content();

            logger.info("Agent completed", Map.of(
                "traceId", traceId,
                "status", "SUCCESS",
                "responseLength", response.length()
            ));

            return response;
        } catch (Exception e) {
            logger.error("Agent failed", Map.of(
                "traceId", traceId,
                "error", e.getMessage(),
                "errorType", e.getClass().getSimpleName()
            ), e);

            throw new AgentException("Failed to resolve issue", e);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Tracing Tool Execution

@Aspect
@Component
public class ToolExecutionTracing {

    private final MeterRegistry meterRegistry;

    @Around("@annotation(com.example.Tool)")
    public Object traceToolExecution(ProceedingJoinPoint joinPoint) throws Throwable {
        var toolName = joinPoint.getSignature().getName();
        var startTime = System.nanoTime();

        try {
            var result = joinPoint.proceed();
            var duration = System.nanoTime() - startTime;

            meterRegistry.timer("agent.tool.execution", 
                "tool", toolName, 
                "status", "success")
                .record(duration, TimeUnit.NANOSECONDS);

            return result;
        } catch (Exception e) {
            var duration = System.nanoTime() - startTime;

            meterRegistry.counter("agent.tool.errors",
                "tool", toolName,
                "errorType", e.getClass().getSimpleName())
                .increment();

            throw e;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 7: Deployment and Scaling

Docker-Based Agent Deployment

FROM eclipse-temurin:21-jdk-alpine

WORKDIR /app

COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
COPY src src

RUN ./mvnw clean package -DskipTests -q

FROM eclipse-temurin:21-jre-alpine
RUN apk add --no-cache dumb-init

WORKDIR /app
COPY --from=0 /app/target/*.jar app.jar

# Use dumb-init to properly handle signals
ENTRYPOINT ["dumb-init", "--"]
CMD ["java", "-Xmx512m", "-Xms256m", "-XX:+UseG1GC", "-jar", "app.jar"]
Enter fullscreen mode Exit fullscreen mode

Kubernetes Deployment with Horizontal Scaling

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ai-agent
spec:
  replicas: 3
  selector:
    matchLabels:
      app: ai-agent
  template:
    metadata:
      labels:
        app: ai-agent
    spec:
      containers:
      - name: agent
        image: myregistry/ai-agent:latest
        resources:
          requests:
            memory: "512Mi"
            cpu: "500m"
          limits:
            memory: "1Gi"
            cpu: "1000m"
        livenessProbe:
          httpGet:
            path: /actuator/health
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5
Enter fullscreen mode Exit fullscreen mode

Conclusion and Best Practices Roadmap

The Path to Production AI Agents

Building production-ready AI agents is not simply about integration—it's about thoughtful architecture that balances:

  • Autonomy vs. Control: Agents need freedom to make decisions, but within guardrails
  • Speed vs. Safety: Fast response times often conflict with thorough verification
  • Generalization vs. Reliability: Flexible agents are harder to test than narrow chatbots
  • Cost vs. Quality: Larger models give better reasoning but higher inference costs

Recommended Implementation Sequence

  1. Phase 1: Start with a single, well-defined use case (e.g., order cancellation)
  2. Phase 2: Add conversation memory and context management
  3. Phase 3: Introduce multiple, carefully specified tools
  4. Phase 4: Implement observability and monitoring
  5. Phase 5: Add resilience patterns (circuit breakers, retries)
  6. Phase 6: Expand to multiple use cases and deploy at scale

Key Takeaways

  • Use Spring AI abstractions to decouple from specific LLM providers
  • Design tools to be atomic, specific, and independently testable
  • Distinguish between conversation context and long-term memory
  • Implement observability from day one—agent behavior is complex
  • Protect your system with resilience patterns
  • Start small, measure carefully, scale gradually

The future of enterprise software involves AI agents seamlessly integrated into business processes. By following these patterns and maintaining discipline around quality and observability, your team can build systems that deliver genuine value while remaining maintainable and trustworthy.


About the Author: Building AI systems at scale for over 5 years. Specialized in Spring Boot microservices, LLM integration, and production resilience patterns.

Next Steps: Share your agent architecture challenges in the comments. What patterns have worked for you?

Top comments (0)