Files
zenml/venv/lib/python3.9/site-packages/mlflow/langchain/_langchain_autolog.py
Christian Mantha 2ca0b9ef7c star
2026-03-02 19:10:52 -05:00

283 lines
11 KiB
Python

import contextlib
import inspect
import logging
import uuid
import warnings
from copy import deepcopy
from packaging.version import Version
import mlflow
from mlflow.entities import RunTag
from mlflow.entities.run_status import RunStatus
from mlflow.exceptions import MlflowException
from mlflow.langchain.runnables import get_runnable_steps
from mlflow.tracking.context import registry as context_registry
from mlflow.utils import name_utils
from mlflow.utils.autologging_utils import get_autologging_config
from mlflow.utils.autologging_utils.config import AutoLoggingConfig
from mlflow.utils.autologging_utils.safety import _resolve_extra_tags
_logger = logging.getLogger(__name__)
UNSUPPORTED_LOG_MODEL_MESSAGE = (
"MLflow autologging does not support logging models containing BaseRetriever because "
"logging the model requires `loader_fn` and `persist_dir`. Please log the model manually "
"using `mlflow.langchain.log_model(model, artifact_path, loader_fn=..., persist_dir=...)`"
)
INFERENCE_FILE_NAME = "inference_inputs_outputs.json"
# A *global* state that indicates whether MLflow should patch the inference method
# for artifact auto-logging (model, signature input example). This disablement
# is global across threads, as single model inference can trigger multiple threads,
# for example, LangChain's batch()/abatch() API processes each request in a child thread.
IS_PATCHING_DISABLED_FOR_ARTIFACTS = False
@contextlib.contextmanager
def disable_patching():
"""
Temporarily disable auto-logging for optional artifacts (model, signature, input
examples) to avoid "double-logging" when invoking the patched chain. Without this
disablement applied, the patched inference method calls child components that may
also be patched, leading to redundant logging.
"""
global IS_PATCHING_DISABLED_FOR_ARTIFACTS
original_artifact_flag = IS_PATCHING_DISABLED_FOR_ARTIFACTS
IS_PATCHING_DISABLED_FOR_ARTIFACTS = True
try:
yield
finally:
IS_PATCHING_DISABLED_FOR_ARTIFACTS = original_artifact_flag
def patched_inference(func_name, original, self, *args, **kwargs):
"""
A patched implementation of langchain models inference process which enables
logging the traces, and other optional artifacts like model, input examples, etc.
We patch inference functions for different models based on their usage.
"""
def _invoke(self, *args, **kwargs):
with disable_patching():
return original(self, *args, **kwargs)
config = AutoLoggingConfig.init(mlflow.langchain.FLAVOR_NAME)
if not IS_PATCHING_DISABLED_FOR_ARTIFACTS and config.should_log_optional_artifacts():
with _setup_autolog_run(config, self) as run_id:
result = _invoke(self, *args, **kwargs)
_log_optional_artifacts(config, run_id, result, self, func_name, *args, **kwargs)
else:
result = _invoke(self, *args, **kwargs)
return result
@contextlib.contextmanager
def _setup_autolog_run(config, model):
"""Set up autologging run and return the run ID.
This function only creates a run when there is no active run and the model does not have
a run ID attribute propagated from the previous call. Iff it creates a new run, MLflow should
terminate the run at the end of the inference.
Args:
config: AutoLoggingConfig: The autologging configuration.
model: Any: The LangChain model instance that runs the inference.
Returns: yields the run IDs
"""
if propagated_run_id := getattr(model, "run_id", None):
# When model has "run_id" attribute, it means the model is already invoked once with autolog
# enabled and the run_id is propagated from the previous call, so we don't create a new run.
run_id = propagated_run_id
# The run should be already terminated at the end of the previous call.
should_terminate_run = False
elif active_run := mlflow.active_run():
run_id = active_run.info.run_id
tags = _resolve_tags(config.extra_tags, active_run)
mlflow.MlflowClient().log_batch(run_id, tags=[RunTag(k, str(v)) for k, v in tags.items()])
should_terminate_run = False
else:
from mlflow.tracking.fluent import _get_experiment_id
run = mlflow.MlflowClient().create_run(
experiment_id=_get_experiment_id(),
run_name="langchain-" + name_utils._generate_random_name(),
tags=_resolve_tags(config.extra_tags),
)
run_id = run.info.run_id
should_terminate_run = True
run_status = None
try:
yield run_id
except Exception:
run_status = RunStatus.to_string(RunStatus.FAILED)
raise
finally:
if should_terminate_run:
mlflow.MlflowClient().set_terminated(run_id, status=run_status)
def _resolve_tags(extra_tags, active_run=None):
resolved_tags = context_registry.resolve_tags(extra_tags)
tags = _resolve_extra_tags(mlflow.langchain.FLAVOR_NAME, resolved_tags)
if active_run:
# Some context tags like mlflow.runName are immutable once logged, but they might be already
# set when the run is created, then we should avoid updating them.
excluded_tags = {tag for tag in active_run.data.tags.keys() if tag.startswith("mlflow.")}
tags = {k: v for k, v in tags.items() if k not in excluded_tags}
return tags
def _get_input_data_from_function(func_name, model, args, kwargs):
func_param_name_mapping = {
"invoke": "input",
"batch": "inputs",
"stream": "input",
}
input_example_exc = None
if param_name := func_param_name_mapping.get(func_name):
inference_func = getattr(model, func_name)
# A guard to make sure `param_name` is the first argument of inference function
if next(iter(inspect.signature(inference_func).parameters.keys())) != param_name:
input_example_exc = MlflowException(
"Inference function signature changes, please contact MLflow team to "
"fix langchain autologging.",
)
else:
return args[0] if len(args) > 0 else kwargs.get(param_name)
else:
input_example_exc = MlflowException(
f"Unsupported inference function. Only support {list(func_param_name_mapping.keys())}."
)
_logger.warning(
f"Failed to gather input example of model {model.__class__.__name__} "
f"due to {input_example_exc}."
)
def _convert_data_to_dict(data, key):
if isinstance(data, dict):
return {f"{key}-{k}": v for k, v in data.items()}
if isinstance(data, list):
return {key: data}
if isinstance(data, str):
return {key: [data]}
raise MlflowException("Unsupported data type.")
def _update_langchain_model_config(model):
# Langchain models are Pydantic models, and the value for extra is
# ignored, we need to set it to allow so as to set attributes on
# the model to keep track of logging status
import langchain
try:
# LangChain 0.3.0 and above is fully migrated to Pydantic v2
if Version(langchain.__version__) >= Version("0.3.0"):
if hasattr(model, "model_config") and model.model_config is not None:
model.model_config["extra"] = "allow"
model.__pydantic_extra__ = {}
return True
else:
from langchain_core.pydantic_v1 import Extra
if hasattr(model, "__config__"):
model.__config__.extra = Extra.allow
return True
except Exception as e:
warnings.warn(
"Failed to set extra attribute on the model for keeping track of logging status. "
f"MLflow langchain autologging might log model several times. Error: {e}"
)
return False
def _runnable_with_retriever(model):
from langchain.schema import BaseRetriever
with contextlib.suppress(ImportError):
from langchain.schema.runnable import RunnableBranch, RunnableParallel, RunnableSequence
from langchain.schema.runnable.passthrough import RunnableAssign
if isinstance(model, RunnableBranch):
return any(_runnable_with_retriever(runnable) for _, runnable in model.branches)
if isinstance(model, RunnableParallel):
return any(
_runnable_with_retriever(runnable)
for runnable in get_runnable_steps(model).values()
)
if isinstance(model, RunnableSequence):
return any(_runnable_with_retriever(runnable) for runnable in get_runnable_steps(model))
if isinstance(model, RunnableAssign):
return _runnable_with_retriever(model.mapper)
return isinstance(model, BaseRetriever)
def _chain_with_retriever(model):
with contextlib.suppress(ImportError):
from langchain.chains import RetrievalQA
return isinstance(model, RetrievalQA)
return False
def _log_optional_artifacts(autolog_config, run_id, result, self, func_name, *args, **kwargs):
input_example = None
if autolog_config.log_models and not hasattr(self, "_mlflow_model_logged"):
if _runnable_with_retriever(self) or _chain_with_retriever(self):
_logger.info(UNSUPPORTED_LOG_MODEL_MESSAGE)
else:
# warn user in case we did't capture some cases where retriever is used
warnings.warn(UNSUPPORTED_LOG_MODEL_MESSAGE)
if autolog_config.log_input_examples:
input_example = deepcopy(
_get_input_data_from_function(func_name, self, args, kwargs)
)
if not autolog_config.log_model_signatures:
_logger.info(
"Signature is automatically generated for logged model if "
"input_example is provided. To disable log_model_signatures, "
"please also disable log_input_examples."
)
registered_model_name = get_autologging_config(
mlflow.langchain.FLAVOR_NAME, "registered_model_name", None
)
try:
with disable_patching():
mlflow.langchain.log_model(
self,
"model",
input_example=input_example,
registered_model_name=registered_model_name,
run_id=run_id,
)
except Exception as e:
_logger.warning(f"Failed to log model due to error {e}.")
# only try logging model once, even if it can't be logged
# we don't want to spam the user with warnings/infos
if _update_langchain_model_config(self):
self._mlflow_model_logged = True
# Even if the model is not logged, we keep a single run per model
if _update_langchain_model_config(self):
# NB: We have to set these attributes AFTER the model is logged, otherwise those extra
# attributes will be logged as a part of the pickled model and pollute the loaded model.
if not hasattr(self, "run_id"):
self.run_id = run_id
if not hasattr(self, "session_id"):
self.session_id = uuid.uuid4().hex
self.inference_id = getattr(self, "inference_id", 0) + 1
return result