← Back to DevBytes

Multi-Agent Debate Systems: Improving LLM Reasoning

What Are Multi-Agent Debate Systems?

Multi-Agent Debate Systems are an architectural pattern where multiple Large Language Model (LLM) instances—or "agents"—are prompted to take on distinct perspectives and engage in structured argumentation around a given problem. Rather than relying on a single model call, the system orchestrates a back-and-forth exchange where agents challenge each other's reasoning, point out flaws, and iteratively refine a final answer.

At its core, the pattern works like this:

This structure can be extended to N agents with specialized roles—mathematical verifier, ethics checker, domain expert, adversarial tester—all debating concurrently or in rounds. The key insight is that reasoning improves through adversarial collaboration, much like red-teaming in security or peer review in academia.

Why Multi-Agent Debate Matters

Single-model reasoning suffers from well-documented failure modes:

Debate systems address these by forcing contradiction into the loop. When Agent B explicitly tries to break Agent A's argument, both agents must engage in deeper reasoning. Research from organizations like Anthropic, DeepMind, and OpenAI has shown that debate protocols can significantly improve truth-seeking behavior, reduce hallucination rates, and produce more calibrated uncertainty estimates.

The technique matters because it provides a training-free improvement layer—you don't need to fine-tune the underlying model. You simply orchestrate existing models more intelligently. This makes it immediately applicable to any LLM API (GPT-4, Claude, Gemini, open-source models via vLLM, etc.).

Core Architecture: A Practical Implementation

Let's build a complete multi-agent debate system from scratch using Python and the OpenAI API. The system will have three agents—Proposer, Critic, and Judge—that debate a given question for multiple rounds.

import os
import json
from openai import OpenAI
from typing import List, Dict, Optional
from dataclasses import dataclass, field
import time

client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))

@dataclass
class DebateRound:
    """Tracks the state of a single debate round."""
    round_number: int
    proposer_response: str
    critic_response: str
    judge_synthesis: Optional[str] = None

@dataclass
class DebateConfig:
    """Configuration for the debate system."""
    question: str
    num_rounds: int = 2
    model: str = "gpt-4o"
    temperature: float = 0.7
    proposer_role: str = "expert_solver"
    critic_role: str = "skeptical_reviewer"
    judge_role: str = "neutral_arbiter"
    max_tokens_per_response: int = 800
    verbose: bool = True

