13 KiB
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(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 servebackend - 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.
Environment
- OS: Linux
- Python: 3.10+
- Hardware: 1x GPU. The default
T_s=100frame window +--max-model-len 131072wants ≈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:
# 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):
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 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:
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 talkativefor 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:
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-urlfor 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):
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
# 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/,
which wrap MediaMTX + ffmpeg:
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
--omniis not used: the model keeps the Qwen3-VL architecture (only the weights are retrained), so stockvllm serveruns the forward pass; this recipe only adds the interaction/serving layer.- On a host without
nvcc/ninja,vllm serveof the 8B can crash engine-core in the FlashInfer sampler JIT (FileNotFoundError: 'ninja') duringprofile_run. SetVLLM_USE_FLASHINFER_SAMPLER=0(or installninja) to work around it. force_silence_before_queryis 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_URLat the bridges inexamples/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.
Native multi-stage pipeline (experimental)
An experimental native speech path is available alongside the existing Day-0 serving path:
one request with image/video and text
-> JoyAI complete action text
-> Qwen3-TTS Talker
-> Code2Wav
-> 24 kHz audio
It runs behind one vllm serve --omni endpoint:
vllm serve jdopensource/JoyAI-VL-Interaction-Preview --omni \
--deploy-config vllm_omni/deploy/joyai_vl_interaction.yaml \
--port 8091
With modalities: ["text", "audio"], its behavior is:
| JoyAI action | Native output |
|---|---|
</response> <text> |
action text + speech for <text> |
</silence> |
action text + empty audio output (zero samples) |
</response> <note> </delegation> <question> |
action text + speech for <note> only |
Native speech supports the Qwen3-TTS CustomVoice task. The request-level voice and
language fields select the speaker and language; when omitted, they default to
Vivian and Auto. Values supplied as tts_speaker and tts_language inside
additional_information take priority.
This experimental native multi-stage pipeline is request-scoped, stateless, and all-sync. It currently does not include:
- continuous frame sessions, persistent standing instructions, or session memory;
- audio input / ASR, full-duplex interaction, or barge-in;
- background Agent execution for delegated questions;
- async-chunk support from Talker to Code2Wav;
- integration with the existing WebUI.
For actions with spoken text, the Talker starts only after the complete JoyAI action is
available, and Code2Wav starts only after the Talker completes. Silence actions skip both
TTS stages and return an empty audio output with zero samples. All three stages are placed
on GPU 0 by the provided
deploy config.