第2105篇:LLM增强的个性化推荐系统——解决协同过滤的冷启动和长尾问题
2026/4/30大约 10 分钟
第2105篇:LLM增强的个性化推荐系统——解决协同过滤的冷启动和长尾问题
适读人群:推荐系统工程师,想引入LLM能力的团队 | 阅读时长:约20分钟 | 核心价值:掌握LLM如何解决传统推荐系统的冷启动、长尾内容、可解释性三大痛点
传统推荐系统做了十几年,矩阵分解、协同过滤、深度CTR模型,这些都已经很成熟了。但还有三个问题一直没解决好:
- 冷启动:新用户没历史行为,不知道推什么
- 长尾内容:交互数据少的内容永远被忽视,形成马太效应
- 可解释性:"为你推荐"背后的原因说不清楚
LLM在这三个问题上有天然优势,但不是替代传统推荐,而是补充。这篇文章讲如何做好这个组合。
整体架构
LLM用户画像提炼
/**
* 从用户行为中提炼结构化兴趣画像
*
* 传统方法:统计各类目的点击/购买频率
* LLM方法:理解行为序列背后的语义意图,提炼更抽象的兴趣标签
*
* 例:用户看了5篇关于"Python调用API"的文章
* 传统:兴趣词=Python, API
* LLM:兴趣={编程语言:Python, 技能水平:初中级, 场景:后端集成,
* 深层需求:提升工程能力}
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class UserProfileBuilder {
private final ChatLanguageModel llm;
private final UserBehaviorRepository behaviorRepo;
/**
* 基于近期行为构建用户兴趣画像
*/
public UserInterestProfile buildProfile(String userId) {
// 获取近30天的交互数据
List<UserBehavior> recentBehaviors = behaviorRepo.getRecentBehaviors(userId, 30);
if (recentBehaviors.isEmpty()) {
return UserInterestProfile.empty(userId);
}
// 构建行为描述(给LLM看的输入)
String behaviorSummary = summarizeBehaviors(recentBehaviors);
String prompt = """
请分析以下用户的内容消费行为,提炼用户兴趣画像。
用户行为(近30天):
%s
请返回JSON格式的兴趣画像:
{
"primaryInterests": ["主要兴趣领域,按重要性排序"],
"skillLevel": {
"domain": "领域名称",
"level": "BEGINNER/INTERMEDIATE/ADVANCED/EXPERT"
},
"contentPreferences": {
"preferredFormat": "TUTORIAL/CASE_STUDY/NEWS/DEEP_DIVE",
"preferredLength": "SHORT/MEDIUM/LONG",
"prefersTechnical": true/false
},
"currentFocus": "用户当前最可能在关注的主题(1句话)",
"inferredGoals": ["推断的用户目标,例如学习XX技术、解决XX问题"]
}
只返回JSON,不要解释。
""".formatted(behaviorSummary);
try {
String response = llm.generate(prompt);
return parseProfile(userId, response);
} catch (Exception e) {
log.error("用户画像构建失败: userId={}", userId, e);
return UserInterestProfile.fromKeywords(userId, extractKeywords(recentBehaviors));
}
}
private String summarizeBehaviors(List<UserBehavior> behaviors) {
// 按交互类型聚合
Map<String, List<String>> byType = behaviors.stream()
.collect(Collectors.groupingBy(
UserBehavior::getInteractionType,
Collectors.mapping(UserBehavior::getContentTitle, Collectors.toList())
));
StringBuilder sb = new StringBuilder();
if (byType.containsKey("COMPLETED")) {
sb.append("完整阅读/观看:\n");
byType.get("COMPLETED").stream().limit(10)
.forEach(title -> sb.append("- ").append(title).append("\n"));
}
if (byType.containsKey("BOOKMARKED")) {
sb.append("收藏:\n");
byType.get("BOOKMARKED").stream().limit(5)
.forEach(title -> sb.append("- ").append(title).append("\n"));
}
if (byType.containsKey("SEARCHED")) {
sb.append("搜索关键词:");
sb.append(byType.get("SEARCHED").stream().limit(10)
.collect(Collectors.joining(", ")));
sb.append("\n");
}
return sb.toString();
}
private UserInterestProfile parseProfile(String userId, String json) {
try {
String cleaned = json.substring(json.indexOf('{'), json.lastIndexOf('}') + 1);
ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readTree(cleaned);
List<String> interests = new ArrayList<>();
for (JsonNode i : node.path("primaryInterests")) {
interests.add(i.asText());
}
String currentFocus = node.path("currentFocus").asText("");
List<String> goals = new ArrayList<>();
for (JsonNode g : node.path("inferredGoals")) {
goals.add(g.asText());
}
return new UserInterestProfile(userId, interests, currentFocus, goals,
extractContentPreferences(node), LocalDateTime.now());
} catch (Exception e) {
log.warn("画像解析失败: {}", e.getMessage());
return UserInterestProfile.empty(userId);
}
}
private UserInterestProfile.ContentPreferences extractContentPreferences(JsonNode node) {
JsonNode prefs = node.path("contentPreferences");
return new UserInterestProfile.ContentPreferences(
prefs.path("preferredFormat").asText("TUTORIAL"),
prefs.path("preferredLength").asText("MEDIUM"),
prefs.path("prefersTechnical").asBoolean(true)
);
}
private List<String> extractKeywords(List<UserBehavior> behaviors) {
return behaviors.stream()
.map(UserBehavior::getContentTags)
.flatMap(Collection::stream)
.distinct()
.limit(10)
.toList();
}
@Data
@AllArgsConstructor
public static class UserInterestProfile {
private String userId;
private List<String> primaryInterests;
private String currentFocus;
private List<String> inferredGoals;
private ContentPreferences contentPreferences;
private LocalDateTime updatedAt;
static UserInterestProfile empty(String userId) {
return new UserInterestProfile(userId, List.of(), "",
List.of(), new ContentPreferences("TUTORIAL", "MEDIUM", true),
LocalDateTime.now());
}
static UserInterestProfile fromKeywords(String userId, List<String> keywords) {
return new UserInterestProfile(userId, keywords, "",
List.of(), new ContentPreferences("TUTORIAL", "MEDIUM", true),
LocalDateTime.now());
}
public record ContentPreferences(
String preferredFormat, String preferredLength, boolean prefersTechnical) {}
}
}LLM内容语义特征提取
/**
* 从内容中提取LLM语义特征
*
* 用于:
* 1. 内容向量化(用于召回)
* 2. 内容标签生成(用于匹配)
* 3. 冷启动内容(无交互数据也能被推荐)
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ContentSemanticFeatureExtractor {
private final ChatLanguageModel llm;
private final EmbeddingModel embeddingModel;
/**
* 提取内容的语义特征
*/
public ContentSemanticFeature extract(ContentItem content) {
String prompt = """
请分析以下内容,提取语义特征标签。
标题:%s
摘要:%s
请返回JSON:
{
"semanticTags": ["语义标签,按重要性排序,最多10个"],
"targetAudience": ["适合哪类读者,例如:Java初学者/高级架构师"],
"difficultyLevel": "BEGINNER/INTERMEDIATE/ADVANCED",
"contentType": "TUTORIAL/CASE_STUDY/NEWS/OPINION/REFERENCE",
"keyTopics": ["核心话题,最多5个"],
"relatedDomains": ["相关技术领域"],
"practicalValue": "HIGH/MEDIUM/LOW"
}
只返回JSON。
""".formatted(
content.getTitle(),
truncate(content.getSummary(), 500)
);
// LLM提取结构化特征
ContentSemanticFeature.Builder builder = ContentSemanticFeature.builder()
.contentId(content.getContentId());
try {
String response = llm.generate(prompt);
parseAndFillFeature(builder, response);
} catch (Exception e) {
log.warn("内容特征提取失败: contentId={}", content.getContentId());
}
// Embedding向量(用于向量召回)
String embeddingText = buildEmbeddingText(content);
try {
float[] vector = embeddingModel.embed(embeddingText).content().vector();
builder.embeddingVector(vector);
} catch (Exception e) {
log.warn("内容向量化失败: contentId={}", content.getContentId());
}
return builder.build();
}
private String buildEmbeddingText(ContentItem content) {
return String.format("%s\n%s\n%s",
content.getTitle(),
content.getSummary() != null ? content.getSummary() : "",
String.join(" ", content.getExistingTags())
).trim();
}
private void parseAndFillFeature(
ContentSemanticFeature.Builder builder, String json) {
try {
String cleaned = json.substring(json.indexOf('{'), json.lastIndexOf('}') + 1);
ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readTree(cleaned);
List<String> semanticTags = new ArrayList<>();
for (JsonNode t : node.path("semanticTags")) {
semanticTags.add(t.asText());
}
builder.semanticTags(semanticTags)
.difficultyLevel(node.path("difficultyLevel").asText("INTERMEDIATE"))
.contentType(node.path("contentType").asText("TUTORIAL"))
.practicalValue(node.path("practicalValue").asText("MEDIUM"));
} catch (Exception e) {
log.warn("内容特征解析失败: {}", e.getMessage());
}
}
private String truncate(String text, int maxLen) {
if (text == null) return "";
return text.length() > maxLen ? text.substring(0, maxLen) : text;
}
@Data
@Builder
public static class ContentSemanticFeature {
private String contentId;
private List<String> semanticTags;
private String difficultyLevel;
private String contentType;
private String practicalValue;
private float[] embeddingVector;
}
}LLM重排序(精排层)
/**
* LLM驱动的重排序
*
* 在精排候选集(通常50-100条)中,
* 用LLM理解用户画像和内容语义,做最终排序
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LlmReranker {
private final ChatLanguageModel llm;
private final UserProfileBuilder profileBuilder;
/**
* 对候选集做最终重排序
*
* 注意:LLM重排序有延迟(200-500ms),只用于精排的最后阶段
* 不能在毫秒级的召回/粗排层使用
*/
public List<RecommendedItem> rerank(
String userId,
List<CandidateItem> candidates,
int topN) {
if (candidates.isEmpty()) return List.of();
// 获取用户画像(缓存版本,不实时计算)
UserProfileBuilder.UserInterestProfile profile =
getCachedProfile(userId);
// 内容太多就截断(LLM上下文有限制)
int maxCandidates = Math.min(candidates.size(), 30);
List<CandidateItem> subset = candidates.subList(0, maxCandidates);
String prompt = buildRerankPrompt(profile, subset, topN);
try {
String response = llm.generate(prompt);
return parseRerankResult(response, candidates);
} catch (Exception e) {
log.error("LLM重排序失败,降级到原始顺序: {}", e.getMessage());
return candidates.stream()
.limit(topN)
.map(c -> new RecommendedItem(c.contentId(), c.originalScore(), ""))
.toList();
}
}
private String buildRerankPrompt(
UserProfileBuilder.UserInterestProfile profile,
List<CandidateItem> candidates, int topN) {
String userProfileSection = String.format("""
**用户画像**:
- 主要兴趣:%s
- 当前关注:%s
- 目标:%s
""",
String.join(", ", profile.getPrimaryInterests()),
profile.getCurrentFocus(),
profile.getInferredGoals().isEmpty() ? "未知" :
profile.getInferredGoals().get(0)
);
StringBuilder candidatesSection = new StringBuilder("**候选内容**(附ID和摘要):\n");
for (int i = 0; i < candidates.size(); i++) {
CandidateItem item = candidates.get(i);
candidatesSection.append(String.format(
"%d. [%s] %s - %s\n",
i + 1, item.contentId(), item.title(),
truncate(item.summary(), 100)
));
}
return userProfileSection + "\n" + candidatesSection + "\n" + String.format("""
请根据用户画像,从以上候选内容中选出最值得推荐的%d条,并说明推荐原因。
排序标准:
1. 与用户当前关注点的相关性(权重最高)
2. 内容质量和实用价值
3. 避免重复推荐同一主题
4. 适合用户技能水平
返回JSON:
{
"ranked": [
{
"contentId": "内容ID",
"reason": "推荐原因(10字以内)",
"score": 0.0-1.0
}
]
}
只返回JSON,不要其他文字。
""", topN);
}
private List<RecommendedItem> parseRerankResult(
String response, List<CandidateItem> allCandidates) {
try {
String json = response.substring(response.indexOf('{'), response.lastIndexOf('}') + 1);
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(json);
Map<String, CandidateItem> candidateMap = allCandidates.stream()
.collect(Collectors.toMap(CandidateItem::contentId, c -> c));
List<RecommendedItem> result = new ArrayList<>();
for (JsonNode item : root.path("ranked")) {
String contentId = item.path("contentId").asText();
String reason = item.path("reason").asText("");
double score = item.path("score").asDouble(0.5);
if (candidateMap.containsKey(contentId)) {
result.add(new RecommendedItem(contentId, score, reason));
}
}
return result;
} catch (Exception e) {
log.warn("重排序结果解析失败: {}", e.getMessage());
return allCandidates.stream()
.limit(10)
.map(c -> new RecommendedItem(c.contentId(), c.originalScore(), ""))
.toList();
}
}
private UserProfileBuilder.UserInterestProfile getCachedProfile(String userId) {
// 实际应该有缓存机制,不是每次都实时构建
return profileBuilder.buildProfile(userId);
}
private String truncate(String text, int maxLen) {
if (text == null) return "";
return text.length() > maxLen ? text.substring(0, maxLen) + "..." : text;
}
public record CandidateItem(
String contentId, String title, String summary, double originalScore) {}
public record RecommendedItem(String contentId, double score, String reason) {}
}冷启动解决方案
/**
* 冷启动解决:新用户的首次推荐
*
* 传统方案:推热门内容(效果差,同质化严重)
* LLM方案:通过简短问答了解用户,生成个性化初始画像
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ColdStartOnboardingService {
private final ChatLanguageModel llm;
private final ContentRepository contentRepo;
/**
* 快速问卷:3个问题了解新用户
*
* 设计原则:问题少、回答容易、信息量大
*/
public OnboardingQuestion getNextQuestion(
String userId, List<OnboardingAnswer> previousAnswers) {
if (previousAnswers.isEmpty()) {
return new OnboardingQuestion(1,
"您主要使用我们的平台目的是?",
List.of("学习新技术", "解决工作问题", "了解行业动态", "其他"),
"select");
}
if (previousAnswers.size() == 1) {
return new OnboardingQuestion(2,
"您目前最关注的技术领域是?",
List.of("后端开发", "前端开发", "AI/机器学习", "云原生/DevOps", "数据工程"),
"multi_select");
}
if (previousAnswers.size() == 2) {
return new OnboardingQuestion(3,
"您的技术背景?",
List.of("在校学生/应届生", "1-3年工作经验", "3-5年工作经验", "5年以上"),
"select");
}
return null; // 问卷完成
}
/**
* 根据问卷答案生成初始推荐
*/
public List<String> generateInitialRecommendations(
List<OnboardingAnswer> answers, int count) {
String answerSummary = answers.stream()
.map(a -> a.question() + ":" + String.join(",", a.selectedOptions()))
.collect(Collectors.joining("\n"));
// 用LLM理解用户答案,转化为内容检索标签
String prompt = """
新用户填写了以下调查问卷:
%s
请分析并生成5-8个适合这个用户的内容检索关键词(代表他们可能感兴趣的内容类型)。
返回JSON:{"keywords": ["关键词1", "关键词2", ...]}
只返回JSON。
""".formatted(answerSummary);
try {
String response = llm.generate(prompt);
String json = response.substring(response.indexOf('{'), response.lastIndexOf('}') + 1);
ObjectMapper mapper = new ObjectMapper();
List<String> keywords = new ArrayList<>();
for (JsonNode kw : mapper.readTree(json).path("keywords")) {
keywords.add(kw.asText());
}
// 基于关键词找内容
return contentRepo.findByKeywords(keywords, count).stream()
.map(c -> c.getContentId())
.toList();
} catch (Exception e) {
log.error("冷启动推荐失败: {}", e.getMessage());
// 降级:推最高质量的内容
return contentRepo.findTopQuality(count).stream()
.map(c -> c.getContentId())
.toList();
}
}
public record OnboardingQuestion(
int step, String question, List<String> options, String type) {}
public record OnboardingAnswer(
String question, List<String> selectedOptions) {}
}可解释推荐
/**
* 推荐理由生成
*
* 用户问"为什么推荐这个给我"时能给出清晰答案
* 也可以主动展示推荐理由,增加点击率
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class RecommendationExplanationService {
private final ChatLanguageModel llm;
/**
* 生成推荐理由(批量,减少LLM调用次数)
*/
public Map<String, String> generateExplanations(
UserProfileBuilder.UserInterestProfile profile,
List<ContentItem> items) {
if (items.isEmpty()) return Map.of();
String userContext = String.format("用户兴趣:%s,当前关注:%s",
String.join("、", profile.getPrimaryInterests()),
profile.getCurrentFocus());
StringBuilder itemsSection = new StringBuilder();
for (ContentItem item : items) {
itemsSection.append(String.format("[%s] %s\n",
item.getContentId(), item.getTitle()));
}
String prompt = """
根据用户画像,为以下推荐内容生成简短的个性化推荐理由(每条不超过15字)。
用户信息:%s
推荐内容:
%s
返回JSON(contentId → reason映射):
{
"explanations": {
"contentId1": "推荐理由",
"contentId2": "推荐理由"
}
}
推荐理由风格:自然、个性化,避免"因为你..."这类固定句式
只返回JSON。
""".formatted(userContext, itemsSection);
try {
String response = llm.generate(prompt);
return parseExplanations(response);
} catch (Exception e) {
log.warn("推荐理由生成失败: {}", e.getMessage());
return Map.of();
}
}
private Map<String, String> parseExplanations(String json) {
try {
String cleaned = json.substring(json.indexOf('{'), json.lastIndexOf('}') + 1);
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(cleaned);
Map<String, String> result = new HashMap<>();
root.path("explanations").fields()
.forEachRemaining(entry ->
result.put(entry.getKey(), entry.getValue().asText()));
return result;
} catch (Exception e) {
log.warn("推荐理由解析失败: {}", e.getMessage());
return Map.of();
}
}
}实践建议
LLM重排序的延迟问题
LLM重排序的延迟通常在200-800ms,这在某些实时场景(比如首页feed加载)是不可接受的。解决方案:对于实时场景,使用预计算的画像+传统排序;LLM只用于非实时场景(比如每天更新一次的个性化专题)或延迟容忍的场景(用户主动点击"为我推荐")。
画像更新频率
用户兴趣会随时间演变,画像不能只在注册时建一次就不更新。但每次对话后都重新构建画像也太慢。合理的策略:每天增量更新一次(加入最近一天的行为),每周全量重建一次(避免旧画像累积错误)。
推荐多样性
LLM重排序有时会导致推荐结果聚集(都是同一类型的内容),因为LLM会给和用户兴趣完全匹配的内容很高的分数。需要显式加入多样性约束:同一来源的内容不超过3条,同一主题不超过2条。这个约束应该在排序后做规则过滤,而不是期望LLM自觉控制。
