这篇能做出什么
假设手里有一份用户反馈表:电商评论、客服会话记录、问卷开放题、App 内的意见反馈,几百到几万条不等。这些文本里其实藏着"用户是谁、想要什么、卡在哪一步",但一条条读太慢,靠关键词搜索又抓不住语义。
做完这套流程,你会得到四样东西:
1. 一份结构化标签表:每条反馈都带上"目标 / 场景 / 痛点 / 现有替代方案 / 决策因素 / 付费意愿档位"等字段,导出成 CSV,能直接丢进 BI 或 Excel 做透视。
2. 标签分布统计:比如"售后场景下 62% 的痛点是客服响应",交叉表一拉就出来。
3. 3~6 张用户画像卡:每张包含画像名、典型场景、核心诉求、主要顾虑、可执行建议、代表原话。可以直接放进需求评审或投放 brief。
4. 一套可复现的脚本目录:换一批数据重跑即可,支持断点续跑,不用每次从头烧 token。
举个具体例子。原始输入是一条评论:
> 买了两次都因为发货太慢退了,客服回复也慢,后来干脆去楼下超市买了,虽然贵点但当天能拿到。
跑完流程后,这条记录会变成:scenario=售后、pain=速度、workaround=线下超市、decision=客服响应、quote=后来干脆去楼下超市买了。几百条这样的记录聚合起来,"急用型用户"这张画像卡就自己浮出来了。
前置条件清单
- Python 环境,能
pip install即可,建议用虚拟环境隔离依赖。 - 一个模型接口:OpenAI 兼容风格的
base_url+api_key+ 模型名,具体模型和参数以所用服务的官方文档当前版本为准。文本本身不强求大模型,中等规模的模型抽取标签通常够用。 - 可选:embedding 接口。如果想把反馈自动聚成簇,需要 embedding;也可以用本地 embedding 模型,避免把原始文本发到外部。
- 数据:至少 200 条文本起步,少于这个量做分布统计意义不大。字段里最好有一个稳定的唯一 ID。
- 磁盘上的断点文件:跑批过程中随时可能中断,落盘是必须的。
推荐的目录结构:
```text
persona/
data/raw.csv # 原始数据,只读
data/clean.csv # 清洗脱敏后
prompts/extract.md # 抽取提示词
prompts/persona.md # 画像卡提示词
out/labels.jsonl # 逐条结果,断点文件
out/labels.csv # 展开后的表格
out/report.md # 最终报告
src/01_clean.py
src/02_extract.py
src/03_analyze.py
src/04_persona.py
```
依赖大致是这些:
```bash
pip install pandas scikit-learn openai
```
步骤 1:先定字段,再动手
这一步不做,后面全是返工。先回答一个问题:这份画像要拿去做决策吗?如果只是"看看用户长什么样",字段可以随便定;如果要据此改产品、改话术、改投放,字段必须能落到行动上。
一个好用的字段集:
| 字段 | 含义 | 取值 |
|---|---|---|
| goal | 想达成什么 | 自由文本,一句话 |
| scenario | 使用场景 | 枚举 |
| pain | 主要痛点 | 枚举 |
| workaround | 当前替代方案 | 自由文本,无则 none |
| decision | 决策因素 | 枚举 |
| pay_level | 付费意愿档位 | 低 / 中 / 高 / 未知 |
| quote | 代表原话 | 逐字摘录 |
三条经验:枚举值控制在 5~8 个,太多模型会飘;每个枚举都要有"其他"兜底;不要设计"用户性格""用户情绪"这类无法验证、也无法行动的字段。
步骤 2:清洗与脱敏
先把隐私处理掉,再谈分析。手机号、邮箱、证件号、订单号这类内容用正则替换成占位符,既合规,也不影响语义。
```python
import re
import pandas as pd
df = pd.read_csv("data/raw.csv")
df = df.dropna(subset=["text"]).drop_duplicates(subset=["text"])
PATTERNS = [
(re.compile(r"1[3-9]\d{9}"), "[手机号]"),
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"), "[邮箱]"),
(re.compile(r"\b\d{17}[\dXx]\b"), "[证件号]"),
(re.compile(r"\b\d{12,}\b"), "[单号]"),
]
def scrub(s: str) -> str:
for p, r in PATTERNS:
s = p.sub(r, s)
return s
df["text"] = df["text"].astype(str).str.strip().map(scrub)
df = df[df["text"].str.len().between(5, 2000)]
df.to_csv("data/clean.csv", index=False)
print(len(df))
```
长度上限的作用是防止个别超长文本把单次请求撑爆;下限是滤掉"好""谢谢"这类没有信息量的内容。
步骤 3:把标签体系写进提示词
把提示词单独存成文件,方便版本管理和对比效果。关键是三点:明确只输出 JSON、明确枚举范围、要求摘录必须逐字来自原文。
prompts/extract.md:
```markdown
你是用户研究分析师。请从下面这条用户反馈中抽取结构化标签。
只输出一个 JSON 对象,不要输出解释,不要加代码块围栏。
字段定义:
- goal: 用户想达成什么。字符串,一句话。
- scenario: 使用场景。从 [工作, 学习, 生活, 购物, 售后] 中选一个。
- pain: 主要痛点。从 [价格, 效果, 速度, 易用性, 稳定性, 客服, 其他] 中选一个。
- workaround: 用户当前的替代方案。没有就写 "none"。
- decision: 影响决策的因素。从 [口碑, 价格, 试用体验, 客服响应, 品牌, 其他] 中选一个。
- pay_level: 付费意愿。从 [低, 中, 高, 未知] 中选一个。
- quote: 最能代表上述判断的原文片段,必须逐字来自原文,不超过 40 字。
用户反馈:
"""
{{TEXT}}
"""
```
要求"逐字来自原文"很关键。模型很容易顺手润色或概括,一旦加工过,后面就没法拿原话给业务方看。
步骤 4:单条打通,再做批量
先拿一条数据跑通,确认返回能被解析,再考虑并发。
```python
import json
import os
import time
from openai import OpenAI
client = OpenAI(
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ["LLM_BASE_URL"],
)
MODEL = os.environ["LLM_MODEL"] # 模型名以所用服务官方文档当前版本为准
PROMPT = open("prompts/extract.md", encoding="utf-8").read()
ALLOWED = {
"scenario": {"工作", "学习", "生活", "购物", "售后"},
"pain": {"价格", "效果", "速度", "易用性", "稳定性", "客服", "其他"},
"decision": {"口碑", "价格", "试用体验", "客服响应", "品牌", "其他"},
"pay_level": {"低", "中", "高", "未知"},
}
def strip_fence(s: str) -> str:
s = s.strip()
if s.startswith("```"):
s = s.split("\n", 1)[1] if "\n" in s else s
s = s.rsplit("```", 1)[0]
return s.strip()
def extract(text: str, retries: int = 4) -> dict:
prompt = PROMPT.replace("{{TEXT}}", text)
for i in range(retries):
try:
resp = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
data = json.loads(strip_fence(resp.choices[0].message.content))
for key, allowed in ALLOWED.items():
if data.get(key) not in allowed:
data[key] = "其他" if "其他" in allowed else "未知"
if data.get("quote") and data["quote"] not in text:
data["quote"] = ""
return data
except Exception as e:
if i == retries - 1:
return {"error": str(e)}
time.sleep(2 ** i)
return {}
```
两个细节:temperature=0 能让同一批数据重跑的结果更稳定,但并非所有服务都支持这个参数,遇到报错就去掉;quote 校验用子串判断,不匹配就清空,这是防幻觉的兜底。
步骤 5:批量抽取与断点续跑
落盘用 JSONL,一行一条,天然支持追加和续跑。启动时先读一遍已有结果的 ID,跳过处理过的。
```python
import json
import os
from concurrent.futures import ThreadPoolExecutor
import pandas as pd
df = pd.read_csv("data/clean.csv")
out_path = "out/labels.jsonl"
done = set()
if os.path.exists(out_path):
with open(out_path, encoding="utf-8") as f:
for line in f:
try:
done.add(json.loads(line)["id"])
except Exception:
continue
todo = df[~df["id"].isin(done)]
def work(row):
r = extract(str(row["text"]))
r["id"] = row["id"]
return r
with open(out_path, "a", encoding="utf-8") as f, \
ThreadPoolExecutor(max_workers=4) as pool:
for r in pool.map(work, [row for _, row in todo.iterrows()]):
f.write(json.dumps(r, ensure_ascii=False) + "\n")
f.flush()
```
并发数从 4 起,遇到限流就降;如果服务端有限流提示,按它的建议调整间隔。先拿 50 条试跑,估算一下总耗时和用量,再决定要不要全量。
步骤 6:统计标签分布
```python
import json
import pandas as pd
rows = [json.loads(l) for l in open("out/labels.jsonl", encoding="utf-8")]
rows = [r for r in rows if "error" not in r]
lab = pd.DataFrame(rows)
print(lab["pain"].value_counts())
print(pd.crosstab(lab["scenario"], lab["pain"], normalize="index").round(2))
lab.to_csv("out/labels.csv", index=False)
```
交叉表是最容易出结论的地方。"售后 × 客服"占比高,说明问题在服务流程;"购物 × 价格"占比高,说明该调整定价或促销策略。这一步不需要模型,纯统计。
步骤 7:聚成簇,再写成画像卡
有了标签,可以直接按 pain 分组建卡,也可以先做聚类,让数据自己分出人群。聚类用 embedding 加 KMeans 比较省事:
```python
import numpy as np
from sklearn.cluster import KMeans
from openai import OpenAI
client = OpenAI(
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ["LLM_BASE_URL"],
)
EMB_MODEL = os.environ["EMBEDDING_MODEL"] # 以所用服务官方文档当前版本为准
texts = lab["text"].fillna("").tolist()
vecs = []
for i in range(0, len(texts), 64):
batch = texts[i:i + 64]
resp = client.embeddings.create(model=EMB_MODEL, input=batch)
vecs.extend([d.embedding for d in resp.data])
X = np.array(vecs)
X = X / np.linalg.norm(X, axis=1, keepdims=True) # 归一化后更稳
k = 5
lab["cluster"] = KMeans(n_clusters=k, n_init=10, random_state=0).fit_predict(X)
```
k 取多少没有标准答案。先用 4~6 试,看看每簇的样本量是否均衡、簇内文本是否真的像一类人。簇太小(比如只有三五条)就并回去。也可以跳过聚类,直接按 scenario + pain 组合分组,解释性更强,适合给非技术同事讲。
分组定下来后,把每簇的标签分布和一批原话交给模型写画像卡。prompts/persona.md:
```markdown
下面是同一簇用户的标签分布和反馈摘录。请写一张用户画像卡。
包含:
1. 画像名(6 字以内,要能和其他簇区分)
2. 一句话特征
3. 典型场景
4. 核心诉求(不超过 3 条)
5. 主要顾虑(不超过 3 条)
6. 可执行建议(产品、运营、客服各 1 条)
7. 代表原话(3 条,逐字引用,不要改写)
不要编造没出现过的信息,信息不足的地方写"数据不足"。
标签分布:
{{DIST}}
反馈摘录:
{{SAMPLES}}
```
最后一定要人工过一遍。挑出明显跑偏的画像卡,回头看是抽取阶段标签错了,还是聚类把两类人混在一起了,再针对性调整。
常见坑与排错
模型返回带了代码块围栏。json.loads 直接报错。用 strip_fence 先剥一层,或者干脆在提示词里强调"不要加围栏",两者一起上更稳。
同一意思出现多个标签。比如"贵""价格高""性价比低"被抽成三个不同的值。解决办法是枚举固定 + 后处理归一,把不在枚举里的值统一映射到"其他",再人工看一遍"其他"里都是什么,决定要不要扩充枚举。
模型编造原话。quote 字段看着像原文其实被改写了。脚本里做子串校验,不匹配就置空,宁可缺也不要假。这一点在拿原话给业务方看时尤其重要。
并发一开就被限流。把 max_workers 降到 2,重试间隔改长,或者按时间窗口分批发送。断点续跑能保证限流不会让你从头再来。
长文本被截断。超长反馈可以先按句切分,逐段抽取再合并;或者先让模型做一次摘要,再对摘要抽标签。两种都会损失细节,按分析目的取舍。
样本本身有偏差。评论区的用户和沉默用户不是一类人,问卷回答者也不是。做结论时把数据来源写进报告,避免把"爱评论的人"当成"全部用户"。
成本失控。全量跑之前先用 50 条测一遍,按实际 token 用量线性估算。抽取和聚类是两笔开销,embedding 通常比对话便宜,可以先做聚类看效果。
只看分布不看原文。统计表告诉你"客服痛点占 40%",但到底是响应慢、态度差还是权限不够,只有翻原话才知道。每张画像卡至少配三条逐字原话。
下一步建议
跑通一次之后,可以考虑这几件事:
- 做小规模人工校验。随机抽 50 条,人工标一遍标签,和模型结果比一致率。这个数字决定了后续能不能信任自动统计。
- 接入增量更新。把脚本挂到定时任务上,每周跑一次新增反馈,画像卡里的趋势变化比单次快照更有价值。
- 把标签接到 BI。
out/labels.csv可以直接当明细表用,配合时间维度看痛点迁移。 - 用画像卡驱动具体动作。给投放团队改文案、给客服团队改话术、给产品团队排优先级,每张卡对应一个具体交付物,而不是一份只能看的 PPT。
- 建立标签漂移监控。定期看"其他"这个兜底标签的占比,它持续升高通常意味着业务变了、标签体系该更新了。
标签:#用户画像 #大模型应用 #提示词工程 #数据分析 #Python #用户研究
