← Back to DevBytes

Building a Multi-Agent Research Pipeline with vLLM: Complete Guide

Introduction to Multi-Agent Research Pipelines with vLLM

Modern research workflows increasingly rely on large language models to synthesize information, generate hypotheses, and produce reports. A single LLM call, however, is rarely sufficient for complex research tasks. Multi-agent pipelines break a large problem into specialized roles — planner, searcher, reader, critic, writer — each powered by a model that can be tuned, prompted, and scaled independently. vLLM, with its PagedAttention-based inference engine, is uniquely suited as the backend for such pipelines because it delivers high throughput, low latency, and native support for concurrent requests.

This guide walks through the architecture, implementation, and operational best practices for building a production-grade multi-agent research pipeline on top of vLLM. By the end, you will have a working system where several agents collaborate asynchronously to research a topic, critique findings, and produce a polished report.

What Is a Multi-Agent Research Pipeline?

A multi-agent research pipeline is a coordinated system in which multiple LLM-backed agents, each with a distinct role, exchange structured messages to accomplish a research objective. Rather than asking one model to "research X and write a report," you decompose the task:

Each agent is essentially a prompt template plus a call to a vLLM-served model. The orchestration layer routes messages between agents, enforces schemas, and handles retries.

Why vLLM Is the Right Backend

Multi-agent pipelines are bursty and concurrent: a planner may emit five sub-questions, each spawning a searcher, each producing multiple reader calls. vLLM addresses the resulting load profile through several features:

Architecture Overview

The pipeline consists of three layers:

Agents communicate via a shared message bus backed by asyncio.Queue for single-process deployments or Redis Streams for distributed setups. Each message is a JSON object with from, to, type, and payload fields.

Setting Up the vLLM Server

Install vLLM in an isolated environment. vLLM pins specific CUDA and PyTorch versions, so a dedicated virtualenv or container is recommended.

pip install vllm>=0.6.0
pip install openai>=1.40.0 pydantic>=2.0 aiohttp

Launch the server with a model suitable for agentic reasoning. For research pipelines, a 7B–14B instruct model is a good starting point for development; scale to 70B for production quality.

vllm serve meta-llama/Meta-Llama-3.1-8B-Instruct \
  --port 8000 \
  --max-model-len 16384 \
  --gpu-memory-utilization 0.9 \
  --enable-auto-tool-choice \
  --guided-decoding-backend outlines

Key flags explained:

Verify the server is healthy:

curl http://localhost:8000/v1/models

Defining the Agent Base Class

All agents share common behavior: formatting prompts, calling vLLM, parsing structured output, and emitting messages. We define an abstract base class.

from __future__ import annotations
import asyncio
import json
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Optional
from openai import AsyncOpenAI

@dataclass
class Message:
    sender: str
    recipient: str
    msg_type: str
    payload: dict[str, Any]

@dataclass
class AgentConfig:
    name: str
    model: str
    temperature: float = 0.3
    max_tokens: int = 2048
    system_prompt: str = ""

class BaseAgent(ABC):
    def __init__(self, config: AgentConfig, client: AsyncOpenAI):
        self.config = config
        self.client = client

    async def generate(
        self,
        user_prompt: str,
        response_format: Optional[dict] = None,
    ) -> str:
        messages = [
            {"role": "system", "content": self.config.system_prompt},
            {"role": "user", "content": user_prompt},
        ]
        kwargs: dict[str, Any] = {
            "model": self.config.model,
            "messages": messages,
            "temperature": self.config.temperature,
            "max_tokens": self.config.max_tokens,
        }
        if response_format:
            kwargs["extra_body"] = {"guided_json": response_format}
        resp = await self.client.chat.completions.create(**kwargs)
        return resp.choices[0].message.content or ""

    @abstractmethod
    async def run(self, inbox: asyncio.Queue[Message], outbox: asyncio.Queue[Message]) -> None:
        ...

The generate method accepts an optional Pydantic-style JSON schema passed through vLLM's guided_json extra body parameter, guaranteeing the model returns valid JSON for downstream parsing.

