Multi-digit literals come back with spaces between the digits (`42` → `4 2`): generation, not tokenization. Anyone else seeing this?

#58
by buley - opened

TL;DR. Running Muse-Glimmer-30B, asking for an exact string containing a
number reliably returns the digits separated by spaces. 42 comes back as
4 2, a six-hex-char nonce 34ff80 comes back as 3.4. The tokenizer
round-trips those strings byte-exactly, and the model emits an explicit space
token between two digit tokens, so this appears to happen at generation. A
second model on the identical serving path reproduces the same nonces perfectly.
I have not ruled out my own quantization (~4.5 bits/param) and I would like
to know whether anyone sees this at higher precision or on a stock stack.


Setup, stated up front because it matters

I am not running a stock stack, and that is the main reason I am asking rather
than asserting:

  • Model: Muse-Glimmer-30B, 52-layer text decoder, vocab 202,048.
  • Weights: a ~16.7 GB quantized artifact for a ~30B model — call it
    ≈4.46 bits/parameter. Aggressive.
  • Runtime: a custom CPU inference server, not transformers / llama.cpp /
    vLLM. Greedy decode.
  • Tokenizer: the HF tokenizer.json shipped with the model (28,129,897
    bytes), loaded directly.
  • Prompting: the model's own Harmony-lite template
    (<|start|>{role}<|message|>{content}<|eot|>).

So "our runtime is wrong" is a live hypothesis throughout. The evidence below is
me trying to narrow where it goes wrong, not to prove the model is at fault.

The symptom

Asked — through a real tool call, so the string had to survive into a file — to
write exactly MOONSHINE_CERT_ALPHA 42:

expected : b'MOONSHINE_CERT_ALPHA 42'     (23 bytes)
actual   : b'MOONSHINE_CERT_ALPHA 4 2'    (24 bytes)

It is not confined to tool calls. Plain chat, asking it to echo a random nonce:

asked for c03dd370  ->  returned c03dd37     (truncated)
asked for 34ff80    ->  returned 3.4         (mangled)

Same shape every time: a multi-character literal, especially a numeric one, does
not survive.

The tokenizer is not doing it

This was my first suspicion and it does not hold up. The tokenizer encodes
digits as single-character tokens with no separator:

"42"    ->  [32, 30]              two digit tokens, nothing between them
"4 2"   ->  [32, 220, 30]         220 is an explicit space
"6193"  ->  [34, 29, 37, 31]      four single-digit tokens

4 2 differs from 42 by the presence of token 220. That token has to be
emitted. Round-tripping input through tokenize → detokenize is byte-exact,
including c03dd370 → 7 tokens → c03dd370, so decoding is not inserting it
either.

That is what makes me say generation rather than tokenization: the sampler is
choosing a space token in a position where the target string has none.

It then reads its own output back as the spec

This is the part I found most interesting, and it is why the failure is not
merely "one wrong byte."

Given a prompt containing line=雪\t42, the model's very first echo of the
prompt already renders it line=雪<TAB>4 2. It then spends the rest of the
generation arguing with itself about which was intended, quoting its own
corrupted rendering back:

"Could be tab between 雪 and 4? … Or maybe it's line=雪\t42? The formatting
might have collapsed."

Across one 1024-token reply I counted 34 renderings as 4 2 against 16 as
42
, in 33 separate attempts. It never converged, never emitted a tool call,
and burned the entire budget deliberating.

So a generation defect became a comprehension defect: the model is not confused
by the prompt, it is confused by its own transcript. In an agentic loop that is
fatal — it never gets as far as acting.

A control on the identical code path

Same serving stack, same front-end code, same minute, different model
(qwen3-coder-next), same nonce-echo prompt:

qwen3-coder-next     "banana 449939"                  nonce reproduced exactly
muse-glimmer-30b     "…banana and the token 3.4"      34ff80 -> 3.4

That is the observation that moved me from "my stack is broken" toward "this is
model- or quantization-specific." Everything from the HTTP handler down to the
detokenizer is shared between those two runs.

What I have NOT ruled out — genuinely

  1. Quantization. ≈4.46 bits/param is aggressive, and exact-token
    reproduction is plausibly one of the first capabilities to degrade. This is
    my leading hypothesis
    and it is the main thing I am asking about. If you
    are running this model at fp16/bf16 or 8-bit and it echoes nonces cleanly,
    that would be decisive and I would love to hear it.
  2. My runtime. Custom CPU implementation. The Qwen control shares that code,
    which narrows it a lot, but the two models take different arch paths
    internally, so it does not eliminate it.
  3. Sampling. Greedy here. I have not swept temperature/top-p, and I do not
    have a strong prior that it would matter for a defect this systematic — but I
    have not tested it.
  4. Template. I use the model's Harmony-lite template. A subtly wrong render
    could put the model somewhere odd in distribution, though it produces
    perfectly coherent prose otherwise.