class MultiAgentDebateSystem:
    """
    A multi-agent debate system that improves LLM reasoning through
    structured adversarial collaboration.
    """
    
    def __init__(self, config: DebateConfig):
        self.config = config
        self.debate_history: List[DebateRound] = []
        self.system_prompts = self._build_system_prompts()
    
    def _build_system_prompts(self) -> Dict[str, str]:
        """Construct role-specific system prompts for each agent."""
        
        proposer_prompt = f"""You are the PROPOSER agent in a debate. Your role is to provide the most 
accurate, well-reasoned answer to the user's question. 

Guidelines:
- Break down complex problems step by step (Chain of Thought)
- Cite relevant facts, principles, or data when applicable
- Express confidence levels honestly (e.g., "I am 90% confident because...")
- Anticipate potential objections and address them preemptively
- If uncertain about something, state it explicitly rather than guessing
- Your answer must stand up to rigorous critical scrutiny"""

        critic_prompt = f"""You are the CRITIC agent in a debate. Your role is to rigorously examine 
the Proposer's answer and identify any weaknesses.

Guidelines:
- Look for logical fallacies, missing steps, or unwarranted assumptions
- Check for factual errors or outdated information
- Identify edge cases where the reasoning breaks down
- Question confidence levels that seem over- or under-confident
- Suggest specific improvements rather than just vague criticism
- Be adversarial but constructive—your goal is better truth, not just winning
- If the Proposer's answer is genuinely excellent, acknowledge that honestly"""

        judge_prompt = f"""You are the JUDGE agent in a debate. Your role is to synthesize the arguments 
from both the Proposer and the Critic into a final, improved answer.

Guidelines:
- Weigh both arguments objectively based on evidence and logic
- Incorporate valid criticisms and discard invalid ones
- Produce a final answer that is more accurate than either individual argument
- Explain your reasoning for accepting or rejecting specific points
- If consensus is reached, state it clearly; if not, explain the remaining uncertainty
- Format your output with: FINAL ANSWER, followed by DETAILED REASONING"""

        return {
            "proposer": proposer_prompt,
            "critic": critic_prompt,
            "judge": judge_prompt
        }
    
    def _call_agent(self, system_prompt: str, user_message: str, 
                    agent_name: str) -> str:
        """Make an API call to an agent and return its response."""
        
        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_message}
        ]
        
        if self.config.verbose:
            print(f"\n{'='*60}")
            print(f"🤖 Calling {agent_name} agent...")
            print(f"{'='*60}")
        
        try:
            response = client.chat.completions.create(
                model=self.config.model,
                messages=messages,
                temperature=self.config.temperature,
                max_tokens=self.config.max_tokens_per_response
            )
            content = response.choices[0].message.content
            
            if self.config.verbose:
                print(f"\n{content[:300]}..." if len(content) > 300 else f"\n{content}")
            
            return content
        
        except Exception as e:
            print(f"Error calling {agent_name}: {e}")
            raise
    
    def run_debate(self) -> Dict:
        """
        Execute the full multi-round debate and return results.
        """
        question = self.config.question
        
        # Round 1: Initial proposal and first criticism
        for round_num in range(1, self.config.num_rounds + 1):
            if self.config.verbose:
                print(f"\n{'🔵'*30}")
                print(f"DEBATE ROUND {round_num}")
                print(f"{'🔵'*30}")
            
            # Build context from previous rounds
            context = self._build_debate_context(current_round=round_num)
            
            # Proposer generates or refines answer
            proposer_message = f"""QUESTION: {question}

PREVIOUS DEBATE CONTEXT:
{context}

Please provide your {'revised ' if round_num > 1 else ''}answer to the question. 
{'Address the criticisms from previous rounds and strengthen your reasoning.' if round_num > 1 else ''}"""
            
            proposer_response = self._call_agent(
                self.system_prompts["proposer"],
                proposer_message,
                "PROPOSER"
            )
            
            # Critic examines the proposer's response
            critic_message = f"""QUESTION: {question}

PROPOSER'S ANSWER TO CRITIQUE:
{proposer_response}

PREVIOUS DEBATE CONTEXT:
{context}

Please provide your detailed critique of the Proposer's answer above."""
            
            critic_response = self._call_agent(
                self.system_prompts["critic"],
                critic_message,
                "CRITIC"
            )
            
            # Store the round
            debate_round = DebateRound(
                round_number=round_num,
                proposer_response=proposer_response,
                critic_response=critic_response
            )
            self.debate_history.append(debate_round)
            
            # Brief pause to avoid rate limits
            time.sleep(0.5)
        
        # Final: Judge synthesizes everything
        judge_message = self._build_judge_message(question)
        judge_response = self._call_agent(
            self.system_prompts["judge"],
            judge_message,
            "JUDGE"
        )
        
        # Attach judge response to last round
        self.debate_history[-1].judge_synthesis = judge_response
        
        return {
            "question": question,
            "debate_rounds": [
                {
                    "round": r.round_number,
                    "proposer": r.proposer_response,
                    "critic": r.critic_response,
                    "judge_synthesis": r.judge_synthesis
                }
                for r in self.debate_history
            ],
            "final_answer": judge_response,
            "config": {
                "model": self.config.model,
                "num_rounds": self.config.num_rounds,
                "temperature": self.config.temperature
            }
        }
    
    def _build_debate_context(self, current_round: int) -> str:
        """Construct context string from previous debate rounds."""
        if not self.debate_history:
            return "No previous rounds. This is the opening argument."
        
        context_parts = []
        for debate_round in self.debate_history:
            context_parts.append(f"""
--- ROUND {debate_round.round_number} ---
PROPOSER: {debate_round.proposer_response}

CRITIC: {debate_round.critic_response}
""")
        
        return "\n".join(context_parts)
    
    def _build_judge_message(self, question: str) -> str:
        """Build the comprehensive message for the Judge agent."""
        
        debate_transcript = self._build_debate_context(
            current_round=self.config.num_rounds + 1
        )
        
        return f"""QUESTION: {question}

COMPLETE DEBATE TRANSCRIPT:
{debate_transcript}

You must now deliver your FINAL JUDGMENT. Synthesize all arguments.
Output format:

FINAL ANSWER:
[Your synthesized, improved answer]

DETAILED REASONING:
[Explain which arguments you accepted, which you rejected, and why]
"""

