How to use LlamaIndex Workflows for open source

open-source入门27 分钟阅读2026/7/11

Last month, I was building a customer support agent that needed to do three things: look up order details from a database, check a knowledge base for troubleshooting steps, and decide whether to escalate to a human. I started with a standard chain-based approach, wiring the steps together in a linear sequence. It worked fine for the happy path. But the moment I needed the agent to loop back—like when a customer's first troubleshooting step didn't work and we needed to try a second approach—the whole thing fell apart. Directed acyclic graphs (DAGs) are great for pipelines, but they're a nightmare for agents that need to reason, branch, and loop.

That's when I stumbled onto LlamaIndex Workflows. Unlike traditional graph-based orchestration where you explicitly wire nodes together, Workflows use an event-driven architecture. Steps emit and receive events, and the framework figures out what runs next based on those events. It sounded like exactly what I needed, so I spent a weekend rebuilding my support agent with it. Here's what I learned.

The Core Concept: Steps and Events

Workflows are built on two fundamental pieces: Steps and Events. A Step is a function decorated with @step that does some work and emits events. Events are typed data classes that carry information between steps. The magic is that steps don't need to know about each other—they just know which events they accept and which they produce.

This is a huge shift from graph-based approaches. In a DAG, you manually define edges: "Node A connects to Node B." In an event-driven workflow, Step A emits a LookupResultEvent, and any step that accepts LookupResultEvent as input will automatically receive it. This makes branching and looping natural instead of forced.

Getting Set Up

First, install the required packages. You'll need LlamaIndex core and the workflow utilities:

pip install llama-index-core>=0.11.16 llama-index-utils-workflow

I also recommend installing the workflow rendering utility—it generates an HTML visualization of your workflow, which is incredibly helpful for debugging:

pip install llama-index-utils-workflow

For the LLM, I'll use OpenAI here since it's the default, but you can swap in any provider. Just set your API key:

import os
os.environ["OPENAI_API_KEY"] = "sk-your-key-here"

If you want to use a local or open-source model like Ollama, you can configure it through the Settings object:

from llama_index.core import Settings
from llama_index.llms.ollama import Ollama

Settings.llm = Ollama(model="llama3", request_timeout=120.0)

Building a Tool-Calling Agent

Let me walk through the actual agent I built. I wanted a workflow that could:

  1. Take a user query
  2. Decide if it needs to look up order info or search the knowledge base
  3. Execute the appropriate tool
  4. Evaluate the result and either respond or loop back for more action

Step 1: Define Your Events

Events are the backbone of the workflow. Each event carries specific data that steps need to pass around. Here's what I defined:

from llama_index.core.workflow import Event

class UserQueryEvent(Event):
    query: str

class ToolCallEvent(Event):
    tool_name: str
    tool_args: dict

class ToolResultEvent(Event):
    tool_name: str
    result: str

class ResponseEvent(Event):
    response: str

The typing here matters a lot. Workflows uses these types to route events to the correct steps. I initially made the mistake of using generic dictionaries instead of typed events, and the routing completely broke.

Step 2: Define Your Tools

I created two simple tools—one for order lookup and one for knowledge base search:

from llama_index.core.tools import FunctionTool

def lookup_order(order_id: str) -> str:
    """Look up order details by order ID."""
    # In reality, this hits a database
    orders = {
        "ORD-123": "Order shipped on Jan 15, tracking: 1Z999AA10123456784",
        "ORD-456": "Order processing, estimated ship date: Jan 20",
    }
    return orders.get(order_id, "Order not found")

def search_knowledge_base(query: str) -> str:
    """Search the knowledge base for troubleshooting steps."""
    # In reality, this queries a vector store
    kb = {
        "reset password": "Go to Settings > Account > Reset Password",
        "wifi connection": "1. Restart router 2. Forget network 3. Reconnect",
    }
    for key, value in kb.items():
        if key in query.lower():
            return value
    return "No relevant articles found"

