@@ -160,7 +160,12 @@ def auto_fix_pad_token(
160160 pad_token_id = getattr (self .tokenizer , "pad_token_id" , None )
161161 if pad_token_id is not None :
162162 if getattr (self .tokenizer , "pad_token" , None ) is None :
163- self .tokenizer .pad_token = self .tokenizer .decode ([pad_token_id ])
163+ # Do not round-trip special token ids through decode().
164+ # decode() returns rendered text, not the canonical token literal,
165+ # and some custom tokenizers intentionally inject formatting.
166+ # ERNIE 4.5 is the concrete regression here: decode([0]) returns
167+ # " <unk>" while the real special token is "<unk>".
168+ self .tokenizer .pad_token = self ._token_literal_for_id (pad_token_id )
164169 return
165170
166171 if not strict and getattr (self .tokenizer , "eos_token_id" , None ) is not None :
@@ -208,7 +213,10 @@ def auto_fix_pad_token(
208213 )
209214
210215 self .tokenizer .pad_token_id = pad_token_id
211- self .tokenizer .pad_token = self .tokenizer .decode ([pad_token_id ])
216+ # Preserve the canonical special-token spelling instead of using
217+ # decode([pad_token_id]). For tokenizers like ERNIE 4.5, decode() is a
218+ # detokenization API and may prepend whitespace to special tokens.
219+ self .tokenizer .pad_token = self ._token_literal_for_id (pad_token_id )
212220
213221 if getattr (model_config , "pad_token_id" , None ) is None :
214222 # Keep the resolved text config aligned so downstream callers do
@@ -217,6 +225,39 @@ def auto_fix_pad_token(
217225
218226 logger .info (f"Tokenicer: Auto fixed pad_token_id={ pad_token_id } (token='{ self .tokenizer .pad_token } ')." )
219227
228+ def _token_literal_for_id (self , token_id : int ) -> str :
229+ # Prefer declared special-token metadata over decode(). The metadata is
230+ # the canonical token literal used by save/load, while decode() is a
231+ # text-rendering path and may be lossy for standalone special tokens.
232+ special_token_attrs = (
233+ "pad_token" ,
234+ "unk_token" ,
235+ "eos_token" ,
236+ "bos_token" ,
237+ "cls_token" ,
238+ "sep_token" ,
239+ "mask_token" ,
240+ )
241+
242+ for attr in special_token_attrs :
243+ attr_id = getattr (self .tokenizer , f"{ attr } _id" , None )
244+ attr_value = getattr (self .tokenizer , attr , None )
245+ if attr_id == token_id and attr_value is not None :
246+ return attr_value
247+
248+ special_tokens_map = getattr (self .tokenizer , "special_tokens_map" , {}) or {}
249+ for attr_value in special_tokens_map .values ():
250+ if isinstance (attr_value , list ):
251+ for item in attr_value :
252+ if isinstance (item , str ) and self .tokenizer .convert_tokens_to_ids (item ) == token_id :
253+ return item
254+ elif isinstance (attr_value , str ) and self .tokenizer .convert_tokens_to_ids (attr_value ) == token_id :
255+ return attr_value
256+
257+ # Final fallback for tokenizers that do not expose enough special-token
258+ # metadata to recover the literal directly.
259+ return self .tokenizer .decode ([token_id ])
260+
220261 @staticmethod
221262 def _resolve_text_model_config (model_config , seen = None ):
222263 if model_config is None :
0 commit comments