Skip to content

feat(backends): preserve token ids across chat turns - #1592

Draft
noaakl wants to merge 1 commit into
generative-computing:mainfrom
noaakl:feat/token-id-history
Draft

feat(backends): preserve token ids across chat turns#1592
noaakl wants to merge 1 commit into
generative-computing:mainfrom
noaakl:feat/token-id-history

Conversation

@noaakl

@noaakl noaakl commented Aug 27, 2026

Copy link
Copy Markdown

A chat template writes adapter control tokens into the rendered prompt, and they exist only in the token ids that render produced. Re-rendering a conversation from messages on a later turn drops them, and encode(decode(ids)) is not the identity, so the re-derived prefix stops matching what the server cached. Every KV block from the first divergence onward is lost, and each turn's history is reinterpreted under the base model rather than the adapter that produced it.

Adds an opt-in policy that keeps the ids instead of re-deriving them:

ctx = ChatContext(retain_token_ids=True)

ChatContext gains the policy plus the state it needs -- sent_token_ids, sent_model_id, sent_message_count -- all propagated to descendant nodes and cleared on a root reset, since ids are per-conversation. PreTokenizedCBlock carries vocabulary ids that bypass the formatter entirely; it has no string form, because there is no text whose re-encoding is guaranteed to reproduce them.

On OpenAIBackend, a retaining context routes to /v1/completions with prompt=[ids] rather than posting messages, since the chat endpoint re-renders and re-tokenizes server-side and would silently revert the policy. The new turn's ids come from subtracting two fresh /tokenize renders -- the already-sent messages, and the whole conversation -- and are spliced onto the retained prefix. The retained ids are never compared against a re-render: they differ from one exactly when a token fails to round-trip, which is the case this policy exists to survive.

The tokenizer API is reached at the server root, not under /v1, which is where vLLM serves it. Combinations the completions endpoint cannot honour are refused rather than silently degraded: tool calling, streaming, a string-valued reasoning level, and ids produced by a different model. A history that shrank -- a compactor dropping turns, or the token-budget truncation view_for_generation applies once a model_id is bound -- is refused too, because the already-sent side can no longer be identified.

Known gaps, both needing a live vLLM server to settle: return_token_ids requires vLLM 0.10.2+, below which no ids are reported and the policy silently never retains.

Assisted-by: Claude Code

Pull Request

Issue

Fixes #

Description

Testing

  • Tests added to the respective file if code was changed
  • New code has 100% coverage if code was added
  • Ensure existing tests and github automation passes (a maintainer will kick off the github automation when the rest of the PR is populated)

Attribution

  • AI coding assistants used

Adding a new component, requirement, sampling strategy, or tool?

If your PR adds or modifies one of the types below, check the matching box. A checklist of type-specific review items will be posted as a comment.

  • Component
  • Requirement
  • Sampling Strategy
  • Tool

NOTE: Please ensure you have an issue that has been acknowledged by a core contributor and routed you to open a pull request against this repository. Otherwise, please open an issue before continuing with this pull request.

A chat template writes adapter control tokens into the rendered prompt, and
they exist only in the token ids that render produced. Re-rendering a
conversation from `messages` on a later turn drops them, and `encode(decode(ids))`
is not the identity, so the re-derived prefix stops matching what the server
cached. Every KV block from the first divergence onward is lost, and each turn's
history is reinterpreted under the base model rather than the adapter that
produced it.

Adds an opt-in policy that keeps the ids instead of re-deriving them:

    ctx = ChatContext(retain_token_ids=True)

`ChatContext` gains the policy plus the state it needs -- `sent_token_ids`,
`sent_model_id`, `sent_message_count` -- all propagated to descendant nodes and
cleared on a root reset, since ids are per-conversation. `PreTokenizedCBlock`
carries vocabulary ids that bypass the formatter entirely; it has no string form,
because there is no text whose re-encoding is guaranteed to reproduce them.

On `OpenAIBackend`, a retaining context routes to `/v1/completions` with
`prompt=[ids]` rather than posting messages, since the chat endpoint re-renders
and re-tokenizes server-side and would silently revert the policy. The new
turn's ids come from subtracting two fresh `/tokenize` renders -- the already-sent
messages, and the whole conversation -- and are spliced onto the retained
prefix. The retained ids are never compared against a re-render: they differ
from one exactly when a token fails to round-trip, which is the case this
policy exists to survive.

The tokenizer API is reached at the server root, not under `/v1`, which is where
vLLM serves it. Combinations the completions endpoint cannot honour are refused
rather than silently degraded: tool calling, streaming, a string-valued
reasoning level, and ids produced by a different model. A history that shrank --
a compactor dropping turns, or the token-budget truncation `view_for_generation`
applies once a model_id is bound -- is refused too, because the already-sent
side can no longer be identified.

