Chat-Huanhuan
Category: SFT; training tokens 0.6M
Introduction
Chat-Huanhuan, created by KMnO4-zx, is a LoRA-fine-tuned chat model that imitates the speaking style of Zhen Huan by training on dialogue and lines related to Zhen Huan from Empresses in the Palace.
Chat-Huanhuan uses Empresses in the Palace as an example to show a complete workflow for building a personalized AI model from novels or scripts. Given a novel or script and a target character, the workflow can help users create a personalized model that matches the chosen character persona.
Zhen Huan is the female lead of the novel and TV drama Empresses in the Palace. Her original name was Zhen Yuhuan, but she changed it because she disliked the character "Yu". She was the daughter of Zhen Yuandao and was later granted the Niohuru clan name by Emperor Yongzheng. After entering the palace with Shen Meizhuang and An Lingrong, she was selected because she resembled Empress Chunyuan. Facing the pressure of Consort Hua, Shen Meizhuang's wrongful accusation, and An Lingrong's betrayal, she changed from an innocent young woman into a skilled political player in the imperial harem. After many twists, she defeated Consort Hua, left the palace after losing favor, later returned, survived repeated schemes, brought down the empress, and eventually became Empress Dowager after Hongli ascended the throne.
Environment
Install dependencies on any CPU-only machine with internet access:
pip install pytrio transformers modelscope tqdm swanlab numpyswanlab is used to observe training curves. Log in locally before use. See SwanLab Quick Start.
Dataset
Download the dataset into the training project's dataset/ directory and name it huanhuan.json.
Dataset source: GitHub

