Building Agents with LCEL and Tool Integration

The LangChain Expression Language (LCEL) transforms how we build LLM workflows. Instead of managing execution flow manually, LCEL lets you compose components declaratively—like Unix pipes for AI. Combined with tool integration, LCEL enables building agents that reason and act in the real world.

The Philosophy Behind LCEL

Before LCEL, building LLM workflows meant manually orchestrating each step. You’d invoke a prompt template, pass the result to a model, then parse the output—all with explicit variable handling and error checking at each stage. This approach works but creates verbose, error-prone code that’s difficult to modify.

LCEL introduces a declarative paradigm borrowed from functional programming and Unix shell philosophy. The pipe operator (|) connects components, with each stage automatically receiving the previous stage’s output. This isn’t just syntactic sugar—it enables powerful features like automatic batching, streaming, and async execution.

1
2
3
4
5
6
7
8
# Traditional approach - explicit orchestration
prompt_result = prompt.invoke({"topic": "machine learning"})
llm_result = llm.invoke(prompt_result)
final_result = parser.invoke(llm_result)

# LCEL approach - declarative composition
chain = prompt | llm | parser
result = chain.invoke({"topic": "machine learning"})

The LCEL version isn’t just shorter—it’s semantically richer. The chain object understands its complete structure. It can introspect inputs and outputs at each stage, optimize execution, and handle errors consistently.

The Runnable Protocol: A Universal Interface

Every component in LangChain implements the Runnable interface. This design decision, inspired by the Gang of Four’s Strategy pattern, enables polymorphism across wildly different components. A prompt template and a language model share the same interface despite doing completely different things.

graph LR
    subgraph "Runnable Interface"
        A[invoke] --> B[Single Input → Output]
        C[batch] --> D[Multiple Inputs → Outputs]
        E[stream] --> F[Chunked Output]
    end

    classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff

    class A,C,E blueClass
Method Purpose When to Use
invoke() Process single input synchronously Default for most operations
batch() Process multiple inputs in parallel Bulk operations, evaluations
stream() Output token-by-token Real-time chat interfaces
ainvoke() Async single input Web servers, non-blocking I/O

The Runnable interface also provides introspection capabilities. You can examine input_schema and output_schema to understand what data flows through each component—invaluable for debugging and documentation.

Composition Patterns in LCEL

LCEL provides several composition primitives that cover most workflow patterns you’ll encounter.

Sequential Chains: The Pipe Operator

The simplest pattern chains components end-to-end. Each component receives the previous one’s output as input.

1
2
3
4
5
6
7
8
9
10
11
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

summarize_chain = (
ChatPromptTemplate.from_template(
"Summarize this article in 3 bullet points:\n\n{article}"
)
| ChatOpenAI(model="gpt-4o")
| StrOutputParser()
)

This chain expects a dictionary with an article key, produces a prompt, sends it to GPT-4o, and extracts the string content from the response. The entire pipeline is a single Runnable with invoke(), batch(), and stream() methods.

Parallel Execution: RunnableParallel

When you need multiple operations on the same input, RunnableParallel executes branches concurrently and collects results into a dictionary.

graph TD
    A[Input Text] --> B[RunnableParallel]
    B --> C[Summary Chain]
    B --> D[Sentiment Chain]
    B --> E[Keywords Chain]
    C --> F[Combined Output]
    D --> F
    E --> F

    classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
    classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
    classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff

    class A blueClass
    class B,C,D,E orangeClass
    class F greenClass

This pattern is particularly powerful when combined with batch mode. If you need to analyze 100 documents with summary, sentiment, and keyword extraction, RunnableParallel multiplies your throughput by running all three analyses concurrently for each document.

The performance implications are significant. Three sequential LLM calls might take 6 seconds total; parallel execution reduces this to roughly 2 seconds—the duration of the slowest individual call.

Conditional Routing: RunnableBranch

Real applications need dynamic behavior. RunnableBranch routes inputs to different chains based on conditions, enabling specialized handling for different input types.

Consider a customer service bot that needs different expertise for technical issues versus billing questions. Rather than one overloaded prompt, you create specialized chains and route based on classification:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from langchain_core.runnables import RunnableBranch