What I am asking

  • Does anyone reproduce 424 2 (or nonce mangling) on a stock stack —
    transformers, llama.cpp, vLLM?
  • Does the effect disappear at higher precision? That is the single most
    useful data point anyone could give me.
  • Is there a known digit-tokenization interaction for this vocab that I should
    know about? Single-character digit tokens are common, but the inserted space
    is what I cannot explain.
  • Is anyone else using this model in an agentic setting? Its prose is good
    and it is a strong reviewer/judge in my testing — this specific inability to
    reproduce a literal is what rules it out for tool-calling work in my setup.

Minimal repro

prompt : Reply with only this token and nothing else: c03dd370
expect : c03dd370
got    : c03dd37

prompt : Say the word banana and the token 34ff80
expect : banana 34ff80
got    : …the token 3.4

Anything with 2+ consecutive digits will do. Worth checking the raw token IDs
rather than the decoded string, so you can see whether a space token is actually
being emitted or whether something downstream is inserting it — that distinction
is the whole question.

Happy to run further experiments on my side if someone has a specific hypothesis
worth testing. I would genuinely rather find out this is my quantization than
leave a note like this standing against the model.

Datapoint for your outstanding question, since we run this model in production on a standard GPU stack: vLLM with the NVFP4 quant (bullerwins), RTX 5090, muse reasoning parser, official chat template.

Your exact repro does not reproduce here. MOONSHINE_CERT_ALPHA 42 comes back verbatim on 8 draws, and a denser probe (integers 40 to 49 on one line) comes back clean 8/8, no space ever inside a literal. So at least on this stack the digit-interior spacing is not a property of the weights themselves at 4-bit, which points at something in your chain, my guess would be the harmony-lite template or the sampling/decode loop of the custom server rather than the quant. Worth testing your same weights through llama.cpp with the official template before touching precision.

One adjacent thing we did measure on this lineage, in case it is related to what you're seeing: under grammar-constrained decoding (json_schema through xgrammar), the model has a real tendency to emit whitespace runs between tokens, we measured ~8% of runs on tight-schema JSON paths degenerating into long space sequences before we forced compact JSON at the grammar level. That is spaces BETWEEN elements, never inside a number literal, and it only shows under constrained decode, but it does show the sampler likes the space token more than it should in low-entropy spots. If your server does any form of constrained or biased decoding, that would be my first suspect.

Resolved: it was our stop tokens, not the weights — <|eom|> is a channel boundary

Thank you, @YoRandom . Your reply was the whole ballgame. Not because it named the answer,
but because a credible non-reproduction on a known-good stack turns an open
question into a bounded one. The moment "same weights, NVFP4, vLLM, official
template, muse reasoning parser, 8/8 clean" landed, the search space collapsed
from the model to our chain — and after that it was just walking the chain
until something confessed.

It confessed twice. Both were ours.

What we had reported

muse-glimmer-30b-3 could not reproduce exact multi-digit literals. Asked to
echo 42, it returned 4 2. Asked for 6193, 6 1 9 3. It was consistent, it
survived greedy decode, it survived a fresh box, and it reproduced under a
"control" where a different model on the same fleet returned digits perfectly. We
had it written up as a weights- or quantization-level property.

Bug one: <|eom|> in the stop list

Our stop-token list for this model was:

200008  <|eot|>           end of turn
200001  <|end_of_text|>
200007  <|eom|>           <-- this one

<|eom|> is end of message, not end of turn. It closes the analysis
channel. Immediately after it the model opens

<|start|>assistant to=user<|message|>

and emits the actual answer.

By listing it as a stop we halted generation at the exact moment the model
finished thinking and was about to speak. Everything we ever read back from
this model was its reasoning.
Not a truncated answer — the wrong channel
entirely.

Same prompt, hand-templated, our front door bypassed, asked for 6193:

# with 200007 as a stop
"The user wants me to reply with only this exact value and nothing else: 6 1 9 3"
   ^ generation ends here

# without it
"...So they want the output to be exactly "6193" with no extra text.
   ...Just 6193. Proceed."
<|eom|> <|start|>assistant to=user<|message|> 6193 <|eot|>
                                              ^^^^ contiguous. correct.

The spaced digits appear only where the model spells them out while
reasoning — completely normal prose behaviour once you see it. Its answer was
always right. We were quoting its inner monologue and calling it a defect.

