别让 LLM 直接改你的数据 —— 一个 AI 数据清洗工具的 12 个工程设计决策

0 阅读15分钟

别让 LLM 直接改你的数据 —— 一个 AI 数据清洗工具的 12 个工程设计决策

最近在做一个叫 SmeltLab 的开源项目:上传一份脏数据,说一句「去掉重复行、统一日期格式、脱敏手机号,然后训练个模型预测并发症」,它就把体检、清洗、建模、解读一条龙跑完。

功能本身没什么好说的,这篇文章记录的是实现过程中那些不太显然、但决定了这东西能不能用的工程决策。技术栈是 FastAPI + Polars + scikit-learn + Next.js,全部跑在本地。

先给个总体架构:

Browser  Next.js 14 / React 18 / TypeScript / Tailwind
  上传 · 对话 · 训练卡 · 图表 · SSE 进度流
              │  REST + SSE
              ▼
FastAPI   app/api/routes
  datasets · clean · train · agent · recipes
              ▼
Services  app/services
  profiler        健康分 / 问题检测 / 列级画像
  cleaner         9 种算子 + 变更记录 + 计划生成
  trainer         轻量 AutoML / 分组切分 / 指标
  trainer_codegen 训练脚本生成 + 进度上报
  training_jobs   子进程调度 / 日志解析
  pi_agent        本地 agent 引擎 → 可见步骤流
              ▼
      Polars + scikit-learn(本地工作区)

一、意图和执行必须分开

这是整个项目的地基。

最直觉的做法是把数据丢给模型让它改。问题有三个:token 成本高(10 万行的表塞不进上下文)、不可复现(同样的输入两次结果可能不一样)、会幻觉(它可能"顺手"帮你编一个填充值,而且不告诉你)。

我们的做法是让 LLM 只输出一个操作计划 JSON,真正动数据的是 Polars 算子:

# LLM 的输入:schema + 健康分 + 问题摘要 + 5 行样本(不是全量数据)
# LLM 的输出:
{
  "reasoning": "...",
  "estimated_row_reduction": 0.04,
  "steps": [{"step_id": "s1", "operation": "dedup", "config": {...}}, ...]
}

5 行样本是关键——够模型判断"这一列是日期但格式混乱",又不至于把数据喂进去。提示词里还写死了算子顺序:

dedup first, then PII, then format fixes, then outlier removal

因为先去重再去异常值,和反过来,结果是不一样的。

好处很直接:指标可复现、过程可审计、模型编不出数字。跑出来的 AUC 0.82,你重跑一百次还是 0.82。


二、LLM 输出的 JSON 一定会截断,要有四级容错

真实环境里模型输出被 max_tokens 截断是常态,尤其 steps 数组比较长的时候。所以解析不能只写一句 json.loads:

def _parse_json_robust(text):
    # 1. 直接解析
    try: return json.loads(text)
    except: pass
    # 2. 正则抠出 {"steps": ...} 对象(模型常在 JSON 前后加解释文字)
    m = re.search(r'\{[\s\S]*"steps"[\s\S]*\}', text)
    if m:
        try: return json.loads(m.group(0))
        except: pass
    # 3. 从最后一个 step_id 处做花括号配对,补齐 "]\n}"
    # 4. 从末尾逐位回退,用栈计算未闭合括号并补齐
    ...

第 3、4 级是专门针对"数组截断"的:模型写到第 7 步就没 token 了,前面 6 步其实都是完整合法的,丢掉太可惜。用栈算出还差几个 } 和 ],补上再解析,能救回大部分情况。

另外 GLM-4.5 这类推理模型可能把结果放在 reasoning_content 字段而不是 content,取的时候要做回退。


三、diff 不是事后比对,是算子自带变更记录

用户最关心的问题是:你到底改了我的哪些数据?

如果等清洗完了再拿两份 DataFrame 比对,你只能得到"有 1200 个单元格变了",说不清是哪一步变的、能不能只撤销其中一步。

我们的做法是每个算子统一返回变更记录:

@dataclass
class ChangeRecord:
    row_index: int
    column: str
    before: Any
    after: Any
    operation: str
    step_id: str

