跳到主要内容

Chat UI — LLM 路由(Omni)

“我不想手动选模型,你帮我挑。” Omni 就是干这个的:一个看起来像普通模型的虚拟条目,内部把每条消息分发到最合适的真实模型。本章讲它怎么选、怎么转、怎么回退。

1. 它要解决的小问题

模型各有所长:有的擅长推理、有的能看图、有的支持工具、有的便宜。让用户每次手动切换很烦。理想是给一个“万能入口”,它自己判断这次该用谁。

Chat UI 的实现是:配了 LLM_ROUTER_ROUTES_PATH 后,模型列表里多出一个虚拟模型(默认显示名 "Omni",models.ts:319)。用户选它发消息,就触发路由逻辑。

重要的文档漂移: README/CLAUDE.md 说路由靠 "Arch-Router model" 调外部 API。当前代码已经换成纯本地启发式——heuristics.ts:21 的注释明写 "This replaces the Arch Router API call with local heuristics"。本章按真实代码讲。

2. 虚拟模型怎么来的

buildModels() 里(models.ts:328):若配了 routes 路径,就造一个 aliasModel,它的 getEndpoint() 不走普通 OpenAI endpoint,而是 makeRouterEndpoint(models.ts:375),并被放到模型列表最前面isRouter: true 这个标记后面到处用来分支。

3. 选路:三条路 + 启发式

路由分两个层次,makeRouterEndpoint(router/endpoint.ts:105)里依次判断:

收到一次生成请求

1. 多模态 bypass:开了 ENABLE_MULTIMODAL 且消息里有图片?
│ └─ 是 → 直接路由到配置的多模态模型(MULTIMODAL_ROUTE)

2. 工具 bypass:开了 ENABLE_TOOLS 且用户选了 MCP 服务器?
│ └─ 是 → 直接路由到配置的工具型模型(AGENTIC_ROUTE)

3. 启发式选路 heuristicSelectRoute():
│ 多模态信号 / 工具信号(被对应 ENABLE_* 标志屏蔽)/ 否则 default

4. 用 routes.json 把"路由名"解析成候选模型列表
└─▶ 逐个候选尝试,第一个成功的就用(失败则回退下一个)

启发式本身很朴素(heuristics.ts:21):有图 → multimodal 路由;有工具 → agentic 路由;否则 default。“智能”其实在 routes.json 策略文件里——每个路由名映射到 primary_model + fallback_models(policy.ts:36 resolveRouteModels)。

4. 策略文件 routes.json

policy.ts:8 读取并校验 routes 文件:必须是扁平数组,每项有唯一 namedescriptionprimary_modelresolveRouteModels(routeName, ...)(policy.ts:36)按名字找到路由,返回 [primary_model, ...fallback_models] 作为候选序列。找不到就退到 default 路由,再不行退到 LLM_ROUTER_FALLBACK_MODEL

5. 候选回退 + 元信息

makeRouterEndpoint 拿到候选序列后逐个尝试(endpoint.ts:255):为候选造一个真实 OpenAI endpoint(createCandidateEndpoint,优先用模型列表里的真实配置),调用成功就返回,失败 continue 下一个,全失败才抛出上游错误(endpoint.ts:282)。

返回流之前,先 yield 一个 routerMetadata 事件(metadataThenStream,endpoint.ts:152),告诉 UI “这次实际用的是哪个模型、走了哪条路”——这就是聊天界面里“via {模型}”标签的来源。这个元信息只对路由型模型存库(+server.ts:546)。

6. 工具流里的二次路由

注意一个易错点:工具流(第 2 章)和路由是两套独立逻辑,但都要选模型。当用户选的是 Omni 且要调工具,runMcpFlow 会先调 resolveRouterTarget(mcp/routerResolution.ts:30)解析出真正要用哪个工具型模型,再用那个模型 id 去发 function-calling 请求。

这里的 resolveRouterTarget 复刻了和 makeRouterEndpoint 一样的优先级(多模态 bypass → 工具 bypass → 启发式),但产物不是“流”,而是 {runMcp, targetModel, candidateModelId, resolvedRoute}——如果它判定该走的不是工具型模型,就返回 runMcp=false,工具流让位给普通生成(runMcpFlow.ts:285)。

7. 巧妙之处

  • 路由器伪装成模型:isRouter 模型复用整套模型/endpoint 机制,UI 无需特例(models.ts:370)。
  • bypass 快捷路径:多模态/工具这两类强信号直接短路,不进启发式(endpoint.ts:167/:205)。
  • 候选序列回退:primary 挂了自动试 fallback,提升可用性(endpoint.ts:255)。
  • 发流前先报实际模型:metadataThenStream 让 UI 立刻显示“via X”(endpoint.ts:152)。
  • 回退也保留推理剥离:转发前把历史消息里的 <think> 块剥掉,避免污染下游模型(endpoint.ts:87 stripReasoningBlocks)。

8. 边界与局限

  • 启发式很粗:只看“有没有图/有没有工具”,不按内容语义选模型(尽管显示名叫 Omni、文档暗示更智能)。真正的差异化全靠运维写好 routes.json。
  • 路由和工具流各有一份选路代码(endpoint.tsrouterResolution.ts),逻辑重复,改一处要记得改另一处。

9. 横向对比

很多“模型路由”项目(如 RouteLLM)用一个学习过的分类器/打分模型按 prompt 难度选便宜还是贵的模型。Chat UI 现版本走的是相反的极简路线:零额外模型调用的规则路由 + 运维手写策略。优点是零延迟、可预测、易审计;代价是“聪明”程度取决于人写的 routes.json,而非自动学习。

代码地图

主题文件符号
路由 endpointsrc/lib/server/router/endpoint.tsmakeRouterEndpoint, metadataThenStream, createCandidateEndpoint
启发式选路src/lib/server/router/heuristics.tsheuristicSelectRoute, MULTIMODAL_ROUTE, AGENTIC_ROUTE
策略解析src/lib/server/router/policy.tsgetRoutes, resolveRouteModels
工具流二次选路src/lib/server/textGeneration/mcp/routerResolution.tsresolveRouterTarget
工具型模型选择src/lib/server/router/toolsRoute.tspickToolsCapableModel, hasActiveToolsSelection
虚拟模型装配src/lib/server/models.tsbuildModels