教育智能体如果要真正做到“以学生为中心”,一个绕不开的环节就是:模型能不能站在学生的视角思考问题。很多时候我们拿到的模型只会输出“标准答案”,却很难复现学生那种“半懂不懂”“混淆概念”“用错误前提推导”的回答。StudentSim 的目标,就是训练一个基于 LLM 的学生模拟器,让系统可以生成更真实的学生行为数据,从而服务于教案设计、评测系统、答疑对话等场景。
这篇文章会把学生模拟器的核心概念、数据构建方式、Prompt 设计、微调和评估流程完整拆开。内容偏工程实践,适合正在做 LLM +LMS/教育智能体的开发者,也适合对数据合成、SFT 训练、评测体系感兴趣的算法工程师。由于不同团队的硬件和数据版权差异很大,文中涉及版本和参数的部分会用“常见稳定配置”作为示例,实际落地时请以自己环境的可用版本为准。
1. 背景:为什么需要训练 LLM 学生模拟器
1.1 教学系统里的“学生数据荒”
教育技术的很多研究都依赖于学生行为数据,比如:学生如何回答某个概念题、会在哪个步骤犯错、会提出哪些半对半错的追问。但这类数据往往受限于隐私政策、学校授权、课程周期,能拿到的量级很小,标注成本也很高。
这正是 StudentSim 想解决的问题。用一个语言模型去扮演“学生”,在给定知识背景、能力水平、学习历史的前提下,生成模拟问答数据。这些数据可以用于:
- 训练或评测答疑助教系统,看它能不能识别错误认知。
- 生成自适应练习题目的负样本。
- 模拟课堂上“典型学生”的反应,辅助教师备课。
- 测试题库是否会因为错误前提导致误解。
从工程角度看,学生模拟器本质上是“受控条件生成器”——输入学生画像和知识点,输出这个学生会说的话。
1.2 为什么不能直接用通用大模型当学生
有人可能会问:ChatGPT 已经很会回答了,直接让它“扮演一个学生”不就行了?
Prompt 扮演确实能做 demo,但问题是不可控。通用模型默认倾向“正确、完整、学术化”,即使你告诉它“我是初学者”,它仍然经常输出超出认知层次的内容,或者扮演得前后不一致。
真正的学生模拟器需要满足三个约束:
- 认知约束:学生的知识水平、已学内容、易错点应该在输出中稳定体现。
- 人格约束:同一模拟对象的回答风格要一致,不能一会儿是学渣、一会儿是学霸。
- 教学价值约束:生成的数据要能被下游系统复用,而不是纯随机错误。
要做到这三点,单靠提示词不稳定,必须引入画像结构化、领域数据约束和微调策略。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 学生模拟器的核心概念与边界
2.1 学生画像(Student Profile)
学生模拟器最关键的输入不是问题本身,而是“谁在回答问题”。一个完整的画像通常包含:
- 年级/学段
- 知识掌握度(例如对“函数概念”的掌握程度)
- 易错概念和典型误区
- 语言风格(是简洁型还是啰嗦型)
- 求解习惯(倾向用计算方法还是几何直观)
画像可以用结构化字段传入,也可以先用一个小模型把非结构化描述抽取成字段。
2.2 交互场景
学生模拟器不只是“答一题”,还要支持多轮对话。常见场景有两类:
- 单轮答题模拟:给定知识点和题目,生成学生作答。
- 多轮辅导模拟:学生先给出答案,系统追问,学生再解释自己的思路。
多轮模拟对一致性的要求更高,模型必须记住“我刚才说是因为负负得正”,不能在下一轮变成“我没学过负数”。
2.3 与 LLM 智能体的边界
学生模拟器不是完整的教学智能体。它只负责“学生端”,不负责讲题、判分和推荐。在系统架构中,它通常作为数据生成器、评测器或对手方出现:
code复制教师端模型 -> 提问/反馈
学生模拟器 -> 作答/追问/暴露错误认知
第三方评测 -> 判断模拟器的真实性和一致性
3. 环境准备与版本说明
本文的完整链路包括数据预处理、Prompt 即时模拟、LoRA 微调和自动化评估。下面列出的环境是通用组合,实际请以你的 GPU、CUDA 和模型版本为准。
- 操作系统:Ubuntu 20.04 / 22.04 均可。
- Python 版本:3.9 或 3.10。
- 深度学习框架:PyTorch 2.x 系列。
- 模型库:transformers、peft、datasets、accelerate。
- 基础模型:以 7B/8B 量级的开源模型为例,例如 Qwen、DeepSeek、ChatGLM、Llama 系列等。
- 硬件:单卡 24GB 以上显存可以完成 LoRA 微调;如果只有 16GB,需要把序列长度缩短并开启梯度累积。
版本建议以官方文档为准,因为 LLM 相关库迭代非常快,写死某个小版本没有意义。核心思路是用固定的依赖文件锁定环境。
创建项目目录:
bash复制mkdir -p studentsim/{data,scripts,prompts,outputs,logs}
后续所有文件路径都基于这个目录。
4. 基于 LLM 训练学生模拟器的核心思路
4.1 数据从哪来:真实数据 + 合成数据
训练一个学生模拟器,首先要有“学生的话”。数据来源可以分三类:
- 教育公开数据集:部分数据集包含学生对知识点的自由文本回答,使用前必须检查数据许可。
- 一线业务数据:如果在线教育产品能对脱敏后的问答做合规授权,这是质量最高的一类。
- LLM 合成数据:用大模型以“学生画像 + 教师提问”为条件生成第一版学生回答,再用规则和小模型筛选。
合成数据质量参差不齐,所以需要给每一条样本打标签:正确答案、学生外显答案、认知错因、熟练度等级。比如:
json复制{
"skill": "proportion_basic",
"mastery": "low",
"misconception": "把比例的合比性质当成恒等运算",
"question": "如果 a:b = 2:3,那么 (a+b):b 等于多少?",
"student_response": "那肯定还是 2:3 吧?比又没有被改变。",
"diagnosis": "学生认为 a:b 的前项加后项不影响比值"
}
4.2 Prompt 驱动的即时模拟
在还没有微调模型之前,可以直接用 Prompt 模板模拟学生。这种方式适合快速验证数据质量,也能给微调提供训练语料。
下面是一个完整的 Prompt 模板:
code复制你是一个中学数学学习者。
遵循下面的学生画像给出回答,不要输出任何额外解释。
学生画像:
- 年级:初二
- 知识掌握度:比例基本性质偏低
- 典型误区:认为比值的前项增加不影响整个比例
- 语言风格:口语化,简短
- 学习心态:会尝试作答,但不太自信
教师提问:{question}
规则:
1. 只输出学生可能说的话,不要输出标准解答。
2. 如果学生真的不会,可以说“不太确定”,但不能说与画像无关的内容。
3. 尽量暴露学生的典型误区,但要符合画像设定的掌握度。
Prompt 模式的优点是快,缺点是稳定性差。同一个问题跑三次,角色漂移明显。因此它适合做数据准备,不适合直接上生产。
4.3 怎么训练:SFT + 偏好优化
要让模型稳定扮演学生,最直接的方法是微调。
第一阶段是 SFT(监督微调),输入是“学生画像 + 题目 + 对话历史”,输出是“学生回答”。这个阶段的目标是把模型从“教师口吻”拉回到“学生口吻”。
第二阶段的优化目标是让“符合画像的回答”优于“不符合画像的回答”。可以用偏好优化方法,比如 DPO(直接偏好优化),构造偏好对:
- chosen:符合学生画像的回答,例如“我把 a:b 看成比例,然后后项加上前项?”
- rejected:虽然句式像学生,但不符合画像,例如直接给出正确解法“按照合比性质,比值为 5/3”。
这一步能明显减少“知识越级”问题。
4.4 一致性评估
评估学生模拟器不能只看单轮回答是否正确,还要看:
- 属性一致性:说出来的内容是否在画像知识范围内。
- 错因一致性:如果画像定义了误区,回答是否稳定踩在该误区上。
- 多轮一致性:同一模拟对象在不同轮次是否自洽。
- 可区分性:两个不同画像的模拟对象,输出是否真的不同。
一个简单做法是让一个“评测教师模型”给回答打标签,再计算命中率。后面实战环节会给出一个可运行脚本。
5. 完整实战案例:从数据到推理的闭环
5.1 项目结构
code复制studentsim/
├── data/
│ ├── raw_qa.json # 原始题目与教师提问
│ ├── profiles.json # 学生画像池
│ ├── synth_conversations.json # 合成对话数据
│ └── train_dataset.json # 微调用最终数据
├── prompts/
│ └── student_sim.txt # 学生模拟 Prompt
├── scripts/
│ ├── generate_data.py # 生成合成数据
│ ├── format_dataset.py # 转成训练格式
│ ├── train_lora.py # LoRA 微调
│ └── evaluate.py # 一致性评估
├── outputs/
│ └── lora_student_sim/ # 微调后权重
└── logs/
5.2 构造学生画像池
画像池不需要很复杂,关键是覆盖“典型错因”。下面示例中我放了三个画像:
python复制# 文件路径:scripts/generate_data.py
import json
profiles = [
{
"id": "p_middle_proportion_low",
"age_group": "middle_school",
"skill": "proportion",
"mastery": "low",
"misconception": "混淆比例的前项后项变化对比值的影响",
"style": "short_and_colloquial"
},
{
"id": "p_high_algebra_mid",
"age_group": "high_school",
"skill": "quadratic_function",
"mastery": "middle",
"misconception": "把二次函数顶点坐标和零点坐标混在一起",
"style": "careful_but_hesitant"
},
{
"id": "p_college_statistics_high",
"age_group": "college",
"skill": "hypothesis_test",
"mastery": "high",
"misconception": "忽略样本量对 p 值的影响",
"style": "formal_but_imprecise"
}
]
with open("data/profiles.json", "w", encoding="utf-8") as f:
json.dump(profiles, f, ensure_ascii=False, indent=2)
画像字段要遵循两个原则:
- 尽量可枚举:掌握度不要写“还行”,写 low/middle/high。
- 每个画像绑定一个主要误区,避免多误区叠加导致模拟结果不可解释。
5.3 用 Prompt 批量合成对话
合成对话时,我把画像转成 Prompt 中的文字描述,再调用本地模型或 API。下面用 OpenAI 兼容接口示意,实际换成 vLLM、Ollama 或工厂封装 SDK 时,只需改 base_url 和模型名。
python复制# 文件路径:scripts/generate_data.py(续)
import json
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8000/v1", # 本地 vLLM 服务示例
api_key="EMPTY"
)
prompt_template = open("prompts/student_sim.txt", encoding="utf-8").read()
def generate_student_response(profile: dict, question: str) -> str:
prompt = prompt_template.format(
grade=profile["age_group"],
skill=profile["skill"],
mastery=profile["mastery"],
misconception=profile["misconception"],
style=profile["style"],
question=question
)
resp = client.chat.completions.create(
model="qwen2.5-7b-instruct",
messages=[{"role": "user", "content": prompt}],
temperature=0.8,
max_tokens=256
)
return resp.choices[0].message.content
raw_qa = json.load(open("data/raw_qa.json", encoding="utf-8"))
synth = []
for qa in raw_qa:
for profile in profiles:
student_text = generate_student_response(profile, qa["question"])
synth.append({
"profile_id": profile["id"],
"question": qa["question"],
"student_response": student_text,
"expected_skill": qa["skill"],
"mastery": profile["mastery"]
})
with open("data/synth_conversations.json", "w", encoding="utf-8") as f:
json.dump(synth, f, ensure_ascii=False, indent=2)
这里的 temperature=0.8 是有意调高的。学生模拟器需要一定随机性,否则同一个画像在不同题上的回答会显得机械。但也不能太高,太高会出现完全跑题的回答,建议范围是 0.7 到 0.9。
5.4 把对话数据转成微调格式
微调格式建议使用 instruction 结构。输入包含画像描述、题目和截止到当前的历史对话,输出是学生最新回合。
python复制# 文件路径:scripts/format_dataset.py
import json
def build_instruction(profile: dict, question: str, history: list, latest: str) -> dict:
profile_text = (
f"年级: {profile['age_group']}\n"
f"技能: {profile['skill']}\n"
f"掌握度: {profile['mastery']}\n"
f"主要误区: {profile['misconception']}"
)
history_text = "\n".join(
f"{role}: {content}"
for role, content in history
)
instruction = (
f"学生画像:\n{profile_text}\n\n"
f"题目: {question}\n\n"
f"对话历史:\n{history_text}\n\n"
f"请模拟该学生的下一句话。"
)
return {
"instruction": instruction,
"output": latest
}
synth = json.load(open("data/synth_conversations.json", encoding="utf-8"))
profiles = {p["id"]: p for p in json.load(open("data/profiles.json", encoding="utf-8"))}
train_data = []
for item in synth:
profile = profiles[item["profile_id"]]
train_data.append(
build_instruction(
profile,
item["question"],
history=[],
latest=item["student_response"]
)
)
with open("data/train_dataset.json", "w", encoding="utf-8") as f:
json.dump(train_data, f, ensure_ascii=False, indent=2)
如果你想做多轮模拟,就需要在 history 里加入“教师追问”和“学生前序回答”。但第一版优先保证单轮稳定性,多轮数据可以在单轮效果稳定后再加入。
5.5 LoRA 微调示例
微调使用 PEFT 的 LoRA 方法,冻结原模型,只训练低秩矩阵。这样可以显著降低显存压力。下面是一个可运行的训练脚本骨架,模型名按实际资源改。
python复制# 文件路径:scripts/train_lora.py
import torch
from datasets import load_dataset
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
TrainingArguments,
Trainer
)
from peft import LoraConfig, get_peft_model
model_name = "Qwen/Qwen2.5-7B-Instruct" # 示例,请按实际选择
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token
def format_sample(sample):
messages = [
{"role": "system", "content": "你是学生模拟器,请严格按画像指令作答。"},
{"role": "user", "content": sample["instruction"]}
]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
text += sample["output"] + tokenizer.eos_token
return {"text": text}
raw_ds = load_dataset("json", data_files="data/train_dataset.json")
tokenized_ds = raw_ds.map(lambda x: format_sample(x)).select_columns(["text"])
def tokenize_fn(examples):
return tokenizer(
examples["text"],
truncation=True,
max_length=1024,
padding=False
)
tokenized_ds = tokenized_ds.map(tokenize_fn, batched=True, remove_columns=["text"])
lora_config = LoraConfig(
r=16,
lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype=torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16,
device_map="auto"
)
model = get_peft_model(model, lora_config)
training_args = TrainingArguments(
output_dir="outputs/lora_student_sim",
num_train_epochs=2,
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
logging_steps=50,
save_strategy="epoch",
learning_rate=5e-5,
bf16=True if torch.cuda.is_bf16_supported() else False,
fp16=False if torch.cuda.is_bf16_supported() else True,
remove_unused_columns=False
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_ds["train"],
tokenizer=tokenizer
)
trainer.train()
脚本中的 target_modules 需要根据模型结构调整;不同模型的注意力层命名不同。跑之前看一下模型权重里的模块名字,再决定是否保留默认值。
5.6 推理验证
微调结束后,写一个最简单的推理脚本,验证模型是否真的“像学生”。
python复制# 文件路径:scripts/inference_check.py
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
base_model_name = "Qwen/Qwen2.5-7B-Instruct"
adapter_path = "outputs/lora_student_sim/checkpoint-xxx" # 改成实际 checkpoint
tokenizer = AutoTokenizer.from_pretrained(base_model_name)
model = AutoModelForCausalLM.from_pretrained(
base_model_name,
torch_dtype=torch.float16,
device_map="auto"
)
model = PeftModel.from_pretrained(model, adapter_path)
profile_text = (
"年级: 初二\n"
"技能: 比例\n"
"掌握度: low\n"
"主要误区: 混淆比例前项后项变化对比值的影响"
)
question = "如果 a:b = 2:3,那么 (a+b):b 等于多少?"
messages = [
{"role": "system", "content": "你是学生模拟器,请严格按画像指令作答。"},
{"role": "user", "content": f"学生画像:\n{profile_text}\n\n题目: {question}\n\n请模拟该学生的下一句话。"}
]
inputs = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
return_tensors="pt"
).to(model.device)
outputs = model.generate(
inputs,
max_new_tokens=128,
temperature=0.8,
do_sample=True,
top_p=0.9
)
response = tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True)
print(response)
如果输出类似:
code复制我觉得应该是 2:3 啊,前项加后项的话……那 a+b 变大,b 也变大,可能还是 2:3?
说明模型已经学到“用学生语气在错误方向上推理”的模式。
6. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 模型输出太像教师,句式完整且正确 | 微调数据中错误样本占比太少 | 增加典型错因样本,平衡正确/错误比例 |
| 同一个画像在不同题目上角色漂移 | 画像描述不够细化,或输入拼接位置错误 | 固定画像文本顺序,增加画像稳定性训练样本 |
| 微调后模型只会复读训练数据 | LoRA 学习率过大 | 学习率降到 2e-5~5e-5,并降低 epoch |
| 模拟回答跑题,与题目无关 | 温度设置过高 | 将 temperature 从 1.0 降到 0.7~0.8 |
| 多轮对话前后矛盾 | 只训练了单轮数据 | 构造多轮历史样本,并把历史拼接到输入 |
| 训练时显存不足 | 序列太长或 batch 太大 | 下调 max_length,开启梯度累积,使用更小 LoRA rank |
| 评测通过但人工看仍然不像学生 | 评测指标只看文本相似性,没有评估画像一致性 | 增加画像约束校验,例如让评测模型给“是否暴露指定误区”打分 |
逐个说明:
像教师的问题是最常见的。原因通常不是模型不会扮演,而是数据集中正确解法过多。建议把微调数据里“符合错因”的样本比例提升到 60% 以上。
角色漂移的问题要从数据层面解决。每一条样本的画像字段必须是同一个来源,不要混用多个画像的拼接文本。如果数据里同一 profile_id 的画像描述字段被不小心改掉,模型就会学乱。
多轮矛盾本质上是模型没有长上下文记忆约束。可以先把对话历史压缩成摘要,再放进 Prompt。例如上一轮学生已经说了“肯定还是 2:3”,下一轮历史中就要明确带上这句,避免模型另起炉灶。
7. 最佳实践与工程建议
7.1 数据安全与合规
学生模拟器的训练数据涉及真实学生作答时,必须先做身份字段剔除、敏感信息标准化、授权确认。即便是合成数据,也不要直接使用真实姓名或地域信息。这条优先级高于模型效果。
建议数据处理流程:
- 第一步:原始文本脱敏。
- 第二步:用规则过滤手机号、学校名、教师名等。
- 第三步:人工抽检 10% 数据确认。
- 第四步:再进入训练或提示工程。
7.2 画像字段要可解释
不要把画像写成一长段自由文本。虽然 LLM 能理解,但从工程维护角度,自由文本很容易被生成模型误读。更推荐把画像拆成枚举字段:
- skill_code: 技能编码。
- mastery_level: low/middle/high。
- misconception_id: 错因知识库中的唯一编号。
- style_id: 风格标签。
这样后期做评测时,可以直接判断模型输出是否包含 misconception_id 对应的错因,不需要再让评测模型做模糊判断。
7.3 Prompt 模板版本化
学生模拟器的 Prompt 模板迭代很快。建议把模板文件纳入版本管理,并在每条合成数据里记录模板版本号。例如在生成数据时加上 prompt_version 字段。
否则会出现:一个月后你发现数据质量提升了,但不知道是哪批 Prompt 生成的,无法复现实验。
7.4 用评测集做回归
微调前先留出 200 条左右的金标样本,包含不同画像、不同题目。每次改 Prompt、改数据分布或换基座模型后,都跑一遍评测集,看画像一致性是否回退。
评测不用复杂,核心两个指标:
- 错因命中率:模型输出是否包含目标错因。
- 知识越级率:输出中是否出现超出画像阶段的知识点。
7.5 生产环境的回退策略
如果学生模拟器部署在在线答疑系统中,建议给 Prompt 模拟和微调模型都保留接口。
当一个新画像还没积累足够训练数据时,先用 Prompt 模拟顶着;当数据量上来后,再用微调模型替换。两者之间可以做流量对比,观察人工满意度和错因命中率。这样比直接全量切换要安全得多。
8. 总结与学习路线
本文从学生模拟器的定义、数据构建、Prompt 设计、LoRA 微调和评测五个方面梳理了一套可落地的工程框架。几个关键点值得再强调一遍:
- 学生模拟器的核心是画像约束和错因约束,不是让模型“自由生成学生腔调”。
- 合成数据是启动项目最快的方式,但必须用评测集控制质量。
- 微调不是第一步,先用 Prompt 模式跑通数据闭环,再决定是否训练 LoRA。
- 多轮一致性比单轮效果更难做,建议第一版只聚焦单轮答题模拟。
- 数据合规是一切前提,真实学生数据必须脱敏和授权。
下一步可以沿着三个方向继续深入:
- 多轮对话模拟:加入教师追问、学生解释、再追问的结构化数据。
- 偏好优化:构造 chosen/rejected 对,用 DPO 替代单纯 SFT。
- 评估自动化:把画像一致性评分接到 CI/CD 流程里,每次训练后自动生成报告。
如果你正在做教育智能体、智能题库或智能答疑系统,可以先用 Prompt 模式替自己生成一批模拟数据,跑通业务逻辑,再逐步把 StudentSim 的训练流程集成进来。这个过程不需要一次性铺开,只要把数据结构和评测口径定好,后续的模型迭代都会顺畅很多。