实现上就是执行前后各取一次列值,逐位比对:

before = df[col].to_list()
# ... 执行 polars 表达式 ...
after = df[col].to_list()
for i, (b, a) in enumerate(zip(before, after)):
    if b != a:
        changes.append(ChangeRecord(i, col, b, a, "text_clean", step_id))

行删除这类操作怎么表达? 它没有"改后值"的概念。我们用一个哨兵列名 __row__ + after="removed":

if c.column == "__row__" and c.after == "removed":
    rows_removed += 1
else:
    values_modified += 1

这样"删了哪些行"和"改了哪些值"能在同一套结构里表达,diff 天然可审计。三个衍生能力也因此变得很简单:

  • 逐条拒收:用户在界面上取消勾选某一步,后端从执行前状态重放,跳过那一步即可;
  • 版本回滚:保留每一步的输入快照,target_version=0 直接回到原始数据;
  • 分布漂移检测:清洗前后各列的分布对比。

四、健康分:4 条规则,各自封顶

数据体检要给出一个 0-100 的分数。规则必须简单、可解释,否则用户不信任:

score = 100.0
score -= min(dup_pct * 2, 30)          # 重复行:每 1% 扣 2 分,最多 -30
score -= min(avg_missing * 1.5, 30)    # 平均缺失率 ×1.5,最多 -30
score -= min(total_issues * 3, 30)     # 列级问题条数 ×3,最多 -30
score -= min(constant_cols * 5, 10)    # 常量列每列 -5,最多 -10
return max(0, min(100, round(score)))

四项上限加起来正好 100,保证分数一定落在 0-100,不会出现"负分"这种需要额外 clamp 的尴尬。

两个细节:第二条用的是全体列的平均缺失率而不是最差列(避免一列全空就把分数打到 0),第三条用的是问题条数而不是严重度加权——这是个偏宽松的设计,因为我们希望分数是用来"排序优先级"而不是"判死刑"。

问题检测里稍微有点意思的是语义类型推断:在 dtype 之上再识别业务类型,取前 100 个非空值做正则投票。

if email_matches / len(values) > 0.8: return "email"
if url_matches   / len(values) > 0.8: return "url"
if phone_matches / len(values) > 0.8: return "phone"
for fmt in _DATE_FORMATS:      # 11 种,含中文 "%Y年%m月%d日"
    if hit_rate > 0.7: return "date"
return "string"

所以它能报出"这一列混了三种日期写法",而不只是告诉你 dtype 是 object。


五、分组切分要重试,否则指标根本算不出来

医疗数据里最典型的场景:一个患者有多条就诊记录。如果用 train_test_split 随机切,同一个人会同时出现在训练集和测试集,模型直接记住了这个人,AUC 虚高。

正确做法是按实体分组切分。但 GroupShuffleSplit 有个坑:随机切出来的测试集可能一个正例都没有,PR-AUC 直接算不出来。

解法是重试,最多 30 次:

groups = df[group].cast(pl.Utf8).to_numpy()
for attempt in range(30):
    splitter = GroupShuffleSplit(n_splits=1, test_size=test_size,
                                 random_state=SEED + attempt)
    train_idx, test_idx = next(splitter.split(indices, groups=groups))
    if task != "classification":
        break
    # 尽量让两侧都有正例
    if y_all[test_idx].sum() >= 2 and y_all[train_idx].sum() >= 5:
        break

random_state=SEED+attempt 保证换种子而不是换方法,且整体仍是确定性的(SEED 固定)。阈值取 2 和 5 是因为测试集正例少于 2 个时指标方差大到没有意义。


六、泄漏列的三重防御

数据泄漏比模型选错更致命,因为它会让你以为模型很好。三层防御:

① 统计型——取值近乎唯一的整型列几乎一定是 ID:

if unique / rows > 0.9 and series.dtype in (pl.Int64, pl.Int32, pl.UInt64, pl.UInt32):
    dropped.append({"name": col, "reason": "疑似 ID(取值近乎唯一)"})

② 语义型——事后信息。中文场景里这类列名很有规律,直接建关键词表:

