Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
The policy-answering assistant now has evaluations. One private case asks about a stale service-account key, with this evidence:
Policy evidence: Service-account keys older than 14 days require a rotation ticket before continued use.
A candidate model may retrieve the right clause and still respond badly. A base language model predicts plausible next tokens; it isn't reliably trained to interpret a user turn, answer as a policy assistant, cite the policy, and stop. Supervised fine-tuning (SFT) teaches that response behavior, and the same chat template must survive from data preparation to serving.

One example defines a behavior contract
A pretrained causal language model has one basic mechanism: given preceding tokens, assign probabilities to the next token. It can sometimes answer a question because conversations appeared in pretraining data, but answering isn't yet a dependable product contract.
For an access-policy assistant, one SFT training row can look like this:
| Role | Content | What this turn contributes |
|---|---|---|
system | Answer from the supplied policy evidence. | Sets the behavior boundary. |
user | My service-account key is 18 days old. Can I keep using it? | Gives the user's request. |
assistant | Open a rotation ticket because keys older than 14 days require review. | Supplies the continuation to reward. |
During SFT, the optimizer increases the probability of target response tokens conditioned on the turns that came before them. In InstructGPT, supervised demonstrations were the first post-training stage before preference feedback was used to refine response quality.[1]

SFT isn't a guarantee that the model only changes style or formatting. Fine-tuning can alter factual behavior and task competence too. LIMA provides a useful, narrower result: with a capable base model, a carefully curated set of 1,000 demonstrations produced strong response-format and instruction-following behavior in its experiments.[2] Treat that as evidence for data quality, not a promise that every task needs little data.
Chat messages eventually become one token stream
Applications store a conversation as structured records. The language model receives tokens. A chat template is the serialization rule between those two representations: it adds role markers, delimiters, whitespace, and, when appropriate, the cue that a new assistant response should begin.[3]