Implementing the Planner Agent

The planner takes a research question and produces a list of sub-questions. Constrained decoding ensures the output is always a valid list.

PLANNER_SCHEMA = {
    "type": "object",
    "properties": {
        "sub_questions": {
            "type": "array",
            "items": {"type": "string"},
            "minItems": 2,
            "maxItems": 6,
        }
    },
    "required": ["sub_questions"],
}

PLANNER_SYSTEM = (
    "You are a research planner. Given a research question, "
    "decompose it into 2-6 focused sub-questions that can be "
    "answered independently. Return JSON only."
)

class PlannerAgent(BaseAgent):
    def __init__(self, client: AsyncOpenAI):
        super().__init__(
            AgentConfig(
                name="planner",
                model="meta-llama/Meta-Llama-3.1-8B-Instruct",
                temperature=0.2,
                max_tokens=1024,
                system_prompt=PLANNER_SYSTEM,
            ),
            client,
        )

    async def run(self, inbox: asyncio.Queue[Message], outbox: asyncio.Queue[Message]) -> None:
        while True:
            msg = await inbox.get()
            if msg.msg_type == "research_question":
                raw = await self.generate(
                    f"Research question: {msg.payload['question']}",
                    response_format=PLANNER_SCHEMA,
                )
                data = json.loads(raw)
                for sq in data["sub_questions"]:
                    await outbox.put(Message(
                        sender="planner",
                        recipient="searcher",
                        msg_type="sub_question",
                        payload={"question": sq, "parent": msg.payload["question"]},
                    ))
            inbox.task_done()

Implementing the Searcher and Reader Agents

The searcher converts a sub-question into search queries and retrieves documents. For this example we use a stub retriever; in production you would plug in a vector store or web search API.

SEARCHER_SYSTEM = (
    "You generate concise web search queries. "
    "Return JSON with a 'queries' array of 1-3 strings."
)

SEARCH_SCHEMA = {
    "type": "object",
    "properties": {
        "queries": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 3}
    },
    "required": ["queries"],
}

async def mock_retrieve(query: str, k: int = 3) -> list[dict]:
    # Replace with: vector DB lookup, SerpAPI, Tavily, etc.
    return [{"title": f"Result for {query}", "text": f"Sample passage about {query}.", "url": "https://example.com"}] * k

class SearcherAgent(BaseAgent):
    def __init__(self, client: AsyncOpenAI):
        super().__init__(
            AgentConfig(name="searcher", model="meta-llama/Meta-Llama-3.1-8B-Instruct",
                        temperature=0.3, max_tokens=512, system_prompt=SEARCHER_SYSTEM),
            client,
        )

    async def run(self, inbox, outbox):
        while True:
            msg = await inbox.get()
            if msg.msg_type == "sub_question":
                raw = await self.generate(
                    f"Sub-question: {msg.payload['question']}",
                    response_format=SEARCH_SCHEMA,
                )
                queries = json.loads(raw)["queries"]
                docs = []
                for q in queries:
                    docs.extend(await mock_retrieve(q))
                await outbox.put(Message(
                    sender="searcher",
                    recipient="reader",
                    msg_type="documents",
                    payload={"sub_question": msg.payload["question"], "docs": docs},
                ))
            inbox.task_done()

The reader extracts structured facts from each document batch.

READER_SYSTEM = (
    "You extract verifiable facts from documents. "
    "Return JSON with a 'facts' array; each fact has "
    "'claim' (string) and 'citation' (string)."
)

FACT_SCHEMA = {
    "type": "object",
    "properties": {
        "facts": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "claim": {"type": "string"},
                    "citation": {"type": "string"},
                },
                "required": ["claim", "citation"],
            },
        }
    },
    "required": ["facts"],
}