# Specialized chains for different query types
tech_chain = ChatPromptTemplate.from_template(
"As a technical support expert:\n{input}"
) | llm
billing_chain = ChatPromptTemplate.from_template(
"As a billing specialist:\n{input}"
) | llm
general_chain = ChatPromptTemplate.from_template(
"As a helpful assistant:\n{input}"
) | llm

# Route based on classification
router = RunnableBranch(
(lambda x: "technical" in x["category"], tech_chain),
(lambda x: "billing" in x["category"], billing_chain),
general_chain # Default fallback
)

This pattern keeps prompts focused and maintainable. Each chain can be tested independently, and adding new categories means adding new branches rather than modifying existing prompts.

Custom Logic: RunnableLambda

When you need Python logic that doesn’t fit existing components, RunnableLambda wraps any function as a Runnable. This is your escape hatch for custom transformations, validations, or side effects.

1
2
3
4
5
6
7
8
9
10
11
12
13
from langchain_core.runnables import RunnableLambda

def validate_and_format(text: str) -> dict:
"""Post-process LLM output with custom logic."""
lines = text.strip().split("\n")
return {
"title": lines[0] if lines else "",
"content": "\n".join(lines[1:]),
"word_count": len(text.split()),
"is_valid": len(text) > 100
}

chain = prompt | llm | StrOutputParser() | RunnableLambda(validate_and_format)

The wrapped function becomes a full Runnable with automatic batching and streaming support where applicable.

Tools: Giving LLMs Hands

Language models reason but can’t act. They can explain how to check the weather but can’t actually make an API call. Tools bridge this gap by defining actions the model can request.

The Anatomy of a Tool

A tool is fundamentally a function with rich metadata. The LLM sees the function’s name, description, and parameter schema—this information guides when and how to call the tool.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from langchain_core.tools import tool

@tool
def get_stock_price(symbol: str) -> dict:
"""Get the current stock price for a given ticker symbol.

Args:
symbol: The stock ticker symbol (e.g., 'AAPL', 'GOOGL')

Returns:
Dictionary with price, change, and volume information
"""
# Integration with financial API
return {"symbol": symbol, "price": 150.25, "change": 2.3}

Three elements matter for effective tool design:

  1. Name: Clear, action-oriented. get_stock_price not stock or gsp
  2. Description: Explains when to use the tool, not just what it does
  3. Parameter types: Guides the model on valid inputs

The docstring is particularly important—it’s the primary mechanism by which the LLM decides whether to use this tool for a given query.

The Tool Calling Protocol

Modern LLMs don’t output tool calls as text. Instead, they return structured objects indicating which tool to call with what arguments. This is more reliable than parsing natural language outputs.

sequenceDiagram
    participant User
    participant LLM
    participant Tool
    participant App

    User->>LLM: "What's Apple's stock price?"
    LLM->>App: tool_calls: [{name: "get_stock_price", args: {symbol: "AAPL"}}]
    App->>Tool: invoke(symbol="AAPL")
    Tool->>App: {price: 150.25, change: 2.3}
    App->>LLM: ToolMessage(content="{price: 150.25...}")
    LLM->>User: "Apple (AAPL) is trading at $150.25, up 2.3%"

The application orchestrates this dance: receiving tool requests from the LLM, executing them, and feeding results back. The LLM then decides whether to call more tools or provide a final answer.

Binding Tools to Models

To enable tool calling, you bind tools to the model. This modifies how the model processes inputs—it now considers whether tools might be helpful before generating a response.

1
2
3
4
5
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o")
tools = [get_stock_price, get_weather, calculate_compound_interest]
llm_with_tools = llm.bind_tools(tools)

The bound model returns AIMessage objects that may contain tool_calls—a list of requested tool invocations. Your application must execute these and return results as ToolMessage objects.

The ReAct Pattern: Reasoning and Acting

ReAct (Reason + Act) is an agent architecture that alternates between thinking and tool use. The model explicitly reasons about what it needs, decides on an action, observes the result, and continues reasoning.

graph TD
    A[User Query] --> B[Think]
    B --> C{Need Tool?}
    C -->|Yes| D[Select Tool]
    D --> E[Execute Tool]
    E --> F[Observe Result]
    F --> B
    C -->|No| G[Final Answer]

    classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
    classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
    classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff

    class A,G greenClass
    class B,C orangeClass
    class D,E,F blueClass

This loop continues until the model decides it has enough information to answer. The explicit reasoning traces make debugging easier—you can see exactly why the agent chose specific tools and how it interpreted results.