Model families don't share one universal chat serialization:
| Checkpoint family example | Shape of its turn markers | Engineering consequence |
|---|---|---|
| ChatML-style | <|im_start|>user ... <|im_end|> | An assistant-start marker can indicate a fresh generation turn. |
| Llama 3 Instruct | Header tokens followed by <|eot_id|> | Use the tokenizer's shipped headers and end-of-turn marker.[4][5] |
| Mistral-7B-Instruct-v0.1 | <s>[INST] ... [/INST] ... </s> | Spacing and turn layout are part of the tokenizer contract.[3][6] |
Those strings are examples of checkpoint-specific formats, not a menu of interchangeable wrappers. Feeding [INST] formatting to a checkpoint trained with another role-token layout may still produce text, but you have changed its input distribution.
This lesson focuses on system, user, and assistant turns. Production templates often also serialize tool messages, tool_calls, and multimodal parts; those schemas are out of scope here and are covered when you wire tool-using agents. Don't treat three-role ChatML as the full production contract.
Render training, fresh generation, and prefill separately
The smallest useful template exercise is to render the same support task in three modes:
- A completed training transcript contains the known assistant answer.
- A new inference request ends at an assistant-start cue.
- A prefill already contains the beginning of an assistant answer and asks the model to continue it.
The last two modes aren't the same. Starting a new assistant turn and continuing an existing assistant message should never happen at once.
1START = "<|im_start|>"
2END = "<|im_end|>"
3
4def render(messages, *, add_generation_prompt=False, continue_final_message=False):
5 if add_generation_prompt and continue_final_message:
6 raise ValueError("choose a new assistant turn or a prefill, not both")
7
8 pieces = []
9 for index, message in enumerate(messages):
10 role = message["role"]
11 content = message["content"]
12 is_prefill = (
13 continue_final_message
14 and index == len(messages) - 1
15 and role == "assistant"
16 )
17 pieces.append(f"{START}{role}\n{content}")
18 if not is_prefill:
19 pieces.append(f"{END}\n")
20
21 if add_generation_prompt:
22 pieces.append(f"{START}assistant\n")
23 return "".join(pieces)
24
25context = [
26 {"role": "system", "content": "Use only the supplied policy evidence."},
27 {"role": "user", "content": "My service-account key is 18 days old. Can I keep using it?"},
28]
29answer = {
30 "role": "assistant",
31 "content": "Open a rotation ticket because keys older than 14 days require review.",
32}
33
34training_text = render(context + [answer])
35generation_text = render(context, add_generation_prompt=True)
36prefill_text = render(
37 context + [{"role": "assistant", "content": '{"rotation_window_days": '}],
38 continue_final_message=True,
39)
40
41print("training_has_answer=", "14 days" in training_text)
42print("generation_ends_at_assistant=", generation_text.endswith(f"{START}assistant\n"))
43print("prefill_is_open=", prefill_text.endswith('{"rotation_window_days": '))
44
45try:
46 render(context, add_generation_prompt=True, continue_final_message=True)
47except ValueError as error:
48 print("invalid_mode_caught=", str(error))1training_has_answer= True
2generation_ends_at_assistant= True
3prefill_is_open= True
4invalid_mode_caught= choose a new assistant turn or a prefill, not bothHugging Face tokenizers implement this idea with apply_chat_template. For generation, add_generation_prompt=True appends a start-of-assistant sequence only when that particular template defines one. For a response prefill, continue_final_message=True keeps the final assistant content open, and the documentation treats combining both flags as an error.[3]
1from transformers import AutoTokenizer
2
3tokenizer = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-Instruct-v0.1")
4messages = [
5 {"role": "user", "content": "My service-account key is 18 days old. Can I keep using it?"},
6]
7
8input_ids = tokenizer.apply_chat_template(
9 messages,
10 tokenize=True,
11 add_generation_prompt=True,
12 return_tensors="pt",
13)This snippet intentionally uses the tokenizer attached to the checkpoint rather than recreating the format with string concatenation. For Mistral-7B-Instruct-v0.1, the shipped template already places generation after [/INST]; add_generation_prompt=True doesn't append a separate assistant-start token. Other checkpoint templates do append a cue. If you render text with tokenize=False and tokenize it in a later step, pass add_special_tokens=False; otherwise BOS or EOS tokens may be inserted twice.[3]
Whitespace changes the token contract
Jinja renders indentation, line breaks, and trailing spaces unless its whitespace-control markers remove them. Hugging Face warns that extra whitespace absent from training can harm performance and recommends - markers around template blocks and expressions.[5] A particularly quiet failure is spaces immediately before a special token. The transcript still looks readable, but the tokenizer receives a different byte sequence than the training run.
Compare a loose template with an exact one. Calling .strip() on the final transcript wouldn't repair the loose version because its three spaces sit inside the sequence, before <|eot|>.
1from hashlib import sha256
2
3from jinja2 import Environment
4
5BAD_TEMPLATE = """{% for message in messages %}
6{{ message['content'] }} {{ '<|eot|>' }}
7{% endfor %}"""
8
9EXACT_TEMPLATE = """{%- for message in messages -%}
10{{- message['content'] -}}{{- '<|eot|>' -}}
11{%- endfor -%}"""
12
13messages = [{"content": "Open a rotation ticket."}]
14environment = Environment()
15
16loose = environment.from_string(BAD_TEMPLATE).render(messages=messages)
17exact = environment.from_string(EXACT_TEMPLATE).render(messages=messages)
18
19print("loose=", repr(loose))
20print("exact=", repr(exact))
21print("spaces_before_eot=", " <|eot|>" in loose)
22print("same_bytes=", loose.encode() == exact.encode())
23print("loose_sha=", sha256(loose.encode()).hexdigest()[:12])
24print("exact_sha=", sha256(exact.encode()).hexdigest()[:12])
25
26assert " <|eot|>" in loose
27assert loose.encode() != exact.encode()1loose= '\nOpen a rotation ticket. <|eot|>\n'
2exact= 'Open a rotation ticket.<|eot|>'
3spaces_before_eot= True
4same_bytes= False
5loose_sha= 4e9ea802389c
6exact_sha= 271e73be7a6bStore a template revision or digest in the training manifest, but also keep golden rendered strings and token IDs. A matching filename doesn't prove that a serving engine uses the same Jinja whitespace semantics. Render the same messages through training and serving paths, compare bytes and token IDs, and block deployment on any unexplained difference.
Before rendering thousands of rows, reject malformed dialogue structure. An assistant reply without a preceding user request is a bad supervised example even if its text is fluent.
1rows = {
2 "valid": ["system", "user", "assistant"],
3 "missing_answer": ["system", "user"],
4 "double_user": ["system", "user", "user", "assistant"],
5}
6
7def role_errors(roles):
8 turns = roles[1:] if roles and roles[0] == "system" else roles
9 expected = "user"
10 for role in turns:
11 if role != expected:
12 return [f"expected {expected}, got {role}"]
13 expected = "assistant" if expected == "user" else "user"
14 if expected == "assistant":
15 return ["conversation ends before assistant answer"]
16 return []
17
18for row_id, roles in rows.items():
19 errors = role_errors(roles)
20 print(f"{row_id}: {'accept' if not errors else 'reject'} {errors}")
21
22assert role_errors(rows["valid"]) == []
23assert role_errors(rows["missing_answer"]) == ["conversation ends before assistant answer"]1valid: accept []
2missing_answer: reject ['conversation ends before assistant answer']
3double_user: reject ['expected assistant, got user']Fine-tuning data must teach supported answers
Good formatting can't rescue bad supervision. If a training row claims that stale keys can wait 30 days when the policy says 14 days with a rotation ticket, SFT reinforces an unsupported answer.
Your first data design decision isn't "How many rows can I generate?" It's "What behavior is each accepted row allowed to teach?"
| Data source | Useful for | Risk to test before training |
|---|---|---|
| Reviewed policy examples | Policy-critical responses and refusal behavior | Sparse coverage of unusual tickets |
| Synthetic variations from approved seeds | Paraphrases, edge cases, tone variation | Unsupported policy claims or near-duplicates |
| Multi-turn transcripts | Follow-ups such as "Which rotation ticket should I use?" | Old context, personal data, or unhelpful agent habits |
Self-Instruct demonstrated a repeatable way to expand instruction data: start from human-written tasks, generate new instructions and responses, then filter invalid or similar rows before fine-tuning.[7] A product team can use the same pattern with policy questions, but it must validate answers against source clauses rather than trusting a generator's confidence.

