# 首个 AI 用例，该由 5000 张工单而非总监来选

> 在笔记本上用 embedding、UMAP 和 HDBSCAN，把原始工单变成带名称和计数的主题地图。

原文: https://fdetimes.net/zh/guides/cluster-support-tickets-embeddings-first-ai-use-case/

设想一个客户手上有 5,000 张客服工单。会议室里没人全部读过，但每个人都确信自己知道客户在抱怨什么。运营负责人认定是配送问题，客服主管则认为是支付报错。

对前线部署工程师（FDE）来说，“首个 AI 用例该做什么？”这个问题，往往就在这种时候抛过来：数据都在，而在场每个人的看法都不一样。

本指南介绍一条小到能在笔记本电脑上跑完的数据管道。按假设的 5,000 张工单规模，花一个下午是对这一场景的合理估计，并非实测数据。这条管道把一堆原始工单变成一张主题地图，每个主题都有名称和数量，首个用例就从这张地图里挑。

大团队用的也是同一思路。Intercom 的 Fin.ai 对客服对话做聚类来发现主题。Anthropic 的 Clio 按主题给对话分组，再让 LLM 为每个簇写标题和摘要。你的版本规模小得多，但逻辑完全一样。

## 要做出什么，需要准备什么

最终产出是一张表：每个主题一行，包含易读的名称、工单数量和几条代表性样例。管道分五步：生成嵌入向量，用 UMAP 降维，用 HDBSCAN 聚类，查看噪声组，最后给簇命名。

你需要 Python、pandas、`umap` 和 `hdbscan` 库，以及客户允许用于其数据的嵌入模型和 LLM。最后这个条件最重要。工单里常有邮箱、电话和订单号，在往外发送任何一行数据之前，先问清楚数据规定。

下面的代码是对流程的简化示意，不是生产代码。`embed()` 是你自己围绕获批嵌入模型写的封装函数。

## 第 1–2 步：把文本变成向量，再压缩空间

先加载工单，为每一行生成嵌入向量。

```python
# 简化示例：embed() 封装了客户批准使用的嵌入模型
import pandas as pd

df = pd.read_csv("tickets.csv")   # 列：id, text
X = embed(df["text"].tolist())    # 每张工单 -> 一个向量
```

马上检查向量数量是否等于行数，在这个场景中是 5,000。如果有缺失，常见原因是空工单，或工单长度超出模型限制。先过滤或截断这些行，再往下走。

下一步沿用 Fin.ai 的做法：聚类前，他们先用 UMAP 把嵌入向量投影到低维空间，并指出 UMAP 在客服消息这类短文本上效果尤其好。

BERTopic 文档解释了原因：UMAP 能同时保留数据的部分局部结构和全局结构，因此空间压缩后，含义相近的工单依然靠在一起。

```python
import umap
# 起点而非标准值：5 维，余弦距离
X_low = umap.UMAP(n_components=5, metric="cosine").fit_transform(X)
```

不要用 UMAP 默认的 2 维。2 维适合画图，但用于聚类时，把数据压到太少的维度可能丢失信息。比较文本嵌入的语义接近程度，余弦距离是常规选择。这两个值都应在客户数据上重新测试。

## 第 3 步：为什么不用 k-means？

OpenAI 的聚类 cookbook 在 1,000 行预先算好的嵌入向量上跑了一个简单的 k-means，簇的数量留给读者按自己的用途决定。

这让 k 成了主观决定，而不是能算出来的数字。面对新客户的工单，你不知道里面有 8 个主题还是 80 个。

猜一个 k，就是把自己的假设强加给数据。

Fin.ai 选择 HDBSCAN，正是因为它不需要事先知道簇的数量。BERTopic 文档补充了两个优点：HDBSCAN 能找出不同形状的簇，并且会把离群点单独分出来，而不是把每篇文档都硬塞进某个主题。

```python
import hdbscan
df["cluster"] = hdbscan.HDBSCAN(min_cluster_size=15).fit_predict(X_low)
print(df["cluster"].value_counts().head(20))
```

最重要的参数是 `min_cluster_size`，即构成一个簇所需的最少工单数。Fin.ai 把它视为需要调节的关键参数。他们最终定为 15，在 Intercom 自己的数据上得到了 700 多个小主题。

再想想你的 5,000 张工单：如果 15 给出几百个零碎的簇，会上没人看得过来。换几个更大的值重跑，比如 30 和 60，对比结果列表。当表格顶部的簇与客服人员每天谈论的问题对得上时，就可以停了。

## 第 4 步：别删掉 -1 标签

HDBSCAN 会给所有不属于稳定簇的工单打上 -1 标签，表示噪声。人的本能是把这组过滤掉，让表格干净些。不要这样做。

```python
noise = df[df["cluster"] == -1]
print(len(noise), "张噪声工单")
print(noise.sample(min(30, len(noise)))["text"].tolist())
```

