跳到主要内容

数据截至 (上游 commit ae57a2357745)

第 1 章 · 路由层:一句话怎么变成「派给谁」

本章讲:AgenticSeek 怎么不动用 LLM,只靠两个小分类器就把你的请求分发给合适的 agent。


1.1 它要解决的小问题

用户敲一句话进来,系统必须先答两个问题:

  1. 谁接这活? 闲聊、写代码、翻文件、上网,还是要先做规划?
  2. 这活多难? 一步能完的交单个 agent;要跨多个能力的,得先拆计划。

为什么不直接问 LLM

这本来是最省事的做法——把 agent 列表塞进 prompt,让模型自己选。但 AgenticSeek 的目标模型是本地跑的 14B~32B,README 的硬件表里明写 7B 「planner agents will likely fail」。让这个量级的模型做元决策,两个毛病躲不掉:

  • 选错人(把「找文件」派给 web agent);
  • 输出格式飘(多一句解释、少一个引号,解析就崩)。

所以项目把路由整个搬出 LLM,交给判别式小模型。


1.2 思路:两个模型投票,取长补短

不是一个分类器,是两个,各有各的短板:

分类器是什么强在哪弱在哪
BART zero-shotfacebook/bart-large-mnli,给一组候选标签就打分泛化好,没见过的说法也能猜不懂本项目的黑话
AdaptiveClassifierllm_router/ 载入的 distilbert 权重,现场喂 few-shot贴合本项目真实请求措辞分布外就懵

两者各出「标签 + 置信度」,归一化后比大小,高者胜

复杂度判断是第三个模型——另一个独立的 AdaptiveClassifier 实例,标签只有 LOW / HIGH

加载在 AgentRouter.__init__ 里一次做完(sources/router.py:23-32):

self.pipelines = self.load_pipelines() # BART
self.talk_classifier = self.load_llm_router() # 任务分类器
self.complexity_classifier = self.load_llm_router() # 复杂度分类器(另一份实例)
self.learn_few_shots_tasks()
self.learn_few_shots_complexity()

注意 load_llm_router() 被调了两次,得到两个互不干扰的实例,然后各喂各的 few-shot(sources/router.py:45-59)。


1.3 一个容易被忽略的细节:底模自带 HIGH/LOW

llm_router/config.json 里的标签表是:

"id_to_label": { "0": "HIGH", "1": "LOW" }

也就是说,从 HuggingFace 下载的 adaptive-classifier/llm-router 底模本来就是个难度分类器llm_router/dl_safetensors.sh 里写明了下载地址)。

于是两条路径分别是:

  • 复杂度分类器:底模的 HIGH/LOW 正好对口,再喂 122 条 few-shot 加固。
  • 任务分类器:同一个底模,硬塞进 143 条 talk/code/files/web 标签的例子。它预测时仍会吐出 HIGH/LOW,所以 llm_router() 第一件事就是把它们滤掉(sources/router.py:359-368):
predictions = self.talk_classifier.predict(text)
predictions = [pred for pred in predictions if pred[0] not in ["HIGH", "LOW"]]
predictions = sorted(predictions, key=lambda x: x[1], reverse=True)
return predictions[0]

这行过滤如果不知道底模来历,看着莫名其妙;知道了就一目了然——这是在把底模原生的标签挡在门外


1.4 few-shot 例子长什么样

两组例子都是硬编码在 router.py 里的元组列表,加载时 random.shuffle 后一次性 add_examples

任务分类sources/router.py:203-357learn_few_shots_tasks),共 143 条:

标签条数例子
web40“Find on the web the latest research papers on AI.”
files37“Can you locate the backup folder I created last month on my system?”
code32“can you make a snake game in python”
talk28“Tell me a funny story”
mcp5“Can you use MCP to find stock market for IBM ?”
coding1“Write a python script to check if the device on my network…”(拼写不一致,见 1.8)

复杂度分类sources/router.py:69-201learn_few_shots_complexity),共 122 条,81 条 LOW、41 条 HIGH。分界线相当直观:

判 LOW判 HIGH
“make a snake game please”“Find ‘budget_2025.xlsx’, analyze it, and make a chart for my boss”
“Search the web for restaurant”“can you find vitess repo, clone it and install by following the readme”
“Make a 3d game in javascript using three.js”“I want you to make me a plan to travel to Tainan”

规律:一个动作 = LOW;要串起两个以上不同能力(查网 + 写码 + 存文件)= HIGH。


1.5 图示:select_agent 的完整流程

怎么读这张图:从上往下顺序执行,HIGH 是一个提前退出的岔路。

query 进来
|
v
agents 只有 1 个? ──yes──> 直接返回它
| no
v
① 检语种 detect_language() langid
v
② 只取第一行 find_first_sentence()
v
③ 翻成英文 translate() Helsinki-NLP MarianMT
v
④ 估复杂度 estimate_complexity()
|
├── HIGH ──> find_planner_agent() ──> 返回 PlannerAgent(结束)
|
└── LOW
v
⑤ 投票 router_vote(text, labels)
v
⑥ 按 best_agent == agent.role 找人 ──> 返回该 agent

源码在 sources/router.py:441-471AgentRouter.select_agent)。

