SaveMyCert
Log in
5 of 5 free questions left today·for 30 a day
Assembling and Deploying Applications

Databricks GenAI Engineer: Coding pyfunc Chains and Registering Them to Unity Catalog

19 min readDatabricks GenAI · Assembling and Deploying ApplicationsUpdated

Coding a chain with pyfunc and registering it to Unity Catalog is the step that turns a working notebook prototype into a versioned, governed MLflow model that Model Serving, batch jobs and ai_query() can all call the same way. This lesson covers choosing the model flavor (LangChain, pyfunc PythonModel or ResponsesAgent), what belongs in load_context versus predict, pre- and post-processing, simple LangChain Expression Language chains, logging from code with ModelConfig, dependencies, input examples and signatures, the embedding model and retriever for a RAG chain, and registering, aliasing and securing the model in Unity Catalog, for the Databricks Certified Generative AI Engineer Associate exam.

What you’ll learn
  • Choose between the LangChain flavor, a pyfunc PythonModel and a ResponsesAgent for a given chain or agent
  • Code a pyfunc chain that does setup in load_context and pre- and post-processing in predict
  • Compose a simple LCEL chain, including a RAG chain that fills both context and question
  • Log a chain from code with set_model and configure it per environment with ModelConfig
  • Package dependencies correctly with pip requirements, code_paths, artifacts, an input example and a signature
  • Register a chain to Unity Catalog, promote it with aliases and grant the right privileges
  • Validate a logged model's environment before creating a serving endpoint

What a chain is and which MLflow flavor to log it with

A chain is the code that turns a user request into an answer: it formats a prompt, optionally retrieves context, calls an LLM and shapes the reply. On Databricks you package that code as an MLflow model so it can be versioned in Unity Catalog and deployed to Model Serving. The flavor you log it with decides how it loads back and how much wrapper code you write.

Flavor / interfaceUse it whenLoads back as
mlflow.langchain.log_modelThe chain is pure LangChain (LCEL) with no custom pre- or post-processing, and consumers want to invoke it or compose it into their own chainsA LangChain runnable via mlflow.langchain.load_model, plus the generic pyfunc interface for serving
mlflow.pyfunc.PythonModel subclassYou need your own Python around the LLM call: input cleaning, masking, output validation, custom formatsA pyfunc model (mlflow.pyfunc.load_model); the inner objects are hidden behind predict
mlflow.pyfunc.ResponsesAgent subclassYou are authoring a new agent (tool calling, multi-turn, streaming) for Mosaic AI Agent FrameworkA pyfunc model that speaks the standard agent request/response and streaming format; MLflow infers its signature

Databricks recommends ResponsesAgent for new agents, because Agent Framework, the Review App, AI Playground and Model Serving all understand its message format out of the box and you do not write a signature by hand. A plain PythonModel still serves fine, but you define the input format and signature yourself. Any pyfunc wrapper around a LangChain chain loads back as pyfunc, so if other teams need the runnable itself, log with the LangChain flavor.

Coding a pyfunc chain: load_context, predict and __init__

A custom pyfunc chain is a subclass of mlflow.pyfunc.PythonModel with two methods that matter: load_context runs once each time the model is loaded, and predict runs on every request. Put request-independent setup in the first and request-specific work in the second.

import mlflow
from mlflow.deployments import get_deploy_client

class SupportChain(mlflow.pyfunc.PythonModel):
    def load_context(self, context):
        # once per load: clients, tokenizers, files packaged with the model
        self.client = get_deploy_client("databricks")
        with open(context.artifacts["glossary"]) as f:
            self.glossary = load_glossary(f)

    def predict(self, context, model_input, params=None):
        question = model_input["question"][0]
        question = mask_account_numbers(question)        # pre-processing
        reply = self.client.predict(endpoint=LLM_ENDPOINT,
                                    inputs=build_request(question))
        return clean_and_validate(reply)                 # post-processing
MethodWhen it runsBelongs here
__init__When you create the instance in your notebook, before logging. If you log by passing the instance, its state is serialised with cloudpicklePlain, picklable settings only. Live clients and large in-memory objects built here are pickled into the model, which fails or bloats it
load_contextOnce, when MLflow loads the model (on each serving replica, in a batch job, in load_model), before the first predictionVector Search and deploy clients, tokenizers, parsing packaged files (context.artifacts)
predictEvery requestAnything that depends on the input: masking, query rewriting, retrieval, the LLM call, trimming to a token budget, formatting the reply

