把领域专家的知识编码进系统:Spotify 数据助手背后的上下文层

2026-06-10 25 预计阅读时间: 1 分钟
来源: engineering.atspotify.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:14 分钟

在 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 的经验表明,这一层做好了,数据助手才从"聊天机器人"变成"可靠的同事"。


相关推荐