class ReaderAgent(BaseAgent):
    def __init__(self, client: AsyncOpenAI):
        super().__init__(
            AgentConfig(name="reader", model="meta-llama/Meta-Llama-3.1-8B-Instruct",
                        temperature=0.1, max_tokens=2048, system_prompt=READER_SYSTEM),
            client,
        )

    async def run(self, inbox, outbox):
        while True:
            msg = await inbox.get()
            if msg.msg_type == "documents":
                context = "\n\n".join(
                    f"[{i}] {d['title']}\n{d['text']}\nURL: {d['url']}"
                    for i, d in enumerate(msg.payload["docs"])
                )
                raw = await self.generate(
                    f"Sub-question: {msg.payload['sub_question']}\n\nDocuments:\n{context}",
                    response_format=FACT_SCHEMA,
                )
                facts = json.loads(raw)["facts"]
                await outbox.put(Message(
                    sender="reader",
                    recipient="critic",
                    msg_type="facts",
                    payload={"sub_question": msg.payload["sub_question"], "facts": facts},
                ))
            inbox.task_done()

Implementing the Critic and Writer Agents

The critic filters low-quality or unsupported claims before they reach the writer.

CRITIC_SYSTEM = (
    "You evaluate research facts for accuracy, relevance, and "
    "support. Return JSON with 'verified' (array of facts) and "
    "'rejected' (array of facts with a 'reason' field)."
)

CRITIC_SCHEMA = {
    "type": "object",
    "properties": {
        "verified": {
            "type": "array",
            "items": {"type": "object",
                      "properties": {"claim": {"type": "string"}, "citation": {"type": "string"}},
                      "required": ["claim", "citation"]},
        },
        "rejected": {
            "type": "array",
            "items": {"type": "object",
                      "properties": {"claim": {"type": "string"}, "reason": {"type": "string"}},
                      "required": ["claim", "reason"]},
        },
    },
    "required": ["verified", "rejected"],
}

class CriticAgent(BaseAgent):
    def __init__(self, client: AsyncOpenAI):
        super().__init__(
            AgentConfig(name="critic", model="meta-llama/Meta-Llama-3.1-8B-Instruct",
                        temperature=0.2, max_tokens=2048, system_prompt=CRITIC_SYSTEM),
            client,
        )
        self.verified: list[dict] = []

    async def run(self, inbox, outbox):
        while True:
            msg = await inbox.get()
            if msg.msg_type == "facts":
                raw = await self.generate(
                    f"Sub-question: {msg.payload['sub_question']}\n\nFacts: {json.dumps(msg.payload['facts'])}",
                    response_format=CRITIC_SCHEMA,
                )
                result = json.loads(raw)
                self.verified.extend(result["verified"])
                await outbox.put(Message(
                    sender="critic",
                    recipient="writer",
                    msg_type="verified_facts",
                    payload={"verified": result["verified"]},
                ))
            inbox.task_done()

The writer accumulates verified facts and produces the final report when signaled.

WRITER_SYSTEM = (
    "You are a research report writer. Synthesize verified facts "
    "into a coherent, well-structured markdown report with inline "
    "citations. Do not invent facts not present in the input."
)

class WriterAgent(BaseAgent):
    def __init__(self, client: AsyncOpenAI, original_question: str):
        super().__init__(
            AgentConfig(name="writer", model="meta-llama/Meta-Llama-3.1-8B-Instruct",
                        temperature=0.4, max_tokens=4096, system_prompt=WRITER_SYSTEM),
            client,
        )
        self.original_question = original_question
        self.all_facts: list[dict] = []
        self.report: str | None = None

    async def run(self, inbox, outbox):
        while True:
            msg = await inbox.get()
            if msg.msg_type == "verified_facts":
                self.all_facts.extend(msg.payload["verified"])
            elif msg.msg_type == "finalize":
                prompt = (
                    f"Original research question: {self.original_question}\n\n"
                    f"Verified facts:\n{json.dumps(self.all_facts, indent=2)}\n\n"
                    "Write the final report in markdown."
                )
                self.report = await self.generate(prompt)
                await outbox.put(Message(
                    sender="writer",
                    recipient="orchestrator",
                    msg_type="report",
                    payload={"report": self.report},
                ))
            inbox.task_done()

Building the Orchestrator