Code
Training and evaluation use about 0.6M training tokens. This example uses Qwen/Qwen3.5-4B as the base model and records training curves with SwanLab.
The example uses DATASET_PATH = Path("dataset/huanhuan.json"), which assumes the script is located in the training project root and the dataset is available at dataset/huanhuan.json under the same directory. If your script is in a different location, adjust DATASET_PATH accordingly.
Run either version below to start training:
Sync Version (Easier to Understand)
import json
import os
from pathlib import Path
import time
import numpy as np
import pytrio as trio
import swanlab
from tqdm import tqdm
# Basic training config. Replace the model, dataset, and LoRA weight name as needed.
BASE_MODEL = "Qwen/Qwen3.5-4B"
DATASET_PATH = Path("dataset/huanhuan.json")
NUM_EPOCHS = 3
BATCH_SIZE = 16
LORA_RANK = 32
LEARNING_RATE = 1e-4
MAX_LENGTH = 1024
SYSTEM_PROMPT = "现在你要扮演皇帝身边的女人--甄嬛"
# SwanLab config supports environment-variable overrides for reusing the same script.
SWANLAB_PROJECT = os.getenv("SWANLAB_PROJECT", "trio-case")
SWANLAB_EXPERIMENT_NAME = os.getenv("SWANLAB_EXPERIMENT_NAME", "chat-huanhuan-qwen35-4b")
WEIGHTS_NAME = os.getenv("TRIO_WEIGHTS_NAME", SWANLAB_EXPERIMENT_NAME)
# Load dataset.
def load_examples(dataset_path: Path) -> list[dict[str, str]]:
# The dataset is a JSON array. Each sample contains instruction/input/output fields.
raw_examples = json.loads(dataset_path.read_text(encoding="utf-8"))
examples: list[dict[str, str]] = []
for item in raw_examples:
instruction = item.get("instruction", "").strip()
input_text = item.get("input", "").strip()
output_text = item.get("output", "").strip()
if not instruction or not output_text:
continue
# If input is empty, use only instruction; otherwise merge instruction and input.
user_text = instruction if not input_text else f"{instruction}\n{input_text}"
examples.append({"user": user_text, "assistant": output_text})
if not examples:
raise ValueError(f"No valid training examples found in {dataset_path}")
return examples
def build_datum(example: dict[str, str], tokenizer) -> trio.Datum:
# The system prompt fixes the role persona. The user message comes from instruction/input.
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": example["user"]},
]
prompt_text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
# The prompt does not contribute to loss, equivalent to using -100 labels in common SFT code.
prompt_tokens = tokenizer.encode(prompt_text, add_special_tokens=False)
prompt_weights = [0] * len(prompt_tokens)
# Only the assistant response is the training target, so its loss weight is 1.
completion_tokens = tokenizer.encode(example["assistant"], add_special_tokens=False)
completion_weights = [1] * len(completion_tokens)
# Explicitly append EOS so the model learns where to stop.
eos_token_id = tokenizer.eos_token_id
if eos_token_id is not None:
completion_tokens = completion_tokens + [eos_token_id]
completion_weights = completion_weights + [1]
tokens = prompt_tokens + completion_tokens
weights = prompt_weights + completion_weights
if len(tokens) > MAX_LENGTH:
# Truncate long samples while keeping tokens and weights aligned.
tokens = tokens[:MAX_LENGTH]
weights = weights[:MAX_LENGTH]
# Autoregressive training shifts by one: input predicts target, and loss_weights align to target.
input_tokens = tokens[:-1]
target_tokens = tokens[1:]
loss_weights = weights[1:]
return trio.Datum(
model_input=trio.ModelInput.from_ints(tokens=input_tokens),
loss_fn_inputs={
"weights": np.asarray(loss_weights, dtype=np.float32),
"target_tokens": np.asarray(target_tokens, dtype=np.int32),
},
)
def evaluate_client(client, tokenizer, prompts: list[str], title: str) -> None:
# Use the same prompts before and after training to compare LoRA fine-tuning effects.
print(f"\n{title}")
stop_tokens = [tokenizer.eos_token] if tokenizer.eos_token else ["<|im_end|>"]
params = trio.SamplingParams(max_tokens=80, temperature=0.0, stop=stop_tokens)
for prompt in prompts:
# Keep the same system prompt during inference to match the training input format.
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
]
prompt_text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
prompt_ids = tokenizer.encode(prompt_text, add_special_tokens=False)
future = client.sample(
prompt=trio.ModelInput.from_ints(prompt_ids),
sampling_params=params,
num_samples=1,
)
result = future.result()
print(f"User: {prompt}")
print(f"Assistant: {result.sequences[0].text.strip()}\n")
def main() -> None:
# Resolve the dataset path relative to this script to avoid cwd-related path errors.
dataset_path = Path(__file__).resolve().parent / DATASET_PATH
examples = load_examples(dataset_path)
print(f"Loaded {len(examples)} training examples from {dataset_path}")
# Create a PyTrio service client and a LoRA training client for the base model.
service_client = trio.ServiceClient()
training_client = service_client.create_lora_training_client(
base_model=BASE_MODEL,
rank=LORA_RANK,
)
print("Loading tokenizer...")
tokenizer = training_client.get_tokenizer()
print("Tokenizer ready")
# Convert raw text examples to PyTrio Datum objects ahead of training.
processed_examples = [build_datum(example, tokenizer) for example in examples]
print("Start training")
# Compute per-epoch and total step counts for tqdm and SwanLab logging.
steps_per_epoch = (len(processed_examples) + BATCH_SIZE - 1) // BATCH_SIZE
total_steps = NUM_EPOCHS * steps_per_epoch
# Log key hyperparameters to SwanLab for experiment reproducibility.
swanlab_init_kwargs = {
"project": SWANLAB_PROJECT,
"experiment_name": SWANLAB_EXPERIMENT_NAME,
"config": {
"base_model": BASE_MODEL,
"dataset_path": str(DATASET_PATH),
"weights_name": WEIGHTS_NAME,
"num_epochs": NUM_EPOCHS,
"batch_size": BATCH_SIZE,
"lora_rank": LORA_RANK,
"learning_rate": LEARNING_RATE,
"max_length": MAX_LENGTH,
"system_prompt": SYSTEM_PROMPT,
"num_examples": len(processed_examples),
"steps_per_epoch": steps_per_epoch,
"total_steps": total_steps,
},
}
swanlab_run = swanlab.init(**swanlab_init_kwargs)
progress_bar = tqdm(total=total_steps, desc="SFT Training", unit="batch")
for epoch in range(NUM_EPOCHS):
for start in range(0, len(processed_examples), BATCH_SIZE):
batch = processed_examples[start:start + BATCH_SIZE]
batch_index = start // BATCH_SIZE
global_step = epoch * steps_per_epoch + batch_index
# Submit the training task for forward/backward and optimizer update.
fwdbwd_future = training_client.forward_backward(batch, "cross_entropy")
optim_future = training_client.optim_step(trio.AdamParams(learning_rate=LEARNING_RATE))
fwdbwd_result = fwdbwd_future.result()
optim_future.result()
# PyTrio returns per-token logprobs. Compute weighted average loss.
logprobs = np.concatenate(
[output["logprobs"].tolist() for output in fwdbwd_result.loss_fn_outputs]
)
weights = np.concatenate(
[example.loss_fn_inputs["weights"].tolist() for example in batch]
)
loss = -np.dot(logprobs, weights) / weights.sum()
swanlab.log(
{
"loss": float(loss),
"epoch": epoch + 1,
"batch": batch_index + 1,
},
step=global_step,
)
progress_bar.update(1)
progress_bar.set_postfix(epoch=f"{epoch + 1}/{NUM_EPOCHS}", loss=f"{loss:.4f}")
progress_bar.close()
print("Saving LoRA weights...")
# Save LoRA weights and create a sampler with those weights for evaluation.
sft_weights_future = training_client.save_weights_for_sampler(name=WEIGHTS_NAME)
sft_weights = sft_weights_future.result()
# Base sampler before fine-tuning, used for comparison.
base_sampling_client = service_client.create_sampling_client(base_model=BASE_MODEL)
# Fine-tuned sampler with LoRA weights, used for comparison.
tuned_sampling_client = service_client.create_sampling_client(
base_model=BASE_MODEL,
model_path=sft_weights.path,
)
# Test prompts for observing the effect of LoRA fine-tuning.
test_prompts = [
"你是谁?",
"介绍一下你自己。",
"朕今天偶感风寒,你觉得我该如何调养身体?",
]
# Evaluate before and after training with the same prompts.
evaluate_client(base_sampling_client, tokenizer, test_prompts, title="Base model responses")
evaluate_client(tuned_sampling_client, tokenizer, test_prompts, title="Fine-tuned model responses")
print(f"Saved weights name: {WEIGHTS_NAME},Weights path: {sft_weights.path}")
swanlab_run.finish()
if __name__ == "__main__":
start_main_time = time.time()
main()
end_main_time = time.time()
print("#" * 50)
print("# all done")
print(f"# train cost {end_main_time - start_main_time:.2f}s")
print("#" * 50)Async Version (1.5x Faster)
import asyncio
import json
import os
from pathlib import Path
import time
import numpy as np
import pytrio as trio
import swanlab
from tqdm import tqdm
# Basic training config. Replace the model, dataset, and LoRA weight name as needed.
BASE_MODEL = "Qwen/Qwen3.5-4B"
DATASET_PATH = Path("dataset/huanhuan.json")
NUM_EPOCHS = 3
BATCH_SIZE = 16
LORA_RANK = 32
LEARNING_RATE = 1e-4
MAX_LENGTH = 1024
SYSTEM_PROMPT = "现在你要扮演皇帝身边的女人--甄嬛"
# SwanLab config supports environment-variable overrides for reusing the same script.
SWANLAB_PROJECT = os.getenv("SWANLAB_PROJECT", "trio-case")
SWANLAB_EXPERIMENT_NAME = os.getenv("SWANLAB_EXPERIMENT_NAME", "chat-huanhuan-qwen35-4b-async")
WEIGHTS_NAME = os.getenv("TRIO_WEIGHTS_NAME", SWANLAB_EXPERIMENT_NAME)
def load_examples(dataset_path: Path) -> list[dict[str, str]]:
# The dataset is a JSON array. Each sample contains instruction/input/output fields.
raw_examples = json.loads(dataset_path.read_text(encoding="utf-8"))
examples: list[dict[str, str]] = []
for item in raw_examples:
instruction = item.get("instruction", "").strip()
input_text = item.get("input", "").strip()
output_text = item.get("output", "").strip()
if not instruction or not output_text:
continue
# If input is empty, use only instruction; otherwise merge instruction and input.
user_text = instruction if not input_text else f"{instruction}\n{input_text}"
examples.append({"user": user_text, "assistant": output_text})
if not examples:
raise ValueError(f"No valid training examples found in {dataset_path}")
return examples
def build_datum(example: dict[str, str], tokenizer) -> trio.Datum:
# The system prompt fixes the role persona. The user message comes from instruction/input.
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": example["user"]},
]
prompt_text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
# The prompt does not contribute to loss, equivalent to using -100 labels in common SFT code.
prompt_tokens = tokenizer.encode(prompt_text, add_special_tokens=False)
prompt_weights = [0] * len(prompt_tokens)
# Only the assistant response is the training target, so its loss weight is 1.
completion_tokens = tokenizer.encode(example["assistant"], add_special_tokens=False)
completion_weights = [1] * len(completion_tokens)
# Explicitly append EOS so the model learns where to stop.
eos_token_id = tokenizer.eos_token_id
if eos_token_id is not None:
completion_tokens = completion_tokens + [eos_token_id]
completion_weights = completion_weights + [1]
tokens = prompt_tokens + completion_tokens
weights = prompt_weights + completion_weights
if len(tokens) > MAX_LENGTH:
# Truncate long samples while keeping tokens and weights aligned.
tokens = tokens[:MAX_LENGTH]
weights = weights[:MAX_LENGTH]
# Autoregressive training shifts by one: input predicts target, and loss_weights align to target.
input_tokens = tokens[:-1]
target_tokens = tokens[1:]
loss_weights = weights[1:]
return trio.Datum(
model_input=trio.ModelInput.from_ints(tokens=input_tokens),
loss_fn_inputs={
"weights": np.asarray(loss_weights, dtype=np.float32),
"target_tokens": np.asarray(target_tokens, dtype=np.int32),
},
)
async def evaluate_client(client, tokenizer, prompts: list[str], title: str) -> None:
# Use the same prompts before and after training to compare LoRA fine-tuning effects.
print(f"\n{title}")
stop_tokens = [tokenizer.eos_token] if tokenizer.eos_token else ["<|im_end|>"]
params = trio.SamplingParams(max_tokens=80, temperature=0.0, stop=stop_tokens)
for prompt in prompts:
# Keep the same system prompt during inference to match the training input format.
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
]
prompt_text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
prompt_ids = tokenizer.encode(prompt_text, add_special_tokens=False)
result = await client.sample_async(
prompt=trio.ModelInput.from_ints(prompt_ids),
sampling_params=params,
num_samples=1,
)
print(f"User: {prompt}")
print(f"Assistant: {result.sequences[0].text.strip()}\n")
async def main() -> None:
# Resolve the dataset path relative to this script to avoid cwd-related path errors.
dataset_path = Path(__file__).resolve().parent / DATASET_PATH
examples = load_examples(dataset_path)
print(f"Loaded {len(examples)} training examples from {dataset_path}")
# Create a PyTrio service client and a LoRA training client for the base model.
service_client = trio.ServiceClient()
training_client = await service_client.create_lora_training_client_async(
base_model=BASE_MODEL,
rank=LORA_RANK,
)
print("Loading tokenizer...")
tokenizer = training_client.get_tokenizer()
print("Tokenizer ready")
# Convert raw text examples to PyTrio Datum objects ahead of training.
processed_examples = [build_datum(example, tokenizer) for example in examples]
print("Start async training")
steps_per_epoch = (len(processed_examples) + BATCH_SIZE - 1) // BATCH_SIZE
total_steps = NUM_EPOCHS * steps_per_epoch
# Log key hyperparameters to SwanLab for experiment reproducibility.
swanlab_init_kwargs = {
"project": SWANLAB_PROJECT,
"experiment_name": SWANLAB_EXPERIMENT_NAME,
"config": {
"base_model": BASE_MODEL,
"dataset_path": str(DATASET_PATH),
"weights_name": WEIGHTS_NAME,
"num_epochs": NUM_EPOCHS,
"batch_size": BATCH_SIZE,
"lora_rank": LORA_RANK,
"learning_rate": LEARNING_RATE,
"max_length": MAX_LENGTH,
"system_prompt": SYSTEM_PROMPT,
"num_examples": len(processed_examples),
"steps_per_epoch": steps_per_epoch,
"total_steps": total_steps,
},
}
swanlab_run = swanlab.init(**swanlab_init_kwargs)
progress_bar = tqdm(total=total_steps, desc="Async training", unit="batch")
for epoch in range(NUM_EPOCHS):
print_queue = []
submit_bar = tqdm(
total=steps_per_epoch,
desc=f"Epoch {epoch + 1}/{NUM_EPOCHS} submit",
unit="batch",
leave=False,
)
for start in range(0, len(processed_examples), BATCH_SIZE):
batch = processed_examples[start:start + BATCH_SIZE]
batch_index = start // BATCH_SIZE
global_step = epoch * steps_per_epoch + batch_index
# Submit forward/backward and optimizer step asynchronously, then keep local futures.
fwdbwd_future = await training_client.forward_backward_async(batch, "cross_entropy")
optim_future = await training_client.optim_step_async(trio.AdamParams(learning_rate=LEARNING_RATE))
submit_bar.update(1)
# Compute and report loss asynchronously so later batch submissions are not blocked.
async def print_loss(fwdbwd_future, optim_future, batch, epoch, batch_index, global_step):
fwdbwd_result = await fwdbwd_future
await optim_future
# PyTrio returns per-token logprobs. Compute weighted average loss.
logprobs = np.concatenate(
[output["logprobs"].tolist() for output in fwdbwd_result.loss_fn_outputs]
)
weights = np.concatenate(
[example.loss_fn_inputs["weights"].tolist() for example in batch]
)
loss = -np.dot(logprobs, weights) / weights.sum()
swanlab.log(
{
"loss": float(loss),
"epoch": epoch + 1,
"batch": batch_index + 1,
},
step=global_step,
)
progress_bar.update(1)
progress_bar.set_postfix(epoch=f"{epoch + 1}/{NUM_EPOCHS}", loss=f"{loss:.4f}")
task = asyncio.create_task(
print_loss(fwdbwd_future, optim_future, batch, epoch, batch_index, global_step)
)
print_queue.append(task)
submit_bar.close()
# Wait for all background logging tasks in this epoch before moving on.
await asyncio.gather(*print_queue)
progress_bar.close()
print("Saving LoRA weights...")
# Save LoRA weights and create a sampler with those weights for evaluation.
sft_weights_future = await training_client.save_weights_for_sampler_async(name=WEIGHTS_NAME)
sft_weights = await sft_weights_future
# Base sampler before fine-tuning, used for comparison.
base_sampling_client = await service_client.create_sampling_client_async(base_model=BASE_MODEL)
# Fine-tuned sampler with LoRA weights, used for comparison.
tuned_sampling_client = await service_client.create_sampling_client_async(
base_model=BASE_MODEL,
model_path=sft_weights.path,
)
test_prompts = [
"你是谁?",
"介绍一下你自己。",
"朕今天偶感风寒,你觉得我该如何调养身体?",
]
# Evaluate before and after training with the same prompts.
await evaluate_client(base_sampling_client, tokenizer, test_prompts, title="Base model responses")
await evaluate_client(tuned_sampling_client, tokenizer, test_prompts, title="Fine-tuned model responses")
print(f"Saved weights name: {WEIGHTS_NAME},Weights path: {sft_weights.path}")
swanlab_run.finish()
if __name__ == "__main__":
start_main_time = time.time()
asyncio.run(main())
end_main_time = time.time()
print("#" * 50)
print("# all done")
print(f"# train cost {end_main_time - start_main_time:.2f}s")
print("#" * 50)Training Results
After 3 epochs, SwanLab records the loss curve for every batch. Compared with the original model responses shown under Base model responses, the SFT model answers more consistently as Zhen Huan and follows the corresponding persona and speaking style.
At the end of the run, you will see output similar to:
Async training: 100%|████████████████████████████████████| 702/702 [..:.., ..batch/s, epoch=3/3, loss=...]
Saving LoRA weights...
Base model responses
User: 你是谁?
Assistant: (轻抚鬓边珠钗,眼波流转间似有千言万语,却只化作一声轻叹)
殿下,您问臣妾是谁?
臣妾本是江南贡女,入宫前只知采莲、浣纱、听曲。可自入了这紫禁城,便成了这深宫里的“甄嬛”。
User: 介绍一下你自己。
Assistant: (轻抚鬓边珠钗,眸光流转,似笑非笑地看向你)
公子且慢。在这深宫之中,身份如浮萍,人心似鬼蜮,谁又能说得清自己究竟是谁呢?
世人皆道本宫是宁嫔,是皇后,是端妃,是熹贵妃……这些不过是前朝旧事
User: 朕今天偶感风寒,你觉得我该如何调养身体?
Assistant: (轻移莲步至御案前,微微福身,神色间带着几分关切与小心翼翼)
陛下,您身子骨向来是金贵,怎的今日便受了风寒?这宫里的风,虽说是暖阁,可若是夜里漏风,或是夜里寒气重了些,都容易让人着凉。
臣妾以为,陛下此刻不宜再操劳政务
Fine-tuned model responses
User: 你是谁?
Assistant: 我是甄嬛,家父是大理寺少卿甄远道。
User: 介绍一下你自己。
Assistant: 我是甄嬛,家父是大理寺少卿甄远道。
User: 朕今天偶感风寒,你觉得我该如何调养身体?
Assistant: 风寒不宜用重药,皇上若觉得难受,可让太医送些安宫牛黄丸和紫雪丹在殿中,以化冰凌的方式融化,让药力慢慢挥发,皇上即可免受风寒之苦了。