mirror of
https://github.com/zenlm/zen-agent.git
synced 2026-10-30 14:58:52 +00:00
882 lines
35 KiB
Python
Executable File
882 lines
35 KiB
Python
Executable File
# Copyright 2023 The Qwen team, Alibaba Group. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import copy
|
|
import json
|
|
import os
|
|
import random
|
|
import time
|
|
from abc import ABC, abstractmethod
|
|
from collections import defaultdict
|
|
from pprint import pformat
|
|
from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple, Union
|
|
|
|
from qwen_agent.llm.schema import ASSISTANT, DEFAULT_SYSTEM_MESSAGE, FUNCTION, SYSTEM, USER, Message
|
|
from qwen_agent.log import logger
|
|
from qwen_agent.settings import DEFAULT_MAX_INPUT_TOKENS
|
|
from qwen_agent.utils.tokenization_qwen import tokenizer
|
|
from qwen_agent.utils.utils import (extract_text_from_message, format_as_multimodal_message, format_as_text_message,
|
|
has_chinese_messages, json_dumps_compact, merge_generate_cfgs, print_traceback)
|
|
|
|
LLM_REGISTRY = {}
|
|
|
|
|
|
def register_llm(model_type):
|
|
|
|
def decorator(cls):
|
|
LLM_REGISTRY[model_type] = cls
|
|
return cls
|
|
|
|
return decorator
|
|
|
|
|
|
class ModelServiceError(Exception):
|
|
|
|
def __init__(self,
|
|
exception: Optional[Exception] = None,
|
|
code: Optional[str] = None,
|
|
message: Optional[str] = None,
|
|
extra: Optional[dict] = None):
|
|
if exception is not None:
|
|
super().__init__(exception)
|
|
else:
|
|
super().__init__(f'\nError code: {code}. Error message: {message}')
|
|
self.exception = exception
|
|
self.code = code
|
|
self.message = message
|
|
self.extra = extra
|
|
|
|
|
|
class BaseChatModel(ABC):
|
|
"""The base class of LLM"""
|
|
|
|
@property
|
|
def support_multimodal_input(self) -> bool:
|
|
# Does the model support multimodal input natively? It affects how we preprocess the input.
|
|
return False
|
|
|
|
@property
|
|
def support_multimodal_output(self) -> bool:
|
|
# Does the model generate multimodal outputs beyond texts? It affects how we post-process the output.
|
|
return False
|
|
|
|
@property
|
|
def support_audio_input(self) -> bool:
|
|
return False
|
|
|
|
def __init__(self, cfg: Optional[Dict] = None):
|
|
cfg = cfg or {}
|
|
self.model = cfg.get('model', '').strip()
|
|
generate_cfg = copy.deepcopy(cfg.get('generate_cfg', {}))
|
|
cache_dir = cfg.get('cache_dir', generate_cfg.pop('cache_dir', None))
|
|
self.max_retries = generate_cfg.pop('max_retries', 0)
|
|
self.generate_cfg = generate_cfg
|
|
self.model_type = cfg.get('model_type', '')
|
|
if 'dashscope' in self.model_type:
|
|
self.generate_cfg['incremental_output'] = True
|
|
|
|
self.use_raw_api = os.getenv('QWEN_AGENT_USE_RAW_API', 'false').lower() == 'true'
|
|
if 'use_raw_api' in generate_cfg:
|
|
self.use_raw_api = generate_cfg.pop('use_raw_api')
|
|
elif self.model_type == 'qwen_dashscope':
|
|
# set qwen3-max to `use_raw_api`
|
|
if self.model == 'qwen3-max' and (not self.use_raw_api):
|
|
logger.info('Setting `use_raw_api` to True when using `Qwen3-Max`')
|
|
self.use_raw_api = True
|
|
|
|
if cache_dir:
|
|
try:
|
|
import diskcache
|
|
except ImportError:
|
|
print_traceback(is_error=False)
|
|
logger.warning('Caching disabled because diskcache is not installed. Please `pip install diskcache`.')
|
|
cache_dir = None
|
|
if cache_dir:
|
|
os.makedirs(cache_dir, exist_ok=True)
|
|
self.cache = diskcache.Cache(directory=cache_dir)
|
|
else:
|
|
self.cache = None
|
|
|
|
def quick_chat(self, prompt: str) -> str:
|
|
*_, responses = self.chat(messages=[Message(role=USER, content=prompt)])
|
|
assert len(responses) == 1
|
|
assert not responses[0].function_call
|
|
assert isinstance(responses[0].content, str)
|
|
return responses[0].content
|
|
|
|
def chat(
|
|
self,
|
|
messages: List[Union[Message, Dict]],
|
|
functions: Optional[List[Dict]] = None,
|
|
stream: bool = True,
|
|
delta_stream: bool = False,
|
|
extra_generate_cfg: Optional[Dict] = None,
|
|
) -> Union[List[Message], List[Dict], Iterator[List[Message]], Iterator[List[Dict]]]:
|
|
"""LLM chat interface.
|
|
|
|
Args:
|
|
messages: Inputted messages.
|
|
functions: Inputted functions for function calling. OpenAI format supported.
|
|
stream: Whether to use streaming generation.
|
|
delta_stream: Whether to stream the response incrementally.
|
|
(1) When False (recommended): Stream the full response every iteration.
|
|
(2) When True: Stream the chunked response, i.e, delta responses.
|
|
extra_generate_cfg: Extra LLM generation hyper-parameters.
|
|
|
|
Returns:
|
|
the generated message list response by llm.
|
|
"""
|
|
|
|
# Unify the input messages to type List[Message]:
|
|
messages = copy.deepcopy(messages)
|
|
_return_message_type = 'dict'
|
|
new_messages = []
|
|
for msg in messages:
|
|
if isinstance(msg, dict):
|
|
new_messages.append(Message(**msg))
|
|
else:
|
|
new_messages.append(msg)
|
|
_return_message_type = 'message'
|
|
messages = new_messages
|
|
|
|
if not messages:
|
|
raise ValueError('Messages can not be empty.')
|
|
|
|
# Cache lookup:
|
|
if self.cache is not None:
|
|
cache_key = dict(messages=messages, functions=functions, extra_generate_cfg=extra_generate_cfg)
|
|
cache_key: str = json_dumps_compact(cache_key, sort_keys=True)
|
|
cache_value: str = self.cache.get(cache_key)
|
|
if cache_value:
|
|
cache_value: List[dict] = json.loads(cache_value)
|
|
if _return_message_type == 'message':
|
|
cache_value: List[Message] = [Message(**m) for m in cache_value]
|
|
if stream:
|
|
cache_value: Iterator[List[Union[Message, dict]]] = iter([cache_value])
|
|
return cache_value
|
|
|
|
if stream and delta_stream:
|
|
logger.warning(
|
|
'Support for `delta_stream=True` is deprecated. '
|
|
'Please use `stream=True and delta_stream=False` or `stream=False` instead. '
|
|
'Using `delta_stream=True` makes it difficult to implement advanced postprocessing and retry mechanisms.'
|
|
)
|
|
|
|
generate_cfg = merge_generate_cfgs(base_generate_cfg=self.generate_cfg, new_generate_cfg=extra_generate_cfg)
|
|
if 'seed' not in generate_cfg:
|
|
generate_cfg['seed'] = random.randint(a=0, b=2**30)
|
|
if 'lang' in generate_cfg:
|
|
lang: Literal['en', 'zh'] = generate_cfg.pop('lang')
|
|
else:
|
|
lang: Literal['en', 'zh'] = 'zh' if has_chinese_messages(messages) else 'en'
|
|
if not stream and 'incremental_output' in generate_cfg:
|
|
generate_cfg.pop('incremental_output')
|
|
|
|
if DEFAULT_SYSTEM_MESSAGE and messages[0].role != SYSTEM:
|
|
messages = [Message(role=SYSTEM, content=DEFAULT_SYSTEM_MESSAGE)] + messages
|
|
|
|
# Not precise. It's hard to estimate tokens related with function calling and multimodal items.
|
|
max_input_tokens = generate_cfg.pop('max_input_tokens', DEFAULT_MAX_INPUT_TOKENS)
|
|
if max_input_tokens > 0:
|
|
messages = _truncate_input_messages_roughly(
|
|
messages=messages,
|
|
max_tokens=max_input_tokens,
|
|
)
|
|
|
|
if functions:
|
|
fncall_mode = True
|
|
else:
|
|
fncall_mode = False
|
|
if 'function_choice' in generate_cfg:
|
|
fn_choice = generate_cfg['function_choice']
|
|
valid_fn_choices = [f.get('name', f.get('name_for_model', None)) for f in (functions or [])]
|
|
valid_fn_choices = ['auto', 'none'] + [f for f in valid_fn_choices if f]
|
|
if fn_choice not in valid_fn_choices:
|
|
raise ValueError(f'The value of function_choice must be one of the following: {valid_fn_choices}. '
|
|
f'But function_choice="{fn_choice}" is received.')
|
|
if fn_choice == 'none':
|
|
fncall_mode = False
|
|
|
|
# Note: the preprocessor's behavior could change if it receives function_choice="none"
|
|
messages = self._preprocess_messages(messages,
|
|
lang=lang,
|
|
generate_cfg=generate_cfg,
|
|
functions=functions,
|
|
use_raw_api=self.use_raw_api)
|
|
if not self.support_multimodal_input:
|
|
messages = [format_as_text_message(msg, add_upload_info=False) for msg in messages]
|
|
|
|
if self.use_raw_api:
|
|
logger.debug('`use_raw_api` takes effect.')
|
|
assert stream and (not delta_stream), '`use_raw_api` only support full stream!!!'
|
|
return self.raw_chat(messages=messages, functions=functions, stream=stream, generate_cfg=generate_cfg)
|
|
|
|
if not fncall_mode:
|
|
for k in ['parallel_function_calls', 'function_choice', 'thought_in_content']:
|
|
if k in generate_cfg:
|
|
del generate_cfg[k]
|
|
|
|
def _call_model_service():
|
|
if fncall_mode:
|
|
return self._chat_with_functions(
|
|
messages=messages,
|
|
functions=functions,
|
|
stream=stream,
|
|
delta_stream=delta_stream,
|
|
generate_cfg=generate_cfg,
|
|
lang=lang,
|
|
)
|
|
else:
|
|
# TODO: Optimize code structure
|
|
if messages[-1].role == ASSISTANT:
|
|
assert not delta_stream, 'Continuation mode does not currently support `delta_stream`'
|
|
return self._continue_assistant_response(messages, generate_cfg=generate_cfg, stream=stream)
|
|
else:
|
|
return self._chat(
|
|
messages,
|
|
stream=stream,
|
|
delta_stream=delta_stream,
|
|
generate_cfg=generate_cfg,
|
|
)
|
|
|
|
if stream and delta_stream:
|
|
# No retry for delta streaming
|
|
output = _call_model_service()
|
|
elif stream and (not delta_stream):
|
|
output = retry_model_service_iterator(_call_model_service, max_retries=self.max_retries)
|
|
else:
|
|
output = retry_model_service(_call_model_service, max_retries=self.max_retries)
|
|
|
|
if isinstance(output, list):
|
|
assert not stream
|
|
logger.debug(f'LLM Output: \n{pformat([_.model_dump() for _ in output], indent=2)}')
|
|
output = self._postprocess_messages(output, fncall_mode=fncall_mode, generate_cfg=generate_cfg)
|
|
if not self.support_multimodal_output:
|
|
output = _format_as_text_messages(messages=output)
|
|
if self.cache:
|
|
self.cache.set(cache_key, json_dumps_compact(output))
|
|
return self._convert_messages_to_target_type(output, _return_message_type)
|
|
else:
|
|
assert stream
|
|
if delta_stream:
|
|
# Hack: To avoid potential errors during the postprocessing of stop words when delta_stream=True.
|
|
# Man, we should never have implemented the support for `delta_stream=True` in the first place!
|
|
generate_cfg = copy.deepcopy(generate_cfg) # copy to avoid conflicts with `_call_model_service`
|
|
assert 'skip_stopword_postproc' not in generate_cfg
|
|
generate_cfg['skip_stopword_postproc'] = True
|
|
output = self._postprocess_messages_iterator(output, fncall_mode=fncall_mode, generate_cfg=generate_cfg)
|
|
|
|
def _format_and_cache() -> Iterator[List[Message]]:
|
|
o = []
|
|
for o in output:
|
|
if o:
|
|
if not self.support_multimodal_output:
|
|
o = _format_as_text_messages(messages=o)
|
|
yield o
|
|
if o and (self.cache is not None):
|
|
self.cache.set(cache_key, json_dumps_compact(o))
|
|
|
|
return self._convert_messages_iterator_to_target_type(_format_and_cache(), _return_message_type)
|
|
|
|
def _chat(
|
|
self,
|
|
messages: List[Union[Message, Dict]],
|
|
stream: bool,
|
|
delta_stream: bool,
|
|
generate_cfg: dict,
|
|
) -> Union[List[Message], Iterator[List[Message]]]:
|
|
if stream:
|
|
return self._chat_stream(messages, delta_stream=delta_stream, generate_cfg=generate_cfg)
|
|
else:
|
|
return self._chat_no_stream(messages, generate_cfg=generate_cfg)
|
|
|
|
@abstractmethod
|
|
def _chat_with_functions(
|
|
self,
|
|
messages: List[Union[Message, Dict]],
|
|
functions: List[Dict],
|
|
stream: bool,
|
|
delta_stream: bool,
|
|
generate_cfg: dict,
|
|
lang: Literal['en', 'zh'],
|
|
) -> Union[List[Message], Iterator[List[Message]]]:
|
|
raise NotImplementedError
|
|
|
|
def _continue_assistant_response(
|
|
self,
|
|
messages: List[Message],
|
|
generate_cfg: dict,
|
|
stream: bool,
|
|
) -> Iterator[List[Message]]:
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
def _chat_stream(
|
|
self,
|
|
messages: List[Message],
|
|
delta_stream: bool,
|
|
generate_cfg: dict,
|
|
) -> Iterator[List[Message]]:
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
def _chat_no_stream(
|
|
self,
|
|
messages: List[Message],
|
|
generate_cfg: dict,
|
|
) -> List[Message]:
|
|
raise NotImplementedError
|
|
|
|
def _preprocess_messages(
|
|
self,
|
|
messages: List[Message],
|
|
lang: Literal['en', 'zh'],
|
|
generate_cfg: dict,
|
|
functions: Optional[List[Dict]] = None,
|
|
use_raw_api: bool = False,
|
|
) -> List[Message]:
|
|
add_multimodel_upload_info = False
|
|
if functions or (not self.support_multimodal_input):
|
|
add_multimodel_upload_info = True
|
|
add_audio_upload_info = False
|
|
if functions or (not self.support_audio_input):
|
|
add_audio_upload_info = True
|
|
messages = [
|
|
format_as_multimodal_message(msg,
|
|
add_upload_info=True,
|
|
add_multimodel_upload_info=add_multimodel_upload_info,
|
|
add_audio_upload_info=add_audio_upload_info,
|
|
lang=lang) for msg in messages
|
|
]
|
|
return messages
|
|
|
|
def _postprocess_messages(
|
|
self,
|
|
messages: List[Message],
|
|
fncall_mode: bool,
|
|
generate_cfg: dict,
|
|
) -> List[Message]:
|
|
messages = [
|
|
format_as_multimodal_message(msg,
|
|
add_upload_info=False,
|
|
add_multimodel_upload_info=False,
|
|
add_audio_upload_info=False) for msg in messages
|
|
]
|
|
if not generate_cfg.get('skip_stopword_postproc', False):
|
|
stop = generate_cfg.get('stop', [])
|
|
messages = _postprocess_stop_words(messages, stop=stop)
|
|
return messages
|
|
|
|
def _postprocess_messages_iterator(
|
|
self,
|
|
messages: Iterator[List[Message]],
|
|
fncall_mode: bool,
|
|
generate_cfg: dict,
|
|
) -> Iterator[List[Message]]:
|
|
pre_msg = []
|
|
for pre_msg in messages:
|
|
yield self._postprocess_messages(pre_msg, fncall_mode=fncall_mode, generate_cfg=generate_cfg)
|
|
logger.debug(f'LLM Output: \n{pformat([_.model_dump() for _ in pre_msg], indent=2)}')
|
|
|
|
def _convert_messages_to_target_type(self, messages: List[Message],
|
|
target_type: str) -> Union[List[Message], List[Dict]]:
|
|
if target_type == 'message':
|
|
return [Message(**x) if isinstance(x, dict) else x for x in messages]
|
|
elif target_type == 'dict':
|
|
return [x.model_dump() if not isinstance(x, dict) else x for x in messages]
|
|
else:
|
|
raise NotImplementedError
|
|
|
|
def _convert_messages_iterator_to_target_type(
|
|
self, messages_iter: Iterator[List[Message]],
|
|
target_type: str) -> Union[Iterator[List[Message]], Iterator[List[Dict]]]:
|
|
for messages in messages_iter:
|
|
yield self._convert_messages_to_target_type(messages, target_type)
|
|
|
|
def raw_chat(
|
|
self,
|
|
messages: List[Union[Message, Dict]],
|
|
functions: Optional[List[Dict]] = None,
|
|
stream: bool = True,
|
|
generate_cfg: Optional[Dict] = None,
|
|
) -> Union[List[Message], List[Dict], Iterator[List[Message]], Iterator[List[Dict]]]:
|
|
if functions and functions[0].get('type') != 'function':
|
|
functions = [{'type': 'function', 'function': f} for f in functions]
|
|
if functions:
|
|
generate_cfg['tools'] = functions
|
|
if stream:
|
|
return self._chat_stream(messages=messages, delta_stream=False, generate_cfg=generate_cfg)
|
|
|
|
@staticmethod
|
|
def _conv_qwen_agent_messages_to_oai(messages: List[Union[Message, Dict]]):
|
|
new_messages = []
|
|
for msg in messages:
|
|
if msg['role'] == ASSISTANT:
|
|
if new_messages[-1]['role'] != ASSISTANT:
|
|
new_messages.append({'role': ASSISTANT})
|
|
if msg.get('content'):
|
|
new_messages[-1]['content'] = msg['content']
|
|
if msg.get('reasoning_content'):
|
|
new_messages[-1]['reasoning_content'] = msg['reasoning_content']
|
|
if msg.get('function_call'):
|
|
if not new_messages[-1].get('tool_calls'):
|
|
new_messages[-1]['tool_calls'] = []
|
|
new_messages[-1]['tool_calls'].append({
|
|
'id': msg.get('extra', {}).get('function_id', '1'),
|
|
'type': 'function',
|
|
'function': {
|
|
'name': msg['function_call']['name'],
|
|
'arguments': msg['function_call']['arguments']
|
|
}
|
|
})
|
|
elif msg['role'] == FUNCTION:
|
|
new_msg = copy.deepcopy(msg)
|
|
new_msg['role'] = 'tool'
|
|
new_msg['id'] = msg.get('extra', {}).get('function_id', '1')
|
|
new_messages.append(new_msg)
|
|
else:
|
|
new_messages.append(msg)
|
|
return new_messages
|
|
|
|
def quick_chat_oai(self, messages: List[dict], tools: Optional[list] = None) -> dict:
|
|
"""
|
|
This is a temporary OpenAI-compatible interface that is encapsulated and may change at any time.
|
|
It is mainly used for temporary interfaces and should not be overly dependent.
|
|
- Only supports full streaming
|
|
- The message is in dict format
|
|
- Only supports text LLM
|
|
"""
|
|
|
|
def _convert_to_qwen_agent_messages(messages):
|
|
new_messages = []
|
|
for msg in messages:
|
|
if msg['role'] in ['system', 'user']:
|
|
new_messages.append(msg)
|
|
elif msg['role'] == 'tool':
|
|
new_msg = copy.deepcopy(msg)
|
|
new_msg['role'] = 'function'
|
|
new_messages.append(new_msg)
|
|
elif msg['role'] == 'assistant':
|
|
if msg['content']:
|
|
new_messages.append({'role': 'assistant', 'content': msg['content']})
|
|
if msg.get('reasoning_content', ''):
|
|
new_messages.append({
|
|
'role': 'assistant',
|
|
'content': '',
|
|
'reasoning_content': msg['reasoning_content']
|
|
})
|
|
if msg.get('tool_calls'):
|
|
for tool in msg.get('tool_calls'):
|
|
new_messages.append({
|
|
'role': 'assistant',
|
|
'content': '',
|
|
'function_call': {
|
|
'name': tool['function']['name'],
|
|
'arguments': tool['function']['arguments']
|
|
}
|
|
})
|
|
return new_messages
|
|
|
|
def _convert_to_oai_message(data):
|
|
message = {'role': 'assistant', 'content': '', 'reasoning_content': '', 'tool_calls': []}
|
|
|
|
for item in data:
|
|
if item.get('reasoning_content'):
|
|
message['reasoning_content'] += item['reasoning_content']
|
|
|
|
if item.get('content'):
|
|
message['content'] += item['content']
|
|
|
|
if item.get('function_call'):
|
|
tool_call = {
|
|
'id': f"{len(message['tool_calls']) + 1}",
|
|
'type': 'function',
|
|
'function': {
|
|
'name': item['function_call']['name'],
|
|
'arguments': item['function_call']['arguments']
|
|
}
|
|
}
|
|
message['tool_calls'].append(tool_call)
|
|
# Fake token usage
|
|
response = {
|
|
'choices': [{
|
|
'message': message
|
|
}],
|
|
'usage': {
|
|
'prompt_tokens': 0,
|
|
'completion_tokens': 0,
|
|
'total_tokens': 0
|
|
}
|
|
}
|
|
return response
|
|
|
|
if tools:
|
|
functions = [tool['function'] for tool in tools]
|
|
else:
|
|
functions = None
|
|
for rsp in self.chat(
|
|
messages=_convert_to_qwen_agent_messages(messages),
|
|
functions=functions,
|
|
stream=True,
|
|
):
|
|
yield _convert_to_oai_message(rsp)
|
|
|
|
|
|
def _format_as_text_messages(messages: List[Message]) -> List[Message]:
|
|
for msg in messages:
|
|
if isinstance(msg.content, list):
|
|
for item in msg.content:
|
|
assert item.type == 'text'
|
|
else:
|
|
assert isinstance(msg.content, str)
|
|
messages = [format_as_text_message(msg, add_upload_info=False) for msg in messages]
|
|
return messages
|
|
|
|
|
|
def _postprocess_stop_words(messages: List[Message], stop: List[str]) -> List[Message]:
|
|
messages = copy.deepcopy(messages)
|
|
if not messages:
|
|
return messages
|
|
|
|
# Make sure it stops before stop words.
|
|
trunc_messages = []
|
|
for msg in messages:
|
|
truncated = False
|
|
trunc_content = []
|
|
for i, item in enumerate(msg.content):
|
|
item_type, item_text = item.get_type_and_value()
|
|
if item_type == 'text':
|
|
truncated, item.text = _truncate_at_stop_word(text=item_text, stop=stop)
|
|
trunc_content.append(item)
|
|
if truncated:
|
|
break
|
|
msg.content = trunc_content
|
|
trunc_messages.append(msg)
|
|
if truncated:
|
|
break
|
|
messages = trunc_messages
|
|
|
|
# It may ends with partial stopword 'Observation' when the full stopword is 'Observation:'.
|
|
# The following post-processing step removes partial stop words.
|
|
partial_stop = []
|
|
for s in stop:
|
|
s = tokenizer.tokenize(s)[:-1]
|
|
if s:
|
|
s = tokenizer.convert_tokens_to_string(s)
|
|
partial_stop.append(s)
|
|
partial_stop = sorted(set(partial_stop))
|
|
if messages:
|
|
last_msg = messages[-1].content
|
|
for i in range(len(last_msg) - 1, -1, -1):
|
|
item_type, item_text = last_msg[i].get_type_and_value()
|
|
if item_type == 'text':
|
|
for s in partial_stop:
|
|
if item_text.endswith(s):
|
|
last_msg[i].text = item_text[:-len(s)]
|
|
break
|
|
|
|
return messages
|
|
|
|
|
|
def _truncate_at_stop_word(text: str, stop: List[str]):
|
|
truncated = False
|
|
for s in stop:
|
|
k = text.find(s)
|
|
if k >= 0:
|
|
truncated = True
|
|
text = text[:k]
|
|
return truncated, text
|
|
|
|
|
|
def _truncate_input_messages_roughly(messages: List[Message], max_tokens: int) -> List[Message]:
|
|
if len([m for m in messages if m.role == SYSTEM]) >= 2:
|
|
raise ModelServiceError(
|
|
code='400',
|
|
message='The input messages must contain no more than one system message. '
|
|
' And the system message, if exists, must be the first message.',
|
|
)
|
|
if not messages:
|
|
return messages
|
|
|
|
turns = []
|
|
for m in messages:
|
|
if m.role == SYSTEM:
|
|
continue
|
|
elif m.role == USER:
|
|
turns.append([m])
|
|
else:
|
|
if turns:
|
|
turns[-1].append(m)
|
|
else:
|
|
raise ModelServiceError(
|
|
code='400',
|
|
message='The input messages (excluding the system message) must start with a user message.',
|
|
)
|
|
|
|
def _count_tokens(msg: Message) -> int:
|
|
if msg.role == ASSISTANT and msg.function_call:
|
|
return tokenizer.count_tokens(f'{msg.function_call}')
|
|
return tokenizer.count_tokens(extract_text_from_message(msg, add_upload_info=True))
|
|
|
|
def _truncate_message(msg: Message, max_tokens: int, keep_both_sides: bool = False):
|
|
if isinstance(msg.content, str):
|
|
content = tokenizer.truncate(msg.content, max_token=max_tokens, keep_both_sides=keep_both_sides)
|
|
else:
|
|
text = []
|
|
for item in msg.content:
|
|
if not item.text:
|
|
return None
|
|
text.append(item.text)
|
|
text = '\n'.join(text)
|
|
content = tokenizer.truncate(text, max_token=max_tokens, keep_both_sides=keep_both_sides)
|
|
return Message(role=msg.role, content=content)
|
|
|
|
def _truncate_turn(indexed_messages1: list, message_tokens1: dict, exceedance: int, is_last_turn: bool):
|
|
# ******* rm this turn *******
|
|
all_tokens = 0
|
|
for msg_idx, msg in indexed_messages1:
|
|
all_tokens += message_tokens1[msg_idx]
|
|
logger.debug(f'exceedance start: {exceedance}, all tokens of this turn {all_tokens}')
|
|
if all_tokens <= exceedance:
|
|
# remove all turn
|
|
return [], (exceedance - all_tokens)
|
|
|
|
# ******* trunk this turn *******
|
|
if len(indexed_messages1) == 1:
|
|
assert is_last_turn
|
|
# very long user
|
|
idx, msg = indexed_messages1[0]
|
|
msg = _truncate_message(msg=msg, max_tokens=message_tokens1[idx] - exceedance, keep_both_sides=True)
|
|
return [msg], 0
|
|
|
|
indexed_messages1 = copy.deepcopy(indexed_messages1)
|
|
message_tokens1 = copy.deepcopy(message_tokens1)
|
|
|
|
# split this turn by step
|
|
messages_per_step = [] # [ [ (idx, msg), (idx, msg) ], [], ... ]
|
|
for msg_idx, msg in indexed_messages1:
|
|
if msg.role == USER:
|
|
if messages_per_step and messages_per_step[-1][-1][1].role == USER:
|
|
messages_per_step[-1].append([msg_idx, msg])
|
|
else:
|
|
messages_per_step.append([[msg_idx, msg]])
|
|
elif msg.role == ASSISTANT:
|
|
if messages_per_step and messages_per_step[-1][-1][1].role == ASSISTANT:
|
|
messages_per_step[-1].append([msg_idx, msg])
|
|
else:
|
|
messages_per_step.append([[msg_idx, msg]])
|
|
elif msg.role == FUNCTION:
|
|
messages_per_step[-1].append([msg_idx, msg])
|
|
|
|
last_step_idx = messages_per_step[-1][0][0]
|
|
|
|
# step1: minimized function result
|
|
logger.debug(f'exceedance step1 **minimized function result**: {exceedance}')
|
|
for i, (msg_idx, msg) in enumerate(indexed_messages1):
|
|
if exceedance <= 0 or msg.role != FUNCTION or (is_last_turn and msg_idx >= last_step_idx):
|
|
continue
|
|
|
|
fn_msg_tokens = message_tokens1[msg_idx]
|
|
|
|
if fn_msg_tokens > exceedance: # enough save room, can be truncated
|
|
msg = _truncate_message(msg=msg, max_tokens=fn_msg_tokens - exceedance, keep_both_sides=True)
|
|
indexed_messages1[i][1] = msg
|
|
message_tokens1[msg_idx] = fn_msg_tokens - exceedance
|
|
exceedance = 0 # force to set to 0 to avoid _truncate_message corner cases since we know there is no exceedance
|
|
break
|
|
else:
|
|
msg.content = 'omit'
|
|
message_tokens1[msg_idx] = 0
|
|
exceedance -= fn_msg_tokens
|
|
if exceedance <= 0:
|
|
return [x[1] for x in indexed_messages1], 0
|
|
|
|
# step2: rm middle step
|
|
logger.debug(f'exceedance step2 **rm middle step**: {exceedance}')
|
|
# keep_messages =
|
|
keep_idx = 0
|
|
for i, step in enumerate(messages_per_step):
|
|
if i == 0 or i == (len(messages_per_step) - 1):
|
|
continue
|
|
step_tokens = sum([message_tokens1[x[0]] for x in step])
|
|
if step_tokens >= exceedance:
|
|
exceedance = 0
|
|
keep_idx = messages_per_step[i + 1][0][0]
|
|
break
|
|
else:
|
|
exceedance -= step_tokens
|
|
keep_idx = messages_per_step[i + 1][0][0]
|
|
|
|
if exceedance <= 0:
|
|
res = [x[1] for x in messages_per_step[0]] + [x[1] for x in indexed_messages1 if x[0] >= keep_idx]
|
|
return res, 0
|
|
|
|
# step3: trunk FUNCTION of last step
|
|
logger.debug(f'exceedance step3 **trunk FUNCTION of last step**: {exceedance}')
|
|
messages_to_keep = []
|
|
for msg_idx, msg in messages_per_step[-1]:
|
|
if msg.role != FUNCTION:
|
|
messages_to_keep.append([msg_idx, msg])
|
|
continue
|
|
|
|
fn_msg_tokens = message_tokens1[msg_idx]
|
|
|
|
if fn_msg_tokens > exceedance: # enough save room, can be truncated
|
|
msg = _truncate_message(msg=msg, max_tokens=fn_msg_tokens - exceedance, keep_both_sides=True)
|
|
exceedance = 0 # force to set to 0 to avoid _truncate_message corner cases since we know there is no exceedance
|
|
else:
|
|
msg.content = 'omit'
|
|
message_tokens1[msg_idx] = 0
|
|
exceedance -= fn_msg_tokens
|
|
messages_to_keep.append([msg_idx, msg])
|
|
|
|
messages_to_keep = messages_per_step[0] + messages_to_keep
|
|
if exceedance <= 0:
|
|
return [x[1] for x in messages_to_keep], 0
|
|
|
|
# step4: trunk content of user/assistant
|
|
logger.debug(f'exceedance step4 **trunk content of user/assistant**: {exceedance}')
|
|
for i, (msg_idx, msg) in enumerate(messages_to_keep):
|
|
fn_msg_tokens = message_tokens1[msg_idx]
|
|
|
|
if fn_msg_tokens > exceedance: # enough save room, can be truncated
|
|
msg = _truncate_message(msg=msg, max_tokens=fn_msg_tokens - exceedance, keep_both_sides=True)
|
|
messages_to_keep[i][1] = msg
|
|
exceedance = 0 # force to set to 0 to avoid _truncate_message corner cases since we know there is no exceedance
|
|
break
|
|
else:
|
|
msg.content = 'omit'
|
|
exceedance -= fn_msg_tokens
|
|
|
|
return [x[1] for x in messages_to_keep], 0
|
|
|
|
available_token = max_tokens
|
|
message_tokens = defaultdict(int)
|
|
last_user_idx = None
|
|
indexed_messages_per_user = defaultdict(list) # user_msg_idx -> [(msg_idx, msg)...]
|
|
new_messages = []
|
|
for msg_idx, msg in enumerate(messages):
|
|
if msg.role == SYSTEM:
|
|
new_messages.append(msg)
|
|
available_token = max_tokens - _count_tokens(msg=msg)
|
|
continue
|
|
message_tokens[msg_idx] = _count_tokens(msg=msg)
|
|
if msg.role == USER:
|
|
last_user_idx = msg_idx
|
|
indexed_messages_per_user[last_user_idx].append([msg_idx, msg])
|
|
|
|
all_tokens = sum([x for x in message_tokens.values()])
|
|
logger.info(f'ALL tokens: {all_tokens}, Available tokens: {available_token}')
|
|
if all_tokens <= available_token:
|
|
return messages
|
|
if available_token <= 0:
|
|
raise ModelServiceError(
|
|
code='400',
|
|
message=f'The input system has exceed the maximum input context length ({max_tokens} tokens)',
|
|
)
|
|
|
|
exceedance = all_tokens - available_token # make exceedance <= 0 -> ok
|
|
for it, (user_msg_idx, indexed_messages) in enumerate(indexed_messages_per_user.items()):
|
|
logger.debug(f'user_msg_idx: {user_msg_idx}, exceedance: {exceedance}')
|
|
if exceedance <= 0:
|
|
new_messages += [x[1] for x in indexed_messages]
|
|
continue
|
|
else:
|
|
is_last_turn = (it == len(indexed_messages_per_user) - 1)
|
|
new_turn, exceedance = _truncate_turn(indexed_messages1=indexed_messages,
|
|
message_tokens1=message_tokens,
|
|
exceedance=exceedance,
|
|
is_last_turn=is_last_turn)
|
|
if new_turn:
|
|
new_messages += new_turn
|
|
|
|
return new_messages
|
|
|
|
|
|
def retry_model_service(
|
|
fn,
|
|
max_retries: int = 10,
|
|
) -> Any:
|
|
"""Retry a function"""
|
|
|
|
num_retries, delay = 0, 1.0
|
|
while True:
|
|
try:
|
|
return fn()
|
|
|
|
except ModelServiceError as e:
|
|
num_retries, delay = _raise_or_delay(e, num_retries, delay, max_retries)
|
|
|
|
|
|
def retry_model_service_iterator(
|
|
it_fn,
|
|
max_retries: int = 10,
|
|
) -> Iterator:
|
|
"""Retry an iterator"""
|
|
|
|
num_retries, delay = 0, 1.0
|
|
while True:
|
|
try:
|
|
for rsp in it_fn():
|
|
yield rsp
|
|
break
|
|
|
|
except ModelServiceError as e:
|
|
num_retries, delay = _raise_or_delay(e, num_retries, delay, max_retries)
|
|
|
|
|
|
def _raise_or_delay(
|
|
e: ModelServiceError,
|
|
num_retries: int,
|
|
delay: float,
|
|
max_retries: int = 10,
|
|
max_delay: float = 300.0,
|
|
exponential_base: float = 2.0,
|
|
) -> Tuple[int, float]:
|
|
"""Retry with exponential backoff"""
|
|
|
|
if max_retries <= 0: # no retry
|
|
raise e
|
|
|
|
# Bad request, e.g., incorrect config or input
|
|
if e.code == '400':
|
|
raise e
|
|
|
|
# If harmful input or output detected, let it fail
|
|
if e.code == 'DataInspectionFailed':
|
|
raise e
|
|
if 'inappropriate content' in str(e):
|
|
raise e
|
|
|
|
# Retry is meaningless if the input is too long
|
|
if 'maximum context length' in str(e):
|
|
raise e
|
|
|
|
logger.warning('ModelServiceError - ' + str(e).strip('\n'))
|
|
|
|
if num_retries >= max_retries:
|
|
raise ModelServiceError(exception=Exception(f'Maximum number of retries ({max_retries}) exceeded.'))
|
|
|
|
num_retries += 1
|
|
jitter = 1.0 + random.random()
|
|
delay = min(delay * exponential_base, max_delay) * jitter
|
|
time.sleep(delay)
|
|
return num_retries, delay
|
|
|
|
|
|
def _rm_think(text: str) -> str:
|
|
if '</think>' in text:
|
|
return text.split('</think>')[-1].lstrip('\n')
|
|
return text
|