# Example usage
config = DebateConfig(
    question="If a bat and a ball cost $1.10 total, and the bat costs $1.00 more than the ball, how much does the ball cost?",
    num_rounds=2,
    model="gpt-4o",
    temperature=0.7,
    verbose=True
)

debate_system = MultiAgentDebateSystem(config)
result = debate_system.run_debate()

print("\n" + "="*60)
print("FINAL SYNTHESIZED ANSWER:")
print("="*60)
print(result["final_answer"])

Understanding the Cognitive Safety Net

The example above uses a deliberately tricky question—the classic bat-and-ball problem that reliably triggers the "fast thinking" error (answering 10 cents instead of 5 cents). Single-model calls frequently get this wrong because the intuitive-but-wrong answer feels satisfying. The debate system forces a second look.

Here's what happens under the hood during a typical debate round:

Advanced Pattern: Specialized Agent Roles

For complex domains, generic "proposer vs. critic" isn't enough. You can assign specialized roles that each examine different facets of the problem. Here's an expanded system with five agents:

class SpecializedDebateSystem:
    """
    A multi-agent debate system with domain-specialized agents
    that examine different facets of a problem.
    """
    
    def __init__(self, question: str, model: str = "gpt-4o"):
        self.question = question
        self.model = model
        self.agents = {
            "logical_reasoner": {
                "system_prompt": """You are a LOGICAL REASONER. Your job is to analyze 
problems using formal logic, identify premises, evaluate deductive and inductive 
arguments, and spot logical fallacies. Use step-by-step reasoning.""",
                "response": None
            },
            "fact_checker": {
                "system_prompt": """You are a FACT CHECKER. Your job is to verify 
factual claims, check for accuracy against known information, and flag any 
statements that are unverifiable or likely false. Be precise about sources.""",
                "response": None
            },
            "mathematical_verifier": {
                "system_prompt": """You are a MATHEMATICAL VERIFIER. Your job is to 
check all calculations, ensure numerical reasoning is sound, verify equations, 
and catch arithmetic errors. Show your verification work.""",
                "response": None
            },
            "edge_case_analyzer": {
                "system_prompt": """You are an EDGE CASE ANALYZER. Your job is to 
stress-test solutions by considering extreme scenarios, boundary conditions, 
and unusual inputs that might break the reasoning.""",
                "response": None
            },
            "synthesizer": {
                "system_prompt": """You are a SYNTHESIZER. Your job is to read all 
analyses from specialized agents, reconcile contradictions, and produce a final 
answer that incorporates all verified insights.""",
                "response": None
            }
        }
    
    def run_analysis(self) -> Dict:
        """Run all specialized agents and synthesize results."""
        
        # Phase 1: Parallel specialized analysis
        analysis_results = {}
        for agent_name, agent_info in self.agents.items():
            if agent_name == "synthesizer":
                continue  # Runs last
            
            response = self._call_agent(
                agent_info["system_prompt"],
                f"Please analyze this problem thoroughly: {self.question}",
                agent_name
            )
            self.agents[agent_name]["response"] = response
            analysis_results[agent_name] = response
            time.sleep(0.3)  # Rate limit buffer
        
        # Phase 2: Synthesizer receives all analyses
        analyses_text = "\n\n".join([
            f"=== {name.upper()} ANALYSIS ===\n{resp}"
            for name, resp in analysis_results.items()
        ])
        
        synthesizer_prompt = f"""
All specialized agents have analyzed this question:

QUESTION: {self.question}

{analyses_text}

Synthesize all findings into a comprehensive final answer. 
Resolve any contradictions. Highlight remaining uncertainties.
Structure: FINAL ANSWER, then SUPPORTING EVIDENCE from each agent."""

        final_response = self._call_agent(
            self.agents["synthesizer"]["system_prompt"],
            synthesizer_prompt,
            "SYNTHESIZER"
        )
        self.agents["synthesizer"]["response"] = final_response
        
        return {
            "question": self.question,
            "specialist_analyses": analysis_results,
            "synthesized_answer": final_response
        }
    
    def _call_agent(self, system_prompt: str, user_message: str, 
                    agent_name: str) -> str:
        response = client.chat.completions.create(
            model=self.model,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_message}
            ],
            temperature=0.7,
            max_tokens=600
        )
        return response.choices[0].message.content

