AI驱动的用户画像:用LLM理解用户行为和意图
AI驱动的用户画像:用LLM理解用户行为和意图
date: 2026-10-10 tags: [用户画像, 意图识别, 个性化, Spring AI, Java]
一、真实故事:点击率从3%到9%的三倍提升
2025年6月,赵静在某在线教育APP的产品复盘会上说了一句让她自己都心寒的话:
"我们有1200万注册用户,3000门课程,但是个性化推荐的点击率只有3%。也就是说,97%的时候,我们给用户推的东西,他们根本不想看。"
会议室里沉默了几秒。
他们的推荐系统并非没做。传统画像都有:年龄、职业、历史购课记录、完课率、常看类目。算法工程师用协同过滤和矩阵分解做了推荐,但这个3%的数字2年没有明显改善。
问题出在哪里?赵静找来一批真实用户访谈后发现了核心矛盾:
用户画像记录了"他们做过什么",但不知道"他们想要什么"。
一个用户历史上买过5门Java课,但现在他想转型做AI产品经理,对技术课已经完全失去兴趣。传统画像还在给他推Java高级课,他当然不点。
另一个用户最近浏览行为是:周一晚9点看了15分钟《Python数据分析》就退出了,周二下班后看了《产品思维》3分钟又退出,周三午休打开了《副业变现》看了40分钟。传统画像看不出这背后的信息:这个人在考虑副业,对实操内容的停留时间远高于理论内容。
接下来4个月,赵静的团队引入了LLM驱动的用户画像系统:
- 用LLM解析用户的行为序列,推断当前的"真实意图"
- 动态更新用户画像(不再依赖离线批处理)
- 根据画像调整推荐策略和内容呈现方式
A/B测试结果:实验组推荐点击率从3%提升到9%(提升200%),购课转化率提升65%,用户7日留存率提升23%。
二、传统用户画像 vs AI驱动画像
2.1 本质差异
传统画像的核心局限:
- 快照式:画像是过去行为的统计,无法反映当前意图
- 结构化偏见:只能表达可以枚举的维度,遗漏复杂的人类意图
- 滞后性:通常T+1更新,实时性差
- 可解释性差:为什么给你推这个,说不清楚
AI驱动画像的优势:
- 用自然语言描述用户当前状态:"正在考虑从Java转向AI领域,关注副业变现,时间碎片化(主要在通勤时间学习)"
- 实时理解行为变化
- 可以直接用于LLM的上下文,无需转码
三、行为日志理解:用LLM解析用户操作序列
3.1 行为日志数据模型
// 用户行为事件
@Entity
@Table(name = "user_behavior_events")
public class UserBehaviorEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String userId;
private String eventType; // PAGE_VIEW/CLICK/PURCHASE/SEARCH/COMPLETE
private String targetId; // 课程ID、文章ID等
private String targetTitle; // 便于LLM理解
private String targetCategory; // 分类
private Integer durationSeconds; // 停留时长(秒)
private LocalDateTime createdAt;
// 上下文信息
private String deviceType; // MOBILE/PC/TABLET
private String timeOfDay; // MORNING/NOON/EVENING/NIGHT
private String dayOfWeek; // WEEKDAY/WEEKEND
}3.2 行为序列预处理
@Service
@Slf4j
public class BehaviorSequenceProcessor {
// 将原始事件列表转化为LLM可理解的自然语言描述
public String formatBehaviorSequence(List<UserBehaviorEvent> events) {
if (events.isEmpty()) return "暂无行为记录";
// 按时间分组(每天一组)
Map<LocalDate, List<UserBehaviorEvent>> byDate = events.stream()
.collect(Collectors.groupingBy(
e -> e.getCreatedAt().toLocalDate()
));
StringBuilder sb = new StringBuilder();
// 只处理最近7天,按时间倒序
byDate.entrySet().stream()
.sorted(Map.Entry.<LocalDate, List<UserBehaviorEvent>>comparingByKey().reversed())
.limit(7)
.forEach(entry -> {
LocalDate date = entry.getKey();
List<UserBehaviorEvent> dayEvents = entry.getValue();
sb.append(String.format("【%s(%s)】\n",
date.toString(),
isWeekend(date) ? "周末" : "工作日"
));
// 合并同一内容的多次浏览
Map<String, Long> contentDuration = dayEvents.stream()
.filter(e -> e.getDurationSeconds() != null)
.collect(Collectors.groupingBy(
e -> e.getTargetTitle() + "|" + e.getTargetCategory(),
Collectors.summingLong(UserBehaviorEvent::getDurationSeconds)
));
contentDuration.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.forEach(e -> {
String[] parts = e.getKey().split("\\|");
String title = parts[0];
String category = parts.length > 1 ? parts[1] : "未知";
long seconds = e.getValue();
sb.append(String.format(" - 浏览《%s》(%s类)约%s\n",
title, category, formatDuration(seconds)
));
});
// 搜索词
List<String> searchKeywords = dayEvents.stream()
.filter(e -> "SEARCH".equals(e.getEventType()))
.map(UserBehaviorEvent::getTargetId)
.distinct()
.limit(5)
.toList();
if (!searchKeywords.isEmpty()) {
sb.append(" - 搜索了: ").append(String.join("、", searchKeywords)).append("\n");
}
// 购买记录
List<String> purchases = dayEvents.stream()
.filter(e -> "PURCHASE".equals(e.getEventType()))
.map(e -> "《" + e.getTargetTitle() + "》")
.toList();
if (!purchases.isEmpty()) {
sb.append(" - 购买了: ").append(String.join("、", purchases)).append("\n");
}
sb.append("\n");
});
return sb.toString();
}
private String formatDuration(long seconds) {
if (seconds < 60) return seconds + "秒";
if (seconds < 3600) return (seconds / 60) + "分钟";
return String.format("%.1f小时", seconds / 3600.0);
}
private boolean isWeekend(LocalDate date) {
DayOfWeek day = date.getDayOfWeek();
return day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY;
}
}四、意图推断:从行为推断用户深层需求
4.1 LLM意图推断服务
@Service
@Slf4j
public class UserIntentInferenceService {
private final ChatClient chatClient;
private final BehaviorSequenceProcessor sequenceProcessor;
private static final String INTENT_INFERENCE_PROMPT = """
你是一名资深用户研究专家,擅长从用户行为数据推断其真实意图和需求。
请分析以下用户在在线教育平台的最近行为,推断其当前的学习意图和需求。
## 用户基本信息
职业:%s
工作年限:%s
过往学习:%s
## 最近7天行为记录
%s
请输出以下JSON格式的分析结果:
{
"currentStage": "用户当前所处的职业/学习阶段(一句话)",
"primaryIntent": "当前最主要的学习意图(一句话,要具体)",
"secondaryIntents": ["次要意图1", "次要意图2"],
"painPoints": ["用户最可能面临的痛点1", "痛点2"],
"learningStyle": "学习风格描述(如:碎片化/系统化,理论型/实操型)",
"urgency": "紧迫程度(HIGH/MEDIUM/LOW)及理由",
"recommendationFocus": "推荐内容应该聚焦在什么方向",
"avoidRecommend": "应该避免推荐什么类型内容",
"confidenceScore": 0.85,
"reasoning": "推断依据(2-3句话)"
}
""";
public UserIntent inferIntent(String userId) {
// 获取用户基础信息
UserProfile profile = userProfileRepo.findByUserId(userId);
// 获取最近7天行为
List<UserBehaviorEvent> recentEvents = behaviorRepo.findByUserIdAndCreatedAtAfter(
userId, LocalDateTime.now().minusDays(7)
);
if (recentEvents.size() < 5) {
log.info("用户 {} 行为数据不足,使用冷启动策略", userId);
return inferColdStartIntent(profile);
}
// 格式化行为序列
String behaviorDesc = sequenceProcessor.formatBehaviorSequence(recentEvents);
// 获取历史学习摘要
String learningHistory = summarizeLearningHistory(userId);
// 调用LLM推断意图
String prompt = String.format(INTENT_INFERENCE_PROMPT,
profile.getOccupation(),
profile.getWorkYears() + "年",
learningHistory,
behaviorDesc
);
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
UserIntent intent = parseIntentResponse(response, userId);
log.info("用户 {} 意图推断完成: primaryIntent={}, confidence={}",
userId, intent.getPrimaryIntent(), intent.getConfidenceScore());
return intent;
}
private String summarizeLearningHistory(String userId) {
List<String> completedCourses = courseRepo.findCompletedByUserId(userId).stream()
.map(c -> c.getTitle() + "(" + c.getCategory() + ")")
.limit(10)
.toList();
return completedCourses.isEmpty()
? "暂无完课记录"
: "已完成课程: " + String.join("、", completedCourses);
}
// 解析LLM输出
private UserIntent parseIntentResponse(String response, String userId) {
try {
// 提取JSON部分
String json = extractJson(response);
JsonNode node = objectMapper.readTree(json);
return UserIntent.builder()
.userId(userId)
.currentStage(node.get("currentStage").asText())
.primaryIntent(node.get("primaryIntent").asText())
.secondaryIntents(parseStringList(node.get("secondaryIntents")))
.painPoints(parseStringList(node.get("painPoints")))
.learningStyle(node.get("learningStyle").asText())
.urgency(Urgency.valueOf(node.get("urgency").asText().split("(")[0]))
.recommendationFocus(node.get("recommendationFocus").asText())
.avoidRecommend(node.get("avoidRecommend").asText())
.confidenceScore(node.get("confidenceScore").asDouble())
.reasoning(node.get("reasoning").asText())
.inferredAt(Instant.now())
.build();
} catch (Exception e) {
log.error("解析意图响应失败: userId={}", userId, e);
return UserIntent.fallback(userId);
}
}
}4.2 意图变化追踪
@Service
public class IntentChangeTracker {
// 检测意图是否发生显著变化(触发画像刷新)
public boolean isSignificantChange(UserIntent previous, UserIntent current) {
if (previous == null) return true;
// 主意图完全不同
if (!previous.getPrimaryIntent().equals(current.getPrimaryIntent())) {
double similarity = calculateTextSimilarity(
previous.getPrimaryIntent(),
current.getPrimaryIntent()
);
if (similarity < 0.6) {
log.info("检测到用户意图重大变化: {} -> {}",
previous.getPrimaryIntent(), current.getPrimaryIntent());
return true;
}
}
// 紧迫程度变化
if (previous.getUrgency() != current.getUrgency() &&
(previous.getUrgency() == Urgency.LOW || current.getUrgency() == Urgency.HIGH)) {
return true;
}
return false;
}
// 文本相似度(简化版)
private double calculateTextSimilarity(String text1, String text2) {
Set<String> words1 = tokenize(text1);
Set<String> words2 = tokenize(text2);
long intersection = words1.stream().filter(words2::contains).count();
long union = words1.size() + words2.size() - intersection;
return union == 0 ? 0 : (double) intersection / union;
}
}五、动态画像更新:实时增量Embedding
5.1 用户画像的向量表示
@Service
@Slf4j
public class UserProfileVectorService {
private final EmbeddingModel embeddingModel;
private final VectorStore userProfileVectorStore;
// 将用户画像转为向量并更新
public void updateProfileVector(String userId, UserIntent intent) {
// 1. 将画像的关键信息组合为一段描述文本
String profileText = buildProfileText(intent);
// 2. 生成Embedding
float[] newEmbedding = embeddingModel.embed(profileText).getOutput();
// 3. 获取历史画像Embedding
Optional<UserProfileVector> existing = profileVectorRepo.findByUserId(userId);
float[] finalEmbedding;
if (existing.isPresent()) {
// 增量更新:指数加权移动平均(新数据权重0.3,历史权重0.7)
float[] oldEmbedding = existing.get().getVector();
finalEmbedding = weightedAverage(oldEmbedding, newEmbedding, 0.3f);
log.debug("增量更新用户画像向量: userId={}", userId);
} else {
finalEmbedding = newEmbedding;
log.info("初始化用户画像向量: userId={}", userId);
}
// 4. 保存
UserProfileVector profileVector = UserProfileVector.builder()
.userId(userId)
.vector(finalEmbedding)
.profileText(profileText)
.updatedAt(Instant.now())
.build();
profileVectorRepo.save(profileVector);
// 5. 更新向量数据库(用于相似用户查找)
Document doc = new Document(profileText, Map.of("userId", userId));
userProfileVectorStore.add(List.of(doc));
}
private String buildProfileText(UserIntent intent) {
return String.format("""
用户当前状态:%s
主要学习意图:%s
次要需求:%s
痛点:%s
学习风格:%s
推荐方向:%s
""",
intent.getCurrentStage(),
intent.getPrimaryIntent(),
String.join(";", intent.getSecondaryIntents()),
String.join(";", intent.getPainPoints()),
intent.getLearningStyle(),
intent.getRecommendationFocus()
);
}
// 向量加权平均
private float[] weightedAverage(float[] old, float[] newer, float newWeight) {
float[] result = new float[old.length];
for (int i = 0; i < old.length; i++) {
result[i] = old[i] * (1 - newWeight) + newer[i] * newWeight;
}
return normalize(result);
}
private float[] normalize(float[] vector) {
double norm = 0;
for (float v : vector) norm += v * v;
norm = Math.sqrt(norm);
float[] normalized = new float[vector.length];
for (int i = 0; i < vector.length; i++) {
normalized[i] = (float) (vector[i] / norm);
}
return normalized;
}
// 查找相似用户(用于协同过滤增强)
public List<String> findSimilarUsers(String userId, int topK) {
UserProfileVector profile = profileVectorRepo.findByUserId(userId)
.orElseThrow(() -> new IllegalStateException("用户画像未初始化: " + userId));
return userProfileVectorStore.similaritySearch(
SearchRequest.query(profile.getProfileText()).withTopK(topK + 1)
).stream()
.map(doc -> (String) doc.getMetadata().get("userId"))
.filter(uid -> !uid.equals(userId))
.limit(topK)
.toList();
}
}5.2 画像更新调度策略
@Component
@Slf4j
public class ProfileUpdateScheduler {
private final UserIntentInferenceService intentService;
private final UserProfileVectorService vectorService;
private final IntentChangeTracker changeTracker;
// 触发式更新:用户行为后异步推断
@EventListener
@Async
public void onSignificantBehavior(SignificantBehaviorEvent event) {
String userId = event.getUserId();
log.debug("触发画像更新: userId={}, eventType={}", userId, event.getEventType());
try {
UserIntent newIntent = intentService.inferIntent(userId);
UserIntent previousIntent = intentRepo.findLatestByUserId(userId).orElse(null);
if (changeTracker.isSignificantChange(previousIntent, newIntent)) {
vectorService.updateProfileVector(userId, newIntent);
intentRepo.save(newIntent);
// 发布画像变更事件,触发推荐刷新
applicationEventPublisher.publishEvent(
new ProfileChangedEvent(userId, newIntent)
);
}
} catch (Exception e) {
log.error("画像更新失败: userId={}", userId, e);
}
}
// 批量刷新:每天凌晨处理最近有行为的用户
@Scheduled(cron = "0 30 2 * * ?")
public void batchRefreshProfiles() {
log.info("开始批量刷新用户画像...");
// 获取过去24小时有行为的用户
List<String> activeUsers = behaviorRepo.findActiveUserIds(
LocalDateTime.now().minusHours(24)
);
log.info("需要刷新画像的用户数: {}", activeUsers.size());
// 限流:每秒处理10个用户(避免LLM API超限)
RateLimiter rateLimiter = RateLimiter.create(10.0);
int updated = 0;
for (String userId : activeUsers) {
rateLimiter.acquire();
try {
UserIntent intent = intentService.inferIntent(userId);
vectorService.updateProfileVector(userId, intent);
intentRepo.save(intent);
updated++;
} catch (Exception e) {
log.error("批量更新失败: userId={}", userId, e);
}
}
log.info("批量画像刷新完成,成功: {}/{}", updated, activeUsers.size());
}
}六、隐私保护:用户画像的本地化处理
6.1 隐私保护架构
关键原则:原始行为数据不出本地,只发送抽象化的行为描述给LLM。
@Service
public class PrivacyPreservingBehaviorProcessor {
// 在发送给LLM之前,对行为数据进行脱敏和抽象
public String anonymizeBehaviorData(List<UserBehaviorEvent> events, String userId) {
// 1. 移除所有可识别个人身份的信息
List<AnonymizedEvent> anonymized = events.stream()
.map(e -> AnonymizedEvent.builder()
// 不发送用户ID
.contentType(e.getTargetCategory())
.contentTitle(e.getTargetTitle()) // 内容标题不算PII
.durationSeconds(e.getDurationSeconds())
.eventType(e.getEventType())
// 只保留时间特征,不发送精确时间
.timeSlot(e.getTimeOfDay())
.dayType(e.getDayOfWeek())
.build())
.toList();
// 2. 不发送搜索词原文(可能包含敏感信息)
// 改为发送搜索词的类别
return formatAnonymizedEvents(anonymized);
}
// 在本地完成的处理(不需要LLM)
public Map<String, Object> extractLocalFeatures(List<UserBehaviorEvent> events) {
Map<String, Object> features = new LinkedHashMap<>();
// 学习时间偏好(本地计算,不需要LLM)
Map<String, Long> timeSlotCount = events.stream()
.collect(Collectors.groupingBy(
e -> e.getTimeOfDay() != null ? e.getTimeOfDay() : "UNKNOWN",
Collectors.counting()
));
String preferredTimeSlot = timeSlotCount.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("UNKNOWN");
features.put("preferred_study_time", preferredTimeSlot);
// 平均单次学习时长
OptionalDouble avgDuration = events.stream()
.filter(e -> e.getDurationSeconds() != null && e.getDurationSeconds() > 0)
.mapToInt(UserBehaviorEvent::getDurationSeconds)
.average();
features.put("avg_session_duration_seconds", avgDuration.orElse(0));
// 设备偏好
Map<String, Long> deviceCount = events.stream()
.filter(e -> e.getDeviceType() != null)
.collect(Collectors.groupingBy(UserBehaviorEvent::getDeviceType, Collectors.counting()));
features.put("device_preference", deviceCount);
return features;
}
}6.2 数据使用透明度
// 用户可查询自己的画像
@RestController
@RequestMapping("/api/my-profile")
public class UserProfileController {
@GetMapping
public UserProfileView getMyProfile(@AuthenticationPrincipal UserDetails user) {
String userId = user.getUsername();
UserIntent intent = intentRepo.findLatestByUserId(userId)
.orElse(null);
if (intent == null) {
return UserProfileView.empty();
}
// 展示给用户的画像(可理解的自然语言)
return UserProfileView.builder()
.currentStage(intent.getCurrentStage())
.primaryIntent(intent.getPrimaryIntent())
.learningStyle(intent.getLearningStyle())
.lastUpdated(intent.getInferredAt())
// 告知用户这个画像是怎么得出的
.reasoning(intent.getReasoning())
.build();
}
// 用户可以纠正/更新自己的画像
@PutMapping("/correction")
public void correctProfile(
@AuthenticationPrincipal UserDetails user,
@RequestBody ProfileCorrectionRequest correction) {
profileService.applyUserCorrection(user.getUsername(), correction);
log.info("用户主动纠正画像: userId={}", user.getUsername());
}
// 用户可以清除画像数据
@DeleteMapping
public void deleteProfile(@AuthenticationPrincipal UserDetails user) {
profileService.deleteProfile(user.getUsername());
log.info("用户删除画像: userId={}", user.getUsername());
}
}七、画像存储:向量化数据库设计
7.1 多层存储设计
// 多层画像存储服务
@Service
@Slf4j
public class LayeredProfileStorage {
private final RedisTemplate<String, UserIntent> redisTemplate;
private final UserIntentRepository intentRepo;
private final UserProfileVectorService vectorService;
private static final Duration CACHE_TTL = Duration.ofHours(2);
public void saveIntent(UserIntent intent) {
String userId = intent.getUserId();
// 1. 更新Redis缓存(最快访问)
String cacheKey = "user:intent:" + userId;
redisTemplate.opsForValue().set(cacheKey, intent, CACHE_TTL);
// 2. 持久化到PostgreSQL
intentRepo.save(intent);
// 3. 异步更新向量库
CompletableFuture.runAsync(() -> {
try {
vectorService.updateProfileVector(userId, intent);
} catch (Exception e) {
log.error("异步更新向量失败: userId={}", userId, e);
}
});
}
public Optional<UserIntent> getLatestIntent(String userId) {
// 先查缓存
String cacheKey = "user:intent:" + userId;
UserIntent cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return Optional.of(cached);
}
// 缓存未命中,查DB
Optional<UserIntent> fromDb = intentRepo.findLatestByUserId(userId);
fromDb.ifPresent(intent -> {
// 回写缓存
redisTemplate.opsForValue().set(cacheKey, intent, CACHE_TTL);
});
return fromDb;
}
}八、个性化AI响应:根据用户画像调整回复风格
8.1 自适应响应生成
@Service
@Slf4j
public class PersonalizedResponseService {
private final ChatClient chatClient;
private final LayeredProfileStorage profileStorage;
// 根据用户画像生成个性化AI回复
public String generatePersonalizedResponse(String userId, String userMessage) {
// 获取用户当前画像
UserIntent intent = profileStorage.getLatestIntent(userId)
.orElse(null);
// 构建个性化系统提示
String systemPrompt = buildPersonalizedSystemPrompt(intent);
return chatClient.prompt()
.system(systemPrompt)
.user(userMessage)
.call()
.content();
}
private String buildPersonalizedSystemPrompt(UserIntent intent) {
if (intent == null) {
return "你是一个专业的在线教育助手,帮助用户解答学习相关问题。";
}
String learningStyle = intent.getLearningStyle();
String stage = intent.getCurrentStage();
String primaryIntent = intent.getPrimaryIntent();
// 根据学习风格调整回答方式
String styleInstruction = buildStyleInstruction(learningStyle);
// 根据紧迫程度调整建议的具体程度
String urgencyInstruction = switch (intent.getUrgency()) {
case HIGH -> "用户学习需求紧迫,直接给出最关键的建议,避免冗长铺垫";
case MEDIUM -> "适当展开说明,平衡深度和效率";
case LOW -> "可以给出更系统化的建议,鼓励深度探索";
};
return String.format("""
你是一名专业的在线教育顾问助手。
## 当前用户状态
- 所处阶段:%s
- 当前意图:%s
- 主要痛点:%s
## 回答风格要求
%s
## 紧迫程度指导
%s
## 注意事项
- 推荐课程时优先推荐「%s」方向的内容
- 避免推荐「%s」类型的内容(用户已过了这个阶段或无需求)
- 如果用户问与学习无关的问题,简短回答后引导回学习话题
""",
stage,
primaryIntent,
String.join(";", intent.getPainPoints()),
styleInstruction,
urgencyInstruction,
intent.getRecommendationFocus(),
intent.getAvoidRecommend()
);
}
private String buildStyleInstruction(String learningStyle) {
if (learningStyle.contains("碎片化")) {
return "用户学习时间碎片化,回答要简洁精炼,关键信息用要点列出,避免长段落";
} else if (learningStyle.contains("实操")) {
return "用户偏好实操型内容,多举具体例子和可操作步骤,减少纯理论讲解";
} else if (learningStyle.contains("系统")) {
return "用户偏好系统化学习,可以给出完整的知识框架和学习路径";
}
return "保持适中的深度,理论与实践结合";
}
}8.2 个性化推荐集成
@Service
public class PersonalizedRecommendationService {
private final UserProfileVectorService vectorService;
private final CourseVectorService courseVectorService;
private final UserIntentInferenceService intentService;
// 基于画像的课程推荐
public List<CourseRecommendation> recommend(String userId, int topK) {
// 1. 获取用户意图
UserIntent intent = intentService.inferIntent(userId);
// 2. 构建推荐查询(自然语言描述)
String recommendQuery = buildRecommendQuery(intent);
// 3. 从课程向量库中检索匹配的课程
List<Document> matchedCourses = courseVectorService.searchCourses(
recommendQuery, topK * 2 // 多取一些用于过滤
);
// 4. 过滤用户已学过的课程
Set<String> purchasedCourseIds = courseRepo.findPurchasedIds(userId);
List<CourseRecommendation> recommendations = matchedCourses.stream()
.filter(doc -> !purchasedCourseIds.contains(doc.getMetadata().get("courseId")))
.limit(topK)
.map(doc -> CourseRecommendation.builder()
.courseId((String) doc.getMetadata().get("courseId"))
.courseTitle((String) doc.getMetadata().get("title"))
.matchReason(generateMatchReason(doc, intent))
.relevanceScore(doc.getScore())
.build())
.toList();
log.info("为用户 {} 生成 {} 条推荐,意图: {}", userId, recommendations.size(), intent.getPrimaryIntent());
return recommendations;
}
private String buildRecommendQuery(UserIntent intent) {
return String.format("%s %s 适合%s的课程",
intent.getPrimaryIntent(),
intent.getRecommendationFocus(),
intent.getLearningStyle().contains("碎片化") ? "碎片时间学习" : "系统学习"
);
}
private String generateMatchReason(Document courseDoc, UserIntent intent) {
// 为什么这门课适合这个用户(展示给用户的理由)
String courseTitle = (String) courseDoc.getMetadata().get("title");
// 用LLM生成一句匹配理由(实际中可以缓存常见组合)
return chatClient.prompt()
.user(String.format("""
用户当前意图:%s
推荐课程:《%s》
用一句话(20字内)说明为什么推荐这门课,要具体指出与用户需求的匹配点。
直接输出这句话,不要其他内容。
""", intent.getPrimaryIntent(), courseTitle))
.call()
.content();
}
}九、冷启动:新用户的画像初始化
9.1 冷启动策略
@Service
public class ColdStartProfileService {
private final ChatClient chatClient;
// 注册时引导用户填写意向(5个关键问题)
public List<OnboardingQuestion> getOnboardingQuestions() {
return List.of(
new OnboardingQuestion("job_title", "你目前的职位是?",
List.of("Java工程师", "Python工程师", "前端工程师", "产品经理", "其他")),
new OnboardingQuestion("experience_years", "工作年限?",
List.of("1年以下", "1-3年", "3-5年", "5年以上")),
new OnboardingQuestion("primary_goal", "你最想提升的方向?",
List.of("AI/机器学习", "后端架构", "向管理转型", "副业变现", "考证/跳槽")),
new OnboardingQuestion("learning_time", "每天能用于学习的时间?",
List.of("15分钟以内", "30分钟", "1小时", "1小时以上")),
new OnboardingQuestion("biggest_challenge", "你现在最大的困惑?",
List.of("不知道学什么", "学了没时间练", "理论和工作脱节", "缺乏方向感"))
);
}
// 根据注册问卷生成初始画像
public UserIntent generateInitialProfile(String userId, Map<String, String> answers) {
String prompt = """
根据用户注册时填写的问卷,推断其学习意图和需求。
## 用户信息
职位:%s
工作年限:%s
主要目标:%s
每日学习时间:%s
最大困惑:%s
请输出JSON格式的用户意图分析(格式与正常意图分析相同)。
由于数据有限,confidenceScore应设为0.5-0.6。
""".formatted(
answers.get("job_title"),
answers.get("experience_years"),
answers.get("primary_goal"),
answers.get("learning_time"),
answers.get("biggest_challenge")
);
String response = chatClient.prompt().user(prompt).call().content();
return parseIntentResponse(response, userId);
}
// 冷启动期间:用相似用户画像辅助
public List<CourseRecommendation> coldStartRecommend(String userId, UserIntent initialIntent) {
// 找到意图最相似的已有用户
List<String> similarUsers = vectorService.findSimilarUsers(userId, 10);
if (similarUsers.isEmpty()) {
// 无相似用户,退化为基于意图的内容检索
return recommendByIntent(initialIntent);
}
// 取相似用户中最受欢迎的课程
Map<String, Long> popularCourses = similarUsers.stream()
.flatMap(uid -> courseRepo.findPurchasedIds(uid).stream())
.collect(Collectors.groupingBy(courseId -> courseId, Collectors.counting()));
return popularCourses.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(10)
.map(entry -> buildRecommendation(entry.getKey(), "相似用户也在学"))
.toList();
}
}十、效果评估:A/B测试设计
10.1 完整A/B测试框架
@Service
@Slf4j
public class PersonalizationABTestService {
// 实验分配(确保同一用户每次进入相同组)
public ExperimentGroup assignGroup(String userId, String experimentId) {
// 使用一致性哈希确保稳定分组
int hash = Math.abs((userId + experimentId).hashCode()) % 100;
ExperimentConfig config = experimentConfigRepo.findById(experimentId)
.orElseThrow(() -> new IllegalArgumentException("实验不存在: " + experimentId));
if (hash < config.getControlGroupPercent()) {
return ExperimentGroup.CONTROL;
} else {
return ExperimentGroup.TREATMENT;
}
}
// 记录曝光和转化
@EventListener
public void onRecommendationShown(RecommendationShownEvent event) {
String group = assignGroup(event.getUserId(), "personalization_v1").name();
experimentMetrics.recordImpression(
"personalization_v1", group, event.getUserId(), event.getCourseId()
);
}
@EventListener
public void onRecommendationClicked(RecommendationClickedEvent event) {
String group = assignGroup(event.getUserId(), "personalization_v1").name();
experimentMetrics.recordConversion(
"personalization_v1", group, event.getUserId(), event.getCourseId(), "click"
);
}
@EventListener
public void onCoursePurchased(CoursePurchasedEvent event) {
String group = assignGroup(event.getUserId(), "personalization_v1").name();
experimentMetrics.recordConversion(
"personalization_v1", group, event.getUserId(), event.getCourseId(), "purchase"
);
}
// 统计显著性检验
public ABTestResult analyzeExperiment(String experimentId, int minSampleSize) {
ExperimentStats control = experimentMetrics.getStats(experimentId, "CONTROL");
ExperimentStats treatment = experimentMetrics.getStats(experimentId, "TREATMENT");
if (control.getImpressions() < minSampleSize || treatment.getImpressions() < minSampleSize) {
return ABTestResult.insufficient("样本量不足,需要至少 " + minSampleSize + " 次曝光");
}
double controlCTR = control.getClickRate();
double treatmentCTR = treatment.getClickRate();
// Z检验
double zScore = calculateZScore(
control.getClicks(), control.getImpressions(),
treatment.getClicks(), treatment.getImpressions()
);
double pValue = calculatePValue(zScore);
boolean isSignificant = pValue < 0.05;
double relativeImprovement = (treatmentCTR - controlCTR) / controlCTR * 100;
return ABTestResult.builder()
.experimentId(experimentId)
.controlCTR(controlCTR)
.treatmentCTR(treatmentCTR)
.relativeImprovement(relativeImprovement)
.zScore(zScore)
.pValue(pValue)
.isStatisticallySignificant(isSignificant)
.controlSample(control.getImpressions())
.treatmentSample(treatment.getImpressions())
.recommendation(isSignificant && relativeImprovement > 10
? "建议全量上线" : "建议继续观察或放弃")
.build();
}
// Z检验统计量
private double calculateZScore(long clicks1, long total1, long clicks2, long total2) {
double p1 = (double) clicks1 / total1;
double p2 = (double) clicks2 / total2;
double p = (double)(clicks1 + clicks2) / (total1 + total2);
return (p2 - p1) / Math.sqrt(p * (1 - p) * (1.0/total1 + 1.0/total2));
}
}十一、系统性能数据
实测数据(100万活跃用户,8核16G单机):
| 指标 | 数值 | 说明 |
|---|---|---|
| 意图推断耗时 | 平均 1.2秒 | 含LLM调用 |
| 画像向量更新 | 平均 200ms | 含Embedding生成 |
| 个性化推荐 | 平均 85ms | 向量检索,有缓存 |
| 缓存命中率 | 78% | Redis TTL 2小时 |
| 每日推断数量 | ~50万次 | 批处理+触发式 |
| LLM API成本 | 约$80/天 | GPT-4o-mini |
成本优化技巧:
- 使用轻量级LLM(GPT-4o-mini)而非旗舰模型,意图推断精度损失<5%
- 画像缓存TTL设为2小时,大幅减少重复推断
- 仅对最近24小时有行为的用户做实时推断,其他用户每天批处理一次
十二、FAQ
Q:LLM意图推断准确率有多高?
A:与人工标注对比,主意图方向准确率约82%,次要意图约68%。但实践中发现,对推荐效果起决定性作用的是方向的大致准确性,而非精确性。方向判断对了,推荐效果就有明显提升。
Q:用户画像需要用户同意吗?
A:必须的。需要在注册协议中明确说明"我们使用您的行为数据生成个性化画像",并提供查看和删除画像的功能。遵守GDPR/PIPL等数据保护法规。
Q:LLM推断速度太慢,影响实时推荐怎么办?
A:推断和推荐分离。实时推荐直接用缓存的画像向量(毫秒级);意图推断在后台异步进行,不阻塞用户请求。
Q:新用户数据不足,冷启动效果差怎么优化?
A:三个策略:1)引导用户完成兴趣问卷(5个问题就够);2)用相似用户的数据辅助;3)前期快速收集行为(展示多种内容,观察哪个停留时间最长)。
Q:画像更新太频繁,LLM成本高怎么解决?
A:设置触发阈值,只有行为发生"显著变化"时才更新(比如搜索词类别改变、停留时间分布变化),而不是每次行为都更新。这样可以减少80%的不必要推断。
Q:如何判断AI画像是否真的比传统画像好?
A:用A/B测试,至少跑2周,确保样本量足够(每组至少1万用户)。关注三个核心指标:推荐点击率(直接效果)、7日留存率(体验改善)、人均收入贡献(商业价值)。三个指标都提升才算真正成功。
Q:画像中存储了哪些数据,有没有隐私风险?
A:合规的画像只存储行为特征和抽象描述,不存储原始行为记录中的敏感字段(真实姓名、精确地理位置、健康信息)。具体做法:用户行为数据留在本地数据库(有访问控制),发给LLM的只是格式化的行为描述,推断结果也是抽象的意图描述,不包含个人标识。
Q:多个产品(APP/小程序/Web)的行为数据如何统一到一个画像?
A:用户ID体系是关键。需要一个统一的用户标识(UID),各端的行为事件都带上这个UID。如果不同端登录态不通,先做账号打通(手机号/邮箱关联),再归一化行为数据。跨端画像的意图推断会更准确,因为数据更完整。
附录:用户画像系统的完整Spring Boot配置
# application.yml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini # 意图推断用轻量模型
temperature: 0.2 # 低温度,输出稳定
max-tokens: 1024
embedding:
options:
model: text-embedding-3-small
data:
redis:
host: ${REDIS_HOST}
port: 6379
lettuce:
pool:
max-active: 50
max-idle: 20
# 画像系统专属配置
user-profile:
intent-inference:
min-events-required: 5 # 至少5条行为才做推断
max-events-to-analyze: 50 # 最多分析50条事件
cache-ttl-hours: 2 # 推断结果缓存2小时
batch-cron: "0 30 2 * * ?" # 每天凌晨2:30批量刷新
rate-limit-per-second: 10 # 每秒最多推断10个用户
vector:
dimension: 1536 # text-embedding-3-small维度
update-weight: 0.3 # 增量更新权重
similar-user-top-k: 20 # 相似用户查找TopK
cold-start:
min-questionnaire-questions: 5
similar-user-fallback: true # 无问卷时用相似用户兜底
# 行为事件过滤
behavior:
significant-events:
- PURCHASE # 购买
- SEARCH # 搜索
- CATEGORY_CHANGE # 分类切换
- LONG_VIEW # 长时间浏览(>5分钟)
- COMPLETE # 完成(课程/视频)
min-view-duration-seconds: 30 # 少于30秒的浏览不计入// 完整的配置绑定类
@ConfigurationProperties(prefix = "user-profile")
@Data
@Component
public class UserProfileConfig {
private IntentInferenceConfig intentInference = new IntentInferenceConfig();
private VectorConfig vector = new VectorConfig();
private ColdStartConfig coldStart = new ColdStartConfig();
@Data
public static class IntentInferenceConfig {
private int minEventsRequired = 5;
private int maxEventsToAnalyze = 50;
private int cacheTtlHours = 2;
private String batchCron = "0 30 2 * * ?";
private double rateLimitPerSecond = 10.0;
}
@Data
public static class VectorConfig {
private int dimension = 1536;
private float updateWeight = 0.3f;
private int similarUserTopK = 20;
}
@Data
public static class ColdStartConfig {
private int minQuestionnaireQuestions = 5;
private boolean similarUserFallback = true;
}
}总结
AI驱动的用户画像与传统画像的核心区别在于:
- 从"历史统计"到"当前意图"——知道用户"想做什么"而非"做过什么"
- 从"结构化标签"到"自然语言描述"——可以直接用于LLM上下文
- 从"离线批处理"到"实时动态更新"——响应用户需求变化
- 从"黑盒模型"到"可解释推断"——用户可以理解为什么推这个
关键实施路径:行为序列格式化 → LLM意图推断 → 向量化存储 → 个性化检索 → A/B测试验证。
