mentat
mentat replaces Ray for vLLM multi-node serving: a Rust daemon for placement,
liveness and reaping, a Rust router for HTTP, and a pure-Python package holding
the ray import name.
mentat has no object store, no memory monitor and no dashboard. Registration retries forever, so containers can start in any order. Each lifecycle decision is one log line. The driver's exception carries a dead actor's exit code and signal.
Components
mentatd daemon— per-node daemon. Control port 6379, HTTP 6380.mentatd start— agent, one per model container.raysymlink alias.mentatd status/mentatd stop— inspect, kill actors.python/ray/— pure-Pythonrayshim wheel,pip install --no-deps. Reports__version__ == "2.57.0".serve/—mentat-serve, separate crate + container. HTTP 6381.
One binary is the daemon, the agent and the CLI. The ray symlink keeps
ray start and ray status working, and ray --version names both versions.
mentatd daemon takes --port (6379), --http-port (6380), --node-ip,
--head-json (/tmp/mentat/head.json) and --peers. Compose uses the
environment.
The shim reports 2.57.0 because vLLM version-checks Ray, and logs a banner at
ray.init. Pure Python is free: execute_model runs over vLLM's MessageQueue
and NCCL, zero Ray calls per token.
mentat-serve is a separate crate and container. The daemon never touches
inference traffic, so restarting the router leaves models serving.
Ports
- 6379/tcp — daemon control (
RAY_ADDRESS) - 6380/tcp — daemon HTTP
- 6381/tcp — mentat-serve HTTP
- 6382/udp — daemon announcements
Deploy
VERSION=<ver> ./build.sh→mentat-artifacts:<ver>,mentatd:<ver>,mentat-serve:<ver>.- mentatd.yaml →
~/compose/mentatd/on each box, per-node.env. Before any model container. - Rebuild the model images; they
COPY --from=mentat-artifacts. docker compose upthe models, either node first.- Optional: mentat-serve.yaml →
~/compose/mentat-serve/. No.envrequired.
Step 2's ordering saves time. A container that starts first retries until a daemon answers.
Set MENTAT_NODE_IP to the address the driver sees. Left empty, the daemon
takes the default route, wrong on a multi-homed box.
Rollback: point IMAGE at the previous real-ray image tag.
Operating
mentatd status [--group g] [--json]
mentatd stop [--group g]
curl -s http://<box>:6380/status | jq .
curl -s http://<box>:6380/metrics
websocat ws://<box>:6380/events
curl -s http://<box>:6381/v1/models
curl -s http://<box>:6381/status.json | jq .
Run mentatd status first. With --group it prints the ray-compatible
N.0/M.0 GPU line the entrypoints grep for.
mentatd stop kills actors and the driver restarts on the dead refs. It runs
immediately, whatever degrade window an agent is inside.
Rank death shows as event=actor_exit with pid and signal, and in vLLM's
exception text.
Daemon HTTP (6380)
/metrics— Prometheus/status— JSON/events— WebSocket; snapshot on connect, thennode_join,node_leave,head_change,agent_register,agent_lost,agent_degraded,agent_dead,pg_created,pg_ready,pg_timeout,actor_spawning,actor_running,actor_dead,driver_connected,driver_disconnected
/events sends a snapshot first, so a late client starts whole. mentat-serve
holds the socket open and re-reads /status on any event.
mentat-serve HTTP (6381)
/v1— OpenAI-compatible, routed by model name, streaming passed through/mcp— merged per-container MCP, tools prefixed<group>__, plus nativeserve_status/status.json— route table and per-group health/v1/models— currently routable models
/, /healthz and /status.json return the same document.
/status.json says why a model is missing from /v1/models. Each group
carries healthy and a why_not: no endpoint, no running actors, unprobed,
probe failed, or probe stale.
Model names come from probing the group's own /v1/models.
Daemon env vars
| Var | Default | Meaning |
|---|---|---|
MENTAT_NODE_IP |
default route | This node's cluster identity |
MENTAT_PEERS |
empty | Comma-separated peer control addresses |
MENTAT_ANNOUNCE_PORT |
6382 | UDP announce port; 0 disables |
MENTAT_ANNOUNCE_ADDR |
empty | Extra unicast announce targets |
MENTAT_ANNOUNCE_INTERVAL_S |
5 | Announce interval |
MENTAT_PG_PENDING_TIMEOUT_MS |
600000 | Placement group PENDING → fail |
MENTAT_AGENT_DEGRADED_AFTER_MS |
30000 | Disconnected agent → agent_degraded |
MENTAT_AGENT_DEAD_AFTER_MS |
60000 | Disconnected agent → actors dead |
MENTAT_PEER_STALE_AFTER_MS |
30000 | Silent peer → stale |
MENTAT_PEER_DEAD_AFTER_MS |
60000 | Silent peer → node_leave |
MENTAT_PEER_STATUS_INTERVAL_MS |
2000 | Peer status push / heartbeat |
MENTAT_ELECTION_HOLD_DOWN_MS |
5000 | Head candidate stability before head_change |
MENTAT_SLOW_CALL_WARN_MS |
15000 | Pending call → one call_pending_long |
MENTAT_SESSION_REAP_GRACE_MS |
0 | Driver EOF → actor reap delay |
MENTAT_TCP_DEAD_AFTER_MS |
75000 | TCP keepalive target (Linux only) |
MENTAT_GPUS |
detected | GPU count override |
MENTAT_NODE_IP and MENTAT_PEERS are the two you set.
MENTAT_PG_PENDING_TIMEOUT_MS times the rendezvous, from the placement group
request to the agents and GPUs arriving. Ten minutes covers a cold box. Lower
it and the driver restarts into the same wait.
MENTAT_AGENT_DEGRADED_AFTER_MS and MENTAT_AGENT_DEAD_AFTER_MS both time how
long an agent's link has been EOF. Calls are held until the first. At 30s the
agent is degraded, a warning. At 60s its actors are dead and the container
restarts. The gap allows for short periods of disconnection.
MENTAT_PEER_STALE_AFTER_MS and MENTAT_PEER_DEAD_AFTER_MS are the mesh
version, timed against status pushes. Keep MENTAT_PEER_STATUS_INTERVAL_MS
well under them. Fifteen missed pushes means stale.
MENTAT_ELECTION_HOLD_DOWN_MS damps flapping. A candidate must stay best this
long before the designation moves. Raise it if head_change streams, and head
changes lag by as much.
MENTAT_SLOW_CALL_WARN_MS sets when call_pending_long fires. A call pending
15s is queued behind a blocking method or stuck on a wedged worker.
Keep MENTAT_SESSION_REAP_GRACE_MS at 0. A restarting vLLM needs the old
actors' names and GPUs freed, so a grace delays recovery.
MENTAT_TCP_DEAD_AFTER_MS targets how fast keepalive notices a wedged peer,
via TCP_KEEPIDLE, TCP_KEEPINTVL and TCP_KEEPCNT. Linux only.
MENTAT_GPUS lets the tests run without GPUs. Otherwise the agent counts with
nvidia-smi.
UDP announcement is how mentat-serve finds daemons. MENTAT_ANNOUNCE_ADDR
adds unicast targets outside the broadcast domain. MENTAT_ANNOUNCE_PORT=0
turns it off.
Agent / container env vars
| Var | Default | Meaning |
|---|---|---|
RAY_ADDRESS |
head.json, then localhost |
Daemon the driver and agents rendezvous on |
MENTAT_GROUP |
— | Group scope for driver and agents |
MENTAT_DAEMON |
RAY_ADDRESS |
Daemon address override |
MENTAT_OPENAI_API |
unset | Announced OpenAI endpoint (head rank only) |
MENTAT_MCP_API |
unset | Announced MCP endpoint (every rank) |
MENTAT_HOST_CONNECT_TIMEOUT_MS |
60000 | Wait for a spawned actor host to dial back |
MENTAT_AGENT_PING_INTERVAL_MS |
2000 | Agent → daemon ping interval |
MENTAT_TCP_DEAD_AFTER_MS |
75000 | TCP keepalive target (Linux only) |
MENTAT_PYTHON |
python3 |
Interpreter used to spawn actors |
MENTAT_SOCK_DIR |
/tmp/mentat |
Actor unix socket directory |
MENTAT_DEBUG |
unset | Log ignored ray shim kwargs |
MENTAT_GROUP scopes placement, ray.nodes(), cluster_resources() and
ray status, so two models on one box never count each other's GPUs. It falls
back to SERVICE_NAME, then default.
RAY_ADDRESS resolves in order: the --address flag, the environment,
head.json, localhost. The CLI and the agent share it.
The API variables are announced at ray start and read once, so export them
first. Head-rank-only MENTAT_OPENAI_API is an entrypoint convention. The
agent announces whatever is set, and mentat-serve takes the lexically first
of several.
MENTAT_HOST_CONNECT_TIMEOUT_MS covers process start only. The actor host
dials back before importing anything heavy, so this times python starting up.
Leave it alone.
MENTAT_AGENT_PING_INTERVAL_MS bounds how fast an agent notices a dead daemon.
MENTAT_PYTHON and MENTAT_SOCK_DIR exist for the tests, which run fake nodes
on one machine. MENTAT_DEBUG logs the ray kwargs the shim ignored.
Set on each actor process: MENTAT_ACTOR_ID, MENTAT_NODE_ID,
MENTAT_GPU_IDS and MENTAT_GCS_ADDRESS from the daemon, plus
MENTAT_AGENT_PID from the agent.
mentat-serve env vars
| Var | Default | Meaning |
|---|---|---|
SERVE_PORT |
6381 | HTTP port |
MENTAT_DAEMONS |
127.0.0.1:6380 |
Seed daemon list; empty = UDP only |
MENTAT_ANNOUNCE_PORT |
6382 | UDP listen port; 0 disables |
ALLOWED_SOURCES |
10.100.0.,192.168.1.,127.0.0.1,::1,172. |
Address prefixes accepted for announcements |
SERVING_TIMEOUT_S |
1800 | Upstream request timeout |
MENTAT_SECRET |
unimplemented | Reserved: announcement signing |
Unset, MENTAT_DAEMONS seeds the local daemon. Set empty, UDP is the only
path. Compose cannot express empty, since ${VAR:-default} reads it as unset.
ALLOWED_SOURCES applies to both the datagram's source and the address it
claims. 172. covers bridge-networked clients, which keep a 172.x source.
SERVING_TIMEOUT_S caps one upstream request, and a non-streaming answer
arrives when generation ends. A 198K prefill took 144.7s to first byte, so
1800s leaves headroom. Lower it and long generations get cut first.
mentat-serve also reads POLL_INTERVAL_S, PROBE_INTERVAL_S,
PROBE_TIMEOUT_S, PROBE_FRESH_S, MCP_TIMEOUT_S, TOOLS_TTL_S and
DISCOVER_PEERS. The defaults derive from each other: PROBE_FRESH_S is three
probe intervals plus a timeout. Setting one alone makes groups flap.
Unparsable *_MS values log bad_env_ms and use the default. Defaults live on
Cfg in config.rs.
Behavior
- Registration retries forever.
- Head election: lowest node id,
MENTAT_ELECTION_HOLD_DOWN_MShold-down. - Daemons mesh over
MENTAT_PEERS; replicate events, exchange status. - Groups scope placement,
ray.nodes(),cluster_resources(), andray status. - TP=N takes N single-GPU agents or fewer multi-GPU ones.
- A second driver for one group is rejected at
ray.init. - Driver and all of a group's agents must reach the same daemon.
- Actors run in their own process group; kills take the tree.
- Actors are serial: a call issued after
run()queues forever. - Agent link EOF: calls held, drained on reconnect; degrade at 30s, give up at 60s.
- mentat-serve discovers daemons by UDP announcement, the
MENTAT_DAEMONSseed, and mesh membership. Each watched daemon is polled on/statuswith/eventsheld open. - Announcements are unsigned. Both datagram source and claimed address must match
ALLOWED_SOURCES, and everything claimed is re-read over TCP and probed. /v1routing gate: group has a running actor and its announced endpoint answers/v1/models, which is also the source of served model names./mcpmerge is ungated.- Engines are admitted as soon as their API answers, which can be during a model's self-test.
- Shim covers only vLLM's audited surface. Anything else raises
AttributeErrornaming the attribute. - The entrypoints pin
VLLM_USE_RAY_V2_EXECUTOR_BACKEND=1. The legacy compiled-DAG surface is unimplemented. - Verified against vLLM
0.1.dev20051+g487ecf187. After boot the only recurring Ray call isray.waitevery 5s. ray start --head,--object_store_memory,RAY_OBJECT_STORE_MEMORY,RAY_memory_monitor_refresh_msaccepted and ignored.
One daemon owns each group's state and the rest carry replicas. When views
disagree, mentat-serve takes the one with more running actors.
Actors get their own process group because vLLM workers fork helpers and a kill
must take the tree. A survivor holds its memory outside ps RSS.
Serial actors match real Ray. run() never returns for a vLLM worker, so any
call after it queues forever. call_pending_long in the log means that
happened.
Announcements are unsigned because the control port already accepts
unauthenticated connections from the same network, so signing datagrams adds no
boundary. Every claim is re-read over TCP anyway. MENTAT_SECRET changes that
when the control plane gets authentication.
Tests
GPU-free, no hardware needed. See tests/README.md.