As the AI industry started recognizing the reasoning capabilities of Large Language Models (LLMs), we decided to make them more agentic by allowing them to perform more complex actions. These categories of Large Language Models became known as agents.
So, single-agent systems were born. But it soon became apparent that assigning several actions to a single agent wasn’t the smartest way to build agentic systems. A better approach was to use multiple agents, each with its own task to perform, working together to achieve a goal defined by the user.
However, another problem arose. Managing multiple agents isn’t as easy as managing a single one. To solve this, several Multi-Agent System frameworks have been introduced, and one of the most promising is CrewAI.
CrewAI helps developers build Multi-Agent Systems. With CrewAI, you can manage your agents, their tasks, and their workflow. In this article, we’ll use CrewAI along with the plethora of LLMs available on Novita to build practical and intelligent Multi-Agent Systems.
What is a Multi-Agent System & Why Do We Need it?
Since the introduction of Large Language Models (LLMs), several approaches have been explored to make them more autonomous. One of the most popular was the ReAct pattern introduced in the paper Synergizing Reasoning and Acting in Language Models. Frameworks like LangChain quickly implemented ReAct to give LLMs agentic capabilities.
Tools like AutoGPT took things even further by building more robust AI agents that could perform almost any task the user desired with just a text prompt. While these single-agent systems showed great promise, they came with limitations. A single agent typically relies on one LLM to handle every task, which leads to a few critical drawbacks:
- Limited context: A single agent has to manage all the tasks using one context window. As the workflow progresses, the agent eventually runs out of context space.
- Increased complexity: As more tasks are handed to a single agent, reasoning becomes harder to manage, making the agent more prone to hallucinations or failure.
- Single point of failure: With one agent responsible for everything, there’s no backup. If it can’t handle a task, the system fails entirely.
- Lack of parallelization: Tasks are processed one after the other. There’s no built-in support for agents working in parallel to speed things up.
Multi-agent systems were introduced to address these challenges. Instead of relying on a single agent to handle multiple tasks, a multi-agent system distributes responsibilities across several agents working in collaboration. This helps avoid issues like exceeding the context length since each agent only needs to manage its own part of the workflow, and the overall context load is reduced.
Multi-agent setups also allow you to use different models for different purposes: some can be fine-tuned for specific tasks, some can come with predefined prompts, some might be equipped with specialized tools, and others can be optimized for reasoning. This diversity not only improves performance but also enables parallelization, making multi-agent systems faster and more efficient than their single-agent counterparts.
To build a multi-agent system, you need two core components: a model provider that supplies the LLMs, and a multi-agent framework to manage the entire agentic workflow. In this article, we’ll be using Novita as our model provider and CrewAI as our framework of choice.
Why You Need a Framework Like CrewAI
Building a multi-agent system is far more complex than building a single agent system. For example you can easily make any model on Novita become an agent by giving it tools with the use of function calling. But once you start connecting multiple agents, the complexity increases significantly. You now have to manage several moving parts, including:
- Communication and task delegation: You need to coordinate how agents communicate with each other and how tasks are handed off between them.
- Tool management: Without a framework, you’re responsible for managing tool definitions and JSON schemas manually, something that can quickly become messy.
- Agentic architectures and patterns: Multi-agent frameworks often come with built-in support for well-established agentic patterns. If you’re building from scratch, you’ll need to stay up to date with these evolving patterns and implement them yourself.
- Observability: Without a framework, you’re also responsible for setting up your own tools to monitor, debug, and visualize how your agents interact and perform.
A Multi-Agent framework can help manage all of these complexities. In this article, we’ll be using CrewAI as our framework of choice. CrewAI offers several advantages over other frameworks: it stands out for its simplicity, using intuitive concepts to model Multi-Agent Systems; it is mature and well-established; and it provides an excellent developer experience.
Understanding the Core Concepts of CrewAI
To build multi-agent systems with CrewAI, you need to be familiar with a few core concepts introduced by the framework. Here’s a brief overview of the main ones we’ll be working with in this article:
- Agents
- Tasks
- Crews
- Processes
- Flows
Agents
Agents are the core of CrewAI. They represent the actual AI workers powered by an LLM. Each agent in CrewAI is defined by a role, a goal that guides its decision-making, and a backstory that shapes its personality. These are all represented as text prompts that get sent to the LLM. Agents can also be equipped with tools, which they can choose from to perform specific actions. The main objective of an agent in CrewAI is to complete the task or tasks assigned to it.
Tasks
Tasks define the work that needs to be completed. In CrewAI, you can create tasks and either assign them directly to specific agents or let agents pick them up if they match the task’s requirements. Each task typically includes a clear description, an expected output, and other relevant parameters such as an optional agent who can handle the task.
Crew
This is the core concept of CrewAI. In CrewAI, a group of agents working together to achieve tasks is referred to as a crew. A crew also defines the workflow of the agents within it.
Processes
A process is a predefined workflow that tells a crew how to execute its tasks. CrewAI supports two main types of processes:
- Sequential: Tasks are handled one at a time by the agents in the crew.
- Hierarchical: In this process, one agent acts as a manager, coordinating other agents and delegating tasks as needed.
Flows
While processes are predefined workflows, CrewAI flows give developers the flexibility to design custom agent workflows. A flow can include agents, tasks, and even entire crews, allowing for more complex interactions.
Building Crews with Novita
Now that we’ve covered the core concepts of CrewAI, let’s walk through building a Crew. In this example, we’ll create a Crew designed to help users rapidly build a minimum viable product (MVP). This Crew consists of three specialized agents:
- Architect: Defines the software structure and outlines the MVP scope. This agent is powered by the moonshotai/kimi-k2-instruct model, hosted on Novita.
- Coder: Implements the Architect’s plan and writes the actual code. It uses a FileWriter tool to create all project files and runs on Novita’s qwen/qwen3-coder-480b-a35b-instruct model.
- Reviewer: Analyzes the code generated by the Coder and suggests improvements using diff-style feedback. Like the Architect, it also uses the moonshotai/kimi-k2-instruct model.
Our Crew will follow a sequential process.
Installation and Setup
Now that we have a plan in place, let’s begin by setting up our environment. We’ll start by installing CrewAI along with its built-in tools support:
pip install 'crewai[tools]'
Next, you’ll need your Novita API key. Once you have it, add it to your environment variables as NOVITA_API_KEY:
export NOVITA_API_KEY=your_api_key_here
With that done, we’re ready to start building the Crew.
Creating the LLMs
Let’s begin by importing the necessary dependencies:
import os from crewai import Agent, Task, Crew, Process, LLM from crewai_tools import FileWriterTool
Next, we’ll create an instance of the FileWriterTool, which will be used by the Coder agent to write files to disk:
file_writer_tool = FileWriterTool()
With the tools in place, we can now initialize the LLMs for each of our agents:
architect_llm = LLM( model="novita/moonshotai/kimi-k2-instruct", temperature=0.5, api_base="https://api.novita.ai/v3/openai", api_key=os.environ['NOVITA_API_KEY'] ) coder_llm = LLM( model="novita/qwen/qwen3-coder-480b-a35b-instruct", temperature=0.4, api_base="https://api.novita.ai/v3/openai", api_key=os.environ['NOVITA_API_KEY'] ) reviewer_llm = LLM( model="novita/moonshotai/kimi-k2-instruct", temperature=0.5, api_base="https://api.novita.ai/v3/openai", api_key=os.environ['NOVITA_API_KEY'] )
These three LLM instances correspond to the models outlined in our plan. They are hosted on Novita and accessed via CrewAI. Behind the scenes, CrewAI uses LiteLLM to connect with and manage these models.
Defining the Agents
Now let’s create the three agents:
architect = Agent( role="Software Architect for MVP Projects", goal="Define a basic system structure and simple feature set to guide MVP development", backstory="""You specialize in quickly outlining software architectures and project scopes for minimum viable products. Your plans help guide coders with enough structure to get started while staying lean.""", llm=architect_llm, verbose=True ) coder = Agent( role="Developer for MVP Projects", goal="Implement the MVP using simple, clear code, and write all necessary code files using the FileWriter tool", backstory="""You're a practical developer focused on speed. You prioritize working code over polish. Simulate any runtime behavior if needed, and make sure to keep code clear and modular.""", llm=coder_llm, verbose=True, tools=[file_writer_tool] ) reviewer = Agent( role="Code Reviewer for MVP Projects", goal="Read the code files, provide helpful feedback, and write improvements as diffs into a markdown file", backstory="""You're a fast but thoughtful reviewer. You check code for clarity, obvious bugs, and provide improvements as diffs. Instead of modifying code directly, you save your suggestions in a markdown file for easy inspection.""", llm=reviewer_llm, verbose=True, tools=[file_writer_tool] )
Each agent is assigned a specific role, goal, and backstory.
Defining the Tasks
Let’s create the agents tasks:
architect_task = Task(
description="""
Create a basic plan for building a simple MVP version of a {project}.
Focus on the core features, basic file structure, and tech stack.
Make the structure minimal but enough to get a working demo.
""",
expected_output="""
A simple architectural overview with:
- Project goals
- Key components or files
- Basic data flow or structure
""",
agent=architect,
output_file="architecture.md"
)
coder_task = Task(
description="""
Based on the architect's plan, implement the core parts of the {project} MVP.
Keep it lean and functional. Use the FileWriter tool to save all your code files.
If you need to simulate behavior (e.g. without a real interpreter), do so with clear comments or mocked logic.
""",
expected_output="""
Working code files saved using the FileWriter tool that implement the key features defined in the plan.
Code should be readable and logically structured.
""",
agent=coder,
context=[architect_task]
)
review_task = Task(
description="""
Review the code files created for the {project} MVP. Read each file using the FileRead tool.
Suggest improvements using diffs. Do not modify the original code files directly.
Instead, save all your suggested diffs into a markdown file using the FileWriter tool.
""",
expected_output="""
A markdown file containing diff-style suggestions for each file reviewed.
""",
agent=reviewer,
context=[coder_task],
output_file="code_review_diffs.md"
)
Creating the Crew
Now that we’ve defined our agents and their respective LLMs and tasks, let’s bring everything together by assembling the crew:
code_crew = Crew( agents=[architect, coder, reviewer], tasks=[architect_task, coder_task, review_task], process=Process.sequential, verbose=True )
With everything set up, we can now run the crew:
if __name__ == "__main__":
code_crew.kickoff(inputs={"project": "Todo App"})
Now we have a fully functioning crew capable of generating an MVP project based on a user-defined specification. In the example above, we asked the crew to build a simple to-do app.
Here’s how it works:
- The Architect agent kicks things off by designing the software architecture and saving the plan to a file called architecture.md.
- The Coder agent then picks up the architect’s output and creates the necessary directories and code files to implement the to-do app.
- Finally, the Reviewer agent reviews the generated code and provides suggestions using diffs, saving its feedback in a file called code_review_diffs.md.
This crew-based development workflow is powerful and modular, but it’s not without a few limitations:
- Hardcoded prompts: By embedding prompts directly into the code, it becomes harder to maintain or improve them over time.
- Scattered structure: Agents and tasks are defined inline, which can lead to a messy and harder-to-navigate script as the project grows.
In the next section, we’ll look at how to clean this up by externalizing agent definitions and task prompts into YAML files.
Building Class-Based Crews
CrewAI allows us to create class-based crews. Let’s update our previous code to follow this structure. First, create a folder called config. This folder will contain YAML files that define the configuration for the agents and tasks.
Let’s create a file named agents.yaml, which will serve as the agent configuration file:
architect: role: > Software Architect for MVP Projects goal: > Define a basic system structure and simple feature set to guide MVP development backstory: > You specialize in quickly outlining software architectures and project scopes for minimum viable products. Your plans help guide coders with enough structure to get started while staying lean. coder: role: > Developer for MVP Projects goal: > Implement the MVP using simple, clear code, and write all necessary code files using the FileWriter tool backstory: > You're a practical developer focused on speed. You prioritize working code over polish. Simulate any runtime behavior if needed, and make sure to keep code clear and modular. reviewer: role: > Code Reviewer for MVP Projects goal: > Read the code files, provide helpful feedback, and write improvements as diffs into a markdown file backstory: > You're a fast but thoughtful reviewer. You check code for clarity, obvious bugs, and provide improvements as diffs. Instead of modifying code directly, you save your suggestions in a markdown file for easy inspection.
This file defines the roles, goals, and backstories for each agent.
Next, we’ll create a tasks.yaml file to hold the task configurations:
Instead, save all your suggested diffs into a markdown file using the FileWriter tool.
architect_task:
description: >
Create a basic plan for building a simple MVP version of a {project}.
Focus on the core features, basic file structure, and tech stack.
Make the structure minimal but enough to get a working demo.
expected_output: >
A simple architectural overview with:
- Project goals
- Key components or files
- Basic data flow or structure
agent: architect
output_file: architecture.md
coder_task:
description: >
Based on the architect's plan, implement the core parts of the {project} MVP.
Keep it lean and functional. Use the FileWriter tool to save all your code files.
If you need to simulate behavior (e.g. without a real interpreter), do so with clear comments or mocked logic.
expected_output: >
Working code files saved using the FileWriter tool that implement the key features defined in the plan.
Code should be readable and logically structured.
agent: coder
context:
- architect_task
review_task:
description: >
Review the code files created for the {project} MVP. Read each file using the FileRead tool.
Suggest improvements using diffs. Do not modify the original code files directly.
Instead, save all your suggested diffs into a markdown file using the FileWriter tool.
expected_output: >
A markdown file containing diff-style suggestions for each file reviewed.
agent: reviewer
context:
- coder_task
output_file: code_review_diffs.md
Next, let’s create our class-based Crew. To do this, we define a class and decorate it with the @CrewBase decorator provided by CrewAI.
@CrewBase
class CodeCrew():
"""Three-agent code crew: architect, coder, reviewer"""
@agent
def architect(self) -> Agent:
pass
@agent
def coder(self) -> Agent:
pass
@agent
def reviewer(self) -> Agent:
pass
@task
def architect_task(self) -> Task:
pass
@task
def code_task(self) -> Task:
pass
@task
def review_task(self) -> Task:
pass
@crew
def crew(self) -> Crew:
pass
In this structure:
- The @agent decorator is used to define agent-producing methods.
- The @task decorator marks task-producing methods.
- The @crew decorator specifies the method that assembles the entire crew.
Now, let’s look at the complete implementation:
import os
from crewai import Agent, Crew, Process, Task, LLM
from crewai.project import CrewBase, agent, crew, task
from crewai.agents.agent_builder.base_agent import BaseAgent
from typing import List
from crewai_tools import FileWriterTool
# Instantiate tool
file_writer_tool = FileWriterTool()
@CrewBase
class CodeCrew():
agents: List[BaseAgent]
tasks: List[Task]
agents_config = 'config/agents.yaml'
tasks_config = 'config/tasks.yaml'
@agent
def architect(self) -> Agent:
llm = LLM(
model="novita/moonshotai/kimi-k2-instruct",
temperature=0.5,
api_base="https://api.novita.ai/v3/openai",
api_key=os.environ['NOVITA_API_KEY']
)
return Agent(
config=self.agents_config['architect'],
llm=llm,
verbose=True
)
@agent
def coder(self) -> Agent:
llm = LLM(
model="novita/qwen/qwen3-coder-480b-a35b-instruct",
temperature=0.4,
api_base="https://api.novita.ai/v3/openai",
api_key=os.environ['NOVITA_API_KEY']
)
return Agent(
config=self.agents_config['coder'],
llm=llm,
verbose=True,
tools=[file_writer_tool]
)
@agent
def reviewer(self) -> Agent:
llm = LLM(
model="novita/moonshotai/kimi-k2-instruct",
temperature=0.5,
api_base="https://api.novita.ai/v3/openai",
api_key=os.environ['NOVITA_API_KEY']
)
return Agent(
config=self.agents_config['reviewer'],
llm=llm,
verbose=True,
)
@task
def architect_task(self) -> Task:
return Task(config=self.tasks_config['architect_task'])
@task
def coder_task(self) -> Task:
return Task(config=self.tasks_config['coder_task'])
@task
def review_task(self) -> Task:
return Task(config=self.tasks_config['review_task'])
@crew
def crew(self) -> Crew:
return Crew(
agents=self.agents,
tasks=self.tasks,
process=Process.sequential,
verbose=True
)
This code defines all the necessary methods and includes attributes for both agents and tasks. It also specifies the paths to the configuration files, which are used within the agent and task methods to load their respective settings.
You can now run the crew by adding the following code to the script:
def main():
code_crew = CodeCrew()
code_crew.crew().kickoff(inputs={"project": "Todo App"})
if __name__ == '__main__':
main()
Building Flows with Novita
We previously built a crew that followed a sequential process. Now, let’s explore how to build a flow that follows a custom-designed workflow.
Our Workflow
We want to design a Customer Support Multi-Agent System that has a lead agent responsible for classifying customer issues. Based on the classification, the system will determine which specialized agent is best suited to handle the issue. The setup consists of four agents:
- Customer Agent: This is the lead agent. It receives the customer’s issue and classifies it into one of the following categories: Billing, Technical, or General.
- Billing Agent: Handles all billing-related issues.
- Technical Agent: Handles technical issues.
- General Agent: Handles any issues that don’t fall into the above categories.

