You can not select more than 25 topics Topics must start with a chinese character,a letter or number, can include dashes ('-') and can be up to 35 characters long.

multi_agent_debate.py 11 kB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. """
  2. This example shows an implementation of the multi-agent debate pattern
  3. for solving math problems from GSM8K benchmark (https://huggingface.co/datasets/openai/gsm8k).
  4. The example consists of two types of agents: solver agents and an aggregator agent.
  5. The solver agents are connected in a sparse manner following the technique described in
  6. "Improving Multi-Agent Debate with Sparse Communication Topology" (https://arxiv.org/abs/2406.11776).
  7. For example, consider the following connection pattern:
  8. A --- B
  9. | |
  10. | |
  11. C --- D
  12. In this pattern, each solver agent is connected to two other solver agents.
  13. The pattern works as follows:
  14. 1. The main function sends a math problem to the aggregator agent.
  15. 2. The aggregator agent distributes the problem to the solver agents.
  16. 3. Each solver agent processes the problem, and publishes a response to all other solver agents.
  17. 4. Each solver agent again use the responses from other agents to refine its response, publish a new response.
  18. 5. Repeat step 4 for a fixed number of rounds. In the final round, each solver agent publish a final response.
  19. 6. The aggregator agent use majority voting to aggregate the final responses from all solver agents to get the final answer, and publishes the answer.
  20. To make the connection dense, modify SolverAgent's handle_response method
  21. to consider all neighbors' responses to use.
  22. To make the connection probabilistic, modify SolverAgent's handle_response method
  23. to sample a random number of neighbors' responses to use.
  24. """
  25. import asyncio
  26. import logging
  27. import os
  28. import re
  29. import sys
  30. import uuid
  31. from dataclasses import dataclass
  32. from typing import Dict, List, Tuple
  33. from agnext.application import SingleThreadedAgentRuntime
  34. from agnext.components import TypeRoutedAgent, message_handler
  35. from agnext.components.models import (
  36. AssistantMessage,
  37. ChatCompletionClient,
  38. LLMMessage,
  39. SystemMessage,
  40. UserMessage,
  41. )
  42. sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
  43. from agnext.core import MessageContext
  44. from common.utils import get_chat_completion_client_from_envs
  45. logger = logging.getLogger(__name__)
  46. logger.setLevel(logging.DEBUG)
  47. @dataclass
  48. class Question:
  49. content: str
  50. @dataclass
  51. class Answer:
  52. content: str
  53. @dataclass
  54. class SolverRequest:
  55. session_id: str
  56. content: str
  57. question: str
  58. @dataclass
  59. class IntermediateSolverResponse:
  60. session_id: str
  61. content: str
  62. solver_name: str
  63. answer: str
  64. round: int
  65. @dataclass
  66. class FinalSolverResponse:
  67. session_id: str
  68. answer: str
  69. class MathSolver(TypeRoutedAgent):
  70. def __init__(self, model_client: ChatCompletionClient, neighbor_names: List[str], max_round: int) -> None:
  71. super().__init__("A debator.")
  72. self._model_client = model_client
  73. if self.metadata["type"] in neighbor_names:
  74. raise ValueError("The agent's name cannot be in the list of neighbor names.")
  75. self._neighbor_names = neighbor_names
  76. self._memory: Dict[str, List[LLMMessage]] = {}
  77. self._buffer: Dict[Tuple[str, int], List[IntermediateSolverResponse]] = {}
  78. self._questions: Dict[str, str] = {}
  79. self._system_messages = [
  80. SystemMessage(
  81. (
  82. "You are a helpful assistant with expertise in mathematics and reasoning. "
  83. "Your task is to assist in solving a math reasoning problem by providing "
  84. "a clear and detailed solution. Limit your output within 100 words, "
  85. "and your final answer should be a single numerical number, "
  86. "in the form of {{answer}}, at the end of your response. "
  87. "For example, 'The answer is {{42}}.'"
  88. )
  89. )
  90. ]
  91. self._counters: Dict[str, int] = {}
  92. self._max_round = max_round
  93. @message_handler
  94. async def handle_response(self, message: IntermediateSolverResponse, ctx: MessageContext) -> None:
  95. if message.solver_name not in self._neighbor_names:
  96. return
  97. # Add only neighbor's response to the buffer.
  98. self._buffer.setdefault((message.session_id, message.round), []).append(message)
  99. # Check if all neighbors have responded.
  100. if len(self._buffer[(message.session_id, message.round)]) == len(self._neighbor_names):
  101. question = self._questions[message.session_id]
  102. # Prepare the prompt for the next question.
  103. prompt = "These are the solutions to the problem from other agents:\n"
  104. for resp in self._buffer[(message.session_id, message.round)]:
  105. prompt += f"One agent solution: {resp.content}\n"
  106. prompt += (
  107. "Using the solutions from other agents as additional information, "
  108. "can you provide your answer to the math problem? "
  109. f"The original math problem is {question}. "
  110. "Your final answer should be a single numerical number, "
  111. "in the form of {{answer}}, at the end of your response."
  112. )
  113. # Send the question to the agent itself.
  114. await self.send_message(
  115. SolverRequest(content=prompt, session_id=message.session_id, question=question), self.id
  116. )
  117. # Clear the buffer.
  118. self._buffer.pop((message.session_id, message.round))
  119. @message_handler
  120. async def handle_request(self, message: SolverRequest, ctx: MessageContext) -> None:
  121. # Save the question.
  122. self._questions[message.session_id] = message.question
  123. # Add the question to the memory.
  124. self._memory.setdefault(message.session_id, []).append(UserMessage(content=message.content, source="Host"))
  125. # Make an inference using the model.
  126. response = await self._model_client.create(self._system_messages + self._memory[message.session_id])
  127. assert isinstance(response.content, str)
  128. # Add the response to the memory.
  129. self._memory[message.session_id].append(
  130. AssistantMessage(content=response.content, source=self.metadata["type"])
  131. )
  132. logger.debug(f"Solver {self.metadata['type']} response: {response.content}")
  133. # Extract the answer from the response.
  134. match = re.search(r"\{\{(\-?\d+(\.\d+)?)\}\}", response.content)
  135. if match is None:
  136. raise ValueError("The model response does not contain the answer.")
  137. answer = match.group(1)
  138. # Increment the counter.
  139. self._counters[message.session_id] = self._counters.get(message.session_id, 0) + 1
  140. if self._counters[message.session_id] == self._max_round:
  141. # If the counter reaches the maximum round, publishes a final response.
  142. await self.publish_message(FinalSolverResponse(answer=answer, session_id=message.session_id))
  143. else:
  144. # Publish intermediate response.
  145. await self.publish_message(
  146. IntermediateSolverResponse(
  147. content=response.content,
  148. solver_name=self.metadata["type"],
  149. answer=answer,
  150. session_id=message.session_id,
  151. round=self._counters[message.session_id],
  152. )
  153. )
  154. class MathAggregator(TypeRoutedAgent):
  155. def __init__(self, num_solvers: int) -> None:
  156. super().__init__("Math Aggregator")
  157. self._num_solvers = num_solvers
  158. self._responses: Dict[str, List[FinalSolverResponse]] = {}
  159. @message_handler
  160. async def handle_question(self, message: Question, ctx: MessageContext) -> None:
  161. prompt = (
  162. f"Can you solve the following math problem?\n{message.content}\n"
  163. "Explain your reasoning. Your final answer should be a single numerical number, "
  164. "in the form of {{answer}}, at the end of your response."
  165. )
  166. session_id = str(uuid.uuid4())
  167. await self.publish_message(SolverRequest(content=prompt, session_id=session_id, question=message.content))
  168. @message_handler
  169. async def handle_final_solver_response(self, message: FinalSolverResponse, ctx: MessageContext) -> None:
  170. self._responses.setdefault(message.session_id, []).append(message)
  171. if len(self._responses[message.session_id]) == self._num_solvers:
  172. # Find the majority answer.
  173. answers = [resp.answer for resp in self._responses[message.session_id]]
  174. majority_answer = max(set(answers), key=answers.count)
  175. # Publish the aggregated response.
  176. await self.publish_message(Answer(content=majority_answer))
  177. # Clear the responses.
  178. self._responses.pop(message.session_id)
  179. print(f"Aggregated answer: {majority_answer}")
  180. async def main(question: str) -> None:
  181. # Create the runtime.
  182. runtime = SingleThreadedAgentRuntime()
  183. # Register the solver agents.
  184. # Create a sparse connection: each solver agent has two neighbors.
  185. # NOTE: to create a dense connection, each solver agent should be connected to all other solver agents.
  186. await runtime.register(
  187. "MathSolver1",
  188. lambda: MathSolver(
  189. get_chat_completion_client_from_envs(model="gpt-4o-mini"),
  190. neighbor_names=["MathSolver2", "MathSolver4"],
  191. max_round=3,
  192. ),
  193. )
  194. await runtime.register(
  195. "MathSolver2",
  196. lambda: MathSolver(
  197. get_chat_completion_client_from_envs(model="gpt-4o-mini"),
  198. neighbor_names=["MathSolver1", "MathSolver3"],
  199. max_round=3,
  200. ),
  201. )
  202. await runtime.register(
  203. "MathSolver3",
  204. lambda: MathSolver(
  205. get_chat_completion_client_from_envs(model="gpt-4o-mini"),
  206. neighbor_names=["MathSolver2", "MathSolver4"],
  207. max_round=3,
  208. ),
  209. )
  210. await runtime.register(
  211. "MathSolver4",
  212. lambda: MathSolver(
  213. get_chat_completion_client_from_envs(model="gpt-4o-mini"),
  214. neighbor_names=["MathSolver1", "MathSolver3"],
  215. max_round=3,
  216. ),
  217. )
  218. # Register the aggregator agent.
  219. await runtime.register("MathAggregator", lambda: MathAggregator(num_solvers=4))
  220. run_context = runtime.start()
  221. # Send a math problem to the aggregator agent.
  222. await runtime.publish_message(Question(content=question), namespace="default")
  223. await run_context.stop_when_idle()
  224. if __name__ == "__main__":
  225. import logging
  226. logging.basicConfig(level=logging.WARNING)
  227. logging.getLogger("agnext").setLevel(logging.DEBUG)
  228. asyncio.run(
  229. main(
  230. "Natalia sold clips to 48 of her friends in April, and then she sold half as many clips in May. How many clips did Natalia sell altogether in April and May?"
  231. )
  232. )
  233. # Expected output: 72