The general rule: work that does not depend on the request belongs in load_context. Anything built in predict is paid for on every call, and anything built in __init__ has to survive pickling when the instance is logged, which live network clients usually cannot. Deferring setup until a request arrives only moves the cost onto whichever request comes first, whereas load_context finishes before the model accepts traffic.

Pre- and post-processing inside predict

Pre-processing is code in predict that runs on the request before the LLM call; post-processing is code in predict that runs on the LLM's response before it is returned. Because both live inside the model, every caller of the serving endpoint gets the same behaviour: a web app, a Slack bot, a REST client and SQL users calling it through ai_query().

  • Pre-processing examples: masking account numbers or other sensitive values so they never leave the chain, expanding acronyms before retrieval, normalising or rewriting the question, trimming retrieved chunks to fit the request's token budget. Masking has to happen before the endpoint call; doing it afterwards is too late, because the value has already been sent.
  • Post-processing examples: stripping Markdown code fences or trailing "Sources:" lines, parsing and validating JSON, reshaping the reply into fixed fields such as answer and sources, removing internal chunk IDs, adding a fallback when validation fails.
Where you enforce a requirementWhat it can and cannot do
Prompt instruction ("return JSON, omit chunk IDs")Cheap and worth doing, but it only raises the chance the LLM complies; it guarantees nothing
AI Gateway guardrail on the endpointCan screen, block or filter content; it cannot reshape a reply into your output fields
Model signatureDescribes the shape of inputs and outputs; it does not clean or transform anything
Post-processing in predictDeterministic code that travels with the model version, so every caller gets validated output

Rule of thumb: a requirement that must hold for every caller, including ones you do not control, belongs in code that ships with the model version rather than in each client or in the prompt alone.

Coding a simple chain with LangChain Expression Language

A simple chain in LangChain Expression Language (LCEL) is a pipeline of runnables joined with |, where each step receives the previous step's output. The basic generation chain is prompt, then chat model, then output parser: format, generate, parse.

from databricks_langchain import ChatDatabricks
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

llm = ChatDatabricks(endpoint="databricks-meta-llama-3-3-70b-instruct")

# prompt -> chat model -> plain string
simple_chain = prompt | llm | StrOutputParser()