Not implemented, deliberately: the checkpoint guards this policy needs to be
fully safe (switch_type == "multi", aLoRA-only placement, a chat_template_features
capability gate, and the bf16 control-token ceiling). All four require reading
the served checkpoint's config.json, and mellea exposes no control-token ids
today. A constant with no caller would read as a guard that exists.

Known gaps, both needing a live vLLM server to settle: the turn terminator is
appended with no overlap check against the emitted ids, which would double the
EOS token if vLLM reports it; and `return_token_ids` requires vLLM 0.10.2+,
below which no ids are reported and the policy silently never retains.

Assisted-by: Claude Code
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: noaa <noaa.kless@ibm.com>
@github-actions github-actions Bot added the enhancement New feature or request label Aug 27, 2026

@jakelorocco jakelorocco left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hello; thank you for putting together the draft PR. I added some initial comments but we will also take a look as a team to provide some more indepth feedback.

I think it might also be helpful if you could provide a minimum viable example to reproduce both the problematic and correct behavior that you are describing. I don't think this needs to be done with Mellea, but if you can just highlight exactly what is getting lost with an example / example tokens, that would be very helpful for myself.

I think the biggest potential issue here is that granite switch (and our adapters) actually run through a function called _generate_from_intrinsic. That function utilizes some lower level transformations to modify the input / output of any given request. We need to understand how this relates to multi-turn conversations and the switch based adapters that this is being implemented for.

Also; our io.yamls tend to re-write the context so I would be interested to hear how this works across multiple turns.

Comment thread mellea/backends/openai.py
Comment on lines +85 to +87
# This lives here rather than in its own module because `OpenAIBackend` is the
# only consumer: a local-tokenizer backend has no use for the route, and a
# multi-provider proxy cannot rely on it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you please expand on why a local-tokenizer backend doesn't need this functionality? or in other words, why we wouldn't want this functionality on the LocalHFBackend?

Comment thread mellea/backends/openai.py
Comment on lines +115 to +120
Do NOT pass the retained ids as `prev_ids`. They are what the server actually
saw, and they diverge from a fresh re-render exactly when `encode(decode(ids))`
loses a token -- which is the case retaining ids exists to survive. Comparing
against them would make this raise on the one conversation the feature is for.
The retained ids are the prefix the caller SPLICES onto this delta, not the
thing it compares against.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It might be nice to include an example of what these control tokens are or a link to the documentation; something like:

encode(decode([0, 1, 2, 3, 4, 5, 6])) -> [1, 2, 3, 4, 5]

Comment thread mellea/backends/openai.py
Comment on lines +1755 to +1771
# Placed here, AFTER extra_body is merged, so the chat_template_kwargs handed
# to /tokenize are the ones this request would actually have sent -- including
# `adapter_name` arriving via user extra_body and `enable_thinking` set above.
# Dispatching earlier would tokenize under a different template than the turn
# is generated under, and the delta would describe the wrong render.
if isinstance(ctx, ChatContext) and ctx.retains_token_ids:
return await self._generate_via_token_ids(
ctx,
conversation,
(extra_params.get("extra_body") or {}).get("chat_template_kwargs"),
action=action,
linearized_context=linearized_context,
_format=_format,
model_options=model_opts,
has_tools=use_tools,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this would need to fire under the generate_from_intrinsic path in order to get the proper formatting and tokens required.

Comment on lines +68 to +83
retain_token_ids (bool): Opt into id-preserving history. When `True`, a
backend that supports it sends the exact token ids already sent plus
only the new turn's, instead of re-rendering the conversation from
text. Re-rendering drops the control tokens a chat template inserted
and cannot reproduce ids exactly (`encode(decode(ids))` is not the
identity), both of which break a server's prefix cache. Defaults to
`False`, so behaviour is unchanged unless asked for.
sent_token_ids (tuple[int, ...]): Ids the server has already seen,
verbatim. Empty until a backend records a turn. A tuple so a caller
cannot mutate the context's state through it.
sent_model_id (str | None): Model those ids were produced by. Ids are not
portable across vocabularies, so a backend can refuse rather than
reinterpret a prefix produced by a different model.
sent_message_count (int): How many chat messages `sent_token_ids` covers. A
backend needs this to re-render exactly the already-sent side of the
conversation, which is what the new turn's ids are subtracted against.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What is the reason for the context to handle this instead of just having a flag on the backend to attempt to tokenize and try to attach tokens to a cblock?

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants