Skip to content

Count structured chat tokens accurately - #441

Open
hvagadia wants to merge 1 commit into
mlcommons:mainfrom
hvagadia:agent/structured-token-metrics
Open

Count structured chat tokens accurately#441
hvagadia wants to merge 1 commit into
mlcommons:mainfrom
hvagadia:agent/structured-token-metrics

Conversation

@hvagadia

@hvagadia hvagadia commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

What changed

  • Preserve complete chat messages and top-level tool definitions for ISL instead of flattening them into plain text.
  • Count structured assistant output for OSL/TPOT with content, reasoning, and tool calls through the model chat template.
  • Use apply_chat_template(..., tokenize=True) so template-inserted and special tokens follow the official tokenizer semantics.
  • Support Kimi's custom tiktoken tokenizer through its public Python wrapper while retaining the existing Rust backend path for standard fast tokenizers.
  • Clarify in code that prompt tokenization is the complete chat input used for ISL, while message tokenization is structured assistant output used for OSL/TPOT.

Why

The previous ISL path flattened multi-turn messages, losing roles, prior reasoning, tool-call history, tool results, tool definitions, and chat-template tokens. The existing output path also omitted structured reasoning unless tool calls were present. In addition, directly using Kimi's underlying tiktoken model would bypass model-specific preprocessing implemented by its official tokenizer wrapper.

These changes preserve the structured request and response fields and delegate their serialization to the model tokenizer.

Validation

  • 8 passed, 160 deselected in focused tests covering structured prompt round trips, ISL preservation, structured reasoning/tool OSL routing, Kimi wrapper selection, and process-shard support.
  • Live Kimi K3 validation on SGLang ran 3 agentic trajectories end-to-end: 25/25 turns completed, 0 request errors, 6 tool-call turns, and all 25 client metric records joined one-to-one with server OpenAI usage records.
Metric Client total Server total Client delta Per-turn comparison
ISL 880,455 892,780 -12,325 (-1.38%) Exactly -493 tokens on every turn; 25/25 within 5%
OSL 9,279 9,592 -313 (-3.26%) Maximum absolute difference: 13 tokens

The constant ISL difference comes from SGLang adding function.strict: false and top-level defer_loading: null to each of the 62 benchmark tool definitions before applying Kimi's chat template. These unset JSON fields add 496 model-input tokens; SGLang excludes a 3-token generation stub from reported prompt usage, producing the observed 493-token per-turn difference. Excluding unset fields makes the server and client input token IDs match exactly.

Tokenizer measurement Result
Setup before performance phase 3.7 s
Drain after generation 75 tokenizations in 1.09 s
Generation duration 202.28 s
Observed bottleneck No, at concurrency 1
High-QPS scalability Not established by this run
  • git diff --cached --check

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

MLCommons CLA bot All contributors have signed the MLCommons CLA ✍️ ✅

@hvagadia
hvagadia marked this pull request as ready for review August 7, 2026 23:04
@hvagadia
hvagadia requested a review from a team August 7, 2026 23:04
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.61538% with 12 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@0afc9f4). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...utils/services/metrics_aggregator/token_metrics.py 82.53% 11 Missing ⚠️
src/inference_endpoint/load_generator/session.py 85.71% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #441   +/-   ##
=======================================
  Coverage        ?   81.90%           
=======================================
  Files           ?      148           
  Lines           ?    19700           
  Branches        ?        0           
=======================================
  Hits            ?    16136           
  Misses          ?     3564           
  Partials        ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@hvagadia hvagadia mentioned this pull request Aug 8, 2026
10 tasks
@hvagadia
hvagadia requested a review from arekay-nv August 10, 2026 16:43
# parts) remain unavailable for ISL reporting.
if token_ids is not None:
prompt_data = PromptData(token_ids=tuple(token_ids))
elif isinstance(data.get("messages"), list | tuple):

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.

I see there's a priority order of message prep here - Should message, prompt, token_id be mutually exclusive? E.g. throwing warning/error if more than one is present in the parquet?

There were frequent confusions about which field is used in the parquet in the loadgen - and would be good to call this priority / mutual exclusiveness out somewhere

if isinstance(ev_rec.data, PromptData) and ev_rec.data.token_ids is not None:
self.registry.record(self.metric_name, len(ev_rec.data.token_ids))
return
if isinstance(ev_rec.data, PromptData) and ev_rec.data.messages is not None:

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.