# Usage
specialized_system = SpecializedDebateSystem(
    question="A pharmaceutical company claims their new drug reduces symptoms in 75% of patients. " +
             "A clinical trial with 200 patients showed 140 improved. Is the company's claim statistically " +
             "justified at a 95% confidence level?",
    model="gpt-4o"
)

result = specialized_system.run_analysis()
print(result["synthesized_answer"])

Integrating with LangChain and Tool Use

For production systems, you'll often want agents to access external tools—calculators, search APIs, code interpreters, or databases. Here's how to wire a debate system into LangChain with tool-calling capabilities:

from langchain_openai import ChatOpenAI
from langchain.tools import tool
from langchain_core.messages import SystemMessage, HumanMessage, ToolMessage
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated, Sequence
import operator
import json

# Define tools that agents can use
@tool
def calculator(expression: str) -> str:
    """Evaluate a mathematical expression. Use for all calculations."""
    try:
        # Safe eval with limited namespace
        result = eval(expression, {"__builtins__": {}}, 
                      {"abs": abs, "round": round, "sum": sum, 
                       "pow": pow, "sqrt": lambda x: x**0.5})
        return str(result)
    except Exception as e:
        return f"Error: {str(e)}"

@tool
def web_search(query: str) -> str:
    """Search for factual information. Returns relevant snippets."""
    # In production, wire this to a real search API
    return f"Search results for '{query}': [Simulated search results would appear here]"

class DebateState(TypedDict):
    question: str
    messages: Annotated[list, operator.add]
    round: int
    proposer_tools_called: list
    critic_tools_called: list
    final_answer: str

def build_tool_enriched_debate_graph():
    """
    Build a LangGraph debate system where agents can invoke
    calculators, search engines, and other tools mid-debate.
    """
    
    llm = ChatOpenAI(model="gpt-4o", temperature=0.7)
    tools = [calculator, web_search]
    llm_with_tools = llm.bind_tools(tools)
    
    def proposer_node(state: DebateState):
        """Proposer agent that can use tools to strengthen arguments."""
        
        system_msg = SystemMessage(content="""You are the PROPOSER in a debate.
You have access to a calculator and web search. Use them to verify your 
reasoning before presenting arguments. Show your work.""")
        
        context = f"QUESTION: {state['question']}\n"
        if state['round'] > 0:
            context += f"Previous round criticisms are in the message history."
        
        messages = [system_msg, HumanMessage(content=context)]
        response = llm_with_tools.invoke(messages)
        
        # Handle tool calls if the LLM requests them
        if hasattr(response, 'tool_calls') and response.tool_calls:
            tool_messages = []
            for tool_call in response.tool_calls:
                tool_name = tool_call['name']
                tool_args = tool_call['args']
                
                if tool_name == 'calculator':
                    result = calculator.invoke(tool_args['expression'])
                elif tool_name == 'web_search':
                    result = web_search.invoke(tool_args['query'])
                
                tool_messages.append(
                    ToolMessage(content=result, tool_call_id=tool_call['id'])
                )
                state['proposer_tools_called'].append({
                    'tool': tool_name,
                    'args': tool_args,
                    'result': result
                })
            
            # Re-invoke with tool results
            all_messages = messages + [response] + tool_messages
            final_response = llm.invoke(all_messages)
            state['messages'].append(final_response.content)
        
        return state
    
    def critic_node(state: DebateState):
        """Critic that fact-checks using tools."""
        
        system_msg = SystemMessage(content="""You are the CRITIC. Use tools to 
verify the Proposer's claims. Check calculations and search for factual 
contradictions.""")
        
        # Similar tool-calling pattern as proposer
        proposer_text = state['messages'][-1] if state['messages'] else ""
        messages = [system_msg, HumanMessage(
            content=f"Critique this argument:\n{proposer_text}"
        )]
        
        response = llm_with_tools.invoke(messages)
        # ... handle tool calls similarly ...
        state['messages'].append(response.content)
        state['round'] += 1
        return state
    
    def judge_node(state: DebateState):
        """Final synthesis after debate rounds."""
        system_msg = SystemMessage(content="You are the JUDGE. Deliver final answer.")
        debate_history = "\n".join(state['messages'])
        messages = [system_msg, HumanMessage(
            content=f"Question: {state['question']}\nDebate:\n{debate_history}\n\nDeliver final synthesis."
        )]
        response = llm.invoke(messages)
        state['final_answer'] = response.content
        return state
    
    # Build the graph
    graph = StateGraph(DebateState)
    graph.add_node("proposer", proposer_node)
    graph.add_node("critic", critic_node)
    graph.add_node("judge", judge_node)
    
    graph.set_entry_point("proposer")
    graph.add_edge("proposer", "critic")
    graph.add_edge("critic", "proposer")  # Loop for multiple rounds
    # In production, add conditional edges based on round count
    graph.add_edge("proposer", "judge")  # After rounds complete
    
    return graph.compile()