LEAK_HINTS = ("费用", "出院", "入院时间", "出院时间", "icu", "ICU",
              "住院日", "住院天", "科室", "姓名", "地址", "电话", "呼吸机")

「住院费用」是并发症发生之后才产生的,「出院科室」和并发症的因果关系是反的。这类列必须剔除,而且要在界面上明确告诉用户"我剔除了这几列,原因是……"。

同样的思路还用来自动选目标列和分组列,用打分而不是硬规则:

def score_target(item):
    score = 0
    if any(h in name for h in TARGET_HINTS): score += 10   # 死亡/结局/转归/预后/label...
    if any(h in name for h in ID_HINTS):     score -= 20   # 编号/患者/病人/住院号...
    if item["unique"] == 2:                  score += 4
    if item["null_pct"] > 20:                score -= 2
    return score

③ 编码防泄漏——填充值和类别映射只能从训练集学:

train_values = values[train_idx]
median = float(np.median(finite))                        # 只算训练集的
categories = sorted({str(v) for v in train_values})      # 只认训练集见过的类别
mapping = {value: idx for idx, value in enumerate(categories)}

用全量数据的中位数填充,是很多人会犯的错。


七、分类阈值不该默认 0.5

不平衡数据上 0.5 基本没法用。我们的做法是在 PR 曲线上找 F1 最大点,并且把阈值存进模型产物:

precision_curve, recall_curve, thresholds = precision_recall_curve(y_test, proba)
f1_curve = np.divide(2 * precision_curve * recall_curve,
                     precision_curve + recall_curve,
                     out=np.zeros_like(precision_curve),
                     where=(precision_curve + recall_curve) > 0)   # 安全除法,防 0/0
best_pos  = int(np.argmax(f1_curve))
threshold = float(thresholds[min(best_pos, len(thresholds) - 1)])
pred = (proba >= threshold).astype(np.int64)

np.divide 的 where 参数是必须的——PR 曲线末端会出现 precision 和 recall 同时为 0 的点,直接除会出 warning 和 nan。

另外两个选择:

  • 模型择优用 PR-AUC 而不是 ROC-AUC,对不平衡数据更敏感;
  • 每个任务都先训一个基线模型(多数类 / 中位数),这样你能一眼看出"0.85 的 AUC"到底是学到了东西,还是只是猜了多数类。

所有模型都显式带 class_weight="balanced" 和固定的 random_state。

兜底比指标本身更重要:目标缺失剔除了多少行、类别太少没做分层、测试集正例少于 3 个、训练集正例少于 30 个、超过 5 万行做了抽样、超时跳过了哪些模型——这些全部以中文 warning 回传前端。给用户一个 AUC 数字但不告诉他这个数字不可信,是静默失败。


八、树模型怎么出训练进度

神经网络可以逐 epoch 报进度,随机森林不行——fit() 是一个黑箱调用。

解法是分块 warm start:先建一个树数量很小的模型,然后只增加树的数量、不重建模型。scikit-learn 的 set_params(n_estimators=...) 配合重新 fit 在树模型上是支持这种增长的:

