Files
mlflow--mlflow/tests/langgraph/test_langgraph_autolog.py
2026-07-13 13:22:34 +08:00

289 lines
11 KiB
Python

import json
import pytest
import mlflow
from mlflow.entities.span import SpanType
from mlflow.entities.span_status import SpanStatusCode
from mlflow.tracing.constant import TokenUsageKey, TraceMetadataKey
from mlflow.version import IS_TRACING_SDK_ONLY
from tests.tracing.helper import get_traces, skip_when_testing_trace_sdk
@skip_when_testing_trace_sdk
def test_langgraph_save_as_code():
input_example = {"messages": [{"role": "user", "content": "what is the weather in sf?"}]}
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
"tests/langgraph/sample_code/langgraph_prebuilt.py",
name="langgraph",
input_example=input_example,
)
# (role, content)
expected_messages = [
("human", "what is the weather in sf?"),
("agent", ""), # tool message does not have content
("tools", "It's always sunny in sf"),
("agent", "The weather in San Francisco is always sunny!"),
]
loaded_graph = mlflow.langchain.load_model(model_info.model_uri)
response = loaded_graph.invoke(input_example)
messages = response["messages"]
assert len(messages) == 4
for msg, (role, expected_content) in zip(messages, expected_messages):
assert msg.content == expected_content
# Need to reload to reset the iterator in FakeOpenAI
loaded_graph = mlflow.langchain.load_model(model_info.model_uri)
response = loaded_graph.stream(input_example)
# .stream() response does not includes the first Human message
for chunk, (role, expected_content) in zip(response, expected_messages[1:]):
assert chunk[role]["messages"][0].content == expected_content
loaded_pyfunc = mlflow.pyfunc.load_model(model_info.model_uri)
response = loaded_pyfunc.predict(input_example)[0]
messages = response["messages"]
assert len(messages) == 4
for msg, (role, expected_content) in zip(messages, expected_messages):
assert msg["content"] == expected_content
# response should be json serializable
assert json.dumps(response) is not None
loaded_pyfunc = mlflow.pyfunc.load_model(model_info.model_uri)
response = loaded_pyfunc.predict_stream(input_example)
for chunk, (role, expected_content) in zip(response, expected_messages[1:]):
assert chunk[role]["messages"][0]["content"] == expected_content
@skip_when_testing_trace_sdk
@pytest.mark.asyncio
@pytest.mark.parametrize("is_async", [True, False], ids=["async", "sync"])
async def test_langgraph_tracing_prebuilt(is_async, mock_litellm_cost):
from tests.langgraph.sample_code.langgraph_prebuilt import graph
mlflow.langchain.autolog()
input_example = {"messages": [{"role": "user", "content": "what is the weather in sf?"}]}
config = {"configurable": {"thread_id": "1"}}
if is_async:
await graph.ainvoke(input_example, config)
else:
graph.invoke(input_example, config)
traces = get_traces()
assert len(traces) == 1
assert traces[0].info.status == "OK"
assert traces[0].data.spans[0].name == "LangGraph"
assert traces[0].data.spans[0].inputs == input_example
# (type, content)
expected_messages = [
("human", "what is the weather in sf?"),
("ai", ""), # tool message does not have content
("tool", "It's always sunny in sf"),
("ai", "The weather in San Francisco is always sunny!"),
]
messages = traces[0].data.spans[0].outputs["messages"]
assert len(messages) == 4
for msg, (type, expected_content) in zip(messages, expected_messages):
assert msg["type"] == type
assert msg["content"] == expected_content
# Validate tool span
tool_span = next(span for span in traces[0].data.spans if span.span_type == SpanType.TOOL)
assert tool_span.name == "get_weather"
assert tool_span.inputs == {"city": "sf"}
assert tool_span.outputs["content"] == "It's always sunny in sf"
assert tool_span.outputs["status"] == "success"
assert tool_span.status.status_code == SpanStatusCode.OK
# Validate token usage
token_usage = json.loads(traces[0].info.trace_metadata[TraceMetadataKey.TOKEN_USAGE])
assert token_usage == {
TokenUsageKey.INPUT_TOKENS: 15,
TokenUsageKey.OUTPUT_TOKENS: 30,
TokenUsageKey.TOTAL_TOKENS: 45,
}
# Thread ID should be recoded in the trace metadata
assert traces[0].info.trace_metadata[TraceMetadataKey.TRACE_SESSION] == "1"
# Verify chat model spans have model name extracted
chat_spans = [s for s in traces[0].data.spans if s.span_type == SpanType.CHAT_MODEL]
for chat_span in chat_spans:
assert chat_span.model_name == "gpt-3.5-turbo"
if not IS_TRACING_SDK_ONLY:
usage = chat_span.get_attribute("mlflow.chat.tokenUsage")
assert chat_span.llm_cost == {
"input_cost": usage["input_tokens"] * 1.0,
"output_cost": usage["output_tokens"] * 2.0,
"total_cost": usage["input_tokens"] * 1.0 + usage["output_tokens"] * 2.0,
}
@skip_when_testing_trace_sdk
def test_langgraph_tracing_diy_graph():
mlflow.langchain.autolog()
input_example = {"messages": [{"role": "user", "content": "hi"}]}
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
"tests/langgraph/sample_code/langgraph_diy.py",
name="langgraph",
)
loaded_graph = mlflow.langchain.load_model(model_info.model_uri)
loaded_graph.invoke(input_example)
traces = get_traces()
assert len(traces) == 1
assert traces[0].info.status == "OK"
assert traces[0].data.spans[0].name == "LangGraph"
assert traces[0].data.spans[0].inputs == input_example
chat_spans = [span for span in traces[0].data.spans if span.name.startswith("ChatOpenAI")]
assert len(chat_spans) == 3
# Verify all chat model spans have model name extracted
for chat_span in chat_spans:
assert chat_span.model_name == "gpt-3.5-turbo"
@skip_when_testing_trace_sdk
def test_langgraph_tracing_with_custom_span():
mlflow.langchain.autolog()
input_example = {"messages": [{"role": "user", "content": "what is the weather in sf?"}]}
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
"tests/langgraph/sample_code/langgraph_with_custom_span.py",
name="langgraph",
input_example=input_example,
)
loaded_graph = mlflow.langchain.load_model(model_info.model_uri)
# No trace should be created for the first call
assert mlflow.get_trace(mlflow.get_last_active_trace_id()) is None
loaded_graph.invoke(input_example)
traces = get_traces()
assert len(traces) == 1
assert traces[0].info.status == "OK"
assert traces[0].data.spans[0].name == "LangGraph"
assert traces[0].data.spans[0].inputs == input_example
spans = traces[0].data.spans
# Validate chat model spans
chat_spans = [s for s in spans if s.span_type == SpanType.CHAT_MODEL]
assert len(chat_spans) == 3
# Verify all chat model spans have model name extracted
for chat_span in chat_spans:
assert chat_span.model_name == "gpt-3.5-turbo"
# Validate tool span
tool_span = next(s for s in spans if s.span_type == SpanType.TOOL)
assert tool_span.name == "get_weather"
assert tool_span.inputs == {"city": "sf"}
assert tool_span.outputs["content"] == "It's always sunny in sf"
assert tool_span.outputs["status"] == "success"
assert tool_span.status.status_code == SpanStatusCode.OK
# Validate inner span
inner_span = next(s for s in spans if s.name == "get_weather_inner")
assert inner_span.parent_id == tool_span.span_id
assert inner_span.inputs == "sf"
assert inner_span.outputs == "It's always sunny in sf"
inner_runnable_span = next(s for s in spans if s.parent_id == inner_span.span_id)
assert inner_runnable_span.name == "RunnableSequence"
@skip_when_testing_trace_sdk
@pytest.mark.asyncio
@pytest.mark.parametrize("is_async", [True, False], ids=["async", "sync"])
async def test_langgraph_tracing_with_parent_span(is_async):
from tests.langgraph.sample_code.langgraph_prebuilt import graph
mlflow.langchain.autolog()
input_example = {"messages": [{"role": "user", "content": "what is the weather in sf?"}]}
with mlflow.start_span("parent"):
if is_async:
await graph.ainvoke(input_example)
else:
graph.invoke(input_example)
traces = get_traces()
assert len(traces) == 1
assert traces[0].info.status == "OK"
# Validate structure
span_id_to_span = {span.span_id: span for span in traces[0].data.spans}
tool_span = next(span for span in traces[0].data.spans if span.span_type == SpanType.TOOL)
assert tool_span.name == "get_weather"
tool_parent_span = span_id_to_span[tool_span.parent_id]
assert tool_parent_span.name == "tools"
assert tool_parent_span.span_type == SpanType.CHAIN
graph_span = span_id_to_span[tool_parent_span.parent_id]
assert graph_span.name == "LangGraph"
assert graph_span.span_type == SpanType.CHAIN
root_span = span_id_to_span[graph_span.parent_id]
assert root_span.name == "parent"
assert root_span.span_type == SpanType.UNKNOWN
@skip_when_testing_trace_sdk
def test_langgraph_chat_agent_trace():
input_example = {"messages": [{"role": "user", "content": "hi"}]}
with mlflow.start_run():
model_info = mlflow.pyfunc.log_model(
name="agent",
python_model="tests/langgraph/sample_code/langgraph_chat_agent.py",
)
loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
# No trace should be created for loading it in
assert mlflow.get_trace(mlflow.get_last_active_trace_id()) is None
loaded_model.predict(input_example)
traces = get_traces()
assert len(traces) == 1
assert traces[0].info.status == "OK"
assert traces[0].info.request_metadata[TraceMetadataKey.MODEL_ID] == model_info.model_id
assert traces[0].data.spans[0].name == "LangGraph"
assert traces[0].data.spans[0].inputs == input_example
loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
list(loaded_model.predict_stream(input_example))
traces = get_traces()
assert len(traces) == 2
assert traces[0].info.status == "OK"
assert traces[0].info.request_metadata[TraceMetadataKey.MODEL_ID] == model_info.model_id
assert traces[0].data.spans[0].name == "LangGraph"
assert traces[0].data.spans[0].inputs == input_example
@skip_when_testing_trace_sdk
def test_langgraph_autolog_with_update_current_span():
model_info = mlflow.langchain.log_model(
lc_model="tests/langgraph/sample_code/langgraph_with_autolog.py",
input_example={"status": "done"},
)
assert model_info.signature is not None
assert model_info.signature.inputs is not None
assert model_info.signature.outputs is not None