本地任务拆分实战:L0硬规则前置与L1模型兜底的两级流水线

发布时间:2026/9/30 6:00:19
本地任务拆分实战:L0硬规则前置与L1模型兜底的两级流水线
1. 为什么要在本地做任务拆分1.1 从一次线上事故说起去年冬天我在做一个内部工单自动分派的小系统。需求本身不复杂用户提交一段自然语言描述系统判断它属于哪个部门、紧急程度如何、要不要转人工。最开始我图省事直接调了一个大模型接口把整段文本丢进去让它一次性输出结构化结果。上线第三天就出事了。有一张工单写着“机房空调外机异响已持续两小时”模型把它分到了“行政后勤”紧急程度标了“低”。实际上机房温度已经开始爬升这种单子必须走基础设施告警通道。事后复盘问题不在模型能力而在于我把所有判断都押在了一次推理上——没有硬性规则兜底没有中间校验模型输出什么就是什么。这件事之后我重新设计了整条链路核心思路就一句话能用确定性规则解决的绝不交给模型模型只处理规则覆盖不到的模糊地带。这就是后来我一直在用的 L0 硬规则前置加 L1 模型兜底的两级流水线。1.2 两级流水线到底解决什么问题先说清楚这两个层级分别是什么。L0 层是硬规则层由人工编写的确定性逻辑构成。关键词匹配、正则表达式、字典查询、数值阈值判断这些都属于 L0。它的特点是快、准、可解释、零成本但覆盖面有限遇到没见过的表达方式就抓瞎。L1 层是模型兜底层用大模型或者小型的本地分类模型来处理 L0 没命中的那部分输入。它的特点是覆盖面广、能理解语义但慢、有成本、输出不稳定。两级流水线的价值在于把大部分请求拦截在 L0只让真正需要语义理解的请求进入 L1。我实测下来在一个日均三千条工单的场景里L0 能拦住大约七成剩下三成走 L1。这意味着模型调用成本直接砍掉七成响应速度也上了一个台阶——L0 的判断通常在毫秒级完成用户几乎无感。更重要的是可靠性。L0 的规则是我自己写的每一条都能追溯到具体的业务逻辑。即使 L1 出了问题L0 仍然能保证核心场景不出错。这种“降级可用”的特性在本地部署环境里尤其重要。1.3 适合哪些场景不适合哪些场景这套架构不是什么万能药它有明确的适用边界。适合的场景有几个共同特征任务边界清晰、存在大量可枚举的模式、对响应速度和成本敏感、需要可解释性。比如工单分类、意图识别、表单字段抽取、日志告警分级、内容审核初筛这些都很适合。不适合的场景也很明显。如果你的任务本身就是开放式的比如让模型写一篇文章、做一段创意文案那 L0 层几乎帮不上忙硬套两级流水线只会增加复杂度。另外如果任务量很小一天就几十条请求那直接调模型更省事没必要为了省那点成本去维护一套规则。还有一个容易被忽略的点L0 规则是需要持续维护的。业务在变用户的表达方式在变规则也得跟着更新。如果你没有精力去定期 review 规则命中率和误判率那这套架构的优势会随着时间推移逐渐衰减。2. L0 硬规则层的设计与实现细节2.1 规则的组织方式优先级队列而非平铺列表很多人写规则就是一堆 if-else 堆在一起能跑就行。但规则一多维护起来就是灾难。我的做法是把规则组织成一个带优先级的队列每条规则有明确的优先级、匹配条件和命中后的动作。为什么用优先级队列而不是简单的顺序执行因为规则之间可能存在冲突。比如一条规则说“包含‘退款’关键词就分到售后组”另一条规则说“包含‘投诉’关键词就分到客诉组”。如果一条工单同时包含这两个词按什么顺序判断就决定了最终结果。优先级机制让这种冲突变得可控——我明确知道哪条规则先跑哪条后跑。具体实现上我用一个列表来存储规则对象每个对象包含这几个字段class L0Rule: def __init__(self, name, priority, matcher, action, description): self.name name # 规则名称用于日志追踪 self.priority priority # 优先级数字越小越先执行 self.matcher matcher # 匹配函数返回布尔值 self.action action # 命中后的动作返回分类结果 self.description description # 规则说明方便后续维护规则列表按 priority 排序执行时从前往后遍历第一条命中的规则直接返回结果不再继续往下走。这种“短路”机制保证了执行效率也避免了多条规则同时命中时的歧义。2.2 匹配器的几种常见写法匹配器是 L0 层的核心它决定了规则能不能准确识别目标。我常用的匹配器有这么几类。关键词匹配是最简单也最常用的一种。但直接写if 退款 in text太粗糙了容易误伤。比如“退款政策是什么”和“我要申请退款”都包含“退款”但意图完全不同。我的做法是结合上下文窗口来判断——检查关键词前后一定范围内是否出现了特定的修饰词。def keyword_matcher(keywords, context_windowNone, required_contextNone): def match(text): for kw in keywords: idx text.find(kw) if idx -1: continue if context_window and required_context: start max(0, idx - context_window) end min(len(text), idx len(kw) context_window) context text[start:end] if any(rc in context for rc in required_context): return True else: return True return False return match正则匹配适合处理有固定格式的内容比如订单号、手机号、身份证号。写正则的时候要注意不要追求一条正则搞定所有情况那样只会写出谁也看不懂的怪物。拆成多条简单的正则每条负责一种格式维护起来轻松得多。字典查询适合处理枚举值。比如部门名称、产品类别这种有限集合直接建一个映射字典查询速度是 O(1)。我一般会把字典单独放在一个 JSON 文件里方便非技术人员也能更新。数值阈值判断在处理告警分级时特别有用。比如温度超过多少度算紧急、响应时间超过多少秒算超时这些都是明确的数值边界用规则处理比模型靠谱得多。2.3 规则命中后的动作设计命中规则之后做什么这个环节看似简单其实也有讲究。我的原则是L0 层的输出必须和 L1 层的输出保持同样的数据结构。这样上层业务代码不需要关心结果是哪一层产生的统一处理就行。输出结构一般包含这几个字段字段名类型说明categorystring分类结果confidencefloat置信度L0 固定为 1.0sourcestring来源标记L0 或 L1rule_namestring命中的规则名称L1 时为 nullraw_inputstring原始输入便于追溯source字段很重要。它让下游系统知道这个结果是规则给的还是模型给的。规则给的结果可以直接信任模型给的结果可能需要人工复核或者二次校验。rule_name则在排查问题时特别有用——我能快速定位是哪条规则命中了如果发现误判直接改那条规则就行。2.4 规则维护的几个实操心得规则写起来容易维护起来难。我踩过几个坑这里分享一下。第一条每条规则必须有单元测试。规则多了之后改一条可能影响另一条。没有测试覆盖的话你根本不知道改动会带来什么后果。我的做法是给每条规则写至少三个测试用例一个应该命中的、一个不应该命中的、一个边界情况的。第二条记录规则命中日志。每次规则命中都记一条日志包含规则名称、输入文本、命中时间。这些日志是后续优化的金矿——你能看到哪些规则命中率高、哪些规则从来没被触发过、哪些规则经常误判。第三条定期清理僵尸规则。业务变化快半年前写的规则可能现在已经没用了。我每个月会跑一次统计把连续三十天零命中的规则标记出来确认后删除。规则库越精简维护成本越低。第四条规则命名要有意义。不要用rule_001、rule_002这种命名用refund_request_with_order_id这种描述性的名字。半年后你回头看能一眼看懂这条规则是干什么的。3. L1 模型兜底层的接入与调优3.1 模型选型本地小模型还是远程大模型L1 层用什么模型这个决策取决于你的部署环境和精度要求。如果对数据隐私要求高、网络环境不稳定、或者需要极低延迟那就选本地部署的小模型。我常用的是参数量在 1B 到 7B 之间的指令微调模型量化后显存占用可以压到 4GB 以内普通带独显的机器就能跑。精度上肯定不如大模型但对于分类、抽取这类任务配合好的提示词效果完全够用。如果对精度要求高、任务复杂度大、且能接受一定的网络延迟那就用远程大模型接口。但要注意远程调用意味着你的数据要出本地如果涉及敏感信息得先做脱敏处理。我的建议是先用本地小模型跑一版看效果能不能接受。能接受就用本地的省心省钱。实在不行再考虑远程方案。很多时候我们对模型精度的要求是被自己吓出来的实际业务场景里85% 的准确率可能就已经够用了。3.2 提示词设计让模型输出结构化结果L1 层的模型调用和普通聊天不一样我需要的是结构化的、可解析的输出而不是一段自由文本。所以提示词的设计要围绕这个目标来。我的提示词模板一般包含这几个部分你是一个任务分类助手。请根据用户输入的内容判断它属于以下哪个类别 类别列表 - 售后咨询 - 技术支持 - 投诉建议 - 其他 要求 1. 只输出类别名称不要输出任何其他内容 2. 如果无法判断输出“其他” 3. 不要解释你的判断理由 用户输入 {user_input}这个模板的关键在于约束输出格式。我明确告诉模型只输出类别名称这样我拿到结果后可以直接用字符串匹配来解析不需要复杂的后处理。但实际跑下来模型有时候还是会“话多”输出类似“这个应该属于售后咨询”这样的内容。所以我在解析层加了一层容错先尝试精确匹配匹配不上就用关键词模糊匹配再匹配不上就归入“其他”并记录日志。3.3 置信度处理模型说“不确定”怎么办模型输出的置信度是个好东西但很多接口不直接提供。我的做法是通过多次采样来估算置信度。具体来说同一个输入让模型跑三次温度参数设成 0.7 左右看三次结果是否一致。三次都一样置信度标为高两次一样置信度标为中三次都不一样置信度标为低。置信度低的请求我不会直接采用模型结果而是转人工复核。这样虽然增加了一点人工成本但避免了模型胡猜导致的错误。在实际系统里低置信度的请求占比通常不到 5%人工完全扛得住。还有一种情况是模型明确表示无法判断。这时候我会检查是不是 L0 层的规则覆盖不够考虑补充新规则。L1 层的“无法判断”日志是 L0 规则优化的最佳输入。3.4 超时与降级策略L1 层是外部依赖不管是本地模型还是远程接口都有可能超时或失败。必须有降级策略。我的做法是设置一个硬超时时间比如 3 秒。超过 3 秒没返回直接放弃 L1走默认兜底逻辑。默认兜底逻辑很简单归入“其他”类别标记为“待人工处理”。同时我会记录超时日志如果某个时间段超时率突然升高说明模型服务可能出了问题需要排查。这种监控在本地部署环境里尤其重要因为本地服务的稳定性往往不如云服务。还有一个细节L1 的调用要异步化。不要让主流程阻塞等待模型返回。我的做法是把 L1 调用放到一个队列里主流程先返回一个“处理中”的状态等模型结果出来后再更新。这样用户体验更好系统吞吐量也更高。4. 两级流水线的串联与调度4.1 主流程的伪代码实现把 L0 和 L1 串起来的主流程其实不复杂但有几个关键点要注意。def process_task(user_input): # 第一步L0 规则匹配 l0_result l0_match(user_input) if l0_result is not None: log_hit(L0, l0_result.rule_name, user_input) return l0_result # 第二步L0 未命中走 L1 log_miss(L0, user_input) try: l1_result l1_predict(user_input, timeout3.0) if l1_result.confidence low: return fallback_result(user_input, reasonlow_confidence) return l1_result except TimeoutError: log_error(L1_timeout, user_input) return fallback_result(user_input, reasontimeout) except Exception as e: log_error(L1_error, str(e), user_input) return fallback_result(user_input, reasonerror)这段代码里有几个设计决策值得说明。L0 命中后直接返回不再走 L1。这是性能优化的关键。有人可能会想L0 和 L1 都跑一遍然后取置信度高的那个这样不是更准吗理论上是的但代价是每次请求都要调模型成本翻倍速度也慢。而且 L0 规则是我精心设计的命中就意味着高置信度没必要再让模型确认一遍。L1 的异常处理要细分。超时、报错、低置信度这三种情况的处理逻辑可能不同。超时可能是服务过载需要扩容报错可能是代码 bug需要修复低置信度可能是输入太模糊需要优化提示词或补充规则。分开记录日志排查问题时才能快速定位。兜底结果要包含足够的信息。fallback_result不仅返回一个默认分类还要带上原因标记。这样人工复核的时候能知道这条为什么没处理好。4.2 日志与监控让流水线可观测两级流水线跑起来之后你必须能回答这几个问题L0 命中率是多少L1 的平均延迟是多少兜底率是多少哪些规则从来没命中过回答这些问题靠的就是日志。我的日志体系分三层。请求级日志记录每一次请求的完整处理路径输入文本、L0 是否命中、命中了哪条规则、L1 的返回结果、最终输出、总耗时。这条日志是排查具体问题的依据。统计级日志按小时或按天聚合L0 命中率、L1 调用量、L1 平均延迟、兜底率、各分类的分布。这些指标反映了系统的整体健康度。规则级日志专门记录 L0 规则的命中情况每条规则的命中次数、命中率、误判反馈。这是优化规则库的依据。我一般用 SQLite 存这些日志查询方便不需要额外部署数据库服务。数据量大了之后可以换成 PostgreSQL 或者 ClickHouse。4.3 性能优化让 L0 更快让 L1 更稳L0 层的性能优化空间其实不大因为它本来就是毫秒级的。但有几个小技巧可以让它更快。把最常命中的规则放在最前面。优先级队列的顺序直接影响平均匹配次数。我每个月会根据命中统计调整一次规则顺序把高频规则前置。匹配器里避免重复计算。比如多个规则都需要对文本做分词那就把分词结果缓存起来不要每个规则都重新分一次。用编译后的正则。Python 的re模块支持预编译正则表达式编译一次多次使用比每次重新编译快很多。L1 层的优化重点在稳定性。设置合理的超时时间太短容易误杀太长影响体验。我的经验值是本地模型 2 秒远程接口 5 秒。做好重试机制但重试次数不要超过两次否则延迟会累积。监控模型服务的资源占用本地部署时显存和内存是瓶颈要提前发现。4.4 一个完整的配置示例下面是我在一个实际项目里用的配置脱敏后分享出来。l0: rules_file: rules/department_rules.json enable_cache: true cache_size: 1000 l1: provider: local model_path: /models/classifier-7b-q4 max_tokens: 64 temperature: 0.1 timeout: 2.0 retry: 1 confidence_sampling: 3 fallback: default_category: 其他 notify_channel: internal_alert logging: level: INFO request_log: logs/requests.db stats_interval: 3600这个配置里confidence_sampling: 3表示采样三次估算置信度。retry: 1表示失败后重试一次。stats_interval: 3600表示每小时输出一次统计日志。5. 常见问题与排查技巧实录5.1 L0 规则误判怎么排查规则误判是最常见的问题。表现是明明应该分到 A 类的输入被规则分到了 B 类。排查思路分三步。第一步找到命中的规则。通过请求日志里的rule_name字段定位是哪条规则干的。第二步分析误判原因。把输入文本和规则条件对照着看通常是关键词匹配太宽泛或者上下文判断逻辑有漏洞。第三步修复并加测试。修改规则后把这条误判的输入加进测试用例防止以后回归。我遇到过一个典型案例规则里写了“包含‘退’字就分到售后组”结果“退休金咨询”被误分到了售后。修复方法是把关键词从“退”改成“退款”和“退货”同时加上上下文判断要求“退”字后面跟着“款”或“货”。5.2 L1 模型输出不稳定的处理模型输出不稳定通常表现为同样的输入不同时间跑出来的结果不一样。这可能是温度参数设太高也可能是模型本身的问题。先检查温度参数。分类任务建议设成 0.1 或更低让输出尽量确定。如果温度已经很低了还不稳定那就是模型能力问题考虑换模型或者补充 L0 规则来覆盖这些不稳定场景。还有一种情况是输入本身有歧义。比如“这个怎么弄”这种没有上下文的输入模型确实没法判断。这种应该归入“其他”并转人工而不是强行让模型猜。5.3 两级流水线的常见问题速查表问题现象可能原因排查方法解决方案L0 命中率突然下降业务输入模式变化对比近期和历史的输入样本补充新规则或调整现有规则L1 延迟升高模型服务过载检查 CPU/GPU 占用率扩容或降低并发兜底率升高L0 和 L1 都失效查看兜底原因分布针对性优化 L0 或 L1同一输入结果不一致温度参数过高检查模型配置降低温度参数规则冲突多条规则同时命中查看规则优先级调整优先级或合并规则日志写入失败磁盘满或权限问题检查磁盘空间和文件权限清理日志或修复权限5.4 几个我踩过的坑坑一规则里用了太宽泛的关键词。比如“问题”这个词几乎每条工单里都有用它做匹配条件等于没匹配。关键词一定要具体宁可多写几条规则也不要用模糊词。坑二忘了处理空输入。用户提交空字符串或者只有空格的内容L0 和 L1 都可能出问题。我的做法是在入口处加一个校验空输入直接返回“无效输入”不进入流水线。坑三模型返回结果带了多余字符。比如我要求输出“售后咨询”模型返回“售后咨询。”多了个句号。解析的时候要做 trim 和标点清理不然匹配不上。坑四没有做并发控制。L1 层如果同时收到大量请求本地模型可能扛不住。我加了一个信号量来控制并发数超过阈值的请求排队等待而不是直接压垮模型服务。坑五规则文件改了没重启服务。早期我把规则写死在代码里改规则要重新部署。后来改成从 JSON 文件加载并且支持热更新——检测到文件修改时间变化就重新加载不用重启。6. 从单机到生产扩展与演进方向6.1 规则库的版本管理规则库是这套系统的核心资产必须做版本管理。我的做法是把规则文件纳入 Git 仓库每次修改都提交一次写清楚修改原因和影响范围。这样出问题的时候可以快速回滚到上一个版本。同时我会给规则文件加一个版本号字段每次加载时记录当前版本。请求日志里也带上版本号这样排查问题时能知道当时用的是哪版规则。6.2 从单级 L1 到多级 L1当业务复杂度上升后单级 L1 可能不够用。比如有些任务需要先分类再抽取字段这时候可以设计成 L1a 负责分类、L1b 负责抽取形成多级模型流水线。但要注意每增加一级延迟和成本都会上升。多级 L1 只适合那些确实需要多步推理的复杂任务。简单的分类任务单级 L1 就够了。6.3 规则自动挖掘的尝试手动写规则效率有限我尝试过用历史数据自动挖掘规则。思路很简单把 L0 未命中的输入收集起来用聚类算法找出高频模式然后人工确认后转成规则。这个方法确实能发现一些我没想到的模式但自动挖掘出来的规则往往太具体泛化能力差。我的做法是把自动挖掘作为辅助手段最终规则还是人工编写和确认。6.4 本地部署的资源规划本地跑 L1 模型资源规划很重要。以 7B 模型量化到 4bit 为例显存占用大约 4GB加上推理时的 KV Cache 和中间激活建议预留 6GB 显存。CPU 推理的话内存占用大约 8GB速度会慢很多只适合低并发场景。如果并发量高可以考虑用推理框架做批处理把多个请求合并成一批一起推理吞吐量能提升好几倍。但批处理会增加单次延迟需要根据业务场景权衡。7. 一些个人体会这套两级流水线我用了快一年最大的感受是不要迷信模型也不要排斥模型。规则和模型各有各的适用场景关键是把它们放在正确的位置上。L0 层让我对系统有了掌控感。每一条规则都是我写的我知道它在做什么、为什么这么做。这种确定性在出问题的时候特别宝贵——我能快速定位、快速修复而不是对着一堆模型输出干瞪眼。L1 层则给了我应对未知的底气。总会有规则覆盖不到的情况总会有新的表达方式出现这时候模型就是最后一道防线。它不完美但比没有强。如果你也在做类似的任务拆分系统我的建议是先把 L0 做扎实再考虑 L1。很多人一上来就想着用模型解决一切结果系统又慢又贵还不稳定。先把能确定的确定下来剩下的再交给模型这才是务实的做法。最后分享一个小技巧定期 review L1 的低置信度日志。这些日志里藏着 L0 规则优化的线索。每次 review你都能发现几条可以补充的规则。坚持几个月L0 的命中率会稳步上升L1 的压力会越来越小。这是一个正向循环越跑越顺。