Your xgrammar note is what made this findable, incidentally. "The sampler likes
the space token more than it should in low-entropy spots" made us look at where
in the output
the spacing lived rather than at whether it was there. It lives in
analysis text. That distinction is the whole bug.

Bug two: the trap immediately after the first one

Fixing the stop list made the answer generate, and we still didn't get it:

{"content": "The user asks for a specific value. I should comply with the
             request and output the exact value.assistant to=user6193",
 "reasoning_content": ""}

The answer is right there, contiguous — and served as one blob with the reasoning
and the channel header glued to it.

Our channel splitter was gated on "were tools offered?" as a proxy for "is
this a channel-framed reply". That proxy had been correct, but only as an
accident of bug one: while <|eom|> truncated generation, a plain chat reply
genuinely never contained a header, so the proxy never had to be right for the
right reason. Fix the stop list and the model runs on into assistant to=user on
ordinary chat too — and a mode-based proxy silently stops matching reality.

We now gate on the evidence in the text (does it actually contain a channel
header?) rather than on the shape of the request.

One subtlety worth stealing, because it is why the gate cannot simply be deleted:
our splitter treats everything before the first header as the analysis channel.
So splitting a headerless reply returns content: "" and buries the answer in
reasoning. "Always split" is not the fix; "split when a header is present" is.
We pinned that hazard in a test asserting the empty result, so nobody later reads
the gate as redundant and removes it.

Final state, end to end:

{"content": "6193",
 "reasoning_content": "The user asks for a specific value. I should comply with the request."}

Why our control fooled us

This is the part worth passing on.

We "controlled" for the model by running the same prompt through the same fleet
with a different model, which returned exact digits. That felt airtight. It
wasn't: our front door selects a different tokenizer, chat template, and
stop-token list per model
. The comparison varied four things at once, isolated
none of them, and pointed confidently at the only one that was innocent.

We also had the disconfirming evidence in hand for hours. Responses were coming
back with content: "" and reasoning_content full. We logged it, noticed
it, and filed it as "the model didn't answer." It was the bug stating itself in
plain language: generation had produced reasoning and nothing else, because we
stopped it before anything else could exist.

The generalisable version

If you are running a channel/reasoning model on a custom serving stack:

  1. eom is not eot. Any model that separates analysis from final output
    has a token that closes a message and one that closes a turn. If your
    harness treats the first as a stop, you will silently serve the analysis
    channel as the answer. Nothing errors. It looks like a model quality problem.

  2. Analysis text has different typographic habits than final text. Models
    spell things out while thinking — digits, acronyms, step numbers. So this
    failure mode specifically mimics tokenizer, quantization and sampler
    defects. It is why our greedy-decode check "confirmed" the bug: greedy decode
    faithfully reproduced the reasoning we were wrongly reading.

  3. content empty with reasoning populated is a channel-plumbing symptom,
    not a model refusal. Check it before anything else.

  4. Beware proxies that are only accidentally correct. Our splitter gate
    ("were tools offered") was right for years because a different bug kept
    plain replies headerless. Fixing that bug broke the proxy. If you are
    inferring a property of the output, test the output, not the request.

  5. A vendor's non-reproduction is data, not a rebuttal. It bounds the search
    even when it doesn't name the cause. Yours saved us from filing a wrong bug
    report against a model that was behaving correctly the entire time.

  6. Check the stop list before the weights. It is one line, it is never
    reviewed, and it is upstream of everything you would otherwise suspect. We
    audited the tokenizer, the chat template, the FFN approximation guards, the KV
    cache, the sharded residual transfer and the decode loop — in that order —
    before looking at a three-element array. The comment above it, written by us,
    asserted that those ids "give clean turns."

A 60-second check

Drop the eom id from your stop list, give it room to run, and look at the tail:

curl -s $STATION/generate -H 'content-type: application/json' -d '{
  "tokens": [ ...your templated prompt... ],
  "max_new_tokens": 200,
  "temperature": 0,
  "eos_token_ids": [200008, 200001]
}'

If the tail contains assistant to=user followed by a different, cleaner
rendering of what you asked for, your stop list is eating your answers. Then
check that whatever parses channels downstream actually runs on that reply —
that was our second bug, and it produced a correct answer we still failed to
deliver.

Thanks again for running our repro and for reporting the negative clearly. A
well-documented "does not reproduce here, and here is my exact stack" is worth
more than most positive results and takes considerably more effort to write. We
appreciated it.

buley changed discussion status to closed

Sign up or log in to comment