噪声组里可能藏着长尾问题：上周刚出现的 bug、某个企业客户自己的故障，或者还没人归类过的功能需求。

手工读 30 行花不了多少时间，换来的是计数表看不到的细节。还有一项值得做的检查：比较两次用不同 `min_cluster_size` 运行时 -1 组的大小，看看哪些工单从簇里掉进了噪声，或者反过来。

## 第 5 步：分两层给簇命名

只有编号的簇，对客户毫无意义。最省事的办法沿用 BERTopic：用 c-TF-IDF 提取每个簇里最重要的词，立刻得到主题的粗略描述。

第二层交给 LLM。OpenAI 的 cookbook 从每个簇随机抽 5 条评论发给 gpt-4 来命名。该页面已标记为归档，模型名称可能过时，但方法依然成立。Fin.ai 也有一个单独的 LLM 步骤，把最终主题转换成易读的标签。

```python
# 简化示例：llm() 封装了获批的 LLM，返回一个字符串
names = {}
for c, g in df[df["cluster"] >= 0].groupby("cluster"):
    sample = g["text"].sample(min(5, len(g))).tolist()
    names[c] = llm("请给这组工单起一个简短的名称：\n" + "\n".join(sample))

df["topic"] = df["cluster"].map(names).fillna("噪声 (-1)")
print(df["topic"].value_counts().head(20))
```

这一步之后检查：除 -1 以外的每个簇，在 `topic` 列里都必须有名称。然后把 LLM 起的名称和 c-TF-IDF 关键词放在一起对照。如果两者对不上，比如 LLM 写的是“支付问题”，关键词却全是“password”和“login”，说明这个簇还不够干净，或者那 5 张样本不具代表性。重新抽样。

## 最大的簇就是首个用例吗？

不一定。FDE 的工作从这里开始，因为算法只会计数。设想表格顶部的三个簇（以下数字为假设）：

| 簇（假设） | 工单数 | 答案是否重复？ | 结果能否衡量？ |
|---|---|---|---|
| 重置密码、账户被锁 | 600 | 几乎每次都一样 | 能：自助解决率 |
| 投诉配送延迟 | 900 | 否，需要查物流系统 | 难，取决于合作方 |
| 退款政策咨询 | 400 | 是，依据政策文档 | 能：与参考答案对比 |

配送簇最大，但它的答案依赖第一周拿不到的数据。密码簇小一些，却答案重复、风险低，还有一个清晰的数字来证明价值。

因此它是首个用例的好候选，而配送簇则成为日后申请更多权限的理由。

**要点:** 最大的簇不一定是首个用例；答案重复、容易验证的那个才是。

## 常见错误

第一个错误是只跑一次就相信结果。聚类对 `min_cluster_size` 很敏感，所以始终要给出至少两种设置下的结果，让客户看到参数变化时哪些主题依然稳定。

第二个错误是把 LLM 生成的名称当作定论。名称只是假设：上幻灯片之前，请客服主管对照几张样例工单读一遍。第三个错误是忽视敏感数据。即使模型跑在客户自己的基础设施上，生成嵌入向量之前也要先遮蔽个人信息。

## 这项技能在实际工作中用在哪里

项目第一周，这张主题表能把客户需求调研从“我们觉得”变成“你们自己的数据显示”。它还会成为基线：Agent 上线后，对新工单重新聚类，看看密码簇是不是真的变小了。

如果你想转向 FDE 岗位，可以在公开数据集上做一遍这个练习，并写成简历中的一条经历：工单数量、方法、发现的簇、建议的用例，以及选择它的理由。

分组和计数由算法替你完成。客户付钱，是请你决定先解决哪个主题，并讲清楚为什么。

**本周动手试试:**

- 从客服系统导出最近 1,000 张工单（或使用公开数据集），用两个不同的 min_cluster_size 值运行 HDBSCAN，比较簇的数量。
- 手工阅读 30 张标为 -1 的工单，记下有没有值得单独成为一个主题的问题。
- 写一页幻灯片，按数量、重复度和可衡量性给前 5 个簇排序，就当明天要向客户汇报。

## 信源

- [K-means Clustering in Python using OpenAI](https://developers.openai.com/cookbook/examples/clustering)

- [Unsupervised Learning Meets Generative AI: Topic Modelling for Real-World Dialogue](https://fin.ai/research/unsupervised-learning-meets-generative-ai-topic-modelling-for-real-world-dialogue/)

- [The Algorithm - BERTopic](https://maartengr.github.io/BERTopic/algorithm/algorithm.html)

- [How HDBSCAN Works](https://hdbscan.readthedocs.io/en/latest/how_hdbscan_works.html)

- [Clio: A system for privacy-preserving insights into real-world AI use](https://www.anthropic.com/research/clio)