order_tool = FunctionTool.from_defaults(fn=lookup_order)
kb_tool = FunctionTool.from_defaults(fn=search_knowledge_base)

Step 3: Build the Workflow

Now for the main event. A workflow class groups your steps together and manages shared context:

from llama_index.core.workflow import Workflow, Context, StartEvent, StopEvent, step
from llama_index.core.llms import LLM

class SupportAgentWorkflow(Workflow):
    def __init__(self, llm: LLM, **kwargs):
        super().__init__(**kwargs)
        self.llm = llm

    @step
    async def decide_tool(self, ctx: Context, ev: StartEvent | UserQueryEvent) -> ToolCallEvent | ResponseEvent:
        """Decide which tool to call based on the query."""
        query = ev.get("query", "")
        
        # Store query in context for later use
        await ctx.set("query", query)
        
        # Ask the LLM which tool to use
        prompt = f"""You are a support agent. Given this user query, decide what to do.

Available tools:
- lookup_order: Look up order details (requires order_id)
- search_knowledge_base: Search for troubleshooting steps (requires query)

User query: {query}

Respond with either:
- TOOL: lookup_order|order_id_value
- TOOL: search_knowledge_base|query_value
- RESPONSE: your direct response if no tool is needed"""

        response = await self.llm.acomplete(prompt)
        text = str(response).strip()
        
        if text.startswith("TOOL:"):
            parts = text.split("|", 1)
            tool_name = parts[0].replace("TOOL:", "").strip()
            tool_arg_value = parts[1].strip() if len(parts) > 1 else ""
            
            if tool_name == "lookup_order":
                return ToolCallEvent(tool_name=tool_name, tool_args={"order_id": tool_arg_value})
            elif tool_name == "search_knowledge_base":
                return ToolCallEvent(tool_name=tool_name, tool_args={"query": tool_arg_value})
        
        elif text.startswith("RESPONSE:"):
            return ResponseEvent(response=text.replace("RESPONSE:", "").strip())
        
        # Fallback
        return ResponseEvent(response="I'm not sure how to help with that. Could you rephrase?")

    @step
    async def execute_tool(self, ctx: Context, ev: ToolCallEvent) -> ToolResultEvent:
        """Execute the selected tool."""
        if ev.tool_name == "lookup_order":
            result = lookup_order(**ev.tool_args)
        elif ev.tool_name == "search_knowledge_base":
            result = search_knowledge_base(**ev.tool_args)
        else:
            result = "Unknown tool"
        
        return ToolResultEvent(tool_name=ev.tool_name, result=result)

    @step
    async def evaluate_result(self, ctx: Context, ev: ToolResultEvent) -> ResponseEvent | UserQueryEvent:
        """Evaluate tool result and decide if we need more action."""
        query = await ctx.get("query")
        
        prompt = f"""You are a support agent. Based on the tool result, decide if you can answer the user's query.

User query: {query}
Tool used: {ev.tool_name}
Tool result: {ev.result}

If the result is sufficient, respond with RESPONSE: followed by your answer.
If you need to try another tool or approach, respond with RETRY: followed by a refined query."""

        response = await self.llm.acomplete(prompt)
        text = str(response).strip()
        
        if text.startswith("RESPONSE:"):
            return ResponseEvent(response=text.replace("RESPONSE:", "").strip())
        elif text.startswith("RETRY:"):
            new_query = text.replace("RETRY:", "").strip()
            return UserQueryEvent(query=new_query)
        
        return ResponseEvent(response=ev.result)

    @step
    async def format_response(self, ctx: Context, ev: ResponseEvent) -> StopEvent:
        """Format and return the final response."""
        return StopEvent(result=ev.response)

