Repository: stanfordnlp/dspy
Pull request #8380 (2025-06-14 03:06:35)

Show warning if forward is directly called
Show a warning if users directly call forward method of dspy Module


Patch:
diff --git a/dspy/predict/predict.py b/dspy/predict/predict.py
index 14b44f0d..50db41a9 100644
--- a/dspy/predict/predict.py
+++ b/dspy/predict/predict.py
@@ -99,7 +99,22 @@ class Predict(Module, Parameter):
 
         # Get the right LM to use.
         lm = kwargs.pop("lm", self.lm) or settings.lm
-        assert isinstance(lm, BaseLM), "No LM is loaded."
+
+        if lm is None:
+            raise ValueError(
+                "No LM is loaded. Please configure the LM using `dspy.configure(lm=dspy.LM(...))`. e.g, "
+                "`dspy.configure(lm=dspy.LM('openai/gpt-4o-mini'))`"
+            )
+
+        if isinstance(lm, str):
+            # Many users mistakenly use `dspy.configure(lm="openai/gpt-4o-mini")` instead of
+            # `dspy.configure(lm=dspy.LM("openai/gpt-4o-mini"))`, so we are providing a specific error message.
+            raise ValueError(
+                f"LM must be an instance of `dspy.BaseLM`, not a string. Instead of using a string like "
+                f"'dspy.configure(lm=\"{lm}\")', please configure the LM like 'dspy.configure(lm=dspy.LM(\"{lm}\"))'"
+            )
+        elif not isinstance(lm, BaseLM):
+            raise ValueError(f"LM must be an instance of `dspy.BaseLM`, not {type(lm)}. Received `lm={lm}`.")
 
         # If temperature is unset or <=0.15, and n > 1, set temperature to 0.7 to keep randomness.
         temperature = config.get("temperature") or lm.kwargs.get("temperature")
diff --git a/dspy/primitives/program.py b/dspy/primitives/program.py
index e306591e..d59c83af 100644
--- a/dspy/primitives/program.py
+++ b/dspy/primitives/program.py
@@ -1,3 +1,5 @@
+import inspect
+import logging
 from typing import Optional
 
 import magicattr
@@ -9,6 +11,7 @@ from dspy.utils.callback import with_callbacks
 from dspy.utils.inspect_history import pretty_print_history
 from dspy.utils.usage_tracker import track_usage
 
+logger = logging.getLogger(__name__)
 
 class ProgramMeta(type):
     """Metaclass ensuring every ``dspy.Module`` instance is properly initialised."""
@@ -155,6 +158,20 @@ class Module(BaseModule, metaclass=ProgramMeta):
             results = parallel_executor.forward(exec_pairs)
             return results
 
+    def __getattribute__(self, name):
+        attr = super().__getattribute__(name)
+
+        if name == 'forward' and callable(attr):
+            # Check if forward iscalled through __call__
+            stack = inspect.stack()
+            called_via_call = len(stack) > 1 and stack[1].function == '__call__'
+
+            if not called_via_call:
+                logger.warning(f"Calling {self.__class__.__name__}.forward() directly is discouraged. "
+                               f"Use {self.__class__.__name__}() instead.")
+
+        return attr
+
 
 def set_attribute_by_name(obj, name, value):
     magicattr.set(obj, name, value)


Test patch:
diff --git a/tests/predict/test_predict.py b/tests/predict/test_predict.py
index c1aea972..e60cd14e 100644
--- a/tests/predict/test_predict.py
+++ b/tests/predict/test_predict.py
@@ -582,6 +582,28 @@ def test_positional_arguments():
     )
 
 
+def test_error_message_on_invalid_lm_setup():
+    # No LM is loaded.
+    with pytest.raises(ValueError, match="No LM is loaded"):
+        Predict("question -> answer")(question="Why did a chicken cross the kitchen?")
+
+    # LM is a string.
+    dspy.configure(lm="openai/gpt-4o-mini")
+    with pytest.raises(ValueError) as e:
+        Predict("question -> answer")(question="Why did a chicken cross the kitchen?")
+
+    assert "LM must be an instance of `dspy.BaseLM`, not a string." in str(e.value)
+
+    def dummy_lm():
+        pass
+
+    # LM is not an instance of dspy.BaseLM.
+    dspy.configure(lm=dummy_lm)
+    with pytest.raises(ValueError) as e:
+        Predict("question -> answer")(question="Why did a chicken cross the kitchen?")
+    assert "LM must be an instance of `dspy.BaseLM`, not <class 'function'>." in str(e.value)
+
+
 @pytest.mark.parametrize("adapter_type", ["chat", "json"])
 def test_field_constraints(adapter_type):
     class SpyLM(dspy.LM):
diff --git a/tests/primitives/test_module.py b/tests/primitives/test_module.py
index 54414683..023f92c3 100644
--- a/tests/primitives/test_module.py
+++ b/tests/primitives/test_module.py
@@ -489,3 +489,25 @@ async def test_module_history_async():
         assert len(program.history) == 2
         assert len(program.cot.history) == 3
         assert len(program.cot.predict.history) == 3
+
+
+def test_forward_direct_call_warning(capsys):
+    class TestModule(dspy.Module):
+        def forward(self, x):
+            return x
+
+    module = TestModule()
+    module.forward("test")
+    captured = capsys.readouterr()
+    assert "Calling TestModule.forward() directly is discouraged" in captured.err
+
+
+def test_forward_through_call_no_warning(capsys):
+    class TestModule(dspy.Module):
+        def forward(self, x):
+            return x
+
+    module = TestModule()
+    module(x="test")
+    captured = capsys.readouterr()
+    assert "Calling TestModule.forward() directly is discouraged" not in captured.err