# The graph can be invoked with:
# debate_graph = build_tool_enriched_debate_graph()
# result = debate_graph.invoke({"question": "...", "round": 0, ...})

Debate Protocols: Designing the Interaction Rules

The structure of the debate—who speaks when, what they see, and how consensus is reached—dramatically affects outcomes. Here are the key protocol variants:

Practical Code: Blind Critique Protocol

The blind critique pattern is particularly powerful for catching reasoning errors. Here's the implementation:

class BlindCritiqueDebate:
    """
    Implements a blind critique protocol where the Critic sees the answer
    but NOT the reasoning chain, forcing independent verification.
    """
    
    def __init__(self, question: str, model: str = "gpt-4o"):
        self.question = question
        self.model = model
    
    def _extract_final_answer(self, full_response: str) -> str:
        """Extract just the conclusion from a detailed reasoning chain."""
        # In production, use more sophisticated parsing
        extraction_prompt = f"""
Given the following detailed response, extract ONLY the final conclusion 
or answer. Strip all reasoning, explanation, and commentary. 
Return just the bare answer:

RESPONSE:
{full_response}

EXTRACTED ANSWER:"""
        
        response = client.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": extraction_prompt}],
            temperature=0.0,  # Deterministic extraction
            max_tokens=200
        )
        return response.choices[0].message.content.strip()
    
    def run_blind_debate(self, num_rounds: int = 2) -> Dict:
        """Execute debate where critic sees only conclusions."""
        
        proposer_system = """You are a careful reasoner. For each problem:
1. Work through your reasoning step by step in detail
2. At the very end, state your FINAL ANSWER clearly marked with ###FINAL_ANSWER###
3. Keep your reasoning and final answer clearly separated"""
        
        critic_system = """You are a CRITIC. You will be shown ONLY the final answer 
(not the reasoning). You must:
1. Assess whether the answer is correct based on your own reasoning
2. If you suspect an error, explain what the correct answer should be
3. Do NOT assume the hidden reasoning is correct just because the answer looks plausible"""
        
        rounds_data = []
        previous_critic_feedback = ""
        
        for round_num in range(1, num_rounds + 1):
            # Proposer: full reasoning + answer
            proposer_msg = f"""QUESTION: {self.question}
PREVIOUS CRITIC FEEDBACK: {previous_critic_feedback or 'None'}
Provide your full reasoning and final answer."""
            
            proposer_full = self._call_chat(proposer_system, proposer_msg)
            final_answer_only = self._extract_final_answer(proposer_full)
            
            # Critic: sees ONLY the final answer
            critic_msg = f"""QUESTION: {self.question}
THE PROPOSER'S FINAL ANSWER (reasoning hidden): {final_answer_only}
Critique this answer. Is it correct? What should the answer be?"""
            
            critic_full = self._call_chat(critic_system, critic_msg)
            previous_critic_feedback = critic_full
            
            rounds_data.append({
                "round": round_num,
                "proposer_full_reasoning": proposer_full,
                "extracted_answer_shown_to_critic": final_answer_only,
                "critic_blind_critique": critic_full
            })
        
        # Judge sees everything
        judge_msg = f"""QUESTION: {self.question}
FULL DEBATE RECORD (Judge sees all reasoning):
{json.dumps(rounds_data, indent=2)}

Provide final synthesis with FINAL ANSWER and reasoning."""
        
        final_judgment = self._call_chat(
            "You are a JUDGE synthesizing a debate. Deliver the most accurate answer.",
            judge_msg
        )
        
        return {
            "question": self.question,
            "rounds": rounds_data,
            "final_judgment": final_judgment
        }
    
    def _call_chat(self, system_prompt: str, user_message: str) -> str:
        response = client.chat.completions.create(
            model=self.model,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_message}
            ],
            temperature=0.7,
            max_tokens=800
        )
        return response.choices[0].message.content

