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 12 kB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  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 DefaultTopicId, RoutedAgent, message_handler
  35. from agnext.components._type_subscription import TypeSubscription
  36. from agnext.components.models import (
  37. AssistantMessage,
  38. ChatCompletionClient,
  39. LLMMessage,
  40. SystemMessage,
  41. UserMessage,
  42. )
  43. from agnext.core import TopicId
  44. sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
  45. from agnext.core import MessageContext
  46. from common.utils import get_chat_completion_client_from_envs
  47. logger = logging.getLogger(__name__)
  48. logger.setLevel(logging.DEBUG)
  49. @dataclass
  50. class Question:
  51. content: str
  52. @dataclass
  53. class Answer:
  54. content: str
  55. @dataclass
  56. class SolverRequest:
  57. session_id: str
  58. content: str
  59. question: str
  60. @dataclass
  61. class IntermediateSolverResponse:
  62. session_id: str
  63. content: str
  64. solver_name: str
  65. answer: str
  66. round: int
  67. @dataclass
  68. class FinalSolverResponse:
  69. session_id: str
  70. answer: str
  71. class MathSolver(RoutedAgent):
  72. def __init__(self, model_client: ChatCompletionClient, neighbor_names: List[str], max_round: int) -> None:
  73. super().__init__("A debator.")
  74. self._model_client = model_client
  75. if self.metadata["type"] in neighbor_names:
  76. raise ValueError("The agent's name cannot be in the list of neighbor names.")
  77. self._neighbor_names = neighbor_names
  78. self._memory: Dict[str, List[LLMMessage]] = {}
  79. self._buffer: Dict[Tuple[str, int], List[IntermediateSolverResponse]] = {}
  80. self._questions: Dict[str, str] = {}
  81. self._system_messages = [
  82. SystemMessage(
  83. (
  84. "You are a helpful assistant with expertise in mathematics and reasoning. "
  85. "Your task is to assist in solving a math reasoning problem by providing "
  86. "a clear and detailed solution. Limit your output within 100 words, "
  87. "and your final answer should be a single numerical number, "
  88. "in the form of {{answer}}, at the end of your response. "
  89. "For example, 'The answer is {{42}}.'"
  90. )
  91. )
  92. ]
  93. self._counters: Dict[str, int] = {}
  94. self._max_round = max_round
  95. @message_handler
  96. async def handle_response(self, message: IntermediateSolverResponse, ctx: MessageContext) -> None:
  97. if message.solver_name not in self._neighbor_names:
  98. return
  99. # Add only neighbor's response to the buffer.
  100. self._buffer.setdefault((message.session_id, message.round), []).append(message)
  101. # Check if all neighbors have responded.
  102. if len(self._buffer[(message.session_id, message.round)]) == len(self._neighbor_names):
  103. question = self._questions[message.session_id]
  104. # Prepare the prompt for the next question.
  105. prompt = "These are the solutions to the problem from other agents:\n"
  106. for resp in self._buffer[(message.session_id, message.round)]:
  107. prompt += f"One agent solution: {resp.content}\n"
  108. prompt += (
  109. "Using the solutions from other agents as additional information, "
  110. "can you provide your answer to the math problem? "
  111. f"The original math problem is {question}. "
  112. "Your final answer should be a single numerical number, "
  113. "in the form of {{answer}}, at the end of your response."
  114. )
  115. # Send the question to the agent itself.
  116. await self.send_message(
  117. SolverRequest(content=prompt, session_id=message.session_id, question=question), self.id
  118. )
  119. # Clear the buffer.
  120. self._buffer.pop((message.session_id, message.round))
  121. @message_handler
  122. async def handle_request(self, message: SolverRequest, ctx: MessageContext) -> None:
  123. # Save the question.
  124. self._questions[message.session_id] = message.question
  125. # Add the question to the memory.
  126. self._memory.setdefault(message.session_id, []).append(UserMessage(content=message.content, source="Host"))
  127. # Make an inference using the model.
  128. response = await self._model_client.create(self._system_messages + self._memory[message.session_id])
  129. assert isinstance(response.content, str)
  130. # Add the response to the memory.
  131. self._memory[message.session_id].append(
  132. AssistantMessage(content=response.content, source=self.metadata["type"])
  133. )
  134. logger.debug(f"Solver {self.metadata['type']} response: {response.content}")
  135. # Extract the answer from the response.
  136. match = re.search(r"\{\{(\-?\d+(\.\d+)?)\}\}", response.content)
  137. if match is None:
  138. raise ValueError("The model response does not contain the answer.")
  139. answer = match.group(1)
  140. # Increment the counter.
  141. self._counters[message.session_id] = self._counters.get(message.session_id, 0) + 1
  142. if self._counters[message.session_id] == self._max_round:
  143. # If the counter reaches the maximum round, publishes a final response.
  144. await self.publish_message(
  145. FinalSolverResponse(answer=answer, session_id=message.session_id), topic_id=DefaultTopicId()
  146. )
  147. else:
  148. # Publish intermediate response.
  149. await self.publish_message(
  150. IntermediateSolverResponse(
  151. content=response.content,
  152. solver_name=self.metadata["type"],
  153. answer=answer,
  154. session_id=message.session_id,
  155. round=self._counters[message.session_id],
  156. ),
  157. topic_id=DefaultTopicId(),
  158. )
  159. class MathAggregator(RoutedAgent):
  160. def __init__(self, num_solvers: int) -> None:
  161. super().__init__("Math Aggregator")
  162. self._num_solvers = num_solvers
  163. self._responses: Dict[str, List[FinalSolverResponse]] = {}
  164. @message_handler
  165. async def handle_question(self, message: Question, ctx: MessageContext) -> None:
  166. prompt = (
  167. f"Can you solve the following math problem?\n{message.content}\n"
  168. "Explain your reasoning. Your final answer should be a single numerical number, "
  169. "in the form of {{answer}}, at the end of your response."
  170. )
  171. session_id = str(uuid.uuid4())
  172. await self.publish_message(
  173. SolverRequest(content=prompt, session_id=session_id, question=message.content), topic_id=DefaultTopicId()
  174. )
  175. @message_handler
  176. async def handle_final_solver_response(self, message: FinalSolverResponse, ctx: MessageContext) -> None:
  177. self._responses.setdefault(message.session_id, []).append(message)
  178. if len(self._responses[message.session_id]) == self._num_solvers:
  179. # Find the majority answer.
  180. answers = [resp.answer for resp in self._responses[message.session_id]]
  181. majority_answer = max(set(answers), key=answers.count)
  182. # Publish the aggregated response.
  183. await self.publish_message(Answer(content=majority_answer), topic_id=DefaultTopicId())
  184. # Clear the responses.
  185. self._responses.pop(message.session_id)
  186. print(f"Aggregated answer: {majority_answer}")
  187. async def main(question: str) -> None:
  188. # Create the runtime.
  189. runtime = SingleThreadedAgentRuntime()
  190. # Register the solver agents.
  191. # Create a sparse connection: each solver agent has two neighbors.
  192. # NOTE: to create a dense connection, each solver agent should be connected to all other solver agents.
  193. await runtime.register(
  194. "MathSolver1",
  195. lambda: MathSolver(
  196. get_chat_completion_client_from_envs(model="gpt-4o-mini"),
  197. neighbor_names=["MathSolver2", "MathSolver4"],
  198. max_round=3,
  199. ),
  200. )
  201. await runtime.add_subscription(TypeSubscription("default", "MathSolver1"))
  202. await runtime.register(
  203. "MathSolver2",
  204. lambda: MathSolver(
  205. get_chat_completion_client_from_envs(model="gpt-4o-mini"),
  206. neighbor_names=["MathSolver1", "MathSolver3"],
  207. max_round=3,
  208. ),
  209. )
  210. await runtime.add_subscription(TypeSubscription("default", "MathSolver2"))
  211. await runtime.register(
  212. "MathSolver3",
  213. lambda: MathSolver(
  214. get_chat_completion_client_from_envs(model="gpt-4o-mini"),
  215. neighbor_names=["MathSolver2", "MathSolver4"],
  216. max_round=3,
  217. ),
  218. )
  219. await runtime.add_subscription(TypeSubscription("default", "MathSolver3"))
  220. await runtime.register(
  221. "MathSolver4",
  222. lambda: MathSolver(
  223. get_chat_completion_client_from_envs(model="gpt-4o-mini"),
  224. neighbor_names=["MathSolver1", "MathSolver3"],
  225. max_round=3,
  226. ),
  227. )
  228. await runtime.add_subscription(TypeSubscription("default", "MathSolver4"))
  229. # Register the aggregator agent.
  230. await runtime.register("MathAggregator", lambda: MathAggregator(num_solvers=4))
  231. runtime.start()
  232. # Send a math problem to the aggregator agent.
  233. await runtime.publish_message(Question(content=question), topic_id=TopicId("default", "default"))
  234. await runtime.stop_when_idle()
  235. if __name__ == "__main__":
  236. import logging
  237. logging.basicConfig(level=logging.WARNING)
  238. logging.getLogger("agnext").setLevel(logging.DEBUG)
  239. asyncio.run(
  240. main(
  241. "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?"
  242. )
  243. )
  244. # Expected output: 72