Spaces:
Running on Zero
A newer version of the Gradio SDK is available: 6.20.0
OpenBMB Local AI Workbench β Technical PRD v2.0
Revised: 2026-06-05 | Corrections from online research embedded inline
Table of Contents
- Purpose & Design Philosophy
- Design Principles (Template Architecture)
- System Architecture
- Model Registry
- Inference Stack β Five Modes
- Tracking: Trackio (Corrected API)
- MCP Layer (Three Integration Paths)
- Training Pipeline
- Export & Quantization
- Agent Mode (ml-intern Inspired)
- UI β Tab Specification
- Field Notes & Correction Loop
- Directory Structure
- Configuration Schema
- Dependencies
- Hackathon Demo Flow
- Corrections from PRD v1
- Roadmap & Extension Points
1. Purpose & Design Philosophy
One-line: A modular AI experimentation platform for the OpenBMB model family that covers dataset ingestion β LoRA training β evaluation β GGUF export β llama.cpp/SGLang deployment β multimodal inference β MCP tool exposure β trace sharing β in a single Gradio app.
Broader framing: The platform is deliberately designed as a template. The config/models.yaml
and config/training.yaml files drive almost all model-specific behaviour. Replacing the OpenBMB
section with any other model family (Qwen, Phi, Gemma β¦) should require only config changes and
a matching service class, never core rewrites.
Why OpenBMB?
- MiniCPM5-1B (2026-05-19): SOTA 1B on-device, 128K context, LlamaForCausalLM β no custom kernels
- MiniCPM4.1-8B (2025-09): sparse InfLLM-v2 attention, ~7Γ long-context speedup vs Qwen3-8B
- MiniCPM-V-4.6 (2026-05-11): 1.3B VLM, SigLIP2-400M + Qwen3.5-0.8B, 262K ctx, mixed 4Γ/16Γ token compression
- MiniCPM-V-4.6-Thinking: same architecture + long-CoT for multimodal reasoning
- MiniCPM-o 4.5 (2026-05-17): omnimodal (vision + audio), real-time conversation
- Apache 2.0 across the board; vLLM / SGLang / llama.cpp / Ollama native support
2. Design Principles (Template Architecture)
| Principle | Implementation |
|---|---|
| Config-driven | models.yaml + training.yaml define all models; no model names hardcoded in logic |
| Event-driven | core/events.py β typed event bus; all cross-module comms via events |
| Registry pattern | core/registry.py β all services register by name; swap without restart |
| Separation of concerns | models/ = loading; training/ = train/eval; ui/ = Gradio; mcp/ = protocol |
| Plugin tools | Any Python function decorated @mcp.tool() is auto-exposed; no wiring needed |
| Async-first | FastAPI backend, Gradio queuing, non-blocking inference |
| Observability | Every significant action fires a Trackio log + event |
| Template portability | Replace config/models.yaml β different model family; keep all else |
3. System Architecture
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β OpenBMB Local AI Workbench β
βββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Gradio UI Layer β Backend Service Layer β
β βββββββββββββββββββββββ β βββββββββββββββββββββββββββββββββββββββββββ β
β β gr.Blocks / Tabs β β β gradio.Server (FastAPI-based) β β
β β chat_tab.py β β β @app.api() β Gradio-queued endpoints β β
β β vision_tab.py β β β @app.mcp.tool() β MCP registration β β
β β train_tab.py β β β @app.get("/") β static/custom frontend β β
β β dataset_tab.py β β βββββββββββββββββββββββββββββββββββββββββββ β
β β export_tab.py β β β
β β traces_tab.py β β βββββββββββββββββββββββββββββββββββββββββββ β
β β agent_tab.py β β β FastMCP server (standalone / bridged) β β
β βββββββββββββββββββββββ β β mcp = FastMCP("OpenBMBWorkbench") β β
β β β Registered tools (see Β§7) β β
β SSE MCP endpoint: β βββββββββββββββββββββββββββββββββββββββββββ β
β /gradio_api/mcp/sse β β
βββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
βΌ βΌ
βββββββββββββββββββββββ ββββββββββββββββββββββββββ
β Model Services β β Tracking & Storage β
β minicpm_text.py β β trackio (local/Space) β
β minicpm_vision.py β β field_notes/ (SQLite) β
β llama_cpp_runner.pyβ β exports/ (GGUF files) β
β sglang_runner.py β β data/ (HF datasets) β
β ollama_runner.py β ββββββββββββββββββββββββββ
βββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Inference Backends β
β Transformers β llama.cpp β SGLang β vLLM β Ollamaβ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
3.1 Event Bus (core/events.py)
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable
import asyncio
class EventType(str, Enum):
DATASET_LOADED = "dataset_loaded"
TRAINING_STARTED = "training_started"
TRAINING_STEP = "training_step"
TRAINING_FINISHED = "training_finished"
EVAL_FINISHED = "eval_finished"
INFERENCE_REQUEST = "inference_request"
INFERENCE_RESPONSE = "inference_response"
TOOL_CALL = "tool_call"
MODEL_LOADED = "model_loaded"
MODEL_SWITCHED = "model_switched"
EXPORT_STARTED = "export_started"
EXPORT_FINISHED = "export_finished"
FIELD_NOTE_SAVED = "field_note_saved"
AGENT_STEP = "agent_step"
@dataclass
class Event:
type: EventType
payload: dict[str, Any] = field(default_factory=dict)
run_id: str = ""
class EventBus:
_handlers: dict[EventType, list[Callable]] = {}
def on(self, event_type: EventType):
"""Decorator to register an event handler."""
def decorator(fn: Callable):
self._handlers.setdefault(event_type, []).append(fn)
return fn
return decorator
async def emit(self, event: Event):
for handler in self._handlers.get(event.type, []):
await handler(event)
bus = EventBus()
3.2 Service Registry (core/registry.py)
from typing import TypeVar, Generic, Type
T = TypeVar("T")
class Registry(Generic[T]):
def __init__(self):
self._services: dict[str, T] = {}
def register(self, name: str, service: T):
self._services[name] = service
def get(self, name: str) -> T:
if name not in self._services:
raise KeyError(f"Service '{name}' not registered. Available: {list(self._services)}")
return self._services[name]
def list(self) -> list[str]:
return list(self._services.keys())
# Singleton registries
model_registry = Registry() # model services
dataset_registry = Registry() # dataset loaders
tool_registry = Registry() # MCP tools
4. Model Registry
4.1 Text Models
| Config ID | HF ID | Architecture | Context | Notes |
|---|---|---|---|---|
minicpm5_1b |
openbmb/MiniCPM5-1B |
LlamaForCausalLM | 128K | Standard; no custom kernels; XML tool calls; SGLang minicpm5 parser |
minicpm5_1b_thinking |
openbmb/MiniCPM5-1B-Thinking |
LlamaForCausalLM | 128K | CoT mode; chat_template_kwargs={"enable_thinking": True} |
minicpm41_8b |
openbmb/MiniCPM4.1-8B |
Sparse InfLLM-v2 | 128K (YaRN) | --trust-remote-code required; 7Γ faster long-ctx vs Qwen3-8B |
4.2 Vision Models
| Config ID | HF ID | Backbone | Context | Notes |
|---|---|---|---|---|
minicpm_v46 |
openbmb/MiniCPM-V-4.6 |
SigLIP2-400M + Qwen3.5-0.8B | 262K | transformers>=5.7.0 + torchcodec; mixed 4Γ/16Γ compression |
minicpm_v46_thinking |
openbmb/MiniCPM-V-4.6-Thinking |
same + CoT head | 262K | enable_thinking=True in chat_template_kwargs |
minicpm_o45 |
openbmb/MiniCPM-o-4.5 |
Omnimodal | - | Vision + Audio; real-time conversation; see Β§4.4 |
4.3 Model Architecture Notes (critical for correct code)
MiniCPM5-1B β standard Llama, loads with:
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained("openbmb/MiniCPM5-1B", trust_remote_code=True)
MiniCPM-V-4.6 β vision-language, loads with:
# β οΈ CORRECTION from v1: NOT AutoModelForCausalLM β use AutoModelForImageTextToText
from transformers import AutoProcessor, AutoModelForImageTextToText
processor = AutoProcessor.from_pretrained("openbmb/MiniCPM-V-4.6", trust_remote_code=True)
model = AutoModelForImageTextToText.from_pretrained(
"openbmb/MiniCPM-V-4.6",
trust_remote_code=True,
torch_dtype=torch.bfloat16,
device_map="auto"
)
Chat format (vision):
messages = [
{
"role": "user",
"content": [
{"type": "image", "image": pil_image}, # PIL Image
{"type": "text", "text": prompt}
]
}
]
inputs = processor.apply_chat_template(
messages,
add_generation_prompt=True,
tokenize=True,
return_tensors="pt",
return_dict=True,
chat_template_kwargs={"enable_thinking": False} # True for Thinking variant
).to(model.device)
MiniCPM4.1-8B β requires trust_remote_code=True for sparse attention kernel.
4.4 models.yaml Schema
# config/models.yaml
# βββ Template: add any HF model here ββββββββββββββββββββββββββββββββββββββ
models:
minicpm5_1b:
hf_id: openbmb/MiniCPM5-1B
type: text # text | vision | omnimodal
architecture: llama # llama | minicpm41 | minicpm_v
context_length: 131072
dtype: bfloat16
trust_remote_code: false # LlamaForCausalLM β no fork
thinking_mode: false # set true for Thinking checkpoints
tool_call_parser: minicpm5 # for SGLang serving
gguf:
available: true
repo: openbmb/MiniCPM5-1B-GGUF
default_quant: Q4_K_M
minicpm41_8b:
hf_id: openbmb/MiniCPM4.1-8B
type: text
architecture: minicpm41 # sparse InfLLM-v2
context_length: 131072
dtype: bfloat16
trust_remote_code: true
thinking_mode: false
gguf:
available: true
repo: openbmb/MiniCPM4.1-8B-GGUF
default_quant: Q4_K_M
minicpm_v46:
hf_id: openbmb/MiniCPM-V-4.6
type: vision
architecture: minicpm_v
context_length: 262144
dtype: bfloat16
trust_remote_code: true
thinking_mode: false
vision_encoder: siglip2_400m
token_compression: "4x_16x" # mixed compression
gguf:
available: true
repo: openbmb/MiniCPM-V-4.6-gguf # official repo β no surgery script needed
main_file: MiniCPM-V-4.6-Q4_K_M.gguf
mmproj: mmproj-MiniCPM-V-4.6-F16.gguf
default_quant: Q4_K_M
minicpm_v46_thinking:
hf_id: openbmb/MiniCPM-V-4.6-Thinking
type: vision
architecture: minicpm_v
context_length: 262144
dtype: bfloat16
trust_remote_code: true
thinking_mode: true
gguf:
available: true
repo: openbmb/MiniCPM-V-4.6-Thinking-gguf
default_quant: Q4_K_M
minicpm_o45:
hf_id: openbmb/MiniCPM-o-4.5
type: omnimodal
architecture: minicpm_o
context_length: 131072
dtype: bfloat16
trust_remote_code: true
thinking_mode: false
note: "Vision + Audio; real-time conversation mode"
5. Inference Stack β Five Modes
Mode A: Transformers (train + eval + fine-tuning)
Used for: LoRA training, evaluation, PEFT
Load text: AutoModelForCausalLM
Load vision: AutoModelForImageTextToText β corrected from v1
# models/minicpm_text.py
class MiniCPMTextService:
def __init__(self, model_id: str, cfg: dict):
self.model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype=torch.bfloat16,
device_map="auto",
trust_remote_code=cfg.get("trust_remote_code", False)
)
self.tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
# models/minicpm_vision.py β new dedicated service
class MiniCPMVisionService:
def __init__(self, model_id: str, cfg: dict):
self.processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True)
self.model = AutoModelForImageTextToText.from_pretrained(
model_id,
torch_dtype=torch.bfloat16,
device_map="auto",
trust_remote_code=cfg.get("trust_remote_code", True)
)
self.thinking = cfg.get("thinking_mode", False)
def chat(self, messages: list[dict], max_new_tokens: int = 512) -> str:
inputs = self.processor.apply_chat_template(
messages,
add_generation_prompt=True,
tokenize=True,
return_tensors="pt",
return_dict=True,
chat_template_kwargs={"enable_thinking": self.thinking}
).to(self.model.device)
with torch.no_grad():
outputs = self.model.generate(**inputs, max_new_tokens=max_new_tokens)
response = self.processor.decode(
outputs[0][inputs["input_ids"].shape[1]:],
skip_special_tokens=True
)
return response
Mode B: llama.cpp (local deployment, CPU/GPU offload)
β οΈ CORRECTION from v1: For MiniCPM-V 4.6, official GGUFs are already published. Download from
openbmb/MiniCPM-V-4.6-ggufβ no need to runconvert_hf_to_gguf.pyyourself. Theminicpmv-surgery.pyscript was for the older MiniCPM-o-2.6; do not use it for V4.6.
Text models β llama-server (standard OpenAI-compatible endpoint)
Vision models β llama-server --mmproj <mmproj.gguf> (multimodal server)
# models/llama_cpp_runner.py
import subprocess, requests, time
class LlamaCppDeployment:
def __init__(self, cfg: dict):
self.model_path = cfg["model_path"]
self.mmproj_path = cfg.get("mmproj_path") # None for text models
self.port = cfg.get("port", 8080)
self._proc = None
def start(self):
cmd = [
"llama-server",
"--model", self.model_path,
"--port", str(self.port),
"--ctx-size", "8192",
"--n-gpu-layers", "-1", # full GPU offload if available
]
if self.mmproj_path:
cmd += ["--mmproj", self.mmproj_path] # enables vision
self._proc = subprocess.Popen(cmd)
self._wait_ready()
def _wait_ready(self, timeout: int = 30):
for _ in range(timeout):
try:
r = requests.get(f"http://127.0.0.1:{self.port}/health")
if r.status_code == 200:
return
except Exception:
pass
time.sleep(1)
raise RuntimeError("llama-server did not start in time")
def chat(self, messages: list[dict]) -> str:
r = requests.post(
f"http://127.0.0.1:{self.port}/v1/chat/completions",
json={"messages": messages, "max_tokens": 512}
)
return r.json()["choices"][0]["message"]["content"]
def stop(self):
if self._proc:
self._proc.terminate()
GGUF Export (only needed if building custom quants):
# For MiniCPM-V 4.6 β convert_hf_to_gguf.py (NOT legacy surgery scripts)
python convert_hf_to_gguf.py openbmb/MiniCPM-V-4.6 --outtype f16
# Output: MiniCPM-V-4.6-F16.gguf + mmproj-MiniCPM-V-4.6-F16.gguf
# Quantize
./llama-quantize MiniCPM-V-4.6-F16.gguf MiniCPM-V-4.6-Q4_K_M.gguf Q4_K_M
Supported quants: Q4_K_M, Q5_K_M, Q8_0, Q2_K, F16
Mode C: SGLang (tool-use, function calling, recommended for MiniCPM5)
MiniCPM5-1B emits XML-style tool calls. SGLang has a native minicpm5 parser:
python -m sglang.launch_server \
--model-path openbmb/MiniCPM5-1B \
--port 30000 \
--tool-call-parser minicpm5 # or: --tool-call-parser auto
For MiniCPM-V-4.6 vision:
python -m sglang.launch_server \
--model-path openbmb/MiniCPM-V-4.6-Thinking \
--port 30000 \
--enable-auto-tool-choice \
--tool-call-parser qwen3_coder \
--default-chat-template-kwargs '{"enable_thinking": true}'
Mode D: vLLM (batch serving, production)
vllm serve openbmb/MiniCPM4.1-8B --trust-remote-code
vllm serve openbmb/MiniCPM-V-4.6 --trust-remote-code
Mode E: Ollama (zero-config, consumer hardware)
ollama run openbmb/minicpm-v4.6 # vision + text
ollama run openbmb/minicpm5-1b # text only
Mode Selection Matrix
| Use case | Recommended mode |
|---|---|
| LoRA training | A (Transformers) |
| Evaluation | A (Transformers) |
| Local chat (fast) | B (llama.cpp) |
| Tool/function calling | C (SGLang) |
| Production batch | D (vLLM) |
| Consumer demo | E (Ollama) |
| Vision inference local | B (llama-server --mmproj) |
| Vision fine-tuning | A (SWIFT / LLaMA-Factory) |
6. Tracking: Trackio (Corrected API)
β οΈ CRITICAL CORRECTION from v1:
trackio.trace()does not exist. There is no context-manager trace API in Trackio. The v1 PRD example was wrong. Correct API:init()/log()/finish()/alert()/Table()/Markdown()
Trackio is a wandb drop-in replacement, local-first, free, Gradio dashboard, HF Spaces sync.
# tracking/trackio_service.py
import trackio
import time
from core.events import bus, EventType, Event
class TrackioService:
def __init__(self, project: str, space_id: str | None = None):
self.project = project
self.space_id = space_id
self._active = False
self._register_handlers()
def _register_handlers(self):
@bus.on(EventType.TRAINING_STARTED)
async def on_training_started(event: Event):
trackio.init(
project=self.project,
space_id=self.space_id,
run_name=event.payload.get("run_name", "training"),
)
self._active = True
@bus.on(EventType.TRAINING_STEP)
async def on_step(event: Event):
if self._active:
trackio.log(event.payload)
@bus.on(EventType.TRAINING_FINISHED)
async def on_done(event: Event):
if self._active:
trackio.log({"status": "finished", **event.payload})
trackio.finish()
self._active = False
@bus.on(EventType.EVAL_FINISHED)
async def on_eval(event: Event):
trackio.init(
project=self.project,
space_id=self.space_id,
run_name=event.payload.get("eval_run_name", "eval"),
)
trackio.log(event.payload)
trackio.finish()
@bus.on(EventType.INFERENCE_RESPONSE)
async def on_inference(event: Event):
# Log inference latency and token count
trackio.init(
project=self.project,
space_id=self.space_id,
run_name="inference",
)
trackio.log({
"model": event.payload.get("model"),
"latency_ms": event.payload.get("latency_ms"),
"tokens_out": event.payload.get("tokens_out"),
"mode": event.payload.get("mode", "text"),
})
trackio.finish()
@bus.on(EventType.TOOL_CALL)
async def on_tool(event: Event):
trackio.alert(
title=f"Tool call: {event.payload.get('tool_name')}",
level=trackio.AlertLevel.INFO,
)
TRL LoRA integration (recommended for training):
from trl import SFTConfig
training_args = SFTConfig(
output_dir="./checkpoints",
report_to="trackio", # β one-liner Trackio integration via TRL
run_name="lora_minicpm5_1b",
)
SQL query via CLI (LLM-friendly):
trackio query project --project workbench --sql "SELECT run_name, loss FROM metrics ORDER BY loss LIMIT 5"
7. MCP Layer (Three Integration Paths)
Path 1: Gradio Blocks + launch(mcp_server=True) β simplest
# app.py (simple path)
import gradio as gr
from mcp.server.fastmcp import FastMCP
demo = gr.Blocks()
# ... tabs ...
demo.launch(
mcp_server=True, # exposes /gradio_api/mcp/sse
server_port=7860,
)
Every gr.Interface function and gr.api() endpoint becomes an MCP tool automatically.
Docstrings become tool descriptions. Type hints become parameter schemas.
Path 2: gradio.Server (custom frontend + MCP)
New API published 2026-04-01. Extends FastAPI. Better for custom dashboards.
# app.py (Server path)
from gradio import Server
from fastapi.responses import HTMLResponse
app = Server()
@app.api(name="run_inference") # Gradio-queued + MCP-compatible
def run_inference(prompt: str, model_id: str) -> str:
"""Run text inference on a MiniCPM text model."""
svc = model_registry.get(model_id)
return svc.generate(prompt)
@app.mcp.tool() # explicit MCP tool registration
async def export_gguf(model_id: str, quant: str = "Q4_K_M") -> str:
"""Export a Transformers model to GGUF format with specified quantization."""
return await export_service.run(model_id, quant)
@app.get("/", response_class=HTMLResponse) # custom frontend
async def homepage():
return open("ui/index.html").read()
app.launch(show_error=True)
Path 3: FastMCP standalone + Gradio client bridge
Use when you need stateful MCP tools, or when running multiple Gradio apps and want to save memory:
# mcp/server.py
from mcp.server.fastmcp import FastMCP
from gradio_client import Client
mcp = FastMCP("OpenBMBWorkbench")
_clients: dict[str, Client] = {}
def _gradio_client(space_id: str) -> Client:
if space_id not in _clients:
_clients[space_id] = Client(space_id)
return _clients[space_id]
@mcp.tool()
async def calculate(expression: str) -> str:
"""Evaluate a mathematical expression."""
import math
return str(eval(expression, {"__builtins__": {}}, vars(math)))
@mcp.tool()
async def dataset_stats(dataset_name: str, split: str = "train") -> dict:
"""Return basic statistics for a HuggingFace dataset."""
from datasets import load_dataset
ds = load_dataset(dataset_name, split=split)
return {
"rows": len(ds),
"columns": ds.column_names,
"features": str(ds.features),
}
@mcp.tool()
async def search_dataset(query: str, max_results: int = 5) -> list[dict]:
"""Search HuggingFace Hub for datasets matching a query."""
from huggingface_hub import list_datasets
results = list(list_datasets(search=query, limit=max_results))
return [{"id": d.id, "downloads": d.downloads, "tags": d.tags} for d in results]
@mcp.tool()
async def start_training(
model_id: str,
dataset_name: str,
lora_rank: int = 16,
epochs: int = 3,
) -> dict:
"""Start a LoRA fine-tuning run on the specified model and dataset."""
# Dispatch to training service via event bus
await bus.emit(Event(
type=EventType.TRAINING_STARTED,
payload={"model_id": model_id, "dataset": dataset_name,
"lora_rank": lora_rank, "epochs": epochs}
))
return {"status": "started", "model_id": model_id}
@mcp.tool()
async def evaluate_model(model_id: str, dataset_name: str) -> dict:
"""Evaluate a model on a dataset and return metrics."""
svc = model_registry.get(model_id)
return await eval_service.run(svc, dataset_name)
@mcp.tool()
async def export_gguf(model_id: str, quant: str = "Q4_K_M") -> str:
"""Convert a fine-tuned model to GGUF format."""
return await export_service.run(model_id, quant)
@mcp.tool()
async def vision_chat(image_path: str, prompt: str, model_id: str = "minicpm_v46") -> str:
"""Run multimodal inference on an image with a prompt."""
svc: MiniCPMVisionService = model_registry.get(model_id)
from PIL import Image
img = Image.open(image_path).convert("RGB")
messages = [{"role": "user", "content": [
{"type": "image", "image": img},
{"type": "text", "text": prompt},
]}]
return svc.chat(messages)
if __name__ == "__main__":
mcp.run(transport="sse", port=8081)
MCP SSE endpoint: http://localhost:8081/sse
Gradio MCP SSE: http://localhost:7860/gradio_api/mcp/sse
8. Training Pipeline
8.1 LoRA β Text Models (PEFT)
# training/lora.py
from peft import LoraConfig, get_peft_model, TaskType
from trl import SFTTrainer, SFTConfig
class LoRATextTrainer:
DEFAULT_TARGET_MODULES = ["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"]
def __init__(self, cfg: dict):
self.cfg = cfg
def train(self, model, tokenizer, dataset, run_name: str):
lora_cfg = LoraConfig(
r=self.cfg.get("lora_rank", 16),
lora_alpha=self.cfg.get("lora_alpha", 32),
target_modules=self.cfg.get("target_modules", self.DEFAULT_TARGET_MODULES),
lora_dropout=self.cfg.get("lora_dropout", 0.05),
bias="none",
task_type=TaskType.CAUSAL_LM,
)
model = get_peft_model(model, lora_cfg)
model.print_trainable_parameters()
training_args = SFTConfig(
output_dir=f"./checkpoints/{run_name}",
num_train_epochs=self.cfg.get("epochs", 3),
per_device_train_batch_size=self.cfg.get("batch_size", 4),
gradient_accumulation_steps=self.cfg.get("grad_accum", 4),
learning_rate=self.cfg.get("lr", 2e-4),
fp16=False,
bf16=True,
report_to="trackio", # β Trackio via TRL
run_name=run_name,
save_steps=100,
logging_steps=10,
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=dataset,
tokenizer=tokenizer,
)
trainer.train()
return model
8.2 LoRA β Vision Models (SWIFT / LLaMA-Factory recommended)
OpenBMB recommends SWIFT or LLaMA-Factory for MiniCPM-V fine-tuning. Vision tower is frozen; only language backbone layers are adapted.
# training/lora_vision.py
class LoRAVisionConfig:
"""
For MiniCPM-V 4.6 LoRA:
- Freeze vision encoder (SigLIP2-400M) + projector
- Apply LoRA only to Qwen3.5-0.8B language layers
- Use SWIFT CLI or LLaMA-Factory for full pipeline
"""
LANGUAGE_TARGET_MODULES = ["q_proj", "k_proj", "v_proj", "o_proj"]
FROZEN_PREFIXES = ["vpm", "resampler", "vision_model"] # freeze vision
@staticmethod
def freeze_vision(model):
for name, param in model.named_parameters():
if any(name.startswith(pfx) for pfx in LoRAVisionConfig.FROZEN_PREFIXES):
param.requires_grad = False
return model
SWIFT CLI approach (recommended for MiniCPM-V 4.6):
swift sft \
--model openbmb/MiniCPM-V-4.6 \
--dataset <your-dataset> \
--lora_rank 16 \
--freeze_vit true \
--output_dir ./checkpoints/minicpm_v46_lora
8.3 HF Jobs Offload (ml-intern pattern)
For long training runs, offload to HF Jobs (free cpu-basic, paid gpu-a100):
# training/hf_jobs.py
from huggingface_hub import HfApi
class HFJobsTrainer:
def submit(self, script_path: str, hardware: str = "gpu-a100") -> str:
api = HfApi()
job = api.create_job(
repo_id=f"{api.whoami()['name']}/ml-workbench-jobs",
script=script_path,
hardware=hardware,
)
return job.job_id
8.4 Evaluation
# training/evaluation.py
class Evaluator:
def run(self, model, tokenizer, dataset, metrics=("perplexity", "accuracy")) -> dict:
results = {}
if "perplexity" in metrics:
results["perplexity"] = self._perplexity(model, tokenizer, dataset)
if "accuracy" in metrics:
results["accuracy"] = self._accuracy(model, tokenizer, dataset)
return results
9. Export & Quantization
# training/export.py
import subprocess
from pathlib import Path
class GGUFExporter:
SUPPORTED_QUANTS = ["F16", "Q4_K_M", "Q5_K_M", "Q8_0", "Q2_K"]
def export(
self,
model_path: str,
output_dir: str,
quant: str = "Q4_K_M",
model_type: str = "text", # "text" | "vision"
) -> list[str]:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Step 1: Convert HF β GGUF F16
# For MiniCPM-V 4.6: convert_hf_to_gguf.py produces main gguf + mmproj
# For text models: same script, no mmproj
f16_path = output_dir / "model-f16.gguf"
subprocess.run([
"python", "convert_hf_to_gguf.py",
model_path,
"--outtype", "f16",
"--outfile", str(f16_path),
], check=True)
outputs = [str(f16_path)]
# Step 2: Quantize
if quant != "F16":
quant_path = output_dir / f"model-{quant}.gguf"
subprocess.run([
"llama-quantize",
str(f16_path),
str(quant_path),
quant,
], check=True)
outputs.append(str(quant_path))
return outputs
Note: For MiniCPM-V 4.6, official GGUF files are already published at
openbmb/MiniCPM-V-4.6-gguf. Download them directly unless you need custom quants.
10. Agent Mode (ml-intern Inspired)
The agent/ module implements a lightweight research-plan-implement loop inspired by HF's
ml-intern (April 2026). This gives the workbench autonomous ML experimentation capabilities.
ml-intern architecture (reference):
Research β browse arXiv/HF Papers, read methodology, traverse citations
Plan β break task down, verify resources (models, datasets, hardware)
Implement β execute scripts on HF Jobs / local GPU
Trace β auto-upload sessions as JSONL to HF Dataset
# agent/loop.py
from smolagents import CodeAgent, HfApiModel
from huggingface_hub import HfApi
class WorkbenchAgent:
"""
Lightweight agent loop for autonomous experimentation.
Uses smolagents + HF ecosystem + registered MCP tools.
"""
def __init__(self, model_id: str = "claude-sonnet-4-6"):
self.model = HfApiModel(model_id)
self.api = HfApi()
self._session_log = []
def run(self, prompt: str, max_steps: int = 20) -> str:
agent = CodeAgent(
tools=self._get_tools(),
model=self.model,
max_steps=max_steps,
)
result = agent.run(prompt)
self._save_session(prompt, result)
return result
def _save_session(self, prompt: str, result: str):
import json, datetime
session = {
"timestamp": datetime.datetime.utcnow().isoformat(),
"prompt": prompt,
"result": result,
"log": self._session_log,
}
# Auto-upload to private HF dataset (ml-intern pattern)
# Dataset: {username}/workbench-sessions
pass
def _get_tools(self):
# Expose MCP tools as smolagents tools
from mcp.server.fastmcp import FastMCP
# ...
return []
11. UI β Tab Specification
Using gr.Blocks with tabs. Optionally swap to gradio.Server for custom frontend.
# app.py
import gradio as gr
from ui.chat_tab import build_chat_tab
from ui.vision_tab import build_vision_tab
from ui.train_tab import build_train_tab
from ui.dataset_tab import build_dataset_tab
from ui.export_tab import build_export_tab
from ui.traces_tab import build_traces_tab
from ui.agent_tab import build_agent_tab
from ui.notes_tab import build_notes_tab
with gr.Blocks(title="OpenBMB Workbench", theme=gr.themes.Ocean()) as demo:
gr.Markdown("# π¬ OpenBMB Local AI Workbench")
with gr.Tabs():
with gr.Tab("π¬ Chat"): build_chat_tab()
with gr.Tab("ποΈ Vision"): build_vision_tab()
with gr.Tab("π Dataset"): build_dataset_tab()
with gr.Tab("ποΈ Train"): build_train_tab()
with gr.Tab("π¦ Export"): build_export_tab()
with gr.Tab("π Traces"): build_traces_tab()
with gr.Tab("π€ Agent"): build_agent_tab()
with gr.Tab("π Field Notes"):build_notes_tab()
demo.launch(
mcp_server=True, # SSE at /gradio_api/mcp/sse
server_port=7860,
share=False,
)
Tab Summary
| Tab | Purpose | Key components |
|---|---|---|
| Chat | Text inference on all text models | Model selector, streaming output, system prompt |
| Vision | Image + text inference on V-4.6/Thinking | Image upload, thinking toggle, bounding box overlay |
| Dataset | HF Hub browser + local CSV/JSON loader | Search bar, split preview, schema inspector |
| Train | LoRA training launcher | Config form, live loss chart (Trackio), checkpoint browser |
| Export | GGUF export + quantization | Model selector, quant dropdown, file download |
| Traces | Trackio run browser + comparison | Run table, metric plots, SQL query box |
| Agent | ml-intern style agent loop | Prompt, step log, paper browser, HF Jobs monitor |
| Field Notes | Correction capture β retrain trigger | Image+prompt+response+correction form, JSONL export |
Vision Tab β key detail
# ui/vision_tab.py
def build_vision_tab():
with gr.Column():
model_dd = gr.Dropdown(
choices=["minicpm_v46", "minicpm_v46_thinking", "minicpm_o45"],
value="minicpm_v46",
label="Vision Model"
)
thinking_cb = gr.Checkbox(label="Enable Thinking Mode", value=False)
image_in = gr.Image(type="pil", label="Upload Image")
video_in = gr.Video(label="Upload Video (MiniCPM-V-4.6 supports video)")
prompt_in = gr.Textbox(label="Prompt")
submit_btn = gr.Button("Run")
output_txt = gr.Textbox(label="Response", lines=10)
def infer(model_id, thinking, image, video, prompt):
svc = model_registry.get(model_id)
content = []
if image:
content.append({"type": "image", "image": image})
if video:
content.append({"type": "video", "video": video})
content.append({"type": "text", "text": prompt})
msgs = [{"role": "user", "content": content}]
svc.thinking = thinking
return svc.chat(msgs)
submit_btn.click(infer, [model_dd, thinking_cb, image_in, video_in, prompt_in], output_txt)
12. Field Notes & Correction Loop
# datasets/field_notes.py
import sqlite3, json
from dataclasses import dataclass, field, asdict
from datetime import datetime
@dataclass
class FieldNote:
id: str = field(default_factory=lambda: datetime.utcnow().isoformat())
model_id: str = ""
modality: str = "text" # "text" | "image" | "video" | "multimodal"
image_path: str | None = None
video_path: str | None = None
prompt: str = ""
response: str = ""
correction: str = "" # human-corrected output
tags: list = field(default_factory=list)
used_in_train: bool = False
class FieldNoteStore:
def __init__(self, db_path: str = "data/field_notes.db"):
self.conn = sqlite3.connect(db_path, check_same_thread=False)
self._init_db()
def _init_db(self):
self.conn.execute("""
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
data JSON NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
self.conn.commit()
def save(self, note: FieldNote):
self.conn.execute(
"INSERT OR REPLACE INTO notes (id, data) VALUES (?, ?)",
(note.id, json.dumps(asdict(note)))
)
self.conn.commit()
def to_hf_dataset(self, output_path: str):
"""Export uncorrected notes as HF Dataset for retraining."""
from datasets import Dataset
rows = [json.loads(r[0]) for r in
self.conn.execute("SELECT data FROM notes WHERE json_extract(data,'$.correction') != ''")]
ds = Dataset.from_list(rows)
ds.save_to_disk(output_path)
return ds
13. Directory Structure
ml-workbench/
βββ app.py # Entry point: gr.Blocks or gradio.Server
βββ requirements.txt
β
βββ config/
β βββ models.yaml # β TEMPLATE AXIS: change this for any model family
β βββ training.yaml # LoRA hyperparams, export settings
β
βββ core/
β βββ events.py # EventBus, EventType enum
β βββ registry.py # Registry[T] generic
β βββ state.py # Global AppState dataclass
β
βββ models/
β βββ base.py # Abstract ModelService
β βββ hf_loader.py # Generic HF Hub downloader
β βββ minicpm_text.py # MiniCPM5-1B, MiniCPM4.1-8B service
β βββ minicpm_vision.py # MiniCPM-V-4.6 / Thinking service
β βββ minicpm_omni.py # MiniCPM-o-4.5 service
β βββ llama_cpp_runner.py # llama-server wrapper
β βββ sglang_runner.py # SGLang server wrapper (tool-use)
β βββ ollama_runner.py # Ollama client
β
βββ datasets/
β βββ loader.py # Abstract DatasetLoader
β βββ hf_datasets.py # HF Hub datasets.load_dataset()
β βββ field_notes.py # SQLite-backed correction store
β βββ synthetic.py # ml-intern style synthetic data gen
β
βββ training/
β βββ lora.py # LoRA text trainer (PEFT + TRL)
β βββ lora_vision.py # LoRA vision config (SWIFT/LLaMA-Factory)
β βββ evaluation.py # Perplexity, accuracy, custom metrics
β βββ export.py # GGUF exporter (convert_hf_to_gguf.py)
β βββ hf_jobs.py # HF Jobs offload (ml-intern pattern)
β
βββ tools/
β βββ calculator.py # Safe math eval tool
β βββ dataset_stats.py # Dataset statistics tool
β βββ hf_search.py # HF Hub search tool
β βββ paper_search.py # arXiv / HF Papers search (ml-intern)
β
βββ tracking/
β βββ trackio_service.py # Trackio integration (init/log/finish/alert)
β
βββ mcp/
β βββ server.py # FastMCP standalone server
β βββ tools.py # All @mcp.tool() decorators
β
βββ agent/
β βββ loop.py # Research β Plan β Implement agent
β βββ prompts/
β βββ system_prompt.yaml # Agent system prompt (ml-intern style)
β
βββ ui/
β βββ chat_tab.py
β βββ vision_tab.py
β βββ train_tab.py
β βββ dataset_tab.py
β βββ export_tab.py
β βββ traces_tab.py
β βββ agent_tab.py
β βββ notes_tab.py
β
βββ exports/ # GGUF + quantized model outputs
βββ data/ # Cached datasets, field_notes.db
14. Configuration Schema
training.yaml
# config/training.yaml
lora:
rank: 16
alpha: 32
dropout: 0.05
target_modules_text:
- q_proj
- k_proj
- v_proj
- o_proj
- gate_proj
- up_proj
- down_proj
target_modules_vision:
- q_proj
- k_proj
- v_proj
- o_proj # language layers only; vision tower frozen
training:
epochs: 3
batch_size: 4
grad_accum: 4
lr: 2.0e-4
bf16: true
report_to: trackio # or: wandb, tensorboard, none
export:
default_quant: Q4_K_M
supported_quants:
- F16
- Q4_K_M
- Q5_K_M
- Q8_0
- Q2_K
trackio:
project: ml-workbench
space_id: null # set to "username/trackio-space" for HF sync
hf_jobs:
enabled: false
hardware: gpu-a100
15. Dependencies
# requirements.txt
# Core
gradio[mcp]>=5.38.0
mcp[cli]>=1.0.0
fastapi
uvicorn[standard]
# Models
transformers>=5.7.0 # required for MiniCPM-V-4.6
torch>=2.1.0
torchvision
torchcodec # video decoding for MiniCPM-V-4.6
accelerate
bitsandbytes
# Training
peft>=0.10.0
trl>=0.9.0
datasets>=2.18.0
# Tracking
trackio>=0.1.0
# MCP + Agents
mcp>=1.0.0
smolagents>=1.0.0
# Serving backends (optional, install as needed)
# sglang # pip install sglang
# vllm # pip install vllm
# ollama # install separately
# Hub
huggingface_hub>=0.23.0
Pillow
requests
pyyaml
16. Hackathon Demo Flow
1. DATASET
Load HF dataset via Dataset tab (e.g. "tatsu-lab/alpaca", 10K rows)
β EventType.DATASET_LOADED
β Trackio: trackio.init("demo-run") + trackio.log({"dataset": ..., "rows": ...})
2. TRAIN
Start LoRA on MiniCPM5-1B (rank=16, 1 epoch demo)
β EventType.TRAINING_STARTED
β TRL SFTTrainer + report_to="trackio" β live loss in Traces tab
3. EXPORT
Click "Export GGUF" β Q4_K_M
β convert_hf_to_gguf.py β llama-quantize
β EventType.EXPORT_FINISHED β file download
4. LLAMA.CPP CHAT
Load exported GGUF in llama-server
β Chat tab, backend: llama.cpp
β Verify text generation quality
5. SWITCH TO VISION
Select MiniCPM-V-4.6 in Vision tab
Upload image β enter prompt β Run
6. THINKING MODE
Toggle "Enable Thinking Mode" β switch to MiniCPM-V-4.6-Thinking
β Observe explicit reasoning trace in response
7. FIELD NOTE
Response is wrong β open Field Notes tab
β Enter correction β Save
β EventType.FIELD_NOTE_SAVED
8. AGENT MODE
Agent tab: "Improve MiniCPM-V-4.6 on [domain] using field notes"
β Research phase: browse HF Papers
β Plan: dataset selection, LoRA config
β Implement: trigger SWIFT fine-tune
9. SHARE TRACES
Traces tab β set space_id β Sync to HF Space
β Shareable dashboard URL
17. Corrections from PRD v1
| v1 Claim | Correct v2 |
|---|---|
trackio.trace("vision_inference") context manager |
Does not exist. Use trackio.init() + trackio.log() + trackio.finish() |
AutoModelForCausalLM for MiniCPM-V-4.6 |
Wrong. Use AutoModelForImageTextToText with transformers>=5.7.0 |
llama-mtmd-cli for vision deployment |
Use llama-server --mmproj <mmproj.gguf> for server; llama-mtmd-cli for CLI-only |
Run convert_hf_to_gguf.py for V4.6 |
Official GGUFs at openbmb/MiniCPM-V-4.6-gguf β download directly; run script only for custom quants |
| Surgery scripts for V4.6 GGUF | Surgery scripts are for MiniCPM-o-2.6 only; do not use for V4.6 |
future MiniCPM-o (vague) |
MiniCPM-o-4.5 released 2026-05-17; specific HF ID available |
app.launch(mcp_server=True) only option |
Two paths: gr.Blocks + mcp_server=True, or gradio.Server with @app.mcp.tool() |
| FastMCP as only MCP approach | Three paths: Gradio native, gradio.Server, standalone FastMCP (see Β§7) |
| MiniCPM4.1-8B: standard architecture | Has sparse InfLLM-v2 attention; requires --trust-remote-code |
| LoRA for vision: PEFT only | OpenBMB recommends SWIFT or LLaMA-Factory for MiniCPM-V fine-tuning |
| No HF Jobs mention | HF Jobs integration (ml-intern pattern) enables GPU cloud offload |
| No SGLang mention | SGLang is the recommended backend for MiniCPM5-1B tool calling |
| No video support | MiniCPM-V-4.6 supports video understanding (torchcodec required) |
18. Roadmap & Extension Points
Swap the model family (template portability)
To build a "Qwen3 Workbench":
- Edit
config/models.yamlβ add Qwen3 model IDs - Create
models/qwen3_service.pyextendingModelService - Register in
app.pyβ done
No changes to training, export, MCP, Trackio, or UI layers.
Planned extensions
| Feature | Module | Notes |
|---|---|---|
| vLLM serving tab | models/vllm_runner.py |
Batch inference, production |
| Ollama quick-start | models/ollama_runner.py |
Zero-config consumer demo |
| Reward model eval | training/reward_eval.py |
For RLHF experiments |
| Synthetic data gen | datasets/synthetic.py |
ml-intern pattern: LLM writes training data |
| Paper-to-code agent | agent/paper_agent.py |
Read arXiv β implement in workbench |
| HF Spaces deploy | deploy/spaces.py |
One-click push as HF Space |
| VINDEX integration | tools/vindex_tool.py |
Knowledge editing via your VINDEX engine |
| OCR pipeline hook | datasets/ocr_loader.py |
Feed your OCR pipeline output as field notes |
| MiniCPM Desk-Pet | agent/desk_pet.py |
LoRA persona switching via MiniCPM5-1B |
| MiniCPM-o audio | ui/audio_tab.py |
Real-time omnimodal via MiniCPM-o-4.5 |
PRD v2.0 β Christof Kaller / ki-fusion-labs.de β 2026-06-05 Research sources: openbmb/MiniCPM-V, openbmb/MiniCPM, gradio-app/trackio, huggingface/ml-intern, huggingface.co/blog/introducing-gradio-server