Seems missing comment (which inherits from the prepopulated token ids comment above)

return self.tokenizer.encode(text, add_special_tokens=add_special_tokens)

def encode_lengths(self, texts: list[str]) -> list[int]:
return [len(self.encode(text, add_special_tokens=False)) for text in texts]

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.

Should we use batch tokenizer for this path?

Comment on lines 319 to +328
if isinstance(ev_rec.data, PromptData) and ev_rec.data.token_ids is not None:
self.registry.record(self.metric_name, len(ev_rec.data.token_ids))
return
if isinstance(ev_rec.data, PromptData) and ev_rec.data.messages is not None:
if self._queue is not None:
self._queue.enqueue_prompt(
(ev_rec.data.messages, ev_rec.data.tools),
self._make_recorder(ev_rec, pre_change),
)
return

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.

Can this be combined so the common if isinstance(ev_rec.data, PromptData) is evaluated one making it more legible.
The messages and self._queue is not None and then be combined.

Comment on lines +359 to +361
if isinstance(ev_rec.data, TextModelOutput) and (
ev_rec.data.reasoning or ev_rec.data.tool_calls
):

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.

Did this change the TPS we are observing as we didn't account for the reasoning output before?



def _backend_from_tokenizer(tokenizer: Any) -> Any | None:
"""Return the tokenizer's supported length-counting path."""

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.

Is this docstring accurate?

Comment on lines +278 to +282
else:
prompt_text = data.get("prompt")
prompt_data = PromptData(
text=prompt_text if isinstance(prompt_text, str) else None
)

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.

