[Fix] JoyVL serving: align with reference engine (bounded long-term memory, timestamps, max_pixels) + recipe updates (#4623)

Signed-off-by: lishunyang12 <125541396+lishunyang12@users.noreply.github.com>
This commit is contained in:
SYLAR authored and GitHub committed 2026-06-24 07:04:17 +08:00
1 parent 0958e62ecc
commit d35cdd2dab
9 files changed
+116 -28

No files matched your search

+34 -5
View File
@@ -5,7 +5,7 @@
## Summary
- Vendor: JD (Joy Future Academy)
- Model: [`jdopensource/JoyAI-VL-Interaction-Preview`](https://huggingface.co/jdopensource/JoyAI-VL-Interaction-Preview) (8B, vanilla Qwen3-VL weights)
- 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
@@ -25,7 +25,8 @@ framework internals and how to add another full-duplex model, see
- 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
≈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
@@ -33,7 +34,7 @@ framework internals and how to add another full-duplex model, see
From repository root:
```bash
# 1. Serve the model (plain `vllm serve`, NOT --omni; it is vanilla Qwen3-VL).
# 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 \
@@ -46,6 +47,26 @@ 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:
@@ -176,14 +197,22 @@ the audio-track caveat.
## Notes
- `--omni` is **not** used: the model is standard Qwen3-VL, so stock `vllm serve`
runs the forward pass; this recipe only adds the interaction/serving layer.
- `--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
+18 -4
View File
@@ -17,8 +17,8 @@ class _FakeSummarizer:
self.chunks += 1
return f"summary-of-chunk-{chunk_index}"
async def compress_to_long_term(self, existing, mids):
return (existing + " | " if existing else "") + f"compressed({len(mids)})"
async def compress_to_long_term(self, mids):
return f"compressed({len(mids)})"
class _FakeDelegation:
@@ -42,7 +42,7 @@ def test_query_freshness_and_archive_on_change():
b.record_response("1 bottle")
assert b.update_query("now describe") is True
assert b.memory.qa_history[-1].query == "count bottles"
assert b.memory.qa_history[-1].responses == [("0.0s", "1 bottle")]
assert b.memory.qa_history[-1].responses == [("0.0 seconds", "1 bottle")]
def test_query_in_frame_on_arrival_then_prefix_on_later_chunks():
@@ -71,7 +71,7 @@ async def test_flush_archives_qa_and_summarizes():
assert summ.chunks == 1
assert b.chunk_index == 2
assert b.should_flush() is False
assert b.memory.qa_history[-1].responses == [("2.0s", "a person enters")]
assert b.memory.qa_history[-1].responses == [("2.0 seconds", "a person enters")]
assert len(b.memory.mid_term_summaries) == 1
@@ -85,6 +85,20 @@ async def test_long_term_compression_after_n_chunks():
assert b.memory.mid_term_summaries == []
@pytest.mark.asyncio
async def test_long_term_window_caps_blocks():
# Long-term memory must not grow unbounded over hours: oldest blocks are dropped
# once the sliding window is full (reference window = 15; here 2 for the test).
summ = _FakeSummarizer()
b = InteractionBrain(
summarizer=summ, chunk_frames=1, long_term_every_n_chunks=1, long_term_window=2, frame_seconds=1.0
)
for _ in range(4):
await b.flush([("0.0s", "u")])
assert len(b.memory.long_term_blocks) == 2 # capped, not 4
assert b.memory.long_term_memory.count("compressed(") == 2
@pytest.mark.asyncio
async def test_delegation_submit_and_fold():
b = InteractionBrain(delegation=_FakeDelegation())
+1 -1
View File
@@ -38,7 +38,7 @@ def test_commit_records_only_spoken():
assert p.commit("</silence>").action.value == "silence"
assert p.brain.response_records == []
assert p.commit("</response> hello").action.value == "response"
assert p.brain.response_records == [("0.0s", "hello")]
assert p.brain.response_records == [("0.0 seconds", "hello")]
def test_commit_dedup_default_is_exact_match_only():
@@ -33,6 +33,7 @@ class JoyVLPolicy:
delegation: DelegationBridge | None = None,
chunk_frames: int = 16,
long_term_every_n_chunks: int = 5,
long_term_window: int = 15,
keep_qa_history: bool = True,
frame_seconds: float = 1.0,
enable_delegation: bool = True,
@@ -44,6 +45,7 @@ class JoyVLPolicy:
delegation=delegation,
chunk_frames=chunk_frames,
long_term_every_n_chunks=long_term_every_n_chunks,
long_term_window=long_term_window,
keep_qa_history=keep_qa_history,
frame_seconds=frame_seconds,
enable_delegation=enable_delegation,
@@ -25,6 +25,7 @@ class InteractionBrain:
delegation: DelegationBridge | None = None,
chunk_frames: int = 16,
long_term_every_n_chunks: int = 5,
long_term_window: int = 15,
keep_qa_history: bool = True,
frame_seconds: float = 1.0,
enable_delegation: bool = True,
@@ -33,6 +34,7 @@ class InteractionBrain:
self._delegation = delegation
self._chunk_frames = chunk_frames
self._long_term_every_n = long_term_every_n_chunks
self._long_term_window = long_term_window
self._keep_qa_history = keep_qa_history
self._frame_seconds = frame_seconds
self._enable_delegation = enable_delegation
@@ -58,10 +60,10 @@ class InteractionBrain:
self._consolidate_lock = asyncio.Lock()
def now(self) -> str:
return f"{self.frame_index * self._frame_seconds:.1f}s"
return f"{self.frame_index * self._frame_seconds:.1f} seconds"
def last_frame_time(self) -> str:
return f"{max(0, self.frame_index - 1) * self._frame_seconds:.1f}s"
return f"{max(0, self.frame_index - 1) * self._frame_seconds:.1f} seconds"
def tick(self, n: int = 1) -> None:
self.frame_index += n
@@ -131,9 +133,14 @@ class InteractionBrain:
summary = await self._summarizer.summarize_chunk(chunk_index, frame_range, frames)
self.memory.mid_term_summaries.append(MidTermSummary(chunk_index, frame_range, summary))
if len(self.memory.mid_term_summaries) >= self._long_term_every_n:
self.memory.long_term_memory = await self._summarizer.compress_to_long_term(
self.memory.long_term_memory, self.memory.mid_term_summaries
)
block = await self._summarizer.compress_to_long_term(self.memory.mid_term_summaries)
if block:
self.memory.long_term_blocks.append(block)
window = self._long_term_window
if window > 0 and len(self.memory.long_term_blocks) > window:
# drop oldest blocks so long-term memory stays bounded over hours
del self.memory.long_term_blocks[: len(self.memory.long_term_blocks) - window]
self.memory.long_term_memory = "\n\n".join(self.memory.long_term_blocks)
self.memory.mid_term_summaries.clear()
async def flush(self, frames: list[tuple[str, str]]) -> None:
@@ -33,6 +33,7 @@ class QAEntry:
@dataclass
class SessionMemory:
long_term_memory: str = ""
long_term_blocks: list[str] = field(default_factory=list)
mid_term_summaries: list[MidTermSummary] = field(default_factory=list)
qa_history: list[QAEntry] = field(default_factory=list)
@@ -91,6 +92,35 @@ def _sample_indices(n: int, budget: int) -> list[int]:
return selected
def _resize_data_url(data_url: str, max_pixels: int) -> str:
"""Downscale a base64 image data URL so width*height <= max_pixels (preserving
aspect ratio), mirroring the reference summarizer. No-op if already within budget,
not a data URL, or PIL is unavailable."""
if max_pixels <= 0 or not data_url.startswith("data:"):
return data_url
import base64
import io
import re
match = re.match(r"data:image/\w+;base64,(.+)", data_url)
if not match:
return data_url
try:
from PIL import Image
img = Image.open(io.BytesIO(base64.b64decode(match.group(1))))
w, h = img.size
if w * h <= max_pixels:
return data_url
scale = (max_pixels / (w * h)) ** 0.5
img = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
buf = io.BytesIO()
img.convert("RGB").save(buf, format="JPEG")
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception:
return data_url
class Summarizer:
def __init__(
self,
@@ -100,12 +130,14 @@ class Summarizer:
mid_term_max_tokens: int = 4000,
long_term_max_tokens: int = 4000,
preferred_time_span: float = 10.0,
max_pixels: int = 262144,
) -> None:
self._backend = backend
self._key_frames = key_frames_per_chunk
self._mid_max_tokens = mid_term_max_tokens
self._long_max_tokens = long_term_max_tokens
self._preferred_time_span = preferred_time_span
self._max_pixels = max_pixels
async def summarize_chunk(
self,
@@ -126,24 +158,25 @@ class Summarizer:
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
for time_range, data_url in picked:
content.append({"type": "text", "text": f"<{time_range}>"})
content.append({"type": "image_url", "image_url": {"url": data_url}})
content.append({"type": "image_url", "image_url": {"url": _resize_data_url(data_url, self._max_pixels)}})
text, _ = await self._backend.generate(
[{"role": "user", "content": content}],
max_tokens=self._mid_max_tokens,
temperature=0.8,
top_p=0.9,
extra_body={"top_k": 40, "repetition_penalty": 1.1, "presence_penalty": 1.0},
extra_body={"top_k": 40, "repetition_penalty": 1.1, "presence_penalty": 1.0, "greedy": False},
)
return text.strip()
async def compress_to_long_term(
self,
existing_long_term: str,
mid_terms: list[MidTermSummary],
) -> str:
async def compress_to_long_term(self, mid_terms: list[MidTermSummary]) -> str:
"""Compress a batch of mid-term summaries into a single long-term block.
Returns one block; the caller (InteractionBrain) accumulates blocks and applies
the sliding window, matching the reference long-term memory behavior.
"""
if not mid_terms:
return existing_long_term
return ""
merged_range = f"{_range_start(mid_terms[0].frame_range)}-{_range_end(mid_terms[-1].frame_range)}"
summaries_text = "\n\n".join(f"<{m.frame_range}>\n{m.summary_text}" for m in mid_terms)
@@ -157,10 +190,9 @@ class Summarizer:
max_tokens=self._long_max_tokens,
temperature=0.3,
top_p=0.7,
extra_body={"top_k": 30, "repetition_penalty": 1.1, "presence_penalty": 0.5},
extra_body={"top_k": 30, "repetition_penalty": 1.1, "presence_penalty": 0.5, "greedy": False},
)
block = f"<{merged_range}>\n{compressed.strip()}"
return f"{existing_long_term.rstrip()}\n\n{block}" if existing_long_term else block
return f"<{merged_range}>\n{compressed.strip()}"
async def aclose(self) -> None:
await self._backend.aclose()
@@ -26,6 +26,7 @@ class InteractionConfig:
persona: str = "default"
frame_seconds: float = 1.0
max_pixels: int = 262144
sampling: SamplingConfig = field(default_factory=SamplingConfig)
force_silence_before_query: bool = True
@@ -42,6 +43,7 @@ class InteractionConfig:
mid_term_key_frames: int = 0
long_term_every_n_chunks: int = 5
long_term_memory_window: int = 15
mid_term_max_tokens: int = 4000
long_term_max_tokens: int = 4000
keep_qa_history: bool = True
@@ -49,6 +49,7 @@ class SessionManager:
key_frames_per_chunk=config.mid_term_key_frames,
mid_term_max_tokens=config.mid_term_max_tokens,
long_term_max_tokens=config.long_term_max_tokens,
max_pixels=config.max_pixels,
)
self._delegation = self._build_delegation(config) if config.enable_delegation else None
self._sessions: dict[str, InteractionSession] = {}
@@ -50,6 +50,7 @@ class InteractionSession:
delegation=delegation,
chunk_frames=config.chunk_frames,
long_term_every_n_chunks=config.long_term_every_n_chunks,
long_term_window=config.long_term_memory_window,
keep_qa_history=config.keep_qa_history,
frame_seconds=config.frame_seconds,
enable_delegation=config.enable_delegation,
@@ -74,7 +75,7 @@ class InteractionSession:
brain = policy.brain
base = t if t is not None else brain.frame_index * self.config.frame_seconds
time_ranges = [f"{base + i * self.config.frame_seconds:.1f}s" for i in range(len(frames))]
time_ranges = [f"{base + i * self.config.frame_seconds:.1f} seconds" for i in range(len(frames))]
delegation_info = await policy.fold_delegations()
@@ -175,5 +176,5 @@ class InteractionSession:
content.append({"type": "text", "text": f"{USER_QUERY_HEADER}\n{query.strip()}"})
for tr, url in zip(time_ranges, frames):
content.append({"type": "text", "text": f"<{tr}>"})
content.append({"type": "image_url", "image_url": {"url": url}})
content.append({"type": "image_url", "image_url": {"url": url}, "max_pixels": self.config.max_pixels})
return {"role": "user", "content": content}