开头如果你做过本地AI相关的小工具一定有过这种体验脑子里想的是让大模型帮我搞定一切拿到需求之后只要是有文本的地方第一反应就是往提示词里塞。可一旦真把任务落到本地的Llama、Qwen上第二次、第三次跑同一份数据的时候你会开始怀疑人生——结果不稳定、延迟忽高忽低、GPU风扇像飞机起飞而且很多本来一句话就能判断的事情大模型非要兜一大圈才给出一个似是而非的回答。我最近在做一个本地文档自动整理的小项目目标是让一个文件夹里的散乱文件自动归类并生成摘要。最开始把全部逻辑都压在本地模型上结果非常不理想。后来推倒重来改成一套L0硬规则前置 L1模型兜底的两级流水线才算真正跑顺。说白了就是凡是能用确定规则判断的绝不让模型上场模型只做规则解决不了的那部分。这篇文章把整条流水线的设计思路、代码骨架、模型选型、性能实测和踩坑记录都拆开讲一遍。适合正在做本地AI任务分发、想控制资源开销或者被LLM结果随机性折磨过的人参考。1. 为什么要把笨办法放在大模型前面——L0硬规则层的定位思考先说一个反直觉的结论在很多AI落地场景里硬规则比模型更值得优先使用。1.1 本地模型的硬伤速度、成本与随机性本地部署大模型的好处大家都知道隐私不出本机没有API费用断网也能跑。但真正用起来之后三个问题会非常明显。第一是速度。我用Ollama跑Qwen2.5 7B的Q4量化版在NVIDIA RTX 3060上一个几十字的短文本推理大概需要300~800毫秒。如果任务复杂一点、提示词长一点一次调用动辄1~2秒。而一条正则表达式匹配同一个文本耗时在微秒级。这中间差了至少三个数量级。当任务量是上千个文件、几万个条目的时候所有事情都交给模型意味着你只能坐在那里等。第二是随机性。LLM本质是采样温度不为零的话同一个输入跑两次可能给出不同结果。哪怕把temperature调到0很多模型在结构化输出上仍然会偶发格式漂移。这在给文档分类这种任务上尤其致命——昨天模型把这篇文章判为技术文档今天它改判为个人笔记而你根本不知道它依据什么。第三是资源占用。本地模型推理不仅吃显存还在持续拉高功耗和温度。如果你在一台既要写代码、又要跑渲染、还要做数据处理的机器上长期跑模型其他工作的体验会被明显拖累。用规则先拦掉大部分简单任务模型只在少数时候被调用整个系统的资源负载曲线会平滑很多。1.2 80/20法则在任务拆分里同样成立我把需要处理的文档随机抽了500个做了一次如果用规则能处理多少的测试结果很意外其中约62%的文件可以仅靠扩展名、路径关键词、文件名模式这些规则就完成正确分类。再叠加正文前50行里的正则匹配规则这个比例提升到了78%。这里说的规则能处理不是指任务本身简单而是指我们其实有大量不需要语义理解的特征可用。比如一份文件的文件名是2025_财报_汇总.xlsx那它属于财务类别是明摆着的事不需要大模型来读一遍表格内容再判断。一个PDF文件路径中包含tax_return关键词归到税务归档也很自然。遗憾的是很多人一上来就把这些显然特征忽略掉了把模型当成唯一的判断器。这正是L0硬规则层存在的意义它不是要替代模型而是要在能力边界清晰的前提下拦截掉确定性场景让模型把算力花在真正需要理解的地方。1.3 两级流水线的整体架构整个流水线可以这样理解任务入队 - 预处理(清洗文本/提取元数据) - L0规则判断 - 命中规则 - 直接出结果结束后处理 - 未命中 - 交给L1模型 - 模型结构化输出 - 结果校验 - 出结果L0是前置闸门L1是兜底通道。两者不是并列关系而是顺序关系。L0的结果不仅决定走哪条路还可以作为L1的上下文输入。比如L0识别出文件类型是发票L1只需要在发票这个范围内做摘要和金额提取任务难度会大幅下降。这个架构还有一个隐性优势可解释性。规则命中的结果你可以明确说出是根据哪条规则得出的结论这在审计、归档、合规场景里非常重要。而模型结果天然不透明能少用就少用。2. L0层实战设计用确定性规则拦下80%的低难度任务2.1 规则层的三层结构L0规则层不是简单写一堆正则就完事它需要分层组织。我在项目里把规则分了三个层级元数据规则不看内容只看文件名、扩展名、路径、修改时间、文件大小。比如*.pdf默认进PDF文档*.jpg进图片路径含/contracts/进合同。文本特征规则看内容前若干行的模式。比如正文正则匹配到发票号码、税额字段判定为发票匹配到此致\n敬礼判断为正式函件。统计规则基于词频、长度、格式特征做分数累计。比如一份Markdown文件如果标题层级多、代码块占比高很可能是技术文档如果行文流水账且日期变体多更可能是日志笔记。这三层规则要按代价从小到大的顺序执行。先查扩展名再读文件头最后才是全文正则。尽量在低成本阶段把任务拦下来避免为了判断一个文件类型而把整份几兆的文本全部读进内存。2.2 规则引擎的代码骨架我给L0层写了一个简洁的规则引擎核心思路是注册表 优先级队列。每条规则有独立的匹配函数和权重命中后能输出原因。# l0_engine.py from dataclasses import dataclass from typing import Callable, Optional dataclass class Rule: name: str priority: int # 数值越小越先执行 apply: Callable[[dict], Optional[dict]] # 入参是任务上下文返回值是补充信息或None class L0Engine: def __init__(self): self._rules [] def register(self, rule: Rule): self._rules.append(rule) self._rules.sort(keylambda r: r.priority) def execute(self, ctx: dict) - dict: result {stage: L0, matched: False, reason: , meta: {}} for rule in self._rules: try: extra rule.apply(ctx) if extra: result[matched] True result[reason] frule:{rule.name} result[meta].update(extra) break except Exception as e: # 规则出错不能拖垮主流程记录后继续下一条 ctx.setdefault(warnings, []).append(f{rule.name}: {e}) return result每条规则本身是独立的纯函数输入是任务上下文输出是补充信息或None。这样做的好处是规则之间没有隐式耦合想加一条新规则只需要写一个函数然后注册不用改主流程。2.3 典型规则实例下面是文件分类场景里几条最有价值的规则列个表格方便对照规则名称层级匹配逻辑判定结果ext_pdf元数据扩展名为.pdf文档类型PDFpath_contract元数据路径包含contract或合同业务线合同invoice_header文本特征前100字匹配发票号码税额meeting_minutes文本特征匹配会议纪要参会人code_ratio统计代码块字符占比30%文档类型技术文档diary_style统计日期变体密度高且段落短文档类型日记/日志一条规则命中之后流水线并不会直接信任结果还需要做一次结果置信度判断。比如只有ext_pdf命中说明知识来源太单薄——任意一个PDF都可能被分到PDF文档这一类我给它一个偏低的置信度但如果invoice_header和path_contract同时命中置信度就非常高L1层甚至不需要再调用模型。2.4 规则结果的可观测性L0层另一个经常被忽略的点是日志。规则判定不透明的系统调试起来就是噩梦。我给每条规则都保留了命中/未命中的完整记录包括输入文本摘要、匹配到的关键词位点、规则执行耗时。这样做的直接收益是当某个文件被分错类别时你能很快定位到是哪条规则误判然后决定是修正规则还是把这条规则标记为不适用于某些场景。这个能力在后面踩坑排查的时候帮了大忙。3. L1模型兜底层选型与部署Ollama 量化模型怎么配3.1 模型选型思路L1层的任务不是处理全部请求而是处理L0无法规则化的部分。对这部分任务需要的是结构化输出能力指令遵循能力而不是最聪明的模型。我实测对比了几个本地可跑的模型模型显存占用(Q4量化)平均单次推理耗时(短文本)结构化输出稳定性我的评价Qwen2.5 7B约6.2GB0.4~0.8秒高首选中文与英文都不错Llama 3.1 8B约6.8GB0.5~1.0秒中英文优秀中文偶尔格式漂移Phi-3 mini 3.8B约3.5GB0.2~0.5秒中高轻量规则失败后短任务够用Qwen2.5 14B约11GB1.2~2.5秒很高最优但显存压力大如果机器是16GB显存我建议7B起步。如果只有8GB显存3B~4B也是可以接受的——毕竟L1只处理少量复杂任务模型品质下降带来的影响有限。显存不够但又想跑大模型的话退路是使用更低的量化等级比如Q3_K_S但效果下降比较明显我一般不推荐。3.2 Ollama部署流程Ollama是目前本地跑模型最省事的工具之一几条命令就能架好。以Linux NVIDIA为例# 安装Ollama curl -fsSL https://ollama.com/install.sh | sh # 拉取模型Qwen2.5 7B Q4量化版 ollama pull qwen2.5:7b # 测试调用 ollama run qwen2.5:7b 用一句话总结机器学习是一种监督学习方法Ollama默认会把模型跑在GPU上如果检测不到NVIDIA驱动会回退到CPU。CPU跑7B模型的速度惨不忍睹强烈建议在调用之前检查一下是否真的用上了GPUnvidia-smi # 看进程列表里是否有ollama的进程占用显存如果Ollama没有走GPU常见的排查点NVIDIA驱动版本太低、CUDA运行库缺失、Ollama服务需要重启。3.3 通过Python调用Ollama做结构化输出L1层的核心诉求是拿到稳定、可解析的结构化结果。我封装了一个调用函数专门让模型输出JSON格式并在外层做格式校验和重试。# l1_model.py import json import requests OLLAMA_URL http://localhost:11434/api/generate def call_llm(prompt: str, model: str qwen2.5:7b, retries: int 2) - dict: payload { model: model, prompt: prompt, stream: False, temperature: 0.0, format: json, # 让Ollama尽量输出JSON options: { num_ctx: 4096, num_predict: 512 } } for attempt in range(retries 1): resp requests.post(OLLAMA_URL, jsonpayload, timeout60) resp.raise_for_status() text resp.json().get(response, ) try: return json.loads(text) except json.JSONDecodeError: # 偶尔模型会输出多余的前缀或suffix尝试修正 cleaned text.strip() start cleaned.find({) end cleaned.rfind(}) if start 0 and end start: return json.loads(cleaned[start:end1]) if attempt retries: continue return {error: invalid_json, raw: text} return {error: failed}提示词模板我固定成指令 输出格式 有限的候选枚举这样模型更难跑偏SYSTEM_PROMPT 你是文档分类助手。以下输入来自本地文件。 请根据内容分类输出JSON {category: tech|finance|person|other, summary: 最多50字的中文摘要, confidence: 0-1} 只输出JSON不要解释。 def classify_by_llm(text: str) - dict: prompt f{SYSTEM_PROMPT}\n\n文件内容片段\n{text[:2000]} return call_llm(prompt)3.4 为什么兜底而不是全部交给模型这是我踩过的一个大坑。最初版本我试图让LLM做所有分类理由是模型理解力更强。结果发现不仅慢而且对类别的判定标准不稳定。比如同一份文件第一次跑它分到finance第二次同样的输入它分到business_paper——这只是因为我的类别设计有重叠模型的决策边界本身就是模糊的。改成只有L0没命中才交给L1之后模型只处理那些真正需要语义理解的样本比如一篇没有明显格式特征的博客文章、一份手写扫描件转出来的乱序文本、一套跨语言混合的会议记录。这些场景里模型的价值才能最大化体现。同时因为样本量小了模型输出偶尔不稳定带来的返工成本也大幅下降。4. 两级流水线的编排细节任务队列、超时与失败降级4.1 任务生命周期与状态机一个任务会经历如下生命周期PENDING-PREPROCESSING-L0_DETECT-L1_FALLBACK(可选) -POST_PROCESS-DONE/FAILED如果L0命中直接跳过L1_FALLBACK否则进入L1。每进入一个阶段任务状态都写日志方便事后回放和统计。# pipeline.py from enum import Enum class TaskState(str, Enum): PENDING pending PREPROCESSING preprocessing L0_DETECT l0_detect L1_FALLBACK l1_fallback POST_PROCESS post_process DONE done FAILED failed4.2 并发与排队控制本地模型服务通常不擅长高并发。Ollama默认会对多个请求做排队但如果同时发10个请求显存会被中间状态占满可能导致OOM或者推理速度急剧下降。我给L1层的调用加了一个信号量限制最多同时2个推理任务import asyncio from functools import wraps LLM_SEMAPHORE asyncio.Semaphore(2) async def limited_llm_call(text: str): async with LLM_SEMAPHORE: loop asyncio.get_event_loop() return await loop.run_in_executor(None, classify_by_llm, text)对于L0层因为都是本地规则判断没有外部资源争抢可以用线程池并行跑比如8个线程同时处理文件瓶颈主要在磁盘IO和文本解析上。4.3 超时熔断与反向兜底一条容易被忽略的原则L1模型调用必须有超时控制而且超时后的行为不能是直接报错要降级成用更保守的规则做兜底。比如某个任务进入L1后30秒还没返回我就自动标记该任务走L0弱规则结果——即使L0之前没有完全命中但L0的中间特征比如文件扩展名、路径关键词可以给出一个低置信度的候选类别。这个结果虽然不如模型准确但总比任务死掉好。处理完超时任务后把原始文件路径记录下来等模型恢复后可以重新跑一轮补偿。反向兜底也很重要。如果L0层出现异常比如读文件超限、正则引擎报错不能直接让任务失败而是降级为跳过L0、直接进入L1。这两套通道互为保险整条流水线才不容易全挂。4.4 上下文传递的设计L0的结果不是用完就丢。我的做法是把L0产生的所有中间信息都塞进任务上下文L1在需要的时候可以读取。class TaskContext: def __init__(self, filepath: str): self.filepath filepath self.mtime None self.file_size 0 self.ext self.text_sample self.l0_result None # 记录L0命中的规则和meta self.l1_result None # 记录L1模型输出 self.final_result None这样L1的提示词可以动态构造。比如L0发现文本里存在税号关键词但没完全命中分类规则L1就可以这样提示文件内容包含税务相关关键词但格式不标准请判断它是否属于财务类文档。模型拿到这个先验信息后准确率明显提升。4.5 审计日志与统计看板所有任务最后都落一条ES或SQLite记录哪个阶段命中的、每条规则耗时多少、是否走了L1、模型输出置信度是多少。这些数据不只是拿来查问题还能反向指导L0规则迭代——如果发现某类任务大量走L1说明规则没覆盖到应该去提取新的特征补规则。5. 一个完整的落地案例自动化文档整理助手5.1 项目需求定义我在本机有一个~/inbox目录平时截图、PDF、Word、Markdown笔记、发票扫描件全都往里丢。需求很简单丢进去一个文件自动按照类别移到~/archive/{category}下并给每个文件生成一份摘要放到同目录的_index.json。这个场景特别适合两级流水线因为文件来源五花八门但其中很大一部分有明确的规则特征。5.2 目录与文件结构项目分成几个模块基本上和流水线的阶段一一对应doc_organizer/ ├── main.py # 入口监听inbox目录变化 ├── pipeline.py # 流水线编排 ├── l0_engine.py # 规则引擎 ├── rules.py # 具体规则定义 ├── l1_model.py # LLM调用封装 ├── preprocess.py # 文本抽取PDF/Word/图片OCR等 ├── postprocess.py # 文件移动、JSON索引更新 └── config.yaml # 类别定义、阈值、模型名5.3 核心流程代码全解监听目录我用的是watchdog库有新文件落盘就触发任务# main.py import yaml from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler from pipeline import process_file class InboxHandler(FileSystemEventHandler): def on_created(self, event): if event.is_directory: return process_file(event.src_path) if __name__ __main__: observer Observer() observer.schedule(InboxHandler(), path./inbox, recursiveFalse) observer.start() observer.join()处理单个文件的核心逻辑很直接# pipeline.py def process_file(filepath: str): ctx TaskContext(filepath) # 阶段1预处理抽取文本与元数据 preprocess(ctx) # 阶段2L0规则判断 ctx.l0_result l0_engine.execute(ctx) # 阶段3如果L0未命中走L1 if not ctx.l0_result[matched]: ctx.l1_result classify_by_llm(ctx.text_sample) final_category decide_category(ctx) else: final_category normalize_output(ctx.l0_result) # 阶段4后处理移动文件、更新索引 postprocess(ctx, final_category)decide_category负责把L1的JSON输出映射到预定义的目标目录。如果JSON里confidence低于0.7我选择不移动文件只把它标为未分类等人工复核避免乱移文件。5.4 两种典型路径的效果对比拿一个名为2025-03-15_产品需求评审.md的文件举例。L0层的路径正则看到文件名里的产品需求同时正文里匹配到用户故事、验收标准、优先级等词多个规则叠加命中直接判定为产品文档置信度0.91。全程没有调用模型耗时约12毫秒。再看一份scan_001.pdf——文件名毫无特征L0所有规则都没命中。进入L1把OCR后的文本大约1500字拼成提示词发给Qwen2.5 7B。模型输出{category: finance, summary: 增值税发票含税金额1230元开票日期2025-03-10, confidence: 0.85}最终文件被移动到~/archive/finance/摘要写进索引。这个流程耗时约1.2秒虽然比L0慢但它在没有规则可循的情况下依然给出了正确结果——这就是L1的价值。5.5 输出的索引结构每次处理完成更新_index.json{ scan_001.pdf: { category: finance, summary: 增值税发票含税金额1230元, confidence: 0.85, handler: l1, ts: 2025-03-15T14:32:10 } }记录handler字段特别重要后续统计哪些类型任务依赖模型、应该沉淀成规则就看这个字段。6. 实测中的性能数据与踩坑记录6.1 性能对比L0快车道 vs L1模型兜底用500个真实文件做测试统计两种路径的耗时分布路径平均耗时95分位耗时最慢单次正确率L0命中约78%的任务14ms35ms120ms93.2%L1兜底约22%的任务1.8s3.5s8.7s89.4%整体平均耗时大约420ms如果全走模型则平均耗时约1.9秒。两级流水线让整批任务耗时下降了约78%。正确率方面L1略低一些但主要是集中在类别本身模糊的样本上人工复核后可接受。6.2 坑1正则误伤与全角/半角符号最典型的翻车案例是发票识别规则。我最初的正则匹配的是发票号码结果不少PDF用的是全角冒号或者中间有多个空格。规则匹配率一下子就掉下去。最后统一做了一层文本清洗把所有全角符号转成半角同时把多余空白压缩再喂给规则。这层预处理对L0和L1都有效我建议放在最前面。# preprocess.py import re def normalize_text(text: str) - str: # 全角转半角 half [] for ch in text: code ord(ch) if code 0x3000: code 0x20 elif 0xFF01 code 0xFF5E: code - 0xFEE0 half.append(chr(code)) text .join(half) # 压缩空白 text re.sub(r\s, , text) return text.strip()6.3 坑2Ollama并发请求导致排队雪崩刚开始L1调用没有做并发控制。某个文件夹一下涌入几百个文件L1层同时发出大量请求Ollama把所有请求塞进队列结果导致整体吞吐量下降单个任务的最长耗时飙到十几秒。加了Semaphore(2)之后问题解决。更稳妥的做法是维护一个FIFO任务队列L1 worker固定2个按顺序消费既保证公平又防止OOM。规则层虽然快也要注意磁盘IO峰值——预览500个PDF时一次性读入内存内存直接吃满2GB后来改成流式读取只读前64KB内容。6.4 坑3JSON输出解析不稳Ollama虽然配合了format: json但小模型偶尔还是会输出多余的解释性句子。最常见的是在JSON前面加一句根据文件内容分类结果如下或者在JSON后面补一句如果有疑问请追问。我前面贴的call_llm函数里已经做了提取第一个{到最后一个}的兜底处理但这里要强调一点rfind(})存在风险。如果模型在JSON的字符串值里又提到一个}比如摘要内容里包含表情符号或代码片段rfind会截错位置。更稳的方案是靠json.JSONDecoder().raw_decode去定位合法的JSON末尾或者用ollama官方SDK的format配合严格schema校验。import json def extract_json(text: str): # 逐位置尝试解析找到合法JSON的终止位置 for i in range(len(text)): if text[i] {: decoder json.JSONDecoder() try: obj, end decoder.raw_decode(text[i:]) return obj except json.JSONDecodeError: continue raise ValueError(no json found)6.5 坑4上下文长度设太小导致模型忽略关键信息默认num_ctx是2048对于中等长度的文档2000字可能就把所有token吃掉了。模型拿到截断的文本之后分类结果容易变得离谱。我一开始没注意这个参数有份合同全文4000多字结果模型只看了一半漏掉了关键的违约条款关键词把租赁合同分成了办公用品说明。后来把num_ctx提升到8192同时提示词里明确告诉模型如果文本被截断优先根据开头段落判断。注意num_ctx越大KV Cache占用越多显存不够的话要配合更小的batch size或者更低量化等级。6.6 优化后的综合效果经过这几轮调优最终流水线跑500份文件的完整时间是2分17秒。78%的文件走了L0快车道22%走了L1兜底。L1部分因为加了并发限制和超时保护没有出现一个任务拖死全局的情况。错误案例里最大的来源是名称相似但类型不同的文件比如一份名为税务说明的个人笔记被分到了财务类——这种语义边界模糊的情况即使是全模型方案也不一定能处理干净。一点个人体会这套L0L1架构做下来我最大的感受是本地AI场景里模型不是万能的规则也不是老土的技术。它们各有各的不可替代性。规则稳定、快、便宜、可解释但缺少语义理解模型灵活、聪明、能处理开放问题但慢、不稳定、要资源。把两者的特长组合成规则优先、模型兜底的流水线既保住了大部分任务的确定性也让模型只在最需要它的地方发光。如果你也在做类似本地AI任务拆分的项目我强烈建议先用一天的日志数据做一次规则覆盖率模拟。你会发现大多数任务根本不需要模型上场。剩下的那一小部分放着让大模型慢慢算体验会好非常多。