Astra's Missing Whitespace

13 min read Original article ↗

When OpenAI released gpt-6-astra, I noticed some strange output in the model-written subagent prompts. Codex encrypts these, but I run a custom harness that doesn’t. The prompts contained long runs of words without any spacing:

Freezeincludesyournewdiagnosticfilewriteswhilebenchmark

The marketing surrounding Astra touted its token efficiency, it seems they sacrificed readability in pursuit of that.

Was that a good price to pay? I want you to guess how much less efficient it is to make this a little bit more readable, just by adding some spaces:

Freeze includes your new diagnostic file writes while benchmark

Write your answer in an envelope!

This article assumes a familiarity regarding “roughly how LLMs work”, requiring knowledge of concepts such as “LLMs ingest and output tokens”. The o200k tokeniser is used for all visualisations.

This article has been updated due to exciting news…

Efficiency

The first rule of optimisation is to “optimise for the real objective”. Proxy objectives are a monkey’s paw, so let’s define what we want.

We want something useful, we want it for low cost (of time and money, but let’s assume throughput is fixed). LLMs are billed per token, so cost can be approximated by that token count.

An LLM does its machinations in its forward passes. The tokens in the context are just the state it maintains for the next pass. You can do more compute in each forward pass, but ultimately the information density of the tokens entering context is a limiting factor.

The vast majority of tokens are ‘internal reasoning state’, unseen by the user. Those tokens should be as information-dense as possible. It all comes down to compute proportional to entropy.

Tokenisers

Astra uses the o200k tokeniser, which is in the ‘Byte-Pair Encoding’ family. They require a pre-tokeniser to break up text into words before the actual frequency based merging operation.

Each caret you see is a 'word' boundary

No token can exceed the interval defined by the pre-tokeniser, so it’s a limiting factor for how many bytes you can stuff into one token. These forced boundary points will be denoted by a caret ^.

The pre-tokeniser is commonly defined by a regex. I want to show you the absolute beauty that’s used in the o200k pre-tokeniser:

[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]*[\p{Ll}\p{Lm}\p{Lo}\p{M}]+(?i:'s|'t|'re|'ve|'m|'ll|'d)?|[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]+[\p{Ll}\p{Lm}\p{Lo}\p{M}]*(?i:'s|'t|'re|'ve|'m|'ll|'d)?|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n/]*|\s*[\r\n]+|\s+(?!\S)|\s+

dusts off the syntax highlighter…

[^
  \r\n\p{L}\p{N}
]?
[
  \p{Lu}
  \p{Lt}
  \p{Lm}
  \p{Lo}
  \p{M}
]*
[
  \p{Ll}
  \p{Lm}
  \p{Lo}
  \p{M}
]+
(?i:
  's|'t|'re|'ve|
  'm|'ll|'d
)?
|
[^
  \r\n\p{L}\p{N}
]?
[
  \p{Lu}
  \p{Lt}
  \p{Lm}
  \p{Lo}
  \p{M}
]+
[
  \p{Ll}
  \p{Lm}
  \p{Lo}
  \p{M}
]*
(?i:
  's|'t|'re|'ve|
  'm|'ll|'d
)?
|
\p{N}{1,3}
|
 ?
[^
  \s\p{L}\p{N}
]+
[\r\n/]*
|
\s*
[\r\n]+
|
\s+
(?!\S)
|
\s+

Yikes. Well you know what they say about eating the elephant, one bounded interval at a time.

First off, the Optional Prefix. It matches one character that isn’t a bunch of things:

It’s kind of poetic that we’re defining something by what it’s not, LLMs do love that…

The Optional Prefix therefore can contain:

  • nothing (it’s optional!)
  • a space or tab:  
  • a symbol: ~ ! @ # $ % ^ & * ( )
  • an emoji: The very well known Seahorse Emoji which absolutely does exist
  • or technically some other esoteric unicode stuff that isn’t relevant.

This means !important can become one token (one leading symbol, run of lowercase letters) but !!important cannot. It will instead be bounded by a run of symbols, then a run of letters.

Next are the Uppercase then Lowercase sections. This is the part that contains the actual letters. The doubling up of the Prefix, Uppercase, Lowercase sections in the regex is to enforce an ordering of upper then lowercase letters without matching on empty segments. We’ll just run through one of them.