There's a lot happening here, so let me break down the key parts:

  • StartEvent and StopEvent are built-in event types. StartEvent kicks off the workflow, and StopEvent ends it.
  • The | operator in type hints is crucial. When a step can return multiple event types, the framework uses these type annotations to route events correctly. decide_tool can return either a ToolCallEvent or a ResponseEvent, and the framework sends each to the right step.
  • Context is a shared state object. I use it to store the original query so later steps can access it. This is way cleaner than threading state through every event.
  • The loop happens in evaluate_result. If the LLM decides the tool result isn't good enough, it emits a UserQueryEvent, which routes back to decide_tool. This is the cyclical behavior that was so painful with DAGs.

Step 4: Run It

from llama_index.llms.openai import OpenAI

llm = OpenAI(model="gpt-4o-mini")
workflow = SupportAgentWorkflow(llm=llm, verbose=True)

result = await workflow.run(query="Where is my order ORD-123?")
print(result)
# Output: "Your order ORD-123 was shipped on January 15th. 
#          Your tracking number is 1Z999AA10123456784."

Setting verbose=True prints out each step as it executes, which was invaluable for debugging. You can see exactly which events are being emitted and which steps are receiving them.

Visualizing the Workflow

One of my favorite features is the HTML rendering. After defining your workflow, you can generate a visual diagram:

from llama_index.utils.workflow import draw_all_possible_flows

draw_all_possible_flows(workflow, filename="support_agent.html")

Open that HTML file in a browser and you get a clear diagram showing all possible paths through your workflow, including the loops. This saved me hours when I was trying to explain the flow to a colleague.

What Surprised Me

A few things caught me off guard during development:

Type hints are not optional. I initially tried to skip the union type hints on step return values, thinking the framework could infer routing. It can't. The entire routing mechanism depends on those type annotations. If you don't specify that decide_tool returns ToolCallEvent | ResponseEvent, the framework won't know where to send events.

Context is async-only. The ctx.set() and ctx.get() methods are coroutines. I forgot the await keyword multiple times and got cryptic errors. Always remember: await ctx.set("key", value).

Event naming matters for readability. When your workflow grows, you'll have many event types. I started with generic names like StepOneEvent and quickly lost track. Switching to descriptive names like ToolCallEvent and EvaluationNeededEvent made everything much clearer.

Practical Tips

  1. Start simple, then add loops. Build a straight-through path first (query → tool → response), verify it works, then add the cyclical logic. Debugging loops in a workflow that doesn't even work linearly is a special kind of misery.

  2. Use verbose=True during development. The console output shows every event emission and step execution. Turn it off in production for performance.

  3. Keep events small. Events should carry only the data needed for the next step. I initially stuffed entire conversation histories into events, which made the workflow slow and hard to debug. Use the Context object for shared state instead.

  4. Test steps in isolation. Since steps are just async functions, you can test them independently by creating the right event and calling the step directly. This is much easier than running the entire workflow for every test.

Honest Limitations

Workflows aren't perfect for everything. For simple linear pipelines (ingest → chunk documents → embed → store), a traditional pipeline is simpler and more straightforward. The event-driven overhead only pays off when you have branching, looping, or conditional logic.

The documentation is still maturing. I ran into a few edge cases—like how to handle multiple steps that emit the same event type—that required digging into the source code. The community is active though, and the LlamaIndex Discord has been helpful.

Also, be aware that the looping capability means you need to guard against infinite loops. I added a retry counter in my context to cap the number of times evaluate_result can loop back. Without that, a confused LLM could spin forever.

Final Thoughts

LlamaIndex Workflows solved the exact problem I had: building an agent that needs to reason, act, and potentially loop back when things don't work out. The event-driven architecture feels natural for agentic systems in a way that DAGs never did. If you're building anything beyond a simple chain—especially systems with tool calling, multi-step reasoning, or human-in-the-loop patterns—Workflows is worth a serious look. Just remember to mind your type hints and guard your loops.

相关 Agent

O

OpenClaw

开源 AI Agent 框架,用于构建自主工作流

了解更多 →