# Example: Testing the blind critique on a reasoning-heavy problem
blind_debate = BlindCritiqueDebate(
    question="""A tank fills from a pipe at 10 gallons per minute. 
    Simultaneously, water drains from a hole at the bottom at a rate equal to 
    5% of the current volume per minute. If the tank starts empty and has a 
    capacity of 1000 gallons, how long until it's full?""",
    model="gpt-4o"
)

result = blind_debate.run_blind_debate(num_rounds=2)
print(result["final_judgment"])

Best Practices for Production Deployments

After building numerous debate systems, several patterns emerge for getting reliable results:

Measuring Debate Effectiveness

To know whether your debate system actually improves reasoning, you need rigorous evaluation. Here's a framework for measuring impact:

class DebateEvaluator:
    """
    Quantitatively measures whether multi-agent debate improves
    accuracy over single-model baselines.
    """
    
    def __init__(self, test_dataset: List[Dict]):
        """
        test_dataset: List of {"question": str, "ground_truth": str, 
                                "category": str, "difficulty": str}
        """
        self.dataset = test_dataset
        self.results = []
    
    def evaluate(self, debate_system_class, single_model_func):
        """
        Compare debate system against single-model performance.
        """
        for item in self.dataset:
            question = item["question"]
            ground_truth = item["ground_truth"]
            
            # Single model baseline
            single_answer = single_model_func(question)
            single_correct = self._judge_correctness(
                single_answer, ground_truth
            )
            
            # Debate system
            debate_config = DebateConfig(
                question=question,
                num_rounds=2,
                model="gpt-4o",
                verbose=False
            )
            debate_system = debate_system_class(debate_config)
            debate_result = debate_system.run_debate()
            debate_answer = debate_result["final_answer"]
            debate_correct = self._judge_correctness(
                debate_answer, ground_truth
            )
            
            self.results.append({
                "question": question,
                "category": item.get("category", "general"),
                "difficulty": item.get("difficulty", "medium"),
                "single_model_correct": single_correct,
                "debate_correct": debate_correct,
                "debate_fixed_error": (not single_correct and debate_correct),
                "debate_introduced_error": (single_correct and not debate_correct),
                "single_answer": single_answer,
                "debate_answer": debate_answer,
                "ground_truth": ground_truth
            })
        
        return self._compute_metrics()
    
    def _judge_correctness(self, answer: str, ground_truth: str) -> bool:
        """Use an LLM judge to evaluate correctness."""
        judge_prompt = f"""
Compare the given answer to the ground truth. 
Answer TRUE if the given answer is substantively correct 
(equivalent in meaning, even if wording differs).
Answer FALSE if it contradicts the ground truth or is incorrect.

GIVEN ANSWER: {answer}
GROUND TRUTH: {ground_truth}
CORRECT (TRUE/FALSE):"""
        
        response = client.chat.completions.create(
            model="gpt-4o",
            messages=[{"role": "user", "content": judge_prompt}],
            temperature=0.0,
            max_tokens=10
        )
        return "TRUE" in response.choices

— Ad —

Google AdSense will appear here after approval

← Back to all articles