PicoClaw 运行时路由系统全解析:Agent 分发、会话隔离与轻/重模型分级选路
【免费下载链接】picoclawTiny, Fast, and Deployable anywhere — automate the mundane, unleash your creativity项目地址: https://gitcode.com/gh_mirrors/pi/picoclaw
PicoClaw 的"路由系统"不是单次决策,而是一条贯穿入站消息到模型执行的组合管道:它依次决定由哪个 Agent 处理消息、用哪些会话维度隔离该对话、以及本回合是使用 Agent 主模型还是更便宜的轻量模型。本文以 docs/architecture/routing-system.md 为主线,结合pkg/routing、pkg/session与pkg/agent的真实源码与测试,完整讲解这套运行时的三层路由机制,读完后你将能够编写精确的 dispatch 规则、配置会话维度与身份链接,并调优模型路由阈值。需要注意,本文讨论的是运行时消息路由,不涉及 Launcher 的 HTTPServeMux路由或前端 TanStack Router 文件路由。
路由系统概览:三个问题,一条管道
在 PicoClaw 中,一条入站消息在真正进入 LLM 之前,路由系统需要连续回答三个问题:
- 哪(哪)个 Agent 处理这条入站消息(Agent 分发);
- 哪些会话维度应当隔离这段对话(会话策略选择);
- 这一回合应使用 Agent 的主模型,还是配置的轻量模型(模型路由)。
三个问题对应四个分层,每个分层有明确的职责与文件归属:
| 分层 | 文件 | 职责 |
|---|---|---|
| Agent 分发 | pkg/routing/route.go、pkg/routing/agent_id.go | 为入站消息选择目标 Agent |
| 会话策略选择 | pkg/routing/route.go | 决定该回合的会话隔离应使用哪些维度 |
| 模型路由 | pkg/routing/router.go、pkg/routing/features.go、pkg/routing/classifier.go | 依据消息复杂度在主模型与轻量模型之间选择 |
| 运行时集成 | pkg/agent/registry.go、pkg/agent/agent_message.go、pkg/agent/turn_coord.go | 应用路由结果、分配会话作用域、在 Provider 执行前选择模型候选集 |
端到端调用链
一条用户消息的正常处理路径如下:
InboundMessage -> NormalizeInboundContext -> RouteResolver.ResolveRoute(...) -> session.AllocateRouteSession(...) -> ensureSessionMetadata(...) -> Router.SelectModel(...) -> provider execution前半段回答"谁应该处理这条消息、它属于哪个会话",后半段回答"该 Agent 本回合应该用哪一档模型"。从前半段到后半段的衔接点,是路由结果同时携带了AgentID与SessionPolicy,而模型选择则在turn_coord阶段独立完成。
Agent 分发:从归一化上下文到 ResolvedRoute
routing.RouteResolver把归一化后的bus.InboundContext转换为一个ResolvedRoute。其定义位于 pkg/routing/route.go:
type ResolvedRoute struct { AgentID string Channel string AccountID string SessionPolicy SessionPolicy MatchedBy string }MatchedBy是调试辅助字段,用于说明路由是依据什么匹配的,典型取值包括:
default:未命中任何规则,走了默认 Agent;dispatch.rule:命中了一条未命名(name为空)的分发规则;dispatch.rule:<rule-name>:命中了一条命名规则,<rule-name>为规则名的小写形式。
MatchedBy的具体构造逻辑见 pkg/routing/route.go 的matchedByForRule函数,测试用例 pkg/routing/route_test.go 中TestResolveRoute_DispatchFirstMatchWins断言了dispatch.rule:support-group这类取值。
此外,Agent ID 与账户 ID 会经过 pkg/routing/agent_id.go 的归一化:NormalizeAgentID/NormalizeAccountID会把任意输入收敛为[a-z0-9][a-z0-9_-]{0,63}的合法形态,非法字符折叠为-、去除首尾破折号、超长截断至 64 字节;空输入分别回退到隐式默认值main与default。
分发输入视图:规则匹配必须对准的归一化形状
在匹配规则之前,解析器会先构建一个归一化的dispatchView(pkg/routing/route.go),每个字段都被处理成规则匹配所需的精确形态:
| 选择器字段 | 运行时形状 |
|---|---|
channel | 小写渠道名 |
account | 归一化后的账户 ID |
space | <space_type>:<space_id> |
chat | <chat_type>:<chat_id> |
topic | topic:<topic_id> |
sender | 小写的规范化发送者 ID(可能被identity_links改写) |
mentioned | 从入站上下文直接拷贝的布尔值 |
注意chat与space的形态推导细节:space_type为空时默认space,chat_type为空时默认direct(见 pkg/routing/route.go)。因此,分发规则必须按这种归一化形状书写,例如 config/config.example.json 之外常见的实际配置写法:
{ "agents": { "dispatch": { "rules": [ { "name": "support-group", "agent": "support", "when": { "channel": "telegram", "chat": "group:-100123" } }, { "name": "slack-mentions", "agent": "support", "when": { "channel": "slack", "space": "workspace:t001", "mentioned": true } } ] } } }配置侧对应的 Go 结构体在 pkg/config/config.go:DispatchRule包含name、agent、when(即DispatchSelector)与可选的session_dimensions;DispatchSelector则覆盖channel、account、space、chat、topic、sender以及指针类型的mentioned。规则匹配时会先对选择器做同样的归一化(normalizeDispatchSelector),再逐字段精确比较,mentioned为 nil 时不参与约束判断(pkg/routing/route.go)。
分发算法与关键语义
ResolveRoute(...)的执行序列(见 pkg/routing/route.go):
- 归一化
channel与account; - 从配置克隆
session.identity_links; - 构建归一化的 dispatch 视图;
- 按顺序扫描
agents.dispatch.rules; - 跳过没有任何约束条件的规则(
selectorHasAnyConstraint判定为空则跳过); - 返回第一个所有选择器字段都精确匹配的规则;
- 若无规则命中,回退到默认 Agent。
由算法结构直接得出以下重要语义:
- 先匹配者获胜(first match wins):规则顺序即优先级;
- 没有分数或优先级字段:除列表顺序外不存在其他排序机制;
- 无效的目标 Agent ID 回退到默认 Agent:
pickAgentID会在目标 ID 不在agents.list中时回退(pkg/routing/route.go); - sender 匹配可以看到
identity_links产生的规范身份:发送者 ID 会先尝试通过resolveLinkedDispatchID映射到规范 ID 再参与比较(pkg/routing/route.go)。
TestResolveRoute_DispatchFirstMatchWins等测试用例(pkg/routing/route_test.go)覆盖了首条命中规则生效、维度覆盖等核心行为。
默认 Agent 解析顺序
如果没有任何分发规则胜出,或规则指向了未知 Agent,解析器按下述顺序选取默认 Agent(pkg/routing/route.go 的resolveDefaultAgentID):
- 被标记为
default: true的 Agent; - 否则取
agents.list中的第一条; - 否则使用隐式
main。
这一顺序与DefaultAgentID = "main"常量(pkg/routing/agent_id.go)保持一致,route_test.go的TestResolveRoute_DefaultAgent_NoBindings即验证了无任何绑定配置时回退到main且MatchedBy为default。
会话策略交接:SessionPolicy 与维度归一化
Agent 分发并不会直接构造会话键,而是输出一个SessionPolicy(pkg/routing/route.go):
type SessionPolicy struct { Dimensions []string IdentityLinks map[string][]string }Dimensions的来源有两处:
- 全局
session.dimensions; - 当命中的规则配置了
dispatch_rule.session_dimensions时,由规则覆盖全局配置。
经过normalizeSessionDimensions归一化后,只有以下维度名会被保留(pkg/routing/route.go):
spacechattopicsender
非法或重复的条目会被静默丢弃——归一化过程会把维度名转小写、去空白,不在白名单中的值直接跳过,已出现过的值去重。
随后 pkg/session/allocator.go 的session.AllocateRouteSession(...)把这个策略转化为:
- 结构化的
SessionScope(版本、AgentID、Channel、Account、维度与值); - 规范的路由会话键(
BuildSessionKey基于作用域签名生成不透明键); - 旧版兼容别名(legacy aliases,覆盖直聊/群聊/主题等历史键形态)。
因此职责划分非常清晰:routing 包负责"什么应该隔离这段对话",session 包负责"这种隔离如何变成键与持久化存储"。源码细节上,buildSessionScope在拼装chat维度时还有一个 Telegram 论坛特例:当渠道为 Telegram、存在 topic 且维度集中没有显式topic维度时,会把 topic 拼进 chat 值(chat_id/topic_id),从而默认保持论坛主题间的隔离(pkg/session/allocator.go),对应测试TestAllocateRouteSession_TelegramForumTopicsRemainIsolatedByDefault(pkg/session/allocator_test.go)。
Identity Links:分发与会话的身份对称
session.identity_links同时被分发与会话分配共享,这是有意为之:一个为了路由被规范化的发送者,也应该映射到同一个会话身份。配置结构为map[string][]string,即"规范身份 → 一组原始 ID 列表"(pkg/config/config.go 的SessionConfig)。
如果缺少这种对称性,系统可能把两条消息路由到同一个 Agent,却把它们的对话历史碎片化到不同会话。从源码实现看,这种对称性体现在两处:
- 分发侧:
canonicalDispatchSenderID依据identity_links解析出规范 ID 用于规则匹配; - 会话侧:
buildSessionScope在sender维度上调用CanonicalSessionIdentityID生成相同的规范身份,buildLegacyDirectPeerIDs也同时生成原始 ID 与规范 ID 两套别名(pkg/session/allocator.go)。
模型路由:复杂度驱动的轻/重模型分级
路由系统的第二阶段,决定一个回合是否可以使用更便宜、更快的轻量模型。配置形态:
{ "routing": { "enabled": true, "light_model": "gemini-2.0-flash", "threshold": 0.35 } }对应配置结构体RoutingConfig(pkg/config/config.go):enabled为开关,light_model是model_list中的模型名,threshold是[0,1]的复杂度分界。
pkg/routing/router.go 的Router.SelectModel(msg, history, primaryModel)将当前回合与结构特征进行比较,返回三个结果:
- 选中的模型名;
- 是否使用了轻量模型;
- 计算出的复杂度分数。
判定规则:分数低于阈值则轻量模型胜出,否则使用 Agent 主模型。源码中threshold <= 0时自动回退到defaultThreshold = 0.35(pkg/routing/router.go)。在运行时,只有 Agent 确实配置了轻量模型候选集时该判定才真正生效,否则执行始终停留在主候选集上。
复杂度特征提取
ExtractFeatures(msg, history)计算一个语言无关的特征向量(pkg/routing/features.go):
| 特征 | 含义 |
|---|---|
TokenEstimate | 近似 token 数;CJK 字符比扁平 rune 分割估算更准确 |
CodeBlockCount | 当前消息中围栏代码块的数量 |
RecentToolCalls | 最近六条历史条目中的工具调用数 |
ConversationDepth | 会话历史总长度 |
HasAttachments | 是否检测到内嵌媒体或常见媒体 URL/文件扩展名 |
特征提取的几个实现细节值得展开:
- Token 估算(
estimateTokens):CJK 字符(U+2E80–U+9FFF、U+F900–U+FAFF、U+AC00–U+D7AF)每个计 1 token,其余字符按 4 字符约 1 token 折算(cjk + (total-cjk)/4)。这种拆分避免了平铺rune_count/3对中日韩文本约 3 倍的严重低估(pkg/routing/features.go); - 代码块计数(
countCodeBlocks):直接统计```分隔符数量除以 2,奇数个未闭合围栏按 0 个完整块处理,避免把内联代码片段或笔误误判为代码任务(pkg/routing/features.go); - 工具调用回看窗口(
lookbackWindow = 6):覆盖约一个完整的工具使用往返(user → assistant+tool_call → tool_result → assistant),统计时读取消息结构化的ToolCalls字段而非解析内容字符串,对任何消息格式都稳健(pkg/routing/features.go); - 附件检测(
hasAttachments):检查data:image/、data:audio/、data:video/等 base64 数据 URI,以及.jpg/.png/.gif/.mp3/.mp4/.webm等常见媒体扩展名。该实现刻意保守——漏判(假阴性)只会让路由回退到主模型,不会造成错误(pkg/routing/features.go)。
设计上这套特征完全是结构化的而非关键词匹配,因此路由行为在跨语言场景下保持一致。
RuleClassifier 打分规则
当前实现的分类器是RuleClassifier(pkg/routing/classifier.go),它使用加权和并封顶到[0, 1]。Classifier本身是接口(pkg/routing/classifier.go),未来可以无缝替换为 ML 或 embedding 方案而不改动路由基础设施。
| 信号 | 分数 |
|---|---|
| 存在附件 | 1.00(硬门,短路直接返回) |
token 估算> 200 | 0.35 |
token 估算> 50 | 0.15 |
| 存在代码块 | 0.40 |
近期工具调用> 3 | 0.25 |
近期工具调用1..3 | 0.10 |
会话深度> 10 | 0.10 |
原始加权和超过 1.0 时封顶为 1.0,以兑现[0, 1]契约(例如长消息 + 代码块 + 工具链的组合原始分可能达到 1.10)。附件是唯一硬门:多模态输入总是需要视觉能力,直接返回 1.0 强制重模型。
默认阈值0.35使以下行为成为设计意图(注释原样来自 pkg/routing/classifier.go):
- 纯问候/简单问答:
0.00→ 轻量模型; - 中等长度文本(50–200 tokens):
0.15→ 轻量模型; - 含代码块的消息:
0.40→ 重模型; - 超长消息(>200 tokens):
0.35→ 重模型; - 活跃工具会话 + 中等消息:
0.25→ 轻量模型(可接受); - 任何带图像/音频附件的消息:
1.00→ 重模型。
一句话概括:琐碎闲聊留在轻量模型,代码任务通常立刻跳到重模型,附件永远强制重模型,长纯文本提示在默认阈值下也会跨入重模型边界。
运行时集成:路由结果如何落到模型执行
Agent 分发与模型路由发生在不同的代码位置:
- pkg/agent/registry.go 持有
RouteResolver(AgentRegistry.resolver字段,构造时routing.NewRouteResolver(cfg),并暴露ResolveRoute方法); - pkg/agent/agent_message.go 的
resolveMessageRoute解析路由并取出对应 Agent 实例,allocateRouteSession调用session.AllocateRouteSession分配会话作用域; - pkg/agent/turn_coord.go 的
selectCandidates调用agent.Router.SelectModel(...):当Router为 nil 或LightCandidates为空时直接使用主候选集;否则根据usedLight切换。
选中轻量模型时,Agent 循环会切换到agent.LightCandidates;未选中时,执行停留在 Agent 主 Provider 候选集。最终选中的模型名会参与llmOpts的组装与执行。
显式会话键的保留路径
一个处于pkg/routing之外但对完整路由故事重要的细节:路由分配完成后,pkg/agent/agent_utils.go 的resolveScopeKey会在调用方已提供显式会话键时保留它,包括:
- 不透明的规范键;
- 旧版
agent:...键。
这使得手工系统流程、测试与兼容路径在即使常规路由作用域会生成不同键的情况下依然保持确定性。ensureSessionMetadata则在每回合与/clear等命令路径上把作用域与别名元数据持久化(pkg/agent/agent_utils.go)。
本文未覆盖的范围
仓库中还存在另外两套与运行时路由无关的路由体系:
- 后端 HTTP 路由,注册于
web/backend/api/router.go; - 前端文件路由,位于
web/frontend/src/routes/。
它们是 Launcher 的实现细节,与本文描述的运行时消息路由系统相互独立。
相关文件索引
- pkg/routing/route.go:Agent 分发、会话策略与分发算法
- pkg/routing/router.go:模型分级选择器
- pkg/routing/classifier.go:复杂度打分
- pkg/routing/features.go:结构特征提取
- pkg/routing/agent_id.go:Agent/账户 ID 归一化
- pkg/session/allocator.go:会话作用域分配与旧版别名
- pkg/agent/registry.go、pkg/agent/agent_message.go、pkg/agent/turn_coord.go:运行时集成
- pkg/agent/agent_utils.go:显式会话键保留与会话元数据
- pkg/config/config.go:
DispatchRule、DispatchSelector、RoutingConfig、SessionConfig结构定义 - pkg/routing/route_test.go、pkg/session/allocator_test.go:路由与分配行为测试
【免费下载链接】picoclawTiny, Fast, and Deployable anywhere — automate the mundane, unleash your creativity项目地址: https://gitcode.com/gh_mirrors/pi/picoclaw
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考