Why ReAct Works

ReAct’s power comes from chain-of-thought reasoning combined with grounded actions. By forcing the model to articulate its reasoning before acting, it makes fewer errors in tool selection. By observing real results rather than hallucinating, it grounds responses in facts.

Consider a query like “What’s the weather in the city where Apple headquarters is located?”. A pure LLM might confidently answer with outdated or incorrect weather data. A ReAct agent would:

  1. Think: “I need to find Apple’s headquarters location”
  2. Act: Search for Apple headquarters → Cupertino, CA
  3. Think: “Now I need the weather in Cupertino”
  4. Act: Get weather for Cupertino → 72°F, sunny
  5. Answer: “It’s 72°F and sunny in Cupertino, where Apple headquarters is located”

Each step is verifiable. If the answer is wrong, you can identify exactly where the reasoning failed.

Building a ReAct Agent

LangChain provides AgentExecutor to handle the ReAct loop automatically:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from langchain.agents import create_react_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate

agent_prompt = ChatPromptTemplate.from_messages([
("system", """You are a helpful assistant with tools.

When you need information you don't have, use appropriate tools.
Think step by step about what information you need.

Available tools: {tools}
Tool names: {tool_names}
"""),
("human", "{input}"),
("placeholder", "{agent_scratchpad}")
])

agent = create_react_agent(
llm=ChatOpenAI(model="gpt-4o"),
tools=tools,
prompt=agent_prompt
)

agent_executor = AgentExecutor(
agent=agent,
tools=tools,
verbose=True, # Shows reasoning steps
max_iterations=5 # Prevents infinite loops
)

The max_iterations parameter is crucial for production. Without it, a confused agent might loop indefinitely. The verbose flag reveals the agent’s reasoning during development.

The Limitations of LCEL for Agents

LCEL excels at static workflows—chains where the structure is known at design time. But agents pose challenges:

  1. Dynamic iteration: The number of tool calls isn’t known in advance
  2. Complex state: Beyond just passing output to input, agents need conversation history, intermediate results, and execution context
  3. Conditional loops: The same node (think) might be revisited many times

The tool loop we manually implemented earlier works but is brittle. What if you need:

  • Parallel tool execution
  • Human approval for certain actions
  • Persistent state across sessions
  • Debugging with state snapshots

These requirements push beyond LCEL’s design. This is precisely why LangGraph exists—it provides explicit graph structures for complex agent workflows. We’ll explore it in the next post.

Error Handling in Tool Execution

Tools interact with external systems that fail. APIs timeout, databases disconnect, rate limits kick in. Robust agents need graceful degradation.

LangChain tools support error handlers that transform exceptions into messages the LLM can understand:

1
2
3
4
5
6
7
8
9
10
11
from langchain_core.tools import tool, ToolException

@tool
def divide(a: float, b: float) -> float:
"""Divide two numbers."""
if b == 0:
raise ToolException("Cannot divide by zero - please provide a non-zero divisor")
return a / b

# Error message goes back to the LLM
divide.handle_tool_error = True

With error handling enabled, the exception message becomes a ToolMessage that the LLM can interpret and potentially work around—perhaps by asking the user for clarification.

Key Takeaways

  1. LCEL is declarative composition: The pipe operator creates readable chains that are more than syntax—they enable automatic optimization and introspection.

  2. Runnables are the universal interface: Every component shares invoke(), batch(), and stream(), making them interchangeable and composable.

  3. Composition patterns solve common needs: Sequential, parallel, conditional, and custom logic cover most workflow patterns without escaping to imperative code.

  4. Tools extend LLM capabilities to actions: Well-designed tools with clear descriptions let LLMs interact with APIs, databases, and external systems.

  5. ReAct combines reasoning with action: The think-act-observe loop grounds LLM responses in real data while maintaining explicit reasoning traces.

  6. LCEL has limits for dynamic agents: Complex state, conditional loops, and human-in-the-loop require more structure than LCEL provides—enter LangGraph.


Next: LangGraph: State Graphs for Agentic Workflows - We’ll explore LangGraph’s StateGraph for building complex agents with explicit state management, cycles, and production-ready control flow.

Mastering LangChain and LangGraph - A Practitioner's Guide LangGraph - State Graphs for Agentic Workflows

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×