1. 从一次文本分类的慢查询说起文本分类这个活儿做过的人都知道模型推理本身其实不算慢真正拖后腿的往往是数据搬运和特征处理那一段。我之前手上有个项目用 Jev 做多标签文本分类数据量大概在千万级每天要跑一轮全量推理。最开始跑一次要将近四个小时其中模型前向传播只占了不到二十分钟剩下的时间全耗在从数据库拉数据、拼 prompt、写回结果这些环节上。后来我把整条链路重新梳理了一遍用 TypeSafe 的思路把 Jev 的调用封装成强类型接口再把分析型查询下沉到 MotherDuck 上执行最终整体耗时压到了原来的五十分之一左右。这篇文章就把这套方案的来龙去脉拆开讲清楚包括为什么这么选、每一步怎么落地、踩过哪些坑。先说清楚这套东西是干什么的。TypeSafe Jev指的是用类型安全的方式去调用 Jev 模型——Jev 是一个文本分类能力比较强的模型支持通过 prompt 模板做零样本和少样本分类但它的原生调用方式对输入输出的约束比较松容易出现字段缺失、类型错乱、prompt 拼接错误这类问题。TypeSafe 的做法是把输入 schema、输出 schema、prompt 模板全部定义成强类型结构编译期就能发现大部分低级错误。MotherDuck则是 DuckDB 的云原生版本把分析型数据库的能力搬到了云端支持直接用 SQL 做大规模聚合和特征计算而且对 Parquet、CSV 这些格式的读取非常快。把这两者接起来核心思路就是让 MotherDuck 负责重活数据扫描、特征聚合、结果落盘让 Jev 只负责它最擅长的部分语义分类中间用 TypeSafe 的接口做胶水保证数据在两边流转时不丢字段、不错类型。这套方案适合谁呢如果你手上有中等规模以上的文本分类任务数据存在关系型数据库或者数据湖里模型用的是 Jev 或者类似的 prompt-based 分类模型而且你已经被“拉数据慢、拼 prompt 乱、写结果错”这三件事折磨过那这套思路基本可以直接抄。哪怕你用的不是 Jev换成别的分类模型只要把 TypeSafe 那层接口改一改MotherDuck 那层的 SQL 逻辑是通用的。2. 整体架构设计与选型考量2.1 为什么不是“一把梭”全放数据库里最直觉的做法是把所有东西都塞进数据库用 SQL 做特征用 UDF 调模型。我试过走不通。原因有三个第一Jev 的推理需要 GPU 或者至少是优化过的 CPU 推理环境数据库进程里跑模型会把数据库本身拖垮第二prompt 模板的版本管理、few-shot 示例的切换这些是应用层的事硬编码进 SQL 里改一次要动数据库第三分类结果往往还要做后处理阈值过滤、标签映射、置信度校准这些逻辑用 SQL 写会非常别扭。所以架构上必须是分层的数据库管数据应用层管模型调用中间用明确的接口契约连接。2.2 为什么选 MotherDuck 而不是别的分析型查询这块可选的有传统数仓、Spark、还有各种云原生 OLAP。我选 MotherDuck 主要看中几点。一是它基于 DuckDB单机性能极强对 Parquet 的向量化读取做得很好千万级数据做聚合基本是秒级返回。二是它支持标准 SQL我不用为了迁就某个引擎去学一套新的 DSL现有的 SQL 优化经验直接能用。三是它的云原生特性让本地开发和云端执行可以用同一套代码本地用 DuckDB 跑测试上线切到 MotherDuck 只改一个连接串。四是成本按查询量计费的模式对于这种“每天跑一轮、平时不怎么查”的场景比常驻集群划算太多。2.3 TypeSafe 这层到底解决什么问题很多人觉得类型安全是“锦上添花”我一开始也这么想直到有一次线上跑批因为上游某个字段从 string 变成了 null导致 prompt 拼接出来少了一段模型把几千条数据全分到了错误类别第二天才发现。TypeSafe 这层的价值在于把“数据形状”这件事从运行时提前到编译时。具体做法是用类型系统定义三个东西——输入记录的结构哪些字段、什么类型、是否可空、prompt 模板的参数结构模板里占位符对应哪些字段、输出结果的结构分类标签、置信度、原始响应。任何一边对不上编译就过不去。这比写一堆运行时校验省事得多也比靠文档约定可靠得多。2.4 数据流转的整体链路整条链路我画成文字版是这样的原始文本存在关系型数据库里每天跑批时先用一条 SQL 把待分类数据导出成 Parquet 落到对象存储MotherDuck 直接读这个 Parquet做去重、截断、字段清洗、批次划分输出一张“待推理表”应用层从 MotherDuck 拉这批数据经过 TypeSafe 的输入校验拼成 prompt批量调 JevJev 返回结果后经过 TypeSafe 的输出校验写回 MotherDuck 的结果表最后在 MotherDuck 里做聚合统计和标签分布分析生成报表。整个过程中数据只在“待推理表”和“结果表”这两个边界上跨系统流动中间不落地到本地磁盘减少了 IO 开销。3. 核心细节解析与实操要点3.1 输入 Schema 的定义要点定义输入 schema 的时候最容易犯的错是“照搬数据库表结构”。数据库表里往往有一堆跟分类无关的字段比如创建时间、更新时间、操作人这些字段如果进了 schema不仅浪费校验开销还会让 prompt 模板的维护变复杂。我的做法是只保留分类真正需要的字段文本内容、可选的上下文、可选的业务标签用于 few-shot 示例筛选。文本内容字段要特别注意长度Jev 对输入长度有上限超长文本必须在进 schema 之前就截断而不是等到拼 prompt 的时候才处理。截断策略我一般用“头尾保留、中间省略”因为文本分类任务里开头和结尾往往包含最关键的信息。另一个要点是可空字段的处理。数据库里的 null 和空字符串是两回事但在 prompt 里都表现为“没有内容”。我的做法是在 schema 里明确区分如果某个字段允许为空就在模板里给它一个默认占位符比如“无”如果不允许为空就在校验阶段直接拒绝让问题数据在进模型之前就暴露出来。这样比让模型去猜“这个空字段是什么意思”要可靠得多。3.2 Prompt 模板的参数化设计Prompt 模板千万不要硬编码在代码里。我见过太多项目模板字符串直接写在调用函数里改一个标点都要重新部署。正确的做法是把模板抽出来做成带参数的模板参数名和输入 schema 的字段名一一对应。这样模板可以独立版本管理A/B 测试不同模板只需要换一个模板 ID不用动代码。模板参数化还有一个好处可以在编译期检查模板里用到的参数是否都在 schema 里定义了避免“模板里写了个字段名但 schema 里根本没有”这种低级错误。Few-shot 示例的管理也是同理。示例不要写死在模板里而是做成一个独立的示例库根据输入数据的业务标签动态选取最相关的几条示例拼进 prompt。这样既能保证示例的相关性又能在示例库更新时不用改模板。示例选取的逻辑我一般用简单的关键词匹配或者向量相似度前者快但粗糙后者准但要多一步计算具体用哪个看业务对准确率的要求。3.3 输出 Schema 的校验策略Jev 的输出是自然语言直接拿来用风险很大。输出 schema 的作用是把自然语言响应解析成结构化数据并做校验。我的做法是要求模型输出 JSON 格式然后在应用层用 schema 去解析和校验。如果解析失败或者字段缺失就触发重试或者降级策略。这里有个细节不要让模型自由发挥 JSON 的字段名而是在 prompt 里明确指定输出格式比如“请输出 JSON包含 label 和 confidence 两个字段label 取值必须是以下之一...”。这样能大幅降低解析失败率。置信度字段的处理也要注意。模型给的置信度往往不是校准过的概率直接拿来当阈值过滤会出问题。我的做法是把置信度分成几档高、中、低高置信度直接采纳中置信度进人工复核队列低置信度直接丢弃或者走规则兜底。这样比用一个固定阈值卡要稳健得多。3.4 MotherDuck 侧的 SQL 优化要点MotherDuck 虽然快但 SQL 写不好照样慢。几个关键优化点第一尽早过滤。待分类数据里往往有一部分是已经处理过的或者不符合分类条件的这些数据要在扫描阶段就用 WHERE 过滤掉不要等到聚合完再过滤。第二用 Parquet 而不是 CSV。Parquet 是列式存储MotherDuck 读的时候只读需要的列IO 量能差好几倍。第三批次划分用窗口函数。把待推理数据按固定大小分批用 ROW_NUMBER() 加整除就能实现比在应用层分批要快得多因为分批逻辑下推到数据库执行了。第四结果表建索引。虽然 DuckDB 对全表扫描很快但如果结果表要频繁按标签查询建一个标签列的索引还是有必要的。3.5 批量推理的并发控制Jev 的推理接口一般都有并发限制打太猛会被限流打太慢又浪费时间。我的做法是用一个固定大小的线程池每个线程负责一批数据的推理批次大小根据接口的 QPS 限制和单次推理耗时来算。具体公式是批次大小 目标 QPS × 单次推理耗时。比如接口限制 10 QPS单次推理 200ms那批次大小就是 2。实际跑的时候我会留一点余量用 1.5 倍左右。另外要加退避重试遇到限流错误就等一会儿再试不要硬刚。4. 实操过程与核心环节实现4.1 环境准备与依赖安装先把基础环境搭起来。Python 环境我用的 3.11依赖主要是三块MotherDuck 的客户端、Jev 的调用 SDK、还有类型校验相关的库。MotherDuck 客户端直接用 duckdb 包就行它内置了对 MotherDuck 的支持只需要在连接的时候带上 token。Jev 的 SDK 按官方文档装注意版本要和模型版本匹配。类型校验我用的是 pydantic它的 model 定义和校验能力足够覆盖这个场景。pip install duckdb pydantic requestsMotherDuck 的连接串格式是md:加上数据库名token 通过环境变量传不要硬编码在代码里。Jev 的密钥同理走环境变量或者密钥管理服务。4.2 定义 TypeSafe 的输入输出模型用 pydantic 定义输入模型字段名和数据库里的列名对应但只保留需要的字段。文本字段加一个最大长度校验超长的直接拒绝让上游去处理。输出模型定义 label 和 confidence 两个字段label 用枚举类型限制取值范围confidence 用浮点数加范围校验。from pydantic import BaseModel, Field, field_validator from enum import Enum class Label(str, Enum): POSITIVE positive NEGATIVE negative NEUTRAL neutral class ClassificationInput(BaseModel): text: str Field(..., max_length2000) context: str | None None field_validator(text) def text_not_empty(cls, v): if not v.strip(): raise ValueError(text cannot be empty) return v.strip() class ClassificationOutput(BaseModel): label: Label confidence: float Field(..., ge0.0, le1.0)这里有个细节text字段的max_length设成 2000是因为 Jev 的上下文窗口加上 prompt 模板本身占用的 token留给输入文本的空间大概就是这么多。设太大没用模型会截断设太小会丢信息。这个值要根据实际用的模型版本和模板长度来调。4.3 MotherDuck 侧的数据准备 SQL数据准备分三步。第一步把原始数据导出成 Parquet这一步在源数据库那边做用标准的导出命令就行。第二步在 MotherDuck 里建待推理表做去重和清洗。第三步做批次划分。-- 建待推理表从 Parquet 读取并清洗 CREATE TABLE pending_classification AS SELECT id, TRIM(text) AS text, CASE WHEN LENGTH(context) 500 THEN SUBSTR(context, 1, 500) ELSE context END AS context, ROW_NUMBER() OVER (ORDER BY id) AS row_num FROM read_parquet(s3://bucket/raw_data/*.parquet) WHERE text IS NOT NULL AND LENGTH(TRIM(text)) 0 AND id NOT IN (SELECT id FROM classification_results); -- 批次划分每批 500 条 CREATE TABLE batches AS SELECT id, text, context, (row_num - 1) / 500 AS batch_id FROM pending_classification;去重那一步用NOT IN子查询如果结果表很大这个子查询会慢可以改成 LEFT JOIN 加 IS NULL 判断性能更好。批次大小 500 是拍脑袋定的实际要根据 Jev 接口的批量能力和单批耗时来调一般控制在单批推理时间 30 秒到 2 分钟之间比较合适。4.4 批量调用 Jev 的完整流程从 MotherDuck 拉一批数据经过输入校验拼 prompt调 Jev解析输出写回结果。整个过程用一个循环包起来按 batch_id 遍历。import duckdb import os from concurrent.futures import ThreadPoolExecutor conn duckdb.connect(fmd:my_db?motherduck_token{os.environ[MD_TOKEN]}) def process_batch(batch_id): rows conn.execute( SELECT id, text, context FROM batches WHERE batch_id ?, [batch_id] ).fetchall() inputs [] for row in rows: try: inp ClassificationInput(textrow[1], contextrow[2]) inputs.append((row[0], inp)) except ValueError as e: print(fskip invalid row {row[0]}: {e}) results [] for doc_id, inp in inputs: prompt build_prompt(inp) response call_jev(prompt) try: output ClassificationOutput.model_validate_json(response) results.append((doc_id, output.label.value, output.confidence)) except Exception as e: print(fparse failed for {doc_id}: {e}) if results: conn.executemany( INSERT INTO classification_results VALUES (?, ?, ?), results ) with ThreadPoolExecutor(max_workers8) as executor: batch_ids [r[0] for r in conn.execute(SELECT DISTINCT batch_id FROM batches).fetchall()] executor.map(process_batch, batch_ids)这里有几个实操细节。build_prompt函数负责把输入模型和模板拼成最终 prompt模板从外部加载不要写死。call_jev函数负责调接口里面要加超时和重试。写回结果用executemany批量插入比一条条插快很多。线程池大小 8 是根据接口 QPS 限制调的实际要压测一下找到最优值。4.5 结果校验与后处理结果写回之后还要做一轮校验。主要检查三件事分类覆盖率有多少条数据成功分类了、标签分布有没有某个标签异常多或异常少、置信度分布低置信度的比例有多高。这些检查用 SQL 就能做。-- 分类覆盖率 SELECT (SELECT COUNT(*) FROM classification_results) * 100.0 / (SELECT COUNT(*) FROM pending_classification) AS coverage_pct; -- 标签分布 SELECT label, COUNT(*) AS cnt, AVG(confidence) AS avg_conf FROM classification_results GROUP BY label ORDER BY cnt DESC; -- 低置信度比例 SELECT SUM(CASE WHEN confidence 0.6 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS low_conf_pct FROM classification_results;覆盖率低于 95% 就要查原因通常是输入校验拒绝太多或者解析失败太多。标签分布如果某个标签占比超过 80%大概率是 prompt 有问题或者模型对这类数据有偏。低置信度比例超过 20% 说明模型对这批数据不太确定需要考虑补充 few-shot 示例或者调整模板。5. 常见问题与排查技巧实录5.1 推理速度上不去的排查思路速度慢是最常见的问题。排查顺序我一般是这样先看 MotherDuck 侧的查询耗时用EXPLAIN ANALYZE看执行计划确认是不是有全表扫描或者数据倾斜。再看应用层的耗时分布在拉数据、拼 prompt、调接口、写结果这四个环节分别打点看哪个环节是大头。最后看 Jev 接口的响应时间如果单次推理就慢那跟架构无关是模型本身的问题。我遇到过一种情况是 MotherDuck 查询很快但应用层拉数据慢原因是用了fetchall()一次性拉太多改成流式拉取就解决了。5.2 解析失败率高的处理办法解析失败通常有三个原因模型没按格式输出、输出被截断、字段名对不上。第一个原因靠改 prompt 解决在 prompt 里把输出格式写得更明确最好给一个示例。第二个原因靠调大 max_tokens 解决但要注意别调太大否则模型会啰嗦。第三个原因靠输出 schema 的容错解决比如允许字段名有别名或者用宽松模式解析。我的经验是把这三个都处理好解析失败率能压到 1% 以下。5.3 数据不一致的排查清单数据不一致是最头疼的问题往往表现为“明明处理了但结果表里没有”或者“同一条数据有两个结果”。排查的时候按这个清单走先确认批次划分有没有重叠ROW_NUMBER()的排序字段是不是唯一的。再确认写回的时候有没有事务保护多线程写同一张表要加锁或者用 upsert。最后确认去重逻辑是不是在正确的位置去重应该在待推理表生成的时候做而不是在结果写回的时候做。问题现象可能原因排查方法解决方案结果表有重复记录批次重叠或写回无幂等按 id 分组计数批次划分加唯一排序写回用 upsert部分数据未分类输入校验拒绝或解析失败查日志中的 skip 和 parse failed放宽校验或改 prompt标签分布异常prompt 有偏或模型偏差对比历史分布调整模板或补 few-shot推理速度波动大接口限流或网络抖动看响应时间分布加退避重试降低并发置信度普遍偏低模型对数据不熟悉看低置信度样本补示例或换模型版本5.4 几个我踩过的坑第一个坑是在 prompt 里用了数据库的原始字段名。数据库字段名往往是缩写或者带下划线模型看了半天不知道什么意思分类准确率上不去。后来改成用自然语言描述字段含义准确率立刻涨了一截。第二个坑是批次大小设得太大。一开始设了 5000结果单批推理要十几分钟中间一旦失败整批重来浪费大量时间。后来改成 500失败重试的成本低多了。第三个坑是没有做结果幂等。有一次跑批中途挂了重启后从头跑结果表里出现了重复记录。后来在写回的时候加了ON CONFLICT DO UPDATE问题解决。5.5 性能调优的实操心得调优这件事我的原则是先测量再优化。不要凭感觉猜哪里慢一定要打点。我一般会在四个环节各加一个计时器跑一轮之后看分布。如果拉数据占大头就优化 SQL 或者改流式拉取如果拼 prompt 占大头就缓存模板编译结果如果调接口占大头就加并发或者换更快的模型如果写回占大头就批量插入或者异步写。另外MotherDuck 的查询结果可以缓存同样的查询第二次跑会快很多利用好这个特性可以把重复查询的成本降到几乎为零。6. 这套方案还能怎么扩展跑通之后我又做了几个扩展效果都不错。一个是增量处理不再每天全量跑而是只处理新增数据用源数据库的更新时间戳做增量标记每天的处理量降到了原来的十分之一。另一个是多模型投票对低置信度的数据同时调 Jev 和另一个分类模型两个结果一致就采纳不一致就进人工队列准确率提升明显。还有一个是结果回流把人工复核过的数据作为新的 few-shot 示例回灌到示例库里模型越跑越准。这几个扩展都不复杂核心还是那套 TypeSafe 加 MotherDuck 的架构只是在上面加了一层逻辑。我个人在实际操作中的体会是这套方案最大的价值不在于“快 50 倍”这个数字而在于它把整条链路变得可观测、可复现、可扩展。每个环节的输入输出都有明确的契约出了问题能快速定位想加新功能也有地方下手。如果你正在做类似的文本分类任务建议先把 TypeSafe 那层接口定义清楚再把数据准备下沉到 MotherDuck剩下的就是调参和优化的事了。
阅读完成 · 觉得有帮助?