ecreeth commited on
Commit
1d176f6
·
verified ·
1 Parent(s): 90ac948

Upload chat_cli.py

Browse files
Files changed (1) hide show
  1. chat_cli.py +270 -304
chat_cli.py CHANGED
@@ -1,304 +1,270 @@
1
- """Chat with the SFT'd microgpt model. Adapted from karpathy/nanochat's chat_cli.py.
2
-
3
- Builds chat-format prompts using our 5 special tokens:
4
- <|bos|> <|user_start|> ...user text... <|user_end|>
5
- <|assistant_start|> ...assistant text... <|assistant_end|>
6
-
7
- Conversation history is maintained across turns (each new user message is appended
8
- to the running token stream). Generation stops on <|assistant_end|>.
9
-
10
- Usage:
11
- python3 chat_cli.py # interactive REPL
12
- python3 chat_cli.py -p "Who are you?" # one-shot
13
- python3 chat_cli.py --ckpt model.pt # explicit checkpoint path
14
- python3 chat_cli.py -t 0.6 -k 50 # nanochat-CLI defaults
15
- python3 chat_cli.py --no-history # reset per turn (no memory)
16
-
17
- Defaults follow nanochat-CLI (T=0.6, top-k=50). Lower temperature than infer.py's
18
- default 0.8 because chat is meant to be focused, not exploratory.
19
-
20
- REPL commands:
21
- quit / exit end the session
22
- clear reset conversation history
23
- """
24
- import argparse
25
- import os
26
- import sys
27
- import time
28
-
29
- import torch
30
- from tokenizers import Tokenizer
31
-
32
- from model import GPT
33
- from infer import _sample_token_batch # reuse our sampler (rep penalty + nucleus + top-k)
34
-
35
-
36
- DEFAULT_CKPT_DIR = os.environ.get('CHECKPOINT_DIR', '.')
37
- # Prefer model_sft.pt (local dev convention with both base + SFT present), fall
38
- # back to model.pt (HF convention — only the SFT'd model is uploaded as model.pt).
39
- DEFAULT_CKPT = os.path.join(DEFAULT_CKPT_DIR, 'model_sft.pt')
40
- DEFAULT_TOKENIZER = 'tokenizer.json'
41
-
42
- SPECIAL_NAMES = [
43
- "<|bos|>",
44
- "<|user_start|>", "<|user_end|>",
45
- "<|assistant_start|>", "<|assistant_end|>",
46
- ]
47
-
48
- device = 'cuda' if torch.cuda.is_available() else ('mps' if torch.backends.mps.is_available() else 'cpu')
49
-
50
-
51
- def _load(ckpt_path, tokenizer_path, compile_step=False):
52
- """Resolve checkpoint (with fallback), load model + tokenizer + special token IDs.
53
-
54
- If `compile_step=True`, torch.compile the inner-loop `model.step` (B=1, T=1 fixed
55
- shapes — perfect for compile). We deliberately don't compile `forward_with_states`
56
- because chat conversations grow each turn → dynamic prompt length → recompile
57
- every turn. Prefill stays eager.
58
-
59
- Compile warmup takes 10–30s on first generation; pays off for any conversation
60
- long enough to generate >~50 tokens. On MPS, torch.compile is less mature than
61
- CUDA — try it but don't be surprised if it falls back to eager.
62
- """
63
- if not os.path.exists(ckpt_path):
64
- # Silent fallback for HF layout (only model.pt = the SFT'd one)
65
- fallback = os.path.join(DEFAULT_CKPT_DIR, 'model.pt')
66
- if ckpt_path != fallback and os.path.exists(fallback):
67
- ckpt_path = fallback
68
- else:
69
- sys.exit(f"error: no checkpoint at {ckpt_path}")
70
- if not os.path.exists(tokenizer_path):
71
- sys.exit(f"error: no tokenizer at {tokenizer_path}")
72
-
73
- tokenizer = Tokenizer.from_file(tokenizer_path)
74
- vocab_size = tokenizer.get_vocab_size()
75
-
76
- ckpt = torch.load(ckpt_path, map_location=device)
77
- config = dict(ckpt['config'])
78
- config['vocab_size'] = ((vocab_size + 63) // 64) * 64
79
- model = GPT.from_config(config).to(device)
80
- state = {k.removeprefix('_orig_mod.'): v for k, v in ckpt['model'].items()}
81
- missing, unexpected = model.load_state_dict(state, strict=False)
82
- if missing: print(f"warn: missing keys: {missing}", file=sys.stderr)
83
- if unexpected: print(f"warn: unexpected keys: {unexpected}", file=sys.stderr)
84
- model.eval()
85
-
86
- specials = {}
87
- for name in SPECIAL_NAMES:
88
- tid = tokenizer.token_to_id(name)
89
- if tid is None:
90
- sys.exit(f"error: tokenizer is missing special token {name} — was it retrained without the SFT vocab?")
91
- specials[name] = tid
92
-
93
- if compile_step:
94
- print("compiling model.step (hot inner-loop path)...", file=sys.stderr)
95
- if device == 'mps':
96
- print(" note: torch.compile on MPS may fall back to eager. CUDA gets the full speedup.",
97
- file=sys.stderr)
98
- compile_kwargs = {'dynamic': False}
99
- # reduce-overhead enables CUDA graphs — big win on Ampere+ where kernel-launch
100
- # overhead dominates the small per-token forward. No-op on MPS/CPU.
101
- if device == 'cuda' and torch.cuda.get_device_capability() >= (8, 0):
102
- compile_kwargs['mode'] = 'reduce-overhead'
103
- model.step = torch.compile(model.step, **compile_kwargs)
104
-
105
- return model, tokenizer, specials, ckpt, ckpt_path
106
-
107
-
108
- @torch.no_grad()
109
- def _generate_one_turn(model, tokenizer, conversation_tokens, specials,
110
- max_new_tokens, temperature, top_k, top_p, repetition_penalty,
111
- stream, cache):
112
- """Run prefill over the NEW tokens since `cache['pos']` (using cached state),
113
- then sample tokens until <|assistant_end|> (or <|bos|>, or max_new_tokens).
114
- Returns the response token list. Mutates `cache` to extend coverage to the
115
- end of conversation_tokens + the assistant_end that will be appended.
116
-
117
- `cache` is a dict { 'pos': int, 'states': list[(B,M,D)] | None, 'seen_mask': bool tensor | None }.
118
- On first call, `cache['states']` is None — we run prefill from scratch.
119
- On subsequent calls, only the new tokens since `cache['pos']` are prefilled,
120
- starting from `cache['states']`. Big speedup once the conversation grows.
121
- """
122
- bos = specials["<|bos|>"]
123
- asst_end = specials["<|assistant_end|>"]
124
- V = model.lm_head.weight.size(0) # padded vocab
125
-
126
- # Slice off only the new tokens to prefill
127
- new_tokens = conversation_tokens[cache['pos']:]
128
- assert len(new_tokens) > 0, "no new tokens to prefill — cache invariant broken"
129
- new_ctx = torch.tensor([new_tokens], device=device) # (1, T_new)
130
-
131
- # Initialize / extend the seen_mask (used for repetition penalty)
132
- if repetition_penalty != 1.0:
133
- if cache.get('seen_mask') is None:
134
- cache['seen_mask'] = torch.zeros(1, V, dtype=torch.bool, device=device)
135
- cache['seen_mask'].scatter_(1, new_ctx, True)
136
- seen_mask = cache.get('seen_mask')
137
-
138
- # Prefill only the new tokens, starting from cached states (or zeros on first turn)
139
- logits, states = model.forward_with_states(new_ctx, initial_states=cache['states'])
140
- next_logits = logits[:, -1, :].clone()
141
-
142
- response = []
143
- for _ in range(max_new_tokens):
144
- next_tok = _sample_token_batch(next_logits, temperature, top_k, top_p,
145
- repetition_penalty, seen_mask) # (1,)
146
- tid = next_tok.item()
147
- if tid == asst_end or tid == bos:
148
- break
149
- response.append(tid)
150
- if stream:
151
- # Per-token decode is fine for ASCII English (the ClimbMix corpus dominates that).
152
- piece = tokenizer.decode([tid])
153
- sys.stdout.write(piece)
154
- sys.stdout.flush()
155
- if seen_mask is not None:
156
- seen_mask.scatter_(1, next_tok.unsqueeze(1), True)
157
- step_logits, states = model.step(next_tok.view(1, 1), states)
158
- next_logits = step_logits[:, 0, :]
159
-
160
- if stream:
161
- print()
162
-
163
- # Advance state through assistant_end so the cache reflects the closed turn.
164
- # (Generation stopped at either an emitted assistant_end, BOS, or max_tokens;
165
- # in the latter two cases we still need to step state through assistant_end.)
166
- asst_end_tok = torch.tensor([[asst_end]], device=device)
167
- _, states = model.step(asst_end_tok, states)
168
- if seen_mask is not None:
169
- seen_mask.scatter_(1, asst_end_tok, True)
170
-
171
- # Update cache: state now reflects [prev cache] + new prefill tokens + generated response + assistant_end
172
- cache['states'] = states
173
- cache['pos'] = len(conversation_tokens) + len(response) + 1 # +1 for assistant_end
174
-
175
- return response
176
-
177
-
178
- def main():
179
- p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
180
- p.add_argument('-p', '--prompt', type=str, default='',
181
- help='single-turn prompt — exits after one response. Empty = interactive REPL.')
182
- p.add_argument('-t', '--temperature', type=float, default=0.6,
183
- help='softmax temperature; 0 = greedy. nanochat-CLI default 0.6 — '
184
- 'tighter than infer.py because chat should be focused.')
185
- p.add_argument('-k', '--top-k', type=int, default=50,
186
- help='top-k sampling; 0 disables. Default 50 (nanochat-CLI).')
187
- p.add_argument('--top-p', type=float, default=1.0,
188
- help='nucleus sampling threshold; 1.0 disables. Try 0.9 for varied responses.')
189
- p.add_argument('-r', '--repetition-penalty', type=float, default=1.15,
190
- help='CTRL-style repetition penalty (default 1.15) keeps chat responses '
191
- 'from looping. Set to 1.0 for raw sampling.')
192
- p.add_argument('-n', '--max-tokens', type=int, default=256,
193
- help='max tokens per assistant response')
194
- p.add_argument('--ckpt', type=str, default=DEFAULT_CKPT,
195
- help='checkpoint path (default $CHECKPOINT_DIR/model.pt)')
196
- p.add_argument('--tokenizer', type=str, default=DEFAULT_TOKENIZER)
197
- p.add_argument('--no-history', action='store_true',
198
- help='reset conversation history before each turn (model has no memory)')
199
- p.add_argument('--seed', type=int, default=None)
200
- p.add_argument('--no-stream', action='store_true',
201
- help='print full response at end instead of token-by-token')
202
- p.add_argument('--compile', action='store_true',
203
- help='torch.compile the inner step() path. First generation pays '
204
- '~10–30s warmup; subsequent generations are 2–5× faster on CUDA. '
205
- 'Best for long REPL sessions; skip for one-shots.')
206
- args = p.parse_args()
207
-
208
- if args.seed is not None:
209
- torch.manual_seed(args.seed)
210
-
211
- print(f"device: {device}", file=sys.stderr)
212
- model, tokenizer, specials, ckpt, used_ckpt = _load(args.ckpt, args.tokenizer,
213
- compile_step=args.compile)
214
- step = ckpt.get('step', '?')
215
- best = ckpt.get('best_loss')
216
- n_params = sum(t.numel() for t in model.parameters())
217
- best_str = f" best_loss={best:.4f}" if isinstance(best, float) else ""
218
- print(f"loaded {used_ckpt} step={step}{best_str} params={n_params:,}", file=sys.stderr)
219
-
220
- bos = specials["<|bos|>"]
221
- user_s = specials["<|user_start|>"]
222
- user_e = specials["<|user_end|>"]
223
- asst_s = specials["<|assistant_start|>"]
224
- asst_e = specials["<|assistant_end|>"]
225
-
226
- print()
227
- print("Mnemo chat mode")
228
- print("-" * 50)
229
- print(f"sampling: T={args.temperature} top_k={args.top_k} top_p={args.top_p} rep_penalty={args.repetition_penalty}")
230
- if not args.prompt:
231
- print("commands: 'quit' / 'exit' to end, 'clear' to reset history")
232
- print("-" * 50)
233
-
234
- conversation_tokens = [bos]
235
- # State cache: skips re-prefilling already-processed turns.
236
- # 'pos' = how far into conversation_tokens the cached state covers.
237
- # 'states' = per-layer (B,M,D) states, None on cold start.
238
- # 'seen_mask' = (1,V) bool over the vocab for repetition penalty.
239
- cache = {'pos': 0, 'states': None, 'seen_mask': None}
240
-
241
- while True:
242
- if args.prompt:
243
- user_input = args.prompt
244
- else:
245
- try:
246
- user_input = input("\nUser: ").strip()
247
- except (EOFError, KeyboardInterrupt):
248
- print("\nGoodbye!")
249
- break
250
-
251
- if user_input.lower() in ('quit', 'exit'):
252
- print("Goodbye!")
253
- break
254
- if user_input.lower() == 'clear':
255
- conversation_tokens = [bos]
256
- cache = {'pos': 0, 'states': None, 'seen_mask': None}
257
- print("Conversation cleared.")
258
- continue
259
- if not user_input:
260
- continue
261
-
262
- if args.no_history:
263
- conversation_tokens = [bos]
264
- cache = {'pos': 0, 'states': None, 'seen_mask': None}
265
-
266
- # Append user turn
267
- conversation_tokens.append(user_s)
268
- conversation_tokens.extend(tokenizer.encode(user_input).ids)
269
- conversation_tokens.append(user_e)
270
- # Open assistant turn (the model continues from here)
271
- conversation_tokens.append(asst_s)
272
-
273
- if not args.no_stream:
274
- sys.stdout.write("\nAssistant: ")
275
- sys.stdout.flush()
276
- t0 = time.time()
277
- response = _generate_one_turn(
278
- model, tokenizer, conversation_tokens, specials,
279
- max_new_tokens=args.max_tokens,
280
- temperature=args.temperature,
281
- top_k=args.top_k,
282
- top_p=args.top_p,
283
- repetition_penalty=args.repetition_penalty,
284
- stream=not args.no_stream,
285
- cache=cache,
286
- )
287
- elapsed = time.time() - t0
288
-
289
- if args.no_stream:
290
- print(f"\nAssistant: {tokenizer.decode(response)}")
291
- n_resp = len(response)
292
- print(f" [{n_resp} tok in {elapsed:.1f}s = {n_resp/max(elapsed, 1e-9):.1f} tok/s]",
293
- file=sys.stderr)
294
-
295
- # Close assistant turn in the history (so the next prefill sees a complete turn)
296
- conversation_tokens.extend(response)
297
- conversation_tokens.append(asst_e)
298
-
299
- if args.prompt:
300
- break
301
-
302
-
303
- if __name__ == '__main__':
304
- main()
 
1
+ """Chat with the SFT'd microgpt model. Adapted from karpathy/nanochat's chat_cli.py.
2
+
3
+ Builds chat-format prompts using our 5 special tokens:
4
+ <|bos|> <|user_start|> ...user text... <|user_end|>
5
+ <|assistant_start|> ...assistant text... <|assistant_end|>
6
+
7
+ Conversation history is maintained across turns (each new user message is appended
8
+ to the running token stream). Generation stops on <|assistant_end|>.
9
+
10
+ Usage:
11
+ python3 chat_cli.py # interactive REPL
12
+ python3 chat_cli.py -p "Who are you?" # one-shot
13
+ python3 chat_cli.py --ckpt model.pt # explicit checkpoint path
14
+ python3 chat_cli.py -t 0.6 -k 50 # nanochat-CLI defaults
15
+ python3 chat_cli.py --no-history # reset per turn (no memory)
16
+
17
+ Defaults follow nanochat-CLI (T=0.6, top-k=50). Lower temperature than infer.py's
18
+ default 0.8 because chat is meant to be focused, not exploratory.
19
+
20
+ REPL commands:
21
+ quit / exit end the session
22
+ clear reset conversation history
23
+ """
24
+ import argparse
25
+ import os
26
+ import sys
27
+ import time
28
+
29
+ import torch
30
+ from tokenizers import Tokenizer
31
+
32
+ from model import GPT
33
+ from infer import _sample_token_batch # reuse our sampler (rep penalty + nucleus + top-k)
34
+
35
+
36
+ DEFAULT_CKPT_DIR = os.environ.get('CHECKPOINT_DIR', '.')
37
+ # Prefer model_sft.pt (local dev convention with both base + SFT present), fall
38
+ # back to model.pt (HF convention — only the SFT'd model is uploaded as model.pt).
39
+ DEFAULT_CKPT = os.path.join(DEFAULT_CKPT_DIR, 'model_sft.pt')
40
+ DEFAULT_TOKENIZER = 'tokenizer.json'
41
+
42
+ SPECIAL_NAMES = [
43
+ "<|bos|>",
44
+ "<|user_start|>", "<|user_end|>",
45
+ "<|assistant_start|>", "<|assistant_end|>",
46
+ ]
47
+
48
+ device = 'cuda' if torch.cuda.is_available() else ('mps' if torch.backends.mps.is_available() else 'cpu')
49
+
50
+
51
+ def _load(ckpt_path, tokenizer_path, compile_step=False):
52
+ """Resolve checkpoint (with fallback), load model + tokenizer + special token IDs.
53
+
54
+ If `compile_step=True`, torch.compile the inner-loop `model.step` (B=1, T=1 fixed
55
+ shapes — perfect for compile). We deliberately don't compile `forward_with_states`
56
+ because chat conversations grow each turn → dynamic prompt length → recompile
57
+ every turn. Prefill stays eager.
58
+
59
+ Compile warmup takes 10–30s on first generation; pays off for any conversation
60
+ long enough to generate >~50 tokens. On MPS, torch.compile is less mature than
61
+ CUDA — try it but don't be surprised if it falls back to eager.
62
+ """
63
+ if not os.path.exists(ckpt_path):
64
+ # Silent fallback for HF layout (only model.pt = the SFT'd one)
65
+ fallback = os.path.join(DEFAULT_CKPT_DIR, 'model.pt')
66
+ if ckpt_path != fallback and os.path.exists(fallback):
67
+ ckpt_path = fallback
68
+ else:
69
+ sys.exit(f"error: no checkpoint at {ckpt_path}")
70
+ if not os.path.exists(tokenizer_path):
71
+ sys.exit(f"error: no tokenizer at {tokenizer_path}")
72
+
73
+ tokenizer = Tokenizer.from_file(tokenizer_path)
74
+ vocab_size = tokenizer.get_vocab_size()
75
+
76
+ ckpt = torch.load(ckpt_path, map_location=device)
77
+ config = dict(ckpt['config'])
78
+ config['vocab_size'] = ((vocab_size + 63) // 64) * 64
79
+ model = GPT.from_config(config).to(device)
80
+ state = {k.removeprefix('_orig_mod.'): v for k, v in ckpt['model'].items()}
81
+ missing, unexpected = model.load_state_dict(state, strict=False)
82
+ if missing: print(f"warn: missing keys: {missing}", file=sys.stderr)
83
+ if unexpected: print(f"warn: unexpected keys: {unexpected}", file=sys.stderr)
84
+ model.eval()
85
+
86
+ specials = {}
87
+ for name in SPECIAL_NAMES:
88
+ tid = tokenizer.token_to_id(name)
89
+ if tid is None:
90
+ sys.exit(f"error: tokenizer is missing special token {name} — was it retrained without the SFT vocab?")
91
+ specials[name] = tid
92
+
93
+ if compile_step:
94
+ print("compiling model.step (hot inner-loop path)...", file=sys.stderr)
95
+ if device == 'mps':
96
+ print(" note: torch.compile on MPS may fall back to eager. CUDA gets the full speedup.",
97
+ file=sys.stderr)
98
+ compile_kwargs = {'dynamic': False}
99
+ # reduce-overhead enables CUDA graphs — big win on Ampere+ where kernel-launch
100
+ # overhead dominates the small per-token forward. No-op on MPS/CPU.
101
+ if device == 'cuda' and torch.cuda.get_device_capability() >= (8, 0):
102
+ compile_kwargs['mode'] = 'reduce-overhead'
103
+ model.step = torch.compile(model.step, **compile_kwargs)
104
+
105
+ return model, tokenizer, specials, ckpt, ckpt_path
106
+
107
+
108
+ @torch.no_grad()
109
+ def _generate_one_turn(model, tokenizer, conversation_tokens, specials,
110
+ max_new_tokens, temperature, top_k, top_p, repetition_penalty,
111
+ stream):
112
+ """Run prefill over the full conversation, then sample tokens until
113
+ <|assistant_end|> (or <|bos|>, or max_new_tokens). Returns the response token list."""
114
+ bos = specials["<|bos|>"]
115
+ asst_end = specials["<|assistant_end|>"]
116
+
117
+ ctx = torch.tensor([conversation_tokens], device=device) # (1, T)
118
+ V = model.lm_head.weight.size(0) # padded vocab
119
+
120
+ seen_mask = None
121
+ if repetition_penalty != 1.0:
122
+ seen_mask = torch.zeros(1, V, dtype=torch.bool, device=device)
123
+ seen_mask.scatter_(1, ctx, True)
124
+
125
+ # Prefill the full conversation in one pass
126
+ logits, states = model.forward_with_states(ctx)
127
+ next_logits = logits[:, -1, :].clone()
128
+
129
+ response = []
130
+ for _ in range(max_new_tokens):
131
+ next_tok = _sample_token_batch(next_logits, temperature, top_k, top_p,
132
+ repetition_penalty, seen_mask) # (1,)
133
+ tid = next_tok.item()
134
+ if tid == asst_end or tid == bos:
135
+ break
136
+ response.append(tid)
137
+ if stream:
138
+ # Per-token decode is fine for ASCII English (the ClimbMix corpus dominates that).
139
+ piece = tokenizer.decode([tid])
140
+ sys.stdout.write(piece)
141
+ sys.stdout.flush()
142
+ if seen_mask is not None:
143
+ seen_mask.scatter_(1, next_tok.unsqueeze(1), True)
144
+ step_logits, states = model.step(next_tok.view(1, 1), states)
145
+ next_logits = step_logits[:, 0, :]
146
+
147
+ if stream:
148
+ print()
149
+ return response
150
+
151
+
152
+ def main():
153
+ p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
154
+ p.add_argument('-p', '--prompt', type=str, default='',
155
+ help='single-turn prompt exits after one response. Empty = interactive REPL.')
156
+ p.add_argument('-t', '--temperature', type=float, default=0.6,
157
+ help='softmax temperature; 0 = greedy. nanochat-CLI default 0.6 '
158
+ 'tighter than infer.py because chat should be focused.')
159
+ p.add_argument('-k', '--top-k', type=int, default=50,
160
+ help='top-k sampling; 0 disables. Default 50 (nanochat-CLI).')
161
+ p.add_argument('--top-p', type=float, default=1.0,
162
+ help='nucleus sampling threshold; 1.0 disables. Try 0.9 for varied responses.')
163
+ p.add_argument('-r', '--repetition-penalty', type=float, default=1.15,
164
+ help='CTRL-style repetition penalty (default 1.15) keeps chat responses '
165
+ 'from looping. Set to 1.0 for raw sampling.')
166
+ p.add_argument('-n', '--max-tokens', type=int, default=256,
167
+ help='max tokens per assistant response')
168
+ p.add_argument('--ckpt', type=str, default=DEFAULT_CKPT,
169
+ help='checkpoint path (default $CHECKPOINT_DIR/model.pt)')
170
+ p.add_argument('--tokenizer', type=str, default=DEFAULT_TOKENIZER)
171
+ p.add_argument('--no-history', action='store_true',
172
+ help='reset conversation history before each turn (model has no memory)')
173
+ p.add_argument('--seed', type=int, default=None)
174
+ p.add_argument('--no-stream', action='store_true',
175
+ help='print full response at end instead of token-by-token')
176
+ p.add_argument('--compile', action='store_true',
177
+ help='torch.compile the inner step() path. First generation pays '
178
+ '~10–30s warmup; subsequent generations are 2–5× faster on CUDA. '
179
+ 'Best for long REPL sessions; skip for one-shots.')
180
+ args = p.parse_args()
181
+
182
+ if args.seed is not None:
183
+ torch.manual_seed(args.seed)
184
+
185
+ print(f"device: {device}", file=sys.stderr)
186
+ model, tokenizer, specials, ckpt, used_ckpt = _load(args.ckpt, args.tokenizer,
187
+ compile_step=args.compile)
188
+ step = ckpt.get('step', '?')
189
+ best = ckpt.get('best_loss')
190
+ n_params = sum(t.numel() for t in model.parameters())
191
+ best_str = f" best_loss={best:.4f}" if isinstance(best, float) else ""
192
+ print(f"loaded {used_ckpt} step={step}{best_str} params={n_params:,}", file=sys.stderr)
193
+
194
+ bos = specials["<|bos|>"]
195
+ user_s = specials["<|user_start|>"]
196
+ user_e = specials["<|user_end|>"]
197
+ asst_s = specials["<|assistant_start|>"]
198
+ asst_e = specials["<|assistant_end|>"]
199
+
200
+ print()
201
+ print("Mnemo chat mode")
202
+ print("-" * 50)
203
+ print(f"sampling: T={args.temperature} top_k={args.top_k} top_p={args.top_p} rep_penalty={args.repetition_penalty}")
204
+ if not args.prompt:
205
+ print("commands: 'quit' / 'exit' to end, 'clear' to reset history")
206
+ print("-" * 50)
207
+
208
+ conversation_tokens = [bos]
209
+
210
+ while True:
211
+ if args.prompt:
212
+ user_input = args.prompt
213
+ else:
214
+ try:
215
+ user_input = input("\nUser: ").strip()
216
+ except (EOFError, KeyboardInterrupt):
217
+ print("\nGoodbye!")
218
+ break
219
+
220
+ if user_input.lower() in ('quit', 'exit'):
221
+ print("Goodbye!")
222
+ break
223
+ if user_input.lower() == 'clear':
224
+ conversation_tokens = [bos]
225
+ print("Conversation cleared.")
226
+ continue
227
+ if not user_input:
228
+ continue
229
+
230
+ if args.no_history:
231
+ conversation_tokens = [bos]
232
+
233
+ # Append user turn
234
+ conversation_tokens.append(user_s)
235
+ conversation_tokens.extend(tokenizer.encode(user_input).ids)
236
+ conversation_tokens.append(user_e)
237
+ # Open assistant turn (the model continues from here)
238
+ conversation_tokens.append(asst_s)
239
+
240
+ if not args.no_stream:
241
+ sys.stdout.write("\nAssistant: ")
242
+ sys.stdout.flush()
243
+ t0 = time.time()
244
+ response = _generate_one_turn(
245
+ model, tokenizer, conversation_tokens, specials,
246
+ max_new_tokens=args.max_tokens,
247
+ temperature=args.temperature,
248
+ top_k=args.top_k,
249
+ top_p=args.top_p,
250
+ repetition_penalty=args.repetition_penalty,
251
+ stream=not args.no_stream,
252
+ )
253
+ elapsed = time.time() - t0
254
+
255
+ if args.no_stream:
256
+ print(f"\nAssistant: {tokenizer.decode(response)}")
257
+ n_resp = len(response)
258
+ print(f" [{n_resp} tok in {elapsed:.1f}s = {n_resp/max(elapsed, 1e-9):.1f} tok/s]",
259
+ file=sys.stderr)
260
+
261
+ # Close assistant turn in the history (so the next prefill sees a complete turn)
262
+ conversation_tokens.extend(response)
263
+ conversation_tokens.append(asst_e)
264
+
265
+ if args.prompt:
266
+ break
267
+
268
+
269
+ if __name__ == '__main__':
270
+ main()