Databricks GenAI Engineer: Coding pyfunc Chains and Registering Them to Unity Catalog
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.
On this page10 sections
- What a chain is and which MLflow flavor to log it with
- Coding a pyfunc chain: load_context, predict and __init__
- Pre- and post-processing inside predict
- Coding a simple chain with LangChain Expression Language
- Logging chains from code and configuring them with ModelConfig
- Packaging dependencies: pip requirements, code_paths and artifacts
- Input examples and model signatures
- Choosing the embedding model and retriever for a RAG chain
- Registering, promoting and securing the chain in Unity Catalog
- Validating a logged model before deployment
- 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 / interface | Use it when | Loads back as |
|---|---|---|
mlflow.langchain.log_model | The chain is pure LangChain (LCEL) with no custom pre- or post-processing, and consumers want to invoke it or compose it into their own chains | A LangChain runnable via mlflow.langchain.load_model, plus the generic pyfunc interface for serving |
mlflow.pyfunc.PythonModel subclass | You need your own Python around the LLM call: input cleaning, masking, output validation, custom formats | A pyfunc model (mlflow.pyfunc.load_model); the inner objects are hidden behind predict |
mlflow.pyfunc.ResponsesAgent subclass | You are authoring a new agent (tool calling, multi-turn, streaming) for Mosaic AI Agent Framework | A 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
| Method | When it runs | Belongs here |
|---|---|---|
__init__ | When you create the instance in your notebook, before logging. If you log by passing the instance, its state is serialised with cloudpickle | Plain, picklable settings only. Live clients and large in-memory objects built here are pickled into the model, which fails or bloats it |
load_context | Once, when MLflow loads the model (on each serving replica, in a batch job, in load_model), before the first prediction | Vector Search and deploy clients, tokenizers, parsing packaged files (context.artifacts) |
predict | Every request | Anything 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
answerandsources, removing internal chunk IDs, adding a fallback when validation fails.
| Where you enforce a requirement | What 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 endpoint | Can screen, block or filter content; it cannot reshape a reply into your output fields |
| Model signature | Describes the shape of inputs and outputs; it does not clean or transform anything |
Post-processing in predict | Deterministic 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;
StrOutputParserpulls 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 (thenformat_docsjoins the documents'page_contentinto one string) and forwards the question unchanged withRunnablePassthrough. Leaving out a key, or swapping the branches, gives the template missing or wrong values. - Use the right model class.
ChatDatabrickscalls a chat endpoint (a Foundation Model APIs endpoint or your own serving endpoint);DatabricksEmbeddingsproduces 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
PythonModelor 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
retrieverfails on load withNameError. Build every object inside the file. - ModelConfig carries values, not objects.
development_configis used while you iterate in the notebook but is not used when the model is logged. Themodel_configargument tolog_modelsupplies (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 ownmodel_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 missing | Argument | Behaviour |
|---|---|---|
| A package from a package index | extra_pip_requirements | Appends to the requirements MLflow inferred |
| A full, exact environment you control | pip_requirements | Replaces the inferred list entirely |
| A full conda specification | conda_env | Defines the whole environment as conda YAML |
Your own module (e.g. text_utils.py) | code_paths | Copies files/folders into the model and prepends them to sys.path on load, so they import |
| A data file (glossary, tokenizer, stop-word list) | artifacts | Copies 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_requirementsto keep everything else that was inferred. - Use only one environment argument.
conda_env,pip_requirementsandextra_pip_requirementsare mutually exclusive; passing any two raises an error before anything is logged.code_paths,artifactsandinput_exampleare 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 readcontext.artifacts[...], ideally once inload_context; reading the original path bypasses the packaged copy.code_pathsmakes 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_examplewhen 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_signatureor aModelSignature) also works, but it does not save a sample request. - Agent interfaces need neither. A
ResponsesAgenthas 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 onpredict. - 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 type | Retriever needs |
|---|---|
| Delta Sync with Databricks-managed embeddings | Just 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 Access | The 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 examplehive_metastore), you must set it. - Name notations. Register with
catalog.schema.model. Load withmodels:/catalog.schema.model@aliasormodels:/catalog.schema.model/<version>. Slash-separated names are not valid registered model names. - Each log with
registered_model_namecreates 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
| Action | Privileges needed |
|---|---|
| Create a new registered model | USE 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 model | USE 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 ofmlflow.pyfunc.load_model(uri).predict(...)in the notebook.pip_requirements_overrideadds or changes packages for that one run, so you can confirm that a single package fixes a failure before touching the model.install_mlflowonly controls installing MLflow itself.update_model_requirementsedits the logged model's requirements; use it (or re-log) after the fix is proven.infer_signaturecreates 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.
- 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.