chunk = max(target_total // 6, 1)          # 固定切成 6 段
model = make_model(model_name, {**params, "n_estimators": chunk}, task)
grown = 0
while grown < target_total:
    grown = min(grown + chunk, target_total)
    model.set_params(n_estimators=grown)   # 只改树数量,不重建
    model.fit(X_fit_s, y_fit)
    metric = eval_metric(model, X_val_s, y_val, task)   # PR-AUC 或 R²
    curve.append([step, round(metric, 4)])
    emit(20 + 62 * step / total_steps, f"{label} {grown}/{target_total}", ...)

逻辑回归和 MLP 走 partial_fit 增量学习(SGDClassifier(loss="log_loss", max_iter=1, tol=None))。

注意训练曲线画的是验证集指标——脚本内部再从训练集切 20% 做验证,绝不用测试集调参。


九、子进程和主进程怎么通信:标记行协议

训练跑在独立子进程里(subprocess.Popen),需要把进度传回后端再推给前端。我们约定三个 stdout 标记:

def emit(pct, stage, **extra):
    payload = {"pct": int(pct), "stage": stage}
    payload.update(extra)
    print("@@PROGRESS " + json.dumps(payload, ensure_ascii=False), flush=True)

def finish(payload):
    print("@@RESULT " + json.dumps(payload, ensure_ascii=False), flush=True)

# __main__ 兜底
# except Exception as exc:
#     print("@@ERROR " + json.dumps({"message": str(exc)}, ensure_ascii=False), flush=True)

后端逐行分派:

if line.startswith("@@PROGRESS "):  job.pct / stage / metrics / history 更新
elif line.startswith("@@RESULT "):  job.result = json.loads(...)
elif line.startswith("@@ERROR "):   job.error = ...
elif line.strip():                  _append_log(job, line)   # 普通 stdout 进日志环

flush=True 是必须的,否则子进程 stdout 走管道时是块缓冲,进度会全部卡在最后一起吐出来,进度条就成了摆设。

选标记行而不是用队列/RPC,是因为这套协议同时服务两个场景:CLI 直接跑(python -m app.cli train,人看 stdout)和 Web 训练(后端解析)。@@ 前缀对直接阅读输出的人来说只是略微碍眼,对解析器来说是明确的锚点。


十、生成的训练脚本要能独立跑

界面上有个「查看训练代码」按钮,返回的是一份完整、独立、可直接 python xxx.py 运行的脚本,不是伪代码。

关键是生成时把真实 schema 硬编码进 CONFIG,脚本里不依赖后端的任何运行时状态:

# 生成前先 inspect 真实数据
schema = inspect_dataset(path, target, group, drop)
# 抽出:numeric / categorical / fill(各列中位数) / categories(各类别取值表) / positive / negative

config = {...}
_TEMPLATE.replace("__CONFIG__", json.dumps(config, ensure_ascii=False, indent=4))

于是脚本开头长这样:

自律训练脚本 —— 由 SmeltLab 根据数据集自动生成
数据集:xxx.csv(1200 行 × 18 列)
目标列:是否死亡(二分类)
模型:随机森林 · 特征 15 个(数值 10 / 类别 5)
流程:读取数据 → 构建特征 → 切分 → 训练(逐轮上报进度)→ 测试集评估 → 保存模型与预测
运行:python 本文件

这样做的好处是结果可带走出走:用户拿到的不是一个只能在这个工具里用的黑箱,而是一份可以拷进自己流水线的、人能读懂的代码。需要 scaling 的模型(lr / mlp)会把 StandardScaler 一起 dump 进 joblib,保证推理可复现。


十一、Windows 上的四个坑

这部分花的时间比预想的多。

① npm 全局安装的 .cmd 不能被 subprocess 直接拉起

我们把本地 agent CLI 当子进程调用,它在 Windows 上是 pi.cmd。subprocess 直接跑 .cmd 有一堆兼容问题。解法是先解析出真实入口再还原 argv:

def _node_cli_bypass():
    # 返回 "<node路径>|<cli.js路径>"

def wrap_cmd_for_windows(argv):
    # "|" 拆分 → [node, cli.js, ...args]
    # 其余 .cmd/.bat 走 cmd.exe /c list2cmdline

② asyncio subprocess 在 Windows 上不稳

proactor 事件循环对子进程支持有问题,长任务会莫名其妙卡住。改成线程 + 队列:

def spawn_line_reader(...):
    # 跑在 threading.Thread 里
    # 通过 queue.Queue 把 ("line", text) / ("done", rc) / ("error", msg) 传回异步侧
    # creationflags=CREATE_NO_WINDOW  # 防弹窗

③ 管道默认 GBK

子进程输出中文,主进程读到乱码。两处都要处理:

sys.stdout.reconfigure(encoding="utf-8")     # CLI 入口
env["PYTHONIOENCODING"] = "utf-8"            # 子进程环境

④ 杀进程要杀树

超时或用户点「停止」时,proc.kill() 只杀直接子进程,孙子进程还活着继续吃 CPU:

# Windows
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], ...)

顺带一提超时设计是双层的:timeout=600s(绝对上限)+ idle_timeout=240s(这段时间内没有任何输出就判定卡死)。对 LLM 驱动的长任务,只看绝对超时是不够的——它可能卡在一个不报错的地方。