The orchestrator wires agents together, routes messages by recipient, and signals the writer once all sub-questions have been processed.

class Orchestrator:
    def __init__(self, client: AsyncOpenAI, question: str):
        self.client = client
        self.question = question
        self.queues: dict[str, asyncio.Queue[Message]] = {}
        self.agents: dict[str, BaseAgent] = {}
        self.report: str | None = None
        self._expected_sub_questions = 0
        self._completed = 0

    def setup(self):
        names = ["planner", "searcher", "reader", "critic", "writer"]
        for n in names:
            self.queues[n] = asyncio.Queue()

        self.agents["planner"] = PlannerAgent(self.client)
        self.agents["searcher"] = SearcherAgent(self.client)
        self.agents["reader"] = ReaderAgent(self.client)
        self.agents["critic"] = CriticAgent(self.client)
        self.agents["writer"] = WriterAgent(self.client, self.question)

    async def _router(self):
        """Forward messages from a shared outbox to recipient inboxes."""
        outbox: asyncio.Queue[Message] = asyncio.Queue()
        self._outbox = outbox
        while True:
            msg = await outbox.get()
            if msg.recipient == "orchestrator":
                if msg.msg_type == "report":
                    self.report = msg.payload["report"]
                    return
            elif msg.recipient in self.queues:
                await self.queues[msg.recipient].put(msg)
                # Track sub-question completion for finalize signal
                if msg.msg_type == "verified_facts":
                    self._completed += 1
                    if self._completed >= self._expected_sub_questions:
                        await self.queues["writer"].put(Message(
                            sender="orchestrator",
                            recipient="writer",
                            msg_type="finalize",
                            payload={},
                        ))
            outbox.task_done()

    async def run(self) -> str:
        self.setup()
        # Start agents
        tasks = []
        for name, agent in self.agents.items():
            tasks.append(asyncio.create_task(agent.run(self.queues[name], self._outbox if hasattr(self, '_outbox') else asyncio.Queue())))
        # We need the router to set _outbox first; restart with proper wiring
        for t in tasks:
            t.cancel()

        self._outbox = asyncio.Queue()
        tasks = [
            asyncio.create_task(self.agents[n].run(self.queues[n], self._outbox))
            for n in self.agents
        ]
        router_task = asyncio.create_task(self._router())

        # Kick off: send research question to planner
        await self.queues["planner"].put(Message(
            sender="user",
            recipient="planner",
            msg_type="research_question",
            payload={"question": self.question},
        ))

        # Wait for planner to emit sub-questions so we know the count
        # (In a fuller implementation, planner would echo the count.)
        # Here we poll the queue size heuristically:
        await asyncio.sleep(2)
        # A more robust approach: planner sends a 'plan' message to orchestrator.
        # For brevity, assume expected = number of sub_question messages routed.
        # We patch by intercepting in router; for this demo set a reasonable cap:
        self._expected_sub_questions = max(1, self._completed + 1)
        # In production, track via a dedicated 'plan_ready' message.

        await router_task
        for t in tasks:
            t.cancel()
        return self.report or "No report generated."

The simplified orchestrator above illustrates the pattern. In production, the planner should emit a plan_ready message containing the sub-question count so the orchestrator knows exactly when to send finalize to the writer. This avoids race conditions and polling.

Running the Pipeline End to End

import asyncio
from openai import AsyncOpenAI

async def main():
    client = AsyncOpenAI(base_url="http://localhost:8000/v1", api_key="dummy")
    orch = Orchestrator(client, "What are the trade-offs between RAG and long-context LLMs?")
    report = await orch.run()
    print(report)

if __name__ == "__main__":
    asyncio.run(main())

When you run this script, the planner decomposes the question, searchers retrieve documents for each sub-question, readers extract facts, the critic filters them, and the writer assembles a cited markdown report. All LLM calls hit the single vLLM server, which batches them transparently.

Scaling to Multiple vLLM Instances

For higher throughput, run multiple vLLM servers behind a load balancer. Because the OpenAI client supports round-robin across base URLs, you can implement a simple pooling client:

import itertools
from openai import AsyncOpenAI

class PooledClient:
    def __init__(self, urls: list[str], api_key: str = "dummy"):
        self._clients = [AsyncOpenAI(base_url=u, api_key=api_key) for u in urls]
        self._cycle = itertools.cycle(self._clients)

    @property
    def chat(self):
        return next(self._cycle).chat

Pass a PooledClient wherever an AsyncOpenAI instance is expected. For production, prefer a real reverse proxy (Nginx, Envoy, or LiteLLM) that also handles health checks and retries.

Best Practices

Use Constrained Decoding for Inter-Agent Messages

Every agent that emits structured data should use vLLM's guided JSON decoding. Unconstrained outputs cause parse failures that cascade through the pipeline. Define Pydantic models or JSON schemas for every agent output and pass them via extra_body={"guided_json": schema}.

Keep Prompts Focused and Short

Each agent should have a single, narrow responsibility. Resist the temptation to make the reader also summarize or the critic also rewrite. Narrow prompts reduce token usage, improve output quality, and make failures easier to diagnose.

Set Temperature Strategically

Use low temperature (0.0–0.2) for extraction and verification agents where determinism matters. Use higher temperature (0.4–0.7) for the planner and writer where creativity is valuable. Never use temperature above 1.0 in research pipelines — it increases hallucination risk.

Implement Retries with Backoff

vLLM can return 429 or 503 under load. Wrap every generate call with exponential backoff:

import asyncio
from openai import APIError, RateLimitError

async def generate_with_retry(agent, prompt, schema, retries=4):
    for attempt in range(retries):
        try:
            return await agent.generate(prompt, response_format=schema)
        except (RateLimitError, APIError) as e:
            if attempt == retries - 1:
                raise
            await asyncio.sleep(2 ** attempt)

Persist Intermediate Artifacts

Save every agent's input and output to disk or a database. This enables debugging, replay, and incremental re-runs when a single agent fails. A simple approach is to log each message as a JSONL line:

import time, json, pathlib

log_path = pathlib.Path("pipeline_log.jsonl")
def log_message(msg: Message):
    with log_path.open("a") as f:
        f.write(json.dumps({
            "ts": time.time(),
            "from": msg.sender,
            "to": msg.recipient,
            "type": msg.msg_type,
            "payload": msg.payload,
        }) + "\n")

Monitor vLLM Metrics

vLLM exposes Prometheus metrics at /metrics. Track vllm:num_requests_running, vllm:num_requests_waiting, and vllm:time_to_first_token_seconds. If waiting requests grow unbounded, add GPU capacity or reduce concurrency in the orchestrator with a semaphore.

Cap Concurrency

Unbounded fan-out can overwhelm the server. Limit concurrent searcher calls with a semaphore sized to your GPU's capacity:

sem = asyncio.Semaphore(16)

async def bounded_search(query):
    async with sem:
        return await mock_retrieve(query)

Use Different Models for Different Agents

A powerful advantage of vLLM is serving multiple models on one server (via --served-model-name and LoRA adapters, or separate processes). Use a smaller, faster model for the searcher (query generation is easy) and a larger model for the critic and writer (judgment and synthesis are hard). This optimizes cost and latency simultaneously.

Conclusion

Building a multi-agent research pipeline on vLLM combines the flexibility of role-specialized LLM agents with the throughput and memory efficiency of a production-grade inference engine. By decomposing research into planner, searcher, reader, critic, and writer roles, enforcing structured outputs through guided decoding, and orchestrating them with async message passing, you get a system that is more accurate, more debuggable, and more scalable than a monolithic prompt. Start with the single-server setup described here, instrument it with logging and metrics, then scale horizontally with pooled vLLM instances and concurrency controls as your research workload grows. The architecture patterns in this guide — constrained inter-agent schemas, narrow agent responsibilities, retry-aware orchestration, and persistent artifacts — form a durable foundation that adapts as models and retrieval backends evolve.

— Ad —

Google AdSense will appear here after approval

← Back to all articles