Skip to content

feat(openai): avoid re-rendering full chat history on multi-turn rollout (#1658) - #1663

Open
hsusul wants to merge 2 commits into
areal-project:mainfrom
hsusul:feat/incremental-prompt-prep-1658
Open

feat(openai): avoid re-rendering full chat history on multi-turn rollout (#1658)#1663
hsusul wants to merge 2 commits into
areal-project:mainfrom
hsusul:feat/incremental-prompt-prep-1658

Conversation

@hsusul

@hsusul hsusul commented Sep 2, 2026

Copy link
Copy Markdown

Description

Resolves #1658.

In multi-turn Agentic RL rollouts (such as SWE, search, reasoning, and tool-using agent episodes), trajectories frequently reach 50–200 turns. The OpenAI-compatible client (ArealOpenAI) and Data Proxy previously re-rendered Jinja2 chat templates and tokenized the entire message history on every turn from scratch.

For an episode of length $N$, cumulative messages processed scaled as $\sum_{i=1}^N (2i-1) = N^2$ ($O(N^2)$). At $N=200$, 40,000 messages were formatted, consuming over 1.1s of CPU time per episode and creating client-side rollout stalls that starve GPU inference backends (SGLang/vLLM).

This PR introduces an incremental prompt preparation mechanism (IncrementalPromptRenderer):

  1. Incremental Suffix Rendering: Reuses the parent interaction's rendered tokens and tokenizes only newly appended delta messages against a bounded synthetic context, preserving the exact generation prompt suffix.
  2. Token Parity Contract: An automated capability probe on first use ensures 100% token-for-token mathematical identity with canonical apply_chat_template across standard architectures (Qwen, ChatML, Llama-3, etc.).
  3. Safe Fallback: Dynamically falls back to canonical full-history rendering for complex/dynamic templates (e.g. dynamic <think> modifications), missing parent tokens, or multimodal processor workflows.
  4. Concat Mode Acceleration: Implemented render_concat_child_tokens for concat chat template mode, eliminating full-history re-tokenization during multi-turn concat rollouts.
  5. No Breaking Changes: Zero changes to public ArealOpenAI signatures, CLI arguments, or trajectory export structures.

Benchmarks & Scaling

Tested across episode lengths using Qwen/Qwen3-0.6B:

Turns ($N$) Full History (Baseline) Incremental (This PR) Speedup Parity Verified
10 turns 18.1 ms 14.7 ms 1.2x 100%
50 turns 107.7 ms 61.6 ms 1.7x 100%
100 turns 342.0 ms 121.9 ms 2.8x 100%
200 turns 1172.6 ms 248.6 ms 4.7x 100%

Verification Plan

  • Comprehensive unit and regression test suite added in tests/experimental/openai/test_prompt_renderer.py:
    • 1-turn, 2-turn, 5-turn, 20-turn, 50-turn incremental token identity vs canonical full-history apply_chat_template.
    • Single tool call, parallel tool calls, sequential tool calls, text-only conversational turns, thinking tags (<think>...</think>), custom system prompts.
    • Automated fallback validation for dynamic templates and legacy parent interactions.
    • Concat mode child token extraction parity vs _concat_prompt_token_ids_with_parent.
  • Verified all existing test suites in tests/experimental/openai/ (63 passed, 0 failed).
  • Passed all 16 pre-commit hooks (ruff check, ruff format, check-yaml, spdx, check-json, etc.).


import torch
from huggingface_hub import snapshot_download
from transformers import AutoConfig

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modification of this file should have nothing to do with this PR, please remove it or submit it separately.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — that was an unrelated local fix that got carried along. Dropped from this branch; opened separately as #1689.

return -1


class IncrementalPromptRenderer:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once reasoning tokens are saved into parent_base_token_ids, they cannot be removed, and for some models like Qwen3, when building context with apply_chat_template, it may remove the previous reasoning content, which can lead to misalignment between the incremental path and the normal path.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, thanks for catching this. Reproduced on Qwen3-0.6B: a cached base of [user, assistant "<think>r1</think>a1"] plus a [user] delta renders 31 tokens incrementally against 24 for the full render, since the template drops reasoning from every turn preceding the last user message. The original probe missed it because the synthetic 2-turn sequence contained no reasoning.

Fixed in the follow-up commit by adding a second capability probe for exactly that shape (_probe_reasoning_history) and refusing incremental rendering when the history carries reasoning that a later user turn supersedes (has_superseded_reasoning). The guard lives inside render_incremental rather than at the call site, so a poisoned prefix can neither be produced nor consumed even if a future caller forgets to check.

One case I deliberately kept on the incremental path: a tool-only delta (assistant <think> + tool_call -> tool result, with no new user turn). Qwen3 keeps the current turn's reasoning there, so it is token-identical to the full render — I verified this and it is covered by test_reasoning_tool_loop_stays_incremental. Blocking it would have removed the optimization for the main multi-turn tool-calling use case.

delta_messages = tokenizer_messages[len(parent.messages) :]
parent_base = parent.prompt_base_token_ids
if parent_base is None:
parent_base = apply_chat_template(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the tools included in the second round of requests are inconsistent with those in the first round, applying only to the incremental part here will lead to a discrepancy in content between the two.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. render_incremental accepted a tools argument but never passed it through to apply_chat_template, so the tool block was whatever the first turn baked into the prefix. Reproduced on Qwen3-0.6B: 1 tool on turn 1 and 2 tools on turn 2 gives 142 tokens incrementally against 178 for the full render, with the second tool's signature missing entirely.

Fixed by recording the tool set the prefix was rendered with (tools_signature(), stored as InteractionWithTokenLogpReward.prompt_tools_signature) and falling back to full-history rendering when it differs from the current turn's. Covered by a parametrized test over tools added, removed, cleared, and newly introduced between rounds.

@guozhihao-224

guozhihao-224 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Hi @hsusul, thanks for the effort on this PR

Checking in: are you still planning to address the review feedback above (the reasoning-content misalignment for models like Qwen3, the tools-inconsistency across turns, and the unrelated testing_utils.py change)? There are also merge conflicts against main at this point.

If you don't have bandwidth to continue, I'd be happy to take this over . Thanks!

…out (areal-project#1658)

- Introduce IncrementalPromptRenderer for O(1) prompt preparation per turn in multi-turn rollouts
- Cache base prompt prefix tokens on InteractionWithTokenLogpReward.prompt_base_token_ids
- Probe tokenizer template capability on first use to ensure 100% token-for-token mathematical parity with full-history rendering
- Add incremental child token rendering in concat chat template mode
- Fall back safely to canonical full-history rendering for dynamic or unsupported templates and multimodal processor inputs
- Add comprehensive unit and regression test suite covering single/multi-tool calling, parallel tools, conversational turns, reasoning blocks, and fallback paths
Review found two cases where the cached prompt prefix diverges from a
canonical full-history render:

- Templates such as Qwen3 drop reasoning blocks from every turn preceding
  the last user message. An append-only cache cannot un-render tokens it
  already holds, so a prefix carrying superseded reasoning is stale. Probe
  the template for this behaviour and fall back to full rendering when the
  history contains reasoning a later user turn supersedes. Tool-only deltas,
  which keep the current turn's reasoning, stay on the incremental path.

- Tool definitions live in the prompt head, which the delta render never
  revisits. A turn declaring a different tool set than the one baked into
  the prefix was silently served the stale tool block. Record the tool set
  the prefix was built with and fall back when it changes between turns.
@hsusul

hsusul commented Sep 9, 2026

Copy link
Copy Markdown
Author

Pushed a revision addressing all three comments (force-push, since dropping the unrelated commit required a rebase).

  • testing_utils.py / pkg_version.py split out into fix(utils): lazy test model dict and safe package version checking #1689 — this branch no longer touches them.
  • Reasoning tokens: reproduced on Qwen3-0.6B (31 tokens incremental vs 24 full). Added a capability probe for templates that drop reasoning before the last user turn, and the renderer now refuses to build or consume a prefix in that state. Tool-only deltas, which are token-identical under Qwen3, stay incremental.
  • Tool set changes: reproduced (142 vs 178 tokens, second tool missing). The prefix now records the tool set it was rendered with and falls back when it changes.

Both fixes fall back to the existing full-history path rather than attempting a partial-invalidation scheme, so parity is unconditional. Details in the thread replies; 10 new tests, each failing without the corresponding guard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Avoid re-rendering the full chat history on every multi-turn tool rollout

3 participants