From 117d8e483ea4445dea0630be60a26df396b650cb Mon Sep 17 00:00:00 2001 From: DavdGao Date: Mon, 5 Aug 2024 20:39:51 +0800 Subject: [PATCH] Implement model-oriented format function in OpenAI and Post API chat wrapper (#381) --- .../en/source/tutorial/206-prompt.md | 83 +++++++----- .../zh_CN/source/tutorial/206-prompt.md | 16 +-- src/agentscope/constants.py | 2 +- src/agentscope/models/dashscope_model.py | 95 +++++-------- src/agentscope/models/gemini_model.py | 36 ++--- src/agentscope/models/litellm_model.py | 114 ++++++++-------- src/agentscope/models/model.py | 127 +++++++++++++++++- src/agentscope/models/ollama_model.py | 17 ++- src/agentscope/models/openai_model.py | 86 ++++++++++-- src/agentscope/models/post_model.py | 53 +++++--- src/agentscope/models/zhipu_model.py | 109 +++++++-------- src/agentscope/prompt/_prompt_optimizer.py | 4 +- .../studio/static/html/dashboard-detail.html | 2 +- .../studio/static/js/dashboard-detail.js | 2 +- src/agentscope/utils/tools.py | 4 +- tests/format_test.py | 101 +++++++++++--- tests/model_test.py | 1 + tests/service_toolkit_test.py | 2 +- 18 files changed, 545 insertions(+), 309 deletions(-) diff --git a/docs/sphinx_doc/en/source/tutorial/206-prompt.md b/docs/sphinx_doc/en/source/tutorial/206-prompt.md index 28a785ed5..47d459527 100644 --- a/docs/sphinx_doc/en/source/tutorial/206-prompt.md +++ b/docs/sphinx_doc/en/source/tutorial/206-prompt.md @@ -8,7 +8,7 @@ especially with different requirements from various model APIs. To ease the process of adapting prompt to different model APIs, AgentScope provides a structured way to organize different data types (e.g. instruction, -hints, dialogue history) into the desired format. +hints, conversation history) into the desired format. Note there is no **one-size-fits-all** solution for prompt crafting. **The goal of built-in strategies is to enable beginners to smoothly invoke @@ -184,7 +184,7 @@ print(prompt) #### Prompt Strategy -If the role field of the first message is `"system"`, it will be converted into a single message with the `role` field as `"system"` and the `content` field as the system message. The rest of the messages will be converted into a message with the `role` field as `"user"` and the `content` field as the dialogue history. +If the role field of the first message is `"system"`, it will be converted into a single message with the `role` field as `"system"` and the `content` field as the system message. The rest of the messages will be converted into a message with the `role` field as `"user"` and the `content` field as the conversation history. An example is shown below: @@ -207,10 +207,18 @@ prompt = model.format( print(prompt) ``` -```bash -[ - {"role": "system", "content": "You are a helpful assistant"}, - {"role": "user", "content": "## Dialogue History\nBob: Hi!\nAlice: Nice to meet you!"}, +```python +prompt = [ + { + "role": "user", + "content": ( + "You are a helpful assistant\n" + "\n" + "## Conversation History\n" + "Bob: Hi!\n" + "Alice: Nice to meet you!" + ) + }, ] ``` @@ -257,7 +265,7 @@ print(prompt) Based on the above rules, the `format` function in `DashScopeMultiModalWrapper` will parse the input messages as follows: - If the first message in the input message list has a `role` field with the value `"system"`, it will be converted into a system message with the `role` field as `"system"` and the `content` field as the system message. If the `url` field in the input `Msg` object is not `None`, a dictionary with the key `"image"` or `"audio"` will be added to the `content` based on its type. -- The rest of the messages will be converted into a message with the `role` field as `"user"` and the `content` field as the dialogue history. For each message, if their `url` field is not `None`, it will add a dictionary with the key `"image"` or `"audio"` to the `content` based on the file type that the `url` points to. +- The rest of the messages will be converted into a message with the `role` field as `"user"` and the `content` field as the conversation history. For each message, if their `url` field is not `None`, it will add a dictionary with the key `"image"` or `"audio"` to the `content` based on the file type that the `url` points to. An example: @@ -292,7 +300,7 @@ print(prompt) { "role": "user", "content": [ - {"text": "## Dialogue History\nBob: Hi!\nAlice: Nice to meet you!"}, + {"text": "## Conversation History\nBob: Hi!\nAlice: Nice to meet you!"}, {"image": "url_to_png2"}, {"image": "url_to_png3"}, ] @@ -316,10 +324,11 @@ own format function for your model. #### Prompt Strategy -- Messages will consist dialogue history in the `user` message prefixed by the system message and "## Dialogue History". +- Messages will consist conversation history in the `user` message prefixed by the system message and "## Conversation History". ```python from agentscope.models import LiteLLMChatWrapper +from agentscope.message import Msg model = LiteLLMChatWrapper( config_name="", # empty since we directly initialize the model wrapper @@ -342,8 +351,10 @@ print(prompt) { "role": "user", "content": ( - "You are a helpful assistant\n\n" - "## Dialogue History\nuser: What is the weather today?\n" + "You are a helpful assistant\n" + "\n" + "## Conversation History\n" + "user: What is the weather today?\n" "assistant: It is sunny today" ), }, @@ -364,12 +375,13 @@ messages as input. The message must obey the following rules (updated in - If the role field of the first input message is `"system"`, it will be treated as system prompt and the other messages will consist -dialogue history in the system message prefixed by "## Dialogue History". +conversation history in the system message prefixed by "## Conversation History". - If the `url` attribute of messages is not `None`, we will gather all urls in the `"images"` field in the returned dictionary. ```python from agentscope.models import OllamaChatWrapper +from agentscope.message import Msg model = OllamaChatWrapper( config_name="", # empty since we directly initialize the model wrapper @@ -387,13 +399,19 @@ prompt = model.format( print(prompt) ``` -```bash +```python [ - { - "role": "system", - "content": "You are a helpful assistant\n\n## Dialogue History\nBob: Hi.\nAlice: Nice to meet you!", - "images": ["https://example.com/image.jpg"] - }, + { + "role": "system", + "content": ( + "You are a helpful assistant\n" + "\n" + "## Conversation History\n" + "Bob: Hi.\n" + "Alice: Nice to meet you!", + ), + "images": ["https://example.com/image.jpg"] + }, ] ``` @@ -404,7 +422,7 @@ takes a string prompt as input without any constraints (updated to 2024/03/22). #### Prompt Strategy -If the role field of the first message is `"system"`, a system prompt will be created. The rest of the messages will be combined into dialogue history in string format. +If the role field of the first message is `"system"`, a system prompt will be created. The rest of the messages will be combined into conversation history in string format. ```python from agentscope.models import OllamaGenerationWrapper @@ -429,7 +447,7 @@ print(prompt) ```bash You are a helpful assistant -## Dialogue History +## Conversation History Bob: Hi. Alice: Nice to meet you! ``` @@ -452,7 +470,7 @@ in our built-in `format` function. #### Prompt Strategy -If the role field of the first message is `"system"`, a system prompt will be added in the beginning. The other messages will be combined into dialogue history. +If the role field of the first message is `"system"`, a system prompt will be added in the beginning. The other messages will be combined into conversation history. **Note** sometimes the `parts` field may contain image urls, which is not supported in `format` function. We recommend developers to customize the @@ -478,14 +496,17 @@ prompt = model.format( print(prompt) ``` -```bash +```python [ - { - "role": "user", - "parts": [ - "You are a helpful assistant\n## Dialogue History\nBob: Hi!\nAlice: Nice to meet you!" - ] - } + { + "role": "user", + "parts": [ + "You are a helpful assistant\n" + "## Conversation History\n" + "Bob: Hi!\n" + "Alice: Nice to meet you!" + ] + } ] ``` @@ -499,7 +520,7 @@ print(prompt) #### Prompt Strategy -If the role field of the first message is `"system"`, it will be converted into a single message with the `role` field as `"system"` and the `content` field as the system message. The rest of the messages will be converted into a message with the `role` field as `"user"` and the `content` field as the dialogue history. +If the role field of the first message is `"system"`, it will be converted into a single message with the `role` field as `"system"` and the `content` field as the system message. The rest of the messages will be converted into a message with the `role` field as `"user"` and the `content` field as the conversation history. An example is shown below: @@ -526,7 +547,7 @@ print(prompt) ```bash [ {"role": "system", "content": "You are a helpful assistant"}, - {"role": "user", "content": "## Dialogue History\nBob: Hi!\nAlice: Nice to meet you!"}, + {"role": "user", "content": "## Conversation History\nBob: Hi!\nAlice: Nice to meet you!"}, ] ``` @@ -537,7 +558,7 @@ prompts for large language models (LLMs). ## About `PromptEngine` Class -The `PromptEngine` class provides a structured way to combine different components of a prompt, such as instructions, hints, dialogue history, and user inputs, into a format that is suitable for the underlying language model. +The `PromptEngine` class provides a structured way to combine different components of a prompt, such as instructions, hints, conversation history, and user inputs, into a format that is suitable for the underlying language model. ### Key Features of PromptEngine diff --git a/docs/sphinx_doc/zh_CN/source/tutorial/206-prompt.md b/docs/sphinx_doc/zh_CN/source/tutorial/206-prompt.md index c2767d902..ed38bad54 100644 --- a/docs/sphinx_doc/zh_CN/source/tutorial/206-prompt.md +++ b/docs/sphinx_doc/zh_CN/source/tutorial/206-prompt.md @@ -184,7 +184,7 @@ print(prompt) ```bash [ {"role": "system", "content": "You are a helpful assistant"}, - {"role": "user", "content": "## Dialogue History\nBob: Hi!\nAlice: Nice to meet you!"}, + {"role": "user", "content": "## Conversation History\nBob: Hi!\nAlice: Nice to meet you!"}, ] ``` @@ -263,7 +263,7 @@ print(prompt) { "role": "user", "content": [ - {"text": "## Dialogue History\nBob: Hi!\nAlice: Nice to meet you!"}, + {"text": "## Conversation History\nBob: Hi!\nAlice: Nice to meet you!"}, {"image": "url_to_png2"}, {"image": "url_to_png3"}, ] @@ -304,7 +304,7 @@ print(prompt) "role": "user", "content": ( "You are a helpful assistant\n\n" - "## Dialogue History\nuser: What is the weather today?\n" + "## Conversation History\nuser: What is the weather today?\n" "assistant: It is sunny today" ), }, @@ -323,7 +323,7 @@ print(prompt) 给定一个消息列表,我们将按照以下规则解析每个消息: - 如果输入的第一条信息的`role`字段是`"system"`,该条信息将被视为系统提示(system - prompt),其他信息将一起组成对话历史。对话历史将添加`"## Dialogue History"`的前缀,并与 + prompt),其他信息将一起组成对话历史。对话历史将添加`"## Conversation History"`的前缀,并与 系统提示一起组成一条`role`为`"system"`的信息。 - 如果输入信息中的`url`字段不为`None`,则这些url将一起被置于`"images"`对应的键值中。 @@ -350,7 +350,7 @@ print(prompt) [ { "role": "system", - "content": "You are a helpful assistant\n\n## Dialogue History\nBob: Hi.\nAlice: Nice to meet you!", + "content": "You are a helpful assistant\n\n## Conversation History\nBob: Hi.\nAlice: Nice to meet you!", "images": ["https://example.com/image.jpg"] }, ] @@ -387,7 +387,7 @@ print(prompt) ```bash You are a helpful assistant -## Dialogue History +## Conversation History Bob: Hi. Alice: Nice to meet you! ``` @@ -436,7 +436,7 @@ print(prompt) { "role": "user", "parts": [ - "You are a helpful assistant\n## Dialogue History\nBob: Hi!\nAlice: Nice to meet you!" + "You are a helpful assistant\n## Conversation History\nBob: Hi!\nAlice: Nice to meet you!" ] } ] @@ -481,7 +481,7 @@ print(prompt) ```bash [ {"role": "system", "content": "You are a helpful assistant"}, - {"role": "user", "content": "## Dialogue History\nBob: Hi!\nAlice: Nice to meet you!"}, + {"role": "user", "content": "## Conversation History\nBob: Hi!\nAlice: Nice to meet you!"}, ] ``` diff --git a/src/agentscope/constants.py b/src/agentscope/constants.py index 1d5297051..87b831b5a 100644 --- a/src/agentscope/constants.py +++ b/src/agentscope/constants.py @@ -27,7 +27,7 @@ # for model wrapper _DEFAULT_MAX_RETRIES = 3 -_DEFAULT_MESSAGES_KEY = "inputs" +_DEFAULT_MESSAGES_KEY = "messages" _DEFAULT_RETRY_INTERVAL = 1 _DEFAULT_API_BUDGET = None # for execute python diff --git a/src/agentscope/models/dashscope_model.py b/src/agentscope/models/dashscope_model.py index 532c5a9a7..0058486ce 100644 --- a/src/agentscope/models/dashscope_model.py +++ b/src/agentscope/models/dashscope_model.py @@ -48,14 +48,13 @@ def __init__( model_name = config_name logger.warning("model_name is not set, use config_name instead.") - super().__init__(config_name=config_name) + super().__init__(config_name=config_name, model_name=model_name) if dashscope is None: raise ImportError( "Cannot find dashscope package in current python environment.", ) - self.model_name = model_name self.generate_args = generate_args or {} self.api_key = api_key @@ -326,12 +325,11 @@ def _save_model_invocation_and_update_monitor( def format( self, *args: Union[Msg, Sequence[Msg]], - ) -> List: - """Format the messages for DashScope Chat API. + ) -> List[dict]: + """A common format strategy for chat models, which will format the + input messages into a user message. - In this format function, the input messages are formatted into a - single system messages with format "{name}: {content}" for each - message. Note this strategy maybe not suitable for all scenarios, + Note this strategy maybe not suitable for all scenarios, and developers are encouraged to implement their own prompt engineering strategies. @@ -339,25 +337,41 @@ def format( .. code-block:: python - prompt = model.format( + prompt1 = model.format( Msg("system", "You're a helpful assistant", role="system"), Msg("Bob", "Hi, how can I help you?", role="assistant"), Msg("user", "What's the date today?", role="user") ) + prompt2 = model.format( + Msg("Bob", "Hi, how can I help you?", role="assistant"), + Msg("user", "What's the date today?", role="user") + ) + The prompt will be as follows: .. code-block:: python + # prompt1 [ { - "role": "system", - "content": "You're a helpful assistant", + "role": "user", + "content": ( + "You're a helpful assistant\\n" + "\\n" + "## Conversation History\\n" + "Bob: Hi, how can I help you?\\n" + "user: What's the date today?" + ) } + ] + + # prompt2 + [ { "role": "user", "content": ( - "## Dialogue History\\n" + "## Conversation History\\n" "Bob: Hi, how can I help you?\\n" "user: What's the date today?" ) @@ -376,54 +390,7 @@ def format( The formatted messages. """ - # Parse all information into a list of messages - input_msgs = [] - for _ in args: - if _ is None: - continue - if isinstance(_, Msg): - input_msgs.append(_) - elif isinstance(_, list) and all(isinstance(__, Msg) for __ in _): - input_msgs.extend(_) - else: - raise TypeError( - f"The input should be a Msg object or a list " - f"of Msg objects, got {type(_)}.", - ) - - messages = [] - - # record dialog history as a list of strings - dialogue = [] - for i, unit in enumerate(input_msgs): - if i == 0 and unit.role == "system": - # system prompt - messages.append( - { - "role": unit.role, - "content": _convert_to_str(unit.content), - }, - ) - else: - # Merge all messages into a dialogue history prompt - dialogue.append( - f"{unit.name}: {_convert_to_str(unit.content)}", - ) - - dialogue_history = "\n".join(dialogue) - - user_content_template = "## Dialogue History\n{dialogue_history}" - - messages.append( - { - "role": "user", - "content": user_content_template.format( - dialogue_history=dialogue_history, - ), - }, - ) - - return messages + return ModelWrapperBase.format_for_common_chat_models(*args) class DashScopeImageSynthesisWrapper(DashScopeWrapperBase): @@ -822,8 +789,8 @@ def format( - If the first message is a system message, then we will keep it as system prompt. - - We merge all messages into a dialogue history prompt in a single - message with the role "user". + - We merge all messages into a conversation history prompt in a + single message with the role "user". - When there are multiple figures in the given messages, we will attach it to the user message by order. Note if there are multiple figures, this strategy may cause misunderstanding for @@ -871,7 +838,7 @@ def format( {"image": "figure3"}, { "text": ( - "## Dialogue History\\n" + "## Conversation History\\n" "Bob: How about this picture?\\n" "user: It's wonderful! How about mine?" ) @@ -937,13 +904,13 @@ def format( dialogue_history = "\n".join(dialogue) - user_content_template = "## Dialogue History\n{dialogue_history}" + user_content_template = "## Conversation History\n{dialogue_history}" messages.append( { "role": "user", "content": [ - # Place the image or audio before the dialogue history + # Place the image or audio before the conversation history *image_or_audio_dicts, { "text": user_content_template.format( diff --git a/src/agentscope/models/gemini_model.py b/src/agentscope/models/gemini_model.py index 3a721f0f2..e5315212b 100644 --- a/src/agentscope/models/gemini_model.py +++ b/src/agentscope/models/gemini_model.py @@ -44,7 +44,7 @@ def __init__( The api_key for the model. If it is not provided, it will be loaded from environment variable. """ - super().__init__(config_name=config_name) + super().__init__(config_name=config_name, model_name=model_name) # Test if the required package is installed if genai is None: @@ -304,8 +304,8 @@ def _extract_text_content_from_response( return response.text + @staticmethod def format( - self, *args: Union[Msg, Sequence[Msg]], ) -> List[dict]: """This function provide a basic prompting strategy for Gemini Chat @@ -344,6 +344,12 @@ def format( `List[dict]`: A list with one user message. """ + if len(args) == 0: + raise ValueError( + "At least one message should be provided. An empty message " + "list is not allowed.", + ) + input_msgs = [] for _ in args: if _ is None: @@ -366,31 +372,27 @@ def format( # system prompt sys_prompt = _convert_to_str(unit.content) else: - # Merge all messages into a dialogue history prompt + # Merge all messages into a conversation history prompt dialogue.append( f"{unit.name}: {_convert_to_str(unit.content)}", ) - dialogue_history = "\n".join(dialogue) + prompt_components = [] + if sys_prompt is not None: + if not sys_prompt.endswith("\n"): + sys_prompt += "\n" + prompt_components.append(sys_prompt) - if sys_prompt is None: - user_content_template = "## Dialogue History\n{dialogue_history}" - else: - user_content_template = ( - "{sys_prompt}\n" - "\n" - "## Dialogue History\n" - "{dialogue_history}" - ) + if len(dialogue) > 0: + prompt_components.extend(["## Conversation History"] + dialogue) + + user_prompt = "\n".join(prompt_components) messages = [ { "role": "user", "parts": [ - user_content_template.format( - sys_prompt=sys_prompt, - dialogue_history=dialogue_history, - ), + user_prompt, ], }, ] diff --git a/src/agentscope/models/litellm_model.py b/src/agentscope/models/litellm_model.py index 9cef58550..948481ae2 100644 --- a/src/agentscope/models/litellm_model.py +++ b/src/agentscope/models/litellm_model.py @@ -8,7 +8,6 @@ from ._model_utils import _verify_text_content_in_openai_delta_response from .model import ModelWrapperBase, ModelResponse from ..message import Msg -from ..utils.tools import _convert_to_str class LiteLLMWrapperBase(ModelWrapperBase, ABC): @@ -52,9 +51,8 @@ def __init__( model_name = config_name logger.warning("model_name is not set, use config_name instead.") - super().__init__(config_name=config_name) + super().__init__(config_name=config_name, model_name=model_name) - self.model_name = model_name self.generate_args = generate_args or {} def format( @@ -296,68 +294,68 @@ def format( self, *args: Union[Msg, Sequence[Msg]], ) -> List[dict]: - """Format the input string and dictionary into the unified format. - Note that the format function might not be the optimal way to construct - prompt for every model, but a common way to do so. - Developers are encouraged to implement their own prompt - engineering strategies if they have strong performance concerns. + """A common format strategy for chat models, which will format the + input messages into a user message. + + Note this strategy maybe not suitable for all scenarios, + and developers are encouraged to implement their own prompt + engineering strategies. + + The following is an example: + + .. code-block:: python + + prompt1 = model.format( + Msg("system", "You're a helpful assistant", role="system"), + Msg("Bob", "Hi, how can I help you?", role="assistant"), + Msg("user", "What's the date today?", role="user") + ) + + prompt2 = model.format( + Msg("Bob", "Hi, how can I help you?", role="assistant"), + Msg("user", "What's the date today?", role="user") + ) + + The prompt will be as follows: + + .. code-block:: python + + # prompt1 + [ + { + "role": "user", + "content": ( + "You're a helpful assistant\\n" + "\\n" + "## Conversation History\\n" + "Bob: Hi, how can I help you?\\n" + "user: What's the date today?" + ) + } + ] + + # prompt2 + [ + { + "role": "user", + "content": ( + "## Conversation History\\n" + "Bob: Hi, how can I help you?\\n" + "user: What's the date today?" + ) + } + ] + Args: args (`Union[Msg, Sequence[Msg]]`): The input arguments to be formatted, where each argument should be a `Msg` object, or a list of `Msg` objects. In distribution, placeholder is also allowed. + Returns: `List[dict]`: - The formatted messages in the format that anthropic Chat API - required. + The formatted messages. """ - # Parse all information into a list of messages - input_msgs = [] - for _ in args: - if _ is None: - continue - if isinstance(_, Msg): - input_msgs.append(_) - elif isinstance(_, list) and all(isinstance(__, Msg) for __ in _): - input_msgs.extend(_) - else: - raise TypeError( - f"The input should be a Msg object or a list " - f"of Msg objects, got {type(_)}.", - ) - - # record dialog history as a list of strings - system_content_template = [] - dialogue = [] - for i, unit in enumerate(input_msgs): - if i == 0 and unit.role == "system": - # system prompt - system_prompt = _convert_to_str(unit.content) - if not system_prompt.endswith("\n"): - system_prompt += "\n" - system_content_template.append(system_prompt) - else: - # Merge all messages into a dialogue history prompt - dialogue.append( - f"{unit.name}: {_convert_to_str(unit.content)}", - ) - - if len(dialogue) != 0: - dialogue_history = "\n".join(dialogue) - - system_content_template.extend( - ["## Dialogue History", dialogue_history], - ) - - system_content = "\n".join(system_content_template) - - messages = [ - { - "role": "user", - "content": system_content, - }, - ] - - return messages + return ModelWrapperBase.format_for_common_chat_models(*args) diff --git a/src/agentscope/models/model.py b/src/agentscope/models/model.py index 5e54df5dc..8d20a108f 100644 --- a/src/agentscope/models/model.py +++ b/src/agentscope/models/model.py @@ -68,7 +68,7 @@ from ..manager import FileManager from ..manager import MonitorManager from ..message import Msg -from ..utils.tools import _get_timestamp +from ..utils.tools import _get_timestamp, _convert_to_str from ..constants import _DEFAULT_MAX_RETRIES from ..constants import _DEFAULT_RETRY_INTERVAL @@ -181,6 +181,7 @@ class in model configuration.""" def __init__( self, # pylint: disable=W0613 config_name: str, + model_name: str, **kwargs: Any, ) -> None: """Base class for model wrapper. @@ -192,10 +193,13 @@ def __init__( config_name (`str`): The id of the model, which is used to extract configuration from the config file. + model_name (`str`): + The name of the model. """ self.monitor = MonitorManager.get_instance() self.config_name = config_name + self.model_name = model_name logger.info(f"Initialize model by configuration [{config_name}]") @classmethod @@ -234,6 +238,127 @@ def format( f" is missing the required `format` method", ) + @staticmethod + def format_for_common_chat_models( + *args: Union[Msg, Sequence[Msg]], + ) -> List[dict]: + """A common format strategy for chat models, which will format the + input messages into a user message. + + Note this strategy maybe not suitable for all scenarios, + and developers are encouraged to implement their own prompt + engineering strategies. + + The following is an example: + + .. code-block:: python + + prompt1 = model.format( + Msg("system", "You're a helpful assistant", role="system"), + Msg("Bob", "Hi, how can I help you?", role="assistant"), + Msg("user", "What's the date today?", role="user") + ) + + prompt2 = model.format( + Msg("Bob", "Hi, how can I help you?", role="assistant"), + Msg("user", "What's the date today?", role="user") + ) + + The prompt will be as follows: + + .. code-block:: python + + # prompt1 + [ + { + "role": "user", + "content": ( + "You're a helpful assistant\\n" + "\\n" + "## Conversation History\\n" + "Bob: Hi, how can I help you?\\n" + "user: What's the date today?" + ) + } + ] + + # prompt2 + [ + { + "role": "user", + "content": ( + "## Conversation History\\n" + "Bob: Hi, how can I help you?\\n" + "user: What's the date today?" + ) + } + ] + + + Args: + args (`Union[Msg, Sequence[Msg]]`): + The input arguments to be formatted, where each argument + should be a `Msg` object, or a list of `Msg` objects. + In distribution, placeholder is also allowed. + + Returns: + `List[dict]`: + The formatted messages. + """ + if len(args) == 0: + raise ValueError( + "At least one message should be provided. An empty message " + "list is not allowed.", + ) + + # Parse all information into a list of messages + input_msgs = [] + for _ in args: + if _ is None: + continue + if isinstance(_, Msg): + input_msgs.append(_) + elif isinstance(_, list) and all(isinstance(__, Msg) for __ in _): + input_msgs.extend(_) + else: + raise TypeError( + f"The input should be a Msg object or a list " + f"of Msg objects, got {type(_)}.", + ) + + # record dialog history as a list of strings + dialogue = [] + sys_prompt = None + for i, unit in enumerate(input_msgs): + if i == 0 and unit.role == "system": + # if system prompt is available, place it at the beginning + sys_prompt = _convert_to_str(unit.content) + else: + # Merge all messages into a conversation history prompt + dialogue.append( + f"{unit.name}: {_convert_to_str(unit.content)}", + ) + + content_components = [] + # Add system prompt at the beginning if provided + if sys_prompt is not None: + if not sys_prompt.endswith("\n"): + sys_prompt += "\n" + content_components.append(sys_prompt) + + # The conversation history is added to the user message if not empty + if len(dialogue) > 0: + content_components.extend(["## Conversation History"] + dialogue) + + messages = [ + { + "role": "user", + "content": "\n".join(content_components), + }, + ] + + return messages + def _save_model_invocation( self, arguments: dict, diff --git a/src/agentscope/models/ollama_model.py b/src/agentscope/models/ollama_model.py index cf38d8942..7d65cafd0 100644 --- a/src/agentscope/models/ollama_model.py +++ b/src/agentscope/models/ollama_model.py @@ -63,9 +63,8 @@ def __init__( Defaults to `None`, which is 127.0.0.1:11434. """ - super().__init__(config_name=config_name) + super().__init__(config_name=config_name, model_name=model_name) - self.model_name = model_name self.options = options self.keep_alive = keep_alive self.client = ollama.Client(host=host, **kwargs) @@ -264,7 +263,7 @@ def format( """Format the messages for ollama Chat API. All messages will be formatted into a single system message with - system prompt and dialogue history. + system prompt and conversation history. Note: 1. This strategy maybe not suitable for all scenarios, @@ -291,7 +290,7 @@ def format( "role": "user", "content": ( "You're a helpful assistant\\n\\n" - "## Dialogue History\\n" + "## Conversation History\\n" "Bob: Hi, how can I help you?\\n" "user: What's the date today?" ) @@ -338,7 +337,7 @@ def format( system_prompt += "\n" system_content_template.append(system_prompt) else: - # Merge all messages into a dialogue history prompt + # Merge all messages into a conversation history prompt dialogue.append( f"{unit.name}: {_convert_to_str(unit.content)}", ) @@ -350,7 +349,7 @@ def format( dialogue_history = "\n".join(dialogue) system_content_template.extend( - ["## Dialogue History", dialogue_history], + ["## Conversation History", dialogue_history], ) system_content = "\n".join(system_content_template) @@ -593,7 +592,7 @@ def format(self, *args: Union[Msg, Sequence[Msg]]) -> str: # system prompt sys_prompt = _convert_to_str(unit.content) else: - # Merge all messages into a dialogue history prompt + # Merge all messages into a conversation history prompt dialogue.append( f"{unit.name}: {_convert_to_str(unit.content)}", ) @@ -601,12 +600,12 @@ def format(self, *args: Union[Msg, Sequence[Msg]]) -> str: dialogue_history = "\n".join(dialogue) if sys_prompt is None: - prompt_template = "## Dialogue History\n{dialogue_history}" + prompt_template = "## Conversation History\n{dialogue_history}" else: prompt_template = ( "{system_prompt}\n" "\n" - "## Dialogue History\n" + "## Conversation History\n" "{dialogue_history}" ) diff --git a/src/agentscope/models/openai_model.py b/src/agentscope/models/openai_model.py index 490284c11..0a87ae381 100644 --- a/src/agentscope/models/openai_model.py +++ b/src/agentscope/models/openai_model.py @@ -1,7 +1,16 @@ # -*- coding: utf-8 -*- """Model wrapper for OpenAI models""" from abc import ABC -from typing import Union, Any, List, Sequence, Dict, Optional, Generator +from typing import ( + Union, + Any, + List, + Sequence, + Dict, + Optional, + Generator, + get_args, +) from loguru import logger @@ -84,9 +93,8 @@ def __init__( model_name = config_name logger.warning("model_name is not set, use config_name instead.") - super().__init__(config_name=config_name) + super().__init__(config_name=config_name, model_name=model_name) - self.model_name = model_name self.generate_args = generate_args or {} try: @@ -331,9 +339,10 @@ def _save_model_invocation_and_update_monitor( completion_tokens=usage.get("completion_tokens", 0), ) + @staticmethod def _format_msg_with_url( - self, msg: Msg, + model_name: str, ) -> Dict: """Format a message with image urls into openai chat format. This format method is used for gpt-4o, gpt-4-turbo, gpt-4-vision and @@ -341,11 +350,11 @@ def _format_msg_with_url( """ # Check if the model is a vision model if not any( - _ in self.model_name - for _ in self.substrings_in_vision_models_names + _ in model_name + for _ in OpenAIChatWrapper.substrings_in_vision_models_names ): logger.warning( - f"The model {self.model_name} is not a vision model. " + f"The model {model_name} is not a vision model. " f"Skip the url in the message.", ) return { @@ -402,18 +411,21 @@ def _format_msg_with_url( return returned_msg - def format( - self, + @staticmethod + def static_format( *args: Union[Msg, Sequence[Msg]], + model_name: str, ) -> List[dict]: - """Format the input string and dictionary into the format that - OpenAI Chat API required. + """A static version of the format method, which can be used without + initializing the OpenAIChatWrapper object. Args: args (`Union[Msg, Sequence[Msg]]`): The input arguments to be formatted, where each argument should be a `Msg` object, or a list of `Msg` objects. In distribution, placeholder is also allowed. + model_name (`str`): + The name of the model to use in OpenAI API. Returns: `List[dict]`: @@ -426,7 +438,13 @@ def format( continue if isinstance(arg, Msg): if arg.url is not None: - messages.append(self._format_msg_with_url(arg)) + # Format the message according to the model type + # (vision/non-vision) + formatted_msg = OpenAIChatWrapper._format_msg_with_url( + arg, + model_name, + ) + messages.append(formatted_msg) else: messages.append( { @@ -437,7 +455,12 @@ def format( ) elif isinstance(arg, list): - messages.extend(self.format(*arg)) + messages.extend( + OpenAIChatWrapper.static_format( + *arg, + model_name=model_name, + ), + ) else: raise TypeError( f"The input should be a Msg object or a list " @@ -446,6 +469,43 @@ def format( return messages + def format( + self, + *args: Union[Msg, Sequence[Msg]], + ) -> List[dict]: + """Format the input string and dictionary into the format that + OpenAI Chat API required. + + Args: + args (`Union[Msg, Sequence[Msg]]`): + The input arguments to be formatted, where each argument + should be a `Msg` object, or a list of `Msg` objects. + In distribution, placeholder is also allowed. + + Returns: + `List[dict]`: + The formatted messages in the format that OpenAI Chat API + required. + """ + # Check if the OpenAI library is installed + try: + import openai + except ImportError as e: + raise ImportError( + "Cannot find openai package, please install it by " + "`pip install openai`", + ) from e + + # Format messages according to the model name + if self.model_name in get_args(openai.types.ChatModel): + return OpenAIChatWrapper.static_format( + *args, + model_name=self.model_name, + ) + else: + # The OpenAI library maybe re-used to support other models + return ModelWrapperBase.format_for_common_chat_models(*args) + class OpenAIDALLEWrapper(OpenAIWrapperBase): """The model wrapper for OpenAI's DALL·E API. diff --git a/src/agentscope/models/post_model.py b/src/agentscope/models/post_model.py index 7167fd6c6..7a6dfa6ec 100644 --- a/src/agentscope/models/post_model.py +++ b/src/agentscope/models/post_model.py @@ -8,12 +8,13 @@ import requests from loguru import logger +from .gemini_model import GeminiChatWrapper +from .openai_model import OpenAIChatWrapper from .model import ModelWrapperBase, ModelResponse from ..constants import _DEFAULT_MAX_RETRIES from ..constants import _DEFAULT_MESSAGES_KEY from ..constants import _DEFAULT_RETRY_INTERVAL from ..message import Msg -from ..utils.tools import _convert_to_str class PostAPIModelWrapperBase(ModelWrapperBase, ABC): @@ -76,7 +77,15 @@ def __init__( **post_args ) """ - super().__init__(config_name=config_name) + if json_args is not None: + model_name = json_args.get( + "model", + json_args.get("model_name", None), + ) + else: + model_name = None + + super().__init__(config_name=config_name, model_name=model_name) self.api_url = api_url self.headers = headers @@ -190,27 +199,27 @@ def format( `Union[List[dict]]`: The formatted messages. """ - messages = [] - for arg in args: - if arg is None: - continue - if isinstance(arg, Msg): - messages.append( - { - "role": arg.role, - "name": arg.name, - "content": _convert_to_str(arg.content), - }, - ) - elif isinstance(arg, list): - messages.extend(self.format(*arg)) - else: - raise TypeError( - f"The input should be a Msg object or a list " - f"of Msg objects, got {type(arg)}.", - ) + # Format according to the potential model field in the json_args + model_name = self.json_args.get( + "model", + self.json_args.get("model_name", None), + ) + + # OpenAI + if model_name.startswith("gpt-"): + return OpenAIChatWrapper.static_format( + *args, + model_name=model_name, + ) + + # Gemini + elif model_name.startswith("gemini"): + return GeminiChatWrapper.format(*args) - return messages + # Include DashScope, ZhipuAI, Ollama, the other models supported by + # litellm and unknown models + else: + return ModelWrapperBase.format_for_common_chat_models(*args) class PostAPIDALLEWrapper(PostAPIModelWrapperBase): diff --git a/src/agentscope/models/zhipu_model.py b/src/agentscope/models/zhipu_model.py index 0aad339fb..c767dd5b4 100644 --- a/src/agentscope/models/zhipu_model.py +++ b/src/agentscope/models/zhipu_model.py @@ -8,7 +8,6 @@ from ._model_utils import _verify_text_content_in_openai_delta_response from .model import ModelWrapperBase, ModelResponse from ..message import Msg -from ..utils.tools import _convert_to_str try: import zhipuai @@ -53,7 +52,7 @@ def __init__( model_name = config_name logger.warning("model_name is not set, use config_name instead.") - super().__init__(config_name=config_name) + super().__init__(config_name=config_name, model_name=model_name) if zhipuai is None: raise ImportError( @@ -298,15 +297,59 @@ def format( self, *args: Union[Msg, Sequence[Msg]], ) -> List[dict]: - """Format the input string and dictionary into the format that - ZhipuAI Chat API required. + """A common format strategy for chat models, which will format the + input messages into a user message. - In this format function, the input messages are formatted into a - single system messages with format "{name}: {content}" for each - message. Note this strategy maybe not suitable for all scenarios, + Note this strategy maybe not suitable for all scenarios, and developers are encouraged to implement their own prompt engineering strategies. + The following is an example: + + .. code-block:: python + + prompt1 = model.format( + Msg("system", "You're a helpful assistant", role="system"), + Msg("Bob", "Hi, how can I help you?", role="assistant"), + Msg("user", "What's the date today?", role="user") + ) + + prompt2 = model.format( + Msg("Bob", "Hi, how can I help you?", role="assistant"), + Msg("user", "What's the date today?", role="user") + ) + + The prompt will be as follows: + + .. code-block:: python + + # prompt1 + [ + { + "role": "user", + "content": ( + "You're a helpful assistant\\n" + "\\n" + "## Conversation History\\n" + "Bob: Hi, how can I help you?\\n" + "user: What's the date today?" + ) + } + ] + + # prompt2 + [ + { + "role": "user", + "content": ( + "## Conversation History\\n" + "Bob: Hi, how can I help you?\\n" + "user: What's the date today?" + ) + } + ] + + Args: args (`Union[Msg, Sequence[Msg]]`): The input arguments to be formatted, where each argument @@ -315,58 +358,10 @@ def format( Returns: `List[dict]`: - The formatted messages in the format that ZhipuAI Chat API - required. + The formatted messages. """ - # Parse all information into a list of messages - input_msgs = [] - for _ in args: - if _ is None: - continue - if isinstance(_, Msg): - input_msgs.append(_) - elif isinstance(_, list) and all(isinstance(__, Msg) for __ in _): - input_msgs.extend(_) - else: - raise TypeError( - f"The input should be a Msg object or a list " - f"of Msg objects, got {type(_)}.", - ) - - messages = [] - - # record dialog history as a list of strings - dialogue = [] - for i, unit in enumerate(input_msgs): - if i == 0 and unit.role == "system": - # system prompt - messages.append( - { - "role": unit.role, - "content": _convert_to_str(unit.content), - }, - ) - else: - # Merge all messages into a dialogue history prompt - dialogue.append( - f"{unit.name}: {_convert_to_str(unit.content)}", - ) - - dialogue_history = "\n".join(dialogue) - - user_content_template = "## Dialogue History\n{dialogue_history}" - - messages.append( - { - "role": "user", - "content": user_content_template.format( - dialogue_history=dialogue_history, - ), - }, - ) - - return messages + return ModelWrapperBase.format_for_common_chat_models(*args) class ZhipuAIEmbeddingWrapper(ZhipuAIWrapperBase): diff --git a/src/agentscope/prompt/_prompt_optimizer.py b/src/agentscope/prompt/_prompt_optimizer.py index b085e2d23..e78062299 100644 --- a/src/agentscope/prompt/_prompt_optimizer.py +++ b/src/agentscope/prompt/_prompt_optimizer.py @@ -120,14 +120,14 @@ def generate_notes( system_prompt: str, dialog_history: List[Msg], ) -> List[str]: - """Given the system prompt and dialogue history, generate notes to + """Given the system prompt and conversation history, generate notes to optimize the system prompt. Args: system_prompt (`str`): The system prompt provided by the user. dialog_history (`List[Msg]`): - The dialogue history of user interaction with the agent. + The conversation history of user interaction with the agent. Returns: List[str]: The notes added to the system prompt. diff --git a/src/agentscope/studio/static/html/dashboard-detail.html b/src/agentscope/studio/static/html/dashboard-detail.html index 39a3c9088..891d8780a 100644 --- a/src/agentscope/studio/static/html/dashboard-detail.html +++ b/src/agentscope/studio/static/html/dashboard-detail.html @@ -8,7 +8,7 @@ xmlns="http://www.w3.org/2000/svg"> - Dialogue + Conversation
list[dict]: }, ) else: - # Merge all messages into a dialogue history prompt + # Merge all messages into a conversation history prompt dialogue.append( f"{unit['name']}: {_convert_to_str(unit['content'])}", ) dialogue_history = "\n".join(dialogue) - user_content_template = "## Dialogue History\n{dialogue_history}" + user_content_template = "## Conversation History\n{dialogue_history}" messages.append( { diff --git a/tests/format_test.py b/tests/format_test.py index 60b2328d9..00582d2ff 100644 --- a/tests/format_test.py +++ b/tests/format_test.py @@ -15,13 +15,12 @@ DashScopeChatWrapper, DashScopeMultiModalWrapper, LiteLLMChatWrapper, + ModelWrapperBase, ) -class ExampleTest(unittest.TestCase): - """ - ExampleTest for a unit test. - """ +class FormatTest(unittest.TestCase): + """Unit test for the format function in the model wrappers.""" def setUp(self) -> None: """Init for ExampleTest.""" @@ -34,6 +33,13 @@ def setUp(self) -> None: ], ] + self.inputs_wo_sys_prompt = [ + [ + Msg("user", "What is the weather today?", role="user"), + Msg("assistant", "It is sunny today", role="assistant"), + ], + ] + self.inputs_vision = [ Msg("system", "You are a helpful assistant", role="system"), [ @@ -212,6 +218,61 @@ def test_openai_chat(self, mock_client: MagicMock) -> None: with self.assertRaises(TypeError): model.format(*self.wrong_inputs) # type: ignore[arg-type] + @patch("builtins.open", mock.mock_open(read_data=b"abcdef")) + @patch("openai.OpenAI") + def test_openai_chat_with_other_models( + self, + mock_client: MagicMock, + ) -> None: + """Test openai chat wrapper with other models.""" + # Prepare the mock client + mock_client.return_value = "client_dummy" + + model = OpenAIChatWrapper( + config_name="", + model_name="glm-4", + client_args={ + "base_url": "http://127.0.0.1:8011/v1/", + }, + ) + + # correct format + ground_truth = [ + { + "role": "user", + "content": ( + "You are a helpful assistant\n" + "\n" + "## Conversation History\n" + "user: What is the weather today?\n" + "assistant: It is sunny today" + ), + }, + ] + + prompt = model.format(*self.inputs) # type: ignore[arg-type] + print(prompt) + self.assertListEqual(prompt, ground_truth) + + def test_format_for_common_models(self) -> None: + """Unit test for format function for common models.""" + prompt = ModelWrapperBase.format_for_common_chat_models(*self.inputs) + + # correct format + ground_truth = [ + { + "role": "user", + "content": ( + "You are a helpful assistant\n" + "\n" + "## Conversation History\n" + "user: What is the weather today?\n" + "assistant: It is sunny today" + ), + }, + ] + self.assertListEqual(prompt, ground_truth) + def test_ollama_chat(self) -> None: """Unit test for the format function in ollama chat api wrapper.""" model = OllamaChatWrapper( @@ -226,7 +287,7 @@ def test_ollama_chat(self) -> None: "content": ( "You are a helpful assistant\n" "\n" - "## Dialogue History\n" + "## Conversation History\n" "user: What is the weather today?\n" "assistant: It is sunny today" ), @@ -248,7 +309,7 @@ def test_ollama_generation(self) -> None: # correct format ground_truth = ( - "You are a helpful assistant\n\n## Dialogue History\nuser: " + "You are a helpful assistant\n\n## Conversation History\nuser: " "What is the weather today?\nassistant: It is sunny today" ) prompt = model.format(*self.inputs) # type: ignore[arg-type] @@ -274,7 +335,7 @@ def test_gemini_chat(self, mock_configure: MagicMock) -> None: { "role": "user", "parts": [ - "You are a helpful assistant\n\n## Dialogue History\n" + "You are a helpful assistant\n\n## Conversation History\n" "user: What is the weather today?\nassistant: It is " "sunny today", ], @@ -297,13 +358,11 @@ def test_dashscope_chat(self) -> None: ) ground_truth = [ - { - "content": "You are a helpful assistant", - "role": "system", - }, { "content": ( - "## Dialogue History\n" + "You are a helpful assistant\n" + "\n" + "## Conversation History\n" "user: What is the weather today?\n" "assistant: It is sunny today" ), @@ -327,13 +386,11 @@ def test_zhipuai_chat(self) -> None: ) ground_truth = [ - { - "content": "You are a helpful assistant", - "role": "system", - }, { "content": ( - "## Dialogue History\n" + "You are a helpful assistant\n" + "\n" + "## Conversation History\n" "user: What is the weather today?\n" "assistant: It is sunny today" ), @@ -360,8 +417,10 @@ def test_litellm_chat(self) -> None: { "role": "user", "content": ( - "You are a helpful assistant\n\n" - "## Dialogue History\nuser: What is the weather today?\n" + "You are a helpful assistant\n" + "\n" + "## Conversation History\n" + "user: What is the weather today?\n" "assistant: It is sunny today" ), }, @@ -421,7 +480,7 @@ def test_dashscope_multimodal_image(self) -> None: {"image": "url3.png"}, { "text": ( - "## Dialogue History\n" + "## Conversation History\n" "user: What is the weather today?\n" "assistant: It is sunny today" ), @@ -484,7 +543,7 @@ def test_dashscope_multimodal_audio(self) -> None: {"audio": "url3.mp3"}, { "text": ( - "## Dialogue History\n" + "## Conversation History\n" "user: What is the weather today?\n" "assistant: It is sunny today" ), diff --git a/tests/model_test.py b/tests/model_test.py index 547215102..992aa528e 100644 --- a/tests/model_test.py +++ b/tests/model_test.py @@ -116,6 +116,7 @@ def test_load_model_configs(self, mock_logging: MagicMock) -> None: model_manager.load_model_configs( model_configs={ "model_type": "TestModelWrapperSimple", + "model_name": "test_model_wrapper", "config_name": "test_model_wrapper", "args": {}, }, diff --git a/tests/service_toolkit_test.py b/tests/service_toolkit_test.py index a910a34af..602537b47 100644 --- a/tests/service_toolkit_test.py +++ b/tests/service_toolkit_test.py @@ -274,7 +274,7 @@ def test_summary(self) -> None: """Test summarization in service toolkit.""" _, doc_dict = ServiceToolkit.get( summarization, - model=ModelWrapperBase("abc"), + model=ModelWrapperBase("abc", "model_name"), system_prompt="", summarization_prompt="", max_return_token=-1,