# RAG: the dict runs each branch on the same input (the question string)
rag_chain = (
    {"context": retriever | format_docs,
     "question": RunnablePassthrough()}
    | rag_prompt | llm | StrOutputParser()
)
  • Order matters. The template must come first to turn variables into messages; the chat model generates; StrOutputParser pulls the text out of the chat message. Putting the parser before the model, or the model before the prompt, breaks the data flow.
  • Every template variable needs a branch. In a RAG chain whose template has {context} and {question}, the leading dictionary feeds the question to the retriever (then format_docs joins the documents' page_content into one string) and forwards the question unchanged with RunnablePassthrough. Leaving out a key, or swapping the branches, gives the template missing or wrong values.
  • Use the right model class. ChatDatabricks calls a chat endpoint (a Foundation Model APIs endpoint or your own serving endpoint); DatabricksEmbeddings produces vectors and has no generation step.

Coding "according to requirements" means mapping each stated need to a step: a required output format to a parser or post-processing, a required grounding source to a retriever branch, a required model to the endpoint name.

Logging chains from code and configuring them with ModelConfig

Models from code is the logging method Databricks recommends for chains and agents: the chain lives in a Python file that ends with mlflow.models.set_model(...), and you pass that file's path to log_model instead of a Python object. MLflow stores the source file and runs it again when the model is loaded to rebuild the object.

# chain.py
import mlflow
from mlflow.models import ModelConfig

config = ModelConfig(development_config="config.yaml")
llm = ChatDatabricks(endpoint=config.get("llm_endpoint"))
retriever = build_retriever(config.get("index_name"))   # built in the file
chain = ...                                            # compose the chain
mlflow.models.set_model(chain)

# notebook
with mlflow.start_run():
    mlflow.langchain.log_model(
        lc_model="chain.py",
        name="chain",
        model_config={"llm_endpoint": "prod-llm",
                      "index_name": "ml.support.docs_index"},
        input_example={"messages": [{"role": "user",
                                     "content": "How do I reset MFA?"}]},
    )
  • Why from code: nothing is pickled, so chains that hold unserialisable clients log cleanly, and reviewers can read the logged source. Passing an object, wrapped in a PythonModel or not, still serialises it.
  • The file must be self-contained. Because it is re-run at load time in a fresh process, variables defined in earlier notebook cells do not exist there; a chain that uses a notebook-defined retriever fails on load with NameError. Build every object inside the file.
  • ModelConfig carries values, not objects. development_config is used while you iterate in the notebook but is not used when the model is logged. The model_config argument to log_model supplies (and overrides) the configuration for the logged model; if neither is available at load time, loading raises an error. Logging the same file once per environment with its own model_config (staging endpoint, production endpoint) keeps one codebase.

Packaging dependencies: pip requirements, code_paths and artifacts

A logged model must carry everything the serving container needs that your notebook happened to have: Python packages, your own local modules and any data files. Each has its own log_model argument, and mixing them up is a frequent cause of ModuleNotFoundError or FileNotFoundError on the endpoint.

What is missingArgumentBehaviour
A package from a package indexextra_pip_requirementsAppends to the requirements MLflow inferred
A full, exact environment you controlpip_requirementsReplaces the inferred list entirely
A full conda specificationconda_envDefines the whole environment as conda YAML
Your own module (e.g. text_utils.py)code_pathsCopies files/folders into the model and prepends them to sys.path on load, so they import
A data file (glossary, tokenizer, stop-word list)artifactsCopies files into the model; on load context.artifacts["name"] gives the local path
  • Inference can miss lazy imports. MLflow infers requirements from what the model imports when it is logged, so a package imported only inside a request-time helper may be left out. Add it with extra_pip_requirements to keep everything else that was inferred.
  • Use only one environment argument. conda_env, pip_requirements and extra_pip_requirements are mutually exclusive; passing any two raises an error before anything is logged. code_paths, artifacts and input_example are separate concerns and combine with any one of them.
  • Workspace and Volume paths are not the model. A serving container cannot read the notebook's /Workspace/Users/... path. Log the file as an artifact and read context.artifacts[...], ideally once in load_context; reading the original path bypasses the packaged copy. code_paths makes modules importable but does not change which path your code opens.
  • Pin versions that matter for reproducibility (the LangChain, MLflow and Vector Search client versions) so a rebuild months later gets the same behaviour.

Input examples and model signatures

A model signature is the schema of a model's inputs and outputs, and Unity Catalog requires one on every model version. An input example is a sample request saved with the model. The easiest way to satisfy both needs is to log with an input_example: when no signature is passed, MLflow runs inference on the example, infers the signature from it, and stores the example alongside the model.

  • Without a signature, Unity Catalog registration fails. Logging an instance with no optional arguments produces a model that cannot become a Unity Catalog model version. Supplying an input_example when you log lets MLflow infer the signature from a real request, and the same example is stored with the model as documentation of the expected input.
  • Writing a signature by hand (mlflow.models.infer_signature or a ModelSignature) also works, but it does not save a sample request.
  • Agent interfaces need neither. A ResponsesAgent has a fixed request/response schema, so MLflow infers its signature at logging time. Recent MLflow versions can also infer a pyfunc signature from type hints on predict.
  • What a signature does not do: it does not validate business rules, clean output or install anything. Output shaping is post-processing in predict.

The example should look like a real request in the chain's input format, for instance {"question": ["How do I reset my password?"]} for a DataFrame-style pyfunc chain, or a messages list for a chat-style agent.

Choosing the embedding model and retriever for a RAG chain

The basic elements of a RAG application are a model flavor, an embedding model, a retriever, dependencies, an input example and a signature; the two that most often go wrong are the embedding model and the retriever settings. The rule that decides most cases: queries must be embedded with the same model that embedded the indexed chunks. Similarity search compares a query vector with stored vectors, which only means something when both come from one model. A different model, even one with the same dimension count, maps text into a different space, and results degrade silently rather than failing.

The embedding model's input window matters too. databricks-bge-large-en embeds up to 512 tokens and databricks-gte-large-en up to 8,192 (both produce 1,024-dimension vectors). If chunks are much longer than the window, only the beginning of each chunk shapes its vector, so details near the end are hard to retrieve. When the chunk size must stay, switch to a model whose window covers the chunk, and rebuild (re-embed) the index with it; changing only the query model would mix vector spaces, and returning more results or adding hybrid keyword search leaves the truncation in place.

How the retriever is configured depends on who computes the embeddings:

Index typeRetriever needs
Delta Sync with Databricks-managed embeddingsJust the index name (plus options such as num_results, columns, query_type); Databricks embeds the query text with the index's own model
Delta Sync with self-managed embeddings, or Direct AccessThe text_column that holds the chunk text and an embedding model (e.g. DatabricksEmbeddings(endpoint=...)) that is the same model that produced the stored vectors
from databricks_langchain import VectorSearchRetrieverTool, DatabricksEmbeddings

retriever_tool = VectorSearchRetrieverTool(
    index_name="ml.support.docs_index",
    text_column="chunk",                     # the text, not the vector column
    embedding=DatabricksEmbeddings(endpoint="databricks-gte-large-en"),
)

Creating and sizing indexes and endpoints (standard vs storage-optimized, sync modes, hybrid search) is covered in the Mosaic AI Vector Search lesson; here you only need to wire a correct retriever into the chain.

Registering, promoting and securing the chain in Unity Catalog

Registering to Unity Catalog means creating a governed model version under a three-level name, catalog.schema.model, in the Unity Catalog model registry. Two things are checked when a version is registered: the three-level name and a signature.

mlflow.set_registry_uri("databricks-uc")      # default in MLflow 3

# option 1: register while logging
mlflow.pyfunc.log_model(name="chain", python_model="chain.py",
                        input_example=example,
                        registered_model_name="ml.support.support_rag")

# option 2: register an already-logged model
mlflow.register_model(model_info.model_uri, "ml.support.support_rag")
  • Registry URI vs tracking URI. set_registry_uri("databricks-uc") sends registrations to Unity Catalog; "databricks" as a registry URI is the legacy workspace registry. The tracking URI is a different setting that only decides where runs and experiments go. MLflow 3 uses Unity Catalog by default; with MLflow 2.x in a workspace whose default catalog is not a Unity Catalog catalog (for example hive_metastore), you must set it.
  • Name notations. Register with catalog.schema.model. Load with models:/catalog.schema.model@alias or models:/catalog.schema.model/<version>. Slash-separated names are not valid registered model names.
  • Each log with registered_model_name creates a new version of that model (version 1, 2, 3 …).

Promoting with aliases

In Unity Catalog, you promote a model version by moving an alias, because stages (Staging, Production, Archived) are not supported. An alias such as champion is a named, movable pointer to one version; consumers load models:/ml.support.support_rag@champion and get whatever version the alias points at, so promotion needs no code change.

from mlflow import MlflowClient
MlflowClient().set_registered_model_alias(
    name="ml.support.support_rag", alias="champion", version=6)

model = mlflow.pyfunc.load_model("models:/ml.support.support_rag@champion")

An alias moves only when someone sets it. Registering version 6 leaves champion on version 5 until you reassign it (with the client or in Catalog Explorer). Tags are metadata you can filter on, but you cannot load a model by tag, and copying a model per release (support_rag_prod) forces consumers to change what they load.

Privileges for models

ActionPrivileges needed
Create a new registered modelUSE CATALOG on the catalog, USE SCHEMA on the schema, and CREATE MODEL (or CREATE FUNCTION) on the schema
Add a version to an existing registered modelUSE CATALOG, USE SCHEMA, and ownership of the model or CREATE MODEL VERSION on it
Load the model (e.g. mlflow.pyfunc.load_model, batch scoring)USE CATALOG, USE SCHEMA, and EXECUTE on the registered model

Table privileges do not carry over: SELECT, MODIFY and CREATE TABLE apply to tables, not models. CREATE MODEL on the schema does not let you add versions to someone else's model; under least privilege, grant CREATE MODEL VERSION on that model rather than transferring ownership. Access control for serving endpoints and the resources an endpoint calls is covered in the model serving lesson.

Validating a logged model before deployment

The reliable pre-deployment check is mlflow.models.predict run in an isolated environment built from the model's logged requirements. It loads the logged model and runs it on input data inside a fresh environment, which is what the serving container will do, so a missing dependency fails before any endpoint exists.

# isolated environment from the logged requirements (virtualenv is the default)
mlflow.models.predict(model_uri, input_data=example, env_manager="uv")

# prove a fix without changing the logged model
mlflow.models.predict(model_uri, input_data=example, env_manager="uv",
                      pip_requirements_override=["databricks-vectorsearch"])

# persist the fix once it is proven
mlflow.models.update_model_requirements(model_uri, "add",
                                        ["databricks-vectorsearch"])
  • env_manager="local" is not a dependency test. It runs in the current environment, so the notebook's extra libraries hide anything the logged requirements miss. The same is true of mlflow.pyfunc.load_model(uri).predict(...) in the notebook.
  • pip_requirements_override adds or changes packages for that one run, so you can confirm that a single package fixes a failure before touching the model. install_mlflow only controls installing MLflow itself.
  • update_model_requirements edits the logged model's requirements; use it (or re-log) after the fix is proven.
  • infer_signature creates a schema; it tests nothing about the environment.

Tip. Expect scenario items that describe a chain, a failure or a requirement and ask for the change, argument or line of code that meets it, often with short Python. pyfunc items turn on where code runs (__init__, load_context or predict) and on whether a requirement must hold for every caller. Simple-chain items show LCEL pipelines whose order or branches differ. RAG-element items ask you to pick the flavor, the dependency argument, the input example and signature, or the embedding model and retriever settings, where several options are real MLflow or Databricks features meant for a different job. Registration items cover the registry URI, the three-level name, aliases instead of stages and Unity Catalog privileges, with stated constraints deciding between workable options. A few items ask you to select two.

Key takeaways
  • Pure LangChain chain that others will compose: LangChain flavor. Custom pre/post-processing: pyfunc PythonModel. New agent for Agent Framework: ResponsesAgent.
  • load_context runs once per load (clients, tokenizers, artifact files); predict runs per request; __init__ state is pickled when you log an instance.
  • Enforce anything every caller must get (masking, JSON shape, removing IDs) in predict, not in the prompt, the clients or a gateway guardrail.
  • LCEL: prompt | chat model | output parser; a RAG chain needs a branch per template variable (retriever | format_docs for context, RunnablePassthrough for the question).
  • Log chains from code with mlflow.models.set_model; the file must build everything itself, and ModelConfig supplies values via model_config at logging time.
  • Packages: extra_pip_requirements appends, pip_requirements replaces (use only one of these and conda_env); local modules: code_paths; data files: artifacts read via context.artifacts.
  • Unity Catalog needs a three-level name and a signature; an input_example gives you both a signature and a sample request.
  • Embed queries with the model that embedded the index, and pick an embedding model whose window fits your chunks.
  • Promote with aliases (no stages); a new version never moves an alias. Create: CREATE MODEL; new version: owner or CREATE MODEL VERSION; load: EXECUTE.
  • Test dependencies with mlflow.models.predict in an isolated environment (virtualenv or uv), not env_manager="local".

Frequently asked questions

What is the difference between load_context and predict in an MLflow pyfunc model?

load_context runs once when MLflow loads the model, for example on each serving replica at startup, and is where you create clients and read files packaged with the model. predict runs on every request and is where request-specific work happens: masking or rewriting the input, retrieval, the LLM call and formatting the output. Moving per-request setup out of predict into load_context cuts latency without changing results.

Do I need a model signature to register a model in Unity Catalog?

Yes. Unity Catalog rejects model versions without a signature. The simplest way to get one is to pass an input_example when logging: MLflow infers the signature from it and stores the example, which Model Serving can also show as a sample request. ResponsesAgent models get their signature inferred automatically.

Can I use Staging and Production stages with Unity Catalog models?

No. Stages belong to the legacy workspace model registry. In Unity Catalog you assign an alias such as champion to a version and load models:/catalog.schema.model@champion. Promotion is moving the alias, and registering a new version never moves it automatically.

What is the difference between pip_requirements and extra_pip_requirements in MLflow?

extra_pip_requirements adds packages to the list MLflow inferred when logging the model, while pip_requirements replaces that list entirely. Only one of pip_requirements, extra_pip_requirements and conda_env can be passed to a logging call; combining them raises an error. Local Python modules go in code_paths and data files in artifacts.

Why does Databricks recommend logging chains and agents from code?

With models from code, the chain is defined in a Python file that calls mlflow.models.set_model, and log_model receives the file path. Nothing is pickled, so chains holding unserialisable clients log cleanly, and the stored source is readable. The file is re-run at load time, so it must create everything it uses; environment-specific values are passed with ModelConfig through log_model's model_config argument.

Which Unity Catalog privileges are needed to create and to load a registered model?

Both need USE CATALOG on the catalog and USE SCHEMA on the schema. Creating a registered model also needs CREATE MODEL on the schema; adding a version to an existing model needs ownership of it or CREATE MODEL VERSION on it; loading a model needs EXECUTE on the registered model. Table privileges such as SELECT do not apply to models.

Which embedding model should a RAG chain use for user queries?

The same embedding model that produced the vectors stored in the index. Vectors from a different model live in a different space, even with the same dimension count, so retrieval quality drops without any error. If you change the embedding model, re-embed the index with it. With Databricks-managed embeddings, the index embeds queries with its own model automatically.

How can I check that a logged MLflow model's dependencies are complete before serving it?

Run mlflow.models.predict on the model URI with sample input and an isolated environment manager (virtualenv, the default, or uv). It builds a fresh environment from the logged requirements, so a missing package fails before deployment. pip_requirements_override lets you test a fix for one run; update_model_requirements or re-logging persists it.

Source

This lesson covers the "Assembling and Deploying Applications" domain of the official Databricks GenAI exam guide. Vendors revise their guides — check the source for the current version.

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?