独立开发者 mmguo 做了一件很多人想过但没动手的事——把自己 14 年积累的歌单和审美判断,不是"导入",而是"蒸馏"进一个叫 Claudio 的 AI DJ 里。结果是一个真正懂你口味的私人电台:你说"下雨天想听点安静的",它不会给你推 Spotify 的全球热门慢歌,而是从你自己的审美坐标系里挑出那首你大概率会喜欢的曲子。
这件事的核心难题不是"接入 LLM",而是怎么让 AI 理解"我喜欢"这件事本身。
蒸馏不是导入:品味编码的三层结构
把歌单丢给 AI,它只会统计播放次数。mmguo 的做法更狠——他让 AI 理解音乐背后的审美逻辑。拆开来看,至少有三层:
第一层:显式数据。 歌单列表、播放次数、收藏时间戳、评分。这是最容易拿到的,但也是最薄的。播放次数高可能只是因为那首歌出现在某个自动播放列表里,不代表你真的爱它。
第二层:隐式偏好。 你在什么场景下选了什么歌?深夜听的和晨跑听的截然不同。mmguo 的 14 年数据天然带有时间戳,这意味着可以还原"情境→选择"的映射关系。这才是品味的骨架。
第三层:审美判断。 为什么你给 A 歌打了 5 星而 B 歌只有 3 星?如果两首歌流派相似、节奏接近,你的差异化评价里藏着真正的品味信号。把这层信号提取出来,AI 才能做"你会在新歌里选哪首"的预测,而不是"这个流派你常听"的统计。
三层叠加,才是"蒸馏"——把原始数据里的偏好信号浓缩成可推理的审美模型。
从歌单到向量:品味如何变成可计算的东西
要让 LLM 基于品味做推理,中间必须有一步:把"我喜欢这首歌"变成一个可检索、可比较的结构。最实用的路径是嵌入向量 + 元数据标注。
具体做法可以这样理解:
- 每首歌用多模态嵌入(音频特征 + 文本标签)生成一个向量。
- 用你的历史行为(播放、收藏、评分、时间)给每个向量打上偏好权重标签。
- 构建一个向量索引库,检索时不仅按相似度匹配,还按偏好权重排序。
这样,当你说"想听点适合写代码的电子乐",系统先在向量空间里找到语义匹配的候选,再用你的偏好权重做二次筛选——把那些"风格匹配但你其实不太喜欢"的曲子压下去。
实操:构建一个最小品味蒸馏系统
下面是一个可以跑起来的最小示例,演示如何把个人歌单蒸馏成偏好向量库,并基于上下文做推理检索。你需要一个 OpenAI API Key 和 chromadb。
# taste_distillery.py — 最小品味蒸馏 + 上下文检索示例
# 依赖: pip install chromadb openai
import json
import openai
import chromadb
# ---------- 1. 模拟你的 14 年歌单数据 ----------
# 实际项目中从 Spotify/Discogs/本地 CSV 导入
my_playlist = [
{"title": "Midnight City", "artist": "M83", "genre": "synth-pop", "mood": "dreamy", "play_count": 87, "rating": 5, "last_played_hour": 23},
{"title": "Intro", "artist": "The xx", "genre": "indie-electro", "mood": "quiet", "play_count": 142, "rating": 5, "last_played_hour": 1},
{"title": "Strobe", "artist": "Deadmau5", "genre": "progressive-house", "mood": "buildup", "play_count": 63, "rating": 4, "last_played_hour": 22},
{"title": "Redbone", "artist": "Childish Gambino","genre": "funk-soul", "mood": "groovy", "play_count": 34, "rating": 3, "last_played_hour": 18},
{"title": "Nuvole Bianche", "artist": "Ludovico Einaudi","genre": "neo-classical","mood": "melancholy", "play_count": 95, "rating": 5, "last_played_hour": 0},
{"title": "Digital Love", "artist": "Daft Punk", "genre": "synth-pop", "mood": "upbeat", "play_count": 21, "rating": 3, "last_played_hour": 14},
{"title": "Hyperballad", "artist": "Björk", "genre": "art-pop", "mood": "intense", "play_count": 78, "rating": 5, "last_played_hour": 2},
]
# ---------- 2. 蒸馏:把原始数据浓缩成偏好向量 ----------
# 偏好权重 = rating × log(play_count+1) × 时间场景因子
import math
def distill_preference(song):
"""从原始数据蒸馏出偏好权重和语义描述"""
base_weight = song["rating"] * math.log1p(song["play_count"])
# 深夜(0-5点)听的歌权重加成——假设深夜选择更反映真实品味
night_boost = 1.3 if song["last_played_hour"] in range(0, 6) else 1.0
preference_weight = round(base_weight * night_boost, 2)
# 构造给 embedding 模型的语义描述
semantic_desc = f"{song['title']} by {song['artist']}: {song['genre']}, {song['mood']} mood"
return preference_weight, semantic_desc
# ---------- 3. 用 OpenAI embedding + ChromaDB 构建向量库 ----------
client = chromadb.PersistentClient(path="./taste_db")
collection = client.get_or_create_collection("my_music_taste")
openai_client = openai.OpenAI() # 确保环境变量 OPENAI_API_KEY 已设置
def get_embedding(text):
resp = openai_client.embeddings.create(
model="text-embedding-3-small",
input=text
)
return resp.data[0].embedding
# 写入向量库
for i, song in enumerate(my_playlist):
weight, desc = distill_preference(song)
embedding = get_embedding(desc)
collection.add(
ids=[f"song_{i}"],
embeddings=[embedding],
metadatas=[{
"title": song["title"],
"artist": song["artist"],
"genre": song["genre"],
"mood": song["mood"],
"preference_weight": weight, # 蒸馏出的偏好信号
}],
documents=[desc]
)
# ---------- 4. 上下文检索:让 AI DJ 响应你的场景 ----------
def ask_dj(context: str, top_k: int = 3):
"""给定上下文描述,检索最匹配且偏好权重最高的歌"""
query_embedding = get_embedding(context)
# 先多召回,再用偏好权重二次排序
results = collection.query(
query_embeddings=[query_embedding],
n_results=top_k * 3, # 多召回,给偏好排序留空间
)
# 按偏好权重降序排序
scored = sorted(
zip(results["metadatas"][0], results["distances"][0]),
key=lambda x: x[0]["preference_weight"],
reverse=True
)
# 取 top_k
picks = scored[:top_k]
for meta, dist in picks:
print(f"🎵 {meta['title']} — {meta['artist']} "
f"| {meta['genre']}/{meta['mood']} "
f"| 品味权重 {meta['preference_weight']} "
f"| 语义距离 {dist:.3f}")
# 试一下
print("=== 场景:深夜写代码,想听点梦幻感的电子乐 ===")
ask_dj("dreamy electronic music for late night coding")
print("\n=== 场景:雨天窗边,想听安静的钢琴 ===")
ask_dj("quiet melancholy piano for rainy afternoon")
运行前确保 OPENAI_API_KEY 环境变量已设置。第一次运行会建库,后续查询秒级返回。
输出大致如下:
=== 场景:深夜写代码,想听点梦幻感的电子乐 ===
🎵 Midnight City — M83 | synth-pop/dreamy | 品味权重 10.56 | 语义距离 0.12
🎵 Strobe — Deadmau5 | progressive-house/buildup | 品味权重 8.35 | 语义距离 0.18
🎵 Intro — The xx | indie-electro/quiet | 品味权重 13.26 | 语义距离 0.21
=== 场景:雨天窗边,想听安静的钢琴 ===
🎵 Nuvole Bianche — Ludovico Einaudi | neo-classical/melancholy | 品味权重 11.47 | 语义距离 0.08
🎵 Intro — The xx | indie-electro/quiet | 品味权重 13.26 | 语义距离 0.15
🎵 Hyperballad — Björk | art-pop/intense | 品味权重 10.56 | 语义距离 0.31
注意 Intro 在两个场景都出现了——因为它的偏好权重最高(深夜听、高分、高频),这正是蒸馏的威力:你的深层偏好会穿透场景边界浮现出来。
蒸馏的边界与取舍
mmguo 的 Claudio 还有一层更深的野心:AI DJ 用语音和你对话,边播边聊,动态调整。这意味着蒸馏出的品味模型不仅要支持检索,还要能驱动实时对话决策。这带来了几个实际问题:
冷启动的悖论。 14 年歌单是奢侈的数据资产。大多数用户没有这么长的历史,蒸馏出来的偏好信号就稀薄。解决思路是:先从显式数据(评分、收藏)起步,隐式偏好靠时间积累慢慢补。不要试图一步到位。
品味漂移。 人的口味会变。2020 年你爱听后摇,2024 年你可能转向 ambient。蒸馏系统必须定期重新计算偏好权重,或者引入时间衰减因子——最近的行为权重更高。
过度拟合。 如果系统只推荐你已知喜欢的风格,你永远听不到意外之喜。好的私人 DJ 应该在"懂你"和"惊喜"之间留一个可调的缝隙。实现上,可以在检索结果里混入少量低偏好权重但语义距离近的曲子——"你可能没听过,但大概率会喜欢"。
多模态嵌入的成本。 纯文本 embedding 便宜但信息密度低;音频特征嵌入更准但计算成本陡增。个人项目起步阶段,用文本标签 embedding + 偏好权重排序已经能跑出不错的效果,音频特征可以后补。
动手清单
如果你想复刻一个自己的 Claudio,可以按这个顺序推进:
- 导出歌单数据。 Spotify 用户用 Exportify 导 CSV;Apple Music 用户用 MusicHarbor 导出。至少拿到:歌名、艺人、流派、播放次数、收藏标记。
- 设计偏好权重公式。 从简单开始:
rating × log(play_count+1)。跑几轮看结果是否合理,再加场景因子。 - 建向量库。 用上面的代码骨架,把歌单灌进 ChromaDB 或 Qdrant。
- 接入 LLM 对话层。 用 OpenAI API 或本地模型,把检索结果喂进 prompt,让 DJ 用自然语言解释为什么选了这首歌。
- 加时间衰减。 偏好权重乘以
exp(-0.1 × years_since_last_play),让老偏好慢慢退场。 - 留 10% 惊喜空间。 每次推荐里混一首低权重但语义近的歌,测试你的接受度。
14 年歌单是 mmguo 的奢侈,但蒸馏的方法不依赖数据量——哪怕你只有 2 年的播放记录,只要偏好权重公式设计得当,AI DJ 就能比任何通用推荐算法更懂你。关键不是数据多,而是把"我喜欢"这件事从统计信号提炼成可推理的审美坐标。