用 LLM 做大规模分类:从暴力遍历到层级剪枝的实战优化
古董级程序员,大厂出来后一直在创业公司,现在仍活跃在一线做 AI 相关的工程。一些零散的想法、踩过的坑、以及对新工具的折腾记录,会不定期写在微信公众号「字与码」。如果博客里的内容对你有帮助,欢迎顺手关注。
前段时间做了一件事:给几千个 API provider 和上万个 tool 自动打上分类标签。分类体系是三级树形结构(L1→L2→L3),大约一百多个叶子节点。最朴素的做法——每个实体和每个 category 做一次 LLM 判断——算下来需要上百万次调用,光 token 费就是一笔很可观的数字。最终实际跑下来,调用量压缩到了原来的大约 1%。
这篇文章把优化过程中用到的几种手段拆出来,尽量写成通用的套路。不是只适用于「分类」场景——任何需要用 LLM 对大量数据做结构化判断的任务,遇到的问题和可以借鉴的思路其实差不多。
问题的原始规模
先把场景说具体。假设有 200 个 provider,每个 provider 下平均 50 个 tool,category 树有 3 级,共约 120 个叶子节点。
如果不做任何优化,对每个实体逐一调用 LLM 判断它是否属于每个 category:
200 providers × 120 categories = 24,000 次
10,000 tools × 120 categories = 1,200,000 次
总计 ≈ 1,224,000 次 LLM 调用
按目前主流模型的定价,哪怕用便宜的模型(比如 Qwen-plus),每次调用几百 token,总 token 量也会到数亿级别。更关键的是时间:即便并发拉满,百万级调用跑完也要几个小时。
这显然不是正确的打开方式。
优化一:层级剪枝——把树搜索变成逐级过滤
三级分类树的结构天然适合剪枝。与其把 120 个叶子节点一次性扔给 LLM,不如分三步走:
- 先用 L1(假设 10 个大类)做一次判断,确定实体属于哪几个大方向
- 只展开匹配的 L1 分支,取出其下的 L2 节点再判断一次
- 再展开匹配的 L2,在最细粒度的 L3 上做最后一次判断
这和搜索算法里的剪枝思路一样。alpha-beta 剪枝、分支定界(branch and bound)干的都是类似的事:尽早排除不可能的分支,避免遍历整棵树。区别只是”评估函数”从数值比较变成了 LLM 判断。
对于单个 provider 来说,原本需要 120 次调用(逐叶子判断),现在变成 3 次(L1 一次 + L2 一次 + L3 一次)。即使算上每层候选数量不同导致 prompt 长度增加,token 消耗也远低于逐条调用。
实际效果取决于树的分支因子和稀疏程度。一个 provider 通常只属于一两个大类,也就是说 L1 那一刀就能砍掉 80%~90% 的搜索空间。这是整个优化里收益最大的一步。
通用化
只要你的标签体系是层级结构——不管是商品分类、文档主题、权限体系还是知识图谱的节点——都可以把「一次性枚举所有候选」改成「逐级过滤」。哪怕标签体系是扁平的,人为构造两层分组(先粗分再细分)也能显著减少 LLM 的判断次数。
这其实是信息检索里很成熟的模式。搜索引擎先用倒排索引粗筛,再用排序模型精排;推荐系统先召回再排序。LLM 分类也可以这么做:cheap pass 筛范围,expensive pass 定结果。
优化二:继承传播——利用实体间的从属关系
很多数据本身就有层级关系。在我这个场景里,tool 属于 provider——一个做天气数据的 provider 下面不太可能出现金融分析的 tool。
所以流程是:先给 provider 打分类,然后每个 tool 只在自己所属 provider 的分类范围内做细化判断。
这一步的效果很直观:如果 provider 被分到了 2 个 L1 大类(假设每个 L1 下有 12 个叶子节点),tool 的候选集就从 120 缩到 24,搜索空间直接缩小了 80%。
这种思路可以推广到任何存在包含关系的场景:
- 文档分类时,先给「文件夹/项目」定主题,里面的文件在主题范围内细分
- 用户画像标签,先给「组织/团队」打标签,成员在此范围内补充个体差异
- 商品属性标注,先确定品类,再在品类内做属性匹配
本质上这是在利用先验知识缩小搜索空间。parent 的分类结果就是 child 的先验约束。
优化三:批量合并——一次 LLM 调用处理多个实体
单次 LLM 调用处理一个实体效率太低。现代 LLM 的上下文窗口动辄 128K token,只喂一条数据是严重浪费。
做法很直接:把同一个 provider 下的多个 tool 打包成一个请求,让 LLM 一次返回所有结果。
// 输入:8 个 tool 打包
{
"role": "user",
"content": "Classify these tools:\n 1. weather_current: ...\n 2. weather_forecast: ...\n ..."
}
// 输出:一次返回 8 个结果
{
"results": {
"1": ["meteorology_current"],
"2": ["meteorology_forecast"],
...
}
}
批大小的选择有讲究。太小浪费请求次数,太大可能超出上下文窗口或导致输出质量下降。实测下来,8~15 个实体一批是比较稳妥的区间。
这里有一个值得注意的细节:同 provider 下的 tool 共享同一套 category 候选集和 provider 上下文。把这部分放进 system message 作为不变前缀,tool 列表放进 user message 作为变化部分。这种结构天然对 prompt caching 友好——下面会专门说。
批量处理的经济学
主流 LLM 提供商(OpenAI、Anthropic、Google)都提供了 Batch API,对于非实时场景可以直接拿到 50% 的价格折扣,代价是 24 小时内返回结果。即使不用 Batch API,通过在应用层把多个判断合并成一次调用,token 开销也会因为 system prompt 的复用而大幅降低。
OpenAI 的文档里有一句很直白的话:prompt caching 可以把输入 token 成本降低最多 90%。前提是你的请求前缀足够长且重复率高——而批量请求里的 system message 正好满足这个条件。
优化四:增量跳过——不重复已有的判断
这条最简单但也最容易忽略:如果一个实体已经有了某些 category 标签,就不要再让 LLM 重新判断这些标签。
实现上就是在构造候选集时,先把已有的标签排除掉。如果一个 tool 已经被标记了 8 个 category,120 个候选减去 8 个,少处理的虽然不算多,但累积到上万个实体就很可观了。
更极端的情况:如果一个 tool 已经被标上了所有它所属 provider 范围内的叶子节点,那它就完全不需要参与 LLM 调用——直接跳过。在增量运行(比如每天跑一次自动分类)的场景下,大部分实体都属于这种情况,整体调用量会随着时间推移越来越少。
通用化
这就是经典的增量计算思想。数据库有 CDC(Change Data Capture),构建工具有增量编译,搜索引擎有增量索引。LLM 分类也一样:只处理变化的部分,跳过已经稳定的部分。
实现的关键是把 LLM 的输出结果持久化。下次运行时读取上次的结果,对比差异,只处理新增或变化的实体。这要求输出结构是可追踪的——用 slug 或 ID 而不是自然语言描述来标识分类结果。
优化五:祖先回填——匹配叶子后自动补齐路径
三级分类树有一个性质:如果一个实体匹配了 L3 的某个叶子节点,它必然也属于该叶子的 L2 父节点和 L1 祖父节点。
所以只要在叶子层做判断就够了,中间层的归属关系通过树结构自动推导。代码里就是一个简单的沿 parent_id 向上遍历:
def expand_with_ancestors(slugs, slug_map, id_map):
result = set(slugs)
for slug in list(slugs):
node = slug_map.get(slug)
pid = node.parent_id
while pid:
parent = id_map.get(pid)
if parent:
result.add(parent.slug)
pid = parent.parent_id
else:
break
return result
这件事看起来不起眼,但它省掉了中间层的 LLM 调用。不做祖先回填的话,你可能还得额外跑一轮 L2 和 L1 的分类来确保每一层都被正确标记。
在图数据库或知识图谱的场景里,这种操作叫做传递闭包(transitive closure)——从直接关系推导出间接关系。在分类树里就是从叶子推导出所有祖先路径。
优化六:Prompt 缓存——让重复的前缀只计费一次
这是最后一个优化,但从成本角度看可能是影响最大的。
前面说了,同 provider 下的 tool 批量请求共享同一个 system message(包含 provider 描述和候选 category 列表),只有 user message 里的 tool 列表不同。
OpenAI 和 Anthropic 都支持 prompt caching:如果两次请求的前缀相同,第二次只需要为前缀部分付很少的钱。OpenAI 是自动生效的(前缀 ≥1024 token 即可触发),缓存命中时输入 token 成本降低 50%。Anthropic 需要手动标记 cache breakpoint,但缓存读取的 token 成本只有正常价格的 10%。
要利用好这个机制,请求的结构很重要:
- 把不变的部分(指令、候选列表、背景信息)放在前面,作为 system message
- 把变化的部分(具体的实体数据)放在后面,作为 user message
- 保持同一批次内请求的前缀完全一致——连空格和换行都不能变
这也是为什么前面的批量合并要按 provider 分组:同一个 provider 下的所有 tool 批次共享同一个 system prompt,缓存命中率最高。
成本对比
假设 system message 有 2000 token,user message 有 500 token,总共 2500 token/request。
对同一个 provider 下 5 个批次的请求:
| 无缓存 | 有缓存 | |
|---|---|---|
| 第 1 次 | 2500 token 全价 | 2500 token 全价 |
| 第 2-5 次 | 2500 × 4 = 10000 token 全价 | 500 × 4 = 2000 全价 + 2000 × 4 = 8000 缓存价(~10%) |
| 总输入 token 费用(相对) | 12500 × P | 2500P + 2000P + 800P = 5300P |
粗算下来节省了 57% 的输入 token 费用。如果批次更多,比例还会更高。
并发编排:asyncio + Semaphore
优化不光是减少调用次数,还要在合理范围内尽量并行。Python 的 asyncio + Semaphore 是处理这类 I/O 密集任务的标准模式。
核心思路:创建一个信号量控制并发上限,用 asyncio.gather 把所有批次任务扔出去,信号量自动限流。
sem = asyncio.Semaphore(concurrency)
async def process_batch(batch):
async with sem:
return await llm_classify(batch)
results = await asyncio.gather(
*[process_batch(b) for b in batches],
return_exceptions=True,
)
concurrency 参数需要根据 LLM 服务的 rate limit 来调整。OpenAI 的 tier 不同,并发上限差别很大;自部署模型则看 GPU 的吞吐上限。经验值:先从 5~10 开始,观察 429 错误率逐步调高。
这篇关于 async LLM 模式的文章总结得不错:Semaphore 控制的是「同时在跑的请求数」,不是「每分钟请求数」。如果需要严格的速率控制,还得加 token bucket 或 sliding window。
综合效果
把六种优化叠加起来,对上面的场景做个粗略估算:
| 优化手段 | 调用次数(provider) | 调用次数(tool) |
|---|---|---|
| 原始暴力 | 24,000 | 1,200,000 |
| + 层级剪枝 | 600(3 层 × 200) | 30,000(3 层 × 10,000) |
| + 继承传播 | 600 | 10,000 |
| + 批量合并(8 个/批) | 75 | 1,250 |
| + 增量跳过(假设 50% 已有) | 38 | 625 |
| + 祖先回填 | 38(省掉中间层调用) | 625 |
从 120 万降到不到 700 次——两个数量级的差距。实际跑下来,对几千个 provider 和上万个 tool 的全量分类,几分钟就能完成。
这些优化背后的共同思路
回过头来看,六种手段其实围绕同一个目标:在不损失分类质量的前提下,尽量少调用 LLM。
拆开来看就三件事:
缩小搜索空间——层级剪枝、继承传播、增量跳过,本质都是在「调用之前」就排除掉不需要判断的组合。这和所有搜索、检索、推理系统的核心思路一样:先用便宜的方式过滤,再用贵的方式确认。
摊薄调用成本——批量合并和 prompt 缓存,是在「调用的时候」把单次调用的价值最大化。多个实体共享一次调用、多次请求共享一段前缀,都是在减少「每条数据的边际成本」。GPU 推理优化领域用的 continuous batching 和 KV-cache reuse 是同样的道理,只不过一个在应用层,一个在基础设施层。
利用结构推导——祖先回填和继承传播,是在「调用之后」或「调用之前」通过数据结构自身的性质推导出额外信息,完全不需要 LLM 参与。这类”免费的推理”往往被忽视,但积少成多效果显著。
这三条原则不限于分类场景。做 RAG 的人会先用 embedding 召回再让 LLM 精读,本质是「缩小搜索空间」;做 agent 的人会复用 system prompt 和 tool 描述来触发缓存,本质是「摊薄调用成本」;做知识图谱的人通过关系推理减少查询次数,本质是「利用结构推导」。
思路通了,实现手段可以千变万化。
几个还能继续优化的方向
当前的方案还不是极限。有几个可以进一步探索的方向:
模型级联(model cascade):先用最便宜的小模型做初筛(比如 Gemini Flash 的价格只有 Claude Sonnet 的几十分之一),只在小模型不确定的 case 上升级到大模型。C3PO 这篇论文用概率约束来自动决定什么时候 escalate,思路值得借鉴。
embedding 预筛:在调用 LLM 之前,先用 embedding 相似度做一轮快速过滤。把实体描述和每个 category 的描述都 embed 成向量,余弦相似度低于某个阈值的直接排除。这比 LLM 调用便宜几个数量级。
结构化输出约束:用 JSON Schema 或 Pydantic 模型约束 LLM 的输出格式,减少解析失败和重试的次数。OpenAI 和 Gemini 都原生支持 response_format 参数。
主动学习(active learning):在批量分类中加入置信度评估,低置信度的结果标记出来给人工复核,高置信度的直接采纳。随着人工反馈积累,模型提示可以持续改进。
写在最后
用 LLM 做大规模数据处理,很容易掉进”每条数据调一次 API”的陷阱。当数据量从几十增长到几万甚至几十万,调用成本和延迟会呈线性甚至超线性增长。
好消息是,在计算机科学的各个分支里——搜索、数据库、编译器、分布式系统——处理”大规模×重复模式”的经验已经积累了几十年。LLM 只是一个新的、比较贵的”评估函数”,围绕它的工程优化手段和传统系统并没有本质区别。
树搜索剪枝、批量摊销、增量计算、缓存复用——这些老招式,在 LLM 时代依然好使。