最近在做一个客服问答机器人的数据处理环节,我发现一个挺普遍的现象:很多人的精力都花在“调模型”上,真正卡住他们的反而是数据。原始语料里字段名不统一、一问多答、空字符串满天飞,好不容易清洗完,又开始头疼训练集和测试集怎么分才公平,等模型跑完,还要对着几十万行推理结果发呆。整个流程走下来,我最深的感受是——LLM开发里真正决定项目上限的,往往是数据处理。而这里面的主力工具,我用的就是Pandas。
这篇内容围绕“LLM开发中的数据处理全流程”做一次完整梳理:从CSV、JSON这类原始文件的导入,到文本清洗、prompt特征工程,再到训练集/验证集划分和推理结果分析,每个环节都会给出我在实际项目里的操作方法、判断逻辑,以及踩过的坑。如果你打算用Pandas把LLM项目的数据链路跑通,或者刚入门想找一条不绕弯的路,这篇应该能帮你省下不少试错时间。
1. 先说一个反直觉的结论:LLM项目的瓶颈常常发生在数据阶段
1.1 数据处理在LLM开发里的真实占比
很多人第一次接触LLM开发,都会觉得核心是“提示词怎么写”或者“模型选哪个”。这个想法不能说错,但放到真实项目里,你会发现时间分配根本不是这样。我自己做过几个和文本分类、问答抽取相关的Demo,粗算下来,数据整理、清洗、格式转换、评测分析这几件事,占掉了差不多六到七成的工时,真正的“调用模型”反而只是一小段代码。
原因也不难理解。LLM虽然能容忍一定程度的格式混乱,但“能跑”和“效果稳定”是两回事。一个数据集如果存在大量重复样本,训练时模型会把常见回答背下来,测试时看起来分数很高,换个场景立刻露馅;如果测试集和训练集存在同源数据,那评测结果更是直接失真。这些问题不在数据阶段解决,后面花再多时间调提示词都是白搭。
所以我的建议是,别急着把数据丢给模型,先把数据在Pandas里过一遍。Pandas处理表格化数据的能力,恰恰覆盖了LLM开发中绝大部分的数据需求,这也是我推荐它的理由。
1.2 LLM开发里最常见的三类数据形态
回头看我经手的项目,LLM开发中的数据大体可以归成三类。
第一类是问答对或者指令对,通常以CSV、Excel或者JSON形式存在,字段一般是“问题、标准答案”,可能还会带上来源、难度、分类标签。这类数据主要用于微调或者做少样本评测,是最基础也最常见的一种。
第二类是对话上下文或长文本,比如多轮客服会话、文档片段。它们格式往往不规整,一条记录里可能有多轮消息,需要先展开成“一对多”的结构才能使用。
第三类是模型推理结果,也就是模型跑完以后返回的一大堆文本或JSON。这些结果需要跟原始样本合并,经过解析、字段提取、统计,才能变成我们看得懂的指标。
这三类数据,用Pandas都能很顺地处理。我后面要讲的导入、清洗、特征工程、划分、评测,本质上就是围绕这三类形态展开的。
1.3 版本与环境:两行代码解决的事
关于环境我多说一句。Pandas本身安装非常省事,但我建议直接用Python 3.9以上版本,配合Pandas 2.x,一些字符串方法和内存优化会更顺手。我维护过一份几千万行的用户行为表,用Pandas 1.x读起来明显比2.x慢,所以版本升级不是玄学,是实打实的性能收益。
原生Python如果要处理行数特别多、字符串特别长的数据,最好不要硬扛,建议配合numpy一起用。我自己的常用组合就三个包:pandas、numpy、matplotlib,前者做数据,中间做数值运算,后者画分布图。环境干净,后面排错也方便。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据导入:把散乱文件变成规整DataFrame的细节
2.1 读CSV时最常见的三个错误
先从一个我自己经常见到的场景说起:别人给你一份CSV,你高高兴兴pd.read_csv()一读,要么报编码错误,要么数据全挤在一列里,要么第一行莫名其妙变成了列名。这三个问题在LLM数据场景里尤其常见,因为语料很多时候是从各个渠道导出的,格式五花八门。
编码问题的解法最直接——指定encoding参数。常规数据用utf-8,但如果是Windows下导出的Excel另存文件,utf-8可能直接报错或出现乱码,这时候试试gbk,或者更省心的encoding='utf-8-sig'。我处理某份客服导出数据时遇到过“\ufeff”开头的怪字符,其实这就是BOM标志,用utf-8-sig读取就自动清掉了。
数据挤在一列的情况,多半是分隔符不对。默认分隔符是逗号,但如果文件实际用的是制表符,就指定sep='\t';如果文本里的逗号本身是内容的一部分,推荐指定engine='python'配合正则分隔符。第一行变列名的情况,加一个header=None,然后自己指定columns,别偷懒把数据第一行当表头。
python复制import pandas as pd
# 处理编码和分隔符的最常见写法
df = pd.read_csv('chat_logs.tsv', sep='\t', encoding='utf-8-sig', header=None)
df.columns = ['session_id', 'user_query', 'assistant_answer', 'create_time']
print(df.shape)
这些都是两三行代码的事,但每一步不到位,后续清洗的麻烦就成倍放大。我的习惯是遇到读取报错时,先不急着查官方文档,而是用文本编辑器把文件的前几行原始内容看一遍,编码、分隔符、表头的问题基本一眼就能定位。
2.2 JSON数据:LLM语料的典型格式
LLM项目的原始数据,更多时候不是CSV而是JSON。尤其是从各种平台导出的对话记录,一个文件里可能嵌套好几层。Pandas读取JSON有两种常用方式。
一种是标准的json数组,直接pd.read_json()就能读;另一种是JSON Lines格式,即每行一个JSON对象,这时候必须指定lines=True。我处理过一份多轮对话数据,每条记录外层是session_id和messages数组,里面又嵌套role和content。直接读进来会得到一堆object,非常不好用。
这时可以把嵌套字段展开。最粗暴但好用的方法是用列表推导式把内层消息拆成一行一行,再重建DataFrame;如果嵌套层级比较规整,也可以用pd.json_normalize()自动拍平。我一般优先用前者,因为可控性更强,尤其是当你需要保留会话ID、轮次序号这类外围字段时。
python复制import json
import pandas as pd
# 读取JSON Lines并展开多轮对话
with open('multi_turn.jsonl', 'r', encoding='utf-8') as f:
records = []
for line in f:
obj = json.loads(line)
for turn_no, msg in enumerate(obj['messages']):
records.append({
'session_id': obj['session_id'],
'turn_no': turn_no,
'role': msg['role'],
'content': msg['content'],
})
df = pd.DataFrame(records)
print(df.groupby('session_id')['turn_no'].max().describe())
这段代码的意思是把一长串的嵌套对话摊平成每条消息一行,后面清洗和拼接prompt都会方便很多。为什么不直接整体读进来?因为嵌套结构不仅难统计,在后续筛选特定角色消息时也很别扭。
2.3 让字段类型从一开始就正确
数据导入时还有一个容易被忽略的点:字段类型。Pandas里object类型是个“万能口袋”,什么都能装,但装进去以后很多方法会变慢,排序和比较也容易出问题。我在导入阶段就会先看一眼df.dtypes,然后对明显不是文本的字段做转换。
常用做法有两个:一是在读取时直接指定dtype参数,比如把session_id指定成str,把时间字段在读取后再用pd.to_datetime()转换;二是用converters参数针对特定列做自定义转换。实际项目里,ID字段如果被读成了int64,后续拼接或者去重时可能会导致“数值看起来一样但字符串形式不同”的惨案,所以我一般会在导入时就固定成str类型。
python复制df = pd.read_csv('samples.csv', dtype={'session_id': str, 'label': 'category'})
df['create_time'] = pd.to_datetime(df['create_time'], errors='coerce')
这里errors='coerce'值得多说一句:遇到无法解析的时间,Pandas会把对应值换成NaT而不是直接整列报错。对LLM语料来说,时间字段有时乱得吓人,这一招能保住整批数据不白读。时间字段在后续做按时间排序、划定训练窗口时很有用,提前转换好,后面不用再回头补。
3. 文本清洗:LLM数据里最耗精力的环节
3.1 缺失值的处置:空字符串、None和"null"
进入清洗环节。很多新手对“缺失值”的理解就是NaN,但LLM语料里真正的坑在于“看起来有值其实是空的”——空字符串、纯空白、字符串里写了“null”“None”“NA”等等。如果你用isna()去查,这些都会被当作有效值放过,等模型跑完才发现全是垃圾输入。
我的做法是先写一个统一函数,把这些“伪空值”全部替换成真正的NaN:
python复制import pandas as pd
def normalize_empty(val):
if val is None:
return pd.NA
if isinstance(val, str):
if val.strip() in ('', 'null', 'None', 'NaN', 'NA', 'N/A'):
return pd.NA
return val
text_cols = ['user_query', 'assistant_answer']
for col in text_cols:
df[col] = df[col].map(normalize_empty)
处理完之后再决定“删”还是“补”。如果是问答对数据,问题为空或者答案为空的样本对训练毫无意义,我通常直接dropna(subset=[...])删掉;如果是对话历史,某一条消息为空,其实不影响整体结构,可以保留。删有删的道理,保有保的道理,关键是先分清“真空”和“假空”,别一刀切。
| 原始值 | isna()判断 | 是否需要清洗 |
|---|---|---|
| None | True | 无需额外处理 |
| ""(空字符串) | False | 需要 |
| "null" | False | 需要 |
| " "(纯空格) | False | 需要 |
3.2 重复样本:精确去重与语义去重的取舍
LLM语料里重复样本的问题比想象中普遍。最常见的是同一道题被多条渠道重复采集,问题文本一字不改。这种情况直接用drop_duplicates(subset=['user_query'])就能解决。
但还有一种更难缠的情况:两条样本内容不一样,语义却高度雷同。比如“帮我查一下快递”和“请问我的包裹到哪了”,对模型训练和评测来说,这种重复同样会造成数据倾斜。Pandas没有现成的语义去重方法,但可以先做一层“粗去重”:去掉标点和空格之后比较字符串相似度,或者用n-gram集合做Jaccard相似度估算。相似度超过阈值的样本再人工抽查,比一开始什么都不做、盲目训练要靠谱得多。
我自己写过一个简化版:把文本转成字符集合或者二元词组集合,用Python的set运算算交集除以并集,然后筛选出相似度超过0.8的记录单独review。这个方法不完美,但胜在快,特别适合在数据量特别大、人眼看不过来的场景下先筛出嫌疑样本。
3.3 格式标准化:编码、全角半角、换行符
第三个清洗重点是格式标准化。LLM是从字符级别理解文本的,格式乱了,模型也跟着乱。
最常做的一类操作是:把全角标点转成半角、统一换行符、去掉文本里多余的连续空格、把Unicode特殊空白替换成普通空格。在Pandas里这些都能靠str访问器批量完成,不要用for循环一条条处理,那是纯Python时代的写法,几万条数据能跑半天。
python复制import re
def clean_text(s):
if not isinstance(s, str):
return s
s = s.replace('\u3000', ' ') # 全角空格
s = re.sub(r'[\t\r\n]+', ' ', s) # 换行/制表替换为空格
s = re.sub(r' +', ' ', s) # 连续空格压缩
s = s.strip()
return s
df['user_query'] = df['user_query'].map(clean_text)
这里有个边界要提醒:如果数据本身是多轮对话,或者文本内部包含代码块,换行符是有语义的,不能盲目替换成空格。我在清洗时会先看数据再决定——长文本、代码类样本只做首尾strip和全角转半角;短指令类样本才会压缩换行,因为指令数据里换行常常是排版噪音。
4. prompt模板拼接与特征工程:让原始数据变成模型输入
4.1 文本长度统计与截断策略
清洗完之后,就要考虑怎么把数据变成LLM能用的输入。第一步是先摸清文本长度分布,否则根本不知道该截多长、该预留多少token。
Pandas里一行代码就能统计:
python复制df['query_len'] = df['user_query'].str.len()
df['query_len'].describe()
describe()会输出最小值、分位数、最大值和平均值,非常直观。我一般还要看一眼len>2000的样本占比,这类超长样本在调用模型时容易被截断,需要单独处理。截断策略不是简单地在固定位置切一刀,更好的做法是在尽可能靠后的位置保留语义完整边界,比如按句号所在位置截断。
如果只是粗略估算token数,可以按“字符数除以3到4”来做,中文场景下通常比较接近。但这个估算值只是用来排序和筛选,真正要精确还得看具体模型的分词器,Pandas阶段做到这个粒度就够了。
4.2 用assign、apply批量生成prompt
在LLM开发里,特征工程不太像传统机器学习那样搞一堆数值特征,更多是把“原始字段”拼成“模型输入字符串”。我在项目里最常用的一招是:提前定义好prompt模板,然后对整个DataFrame批量套用。
比如系统提示词模板可以是:
python复制prompt_template = (
"你是客服助手。请基于以下信息回答用户问题。\n"
"用户意图: {intent}\n"
"历史反馈: {history}\n"
"用户问题: {question}\n"
"请给出简洁回答:"
)
df['prompt'] = df.apply(
lambda r: prompt_template.format(
intent=r['intent'],
history=r['history'][:200],
question=r['user_query']
),
axis=1
)
这里有几处细节值得讲。第一,history字段在拼进prompt前先做了切片,避免单条样本过长;第二,apply(axis=1)虽然比向量化慢,但模板场景下字段多、逻辑杂,可读性和灵活性优先,数据量在一两万条范围内完全能接受;第三,模板里用.format而不是f-string写在循环里,是为了复用。
如果你的prompt模板是固定拼接几条文本字段、不涉及条件判断,那更推荐用字符串加法或者astype(str)直接拼,避开apply的逐行性能损耗。规则很简单:逻辑简单用向量化拼接,逻辑复杂用apply。
4.3 标签体系处理:从原始标注到模型输出格式
LLM开发里还有一类常见结构物:标签。文本分类、意图识别、情感判断的项目,都会有一列原始标注,但标注格式往往五花八门。有人标“正向”,有人标“positive”,还有人标“pos”。如果不统一,后面做评测根本没法对比。
我通常会在Pandas里把标签映射成统一格式。简单标签用map或replace就能解决:
python复制label_map = {
'正向': 'positive', '负向': 'negative', '中性': 'neutral',
'pos': 'positive', 'neg': 'negative',
}
df['label_std'] = df['label_raw'].map(label_map)
多标签场景(一条样本可能同时属于多个类别)要注意的地方更多:原始字段是“标签A、标签B”这种以顿号分隔的字符串。我会先把它们拆成list,再用explode()展开,同时保留原始样本ID,后续做统计时再groupby回原表。这个过程不复杂,但顺序错了很容易把样本对应关系弄乱。
5. 数据划分的坑:随机切分会让评测失真
5.1 为什么需要分层抽样
模型做评测之前,一定要先想清楚训练集、验证集、测试集怎么分。很多人直接用一个随机数种子做train_test_split,这对大部分CV、NLP任务来说勉强可以,但对LLM项目来说,往往是不够的。
原因在于:如果你的标签分布本身就不均匀(比如10%的正样本、90%的负样本),随机划分很可能把测试集里的正样本分得更少,甚至一个都没有。这时需要分层抽样,保证每个集合里各类别占比接近总体。
在Pandas里实现分层抽样,我习惯用groupby配合sample,代码很简单:
python复制# 按标签分层抽样出20%作为测试集
test_df = (
df.groupby('label_std', group_keys=False)
.apply(lambda g: g.sample(frac=0.2, random_state=42))
)
train_df = df.drop(index=test_df.index)
5.2 按会话或用户ID去重后再划分
另一个更容易被忽略的问题是“数据串集”。在客服、对话、推荐这类场景里,同一用户的多次提问往往强相关,如果训练集里有某个用户的历史样本,测试集里又出现同一个用户的新样本,模型等于提前“看见过”这个用户的说话习惯,评测分数会虚高。
解决办法是在划分之前先按用户或会话ID去重:
python复制unique_users = df['user_id'].drop_duplicates()
test_users = unique_users.sample(frac=0.2, random_state=42)
test_df = df[df['user_id'].isin(test_users)]
train_df = df[~df['user_id'].isin(test_users)]
先抽用户,再取用户的所有样本,而不是直接抽样本。这一步对“防止数据泄漏”的意义非常大,我身边很多朋友的项目评测分数虚高,最后排查下来都是这一关没把住。
记住一个原则:同一个会话或同一个用户的所有样本,必须整体进入同一个集合,禁止拆散。
5.3 验证集与测试集各自的责任边界
再往下说一点:验证集和测试集的职责是不一样的。验证集是你在调prompt、试各种后处理策略时反复用来验证效果的,你调整策略本身就是在“用”验证集的信息,所以验证集上的分数会有一定程度的乐观偏差。测试集只有在最终评定时才碰,一旦碰了就不该再返工调整。
这意味着验证集可以稍微小一点,但测试集要保持足够规模,保证统计结果的稳定。我在实际项目中常用的比例是:训练80%、验证10%、测试10%,如果数据量特别小,会把验证集做成K折再平均,避免单次划分的运气成分。
还有一个细节:划分完毕后,立刻把各集合的标签分布打印一遍,用value_counts(normalize=True)对比,确认分层有效。这一步三分钟能做完,却能在后期省掉无数“为什么分数忽高忽低”的困惑。
6. 模型推理结果的后处理与分析:用Pandas给LLM“打分”
6.1 解析LLM输出并归并到DataFrame
模型跑完以后,你会得到一大堆输出字符串,可能是JSON,可能是纯文本,也可能夹杂着模型自己的解释。拿到的第一步是把它们解析成结构化字段,再和原始样本并回去。
如果模型输出的是JSON,且格式稳定,可以直接用json.loads解析然后拆列;如果格式不稳定,就需要先用正则抓取关键字段。我建议无论格式多稳定,都要写一个带try/except的解析函数,把解析失败的样本放到单独的DataFrame里查看,而不是让整个脚本崩掉。
python复制import json
def parse_answer(raw):
if not isinstance(raw, str):
return pd.NA, pd.NA
try:
obj = json.loads(raw)
return obj.get('label', pd.NA), obj.get('explanation', pd.NA)
except Exception:
return pd.NA, pd.NA
df[['pred_label', 'explanation']] = df['model_raw'].apply(
lambda x: pd.Series(parse_answer(x))
)
这样归并之后,df里就同时有了真实标签、预测标签、模型解释、原始问题等一系列字段,后续所有分析都在这一个表里完成。
6.2 分组统计让指标更可靠
评测指标最常见的做法是算整体准确率或F1。但整体数字会掩盖很多细节:比如在某个特定类别上模型表现很差,但因为这个类别样本很少,整体分数依然好看。为了避免这种“被平均”造成的误判,我几乎每次都会做分组统计。
Pandas里最顺手的是crosstab和groupby组合。想看预测和真实标签的交叉情况,用crosstab直接生成混淆矩阵;想看每个类别各自的准确率,则可以先构造一个“是否预测正确”的布尔列,再按类别分组求平均:
python复制df['is_correct'] = df['pred_label'] == df['label_std']
acc_by_label = df.groupby('label_std').agg(
sample_count=('label_std', 'count'),
accuracy=('is_correct', 'mean'),
).reset_index()
print(acc_by_label.sort_values('sample_count', ascending=False))
这样的输出一打印出来,哪个类别样本又多又容易错,一眼就能看出来。比单纯打印一个0.87要诚实得多。
6.3 错误样本挖掘:从混淆矩阵到bad case列表
拿到分组准确率之后,下一步就是挖掘错误样本。我的习惯是先看“错误最多的是哪几类组合”,然后从每个组合里随机抽几条bad case看看模型到底错在哪。
在Pandas里,可以先筛出预测错误的样本,再按“真实标签+预测标签”分组计数:
python复制bad_df = df[~df['is_correct']]
bad_pairs = (
bad_df.groupby(['label_std', 'pred_label'])
.size()
.reset_index(name='count')
.sort_values('count', ascending=False)
)
print(bad_pairs.head(10))
比如你发现“真实标签是投诉、预测成咨询”的样本最多,那就单独取出这个条件的所有行,打印出原始文本和模型解释,逐条看。这一步几乎总能发现规律:可能是某些措辞模型理解不了,可能是标注本身有问题,也可能是prompt里缺少了某个约束。找到规律再改,比盲目换模型、换温度参数有效得多。
7. 几个让处理效率提升一个量级的Pandas习惯
7.1 尽量用向量化操作而不是for循环
最后聊一些日常习惯。很多从Python基础转来做数据的人,最顽固的习惯是for循环。但同样一个操作,用向量化可能快几十倍甚至上百倍,我实测过对一个百万行DataFrame做字符串替换,用for循环要几分钟,而str.replace连一秒都不到。
判断哪些能向量化,有个简单的标准:如果操作逐行独立、结果可以整列生成,那就能向量化。文本的strip、replace、upper,数值的加减乘除,条件选择的np.where,都属于第一梯队的选择。确实需要逐行复杂判断的场景,再退一步用apply,但尽量控制次数,别在循环里套循环。
7.2 善用query、assign、pipe保持代码可读
第二个习惯是尽量用链式写法。Pandas教程里常见的df[df['x']>1]这种布尔索引没有问题,但步骤一多,代码就变成一大坨,别人看着累,自己第二天回来看也头疼。
我更推荐把一系列操作串成链:
python复制result_df = (
df.dropna(subset=['user_query'])
.assign(correct_flag=lambda d: d['pred_label'] == d['label_std'])
.query('correct_flag == False')
.sort_values('create_time')
.groupby('label_std')
.head(5)
)
这样每一步做什么清清楚楚,而且不容易犯“变量被中间步骤误改”的毛病。不过链式操作也有个前提:你要确认每一步都返回新的DataFrame,有些操作是原地修改的,链式里混用会踩坑。
7.3 数据量超出内存时的降级方案
LLM语料经常很大,动辄几千万行。如果Pandas直接读进去内存爆掉,有几个降级方案。
第一个是分块读取,pd.read_csv()的chunksize参数可以把大文件一段一段读进来,每一段处理完就释放内存;第二个是把数据从内存搬到外存,用sqlite或者duckdb这类工具做过滤和聚合,再把结果读回Pandas;第三个是优化dtype,比如把一些分类列从object转成category类型,内存能省下一大半。
大多数场景下,先做字段类型优化、再做列裁剪,已经能解决90%的问题。我的经验是,能少读的列就别读,能用category的别用object,能聚合的就别逐行展开。内存问题不是一个无解的黑洞,只要养成“先看结构再动手”的习惯,基本都能化解。
我在实际项目中走过不少弯路,最大的体会就一句话:Pandas在LLM开发里不是一个“锦上添花”的工具,而是从数据到模型再到结果分析的整个链路里最常用的基础设施。很多听起来高级的“数据质量提升”“评测体系搭建”,落到代码层面不过就是read、clean、assign、groupby、crosstab这几个基础动作的组合。真正拉开差距的,不是你会几个函数,而是你有没有一套清晰的处理流程和判断标准。希望这篇梳理能帮你把流程搭起来,少踩几个我踩过的坑。
