Files
2026-07-13 12:24:33 +08:00

382 lines
12 KiB
Markdown

# 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](commands.md), 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:
1. Creating a new file in `lmcache/cli/commands/` with a `BaseCommand` subclass.
2. Adding one import + one entry to `ALL_COMMANDS` in `commands/__init__.py`.
### Mechanism
```python
# 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
```
```python
# 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
1. `lmcache <cmd> ...` invokes `main()` in `main.py`.
2. `main.py` imports `ALL_COMMANDS` from `commands/__init__.py`.
3. At import time, `__init__.py` imports each command class and instantiates
it into the `ALL_COMMANDS` list. Instantiation validates that all abstract
methods are implemented (`TypeError` on failure).
4. `main.py` iterates `ALL_COMMANDS` and calls `cmd.register(subparsers)`.
5. `BaseCommand.register()` creates an argparse subparser (using `name()` and
`help()`), calls `add_arguments()` to wire up flags, and sets
`parser.set_defaults(func=self.execute)`.
6. After parsing, `main.py` dispatches via `args.func(args)`, which calls the
matched command's `execute()`.
### How to add a new subcommand
**Step 1.** Create `lmcache/cli/commands/describe.py`:
```python
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`:
```python
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)
```toml
[project.scripts]
lmcache = "lmcache.cli.main:main"
```
---
## 2. Hierarchical Metrics System
### Goal
A lightweight, dependency-free metrics system that:
1. Accepts metrics organized into **sections** (categories).
2. Uses a **handler + formatter** architecture (like Python `logging`) to
separate *where* metrics are written from *how* they are rendered.
3. 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. Calls `emit()`
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.
```python
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:
```python
# 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 `width` param on
`TerminalFormatter`).
- 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, `None` is printed as `N/A`.
- Output goes directly to stdout (conventional CLI behavior, not via `logging`).
### JSON output format
JSON uses machine keys, not display labels:
```json
{
"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:
```python
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
- `Metrics` holds an ordered list of `Section` objects. Each `Section` stores
a machine key, a display label, and a list of `(key, label, value)` entries.
- `metrics["name"]` returns the `Section` with that machine key. `KeyError`
if `add_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 calls `handler.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.
```bash
$ 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()`.
```bash
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`:
```bash
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.