三个步骤值得单独说:

  • ② 只取第一行find_first_sentencesources/router.py:392-399):实现上是 text.split("\n") 取第一段并 strip()——严格说是「第一行」而非「第一句」。目的是别让长篇大论里的枝节干扰分类。
  • ③ 翻译:中文/法语请求先翻成英文,因为两个分类器的 few-shot 全是英文(sources/language.py:41-58LanguageUtility.translate)。支持的语种由 config.inilanguages 决定,每种非英语都会加载一个 Helsinki-NLP/opus-mt-<lang>-en 模型。
  • ④ 复杂度优先HIGH 直接短路,根本不跑投票

1.6 原理演示:投票是怎么算的

下面这段把 router_vote 的核心想法抽出来演一遍。

# 示意,非源码
def vote(text, labels):
if len(text) <= 8: # 太短一律当闲聊
return "talk"
bart_label, bart_conf = bart_classify(text, labels) # 零样本
llm_label, llm_conf = adaptive_classify(text) # few-shot
total = bart_conf + llm_conf # 归一化到同一把尺子
return bart_label if bart_conf / total > llm_conf / total else llm_label

重点看归一化那一步:两个模型的置信度量纲不同(BART 是 NLI 蕴含概率,AdaptiveClassifier 是原型相似度混神经打分),直接比大小没意义,所以先各自除以两者之和。

真实实现(sources/router.py:370-390router_vote):

if len(text) <= 8:
return "talk"
result_bart = self.pipelines['bart'](text, labels)
result_llm_router = self.llm_router(text)
...
final_score_bart = confidence_bart / (confidence_bart + confidence_llm_router)
final_score_llm = confidence_llm_router / (confidence_bart + confidence_llm_router)
return bart if final_score_bart > final_score_llm else llm_router

len(text) <= 8 那行是个便宜的短路:"hi"、"你好" 这种不值得跑两个模型。


1.7 复杂度估计里的「不确定就往难了判」

estimate_complexity 有一条很有意思的保守规则(sources/router.py:401-426):

complexity, confidence = predictions[0][0], predictions[0][1]
if confidence < 0.5:
self.logger.info(f"Low confidence in complexity estimation: {confidence}")
return "HIGH"

置信度低于 0.5 时返回 HIGH,而不是 LOW 逻辑是:判错方向的代价不对称——

误判方向后果
简单事被判 HIGH多花一次规划的 LLM 调用,仍然做得成
复杂事被判 LOW单个 agent 干不完,用户拿到半成品

另外,分类器抛异常时 fallback 是 LOWsources/router.py:411-413),预测列表为空时也是 LOW——这两处和上面的保守策略方向相反,属于「模型挂了就别折腾规划」的兜底。


1.8 关键细节与坑

坑 1:两侧标签集不一致

投票的两个模型用的标签根本不是同一套

BART 侧 labels = [agent.role for agent in self.agents]
→ talk / code / files / web / planification

自适应侧 labels 来自 few-shot
→ talk / code / files / web / mcp / coding

对不上的部分:

标签BART 侧自适应侧有对应 agent 吗
planification有(PlannerAgent)
mcp有(5 条例子)默认没有(cli.py 里注释掉,api.py 未注册)
coding有(1 条,疑似 code 的笔误)

后果:一旦自适应分类器返回 mcpcoding 且赢下投票,select_agent 末尾的匹配循环走空,打印 “Error choosing agent.” 并 返回 Nonesources/router.py:464-471)。上游 Interaction.think() 收到 Nonereturn Falsesources/interaction.py:279-280),整轮请求作废。

坑 2:路由是无状态的

select_agent 只看当前这一句,不看对话历史。所以「把刚才那个游戏改简单点」这种指代前文的话,全靠 few-shot 里塞了 “Make the game less hard” 这类例子撑着。

Interaction.think()换人倒是做了补救:如果这轮选的 agent 和上轮不同,会把上一轮的一问一答补进新 agent 的 memory(sources/interaction.py:281-290):

if self.current_agent != agent and self.last_answer is not None:
push_last_agent_memory = True
...
if push_last_agent_memory:
self.current_agent.memory.push('user', self.last_query)
self.current_agent.memory.push('assistant', self.last_answer)

注意补进去的是上一句 query 和上一个答案,不是完整历史——是个很轻的交接。

坑 3:启动成本不低

AgentRouter.__init__ 一次要加载:BART-large-MNLI(约 1.6GB)、两份 distilbert 分类器、以及每个非英语语种一个 MarianMT 翻译模型。这就是 CLI 启动时那几行 “Loading zero-shot pipeline...” / “Loading LLM router model...” 的来源。

细节:get_device 写了但没用上

AgentRouter.get_device()sources/router.py:61-67)会探测 mps/cuda/cpu,但没有任何调用点把它传给 pipeline 或分类器——load_pipelines() 建 BART 时没传 device。也就是说路由推理跑在库的默认设备上。


1.9 本章代码地图

主题文件符号
路由主流程sources/router.pyAgentRouter.select_agent
投票sources/router.pyAgentRouter.router_votellm_router
复杂度sources/router.pyAgentRouter.estimate_complexity
few-shot 数据sources/router.pylearn_few_shots_taskslearn_few_shots_complexity
模型加载sources/router.pyload_pipelinesload_llm_router
语种与翻译sources/language.pyLanguageUtility.detect_languagetranslate
底模标签表llm_router/config.jsonid_to_label
底模下载脚本llm_router/dl_safetensors.sh
路由的调用方sources/interaction.pyInteraction.think