在 Spotify,数据问题曾经有一个固定模式:先翻找相关仪表盘,再确认指标口径,然后找人核对——整个过程可能要跨 Slack、内部文档和好几个工具来回折腾。问题不是没有答案,而是答案散落在各处,需要一位"懂行的人"才能拼起来。
Spotify 的解法不是雇更多懂行的人,而是把这位领域专家的知识编码成一层上下文,喂给他们的数据助手(Data Assistant)。这篇文章拆解这层上下文层的设计思路,并给出一个可落地的实践方案。
从"找人问"到"找系统问"
传统数据平台的核心假设是:用户自己能找到数据、理解数据、用对数据。现实是,大多数人对数据的需求是问题驱动的——"上周北欧市场的留存率跌了多少?"——而不是表名驱动的。
领域专家的价值在于两点:
- 知道该看什么:哪个表是权威来源、哪个指标有已知偏差、哪个仪表盘已经过时。
- 知道该怎么解读:数值波动是季节性还是异常、口径变更发生在哪天、上下游依赖是什么。
Spotify 的上下文层本质上就是把这两类知识结构化,让助手在回答时不再只依赖 LLM 的通用推理,而是基于经过编码的领域事实做判断。
上下文层的三个组成部分
根据 Spotify 工程团队的实践,上下文层大致由三块拼成:
1. 数据实体注册表(Entity Registry)
不是简单的数据目录,而是给每个核心数据实体打上语义标签。一张表不只是"有 47 列的 Hive 表",而是"北欧市场日级留存指标的唯一权威来源,口径自 2023-06 起从 7 天改为 30 天"。
这类信息需要领域专家持续维护,但维护的是声明式描述,而不是每次重复回答同一个问题。
2. 关系图谱(Relationship Graph)
数据实体之间有依赖、有冲突、有版本演进。图谱记录的是:这张表从哪张上游表聚合而来、哪个仪表盘引用了它、哪些团队是它的 stakeholder。
助手拿到一个问题时,不再只检索一张表的元数据,而是沿着图谱走几步,把相关上下文一起带进来。
3. 决策日志(Decision Log)
口径变更、字段废弃、架构迁移——这些"为什么这样做"的记录,是通用 LLM 最缺的信息。决策日志把时间线上的关键事件和原因编码进去,助手就能回答"为什么这个指标突然跳了 20%"这类问题。
实践:用 Python 搭一个最小上下文层
下面是一个可运行的最小原型,展示如何把领域知识编码成上下文层,并让 LLM 调用时带上这些结构化信息。
第一步:定义数据实体和关系
# context_layer.py — 最小上下文层定义
from dataclasses import dataclass, field
from datetime import date
from typing import Optional
@dataclass
class DataEntity:
"""一个数据实体,携带语义描述而非纯技术元数据"""
id: str
name: str
description: str # 面向业务的语义描述
authoritative: bool # 是否为该指标的权威来源
owner_team: str
known_caveats: list[str] = field(default_factory=list) # 已知偏差或注意事项
decisions: list["Decision"] = field(default_factory=list)
@dataclass
class Decision:
"""口径变更或架构决策的记录"""
date: date
summary: str
reason: str
changed_fields: list[str] = field(default_factory=list)
@dataclass
class Relationship:
"""实体间关系:依赖、引用、冲突等"""
source_id: str
target_id: str
relation_type: str # "depends_on" | "referenced_by" | "conflicts_with"
note: Optional[str] = None
# ---- 填入领域专家的知识 ----
nordic_retention = DataEntity(
id="nordic_daily_retention",
name="北欧市场日级留存率",
description="北欧五国用户的 30 天滚动留存率,按注册日分组",
authoritative=True,
owner_team="growth-analytics",
known_caveats=[
"2023-06-01 前的数据使用 7 天口径,不可直接比较",
"芬兰用户量小,单日波动大,建议看 7 天均值",
],
decisions=[
Decision(
date=date(2023, 6, 1),
summary="留存口径从 7 天改为 30 天",
reason="业务方需要更长窗口评估订阅转化",
changed_fields=["retention_window"],
),
],
)
global_retention = DataEntity(
id="global_daily_retention",
name="全球日级留存率",
description="全量用户的 30 天留存,不含北欧单独口径",
authoritative=True,
owner_team="core-analytics",
known_caveats=["与 nordic_daily_retention 口径不同,不可直接相减"],
)
entities = {e.id: e for e in [nordic_retention, global_retention]}
relationships = [
Relationship(
source_id="nordic_daily_retention",
target_id="global_daily_retention",
relation_type="conflicts_with",
note="口径差异:北欧含订阅转化延迟,全球不含",
),
]
第二步:根据用户问题组装上下文
# assemble_context.py — 把问题相关的上下文拼成 prompt 片段
def find_relevant_entities(question: str, entities: dict) -> list[DataEntity]:
"""简易关键词匹配;生产环境可用向量检索替代"""
keywords = {
"北欧": ["nordic_daily_retention"],
"留存": ["nordic_daily_retention", "global_daily_retention"],
"全球": ["global_daily_retention"],
}
matched_ids = set()
for kw, ids in keywords.items():
if kw in question:
matched_ids.update(ids)
return [entities[id] for id in matched_ids if id in entities]
def find_related(entity_id: str, relationships: list[Relationship]) -> list[Relationship]:
return [r for r in relationships if r.source_id == entity_id or r.target_id == entity_id]
def build_context_block(question: str, entities: dict, relationships: list[Relationship]) -> str:
"""组装一段结构化上下文,插入 LLM prompt"""
relevant = find_relevant_entities(question, entities)
if not relevant:
return "未找到相关数据实体,请仅基于通用知识回答,并声明不确定性。"
lines = ["[上下文层 — 领域专家编码信息]\n"]
for entity in relevant:
lines.append(f"实体: {entity.name} (id={entity.id})")
lines.append(f"描述: {entity.description}")
lines.append(f"权威来源: {'是' if entity.authoritative else '否'}")
lines.append(f"负责团队: {entity.owner_team}")
if entity.known_caveats:
lines.append("已知注意事项:")
for c in entity.known_caveats:
lines.append(f" - {c}")
if entity.decisions:
lines.append("决策日志:")
for d in entity.decisions:
lines.append(f" - [{d.date}] {d.summary} (原因: {d.reason})")
rels = find_related(entity.id, relationships)
if rels:
lines.append("关联关系:")
for r in rels:
lines.append(f" - {r.relation_type}: {r.target_id if r.source_id == entity.id else r.source_id} — {r.note}")
lines.append("\n[指令] 基于以上上下文回答问题。如果上下文中有注意事项或口径差异,必须在回答中明确提及。")
return "\n".join(lines)
# ---- 测试 ----
question = "上周北欧留存率为什么突然跌了?"
context = build_context_block(question, entities, relationships)
print(context)
运行输出:
[上下文层 — 领域专家编码信息]
实体: 北欧市场日级留存率 (id=nordic_daily_retention)
描述: 北欧五国用户的 30 天滚动留存率,按注册日分组
权威来源: 是
负责团队: growth-analytics
已知注意事项:
- 2023-06-01 前的数据使用 7 天口径,不可直接比较
- 芬兰用户量小,单日波动大,建议看 7 天均值
决策日志:
- [2023-06-01] 留存口径从 7 天改为 30 天 (原因: 业务方需要更长窗口评估订阅转化)
关联关系:
- conflicts_with: global_daily_retention — 口径差异:北欧含订阅转化延迟,全球不含
[指令] 基于以上上下文回答问题。如果上下文中有注意事项或口径差异,必须在回答中明确提及。
第三步:接入 LLM
# call_llm.py — 把上下文层拼入实际 LLM 调用
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
system_prompt = """你是 Spotify 内部数据助手。
回答数据相关问题时,必须优先参考上下文层提供的领域专家信息。
如果上下文层没有覆盖到的问题,明确说明你的回答是推测而非确认。"""
question = "上周北欧留存率为什么突然跌了?"
context_block = build_context_block(question, entities, relationships)
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"{context_block}\n\n用户问题: {question}"},
],
temperature=0.2,
)
print(response.choices[0].message.content)
这段代码的核心思路:不让 LLM 自由发挥,而是把领域专家已经确认的知识以结构化文本注入 prompt,同时用指令约束 LLM 必须引用这些信息、必须标注不确定的部分。
从原型到生产的关键差距
上面的原型跑通了逻辑,但 Spotify 的生产系统还处理了几个原型没覆盖的问题:
知识维护的可持续性。 声明式描述写一次容易,持续更新难。Spotify 的做法是把上下文层的更新嵌入数据变更流程——表口径变了,CI 流水线会提醒 owner 更新对应实体的描述和决策日志。没有更新的变更会被标记为"上下文过期",助手回答时会降级为"此信息可能已过时"。
检索精度。 关键词匹配只够演示。生产环境需要语义检索,把用户问题映射到正确的实体集合。Spotify 用的是实体描述的 embedding 加轻量 reranker,而不是全文搜索。
冲突处理。 当两个实体对同一指标给出不同口径时,助手不能只报一个值。上下文层的关系图谱显式标注了 conflicts_with,助手回答时会同时列出两个口径并解释差异——这正是领域专家平时会做的事。
覆盖范围。 不是所有数据实体都需要编码进上下文层。Spotify 只对高频查询的核心指标做了完整编码,其余实体仍走通用数据目录。起步时建议选 5-10 个最常被问到的实体,先把这些做深做准。
落地检查清单
如果你打算在自己的团队建类似的上下文层,先确认这几件事:
| 检查项 | 说明 |
|---|---|
| 选好首批实体 | 不要试图覆盖全量数据,选 5-10 个每周都会被问到的高频指标 |
| 领域专家愿意写声明 | 如果专家觉得"写描述比直接回答还麻烦",流程设计有问题——描述写一次,回答可以重复用千百次 |
| 有变更触发机制 | 口径变了但描述没更新,上下文层就成了误导层。把更新嵌入 CI 或变更审批流程 |
| 助手回答必须标注来源 | 每个回答附上引用的实体 ID 和决策日志日期,让用户能验证 |
| 不确定时降级而非编造 | 上下文层没覆盖的问题,助手必须声明"这是推测,建议咨询 [owner_team]" |
上下文层的本质不是技术问题,而是知识管理问题。技术实现可以简单到几个 dataclass 加一段 prompt 拼装,真正的难度在于:让领域专家持续把知识写进来,让系统在知识过期时主动降级,让用户信任助手的回答背后有人的判断而不是纯模型推理。
Spotify 的经验表明,这一层做好了,数据助手才从"聊天机器人"变成"可靠的同事"。