[
  \p{Lu}
  \p{Lt}
  \p{Lm}
  \p{Lo}
  \p{M}
]*

[
  \p{Ll}
  \p{Lm}
  \p{Lo}
  \p{M}
]+

This will match on:

  • Uppercase then Lowercase: Hello
  • all Lowercase: world

And the alternative Uppercase, Optional Lowercase will match on:

  • all Uppercase: HELLO

Since the regex match in both cases is Uppercase then Lowercase, the opposite Lowercase to Uppercase transition will always result in a boundary at that case transition.

helloWORLD can never become one token, no matter how often it happens in the training data.

Finally the Optional Contractions:

(?i:
  's|
  't|
  're|
  've|
  'm|
  'll|
  'd
)?

This just allows common contractions like you're to potentially stay as one token.

Overall, the pre-tokeniser forces this behaviour:

  • lowerUPPER will never become one token.
  • longrunsofsamecase can become one token.
  • a leading space can be ‘absorbed’ into a token.
  • mixing_symbols__and___text only absorbs a lone leading symbol, eg !important

There’s nothing in the pre-tokeniser stopping this from becoming one token!

Freezeincludesyournewdiagnosticfilewriteswhilebenchmark

As long as the tokeniser’s training data contained this kind of thing, this could very well be more efficient!

Whitespace

Let’s actually count the tokens.

    
    
         Freeze    includes    your    new    diagn    ostic    file    writes    while    benchmark   
    
    10 tokens·5.5 bytes per token
    
  

Almost one token per word, so that’s pretty good, right?

How much worse is the added whitespace?

Take out your envelopes

    
    
         Freeze    ·includes    ·your    ·new    ·diagnostic    ·file    ·writes    ·while    ·benchmark   
    
    9 tokens·7 bytes per token
    
  

Oh it’s better. Another example:

    
    
         This    co    herent    l    ifecycle    pol    ic    yp    re    ferred    over    fin    item    ode    heur    istic   
    
    16 tokens·3.69 bytes per token
    
  
    
    
         This    ·coherent    ·lifecycle    ·policy    ·preferred    ·over    ·finite    ·mode    ·heuristic   
    
    9 tokens·7.44 bytes per token
    
  

Oh that’s much better. The no-whitespace version breaks the word-token alignment, creating inefficient fragments.

Across a sample of 2,217 inter-agent messages in my sessions, totalling 181,749 tokens, the more readable version had:

  • 6.88% more characters and
  • 4.77% fewer tokens.

In hindsight, that’s not too surprising. Most text has spaces between words, and the tokeniser was trained on a lot of ‘normal text’. The pre-tokeniser explicitly includes leading spaces so that they’re absorbed into words.

Interestingly, Astra doesn’t always delete whitespace. Here it kept the space before ‘deliberately’:

    
    
         extension    line    ·deliberately    not    added    until    RED   
    
    7 tokens·6 bytes per token
    
  

Notice that removing the space affects the token before the space:

    
    
         extension    lined    el    iber    ately    not    added    until    RED   
    
    9 tokens·4.56 bytes per token
    
  

To remove that space, the model would need to emit lined instead of line , deciding to elide the space before it reached the space-containing token.

We know models can do this kind of ‘thinking ahead’, it’s how they’re able to write rhymes. However, Astra seems to have simply learned a cheap, per token decision.

Across the sample, Astra dropped about 70% of spaces removable just by picking the next token without a leading space, whereas it dropped less than 30% of the ones that required that kind of planning.

But they optimised for token efficiency!

Did they optimise for token efficiency?

The first rule of optimisation is “optimise for the real objective”.

If you read the OpenAI online tokeniser playground notes below the text area:

A helpful rule of thumb is that one token generally corresponds to ~4 characters of text for common English text.

If you dig around the codex repo, there are multiple copies of a function like approx_token_count():

const APPROX_BYTES_PER_TOKEN: usize = 4;

pub fn approx_token_count(text: &str) -> usize {
    let len = text.len();
    len.saturating_add(APPROX_BYTES_PER_TOKEN.saturating_sub(1)) / APPROX_BYTES_PER_TOKEN
}

I am pretty certain that this estimate has replaced an actual token count within some part of OpenAI’s RL pipeline.

As a result, demonstrably for ‘inter-agent’ communications, and likely for internal reasoning in general, the model has been optimised to produce fewer bytes instead of fewer tokens. That proxy objective is resulting in exactly the opposite of the effect desired.

