之前 Agent 里每来一句话,不管三七二十一,全塞给 32B 的主模型。结果就是用户问一句”今天怎么样”,也得让大模型吭哧吭哧思考三秒,烧掉两千个 token——你说亏不亏?
说白了,大部分请求根本用不着主模型出场。要么就是寒暄两句,要么意图明明白白摆在那儿,杀鸡焉用牛刀。
后来我在前面塞了一层 1.7B 的小分类器,跑在本地 Ollama 上,专门干一件事:判断这次该走哪条路。
意图分了哪几类
按业务划了 6 类,从纯聊天到多模态病虫害诊断都覆盖到了:
1 2 3 4 5 6 7 8 9
| from enum import Enum
class IntentCategory(str, Enum): PEST_DIAGNOSIS = "pest_diagnosis" DEVICE_MANAGEMENT = "device_management" DATA_ANALYSIS = "data_analysis" AUTOMATION = "automation" AGRICULTURE = "agriculture" GENERAL = "general"
|
分类器要的是规规矩矩的结构化输出,所以拿 Pydantic 卡一道——免得它一时兴起跟你聊起人生哲学:
1 2 3 4 5 6 7 8
| from pydantic import BaseModel, Field
class RouterOutput(BaseModel): category: str = Field(description="意图分类") confidence: float = Field(default=0.5, description="置信度 0-1") reasoning: str = Field(default="", description="分类理由") requires_image: bool = Field(default=False, description="是否需要图片") sub_intent: str = Field(default="", description="子意图")
|
分类器本体
模型挑了 qwen3:1.7b,温度压到 0.1(这种活儿不需要它发挥创造力),max_tokens 给个 256 就够用,超时卡 5 秒——再长就纯属耽误事,还不如让主模型直接上:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| from langchain_openai import ChatOpenAI
class IntentRouter: def __init__(self): self._router_model = ChatOpenAI( base_url="http://localhost:11434/v1", model="qwen3:1.7b", max_tokens=256, temperature=0.1, timeout=5.0, )
async def classify(self, query: str, history: list = None) -> RoutingResult: try: return await self._classify_with_llm(query, history) except Exception: return self._keyword_fallback(query)
|
关键词兜底
小模型也是会抽风的——超时、吐出非法 JSON、给你一个根本不存在的类别。这些事儿要是直接报错给用户,体验立马就崩。
所以底下又垫了一层关键词匹配。逻辑笨是真的笨,但有个好处:它永远不会挂。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| def _keyword_fallback(self, query: str) -> RoutingResult: q = query.lower()
pest_keywords = ["病虫害", "病害", "虫害", "枯萎", "黄叶", "斑点", "诊断", "识别病", "什么病", "怎么治"] if any(kw in q for kw in pest_keywords): return RoutingResult( category=IntentCategory.PEST_DIAGNOSIS, confidence=0.75, reasoning="关键词匹配: 病虫害相关", )
auto_keywords = ["自动化", "自动", "规则", "触发", "条件", "定时", "创建规则", "编排", "工作流", "联动"] if any(kw in q for kw in auto_keywords): return RoutingResult( category=IntentCategory.AUTOMATION, confidence=0.7, reasoning="关键词匹配: 自动化相关", )
device_keywords = ["设备", "传感器", "连接", "断开", "在线", "离线", "状态"] if any(kw in q for kw in device_keywords): return RoutingResult( category=IntentCategory.DEVICE_MANAGEMENT, confidence=0.7, reasoning="关键词匹配: 设备管理相关", )
return RoutingResult( category=IntentCategory.GENERAL, confidence=0.5, reasoning="未匹配到特定领域关键词", )
|
匹配顺序这事得动点脑子。举个例子,“设备联动”这句话里既有”设备”又有”联动”,但用户心里想的明明是自动化规则。要是按字母序匹配,肯定被设备这一支先吃掉,结果就是用户问”帮我做个联动”,系统跑去查设备列表给他——尴不尴尬?
所以自动化的关键词必须排在设备前面判。
工具按意图加载
分类只是手段,真正想干的事是把工具集裁到一个合理的大小。
Anthropic 之前发过一个经验值:工具一旦超过 20 个,模型选错的概率明显抬头。所以分类完了之后,按 tag 拉对应的工具子集就行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| def _select_pipeline(self, routing: RoutingResult): registry = self._tool_registry
if routing.category == IntentCategory.DEVICE_MANAGEMENT: tools = registry.get_tools_by_tags(["device", "entity"]) system_prompt = get_prompt("device_expert")
elif routing.category == IntentCategory.DATA_ANALYSIS: tools = registry.get_tools_by_tags(["data", "device"]) system_prompt = get_prompt("data_analyst")
elif routing.category == IntentCategory.AUTOMATION: tools = registry.get_tools_by_tags(["automation", "device", "entity"]) system_prompt = get_prompt("automation_expert")
elif routing.category == IntentCategory.AGRICULTURE: tools = registry.get_tools_by_tags(["agriculture", "diagnosis", "data"]) system_prompt = get_prompt("agriculture_expert")
else: tools = registry.get_tools_by_tags(["general"]) system_prompt = get_prompt("general_assistant")
return model, tools, system_prompt
|
工具注册的时候顺手打 tag,注册中心按 tag 取交集,就这么简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class ToolRegistry: def __init__(self): self._tools: Dict[str, ToolRegistration] = {}
def register(self, tool, tags=None, description_zh="", examples=None): reg = ToolRegistration( tool=tool, tags=set(tags or []), description_zh=description_zh or tool.description, examples=examples or [], ) self._tools[tool.name] = reg
def get_tools_by_tags(self, tags: List[str]) -> List[BaseTool]: result_set = {} tag_set = set(tags) for name, reg in self._tools.items(): if reg.tags & tag_set: result_set[name] = reg.tool return sorted(result_set.values(), key=lambda t: t.name)
|
跑下来什么感觉
上了这层路由以后,体感最爽的就是寒暄类的请求——基本秒回。小模型一拍板,主模型连脸都不用露。Token 账单也瘦了一圈,没具体统计,但调到自动化场景时,上下文里塞的工具描述肉眼可见地少了一大截。
最坑的地方倒不是模型本身,反而是关键词兜底的顺序——这玩意儿排错了,出来的结果能让你怀疑人生。还有个一直在我 TODO 里没动的事:小模型分类错了到底咋办?目前是无条件相信兜底,但理论上应该按 confidence 卡个线,太低就升级给主模型再判一次。
这块还在慢慢磨。