Files
mlflow--mlflow/mlflow/langchain/api_request_parallel_processor.py
2026-07-13 13:22:34 +08:00

334 lines
13 KiB
Python

# Based ons: https://github.com/openai/openai-cookbook/blob/6df6ceff470eeba26a56de131254e775292eac22/examples/api_request_parallel_processor.py
# Several changes were made to make it work with MLflow.
# Currently, only chat completion is supported.
"""
API REQUEST PARALLEL PROCESSOR
Using the LangChain API to process lots of text quickly takes some care.
If you trickle in a million API requests one by one, they'll take days to complete.
This script parallelizes requests using LangChain API.
Features:
- Streams requests from file, to avoid running out of memory for giant jobs
- Makes requests concurrently, to maximize throughput
- Logs errors, to diagnose problems with requests
"""
from __future__ import annotations
import logging
import queue
import threading
import time
import traceback
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import Any
from mlflow.langchain._compat import import_base_callback_handler, try_import_chain
BaseCallbackHandler = import_base_callback_handler()
Chain = try_import_chain()
import mlflow
from mlflow.exceptions import MlflowException
from mlflow.langchain.utils.chat import (
transform_request_json_for_chat_if_necessary,
try_transform_response_iter_to_chat_format,
try_transform_response_to_chat_format,
)
from mlflow.langchain.utils.serialization import convert_to_serializable
from mlflow.pyfunc.context import Context, get_prediction_context
from mlflow.tracing.utils import maybe_set_prediction_context
_logger = logging.getLogger(__name__)
@dataclass
class StatusTracker:
"""
Stores metadata about the script's progress. Only one instance is created.
"""
num_tasks_started: int = 0
num_tasks_in_progress: int = 0 # script ends when this reaches 0
num_tasks_succeeded: int = 0
num_tasks_failed: int = 0
num_api_errors: int = 0 # excluding rate limit errors, counted above
lock: threading.Lock = threading.Lock()
def start_task(self):
with self.lock:
self.num_tasks_started += 1
self.num_tasks_in_progress += 1
def complete_task(self, *, success: bool):
with self.lock:
self.num_tasks_in_progress -= 1
if success:
self.num_tasks_succeeded += 1
else:
self.num_tasks_failed += 1
def increment_num_api_errors(self):
with self.lock:
self.num_api_errors += 1
@dataclass
class APIRequest:
"""
Stores an API request's inputs, outputs, and other metadata. Contains a method to make an API
call.
Args:
index: The request's index in the tasks list
lc_model: The LangChain model to call
request_json: The request's input data
results: The list to append the request's output data to, it's a list of tuples
(index, response)
errors: A dictionary to store any errors that occur
convert_chat_responses: Whether to convert the model's responses to chat format
did_perform_chat_conversion: Whether the input data was converted to chat format
based on the model's type and input data.
stream: Whether the request is a stream request
prediction_context: The prediction context to use for the request
"""
index: int
lc_model: Any
request_json: dict[str, Any]
results: list[tuple[int, str]]
errors: dict[int, str]
convert_chat_responses: bool
did_perform_chat_conversion: bool
stream: bool
params: dict[str, Any]
prediction_context: Context | None = None
def _predict_single_input(self, single_input, callback_handlers, **kwargs):
config = kwargs.pop("config", {})
config["callbacks"] = config.get("callbacks", []) + (callback_handlers or [])
if self.stream:
return self.lc_model.stream(single_input, config=config, **kwargs)
if hasattr(self.lc_model, "invoke"):
return self.lc_model.invoke(single_input, config=config, **kwargs)
else:
# for backwards compatibility, __call__ is deprecated and will be removed in 0.3.0
# kwargs shouldn't have config field if invoking with __call__
return self.lc_model(single_input, callbacks=callback_handlers, **kwargs)
def _try_convert_response(self, response):
if self.stream:
return try_transform_response_iter_to_chat_format(response)
else:
return try_transform_response_to_chat_format(response)
def single_call_api(self, callback_handlers: list[BaseCallbackHandler] | None):
from mlflow.langchain._compat import import_base_retriever
from mlflow.langchain.utils.logging import langgraph_types, lc_runnables_types
BaseRetriever = import_base_retriever()
if isinstance(self.lc_model, BaseRetriever):
# Retrievers are invoked differently than Chains
response = self.lc_model.get_relevant_documents(
**self.request_json, callbacks=callback_handlers, **self.params
)
elif isinstance(self.lc_model, lc_runnables_types() + langgraph_types()):
if isinstance(self.request_json, dict):
# This is a temporary fix for the case when spark_udf converts
# input into pandas dataframe with column name, while the model
# does not accept dictionaries as input, it leads to errors like
# Expected Scalar value for String field 'query_text'
try:
response = self._predict_single_input(
self.request_json, callback_handlers, **self.params
)
except TypeError as e:
_logger.debug(
f"Failed to invoke {self.lc_model.__class__.__name__} "
f"with {self.request_json}. Error: {e!r}. Trying to "
"invoke with the first value of the dictionary."
)
self.request_json = next(iter(self.request_json.values()))
(
prepared_request_json,
did_perform_chat_conversion,
) = transform_request_json_for_chat_if_necessary(
self.request_json, self.lc_model
)
self.did_perform_chat_conversion = did_perform_chat_conversion
response = self._predict_single_input(
prepared_request_json, callback_handlers, **self.params
)
else:
response = self._predict_single_input(
self.request_json, callback_handlers, **self.params
)
if self.did_perform_chat_conversion or self.convert_chat_responses:
response = self._try_convert_response(response)
else:
# return_only_outputs is invalid for stream call
if Chain and isinstance(self.lc_model, Chain) and not self.stream:
kwargs = {"return_only_outputs": True}
else:
kwargs = {}
kwargs.update(**self.params)
response = self._predict_single_input(self.request_json, callback_handlers, **kwargs)
if self.did_perform_chat_conversion or self.convert_chat_responses:
response = self._try_convert_response(response)
elif isinstance(response, dict) and len(response) == 1:
# to maintain existing code, single output chains will still return
# only the result
response = response.popitem()[1]
return convert_to_serializable(response)
def call_api(
self, status_tracker: StatusTracker, callback_handlers: list[BaseCallbackHandler] | None
):
"""
Calls the LangChain API and stores results.
"""
_logger.debug(f"Request #{self.index} started with payload: {self.request_json}")
try:
with maybe_set_prediction_context(self.prediction_context):
response = self.single_call_api(callback_handlers)
_logger.debug(f"Request #{self.index} succeeded with response: {response}")
self.results.append((self.index, response))
status_tracker.complete_task(success=True)
except Exception as e:
self.errors[self.index] = (
f"error: {e!r} {traceback.format_exc()}\n request payload: {self.request_json}"
)
status_tracker.increment_num_api_errors()
status_tracker.complete_task(success=False)
def process_api_requests(
lc_model,
requests: list[Any | dict[str, Any]] | None = None,
max_workers: int = 10,
callback_handlers: list[BaseCallbackHandler] | None = None,
convert_chat_responses: bool = False,
params: dict[str, Any] | None = None,
context: Context | None = None,
):
"""
Processes API requests in parallel.
"""
# initialize trackers
retry_queue = queue.Queue()
status_tracker = StatusTracker() # single instance to track a collection of variables
next_request = None # variable to hold the next request to call
context = context or get_prediction_context()
results = []
errors = {}
# Note: we should call `transform_request_json_for_chat_if_necessary`
# for the whole batch data, because the conversion should obey the rule
# that if any record in the batch can't be converted, then all the record
# in this batch can't be converted.
(
converted_chat_requests,
did_perform_chat_conversion,
) = transform_request_json_for_chat_if_necessary(requests, lc_model)
requests_iter = enumerate(converted_chat_requests)
with ThreadPoolExecutor(
max_workers=max_workers, thread_name_prefix="MlflowLangChainApi"
) as executor:
while True:
# get next request (if one is not already waiting for capacity)
if not retry_queue.empty():
next_request = retry_queue.get_nowait()
_logger.warning(f"Retrying request {next_request.index}: {next_request}")
elif req := next(requests_iter, None):
# get new request
index, converted_chat_request_json = req
next_request = APIRequest(
index=index,
lc_model=lc_model,
request_json=converted_chat_request_json,
results=results,
errors=errors,
convert_chat_responses=convert_chat_responses,
did_perform_chat_conversion=did_perform_chat_conversion,
stream=False,
prediction_context=context,
params=params,
)
status_tracker.start_task()
else:
next_request = None
# if enough capacity available, call API
if next_request:
# call API
executor.submit(
next_request.call_api,
status_tracker=status_tracker,
callback_handlers=callback_handlers,
)
# if all tasks are finished, break
# check next_request to avoid terminating the process
# before extra requests need to be processed
if status_tracker.num_tasks_in_progress == 0 and next_request is None:
break
time.sleep(0.001) # avoid busy waiting
# after finishing, log final status
if status_tracker.num_tasks_failed > 0:
raise mlflow.MlflowException(
f"{status_tracker.num_tasks_failed} tasks failed. Errors: {errors}"
)
return [res for _, res in sorted(results)]
def process_stream_request(
lc_model,
request_json: Any | dict[str, Any],
callback_handlers: list[BaseCallbackHandler] | None = None,
convert_chat_responses: bool = False,
params: dict[str, Any] | None = None,
):
"""
Process single stream request.
"""
if not hasattr(lc_model, "stream"):
raise MlflowException(
f"Model {lc_model.__class__.__name__} does not support streaming prediction output. "
"No `stream` method found."
)
(
converted_chat_requests,
did_perform_chat_conversion,
) = transform_request_json_for_chat_if_necessary(request_json, lc_model)
api_request = APIRequest(
index=0,
lc_model=lc_model,
request_json=converted_chat_requests,
results=None,
errors=None,
convert_chat_responses=convert_chat_responses,
did_perform_chat_conversion=did_perform_chat_conversion,
stream=True,
prediction_context=get_prediction_context(),
params=params,
)
with maybe_set_prediction_context(api_request.prediction_context):
return api_request.single_call_api(callback_handlers)