Defining the State of the Flow
Flows in CrewAI have a shared state. The state is accessible across all agents and crews within the flow. For our customer support flow, the state contains the issue label and the user’s query.
Let’s import the necessary modules and define our state:
import json import os from typing import Literal, Dict, Any from pydantic import BaseModel, ValidationError from crewai import Agent, LLM from crewai.flow.flow import Flow, start, router, listen # Define the flow state class SupportState(BaseModel): issue: Literal["billing", "technical", "general"] = "general" message: str = ""
Creating the Flow
Flows in CrewAI are graph-based and use an event-driven system to handle transitions between nodes. To create our flow, we’ll start with a node that classifies the issue, followed by a router node that determines which handler node to route to based on the classification.
class CustomerSupportFlow(Flow[SupportState]):
@start()
def classify_issue(self):
pass
@router(classify_issue)
def route_based_on_issue(self) -> str:
print(f"📨 Routing to agent for issue type: {self.state.issue}")
if self.state.issue == "billing":
return "billing"
if self.state.issue == "technical":
return "technical"
else:
return "general"
@listen("billing")
def handle_billing(self):
pass
@listen("technical")
def handle_technical(self):
pass
@listen("general")
def handle_general(self):
pass
The method decorated with @start() is the entry point of the flow. In this case, it handles issue classification. The method decorated with @router() is executed after classify_issue and determines which agent to route to. The other methods use the event-driven system to respond when they are routed to.
Implementing the Flow Methods
Let’s now implement each method and assign the appropriate agent to each:
class CustomerSupportFlow(Flow[SupportState]):
@start()
def classify_issue(self) -> Dict[str, Any]:
intake_agent = Agent(
role="User Intake Agent",
goal="Classify user support issue and summarize the message",
backstory="You classify the user query into billing, technical, or general categories.",
llm=LLM(
model="novita/qwen/qwen2.5-vl-72b-instruct",
temperature=0.3,
api_base="https://api.novita.ai/v3/openai",
api_key=os.environ["NOVITA_API_KEY"]
),
verbose=True
)
prompt = f"""
A user submitted this message: "{self.state.message}"
Your task:
1. Identify whether the issue is "billing", "technical", or "general".
2. Rephrase or extract the message clearly.
Respond in valid JSON format like the example below:
{{
"issue": "billing",
"message": "The user has been charged twice for their subscription and is requesting a refund."
}}
"""
output = intake_agent.kickoff(prompt, response_format=SupportState)
try:
# Parse the JSON string
print(output.raw)
parsed_json = json.loads(output.raw)
# Validate with Pydantic
validated = SupportState(**parsed_json)
# Save to state
self.state.issue = validated.issue
self.state.message = validated.message
except (json.JSONDecodeError, ValidationError) as e:
print("❌ Failed to parse or validate response:", e)
self.state.issue = "general"
@router(classify_issue)
def route_based_on_issue(self) -> str:
print(f"📨 Routing to agent for issue type: {self.state.issue}")
if self.state.issue == "billing":
return "billing"
if self.state.issue == "technical":
return "technical"
else:
return "general"
@listen("billing")
def handle_billing(self):
agent = Agent(
role="Billing Agent",
goal="Handle billing questions, refunds, and invoice issues",
backstory="You resolve billing-related queries effectively.",
llm=LLM(
model="novita/meta-llama/llama-3.3-70b-instruct",
temperature=0.5,
api_base="https://api.novita.ai/v3/openai",
api_key=os.environ["NOVITA_API_KEY"]
),
verbose=True
)
result = agent.kickoff(self.state.message)
print("💰 Billing Agent Response:", result)
@listen("technical")
def handle_technical(self):
agent = Agent(
role="Technical Support Agent",
goal="Help users resolve technical issues",
backstory="You're a technical expert providing troubleshooting support.",
llm=LLM(
model="novita/meta-llama/llama-3.1-8b-instruct",
temperature=0.5,
api_base="https://api.novita.ai/v3/openai",
api_key=os.environ["NOVITA_API_KEY"]
),
verbose=True
)
result = agent.kickoff(self.state.message)
print("🛠 Technical Agent Response:", result)
@listen("general")
def handle_general(self):
agent = Agent(
role="General Support Agent",
goal="Provide helpful answers to non-technical and non-billing questions",
backstory="You're friendly and well-informed about our services.",
llm=LLM(
model="novita/minimaxai/minimax-m1-80k",
temperature=0.5,
api_base="https://api.novita.ai/v3/openai",
api_key=os.environ["NOVITA_API_KEY"]
),
verbose=True
)
result = agent.kickoff(self.state.message)
print("📋 General Agent Response:", result)
The classify_issue method uses Pydantic to ensure that the model returns a valid response. With this setup complete, we can now run the flow using the following code:
def run():
flow = CustomerSupportFlow()
flow.plot("CustomerSupportFlowPlot")
# Example input message
example_input = {
"message": "Hello, I was charged twice for my subscription and need a refund."
}
flow.kickoff(inputs=example_input)
if __name__ == "__main__":
run()
Conclusion
To build a Multi-Agent System, you need two key components: a model provider that offers a wide variety of models, and a Multi-Agent Framework to orchestrate their interactions. In this article, we’ve shown how Novita and CrewAI can serve these roles effectively. To learn more about CrewAI, be sure to explore their documentation. And if you’re looking to experiment with more models for your crews, check out the Novita LLM playground.
Repo with files – https://github.com/novitalabs/Novita-CollabHub/tree/main/examples/novita-crewai
About Novita AI
Novita AI is an AI cloud platform that offers developers an easy way to deploy AI models using our simple API, while also providing an affordable and reliable GPU cloud for building and scaling.
Discover more from Novita
Subscribe to get the latest posts sent to your email.