Recommend hardening this to elif isinstance(data.get("prompt"), str) and adding a warning in the else clause to catch multimodal inputs or any other cases - otherwise these may get silently ignored.

)
self._prefix_len = len(tok.tokenize(prefix))
self._prefix_len = len(prefix)
with_assistant = cast(

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.

rename to with_assistant_tokens to help disambiguate text data from tokens.

Comment on lines 302 to 308
try:
prefix = cast(
str,
list[int],
tok.apply_chat_template(
[_PREFIX_USER_MSG], tokenize=False, add_generation_prompt=False
[_PREFIX_USER_MSG], tokenize=True, add_generation_prompt=False
),
)

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.

apply_chat_template returns a dictionary by default if tokenize is set to True. This is only trigerred when tokenize is True. Please update call sites. Also, can you add tests that would catch this behavior - fail with current change and pass with return_dict set to False for tokenize set to True.

@arekay-nv arekay-nv left a comment

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.

Consolidated review

This change targets accurate structured-chat token counting, but as written the core counts are incorrect on the pinned transformers==5.5.0, and two further issues persist even after the primary fix. All findings below are verified against the pinned tokenizer (live repro on Qwen/Qwen2.5-0.5B-Instruct / Qwen3-8B).

Must fix

  • tokenize=True returns a BatchEncoding; len() counts keys, not tokens (token_metrics.py 309/318/465/513). ISL → ~2, structured OSL/TPOT → 0. Fix: return_dict=False on every tokenize=True site.
  • A unit-test fake masks the above — it returns a bare list for tokenize=True, so the suite stays green (test_token_metrics.py).

Persists after the fix above

  • Reasoning silently dropped from OSL/TPOT — input templates ignore reasoning_content (metrics_table.py 360/404).
  • Empty/multimodal messages dropped from ISL — messages: []IndexError; multimodal content errors; _extract_prompt_text is now dead (session.py 272).

Should fix

  • _token_count_prompt lacks tool-call normalization, a try/fallback, and chat_template_kwargs parity that _token_count_message/the adapter already have (token_metrics.py 499).
  • Custom-tiktoken backend unreachable — tiktoken undeclared (token_metrics.py 138).
  • Prompt lane drains serially under the flush lock, never sharded — high-QPS runs can exceed --drain-timeout (token_metrics.py 775).

Checked and fine: msgspec wire round-trip + gc=False safety, _inflight accounting across the three flush phases, and add_generation_prompt=True for ISL.

Suggested tests are inline on the relevant lines; each is written to fail on the current code and pass after the described fix. Real-tokenizer tests use the cached Qwen/Qwen2.5-0.5B-Instruct and can be pytest.skip-guarded when uncached.

encoded = self._tokenizer.apply_chat_template( # type: ignore[union-attr]
list(messages), **kwargs
)
return len(encoded)

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.

[critical] len(apply_chat_template(..., tokenize=True)) counts BatchEncoding keys, not tokens.

On the pinned transformers==5.5.0, apply_chat_template(..., tokenize=True) defaults to return_dict=True and returns a BatchEncoding, so len(encoded) is the key count (~2), not the token count. Live repro on Qwen/Qwen2.5-0.5B-Instruct: a 44‑token prompt → ISL 2. The same pattern corrupts the baseline (_prefix_len/_baseline become 2/0) and _token_count_message (line 465), so structured OSL/TPOT collapse to 0 for every reasoning/tool-call sample.

Fix: pass return_dict=False (or index ["input_ids"]) on every tokenize=True site — here plus lines 309, 318, 465.

Suggested test (fails now, passes after the fix):

def test_structured_prompt_isl_is_token_count_not_batchencoding_keys():
    bt = BatchTokenizer("Qwen/Qwen2.5-0.5B-Instruct",
                        live_workers=1, cores_per_worker=0, n_workers=0)
    try:
        msgs = ({"role": "user", "content": "Write a detailed essay about the fall of Rome."},)
        assert bt._token_count_prompt(msgs, None) > 10   # returns 2 today
    finally:
        bt.close()


def apply_chat_template(
self, messages, tokenize=False, add_generation_prompt=False
self, messages, tools=None, tokenize=False, add_generation_prompt=False

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.

[high] This fake hides the BatchEncoding defect, so the suite stays green while production is wrong.

apply_chat_template(..., tokenize=True) here returns a bare list, so len() equals the token count — but real transformers returns a BatchEncoding whose len() is the key count (~2). That is why the new chat-template tests pass while the real path returns 2.

Make the fake return a mapping for tokenize=True unless return_dict=False, so the suite fails until the call sites are fixed:

class _BE(dict):  # BatchEncoding stand-in: len() == number of keys
    pass

def apply_chat_template(self, messages, tools=None, tokenize=False,
                        add_generation_prompt=False, return_dict=True):
    ids = list(range(len(...)))            # existing token logic
    if tokenize and return_dict:
        be = _BE(); be["input_ids"] = ids; be["attention_mask"] = [1]*len(ids)
        return be
    return ids if tokenize else rendered

def _extract_message(self, ev_rec, row, pre_change):
if isinstance(ev_rec.data, TextModelOutput) and ev_rec.data.tool_calls:
if isinstance(ev_rec.data, TextModelOutput) and (
ev_rec.data.reasoning or ev_rec.data.tool_calls

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.

[high] Reasoning is silently dropped from OSL/TPOT (independent of the return_dict fix).

Routing reasoning-only outputs through the input chat template renders {"role": "assistant", "reasoning_content": ...}, which reference templates (Qwen, DeepSeek-R1, …) ignore — and it does not raise, so the whitespace fallback never fires. Verified on Qwen2.5: _token_count_message("hi", "<long reasoning>", None) == _token_count_message("hi", None, None). Previously the plain-text path counted reasoning via str(TextModelOutput), so this under-reports OSL/TPOT (and inflates TPS) for exactly the reasoning models this change targets. Same applies to TpotTrigger (line 404).

Consider counting reasoning as generated text rather than through the input-side template.

Suggested test:

def test_reasoning_tokens_are_counted():
    bt = BatchTokenizer("Qwen/Qwen2.5-0.5B-Instruct",
                        live_workers=1, cores_per_worker=0, n_workers=0)
    try:
        assert bt._token_count_message("hi", "a long private chain of reasoning", None) \
             > bt._token_count_message("hi", None, None)
    finally:
        bt.close()

# parts) remain unavailable for ISL reporting.
if token_ids is not None:
prompt_data = PromptData(token_ids=tuple(token_ids))
elif isinstance(data.get("messages"), list | tuple):

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.

[medium] Empty and multimodal messages are silently dropped from ISL.

isinstance([], list | tuple) is True, so a dataset messages: [] builds PromptData(messages=()); _token_count_prompt then calls apply_chat_template([], add_generation_prompt=True), which raises IndexError (verified) — and with no fallback that drops the sample's ISL and taints the drain. This branch is also a regression for multimodal chat: the previous code extracted text via _extract_prompt_text(...) (now dead code), whereas list-shaped content sent here makes text-only templates error.

Guard with truthiness so empty falls through to the text branch, and sanitize/fall back for non-text content:

elif data.get("messages"):
    ...

Suggested test:

def test_empty_messages_fall_through_not_indexerror():
    from transformers import AutoTokenizer
    tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
    with pytest.raises(IndexError):   # documents current behavior; guard removes it
        tok.apply_chat_template([], tokenize=True, add_generation_prompt=True, return_dict=False)

self._thread, self._token_count_message, content, reasoning, tool_calls
)

def _token_count_prompt(

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.

[medium] _token_count_prompt lacks the safeguards _token_count_message already has.

Three gaps versus the message path:

  1. No _normalize_tool_calls_for_template on assistant tool_calls embedded in prompt history. Agentic multi-turn history carries wire-JSON arguments strings; Hermes/Qwen templates that iterate arguments as a mapping then diverge or raise (verified 50 vs 49 tokens on Qwen2.5), while OSL handles them.
  2. No try/except fallback — a template failure propagates and taints the drain instead of degrading to an approximate count.
  3. chat_template_kwargs ignored — the adapter forwards these to the server (e.g. Qwen3 enable_thinking: 19 vs 15 tokens), but they are not threaded through PromptData, so ISL diverges from the served prompt.

Mirror the message path: normalize embedded tool calls, wrap in the same try/fallback, and carry chat_template_kwargs through PromptData.

model = getattr(tokenizer, "model", None)
model_type = type(model)
if (
model_type.__module__ == "tiktoken.core"

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.

[medium] The custom-tiktoken backend is unreachable — tiktoken is not a declared dependency.

For a Kimi-style tokenizer, AutoTokenizer.from_pretrained(...) imports tiktoken during load and raises ImportError before _backend_from_tokenizer is ever reached (tiktoken is not in pyproject.toml; not importable in the current env). As-is the advertised backend can never activate in a clean install. Declare tiktoken in the appropriate dependency group (pinned ==) or document the requirement, and add a test that exercises a real tiktoken-backed tokenizer rather than a fake.

self._record(on_count, count)
for i, ((messages, tools), on_count) in enumerate(prompt_items):
try:
count = await self._tokenizer.token_count_prompt_async(

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.

[medium] The prompt lane never shards and drains serially under the flush lock.

token_count_prompt_async runs on the small in-process thread pool (like the message path), and _flush awaits each prompt item one-by-one while holding self._lock. On main this in-process lane was justified as "rare (tool calls)"; this change makes it the path for every structured-chat ISL sample. At high QPS the end-of-run drain can exceed --drain-timeout, leaving ISL permanently uncounted (n_pending_tasks > 0) and blocking concurrent live flushes. Consider batching prompt render+encode through the sharded path like count_texts_async.

Suggested test (peak-concurrency probe with a stub tokenizer):

async def test_prompt_drain_is_not_serialized_per_item():
    peak = active = 0
    class T:
        async def count_texts_async(self, *a, **k): return []
        async def token_count_message_async(self, *a, **k): return 0
        async def token_count_prompt_async(self, m, t, loop):
            nonlocal active, peak
            active += 1; peak = max(peak, active)
            await asyncio.sleep(0.01); active -= 1; return 3
    q = TokenBatchQueue(T(), asyncio.get_running_loop())
    for _ in range(8):
        q.enqueue_prompt((({"role":"user","content":"x"},), None), lambda c: None)
    await q.drain_all()
    assert peak > 1

@nv-alicheng nv-alicheng left a comment

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.

Review Council — Multi-AI Code Review

Reviewed by: Claude + Code-Quality | Depth: standard

codex unavailable in this environment. This PR already has a thorough human review; this pass independently corroborates the existing critical/high findings and adds only net-new items (see summary).

if isinstance(ev_rec.data, PromptData) and ev_rec.data.token_ids is not None:
self.registry.record(self.metric_name, len(ev_rec.data.token_ids))
return
if isinstance(ev_rec.data, PromptData) and ev_rec.data.messages is not None:

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.

[Claude] medium (testing): The new ISL structured-prompt lane has no queue-integration test. Grep over tests/ finds zero references to IslTrigger or enqueue_prompt. Untested: (a) IslTrigger.fire messages routing here (322-328), including the silent drop when self._queue is None; (b) TokenBatchQueue.enqueue_prompt and the whole prompt lane in _flush (token_metrics.py:773-787) — the _record/_inflight decrement, drain-vs-live requeue, and the CancelledError handler that requeues all prompt_items (token_metrics.py:765). The only prompt coverage is test_token_count_prompt_preserves_messages_tools_and_generation_prompt, which calls token_count_prompt_async directly and never touches the queue or trigger; the metrics_table message-routing test covers OSL/TPOT only, not ISL. This is a hot-path lane with nontrivial cancellation/requeue logic — add direct queue + trigger tests.


text: str | None = None
token_ids: tuple[int, ...] | None = None
messages: tuple[dict[str, Any], ...] | None = None

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.

[Both] low (code-quality): PromptData is gc=False (types.py:283) but carries two mutable-container fields, messages: tuple[dict[str, Any], ...] and tools: tuple[dict[str, Any], ...], holding arbitrary nested chat/tool dicts. Every sibling struct in this file documents the gc-audit convention: TextModelOutput carries an explicit AT-RISK (gc=False): Has mutable container field 'tool_calls'... note, StreamChunk/ErrorData justify gc=False with "metadata contains only scalar key-value pairs." PromptData's docstring has no such note even though its dicts are the least scalar of any. The choice is practically safe (JSON-native dataset dicts don't cycle back; encoded synchronously and short-lived) — so this is a convention/documentation gap, not a runtime defect — but it breaks the file's own documented gc-safety process. Add the AT-RISK annotation. Separately, dict[str, Any] for messages/tools is loosely typed where a TypedDict (role/content/optional reasoning_content/tool_calls) would pin the keys the template code reaches for.

@nv-alicheng

Copy link
Copy Markdown
Collaborator

Review Council — Multi-AI Code Review

Reviewed by: Claude + Code-Quality | Depth: standard
(codex CLI unavailable in this environment.)

This PR already carries a thorough human review (arekay-nv, nvzhihanj). This council pass independently corroborates those findings and adds net-new ones — it does not re-file what's already commented.

✅ Corroborated (already commented — not re-filed)

  • token_metrics.py:513 (critical): confirmed. apply_chat_template(..., tokenize=True) is called at 305, 312, 462, 510 with no return_dict=False anywhere in the file, so on transformers==5.5.0 len(encoded) counts BatchEncoding keys (~2), not tokens → structured ISL/OSL/TPOT collapse. Real.
  • test_token_metrics.py:142 (high) and metrics_table.py:360 (high, reasoning dropped) — consistent with our independent read.

🟡 Net-new — Should Fix (medium)

# File Line Category Reviewer Summary
1 session.py 53 dead-code Both _extract_prompt_text has zero production callers after this PR rerouted the messages branch to PromptData(messages=...) — grep confirms only its own def remains; it stays "green" solely because test_async_session.py still exercises it directly, masking the deadness. Delete it (and its orphaned tests), or wire it in as the text fallback for chat-template render failures. (def is at an untouched line — reported here, not inline)
2 metrics_table.py 322 testing Claude New ISL structured-prompt lane has no queue-integration test (zero refs to IslTrigger/enqueue_prompt in tests/): the _queue is None silent-drop, enqueue_prompt, and the prompt-lane _flush requeue/CancelledError path (token_metrics.py:765,773-787) are all uncovered. Hot-path cancellation/requeue logic — add direct tests.

🔵 Net-new — Consider (low)

# File Line Category Reviewer Summary
3 core/types.py 301 code-quality Both PromptData is gc=False with mutable messages/tools dict-tuples but — unlike every sibling struct in the file — carries no AT-RISK gc-audit note, breaking the file's documented gc-safety convention (safe in practice; add the note). Also dict[str, Any] → a TypedDict would pin message keys.
4 token_metrics.py 299 code-quality Quality self._backend is set inside _load_tokenizer but never declared/annotated in __init__, unlike its sibling self._tokenizer: PreTrainedTokenizerBase | None = None. Declare it for a pinned type.
5 token_metrics.py 207 code-quality Quality [len(getattr(item, "ids", item)) for item in encoded] conflates two return shapes (Encoding.ids vs raw list) via a self-referential getattr default — branch on the known backend type instead.

Dropped as duplicates of existing threads: IslTrigger double-isinstance/combine (already arekay-nv:328), tiktoken magic-string coupling (same line as the existing tiktoken-not-declared thread at :138).

Commit hygiene fine: 2 commits, no fixups.

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.

5 participants