The next lab treats each assistant answer as a candidate SFT row. A row is accepted only if it contains required policy facts and omits a known unsupported claim.
1policies = {
2 "stale_key": {
3 "required": ("14 days", "rotation ticket"),
4 "forbidden": ("30 days",),
5 },
6 "admin_access": {
7 "required": ("reviewer approval", "policy p-7"),
8 "forbidden": ("auto-approve",),
9 },
10}
11
12candidate_rows = [
13 {
14 "id": "row-001",
15 "policy": "stale_key",
16 "answer": "Open a rotation ticket because keys older than 14 days require review.",
17 },
18 {
19 "id": "row-002",
20 "policy": "stale_key",
21 "answer": "Keep using the key and rotate it within 30 days.",
22 },
23 {
24 "id": "row-003",
25 "policy": "admin_access",
26 "answer": "Escalate temporary admin access for reviewer approval under policy P-7.",
27 },
28]
29
30def evaluate(row):
31 text = row["answer"].lower()
32 rule = policies[row["policy"]]
33 has_required_facts = all(fact in text for fact in rule["required"])
34 contains_forbidden_claim = any(claim in text for claim in rule["forbidden"])
35 return has_required_facts and not contains_forbidden_claim
36
37accepted = []
38for row in candidate_rows:
39 decision = "accept" if evaluate(row) else "reject"
40 print(f"{row['id']}: {decision}")
41 if decision == "accept":
42 accepted.append(row["id"])
43
44print("accepted_rows=", accepted)
45assert accepted == ["row-001", "row-003"]1row-001: accept
2row-002: reject
3row-003: accept
4accepted_rows= ['row-001', 'row-003']This filter is deliberately small. A real data pipeline also checks duplicated instructions, personal information, unsafe replies, role ordering, length limits, and human-review requirements for high-risk cases. Version the evidence snapshot, generator prompt, filters, and accepted dataset together. Otherwise you won't know which change caused a behavior regression.
An accepted dataset also needs coverage. Rows for stale-key and admin-access cases don't demonstrate how the assistant should answer a source-citation question.
1required_intents = {
2 "stale_key",
3 "admin_access",
4 "source_citation",
5}
6accepted_rows = [
7 {"id": "row-001", "intent": "stale_key"},
8 {"id": "row-003", "intent": "admin_access"},
9 {"id": "row-004", "intent": "stale_key"},
10]
11
12covered = {row["intent"] for row in accepted_rows}
13missing = sorted(required_intents - covered)
14counts = {
15 intent: sum(row["intent"] == intent for row in accepted_rows)
16 for intent in sorted(required_intents)
17}
18
19print("accepted_counts=", counts)
20print("missing_critical_intents=", missing)
21print("ready_for_training=", not missing)
22
23assert missing == ["source_citation"]1accepted_counts= {'admin_access': 1, 'source_citation': 0, 'stale_key': 2}
2missing_critical_intents= ['source_citation']
3ready_for_training= FalseWhich tokens should produce gradient?
Every completed chat transcript contains tokens from the system prompt, user question, and assistant answer. You must decide which of those tokens are supervised targets.
Two choices matter:
| Objective choice | Target tokens | When it can be reasonable |
|---|---|---|
| Full-sequence causal loss | All non-padding transcript tokens | You intentionally train the model on the whole conversation distribution. |
| Assistant-only loss | Assistant response spans, usually including their end-of-turn markers | You want the gradient budget focused on response behavior rather than reproducing prompt text. |
Assistant-only loss is common, but it isn't the definition of SFT. TRL exposes it as assistant_only_loss=True for conversational data only when the chat template can identify assistant spans through {% generation %} and {% endgeneration %} markers. Current TRL releases automatically patch templates for some bundled model families; inspect the resolved template for the checkpoint you train.[8] If the span mask is wrong, you may silently train on user text or mask out the answer you meant to learn.
In a causal language model, the target for a token is evaluated after the preceding tokens. The tiny preprocessing lab below marks assistant words and the assistant turn terminator as targets; role markers, system text, and user text receive the usual ignore label -100.
1conversation = [
2 ("system", "Use supplied policy evidence"),
3 ("user", "Stale key needs access"),
4 ("assistant", "Open rotation ticket"),
5]
6
7tokens = []
8labels = []
9
10for role, text in conversation:
11 tokens.append(f"<{role}>")
12 labels.append("-100")
13 for word in text.split():
14 tokens.append(word)
15 labels.append(word if role == "assistant" else "-100")
16 tokens.append("<eot>")
17 labels.append("<eot>" if role == "assistant" else "-100")
18
19trained_targets = [label for label in labels if label != "-100"]
20masked_tokens = sum(label == "-100" for label in labels)
21
22print("tokens=", tokens)
23print("trained_targets=", trained_targets)
24print("masked_tokens=", masked_tokens)
25
26assert trained_targets == ["Open", "rotation", "ticket", "<eot>"]
27assert labels[tokens.index("<user>")] == "-100"1tokens= ['<system>', 'Use', 'supplied', 'policy', 'evidence', '<eot>', '<user>', 'Stale', 'key', 'needs', 'access', '<eot>', '<assistant>', 'Open', 'rotation', 'ticket', '<eot>']
2trained_targets= ['Open', 'rotation', 'ticket', '<eot>']
3masked_tokens= 13Use this kind of inspected tiny batch before launching training. A training-loss curve can't reveal that your boundary finder shifted one token too far and trained the wrong span.
This calculation makes the objective choice visible. Full-sequence loss scores nearly the whole serialized row; assistant-only loss scores only the desired answer span and its terminator.
1stream = [
2 ("system", "<system>"),
3 ("system", "Use"),
4 ("system", "policy"),
5 ("system", "<eot>"),
6 ("user", "<user>"),
7 ("user", "Stale"),
8 ("user", "key"),
9 ("user", "<eot>"),
10 ("assistant-marker", "<assistant>"),
11 ("assistant", "Open"),
12 ("assistant", "ticket"),
13 ("assistant", "<eot>"),
14]
15
16full_sequence_targets = [token for _, token in stream[1:]]
17assistant_only_targets = [token for role, token in stream if role == "assistant"]
18
19print("full_sequence_target_count=", len(full_sequence_targets))
20print("assistant_only_target_count=", len(assistant_only_targets))
21print("assistant_only_targets=", assistant_only_targets)
22
23assert assistant_only_targets == ["Open", "ticket", "<eot>"]
24assert len(assistant_only_targets) < len(full_sequence_targets)1full_sequence_target_count= 11
2assistant_only_target_count= 3
3assistant_only_targets= ['Open', 'ticket', '<eot>']Packing saves padding, but test isolation explicitly
SFT rows have different lengths. If every short chat is padded to a long context window, the accelerator spends much of its work processing padding. Packing fills a window with several short sequences instead. TRL supports packing as a training configuration for this reason.[8]
Packing introduces a decision that teams often miss: may a token in conversation B attend to earlier tokens in conversation A? Some concatenated language-model recipes separate samples with an end token while retaining ordinary causal attention. If you need each support conversation to be an independent supervised example, use a trainer or attention kernel that supports isolation and verify its boundary behavior.
First measure why packing is tempting. Four short training rows padded separately to a 16-token window waste most of their capacity; filling shared windows reduces that waste.
1window = 16
2row_lengths = [9, 6, 7, 5]
3
4separate_capacity = window * len(row_lengths)
5separate_utilization = sum(row_lengths) / separate_capacity
6
7packed_windows = []
8for length in row_lengths:
9 for index, used in enumerate(packed_windows):
10 if used + length <= window:
11 packed_windows[index] += length
12 break
13 else:
14 packed_windows.append(length)
15
16packed_capacity = window * len(packed_windows)
17packed_utilization = sum(row_lengths) / packed_capacity
18
19print("separate_utilization=", f"{separate_utilization:.1%}")
20print("packed_windows=", packed_windows)
21print("packed_utilization=", f"{packed_utilization:.1%}")
22
23assert packed_windows == [15, 12]
24assert packed_utilization > separate_utilization1separate_utilization= 42.2%
2packed_windows= [15, 12]
3packed_utilization= 84.4%Suppose two three-token rows are packed into one sequence:
| Packed position | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| Conversation ID | A | A | A | B | B | B |
| Allowed history for strict isolation | A only | A only | A only | B only | B only | B only |
For strict isolation, the causal self-attention matrix contains two blocks:
Each row is a query position; each column is a possible earlier key position. The zeros in row 4 under columns 0 through 2 prevent the second conversation from reading the first one.
1conversation_ids = ["A", "A", "A", "B", "B", "B"]
2
3mask = [
4 [
5 int(key_position <= query_position and key_id == query_id)
6 for key_position, key_id in enumerate(conversation_ids)
7 ]
8 for query_position, query_id in enumerate(conversation_ids)
9]
10
11for row in mask:
12 print(" ".join(map(str, row)))
13
14cross_conversation_edges = [
15 (query, key)
16 for query, row in enumerate(mask)
17 for key, allowed in enumerate(row)
18 if allowed and conversation_ids[query] != conversation_ids[key]
19]
20
21print("cross_conversation_edges=", cross_conversation_edges)
22assert cross_conversation_edges == []
23assert mask[4][1] == 011 0 0 0 0 0
21 1 0 0 0 0
31 1 1 0 0 0
40 0 0 1 0 0
50 0 0 1 1 0
60 0 0 1 1 1
7cross_conversation_edges= []Don't assume an option named packing=True automatically constructs this matrix. Inspect your trainer's documented semantics and run a small boundary test with the implementation you'll train.
Production failures are usually contract failures
Once a checkpoint has been fine-tuned, evaluate it with the private cases you built in the previous chapter. Log the rendered prompt, tokenizer revision, template revision, truncation policy, and generation settings alongside every result. Otherwise an answer regression could be a template deployment bug rather than a model change.
| Symptom | Likely contract failure | First diagnostic check |
|---|---|---|
| Model generates another user question instead of answering | Missing assistant-start cue for a template that needs one | Inspect final rendered tokens before .generate(). |
| Model emits unfamiliar role markers or rambles | Checkpoint served with another template family | Compare tokenizer/template revision with training manifest. |
| Responses changed after refactoring preprocessing | BOS, EOS, or delimiters were inserted twice | Count special-token IDs after rendering and tokenization. |
| Good short answers, incorrect long threads | Truncation removed the policy evidence or system instruction | Log retained messages and token count. |
| Low loss, poor response quality | Wrong target-span mask or noisy accepted rows | Render one batch with visible labels and inspect rejected data. |
This minimal deployment manifest check can't measure model quality, but it prevents shipping an endpoint whose serialization contract is known to differ from the fine-tuning run.
1training_manifest = {
2 "checkpoint": "access-policy-sft-v3",
3 "tokenizer_revision": "tokens-7",
4 "template_revision": "chatml-policy-v2",
5 "add_special_tokens_after_template": False,
6}
7
8deployments = [
9 {
10 "name": "candidate-safe",
11 "checkpoint": "access-policy-sft-v3",
12 "tokenizer_revision": "tokens-7",
13 "template_revision": "chatml-policy-v2",
14 "add_special_tokens_after_template": False,
15 },
16 {
17 "name": "candidate-drifted",
18 "checkpoint": "access-policy-sft-v3",
19 "tokenizer_revision": "tokens-7",
20 "template_revision": "mistral-wrapper-v1",
21 "add_special_tokens_after_template": True,
22 },
23]
24
25def mismatches(candidate):
26 return [
27 field
28 for field, expected in training_manifest.items()
29 if candidate[field] != expected
30 ]
31
32for deployment in deployments:
33 drift = mismatches(deployment)
34 status = "block" if drift else "evaluate"
35 print(f"{deployment['name']}: {status} drift={drift}")
36
37assert mismatches(deployments[0]) == []
38assert mismatches(deployments[1]) == [
39 "template_revision",
40 "add_special_tokens_after_template",
41]1candidate-safe: evaluate drift=[]
2candidate-drifted: block drift=['template_revision', 'add_special_tokens_after_template']A matching manifest earns the right to run quality evaluations; it doesn't prove the model is ready. Re-run grounded policy cases, critical failure slices, p95 latency checks, and human review checks after every fine-tune or serving change.
The final executable check joins template work back to evaluation. Template parity is mandatory, but a correctly formatted candidate still ships only if grounded cases and operational limits pass.
1candidates = [
2 {
3 "name": "sft-v3",
4 "template_matches": True,
5 "grounded_rate": 0.99,
6 "critical_errors": 0,
7 "p95_latency_ms": 620,
8 },
9 {
10 "name": "sft-v4-fast",
11 "template_matches": True,
12 "grounded_rate": 0.94,
13 "critical_errors": 1,
14 "p95_latency_ms": 410,
15 },
16]
17
18def blockers(candidate):
19 failed = []
20 if not candidate["template_matches"]:
21 failed.append("template_drift")
22 if candidate["grounded_rate"] < 0.98:
23 failed.append("grounded_quality")
24 if candidate["critical_errors"] > 0:
25 failed.append("critical_policy_error")
26 if candidate["p95_latency_ms"] > 700:
27 failed.append("latency")
28 return failed
29
30for candidate in candidates:
31 failed = blockers(candidate)
32 decision = "release" if not failed else "block"
33 print(f"{candidate['name']}: {decision} blockers={failed}")
34
35assert blockers(candidates[0]) == []
36assert blockers(candidates[1]) == ["grounded_quality", "critical_policy_error"]1sft-v3: release blockers=[]
2sft-v4-fast: block blockers=['grounded_quality', 'critical_policy_error']Mastery check
Key concepts
- Base-model continuation versus instruction-tuned response behavior
- SFT examples grounded in approved evidence
- Chat-template serialization and checkpoint-specific markers
- Fresh generation versus assistant prefill
- Full-sequence versus assistant-only loss
- Synthetic-data acceptance checks
- Packed-sequence isolation as a verified design choice
- Training and serving template parity
Evaluation rubric
- Foundational: Explains why a base model can complete dialogue text without reliably acting as a support assistant.
- Intermediate: Renders completed, generation, and prefill versions of one conversation without mixing their boundary rules.
- Intermediate: Builds an assistant-only target mask and explains why that's a choice rather than a universal SFT requirement.
- Advanced: Designs a data and serving audit that rejects unsupported policy rows and template drift before evaluation.
Common pitfalls
- Training on plausible synthetic answers without checking them against policy evidence.
- Hand-formatting messages with delimiters that don't match the checkpoint's tokenizer template.
- Treating
assistant_only_lossor packed isolation as automatic behavior without inspecting the trainer. - Using a new-assistant generation cue when the request already contains an assistant prefill.
- Declaring a fine-tuned checkpoint better before rerunning grounded private evaluations.
Follow-up questions
Why can a base model produce conversation-looking text without being a reliable assistant?
Answer
It predicts likely continuations from its token context. Dialogue appeared in pretraining data, so it may continue a conversation, but no dependable product contract says it must answer the user's request, follow policy evidence, or stop at an assistant boundary. SFT supplies examples that reward those continuations.
When do you use add_generation_prompt=True, and when do you use continue_final_message=True?
Answer
Use a generation prompt when the final input turn is complete and you need to start a fresh assistant answer, if the checkpoint's template uses such a cue. Use final-message continuation when an assistant response has already begun, such as a JSON prefill. They represent different contexts and shouldn't be enabled together.
Why isn't assistant-only loss the definition of supervised fine-tuning?
Answer
SFT means optimizing a model on supervised example sequences. A trainer can compute loss across the full transcript or only selected assistant spans. Assistant-only loss is useful when you want gradient focused on responses, but it requires correct template-aware span masks and remains an objective choice.
What must you verify before using packed support conversations as independent examples?
Answer
Verify whether tokens in one packed conversation can attend to tokens from an earlier conversation. If independence matters, confirm that your trainer and attention implementation enforce boundaries, then test a tiny packed batch instead of assuming packing implies isolation.
Practice extension
Add a third policy row for a source-citation requirement and a second rejected answer that invents automatic approval. Extend filter-grounded-sft-rows.py to print which required fact is missing or which forbidden claim was found. Then add its accepted answer to the rendering and label-building labs. Show exactly which assistant tokens become supervised targets and exactly which evidence allowed the row into training.