万级文本数据调用 LLM API 的工程化“防坑防断”指南

3 阅读4分钟

📌 痛点:跑几十条没问题,跑 14,000 条就崩了

很多 LLM 应用的 demo 跑得很顺畅,一到生产环境处理大量数据就各种问题:

  • 跑到一半触发 API 限流,程序中断
  • 网络抖动导致某次请求失败,之前的结果全部丢失
  • 数据里有脏值(空文本、超长文本),程序直接报错退出

我在处理 14,000 条地铁微博时,把这些坑全踩了一遍。这篇文章分享一套经过实战检验的批量调用 LLM API 的工程化方案

🛠️ 问题一:API 限速——如何优雅地“慢下来”

免费或低成本的 API 都有速率限制(比如每分钟 60 次)。如果不加控制,程序会在短时间内疯狂请求,然后被 API 返回 429 错误。

解决方案:在循环中加入固定间隔

import time

for idx, row in tqdm(df.iterrows(), total=len(df)):
    category = call_deepseek_api(text)
    results.append(category)
    time.sleep(1.2)  # 每次请求后等待 1.2
  • 为什么是 1.2 秒?  每分钟 60 次 = 每秒 1 次。1.2 秒的间隔保证了每分钟约 50 次请求,留出安全余量。
  • tqdm 进度条:让你知道程序还在跑,以及预计剩余时间。

💾 问题二:程序中断——如何避免结果全部丢失?

假设你跑了 8,000 条,突然电脑死机或 API 报错,如果没保存进度,之前的结果全部作废。

解决方案:每 N 条保存一次临时文件

if (idx + 1) % 1000 == 0:
    temp_df = pd.DataFrame({
        '微博原文': df['微博正文'].iloc[:idx + 1],
        '需求层次': results
    })
    temp_df.to_csv('classified_results_temp.csv', index=False, encoding='utf_8_sig')

关键点

  • (idx + 1) % 1000 == 0:每处理 1000 条就保存一次。
  • 保存的是当前已处理的所有数据,而不是增量。
  • 程序重启后,可以从临时文件读取已处理的数据,跳过前 N 条继续跑。

🛡️ 问题三:异常处理——如何让单次失败不影响整体?

网络抖动、API 暂时不可用、某条数据格式异常……这些都会导致单次请求失败。如果不用 try-except 包裹,整个程序就会崩溃。

解决方案:捕获异常并返回默认值

def call_deepseek_api(text):
    try:
        response = requests.post(url, headers=headers, json=payload, timeout=20)
        response.raise_for_status()
        result = response.json()['choices'][0]['message']['content'].strip()
        return result if result in CATEGORIES else "其他"
    except Exception as e:
        print(f"API调用出错: {str(e)[:50]}")  # 打印简略错误信息
        return "其他"  # 返回默认值,让程序继续

关键点

  • timeout=20:防止某个请求卡死,20 秒后自动超时。
  • 返回默认值:失败时返回“其他”或“API错误”,而不是让程序中断。
  • 错误日志:打印错误信息但截断长度,避免刷屏。

📁 问题四:数据质量——如何应对空文本和异常格式?

真实数据里总有脏东西:空字符串、全是空格、None 值、超长文本……

解决方案:在循环入口做防御性处理

text = str(row['微博正文']).strip()
if not text:
    results.append("其他")  # 空文本直接归类
    continue

# 截断超长文本,控制 token 消耗
category = call_deepseek_api(text[:500])

关键点

  • str().strip() 确保文本是字符串且去除了首尾空格。
  • 空文本直接跳过,不浪费 API 调用。
  • text[:500] 截断长文本,对于分类任务,前 500 字已经足够判断。

📊 完整的主循环代码

def process_csv(input_path, output_path):
    df = pd.read_csv(input_path, encoding='utf-8-sig')
    results = []
    
    for idx, row in tqdm(df.iterrows(), total=len(df)):
        text = str(row['微博正文']).strip()
        if not text:
            results.append("其他")
            continue
        
        category = call_deepseek_api(text[:500])
        results.append(category)
        
        # 每1000条保存进度
        if (idx + 1) % 1000 == 0:
            pd.DataFrame({
                '原文': df['微博正文'].iloc[:idx + 1],
                '需求层次': results
            }).to_csv('temp.csv', index=False)
        
        time.sleep(1.2)  # 限速
    
    # 最终保存
    pd.DataFrame({
        '微博原文': df['微博正文'],
        '需求层次': results
    }).to_csv(output_path, index=False)

💡 总结:批量调用的四道保险

保险方法作用
限速time.sleep(1.2)避免触发 API 限流
断点续传每 1000 条保存临时文件防止中断导致数据丢失
异常处理try-except + 默认值单次失败不影响整体
数据清洗空值检查 + 截断减少无效调用和 token 消耗

这套方案让我的 14,000 条数据处理稳定运行了约 5 个小时,中间网络波动了 3 次,但没有丢失任何已处理的数据。

🔗 完整代码

👉 nanjing-metro-analysis/scripts/01_demand_classification/classify_demand.py