Bonus Update - Taking a peek at some internal reasoning

It’s the 21st of October. OpenAI has been publishing Misalignment Reports, and inside them are snippets of internal reasoning. They are missing a lot of whitespace!

    
    
         Need    ·ask    notifications    ·if    send    no    --    ·since    ·no    Slack    ·with    write    ·no    _notify    true    ·prevents    alerts    but    ·disabling    ?    ·parent    ·shouldn't    set    ·no    _notify    true    ·since    ·policy    ·trigger    user    no    ·messages    ·requires    not    pro    active    ·tool    .    ·Artifact    actions    central    autom    atics    ·okay    ·scope    ?    ·Better    ·flag    ·sink    ·emits    ·no    ·direct    ·notifications    ·but    ·separate    central    watch    may    notify    ·if    P    0    ·doesn't    need    ·ourselves    ·send    .    ·User    explicit    nom    essages    ·originally    ·should    keep    n    ore    c    ipient    .    ·Existing    ·default    ·ping    empty    ·enough    central    not    ifier    alerts    ?    ·no    _notify    ·only    ·way    ·prevents    ;    ·but    ·disabling    required    alerts    unsafe    .    ·Parent    ·aware    policy    .    ·Don't    strong    adv    ise    ·no    _notify    .   
    
    112 tokens·5.19 bytes per token
    
  

Looking through these snippets, there are heaps of spaceless runs that can never become one token:

  • noSlack - a lowercase to uppercase transition.
  • doesn'tneed - 'tneed isn’t a contraction in the regex.

If we count their ‘added spaces’ version:

    
    
         Need    ·ask    ·notifications    ·if    ·send    ·no    --    ·since    ·no    ·Slack    ·with    ·write    ·no    _notify    ·true    ·prevents    ·alerts    ·but    ·disabling    ?    ·parent    ·shouldn't    ·set    ·no    _notify    ·true    ·since    ·policy    ·trigger    ·user    ·no    ·messages    ·requires    ·not    ·proactive    ·tool    .    ·Artifact    ·actions    ·central    ·autom    atics    ·okay    ·scope    ?    ·Better    ·flag    ·sink    ·emits    ·no    ·direct    ·notifications    ·but    ·separate    ·central    ·watch    ·may    ·notify    ·if    ·P    0    ·doesn't    ·need    ·ourselves    ·send    .    ·User    ·explicit    ·no    ·messages    ·originally    ·should    ·keep    ·no    ·recipient    .    ·Existing    ·default    ·ping    ·empty    ·enough    ·central    ·notifier    ·alerts    ?    ·no    _notify    ·only    ·way    ·prevents    ;    ·but    ·disabling    ·required    ·alerts    ·unsafe    .    ·Parent    ·aware    ·policy    .    ·Don't    ·strong    ·advise    ·no    _notify    .   
    
    107 tokens·5.79 bytes per token
    
  

That’s 4.46% fewer tokens. Across all the excerpts on that page combined, we can save 5.96% of the reasoning tokens!

I wonder if there is a causal link between the agent misbehaviour and this byte-count pressure?

What is more token efficient?

If you’re willing to trade readability for fewer tokens, removing a space can help in some particular cases. Numbers are in their own regex section and don’t have ‘space prefix’ versions.

    
    
         A    ·cat    ·has    ·    9    ·lives   
    
    6 tokens·2.83 bytes per token
    
  

As a result, A cat has9 lives with no space between has and 9 will be one token more efficient and slightly less readable.

    
    
         A    ·cat    ·has    9    ·lives   
    
    5 tokens·3.2 bytes per token
    
  

The various combinations of spellings and case also create different tokenisations:

  
  
    
      
      
        lowerTitleUPPER
      
    
      US, leading space     ·authorized      ·Authorized      ·AUTH    ORIZED        US, elided space     authorized      Authorized      AUTHORIZED        British, leading space     ·authorised      ·Author    ised      ·AUTHOR    I    SED        British, elided space     author    ised      Author    ised      AUTH    ORI    SED      
  

The model picked this sequence:

    
    
         Main    AUTHORIZED    post   
    
    3 tokens·6 bytes per token
    
  

Which is fewer tokens than:

    
    
         Main    ·AUTH    ORIZED    ·post   
    
    4 tokens·5 bytes per token
    
  

