第1938篇:多轮对话的意图漂移检测——识别用户是否改变了对话主题
第1938篇:多轮对话的意图漂移检测——识别用户是否改变了对话主题
做多轮对话系统有一个让我困扰了很久的问题,叫做"意图漂移"。
举个例子,用户开始问:"帮我查一下订单ORD-123456的状态"。AI回答后,用户问:"那这个订单什么时候能到?"这是正常的对话延续。但如果用户接着问:"对了,你们家有没有防晒霜?"——这就是话题切换,如果AI还抱着之前的订单上下文继续处理,就可能出错。
最经典的错误案例:用户在问完订单后,突然问"帮我推荐一款手机"。AI如果不识别这个话题切换,可能会说"根据您的订单,我为您推荐……"然后把订单里的商品跟手机推荐混在一起,给出一个完全驴唇不对马嘴的回答。
意图漂移检测,就是要让系统识别出"用户是否切换了话题",然后做出正确的上下文管理决策。
意图漂移的几种类型
先把漂移类型分清楚,不同类型的处理策略不一样:
类型1:话题切换(Topic Switch) 用户完全切换到一个不相关的新话题。
- 之前:问订单物流 → 之后:问防晒产品
- 特征:新话题和之前的话题没有实体或语义关联
类型2:话题延伸(Topic Extension) 用户在同一大主题下的不同子话题跳跃。
- 之前:问某款手机的价格 → 之后:问这款手机的参数
- 特征:新话题和之前有实体关联(同一款手机),但关注点转移了
类型3:上下文遗忘(Context Abandonment) 用户放弃了之前的问题,开始了一个新问题。
- 之前:在讨论一个复杂的退款流程 → 之后:忽然问"你是ChatGPT吗"
- 特征:新问题和之前的业务上下文完全脱节
类型4:意图修正(Intent Correction) 用户修正了自己之前的问题。
- 之前:问A产品 → 之后:"不对,我说的是B产品"
- 特征:用户明确否定了之前的意图
基于规则的意图漂移检测
最直接的方法是用规则检测:
@Service
public class IntentDriftDetector {
private final EntityExtractor entityExtractor;
private final IntentClassifier intentClassifier;
private final SemanticSimilarityService similarityService;
public DriftAnalysis analyze(String newMessage, ConversationContext context) {
if (context.getHistory().size() < 2) {
// 对话刚开始,无法判断漂移
return DriftAnalysis.noDrift();
}
// 获取最近几轮的意图和实体
List<TurnSummary> recentTurns = getRecentTurns(context, 3);
TurnSummary newTurn = analyzeTurn(newMessage);
// 检查各类漂移信号
double driftScore = 0.0;
List<DriftSignal> signals = new ArrayList<>();
// 信号1:意图类别变化
String prevDominantIntent = getDominantIntent(recentTurns);
double intentSimilarity = intentClassifier.similarity(newTurn.getIntent(), prevDominantIntent);
if (intentSimilarity < 0.4) {
driftScore += 0.4;
signals.add(DriftSignal.intentChange(prevDominantIntent, newTurn.getIntent(), intentSimilarity));
}
// 信号2:核心实体变化
Set<String> prevEntities = getRecentEntities(recentTurns);
Set<String> newEntities = newTurn.getEntities();
double entityOverlap = calculateEntityOverlap(prevEntities, newEntities);
if (entityOverlap < 0.2 && !newEntities.isEmpty() && !prevEntities.isEmpty()) {
driftScore += 0.3;
signals.add(DriftSignal.entityChange(prevEntities, newEntities, entityOverlap));
}
// 信号3:语义相似度
String prevContext = buildContextSummary(recentTurns);
double semanticSimilarity = similarityService.similarity(newMessage, prevContext);
if (semanticSimilarity < 0.3) {
driftScore += 0.3;
signals.add(DriftSignal.semanticDrift(semanticSimilarity));
}
// 信号4:显式话题切换词
if (containsTopicSwitchIndicators(newMessage)) {
driftScore += 0.5; // 明确切换词权重更高
signals.add(DriftSignal.explicitSwitch());
}
// 判断漂移类型
DriftType driftType = classifyDrift(signals, driftScore);
return DriftAnalysis.builder()
.driftScore(driftScore)
.driftType(driftType)
.signals(signals)
.isDrifted(driftScore > 0.5)
.build();
}
private boolean containsTopicSwitchIndicators(String message) {
List<String> switchIndicators = List.of(
"换个问题", "另外问一下", "对了", "顺便问一下", "还有个问题",
"跟刚才不同", "换个话题", "重新问", "算了不说那个了"
);
return switchIndicators.stream().anyMatch(message::contains);
}
private double calculateEntityOverlap(Set<String> prev, Set<String> next) {
if (prev.isEmpty() || next.isEmpty()) return 0.5; // 没有实体时不确定
Set<String> intersection = new HashSet<>(prev);
intersection.retainAll(next);
Set<String> union = new HashSet<>(prev);
union.addAll(next);
return (double) intersection.size() / union.size();
}
private DriftType classifyDrift(List<DriftSignal> signals, double score) {
if (score < 0.3) return DriftType.NO_DRIFT;
boolean hasExplicitSwitch = signals.stream()
.anyMatch(s -> s.getType() == DriftSignal.Type.EXPLICIT_SWITCH);
if (hasExplicitSwitch || score > 0.8) {
return DriftType.TOPIC_SWITCH;
}
boolean onlyEntityChange = signals.stream()
.allMatch(s -> s.getType() == DriftSignal.Type.ENTITY_CHANGE);
if (onlyEntityChange) {
return DriftType.TOPIC_EXTENSION;
}
return DriftType.UNCLEAR_DRIFT;
}
}基于LLM的意图漂移检测
规则方法处理不了语义复杂的情况,对于重要的对话场景,可以用LLM来判断:
@Service
public class LlmIntentDriftDetector {
private final LlmClient llmClient;
private final ObjectMapper objectMapper;
public DriftAnalysis analyze(String newMessage, List<ConversationTurn> recentHistory) {
// 构建判断提示
String historyText = formatHistory(recentHistory);
String prompt = String.format("""
请分析以下对话的最新消息是否偏离了之前的主题。
对话历史(最近几轮):
%s
最新消息:
%s
请以JSON格式返回分析结果:
{
"is_drifted": true/false,
"drift_type": "NO_DRIFT/TOPIC_SWITCH/TOPIC_EXTENSION/INTENT_CORRECTION",
"confidence": 0.0-1.0,
"reason": "简短说明判断原因",
"prev_topic": "之前话题的简短描述",
"new_topic": "新消息话题的简短描述",
"shared_context": "两个话题是否有共同的上下文(如果有,描述是什么)"
}
判断标准:
- NO_DRIFT:新消息是对之前话题的自然延续
- TOPIC_SWITCH:新消息完全切换到不相关的新话题
- TOPIC_EXTENSION:新消息在同一大主题下切换到不同子话题
- INTENT_CORRECTION:用户修正或撤回了之前的意图
""", historyText, newMessage);
try {
String response = llmClient.complete(prompt);
return parseAnalysisResponse(response);
} catch (Exception e) {
log.error("LLM意图漂移检测失败", e);
// 降级到规则检测
return ruleBasedDetector.analyze(newMessage, null);
}
}
private String formatHistory(List<ConversationTurn> history) {
return history.stream()
.map(turn -> String.format("%s: %s",
turn.isUser() ? "用户" : "AI",
truncate(turn.getContent(), 200)))
.collect(Collectors.joining("\n"));
}
}检测结果的处理策略
检测到漂移之后,怎么处理是另一个学问:
@Service
public class DriftResponseHandler {
private final ContextManager contextManager;
/**
* 根据漂移分析结果,决定如何调整对话上下文
*/
public ContextAdjustment handleDrift(DriftAnalysis analysis, ConversationContext context) {
if (!analysis.isDrifted()) {
// 没有漂移,正常继续
return ContextAdjustment.maintain();
}
return switch (analysis.getDriftType()) {
case TOPIC_SWITCH -> handleTopicSwitch(analysis, context);
case TOPIC_EXTENSION -> handleTopicExtension(analysis, context);
case INTENT_CORRECTION -> handleIntentCorrection(analysis, context);
default -> handleUnclearDrift(analysis, context);
};
}
private ContextAdjustment handleTopicSwitch(DriftAnalysis analysis, ConversationContext context) {
// 话题完全切换:清理旧上下文,开始新主题
// 但不能直接清空,要保留用户身份和会话级信息
ContextSnapshot preserved = context.extractPreservedContext();
// 归档旧话题的摘要(可以在用户想回到旧话题时恢复)
TopicSummary archivedTopic = contextManager.archiveTopic(context);
log.info("检测到话题切换:{} -> {}",
analysis.getPrevTopic(), analysis.getNewTopic());
return ContextAdjustment.builder()
.action(ContextAction.RESET_WITH_PRESERVATION)
.preservedContext(preserved)
.archivedTopics(List.of(archivedTopic))
.systemHint(String.format(
"用户切换了话题,从「%s」转向「%s」。" +
"请完全按照新话题来处理,不要引用之前对话的订单/商品/问题。",
analysis.getPrevTopic(), analysis.getNewTopic()
))
.build();
}
private ContextAdjustment handleTopicExtension(DriftAnalysis analysis, ConversationContext context) {
// 话题延伸:保留核心实体,但更新关注焦点
// 找出两个话题共同的实体
Set<String> sharedEntities = findSharedEntities(
analysis.getPrevTopic(), analysis.getNewTopic(), context
);
return ContextAdjustment.builder()
.action(ContextAction.PARTIAL_UPDATE)
.retainedEntities(sharedEntities)
.systemHint(String.format(
"用户在同一话题下切换了关注点:%s。" +
"保持对共同实体(%s)的了解,聚焦新的关注点。",
analysis.getReason(),
String.join("、", sharedEntities)
))
.build();
}
private ContextAdjustment handleIntentCorrection(DriftAnalysis analysis, ConversationContext context) {
// 意图修正:撤销之前的理解,重新理解用户意图
return ContextAdjustment.builder()
.action(ContextAction.REVISE_INTENT)
.systemHint(
"用户修正了之前的意图。请重新理解用户的真实需求," +
"之前基于误解的信息可以不再使用。"
)
.build();
}
private ContextAdjustment handleUnclearDrift(DriftAnalysis analysis, ConversationContext context) {
// 不确定是否漂移:不主动改变上下文,但注入提示让模型谨慎处理
if (analysis.getDriftScore() > 0.7) {
// 高概率漂移,提示模型谨慎
return ContextAdjustment.builder()
.action(ContextAction.ADD_HINT)
.systemHint(
"注意:检测到用户的消息可能切换了话题,请谨慎判断是否需要使用之前的上下文信息。"
)
.build();
}
return ContextAdjustment.maintain();
}
}上下文归档与恢复
用户切换话题后,有时会想回到之前的话题:"对了,刚才说的那个订单……"。所以切换时不能直接丢弃,而要归档:
@Service
public class TopicArchiveManager {
private final Map<String, List<TopicArchive>> sessionArchives = new ConcurrentHashMap<>();
public TopicArchive archiveTopic(ConversationContext context) {
// 提取当前话题的核心信息
String topicSummary = summarizeTopic(context);
Set<String> keyEntities = extractKeyEntities(context);
TopicArchive archive = TopicArchive.builder()
.id(UUID.randomUUID().toString())
.sessionId(context.getSessionId())
.topicSummary(topicSummary)
.keyEntities(keyEntities)
.turnCount(context.getHistory().size())
.archivedAt(Instant.now())
.fullHistory(context.getHistory())
.build();
sessionArchives.computeIfAbsent(context.getSessionId(), k -> new ArrayList<>())
.add(archive);
return archive;
}
/**
* 检测用户是否在引用之前归档的话题
*/
public Optional<TopicArchive> detectTopicReference(String message, String sessionId) {
List<TopicArchive> archives = sessionArchives.getOrDefault(sessionId, List.of());
if (archives.isEmpty()) return Optional.empty();
// 检测"刚才"、"之前"、"前面说的"等引用词
boolean hasBackReference = List.of("刚才", "之前", "前面", "上面", "那个").stream()
.anyMatch(message::contains);
if (!hasBackReference) return Optional.empty();
// 找最相关的归档话题
return archives.stream()
.max(Comparator.comparingDouble(
archive -> calculateRelevance(message, archive)
));
}
public void restoreTopic(ConversationContext context, TopicArchive archive) {
// 恢复归档话题的上下文
context.restoreFromArchive(archive);
log.info("恢复话题:{}", archive.getTopicSummary());
}
}在Agent主流程中集成漂移检测
把漂移检测集成进对话处理主流程:
@Service
public class ConversationManager {
private final IntentDriftDetector driftDetector;
private final LlmIntentDriftDetector llmDriftDetector;
private final DriftResponseHandler driftHandler;
private final TopicArchiveManager archiveManager;
private final AgentExecutor agentExecutor;
public AgentResponse processMessage(String message, ConversationContext context) {
// 第一步:检查是否是对归档话题的引用
Optional<TopicArchive> topicReference = archiveManager.detectTopicReference(
message, context.getSessionId()
);
if (topicReference.isPresent()) {
archiveManager.restoreTopic(context, topicReference.get());
}
// 第二步:漂移检测(先用规则,规则不确定再用LLM)
DriftAnalysis driftAnalysis = driftDetector.analyze(message, context);
if (driftAnalysis.getDriftScore() > 0.4 && driftAnalysis.getDriftScore() < 0.8) {
// 规则检测不确定,用LLM做更精细的判断
driftAnalysis = llmDriftDetector.analyze(message, context.getRecentTurns(5));
}
// 第三步:根据漂移分析调整上下文
ContextAdjustment adjustment = driftHandler.handleDrift(driftAnalysis, context);
context.apply(adjustment);
// 第四步:记录漂移事件
if (driftAnalysis.isDrifted()) {
logDriftEvent(driftAnalysis, context, message);
}
// 第五步:正常执行Agent
return agentExecutor.execute(context);
}
private void logDriftEvent(DriftAnalysis analysis, ConversationContext context, String message) {
DriftEvent event = DriftEvent.builder()
.sessionId(context.getSessionId())
.userId(context.getUserId())
.driftType(analysis.getDriftType())
.driftScore(analysis.getDriftScore())
.prevTopic(analysis.getPrevTopic())
.newTopic(analysis.getNewTopic())
.triggerMessage(message)
.timestamp(Instant.now())
.build();
driftEventRepository.save(event);
}
}一个需要注意的陷阱
意图漂移检测做过头了,会产生副作用:误判正常的话题延伸为话题切换,导致上下文被错误清空,用户会感到困惑——"我刚说的它怎么忘了?"
我的建议是宁可漂移漏检,也不要误检。漂移漏检的代价是AI回复有些偏题,用户最多有点迷惑;误检的代价是AI失忆,用户会觉得AI很蠢。
在具体实现上,给漂移判断设置一个较高的触发阈值(我通常用0.65以上),并且在话题切换时,选择"轻度清空"而非"彻底清空"——保留实体信息,只清除具体的业务细节。
// 轻度清空:只清除业务细节,保留实体和用户信息
private ContextAdjustment lightReset(ConversationContext context) {
return ContextAdjustment.builder()
.action(ContextAction.SOFT_RESET)
.preserveEntityMemory(true) // 保留实体记忆
.preserveUserProfile(true) // 保留用户信息
.clearBusinessContext(true) // 清除业务上下文
.clearToolCallHistory(true) // 清除工具调用历史
.build();
}意图漂移检测不是万能的,它的核心价值是让系统"知道自己什么时候应该忘记"。做对了,对话会更自然;做过了,反而会让用户觉得AI记性差。把握好这个度,是做好多轮对话系统的关键功力之一。
