Files
vllm-omni/recipes/JD/JoyAI-VL-Interaction.md
T

221 lines
11 KiB
Markdown
Raw Normal View History

# JoyAI-VL-Interaction
> Real-time streaming video-language interaction (proactive speak / silence / delegate)
## Summary
- Vendor: JD (Joy Future Academy)
- Model: [`jdopensource/JoyAI-VL-Interaction-Preview`](https://huggingface.co/jdopensource/JoyAI-VL-Interaction-Preview) (8B, Qwen3-VL architecture with weights retrained for streaming interaction)
- Task: Per-tick proactive interaction over a live video stream — the model decides
on its own each second to speak, stay silent, or delegate a hard question
- Mode: Online serving — an OpenAI-compatible interaction orchestrator in front of
a plain `vllm serve` backend
- Maintainer: Community
## When to use this recipe
Use this to stand up the streaming-interaction serving layer (`vllm_omni/experimental/fullduplex/`).
The model is served unchanged by `vllm serve`; this layer adds session state, 3-tier
summary memory, the per-tick decision, and pluggable ASR / TTS / delegation. For the
framework internals and how to add another full-duplex model, see
[`vllm_omni/experimental/fullduplex/README.md`](../../vllm_omni/experimental/fullduplex/README.md).
## Environment
- OS: Linux
- Python: 3.10+
- Hardware: 1x GPU. The default `T_s=100` frame window + `--max-model-len 131072` wants
≈48GB+; for ~24GB, lower `--chunk-frames`, `--max-model-len`, and the image limit together,
and/or load the weights in fp8 (see "Smaller GPU: fp8 weights" below — ~9.9 GiB vs 16.8 GiB)
- vLLM / vLLM-Omni: versions from your current checkout
## Start server
From repository root:
```bash
# 1. Serve the model (plain `vllm serve`, NOT --omni; it uses the Qwen3-VL architecture).
# The image limit must cover the short-term frame window (chunk_frames, default 100 = T_s);
# prefix caching keeps the accumulating window cheap. Lower both for smaller GPUs.
vllm serve jdopensource/JoyAI-VL-Interaction-Preview \
--served-model-name JoyAI-VL-Interaction-Preview --port 8061 \
--max-model-len 131072 --enable-prefix-caching \
--limit-mm-per-prompt '{"image":256,"video":1}'
# 2. Interaction orchestrator (OpenAI-compatible, :8070)
python -m vllm_omni.experimental.fullduplex.joyvl.serving.server --port 8070 \
--main-backend-url http://127.0.0.1:8061/v1 --main-model JoyAI-VL-Interaction-Preview
```
### Smaller GPU: fp8 weights
Add `--quantization fp8` to the step-1 `vllm serve` to load the weights in fp8 online
from the bf16 checkpoint (no separate quantized checkpoint needed; uses vLLM's generic
fp8 path, since the model is a standard Qwen3-VL VLM):
```bash
vllm serve jdopensource/JoyAI-VL-Interaction-Preview \
--served-model-name JoyAI-VL-Interaction-Preview --port 8061 \
--quantization fp8 --max-model-len 131072 --enable-prefix-caching \
--limit-mm-per-prompt '{"image":256,"video":1}'
```
Measured here: model weights **9.9 GiB** vs **16.8 GiB** bf16 (**−41%**), which lets the 8B
fit a much smaller card. This is a **memory** option, not a speed one: at one request per
tick (the streaming regime) fp8 was **not** faster (≈19% slower in our test) because
single-stream decode is memory-latency-bound and pays fp8's dynamic-scale overhead; its
throughput gain needs batching. Decisions stay coherent, but fp8 can shift the
speak/silence boundary, so for timing-critical alerting validate output against bf16 first.
Optional one-shot launch (model + orchestrator + JD webui + ASR/TTS/background,
all env-configurable). The JD webui frontend is external — set `WEBUI_DIR` to point at it:
```bash
bash examples/online_serving/joyvl_interaction/scripts/start_all.sh
```
## Using the model
The orchestrator is OpenAI-compatible: send **one user turn per video frame** (~1 fps) to
`/v1/chat/completions` with an `x-session-id` header, and attach an optional **standing
instruction** as a text part. Each reply carries an `interaction` block — `action` is
`silence` / `response` / `delegate` and `text` is what to say:
```bash
curl -s http://127.0.0.1:8070/v1/chat/completions \
-H 'x-session-id: s1' -H 'content-type: application/json' -d '{
"messages": [{"role": "user", "content": [
{"type": "text", "text": "Alert me if a fire breaks out"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
]}]}' | jq .interaction
```
Send the standing instruction once (it persists for the session); subsequent turns send
just the frame. Ready-made headless client: `cli/run_cli_demo.py`. Reset a session with
`POST /reset {"session_id": "..."}`.
**What to ask it** — give a standing task and let it act on its own each second:
- **Proactive alerting** — "tell me when someone enters", "alert me if a fire breaks out"
- **Streaming Q&A** — ask about what's on screen; it answers once it has the evidence
- **Time & memory** — "how many people have walked past?", "what did you see earlier?"
(the 3-tier summary memory lets it recall beyond the live frame window)
- **Read & translate** — "translate the on-screen text as it appears"
- **Delegate hard questions** — handed to a background brain (see below)
- **Live commentary** — `--persona talkative` for continuous, danmaku-style narration
**Personas** (`--persona`): `default` (speak on meaningful events or to answer), `silent`
(answer only when asked, never delegate), `talkative` (proactively narrate).
**Tuning:** `--chunk-frames` (short-term window `T_s`), `--response-dedup-threshold`
(lower drops more near-duplicate narration), and `--no-memory` to disable the 3-tier
summaries. The model stays silent until the first instruction arrives
(`force_silence_before_query`, on by default).
## Delegation (background brain)
When the model judges a question too hard, it emits `</delegation> <question>` and the
orchestrator hands it to a **background brain** — any OpenAI-compatible endpoint you
self-host. Enable it by pointing the orchestrator at one:
```bash
python -m vllm_omni.experimental.fullduplex.joyvl.serving.server --port 8070 \
--main-backend-url http://127.0.0.1:8061/v1 --main-model JoyAI-VL-Interaction-Preview \
--delegation-backend-url <brain-endpoint>/v1 \
--delegation-model <brain-model> --delegation-kind chat
```
`--delegation-kind` picks the bridge:
- `chat` — a stronger text/VL model answers (`/chat/completions`)
- `image` — a text-to-image model generates a picture (`/images/generations`, e.g. Qwen-Image)
- `edit` — an image-edit model restyles the current frame (e.g. Qwen-Image-Edit)
- `router` — dispatch each request to chat / image / edit by inspecting it (set
`--delegation-image-url` / `--delegation-edit-url` for the latter two)
- `stub` — canned answers for tests/demos only (no backend needed)
`chat`/`image`/`edit`/`router` each need a backend URL — **without one, delegation stays
off** (the model's delegate note is still spoken, but nothing is folded back). The brain is
**bring-your-own**: a larger vLLM you serve, or any OpenAI-compatible API (e.g.
`--delegation-backend-url https://api.anthropic.com/v1/ --delegation-model claude-...
--delegation-api-key …`). The reference deployment instead drives the `codex` CLI as the
brain via a separate background-agent service; that agent runs with its own credentials
and bypasses its sandbox, so it is **not bundled here** — self-host a plain
OpenAI-compatible endpoint instead.
## Host the WebUI demo
For the full browser experience — live webcam / RTSP input, voice (ASR/TTS), and the
per-tick decision stream — run JD's official WebUI in front of the orchestrator. Clone the
model repo and start its WebUI pointed at the orchestrator (`:8070`):
```bash
git clone https://github.com/jd-opensource/JoyAI-VL-Interaction.git
cd JoyAI-VL-Interaction/services/webui
uv venv && uv pip install -e .
bash scripts/start_server.sh --api-base http://127.0.0.1:8070/v1
```
Open the printed HTTPS URL, allow the camera (or enter an RTSP URL), and give a standing
instruction. `examples/online_serving/joyvl_interaction/scripts/start_all.sh` can launch
the model + orchestrator + WebUI together — point `WEBUI_DIR` at your clone.
## Verification
```bash
# headless: stream a clip and print the per-tick decision timeline
# (the CLI reads video frames via OpenCV: `uv pip install opencv-python`)
python examples/online_serving/joyvl_interaction/cli/run_cli_demo.py \
path/to/video.mp4 --query "Alert me if a fire breaks out"
pytest tests/e2e/features/fullduplex # framework + JoyVL unit tests
```
## Testing with an RTSP stream (optional)
RTSP is a **webui-side input** — the browser pulls the stream and feeds frames to the
orchestrator over the normal API; no serving-layer code is involved. To simulate an RTSP
camera from a local video file (no physical IP camera needed), use the helper scripts in
[`examples/online_serving/joyvl_interaction/rtsp/`](../../examples/online_serving/joyvl_interaction/rtsp/),
which wrap [MediaMTX](https://github.com/bluenviron/mediamtx/releases) + `ffmpeg`:
```bash
cd examples/online_serving/joyvl_interaction/rtsp
# 1. Local RTSP server (MediaMTX, listens on :8554)
bash ./mediamtx.sh
# 2. Push a local video file as an RTSP stream (another terminal)
bash ./rtsp.sh ./videos/example.mp4 rtsp://127.0.0.1:8554/fire1
# 3. In the WebUI RTSP box, enter: rtsp://127.0.0.1:8554/fire1
# (replace 127.0.0.1 with the MediaMTX host IP if the webui runs on another machine)
```
See the directory's `README.md` for streaming a whole video folder (`rtsp_all.sh`) and
the audio-track caveat.
## Notes
- `--omni` is **not** used: the model keeps the Qwen3-VL architecture (only the
weights are retrained), so stock `vllm serve` runs the forward pass; this recipe
only adds the interaction/serving layer.
- On a host without `nvcc` / `ninja`, `vllm serve` of the 8B can crash engine-core in the
FlashInfer sampler JIT (`FileNotFoundError: 'ninja'`) during `profile_run`. Set
`VLLM_USE_FLASHINFER_SAMPLER=0` (or install `ninja`) to work around it.
- `force_silence_before_query` is on by default — the model stays silent until an
instruction arrives; give a standing task (e.g. "translate the on-screen text")
to arm proactive output.
- **Frame resolution is the main latency knob.** The system/memory prefix is prefix-cached,
so per-tick *new* compute is dominated by the new frame's vision tokens. Measured on the
8B: ~256×192 frames hit Qwen3-VL's min-pixel floor (~72 vision tokens, ~17 ms/tick) vs
~302 tokens / ~38 ms at 640×480 — about 2× cheaper. Downsample frames to ~256×192 for the
tightest latency / highest concurrency; one GPU then sustains ~150–180 concurrent 1 fps
streams with p95 < 200 ms. Per-tick latency is already far inside the 1 fps budget, so
serving is rarely the bottleneck — resolution is the lever if you need more headroom.
- Speech is external and pluggable: point `ASR_URL` / `TTS_URL` at the bridges in
`examples/online_serving/joyvl_interaction/bridges/` or any compatible service.
- The decision prompts, sampling, and 3-tier summary memory (`T_s=100`, mid→long every
5 chunks, `key_frames=0` = summarize all chunk frames) are aligned to the JoyVL
reference adapter so per-tick behavior matches the released model; the framework only
supplies the serving structure.