But avoiding upper case and keeping the spaces would have worked just as well:

    
    
         Main    ·authorized    ·post   
    
    3 tokens·6.67 bytes per token
    
  

You could also:

  • make tokens that are ‘forced’ by a grammar essentially free
  • get rid of line numbers or per-line-paths from file reads
  • get rid of line and character number based MCP tooling
  • allow Lark grammars and text calls for MCP tooling
  • give the model the ability to copy and paste
  • write a ‘token efficient’ minifier, have the model ingest and output that, then format it as it’s written to a file
  • make AST based editing standard for coding agents

Repairing an RL run with speculative decoding

It would be a one line fix to use the real tokeniser instead of the estimate for any future training.

-approx_token_count(text)
+actual_token_count(text)

But what about the expensive RL runs that are already contaminated?

If we can recover the well spaced text, we can do a little bit of training to move the model back on track. We can do that cheaply by taking inspiration from speculative decoding! First we split our text by whitespace and find the characteristic long runs:

Freezeincludesyournewdiagnosticfilewriteswhilebenchmark

We then perform parallel passes of ‘prefill’ pf-over the text prefixes and extract the log probabilities of every possible continuation token. There are several choices for what prefix context can be used before the long run, but it worked well enough with nothing that I stuck with that.

I’m using gpt-oss-20b here because it’s tiny, uses the o200k tokeniser, and is smart enough to know where spaces go between words.

We maintain a baseline set of tokens, starting with the original model’s version. Spaces can go between any pair of characters. We compare each candidate’s first token against the overlapping tokens in the baseline. Any candidates that beat the baseline continue. We can cache prefixes and do tree attention for some efficiency gains (eg the in is shared between multiple suffixes). For this example there are 54 candidates, this process culls that to 26.

  
  
    
           Freeze      in    ·cl    udes       ✓          Freeze      in    clud    ·es       ✓          Freeze      ·includes       ✓          Freeze      includes      ·your       ✓          Freeze      includes      your      ·new       ✓          Freeze      includes      you    ·r       ✓          Freeze      includes      your      new       -          Freeze      include    ·sy    our       ×          Freeze      includes      your      ne    ·w       ×          Freeze      includes      yo    ·urn    ew       ×   
  
  

Some winning candidates change the subsequent tokenisation of the characters after them (like in ), so we have to run another prefill pass to collect their log probs.

A high probability first token doesn’t guarantee a winning sequence. in is more likely than ·includes , but when we add in the clud ·es continuation, it loses.

Some of the winning candidates are a single token (like ·includes ) and can be compared via the cached log probability.

  
  
    
           Freeze      in    ·cl    udes       ×          Freeze      in    clud    ·es       ×          Freeze      ·includes       →          Freeze      includes      ·your       →          Freeze      includes      your      ·new       →          Freeze      includes      you    ·r       ×          Freeze      includes      your      new       -   
  
  

We take the winning ‘edits’ to form the next baseline , and rerun from the first changed token. Over 4 rounds of this, 8 total prefill passes, we recover the correct spacing.

8 prefill passes for 9 words is pretty bad. It demonstrates just how out of distribution this text is compared to what the model expects.

We can make our ‘first draft’ more likely with some cheap heuristics. A dictionary ranked by word frequency lets us ‘fit’ the sequence against actual space-prefixed words with a bit of dynamic programming. This gets us 90% of the way by ‘spaces count’, except it has trouble with compound words (in take vs intake) and plurals (events can vs event scan).

Much better. In this case, the dictionary draft was 100% correct, so we check the continuations and are done in 2 passes instead of 8. That’s the trick with speculative decoding, if you predict correctly, it’s faster!

OpenAI runs actual speculative decoding to increase the throughput of their models. Even if that spec decode model is distilled from the main one, I bet there are second-order efficiencies lost by this being so out of distribution.

Now that we have the correct token sequence, we can do the minimum amount of post-training to fix this. Anchor the token distributions we’re not touching with a KL penalty, then do the smallest push to flip the tokens at each prefix.

  
  
    
          message    ":"    Freeze    includes    your    new           message    ":"    Freeze    ·includes    ·your    ·new     
  
  

Back on track.

untilnexttime

If you would like further reading on this topic, I recommend:

Discuss on Hacker News.