File size: 5,798 Bytes
be40882 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | """
chat_format.py -- single source of truth for the SFT / inference chat template.
After add_special_tokens.py grew the vocab, the model has ATOMIC tokens for
ChatML framing and reasoning/tool markers. This module builds training tensors
and inference prompts that use those exact tokens, so:
* training (sft_*.py) and inference (infer_sft-chat.py) agree byte-for-byte, and
* <|im_start|>, <|im_end|>, <think>, <begin_solution>, <tool_call>, ... are
each a SINGLE token instead of being split into raw bytes.
Template (one turn):
<|im_start|>{role}\n{content}<|im_end|>\n
Generation starts right after a trailing "<|im_start|>assistant\n".
Loss is computed on assistant content + its closing <|im_end|> only; everything
else (system/user turns, headers) is masked with -100.
"""
from typing import List, Dict, Optional, Tuple
# Canonical atomic markers (must exist in tokenizer.json's special_tokens).
THINK_OPEN, THINK_CLOSE = "<think>", "</think>"
SOL_OPEN, SOL_CLOSE = "<begin_solution>", "<end_solution>"
IM_START, IM_END = "<|im_start|>", "<|im_end|>"
# Legacy / alternate marker spellings seen in older datasets -> canonical atomic
# tokens. Applied to every message's content so whatever convention the data
# uses collapses onto the tokens the model actually has.
MARKER_ALIASES = {
"<|begin_of_thought|>": THINK_OPEN,
"<|end_of_thought|>": THINK_CLOSE,
"<|begin_of_solution|>": SOL_OPEN,
"<|end_of_solution|>": SOL_CLOSE,
# occasional variants
"<thinking>": THINK_OPEN,
"</thinking>": THINK_CLOSE,
}
_ROLE_MAP = {
"human": "user", "user": "user", "prompter": "user",
"gpt": "assistant", "assistant": "assistant", "bot": "assistant", "model": "assistant",
"system": "system",
"tool": "tool", "tool_response": "tool", "observation": "tool", "function": "tool",
}
def normalize_markers(text: str) -> str:
if not text:
return text
for alias, canon in MARKER_ALIASES.items():
if alias in text:
text = text.replace(alias, canon)
return text
def norm_role(msg: Dict) -> str:
raw = msg.get("role") or msg.get("from") or msg.get("speaker") or "user"
return _ROLE_MAP.get(str(raw).strip().lower(), "user")
def msg_content(msg: Dict) -> str:
val = msg.get("content") if "content" in msg else msg.get("value")
if val is None:
val = msg.get("text", "")
return val.strip() if isinstance(val, str) else str(val or "").strip()
def format_chat(history, system_prompt: Optional[str] = None,
add_generation_prompt: bool = True) -> str:
"""Build an inference prompt string from (role, content) pairs (or dicts).
Mirrors tokenize_chatml so inference matches training exactly."""
s = ""
if system_prompt:
s += f"{IM_START}system\n{normalize_markers(system_prompt)}{IM_END}\n"
for item in history:
if isinstance(item, dict):
role, content = norm_role(item), msg_content(item)
else:
role, content = item
content = normalize_markers((content or "").strip())
s += f"{IM_START}{role}\n{content}{IM_END}\n"
if add_generation_prompt:
s += f"{IM_START}assistant\n"
return s
def stop_token_ids(tokenizer) -> List[int]:
"""Token ids that should halt generation: <|im_end|> (primary) + <eos>."""
out = []
try:
imid = tokenizer.convert_tokens_to_ids(IM_END)
if imid is not None and imid >= 0:
out.append(int(imid))
except Exception:
pass
eos = getattr(tokenizer, "eos_token_id", None)
if eos is not None:
out.append(int(eos))
return sorted(set(out))
def tokenize_chatml(messages: List[Dict], tokenizer, max_length: int,
vocab_size: Optional[int] = None,
system: Optional[str] = None,
add_bos: bool = True) -> Optional[Tuple[List[int], List[int]]]:
"""
Turn a message list into (input_ids, labels) with assistant-only loss.
messages: list of dicts with role/from + content/value (any supported alias).
system: optional system prompt injected if the messages have none.
Returns None if there is no trainable assistant content.
"""
if not messages:
return None
enc = lambda t: tokenizer.encode(t, add_special_tokens=False)
bos_id = getattr(tokenizer, "bos_token_id", None)
msgs = list(messages)
if system and not any(norm_role(m) == "system" for m in msgs if isinstance(m, dict)):
msgs = [{"role": "system", "content": system}] + msgs
input_ids, labels = [], []
if add_bos and bos_id is not None:
input_ids.append(bos_id)
labels.append(-100)
trained_any = False
for m in msgs:
if not isinstance(m, dict):
continue
role = norm_role(m)
content = normalize_markers(msg_content(m))
if not content:
continue
header = enc(f"{IM_START}{role}\n")
body = enc(content)
footer = enc(f"{IM_END}\n")
input_ids += header
labels += [-100] * len(header)
if role == "assistant":
input_ids += body + footer
labels += body + footer # train content + closing <|im_end|>
trained_any = trained_any or bool(body)
else:
input_ids += body + footer
labels += [-100] * (len(body) + len(footer))
input_ids = input_ids[:max_length]
labels = labels[:max_length]
if vocab_size is not None:
input_ids = [i if 0 <= i < vocab_size else 0 for i in input_ids]
labels = [i if (i == -100 or 0 <= i < vocab_size) else -100 for i in labels]
if not trained_any or not any(l != -100 for l in labels):
return None
return input_ids, labels
|