12 KiB
LMCache CLI Framework & Metrics System Design
Status: Proposal | Date: 2026-03-14
Scope
This document covers the CLI framework (pluggable command discovery) and the
hierarchical metrics logging system. It is the implementation plan for Phase 1
of the CLI design, minus the actual server/ping/describe commands
(those come later). A lmcache mock command is included as a working example.
1. Explicit Command Registration
Goal
Adding a new subcommand (e.g., lmcache describe) requires:
- Creating a new file in
lmcache/cli/commands/with aBaseCommandsubclass. - Adding one import + one entry to
ALL_COMMANDSincommands/__init__.py.
Mechanism
# lmcache/cli/commands/my_cmd.py
from lmcache.cli.commands.base import BaseCommand
class MyCommand(BaseCommand):
def name(self) -> str:
return "my-cmd"
def help(self) -> str:
return "Short help text."
def add_arguments(self, parser) -> None:
parser.add_argument("--flag", ...)
def execute(self, args) -> None:
... # command logic
# lmcache/cli/commands/__init__.py (add import + list entry)
from lmcache.cli.commands.my_cmd import MyCommand
ALL_COMMANDS: list[BaseCommand] = [
...,
MyCommand(),
]
BaseCommand enforces that all four abstract methods (name, help,
add_arguments, execute) are implemented — instantiation fails otherwise.
The concrete register() method (inherited, not typically overridden) wires
everything up automatically.
How command discovery works
lmcache <cmd> ...invokesmain()inmain.py.main.pyimportsALL_COMMANDSfromcommands/__init__.py.- At import time,
__init__.pyimports each command class and instantiates it into theALL_COMMANDSlist. Instantiation validates that all abstract methods are implemented (TypeErroron failure). main.pyiteratesALL_COMMANDSand callscmd.register(subparsers).BaseCommand.register()creates an argparse subparser (usingname()andhelp()), callsadd_arguments()to wire up flags, and setsparser.set_defaults(func=self.execute).- After parsing,
main.pydispatches viaargs.func(args), which calls the matched command'sexecute().
How to add a new subcommand
Step 1. Create lmcache/cli/commands/describe.py:
from lmcache.cli.commands.base import BaseCommand
class DescribeCommand(BaseCommand):
def name(self) -> str:
return "describe"
def help(self) -> str:
return "Describe a running KV cache server."
def add_arguments(self, parser) -> None:
parser.add_argument("--url", required=True)
def execute(self, args) -> None:
... # implementation
Step 2. Register it in lmcache/cli/commands/__init__.py:
from lmcache.cli.commands.describe import DescribeCommand
ALL_COMMANDS: list[BaseCommand] = [
MockCommand(),
DescribeCommand(), # <-- add here
]
That's it — lmcache describe --url http://localhost:8000 is now available.
File layout
lmcache/cli/
├── __init__.py # empty
├── main.py # main() entry point
├── metrics/ # Metrics system (Section 2)
│ ├── __init__.py # re-exports
│ ├── metrics.py # Metrics collector
│ ├── section.py # Section data class
│ ├── handler.py # StreamHandler, FileHandler
│ └── formatter.py # TerminalFormatter, JsonFormatter
├── commands/
│ ├── __init__.py # ALL_COMMANDS registry
│ ├── base.py # BaseCommand ABC
│ └── mock.py # lmcache mock (example command)
└── corpora/ # built-in prompt corpora (future)
Entry point (pyproject.toml)
[project.scripts]
lmcache = "lmcache.cli.main:main"
2. Hierarchical Metrics System
Goal
A lightweight, dependency-free metrics system that:
- Accepts metrics organized into sections (categories).
- Uses a handler + formatter architecture (like Python
logging) to separate where metrics are written from how they are rendered. - Supports stdout, file, and future destinations (e.g. Kafka) without requiring command authors to manage handlers themselves.
Architecture
The metrics system has three layers:
Metrics— the collector. Holds sections and entries. Callsemit()to trigger all registered handlers.MetricsHandler— the destination (where to write). Each handler holds a formatter. Built-in:StreamHandler(writes to a stream like stdout),FileHandler(writes to a file path).MetricsFormatter— the rendering (how to format). Built-in:TerminalFormatter(ASCII table),JsonFormatter(JSON string).
Metrics ──emit()──▶ Handler (destination) ──▶ Formatter (rendering)
StreamHandler(stdout) TerminalFormatter
FileHandler("out.json") JsonFormatter
File layout
lmcache/cli/metrics/
├── __init__.py # re-exports
├── metrics.py # Metrics collector
├── section.py # Section data class
├── handler.py # MetricsHandler, StreamHandler, FileHandler
└── formatter.py # MetricsFormatter, TerminalFormatter, JsonFormatter
API
Each metric has a machine key (used in JSON output) and a human-readable label (used in terminal output). Sections work the same way.
from lmcache.cli.metrics import Metrics, StreamHandler, TerminalFormatter
metrics = Metrics(title="Bench KV Cache Result (30s)")
# Title can be changed after construction
metrics.title("Bench KV Cache Result (60s)")
# Create named sections (machine key + display label)
metrics.add_section("ops", "Operations (ops/s)")
metrics.add_section("hit_rate", "Hit Rate")
metrics.add_section("correctness", "Correctness")
# Add metrics to sections via dict-like access
metrics["ops"].add("store", "Store", 41.3)
metrics["ops"].add("retrieve", "Retrieve", 127.3)
metrics["hit_rate"].add("l1", "L1", "92.3%")
metrics["correctness"].add("checksums", "Checksums", "5060/5060 OK")
# Trigger all handlers
metrics.emit()
Command authors don't register handlers manually. BaseCommand.create_metrics()
sets up default handlers automatically:
# Inside a command's execute() method:
metrics = self.create_metrics("Bench Result", args, width=48)
# ^ automatically adds:
# - StreamHandler → stdout (formatter chosen by --format, default: terminal)
# - FileHandler → if --output is set (same format as --format)
Handlers and Formatters
Handlers (destination):
| Handler | Default Formatter | Description |
|---|---|---|
StreamHandler(formatter, stream) |
TerminalFormatter |
Writes to a text stream (default: stdout) |
FileHandler(path, formatter) |
JsonFormatter |
Writes to a file |
Formatters (rendering):
| Formatter | Description |
|---|---|
TerminalFormatter(width) |
ASCII table with =/- dividers |
JsonFormatter(indent) |
JSON string |
Custom handlers and formatters can be added by subclassing MetricsHandler
and MetricsFormatter.
Terminal output format
========= Bench KV Cache Result (30s) =========
--------------Operations (ops/s)----------------
Store: 41.3
Retrieve: 127.3
-----------------Hit Rate-----------------------
L1: 92.3%
--------------Correctness-----------------------
Checksums: 5060/5060 OK
================================================
Design choices:
- Fixed total width of 48 characters (configurable via
widthparam onTerminalFormatter). - Title row is centered within
=borders. - Section headers are centered within
-borders. - Key-value lines are left-aligned label, right-aligned value.
- Values are formatted automatically: floats get 2 decimal places, strings are
printed as-is,
Noneis printed asN/A. - Output goes directly to stdout (conventional CLI behavior, not via
logging).
JSON output format
JSON uses machine keys, not display labels:
{
"title": "Bench KV Cache Result (30s)",
"metrics": {
"ops": {
"store": 41.3,
"retrieve": 127.3
},
"hit_rate": {
"l1": "92.3%"
},
"correctness": {
"checksums": "5060/5060 OK"
}
}
}
Flat metrics (no section)
For top-level metrics that don't belong to a section, use metrics.add()
directly:
metrics = self.create_metrics("Ping KV Cache", args)
metrics.add("status", "Status", "OK")
metrics.add("rtt_ms", "Round trip time (ms)", 0.42)
metrics.emit()
Produces:
======= Ping KV Cache =======
Status: OK
Round trip time (ms): 0.42
==============================
These go into a default unnamed section — no header line is rendered, and in
JSON the entries appear at the top level of "metrics".
Implementation notes
Metricsholds an ordered list ofSectionobjects. EachSectionstores a machine key, a display label, and a list of(key, label, value)entries.metrics["name"]returns theSectionwith that machine key.KeyErrorifadd_section()was not called first.metrics.add(key, label, value)appends to a default unnamed section (created implicitly on first use).emit()iterates all registered handlers and callshandler.emit().to_dict()returns{"title": ..., "metrics": ...}for programmatic access.- No external dependencies beyond the Python standard library.
3. lmcache mock — Example Command
A mock command that demonstrates the full framework: argument parsing, metrics logging, and both terminal and JSON output. It doesn't connect to any server.
$ lmcache mock --name test-run --num-items 5
============= Mock Result ==============
----------- Input Parameters -----------
Name: test-run
Num items: 5
------------- Mock Metrics -------------
Items processed: 42
Total time (ms): 12.34
Throughput (items/s): 3403.73
-------------- Validation --------------
Status: OK
========================================
# With --output, both stdout and file are produced (two handlers)
$ lmcache mock --name test-run --num-items 5 --output result.json
(same terminal output)
# result.json → {"title": "Mock Result", "metrics": {"input": {"name": "test-run", ...}, ...}}
This command lives in lmcache/cli/commands/mock.py and serves as a reference
implementation for future commands. Note how it uses self.create_metrics()
instead of manually registering handlers.
4. Shared CLI Conventions
--format flag
Controls the stdout rendering format. Default: terminal (ASCII table). Available:
terminal, json. Added automatically by BaseCommand.register().
lmcache bench ... --format json # JSON on stdout (for scripts)
lmcache bench ... --format terminal # ASCII table (default)
--output flag
Saves metrics to a file. The file format follows --format (default:
terminal). Also added automatically by BaseCommand.register().
Can be combined with --format:
lmcache bench ... --output result.txt # terminal format to both stdout and file
lmcache bench ... --format json --output result.json # JSON to both stdout and file
--url flag
The --url flag points to the LMCache HTTP server (e.g.
http://localhost:8000). Each subcommand configures its own --url
flag as needed.
Error handling
Commands print errors to stderr and return exit code 1. The dispatcher catches
exceptions from args.func(args) and prints a clean error message.