分步实现结合向量检索、用户画像和对话记忆的 AI 辅导系统,覆盖 Embedding 向量库选择和 Prompt 构建策略。
通用聊天机器人在会话结束的瞬间就会忘记一切。真正的导师不会。本文将带你了解一个个性化 AI 辅导系统的架构与实现,它将检索增强生成(RAG)与持久化用户记忆相结合,使系统能够跨会话回忆起学生学过的内容、最佳学习方式以及薄弱环节。
让我们一层一层来构建。
如果你曾经把一个 LLM API 调用包装在聊天界面里然后称之为"导师",你就已经知道其中的局限性了:
一个个性化的导师需要聊天机器人无法提供的两样东西:基于事实依据的知识(RAG 提供)和持久化的上下文(用户记忆提供)。下面我们来拆解每个架构组件。
RAG 存在是为了解决一个简单的问题:LLM 不了解你的特定内容。它们没有被训练过你的课程材料、你的教科书或你的自定义练习题集。RAG 允许你在生成时将相关内容片段注入 Prompt,而不是去重新训练或微调模型。
在内容可以被检索之前,需要先将其拆分为可管理的块。块大小非常重要。太大,会因为无关文本浪费上下文窗口空间;太小,会丢失使解释连贯的周围上下文。
def chunk_text(text, max_tokens=300, overlap=50):
"""
Splits content into overlapping chunks to preserve context across chunk boundaries.
"""
words = text.split()
chunks = []
start = 0
while start < len(words):
end = start + max_tokens
chunk = " ".join(words[start:end])
chunks.append(chunk)
start += max_tokens - overlap
return chunks
对于教育内容,按自然边界进行分块通常更有帮助,比如按章节标题、例题或题目-解答对,而不是纯粹按 token 数量。一个数学推导如果在中间步骤被拆分,单独检索时几乎毫无用处。
每个块都会被转换为一个向量嵌入——一种捕获语义含义的数值表示。含义相似的块在向量空间中会彼此靠近,即使措辞略有不同。
from openai import OpenAI
client = OpenAI()
def embed_text(text):
response = client.embeddings.create(
model="text-embedding-3-small",
input=text
)
return response.data[0].embedding
嵌入向量生成后,需要存储在可查询的地方。热门选择包括 Pinecone、Weaviate、Qdrant 和 pgvector(如果你想全部保留在 Postgres 中)。对于辅导系统来说,pgvector 通常是一个很好的起点,因为它可以让你把嵌入向量、用户画像和会话日志都放在同一个关系型数据库中。
CREATE TABLE knowledge_chunks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_id UUID NOT NULL,
content TEXT NOT NULL,
embedding VECTOR(1536),
metadata JSONB
);
CREATE INDEX ON knowledge_chunks USING ivfflat (embedding vector_cosine_ops);
metadata 列比你想象中更重要。为块打上主题、难度等级和先修概念等标签,可以让你过滤检索结果,而不仅仅是按相似度排名。
这是导师从搜索引擎进化为真正导师的关键一步。用户画像存储关于学习者的持久信息,这些信息会影响内容如何被讲解,而不仅仅是哪些内容会被检索到。
CREATE TABLE user_profiles (
user_id UUID PRIMARY KEY,
skill_level JSONB, -- 例如 { "algebra": "intermediate", "calculus": "beginner" }
learning_style TEXT, -- 例如 "visual", "step-by-step", "analogy-heavy"
known_weak_topics TEXT[],
preferred_pace TEXT, -- 例如 "fast", "thorough"
goals TEXT,
updated_at TIMESTAMP DEFAULT now()
);
这里的关键设计决策是:什么应该放在画像里,什么应该放在会话记忆中。作为经验法则:画像数据是缓慢变化且持久的(技能水平、学习偏好、长期目标),而会话记忆是会话特定的、快速变化的(刚刚讨论了什么、学生刚才做错了什么)。
画像不应该是静态的。每次会话结束后,你可以对会话内容进行轻量级提取,将技能评估更新并标记薄弱知识点。
def update_profile_from_session(user_id, session_summary, llm_client):
prompt = f"""
Given this tutoring session summary, extract updates to the student's profile.
Return JSON with keys: skill_level_updates, new_weak_topics, notes.
Session summary: {session_summary}
"""
response = llm_client.generate(prompt)
updates = parse_json(response)
apply_profile_updates(user_id, updates)
这样每一次会话都成为下一次会话的训练数据,不需要任何实际的模型微调。
会话记忆处理当前会话及近期会话中的上下文,比如"我们刚才在聊什么"和"学生在五条消息前说了什么"。有几种常见策略,大多数生产系统会组合使用。
原样保留最近 N 条消息。简单,但随着对话增长成本会变高,而且窗口之外的内容会被遗忘。
定期将较旧的消息压缩成摘要,保持最近消息原样,较旧的上下文被精简。
def update_conversation_memory(session_id, new_message, memory_store):
memory = memory_store.get(session_id)
memory["recent_messages"].append(new_message)
if len(memory["recent_messages"]) > 10:
old_messages = memory["recent_messages"][:5]
summary_prompt = f"Summarize this tutoring exchange concisely:\n{old_messages}"
summary = llm_client.generate(summary_prompt)
memory["summary"] = merge_summaries(memory.get("summary", ""), summary)
memory["recent_messages"] = memory["recent_messages"][5:]
memory_store.save(session_id, memory)
对于需要跨会话持久化(而不只是在一个会话内)的记忆,值得将重要的会话事实(比如"学生 3 月 3 日在对数运算上有困难")嵌入向量并存储在与课程内容相同的向量数据库中,但打上不同的标签。这样你就可以用与检索知识块相同的方式来检索相关的历史交互。
在查询时,一个个性化导师需要同时从多个来源检索:课程内容、相关的历史会话记录和用户画像。
def retrieve_context(user_id, query, top_k=5):
query_embedding = embed_text(query)
# 检索相关知识块
knowledge_results = vector_db.query(
embedding=query_embedding,
filter={"course_id": get_active_course(user_id)},
top_k=top_k
)
# 检索相关历史会话记忆
memory_results = vector_db.query(
embedding=query_embedding,
filter={"user_id": user_id, "type": "conversation_memory"},
top_k=3
)
# 获取持久化用户画像
profile = get_user_profile(user_id)
return {
"knowledge": knowledge_results,
"past_context": memory_results,
"profile": profile
}
这里一个常见的错误是把检索当作单一扁平搜索来对待。实际上,你需要用不同的过滤器分别进行检索调用,因为课程内容和会话记忆有非常不同的相关性信号,不应该在同一个排名中相互竞争。
这就是把所有东西组装成模型能够很好使用的形式的地方。对于辅导系统来说,一个好的 Prompt 结构通常将信息从最稳定到最即时分层排列:
def build_prompt(user_query, context):
profile = context["profile"]
knowledge = "\n\n".join([c["content"] for c in context["knowledge"]])
past_context = "\n".join([m["content"] for m in context["past_context"]])
prompt = f"""
You are a personalized AI tutor. Adapt your explanation style to the student's profile below.
Skill levels: {profile['skill_level']}
Learning style: {profile['learning_style']}
Known weak topics: {profile['known_weak_topics']}
Preferred pace: {profile['preferred_pace']}
RELEVANT COURSE CONTENT:
{knowledge}
RELEVANT PAST INTERACTIONS:
{past_context}
STUDENT'S CURRENT QUESTION:
{user_query}
Ground your explanation in the course content provided above.
Match the student's preferred learning style and pace.
If the question relates to a known weak topic, briefly reinforce the fundamentals before moving forward.
Do not repeat explanations the student has already mastered, based on their skill level.
"""
return prompt
关于 Prompt 构建的几个实践建议:
顺序很重要。 将用户画像放在检索内容之前通常会产生更好的个性化语气,因为模型会早期就以它为基础。
指令要明确且简短。 冗长的指令块容易被部分忽略;具体、直接的指令更可靠。
始终指定事实依据。 明确告诉模型依赖检索内容会显著减少幻觉化的解释,特别是在数学和科学这类事实性或程序性科目上。
Prompt 构建好之后,生成本身就比较标准了,但有几个针对辅导场景的特殊考量需要注意。
def generate_response(prompt, model_client):
response = model_client.generate(
prompt=prompt,
temperature=0.4,
max_tokens=800
)
return response
Temperature 值得仔细调优。太高的值会导致解释在不同会话之间变得不一致,这会让学习者感到困惑,因为他们试图建立稳定的心智模型。太低又会让导师在不同学生面前显得重复和机械。通常在 0.3 到 0.5 之间的中间值最适合教育类解释。
生成之后,值得运行一个轻量级的后处理步骤来提取任何值得写回记忆的信号:
def post_process_response(user_id, session_id, query, response):
save_conversation_turn(session_id, query, response)
if detects_confusion(response) or detects_struggle(query):
flag_topic_for_review(user_id, extract_topic(query))
这形成了一个闭环。每次交互都会反馈到画像和记忆层,这就是系统随时间推移而感觉真正个性化的原因,而不仅仅是在单个会话内的个性化。
下面是一个端到端请求在概念层面的样子:
def handle_student_query(user_id, session_id, query):
context = retrieve_context(user_id, query)
prompt = build_prompt(query, context)
response = generate_response(prompt, model_client)
post_process_response(user_id, session_id, query, response)
return response
表面看起来很简单,但这里的每个函数调用都背后都有持久化状态层支撑:用于知识的嵌入向量、用于长期个性化定制的持久化画像,以及用于会话连贯性的记忆存储。
Prompt 塞得太满。 能检索十个块不意味着你应该全部用上。不相关的检索内容会稀释相关性,甚至可能损害响应质量。按经验调优 top_k。
把画像更新当作即时且绝对的。 一条困惑的消息不应该立即下调学生的技能水平。使用滚动平均值,或者要求在多个会话中观察到某种模式后再更新持久化画像字段。
忽略检索质量指标。 构建一个能运行的 RAG pipeline 很容易,但构建一个能持续检索真正相关内容的 RAG pipeline 就难多了。记录检索结果,特别是早期阶段要定期人工审查。
跳过块的 metadata。 没有难度或主题标签的检索往往会检索到语义相似但教学上错误的内容,比如为初学者的问题检索到高级解释。Metadata 过滤可以低成本解决这个问题。
个性化 AI 导师不是一个单一的系