别让 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", "正在训练并评估模型"))
小结
回头看,这些决策基本围绕三条原则:
- LLM 只做它擅长的事(理解意图、解释结果),凡是涉及"改数据""算指标"的部分一律用确定性代码——可复现比听起来聪明更重要;
- 过程必须可见可审计——每一步改了什么、剔除了哪些列、指标在什么前提下可信,都要明确告诉用户,静默失败是最坏的体验;
- 产物要能脱离工具独立存在——生成的训练脚本能直接
python xxx.py跑,用户不会被锁死。
项目是开源的(MIT),代码在 github.com/PHclaw/smel…。如果你也在做类似的东西,欢迎交流上面这些做法里哪些是对的、哪些有更好的解法。