Save (and Reload) Results
Important security warning
Do not use LLMeter file load functions on data from untrusted sources! For more details on why, see the following section.
LLMeter can save your test configurations and results to files - whether locally or on the Cloud - and load past runs back into Python for analysis later. For example:
from llmeter.results import Result
from llmeter.runner import Runner
# Provide s3:// URI or local path:
base_output_path = f"s3://doc-example-bucket/llmeter/outputs/{endpoint.model_id}"
runner = Runner(
endpoint,
# Configure the output path when creating a Runner...
output_path=base_output_path,
)
results = await runner.run(
payload=sample_payload,
n_requests=3,
clients=5,
# ...*Optionally* specify a specific path for an individual run:
# (Otherwise a subfolder will be created by run name, automatically)
output_path=f"{base_output_path}/my-cool-run"
)
# At some later date, load your result back:
results = Result.load(f"{base_output_path}/my-cool-run")
A note on performance
While it's possible for LLMeter to write run outputs directly to Cloud object stores like Amazon S3, remember it might reduce the maximum throughput you can drive in high-volume tests, since it will consume network bandwidth.
For the most part, generated files are JSON-based (or JSON Lines, for the individual responses). However, LLMeter also handles some more complex data types including:
- Binary data in request/response payloads (such as images)
- Callbacks configured on the test Run, including LLMeter built-ins as well as your custom callback classes
This core de/serialization functionality is implemented in the llmeter.serialization module.
How complex data types are represented and loaded
Objects that are not natively JSON serializable (but support LLMeter's serialization protocol) are saved to JSON-based formats something like the below:
{
"__llmeter_class__": "llmeter.endpoints.bedrock.BedrockConverse",
"__llmeter_state__": {"model_id": "claude-3", "region": "us-west-2"}
}
datetimeobjects are stored as ISO-8601 format strings in UTC timezone, like2024-01-01T00:00:00Z.bytesobjects are serialized to base64 strings in a special{"__llmeter_bytes__": "<base64>"}wrapper.- Objects implementing LLMeter's
Serializableinterface (including for example Endpoints and Callbacks) are represented as dicts with the__llmeter_{class/state}__properties as shown above
When loading these objects back from file, LLMeter will try to import and instantiate any class path saved in the __llmeter_class__ field.
This is the same trust model as Python's native pickle library: It is possible to construct malicious data which will run arbitrary code during loading, so only load data that you trust.
Under the hood: Serialization API components
In many cases you'll be working with high-level classes like Runner and Result that already provide convenience methods to save to and load from file. However, building custom LLMeter extensions may require understanding how our llmeter.serialization components fit together:
| Symbol | Purpose |
|---|---|
Serializable |
Mixin enabling your class to be serialized and loaded by LLMeter |
dump_object / load_object |
Full round-trip persistence via a type-tagged envelope |
json_default |
json.dumps fallback for bytes, datetime, PathLike |
bytes_decoder |
json.loads object hook to restore __llmeter_bytes__ markers |
datetime_to_str / str_to_datetime |
UTC ISO-8601 with Z suffix, both directions |
from llmeter.serialization import (
dump_object, load_object, json_default, bytes_decoder,
datetime_to_str, str_to_datetime,
)
Make custom classes serializable by LLMeter with the Serializable mixin
The Serializable mixin provides two default methods:
_get_llmeter_state: Construct a JSON-ready dictionary of the state your class needs to be re-initialized- The default implementation inspects the arguments of your
__init__constructor and attempts to fetch those from the current object's fields, or with an_underscore prefix if the raw parameter name isn't present.
- The default implementation inspects the arguments of your
_set_llmeter_state: Initialise an instance of your class, based on a state dictionary- The default implementation calls your
__init__with the arguments stored in the dictionary.
- The default implementation calls your
Nested Serializable objects are recursively handled by default: _get_llmeter_state wraps them via dump_object, and _set_llmeter_state restores them via load_object.
You'd only need to override these implementations if, for example:
- An
__init__parameter is consumed without being stored, or - Reconstruction needs special logic beyond
__init__(**state), or - You want to exclude large transient data from persistence
Save and load your own objects
Once your class inherits Serializable, it gets save_to_file() and load_from_file() for free - no extra code required:
from llmeter.callbacks.base import Callback
from llmeter.callbacks.mlflow import MlflowCallback
cb = MlflowCallback(step=5, nested=True)
cb.save_to_file("/tmp/callback.json")
# load_from_file is *polymorphic*: it reads the __llmeter_class__ recorded in the
# file and rebuilds the correct subclass, so you can call it on the base class.
restored = Callback.load_from_file("/tmp/callback.json") # -> MlflowCallback(step=5, nested=True)
Under the hood, those methods use two functions you can also call directly if you're managing the JSON yourself:
dump_object(obj)builds the type-tagged envelope ({"__llmeter_class__": ..., "__llmeter_state__": ...}). It reads the state fromobj._get_llmeter_state(), or - for a plain@dataclassthat doesn't inheritSerializable- from its fields.load_object(data)imports the class named in__llmeter_class__, creates a bare instance (via__new__, bypassing__init__), then repopulates it through_set_llmeter_state()- which by default re-runs__init__with the saved state.
from llmeter.serialization import dump_object, load_object
data = dump_object(my_object) # -> plain dict
my_object = load_object(data) # -> reconstructed instance
Note the envelope produced by dump_object may still contain non-JSON values (like bytes or datetime) nested inside its state, so pair it with json_default when you actually write it out - see below.
Reading and writing the JSON yourself
When you call json.dump/json.dumps directly on LLMeter data, pass json_default so the extra types are handled:
import json
from llmeter.serialization import json_default, bytes_decoder
with open("my-file.json", "w") as f:
json.dump(my_data, f, default=json_default, indent=4)
json_default converts, in order:
- bytes — wrapped in a
{"__llmeter_bytes__": "<base64>"}marker - datetime — UTC ISO-8601 string with a
Zsuffix - date / time —
.isoformat() - os.PathLike — POSIX path string
- anything else —
str()fallback
To turn the bytes markers back into bytes on the way in, pass bytes_decoder as the object_hook:
with open("my-file.json") as f:
data = json.load(f, object_hook=bytes_decoder)
How LLMeter's built-ins use this
For everyday use you rarely touch the functions above directly, because the high-level classes wrap them for you.
Endpoints: config, not connections
Endpoints are saved as configuration only - runtime state like a boto3 client is never written to file. When you load an endpoint back, _set_llmeter_state re-runs the constructor, which recreates that client for you:
from llmeter.serialization import dump_object, load_object
data = dump_object(endpoint)
# → {"__llmeter_class__": "llmeter.endpoints.bedrock.BedrockConverse",
# "__llmeter_state__": {"model_id": "claude-3", "region": "us-west-2"}}
restored = load_object(data) # boto3 client rebuilt via __init__
Runners: the whole run configuration
When you give a Runner an output_path, it saves its full configuration - endpoint, tokenizer and callbacks included - as run_config.json at the start of each run. You can also trigger this yourself:
runner = Runner(endpoint=BedrockConverse(...), callbacks=[MlflowCallback(step=1)])
runner.save(output_path="/tmp/run") # writes /tmp/run/run_config.json
If a callback (or any other configured object) can't be serialized because its class doesn't inherit Serializable, saving raises a TypeError. This is why custom callbacks and cost dimensions should subclass the relevant LLMeter base class - see Callback and the cost dimension base classes.
Dataclasses work automatically
Because the Serializable mixin introspects the constructor, any @dataclass that inherits it is serializable with no extra code - the generated __init__ supplies the parameter names the mixin looks for. LLMeter's built-in cost dimensions are a good example:
from llmeter.callbacks.cost.dimensions import InputTokens
from llmeter.serialization import dump_object, load_object
data = dump_object(InputTokens(price_per_million=3.0))
restored = load_object(data) # -> InputTokens(price_per_million=3.0, granularity=1)