十二、对话即 UI:让模型输出驱动界面

最后一个决策是关于交互的。

清洗和建模的结果需要图表,但我们不想为每种图表写一套前后端协议。做法是约定两个特殊的代码块 info string:

// 手写极简 Markdown 渲染器,按 ``` 切段
const info = fenceInfo.split(/\s+/)[0];
if (info === 'chart')        return <Chart spec={JSON.parse(body)} />;
if (info === 'train-config') return <TrainConfigCard spec={JSON.parse(body)} />;
return <pre>{body}</pre>;

也就是说:模型输出一个 chart 代码块,界面上就出现一张图;输出 train-config,就出现一张可交互的训练卡。 后端只需要吐 JSON spec:

{"type":"bar|line|pie", "title":"...", "unit":"%", "data":[{"label":"缺失值","value":18.2}]}
{"type":"heatmap", "title":"混淆矩阵", "labels":[...], "matrix":[[90,3],[8,20]]}

图表全部手写 SVG(柱状/折线/多线/热力图/饼图五种),没引第三方图表库。原因有两个:一是 spec 驱动渲染不需要图表库的配置层,二是体积可控。热力图的强度映射就是一行:

background: `rgba(17,24,39, ${0.06 + intensity * 0.84})`

还有一个闭环设计:训练卡上的「让 AI 解读结果」按钮,本质是往对话里注入一条新消息:

window.dispatchEvent(new CustomEvent('smeltlab:ask', { detail: text }))

对话页监听这个事件,把它当一条用户消息发给 agent。工具产生的结果可以回到对话里继续追问,不需要另开一个问答界面。


附:两个容易被忽略的细节

① SSE 不能用 EventSource

EventSource 只支持 GET,而我们的 agent 请求需要 POST 大段上下文。改用 fetch + 流读取,手工切块:

const reader = res.body!.getReader();
buffer += decoder.decode(value, { stream: true });
let sep = buffer.indexOf('\n\n');
while (sep !== -1) {
  const raw = buffer.slice(0, sep);
  buffer = buffer.slice(sep + 2);
  const line = raw.split('\n').find(l => l.startsWith('data:'));
  onEvent(JSON.parse(line!.slice(5).trim()));
  sep = buffer.indexOf('\n\n');
}

配合 AbortController 实现"停止生成"——请求中断时后端的 generator finally 会触发子进程清理。

② 模型的"过程叙述"要过滤掉

模型在调工具前经常先说一句「我来看看这个文件」,这种话对用户没有信息量,还暴露了实现机制。过滤思路是攒够一个探测窗口再决定:

# 开头攒够 60 字符或遇到句末标点前,先不下发给前端
# 攒够后用 _NARRATION_PATTERNS(中英双语正则)判定
#   如 ^\s*i'?ll\s+read 、^\s*让我(先)?(读取|阅读|查看)一下
# 命中则连同整句丢弃,句末之后的剩余内容补发

同理,工具调用事件也要翻译成人话,而不是直接把 bash / read 抛给用户:

_TOOL_LABELS = {"read": "正在读取数据文件…", "write": "正在生成结果文件…", ...}
_COMMAND_STAGES = (("app.cli schema",  "正在检查数据字段与可选目标列"),
                   ("app.cli profile", "正在体检数据质量"),
                   ("app.cli clean",   "正在清洗数据"),
                   ("app.cli train",   "正在训练并评估模型"))

小结

回头看,这些决策基本围绕三条原则:

  1. LLM 只做它擅长的事(理解意图、解释结果),凡是涉及"改数据""算指标"的部分一律用确定性代码——可复现比听起来聪明更重要;
  2. 过程必须可见可审计——每一步改了什么、剔除了哪些列、指标在什么前提下可信,都要明确告诉用户,静默失败是最坏的体验;
  3. 产物要能脱离工具独立存在——生成的训练脚本能直接 python xxx.py 跑,用户不会被锁死。

项目是开源的(MIT),代码在 github.com/PHclaw/smel…。如果你也在做类似的东西,欢迎交流上面这些做法里哪些是对的、哪些有更好的解法。