Repository navigation
[Bugfix] MiniCPM-o 4.5 duplex: let the turn-end Talker condition finish speaking - #8911
Conversation
…sh speaking The Talker stops on its 25-frame cadence on the turn-end condition as well, so text sent in earlier units can be left unspoken (vllm-project#8807). When the turn's text ends a sentence, close the turn-end condition with <text_eos> and let it stop only after two silent codec frames in a row, forcing a stop after eight quiet ones. A turn cut off mid-sentence keeps the existing boundary mask. Signed-off-by: Tianyao Wu <rayroy31@gmail.com>
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
|
This PR appears to belong to: docs/design/module/ar_runtime.md, docs/design/module/model_integration.md. Module owners: @tzhouam @fake0fan @Gaohan123 Routing: @tzhouam via module of the changed files, CODEOWNERS; @fake0fan via module of the changed files; @Gaohan123 via module of the changed files @twu3202, please review your own changes and leave a short self-review comment describing what you checked. PRs without author self-review may not be assigned a reviewer. Please take a look when you have a chance. If you would like an automated review, mention @vllm-omni-review-bot in a comment. |
Make the next-turn case fail without the turn-start reset of the text tail, give the MRv2 test Talker the real codec vocabulary size, and use the drain kind constants in the tests. Signed-off-by: Tianyao Wu <rayroy31@gmail.com>
|
Self-review:
|
amy-why-3459
left a comment
There was a problem hiding this comment.
Reviewed fb053f3. This is a static review; I have not independently run the model or CUDA tests.
Quality acceptance: The reported aggregate WER/CER improvements are useful, but the reported regressions should also become fixed evaluation cases: stopping at an intra-sentence pause and emitting additional speech after the intended sentence. Please compare truncation, added speech, and tail latency separately, and document maintainer acceptance of the tradeoff or constrain the new policy further. The current rule-level tests establish implementation behavior, not that quiet codec runs reliably identify completion of the outstanding text. These examples come from the PR report, not an independent reproduction.
Validation gap: The visible checks cover builds, static checks, DCO, and documentation. The reported component tests and model evaluations do not establish complete repository unit-test coverage. Please provide complete unit-test evidence on this SHA using the repository's Buildkite-defined CPU groups and required hardware/backend-specific UT jobs: exact commands, environment/dependency versions, passed/failed/skipped/deselected/collection-error counts, and CI or complete log links. Explain skipped tests and unavailable jobs, or obtain an explicit maintainer disposition for those gaps. For apparently pre-existing failures, include a same-environment base/head comparison. After fixes, provide evidence for the updated head. Merge readiness is not yet established by the available UT evidence.
| if prefilling is not None: | ||
| hits &= ~prefilling[:, None] | ||
| runs = self._mrv2_code_runs.index_select(0, slot_ids) | ||
| runs = torch.where(hits, runs + 1, torch.zeros_like(runs)) |
There was a problem hiding this comment.
[P2] Preserve or reconstruct trailing-code runs when resuming the same condition after preemption.
This clears both counters for every prefilling row, including recomputation after preemption. Recomputation does not start a new condition: if the generated suffix already contains two silent codes, EOS would be eligible without preemption but is masked after this reset. Losing the quiet-code run similarly delays the forced-stop threshold, making stopping behavior depend on scheduling.
Please distinguish a new condition from a resumed condition, resetting only the former and preserving or rebuilding the latter's runs from its generated codec history. Add a regression test comparing EOS decisions for the same codec sequence with and without preemption. test_mrv2_turn_end_drain_resets_runs_on_prefill currently asserts the reset rather than this equivalence.
There was a problem hiding this comment.
Fixed in 7e08057. A prefill row now rebuilds the runs from the request's token history (req_states.all_token_ids, the condition's codes before seq_lens) instead of resetting them. A new condition has no codes yet and starts from zero; a request resumed after preemption gets back the runs it had, up to the quiet-stop length, which is all the rule reads.
test_mrv2_turn_end_drain_stops_the_same_way_after_preemption runs the same code sequence with and without a preemption (resumed in another slot) and compares the EOS decisions step by step. It fails with the old reset.
|
@natureofnature @BeatSeat PTAL |
A request resumed after preemption re-prefills the codes of the condition it was draining. Rebuild its silent and quiet runs from the request's token history (req_states.all_token_ids) rather than resetting them, so the EOS decisions do not depend on preemption. A new condition has no codes yet and still starts from zero. Signed-off-by: Tianyao Wu <rayroy31@gmail.com>
|
Thanks for the review. P2 is fixed in 7e08057 (reply inline). Quality. Same runs as the description (192 turns each), main vs this PR:
The two V1 turns with added speech are the same sentence, and both end in an extra "and" on main too. Mean E2EL per 4-turn session goes up 1.6% (V1) and 2.0% (MRv2). Fixed cases for the two regressions:
@BeatSeat @natureofnature, is this trade-off acceptable to you, or would you rather constrain the rule further? In an earlier version of the rule, requiring three silent codes instead of two raised MRv2 WER from 0.0257 to 0.0286 and added speech after the sentence in 5 turns instead of 0, so I kept two. Unit tests. The ready pipeline's unit-test groups from
* In the group run, There are no collection errors. No test that both sides ran changes its outcome. Head adds the 15 new tests of this PR, all passing (14 CPU, 1 CUDA). The skips are the same on both sides and come from hardware or optional dependencies: NPU, ROCm, 2 or 4 GPUs, Hopper FA3, FlashAttention 2, Mooncake/NIXL, torchao, and models that are not in the HF cache. I can't run the NPU, ROCm or XPU jobs. Commands, pytest summary lines and the per-test comparisonCommands, run in each checkout with pytest -sv tests/model_executor -m 'core_model and cpu'
pytest -sv tests/diffusion/models -m 'core_model and cpu'
pytest -sv tests/entrypoints tests/engine tests/diffusion --ignore=tests/diffusion/models -m 'core_model and cpu'
pytest -sv tests/ -m 'core_model and cpu' --ignore=tests/diffusion --ignore=tests/model_executor --ignore=tests/entrypoints --ignore=tests/engine
pytest -sv tests/engine/ tests/model_executor/ tests/worker_v2/ tests/worker/ -m 'core_model and cuda and L4' --run-level 'core_model'
pytest -sv tests/engine/ tests/model_executor/ tests/worker_v2/ tests/worker/ -m 'core_model and cuda and H100' --run-level 'core_model'
pytest -sv tests/distributed/ -m 'core_model and cuda and L4' --run-level 'core_model'
# after downloading Qwen/Qwen2.5-Omni-7B:
pytest -sv tests/engine/test_async_omni_engine_abort.py -m 'core_model and cuda and L4' --run-level 'core_model'pytest summary lines (warnings omitted): Per-test comparison of the junit files: no test present on both sides changes outcome. Tests only on head, all passed:
|
linyueqian
left a comment
There was a problem hiding this comment.
Please align the sentence-end decision with the text actually sent to the Talker, as the current mismatch can select the wrong close-text and drain mode.
Static review of all eight changed files and their integration with main 96274a0. No fork code, tests, or end-to-end inference were run, and the author's speech-quality results were not independently reproduced.
| state["turn_id"] = turn_id | ||
| # The end of the turn's text so far (see _native_duplex_turn_text_ends_sentence). | ||
| tail = "" if turn_start else str(state.get("turn_text_tail") or "") | ||
| state["turn_text_tail"] = (tail + segment_text)[-_TURN_TEXT_TAIL_CHARS:] |
There was a problem hiding this comment.
[important] Update the tail from the text actually handed off to the Talker. Here, segment_text decodes the entire token delta, but llm2tts later slices the handoff at the first <|turn_eos|>. If an incomplete phrase is followed by <|turn_eos|> and a period, the engine marks the unit sentence-complete even though the period never reaches the Talker, enabling <text_eos> and until-silent draining for incomplete speech. Conversely, a completed sentence followed by an extra word after <|turn_eos|> gets the opposite misclassification. The existing test_native_duplex_turn_eos_row_is_handed_to_talker already covers ordinary tokens after <|turn_eos|>. Please move the tail update after handoff slicing and add regression coverage for both directions, retaining the prior-unit tail when the handoff contains only <|turn_eos|>.
There was a problem hiding this comment.
Done in 893a03f. The tail is now built from the decoded handoff, the same slice the Talker gets. The turn-start reset stays in _native_duplex_segment_output_ids, which runs for every unit, control-only ones included, and llm2tts appends handoff_text after the slice at <|turn_eos|>. A unit whose handoff is only <|turn_eos|> adds nothing, so the previous unit's tail is kept (test_native_duplex_turn_end_uses_the_last_text_of_the_turn).
test_native_duplex_turn_end_reads_only_the_text_handed_to_the_talker covers both directions: "So the" + <|turn_eos|> + "." is not sentence-complete, and "Sure, here it is." + <|turn_eos|> + "And" is. Both cases fail on the previous head.
The handoff to the Talker stops at the first <|turn_eos|>, but the turn's text tail was built from the whole decoded unit, so text sampled after <|turn_eos|> could flip the sentence-end decision either way. Reset the tail at turn start as before, and append only the decoded handoff text. Signed-off-by: Tianyao Wu <rayroy31@gmail.com>
natureofnature
left a comment
There was a problem hiding this comment.
Re-reviewed 7e08057. The MRv2 trailing-run reconstruction and slot-resume regression address the earlier preemption-counter finding at the code level.
[P2] The sentence-end mismatch remains, as tracked in the existing review thread. In stage_input_processors/minicpmo_4_5_omni.py:763–765, turn_text_tail is updated from the full token delta before llm2tts truncates the Talker handoff at the first <|turn_eos|>. A period after that boundary can incorrectly enable <text_eos> and until-silent draining for incomplete speech; an extra word after it can disable them for a complete sentence.
Please update the tail from the decoded handoff slice and cover both misclassification directions, plus an EOS-only handoff that preserves the previous unit's tail. I would fix this before merging.
Static review only: no fork code, tests, or GPU/model inference were run, and the reported speech-quality results were not independently reproduced.
|
Pushed 893a03f for the sentence-end comment from @linyueqian and @natureofnature (reply in the thread). I re-ran the same checks on it:
|
|
Re-reviewed The two earlier correctness findings are addressed at the code level:
Architecturally, keeping this policy inside the MiniCPM-o handoff/Talker implementation is appropriate. The V1 host path, V1 device kernel, and MRv2 path all distinguish the three drain kinds; the existing boundary-mask behavior remains the fallback. The silence classes are an empirical completion heuristic, however, not proof that all outstanding text was spoken. Remaining merge considerations:
This was a static source/test review; I did not execute fork code or independently reproduce the CUDA/model quality results. The benchmark and test counts above are author-reported evidence. |
Independent V1 Seed-TTS check of
|
| Revision | Mean WER | Turns with any error |
|---|---|---|
e461c00 (base) |
0.0983 | 9/16 |
893a03f (this PR) |
0.0371 | 4/16 |
The drop matches the failure mode in the PR: on the base, longer sentences lose the last words (pine trees → pine, as quiet as the desert cut off, toward its own fate cut off, on each side → on it, great heights → greatness). On this PR those tails are spoken. The four remaining errors are not cut-off endings: sensed → 's, fifty written as 50, difficulties → vocalities, and an apostrophe mismatch on an otherwise complete great heights sentence.
I did not rerun the 192-turn MRv2/V1 suite or the zh CER numbers reported in the description.
|
Thanks @amy-why-3459 for the review and for running V1 yourself. Buildkite has run on 0fe9bad (your merge of main) and passed: https://buildkite.com/vllm/vllm-omni/builds/17496. That covers the two groups I couldn't run properly here: Distributed Test has 25 passed / 8 skipped, and the H100 group has 79 passed on an H100. The MiniCPM-o duplex and MRv2 duplex tests passed too. On the NPU pipeline the only failure is the A5 diffusion layer job (exit -1); the MiniCPM-o NPU jobs passed. I can still attach my local JUnit files if they're useful. |
|
WER seems much more acceptable. How does the rest compare to turn-based WER from the o4.5 model? Current code LGTM. |
hsliuustc0106
left a comment
There was a problem hiding this comment.
-
P2: Carry until-silent draining through the V1 multi-frame Talker path. At
0fe9bad3fbb43c0cfca06b29d008179d38a2d028, the new drain-kind assignment can store kind2, but the K-step consumer still bool-casts it and applies only the legacy 25-frame boundary mask. The shipped NPU duplex configuration enables K8, so this path bypasses the updated single-frame silence/quiet-run logic. For example, at step 24 with no trailing silence and EOS-best logits, K-step allows EOS while the single-frame path must mask it until two silent frames; it also does not force the eight-quiet-frame stop. This leaves premature truncation or extra speech on that supported path.twu3202: preserve all three drain kinds in both greedy and stochastic K-step sampling. Acceptance: current-head parity tests before/after two silent frames and eight quiet frames, including K-frame boundaries and unchanged legacy kind-1 behavior; provide the local command/environment/results and disclose any hardware gap. Status: awaiting repair.
Source-only review of all eight changed files and relevant dispatch/sampler contracts; no local tests or NPU speech evaluation executed.
The K-step loop read the drain kind as a bool, so an until-silent chunk only got the boundary mask there. It now masks codec EOS until this condition ends in two silent codes and stops the chunk after eight quiet ones, the same as the single-frame path, using the host-side confirmed frame record so no device sync is added. Signed-off-by: Tianyao Wu <rayroy31@gmail.com>
|
@hsliuustc0106 Thanks, you're right: the K-step loop came in from #7929 with the merge of main and still read the kind as a bool. Fixed in ad79b5a. It now handles the drain kinds the same way as the single-frame path: kind 1 keeps the boundary mask, and kind 2 masks codec EOS until the condition ends in two silent codes and finishes the chunk without sampling once eight quiet codes are confirmed. The runs are counted on New tests in
On 0fe9bad, 19 of the 73 new tests fail, all kind 2; the kind-2 cases that pass there are the ones where the boundary mask gives the same answer. Every kind-0 and kind-1 case passes on both commits. Local results (CPU only; Python 3.12.13, torch 2.13.0+cu130, vLLM 0.31.0, Threadripper PRO 7965WX):
Hardware gap: the K-step path only runs on NPU and I don't have one, so this is not run on NPU and there is no NPU speech evaluation. |
amy-why-3459
left a comment
There was a problem hiding this comment.
Re-reviewed ad79b5a (only the K-step drain fix, 0fe9bad..ad79b5a).
The K-step branch now applies the same criteria as _make_omni_output_single_frame: _DRAIN_BOUNDARY_MASK keeps the cadence window, and _DRAIN_UNTIL_SILENT masks EOS until two trailing silent codes and finishes before sampling once eight quiet codes are confirmed. Counting the runs on _request_codec_history[-step:] is equivalent to the single-frame recent_codes[-step:] here: step and the history both advance once per emitted frame, and _advance_classified only sets emit=False on the finishing sample, so the two can't drift mid-condition. The step > 0 guard keeps retained codes from the previous condition out, and the read piggybacks on the per-frame D2H that is already there, so no extra sync is added. The early-finish branch leaves _request_codec_device_states untouched, which looks fine since the next native-duplex condition drops those caches in prefill anyway.
The parametrized cross-check against the single-frame path (incl. the frame-32 step-start stop, the silent pair across the frame-16 boundary, and the retained-code cases) covers the edge cases I'd expect here. No new issues on this delta.
Since the K-step path is NPU-only and this commit hasn't been run there, an Ascend duplex run with K-step enabled (WER/CER plus tail-cut count, as in #8807) would still be good to have before merge to confirm the end-to-end behavior.
I ran turn-based on the same rows to compare: the same 192 Seed-TTS rows per language, through
For reference, the model card reports 2.38% (en WER) / 0.86% (zh CER) on the full test sets. So zh is at the turn-based level, and en is 0.8 points above it on MRv2 and 1.8 on V1. Looking at the utterances that turn-based gets right but duplex doesn't (en 11 on MRv2 and 20 on V1, zh 10):
|
|
@linyueqian Could you please take another look at this PR? If everything looks good, could you approve it? Thanks! |
hsliuustc0106
left a comment
There was a problem hiding this comment.
- @twu3202, fix the conflicting test marks at
12eeb65c919700370864f87156a2a449e67167ee. The current pre-commit job fails incheck_test_marks.py: module-level CPU is inherited by the CUDA/L4 hardware test. Separate that accelerator case into another module, or scope CPU marks to CPU cases while retainingcore_modeland the hardware marker. Acceptance: the repository marker check passes on the changed file(s), CPU coverage remains selected in its lane, and the accelerator case is selected only in the CUDA/L4 lane. Please include the fix SHA and check/collection output; do not skip or weaken the check.
Verified from the failed job and source marker declarations; I did not run these tests or reproduce a Talker runtime defect.
Omni ReviewBot: supersededThe CI failure noted on |
test_talker_mrv2.py marks the whole module cpu, and its CUDA/L4 sampler test inherited that mark, which check_test_marks.py rejects since vllm-project#8380. Move the test unchanged to test_talker_mrv2_cuda.py, which carries only core_model plus the test's hardware_test marks. Signed-off-by: Tianyao Wu <rayroy31@gmail.com>
|
@hsliuustc0106 Fixed in 1a9210e, which only touches the two test files below; the checker and the hook's SKIP settings are unchanged. The L4 test moves unchanged to Output on 1a9210e (vLLM 0.31.0, one RTX 6000 Ada): The one test the CPU lane no longer selects is the L4 test; the other 34 tests in |
linyueqian
left a comment
There was a problem hiding this comment.
Rechecked ced278b. The only author commit since ad79b5a is 1a9210e, which moves the L4 MRv2 sampler test into test_talker_mrv2_cuda.py so the module-level CPU mark no longer covers it; pre-commit now passes. After merges from main, the PR's own changes to the four runtime files remain identical to ad79b5a. The fused codec prelude from #9077 still takes eos_window_masked from the drain logic, and the EOS censor runs before the fused branch, so all three drain kinds carry through on that path. No remaining actionable findings.
Static review only; I did not run fork code, tests, or e2e. Main CUDA passes at ced278b (build 17675). The completion and WER comparisons reported in this thread were run on earlier revisions with the same runtime logic; NPU speech validation was not run.
…sh speaking (vllm-project#8911) Signed-off-by: Tianyao Wu <rayroy31@gmail.com> Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
Fixes #8807.
Contributor intent (required human answers)
1. What outcome do you want to achieve?
Fix, or at least mitigate, MiniCPM-o 4.5 not speaking the last 1–3 words in full-duplex mode.
2. What is the highest-leverage variable for that outcome?
WER/CER, and the number of turns whose tail is cut.
3. How will this work move that variable?
By changing the last chunk of each turn: first check whether the turn's text ends a complete sentence, and so on, so that the end of the sentence is timed better, which mitigates the problem.
4. Whose customer or user needs does this address, and what are those needs?
Anyone who uses MiniCPM-o full-duplex and cares about speech completion.
Purpose
In native full duplex the Talker speaks 25 codec frames per unit while the Thinker hands it up to 28 characters, so at turn end the Talker still owes text from earlier units. The turn-end condition has a 104-token budget and masks EOS for 5 steps after each 25-frame boundary, but the Talker still stops on its 25-frame cadence (just before or right after the masked steps) and the owed words are not spoken. Details and measurements are in #8807.
This PR changes only the turn-end condition, and only when the turn's text ends a sentence:
<text_eos>, like the last chunk ofTTSStreamingGenerator.generate_with_bufferin the checkpoint'sutils.py. The checkpoint'sMiniCPMODuplexdoes not add it, and stops its turn-end chunk after at most 25 codes like every other chunk.#8807 suggests letting every condition run to its own EOS. With a 104-token budget and no floor or mask on every condition, the Talker still stopped at 24–26 frames in 363 of 386 mid-turn conditions (WER 0.0708, see my comment there), so this PR keeps the per-unit pacing. Mid-turn conditions are unchanged.
Changes:
stage_input_processors/minicpmo_4_5_omni.py: keep the last 16 characters of the text handed to the Talker in this turn (after the slice at<|turn_eos|>) in the handoff state; setmeta["turn_text_ends_sentence"]on every native-duplex unit that hands a condition to the Talker (true only on a turn-end unit when the turn's handed text ends with。!?.!?…, ignoring closing quotes and brackets); one more prompt slot for<text_eos>.minicpmo_4_5_omni_tts.py:turn_end_drainis now a kind (none / boundary mask / until silent); the stop rule is applied in the MRv2 path, the V1 host path and the multi-frame (K-step) loop from [Bugfix][Ascend][Perf] MiniCPM-o 4.5 Talker multi-frame decode and NPU pipeline optimizations #7929;<text_eos>on a closed turn-end condition.codec_state.py: the same rule in the V1 device kernel, using a per-codec-id flag table.Related: #8723 (fused MRv2 Talker kernel, tests
turn_end_drain == 1) and #8573 (CUDA multi-frame decode, usesbool(state.get("turn_end_drain"))) read the same field. Whichever of these and this PR lands second has to carry kind 2 (until silent) into the other's code.Test Plan
vLLM Version: 0.31.0
vLLM-Omni Commit: this branch on top of
e461c00(main). The Seed-TTS comparisons below were run on37f094a(main) vs this change on top of it; after the rebase the Seed-TTS en runs, unit tests and e2e tests were repeated, and the two-turn sessions were run one461c00(main) vs this branch. Main (dfdae25) was later merged in, which brought #7929's multi-frame loop; that loop is covered by CPU unit tests only.One RTX 6000 Ada (48 GB), official
openbmb/MiniCPM-o-4_5weights. To fit 48 GB,minicpmo_4_5.yaml(whichminicpmo_4_5_duplex_mrv2.yamlinherits) was cut down locally:max_sessions, everymax_num_seqsandmax_graph_batch16 → 4;gpu_memory_utilization0.55 / 0.15 / 0.18 → 0.62 / 0.13 / 0.15; Stage 0kv_cache_memory_bytes: 6442450944on CUDA.Two-turn sessions: the two-question audio from #8820, 3 sessions per deploy,
realtime_duplex_demo.pyat temperature 0, transcribed with whisper-large-v3.Test Result
Seed-TTS en, 192 turns, main
37f094avs this PR (same deploy and seed; reruns give the same transcripts):minicpmo_4_5_duplex_mrv2.yamlminicpmo_4_5.yaml(V1)"Tail cut" = the transcript matches the reference up to some word, then stops or gets the last word wrong; a few of the remaining ones are mispronounced names. Turns that are clean on main but not with this PR: 4 on MRv2 (three single-word misreadings such as "secrecy" → "secrety", and one word dropped mid-sentence) and 1 on V1, where "Catch as catch can." loses "can": main also pauses about 0.2 s before that word, and with this PR the Talker stops in the pause. In the other direction, 64 turns on MRv2 and 53 on V1 go from wrong on main to clean. On V1, turns with a gap ≥ 400 ms go from 10 to 12.
Latency from the same runs (concurrency 1, per 4-turn session): mean E2EL 16.36 → 16.63 s on V1 and 16.29 → 16.62 s on MRv2; mean TTFT 312 → 320 ms and 289 → 291 ms. Mean audio RTF goes down (1.05 → 0.97 and 1.03 → 0.95) because each session has about 1.5 s more audio.
How the 192 turn-end conditions stopped on MRv2: 128 sampled EOS after two silent codes, 60 were stopped after eight quiet codes, and 4 kept the boundary mask because the Thinker's text (which repeats the reference) has no final punctuation. None reached the 104-token cap. Of the 60 quiet stops, one has a higher WER than main, a misreading ("secrety") rather than a lost word.
For reference, the checkpoint's own
MiniCPMODuplexon the same 192 turns (same prompts and reference audio; codes turned into audio with this repo'sMiniCPMO45Token2wav, sincestepaudio2isn't installed here) cuts the tail in 84 turns, against 67 for main. Its Thinker text is complete in all 84, and its turn-end Talker chunk used the whole 25-code budget in 48 of them. So the owed text comes from the reference duplex design; main's 104-token turn-end budget already reduces it.Codec ids, measured on MRv2 Talker output from a main run and an earlier run of this rule (Seed-TTS en; a frame counts as silent when it is 35 dB or more below the turn's 90th-percentile frame level). The ids were picked from this data; the zh run below is a check on other data.
Seed-TTS zh, MRv2, 192 turns (48 × 4), scored with paraformer-zh as in
seed_tts_eval.py: CER 0.0239 on main and 0.0105 with this PR. Turns with any error go from 33 to 15: 19 go from wrong to clean and 1 from clean to wrong (a name misread); of the 14 wrong on both, 6 get better, 5 stay the same and 3 get worse. Two of those 3 are the same sentence, "全球每年有超过一百三十五万人,因交通事故而死亡。" (two reference voices): main cuts it after "因交通事" or "因交通事故", and this PR stops in the pause at the comma before "因". The third is a single-character misreading.Two-turn sessions (#8820 audio,
e461c00main vs this branch, 3 sessions each at temperature 0; the 3 sessions of a run have the same length):After rebasing on
e461c00:test_minicpmo_4_5_duplex.py13/13 andtest_minicpmo_4_5_duplex_mrv2.py5/5 (real weights).test_deploy_resolves_three_stage_pipeline[minicpmo_4_5.yaml]and twotest_mrv2_profile_retains_full_thinker_handoffcases); they pass with the stock YAML.