做过Agent落地的人应该都有同感:单轮工具调用做得再漂亮,一旦任务开始绕弯子,整个系统的"笨"就藏不住了。我的Agent系列写到第9章,前几篇分别讲了工具封装、记忆管理和ReAct执行循环,这篇9.3想拆开聊一个最容易被忽略、又最决定体验的环节——动态路由与自适应编排。
路由和编排这俩词听起来像架构师画框用的术语,但真正上过生产环境的人都懂,它们决定了同一个模型在用户面前是"聪明"还是"呆滞"。如果不把路由当回事,所有请求都塞进同一个提示词模板,就等于让一个全能员工同时干财务、法务和客服,再强的能力也会因为上下文互相干扰而频繁翻车。这篇内容适合正在自己写Agent框架、或者用LangGraph/AutoGen这类框架做二次开发的读者,我会把路由分层方案、编排器的自适应调节方式,以及实测中踩过的坑一次性讲透。
1. 先说清楚:动态路由到底在解决什么痛点
1.1 我最早那版死板路由是怎么被业务问倒的
我一开始做Agent路由,思路特别天真:维护一个工具清单,每个工具写一段description,然后让模型在每次执行前自己选。看起来没什么问题,真正接业务的时候才发现,光靠"让模型自己选"远远不够。
当时有个客服场景,用户问"上周订单为什么还没发货",Agent需要先查订单库、再查物流系统、然后把结果汇总成回复。问题出在,模型第一轮确实选了"查询订单",但拿到订单状态后不知道该切换到"查询物流"这个工具,反而继续在订单工具里打转。后来我加了几条if-else规则,情况好转了一点,可新的问题又来了:用户换了个说法,"我的东西寄到哪了",模型又开始乱选。
那句"让模型自己选"本质上把路由决策完全交给了LLM的即时状态,没有任何机制保证它选了之后能走完一整条路径。这是静态路由的第一层尴尬:工具之间是割裂的,Agent没有"路径"的概念。
1.2 路由问题本质上不是"选工具",而是"安排路径"
踩了那次坑之后我意识到,路由要解决的不只是"当前这一步调哪个工具",而是"整个任务需要走哪几条路、按什么顺序走、哪些路可以并行"。
举个更直白的例子。用户说"帮我把这份销售数据和上个月的对比一下,生成一份简要报告"。这条路至少包含:读取数据、查询上个月数据、做对比计算、生成报告文本。如果路由只选了一个"数据读取"工具,后面就断了;如果路由把所有工具一股脑塞进提示词,模型又容易在长上下文里迷失。
所以我把路由拆成两层概念:表层路由解决"第一个动作落在哪个工具/Agent上",路径规划解决"后续动作怎么衔接"。动态路由的真正价值在后者——它要根据前一步的结果实时决定下一步往哪走,而不是预先写好一套死流程。
1.3 动态路由和静态路由的分界线在哪里
静态路由可以写成配置文件:问天气就走天气工具,问时间就走时间工具,查数据库就走SQL工具。这种方案在工具数量少、场景固定的Demo里完全够用,但工具一旦超过十几个,或者用户请求是多意图混合的,静态配置就开始失灵。
动态路由的判别标准我认为有三条:路由决策是否依赖当前对话上下文;路径是否允许根据中间结果改变;路由选择是否带置信度且存在兜底机制。满足这三条才算动态,否则只是把规则从代码挪到了配置里。
这三条标准也是我后面做分层路由设计的出发点。接下来我详细讲一下落地时用的三层漏斗结构。
2. 路由的三层漏斗:规则预筛、语义召回、LLM裁决
2.1 第一层规则预筛:用最小的成本拦住最明显的错路
规则预筛的作用不是把所有请求都分对,而是把"极其明显"的请求以极低成本分流掉,减少后面两层的压力。比如包含"select""查询数据库""表结构""统计一下"这些关键词的请求,99%该走SQL工具;包含"http""网页""打开链接"的请求,大概率该走网页解析工具。
这一步我用的是正则+关键词权重,追求的不是准确率而是精度优先。宁可漏判,不要误判,因为漏判了后面还有语义召回和LLM裁决兜着,误判了想纠回来成本很高。
import re RULE_PATTERNS = { "sql_agent": [ r"select\s|insert\s|update\s|delete\s", r"数据库|查询.*表|表结构|统计.*数据|订单.*金额", ], "web_agent": [ r"https?://|www\.|网页|打开链接|抓取.*页面", ], "code_agent": [ r"代码|调试|bug|报错|写个函数|脚本", ], } def rule_prefilter(query: str): for agent_name, patterns in RULE_PATTERNS.items(): for pat in patterns: if re.search(pat, query, re.IGNORECASE): return agent_name return None这段代码的要点是:每个匹配都带有强信号词,宁缺毋滥。我见过有人把"数据分析"这种泛词也放进SQL规则里,结果用户问"帮我分析一下这个方案的风险",直接被分去了SQL工具,场面一度很难看。
2.2 第二层语义召回:给Agent一个稳定的"直觉"
规则预筛漏掉的大多数请求,都差在"说法变了但意思没变"。比如"我的货到哪了"和"查询物流进度"完全同义,关键词却对不上。这一层我交给embedding相似度召回。
具体做法:每个路由目标维护一段路由描述(注意不是工具description,是专门给路由用的、面向分类的描述文本),把用户当前请求向量化,跟所有路由描述算余弦相似度,取最高分且超过阈值的那个作为候选。
import numpy as np def embedding_match(query_vec, route_vecs, threshold=0.62): best_name, best_score = None, 0.0 for name, vec in route_vecs.items(): score = float(np.dot(query_vec, vec) / ( np.linalg.norm(query_vec) * np.linalg.norm(vec) + 1e-9 )) if score > best_score: best_name, best_score = name, score if best_score < threshold: return None, best_score return best_name, best_score这里有个很关键的经验:路由描述要写"任务类型",不要写"工具功能"。我以前把SQL工具的描述写成"执行SQL查询、支持MySQL和PostgreSQL",embedding匹配时发现用户问"上周销量"和这段描述的相似度反而不如直接写"处理数据库查询和数据分析类任务"高。原因是用户不会按工具功能去表述需求,只会按任务目的去表述。
阈值0.62是我在十几个业务场景里试出来的折中值,工具类型越相似,阈值要拉得越高。如果你的Agent只有聊天和SQL两个路由目标,0.55可能都够;如果有十个以上且互相有交集,建议先跑到0.65再往下调。
2.3 第三层LLM裁决:让模型自己做选择题
前两层都拿不准的时候,才轮到LLM出场。LLM裁决的提示词里,我要求模型输出一个JSON,包含choice、confidence和reason三个字段。注意,一定要求它给置信度和理由。理由是给日志排查用的,置信度是给下游兜底逻辑用的。
ROUTER_PROMPT = """你是路由裁决器。根据用户最近的请求,从以下路由目标中选择最合适的一个。 只输出JSON:{{"choice": "路由名", "confidence": 0到1之间的小数, "reason": "一句话理由"}} 路由目标: {schema_text} 用户请求:{query} 历史上下文:{history_tail}""" def llm_router(query, history, schema_text): prompt = ROUTER_PROMPT.format( schema_text=schema_text, query=query, history_tail=history[-1200:], ) resp = llm_call(prompt, json_mode=True) return json.loads(resp)把历史上下文的尾部拼进去,是我调了好几个版本才加上的。因为很多请求是省略主语和宾语继续说前文的话题,比如用户前面在聊订单,下一句只说了"那退款呢?",不看历史谁也不知道"那"指的是什么。但历史也不能给太长,1200字符是我实测的平衡点,够模型理解上下文,又不会把路由逻辑冲淡。
2.4 置信度裁定与兜底策略:低于阈值时不硬选
三层漏斗串起来之后,真正的裁决逻辑在置信度的裁定上。我的规则是:
| 层级 | 判定条件 | 动作 |
|---|---|---|
| 规则预筛 | 命中强信号词 | 直接路由,不经过后续层 |
| 语义召回 | 最高相似度 >= 0.62 | 进入LLM裁决做二次确认 |
| 语义召回 | 最高相似度 < 0.62 | 进入LLM裁决 |
| LLM裁决 | confidence >= 0.7 | 采用其路由结果 |
| LLM裁决 | confidence < 0.7 | 走兜底策略 |
兜底策略我按场景选过三种:反问澄清、路由到通用Agent、拆解任务后重新路由。客服场景我一般选反问澄清,因为用户已经在线,确认一下意图比猜错强;自动化批处理场景我选拆解重路由,因为不能每步都反问。这里要强调,兜底不是"随便给个默认值",而是要形成闭环:兜底之后的结果要能反馈到日志里,方便后续分析是路由描述不准,还是用户表达歧义。
提示:路由环节最容易犯的错是追求"每次都选对"。实际上动态路由的价值在于"选错的时候能低成本发现、并且有机制纠正",而不是保证万无一失。把精力花在置信度裁定和兜底闭环上,比反复调提示词更有性价比。
3. 自适应编排的四个调节旋钮
有了路由,下一步是编排。我理解的编排不是工作流引擎里画那种固定DAG,而是根据当前任务状态实时调整执行计划的一套调度逻辑。下面四个旋钮是我在实际项目中反复用到的调节维度。
3.1 旋钮一:计划粒度随任务复杂度伸缩
同一个工具,被调用的方式可以差很远。简单任务"把这段话翻译成英文",直接调一次翻译工具就行;复杂任务"调研三个竞品并输出对比报告",需要拆成搜索、抓取、整理、生成四个步骤。
自适应编排的第一步,就是先估计任务复杂度,决定计划拆到多细。我用的方法很朴素:让规划模块生成计划时附带required_steps字段,如果计划步骤数超过预设阈值(比如5步),就进入分层执行模式——先执行一个高层计划,每步再动态展开子计划。
def plan_with_granularity(task, route): plan = planner.predict(task) steps = plan.get("required_steps", 1) if steps > 5: return hierarchical_plan(plan) # 顶层 + 每步子计划 return flat_plan(plan) # 普通顺序执行不需要真的用一个独立的复杂度分类器,规划模型本身已经隐含了判断。你要做的是在规划提示词里明确要求它输出required_steps,然后让编排器根据这个值切换执行模式。
3.2 旋钮二:工具结果反哺下一步路径选择
这是自适应编排里我最看重的一点。固定流程的执行器拿到工具结果只会"继续下一步",而自适应编排器要做的是根据结果重新评估路径。
比如查询订单状态后,如果返回"已发货",下一步就切到物流查询;如果返回"未支付",下一步应该切到支付提醒而不是物流。这个判断听起来简单,但如果不做,Agent就会机械地往下走,最后给用户一个牛头不对马嘴的答案。
我在编排循环里加了一个replan_check函数,在每步执行完成后调用:
def manage_next_step(state): last_result = state["history"][-1] replan = orchestrator.evaluate( state["task"], state["current_step"], last_result ) if replan["should_change"]: state["plan"] = replan["new_plan"] state["route"] = replan["new_route"] return state这个evaluate的本质,是让LLM用当前结果对照原计划,判断"计划还成立吗"。成立就继续,不成立就调整。成本不高,但效果非常明显,Agent从"按剧本走"变成了"按情况走"。
3.3 旋钮三:上下文水位动态压缩
Agent在编排过程中最头疼的问题不是逻辑,而是上下文爆炸。每调一个工具,工具返回的原始内容动不动就是几千字,几个来回就把上下文塞满了,后面的执行质量直线下降。
我做了一个简单的水位监控:给执行过程中的上下文设一个token预算(比如4000 token),每步结束后统计当前累计量,超过预算就触发压缩策略。
压缩策略分三档:
- 摘要压缩:把历史中最久远的工具结果用LLM重写为三句以内的摘要。
- 结构化丢弃:只保留工具调用的
action和result_summary字段,丢弃原始返回体。 - 分段路由:明确把"执行"和"总结"两个阶段分开,执行阶段的详细上下文只用于该阶段,进入总结阶段前整体重构上下文。
我实测下来,第一档最常用,摘要压缩后的信息损失其实很小,因为工具结果里大量内容是重复的格式信息和噪音。关键是压缩动作不能只在最后做,要在编排循环里每步都检查水位。
3.4 旋钮四:失败后的重路由与降级
工具调用失败是编排器必须面对的家常便饭。自适应体现在失败处理上,就是要分清楚"失败的类型"和"该不该重试"。
我的失败分类很简单:
| 失败类型 | 特征 | 处理策略 |
|---|---|---|
| 参数错误 | 工具明确返回参数校验失败 | 修正参数后重试一次,仍失败则换表述 |
| 服务不可用 | 超时、连接失败、5xx | 切换到备用服务,无备用则整体降级 |
| 语义失败 | 工具返回了结果但内容是"查无数据" | 不重试,按空结果走后续路径 |
| 安全拦截 | 查询涉及敏感权限 | 停止当前路径,回溯到上一步让用户确认 |
重试不是无脑重来,要带状态。我把每次失败的上下文(当时的参数、报错信息、已经尝试的次数)塞进重试提示词里,让LLM判断这次失败是"换个参数就行"还是"这条路径走不通需要换路"。很多时候模型能自己意识到"再试也没用,干脆换个Agent来做"。
4. 可复现的Demo:给手写ReAct Agent装上路由层
4.1 一个最小可跑的路由器实现
下面这套代码我简化过,但核心逻辑完整,可以直接跑通。准备一个routes.yaml或者直接在代码里定义路由目标,重点是路由描述按"任务类型"来写:
# routes.py ROUTE_SCHEMA = [ { "name": "sql_agent", "description": "处理数据库查询、SQL生成、数据统计、报表取数类任务", "emb_text": "数据库查询 数据统计 SQL 报表 订单数据 销量分析", }, { "name": "web_agent", "description": "处理网页访问、链接抓取、信息检索、在线搜索类任务", "emb_text": "网页访问 链接抓取 在线搜索 信息检索 网址", }, { "name": "code_agent", "description": "处理代码编写、脚本调试、报错分析、程序运行类任务", "emb_text": "写代码 调试 报错 脚本 程序 函数 算法", }, { "name": "chat_agent", "description": "处理闲聊、通用问答、写作辅助、内容总结类任务", "emb_text": "闲聊 问答 写作 总结 翻译 润色", }, ]路由描述和emb_text我分开维护,因为LLM裁决用的描述要更自然、更像人话,而embedding匹配用的文本可以更"词表化",两者虽然指向同一个路由目标,但服务对象不同。这个细节最初我也没在意,后来发现合并成一段文本时,裁决效果总是差一点,分开之后两个层都稳定了。
4.2 三层路由的裁决函数
# router.py class DynamicRouter: def __init__(self, routes, llm, embed_fn, sim_threshold=0.62): self.routes = routes self.llm = llm self.embed_fn = embed_fn self.sim_threshold = sim_threshold def route(self, query, history_tail=""): # 第一层:规则预筛 rule_hit = rule_prefilter(query) if rule_hit: return {"route": rule_hit, "source": "rule", "confidence": 0.95, "reason": "强规则关键词命中"} # 第二层:语义召回 query_vec = self.embed_fn(query) route_vecs = {r["name"]: self.embed_fn(r["emb_text"]) for r in self.routes} semantic_choice, sim_score = embedding_match( query_vec, route_vecs, self.sim_threshold ) # 第三层:LLM裁决 schema_text = "\n".join( f"- {r['name']}: {r['description']}" for r in self.routes ) llm_result = llm_router(query, history_tail, schema_text) # 置信度裁定 if llm_result["confidence"] >= 0.7: return {"route": llm_result["choice"], "source": "llm", "confidence": llm_result["confidence"], "reason": llm_result["reason"]} if semantic_choice and sim_score >= 0.5: return {"route": semantic_choice, "source": "semantic+llm_low", "confidence": sim_score, "reason": "LLM置信度低,用语义兜底"} return {"route": "chat_agent", "source": "fallback", "confidence": 0.0, "reason": "所有路由信号不足,走通用兜底"}注意看置信度裁定那段:LLM裁决置信度低于0.7时,我并没有直接放弃,而是回过头看语义召回的分数是否超过0.5,如果语义层还算有把握,就用语义层的结果。这样做是因为LLM在confidence输出上本身就不稳定,有时它选对了但不敢肯定,语义层的分数反而能提供额外参考。两层信号交叉验证比单靠一层靠谱得多。
4.3 自适应编排循环
# orchestrator.py class AdaptiveOrchestrator: def __init__(self, tools, memory_budget=4000, max_rounds=8): self.tools = tools self.memory_budget = memory_budget self.max_rounds = max_rounds def run(self, task, router): history = [] # 路由 + 规划 route_result = router.route(task, history_tail="") plan = self.planner(task, route_result["route"]) state = { "task": task, "route": route_result["route"], "route_reason": route_result["reason"], "plan": plan, "history": history, "round": 0, } while state["round"] < self.max_rounds: state["round"] += 1 # 每步执行前先反思:计划还成立吗 self.adjust_plan_if_needed(state) if state["plan"]: step = state["plan"][0] else: break result = self.call_tool(step, state) state["history"].append({ "step": step, "result": result["summary"], "raw_len": result["raw_len"], }) state["plan"] = state["plan"][1:] # 上下文水位管理 if self.count_tokens(state["history"]) > self.memory_budget: self.compress_early_history(state) # 失败重路由 if result.get("failed"): route_result = router.route( f"刚执行{step}失败,尝试换一种路径: {result['error']}", history_tail=self.history_tail(state), ) state["route"] = route_result["route"] return self.final_answer(state)这个循环看起来和普通ReAct很像,区别全在adjust_plan_if_needed和失败重路由那两处。普通ReAct不改变计划,只是反复"思考-行动-观察";我这里每轮都让LLM看一眼"当前进度+剩余计划+最近结果",判断原计划是否还合理,不合理立刻生成新计划。多付出的只是一次轻量LLM调用,换来的是整个执行过程不会一条道走到黑。
4.4 跑起来看日志:什么算自适应生效
我建议你在调试时把路由决策和执行轨迹打出来,重点关注三类日志:
[route] 用户请求: 我的货到哪了 [route] 决策: sql_agent (source=rule, conf=0.95) <- 规则命中"查询" [exec] step1: 查询订单状态 -> 已发货 [replan] 检测到"已发货",切换剩余计划 -> 查询物流 [exec] step2: 查询物流轨迹 -> 上海市浦东新区 [compress] 历史token 4232 > 4000,触发摘要压缩 [final] 输出: 您的包裹已从上海发出,当前位于浦东转运中心注意[replan]那行,这就是自适应编排生效的痕迹:计划顺序不是一开始写死的,而是在step1结果出来后才补上的"查询物流"步骤。如果你的日志里永远没有[replan]和[compress],那说明编排器还没真正"自适应"起来,大概率是调整条件写得过于保守了。
5. 实战踩坑记录:误判、循环、上下文爆炸
5.1 工具名诱惑与描述污染引发的误判
路由层最容易出现的一类误判,我给起了个名字叫"工具名诱惑"。就是路由目标的名字本身带了强语义,比如某个工具叫data_analyzer,LLM看到"分析"就总往那选,哪怕用户说的是"帮我分析一下这个合同的风险",其实应该走文档解析Agent。
解决方法是把路由目标的name改成更"功能化"的代号,比如dwh_agent、doc_agent,同时在描述里明确"不要因为名字含分析就选我,要看任务类型"。这个小改动让我在保险行业项目里的路由准确率直接提升了7个百分点。
另一种污染来自描述文本写得"太全"。我曾经为了让路由更准,把工具描述写成了大作文,包含了所有可能的话术。结果embedding召回时什么请求都能跟它算出不错的相似度,反而把真正的区分度磨没了。路由描述要克制,写清"职责边界"比写清"能力范围"更重要。
5.2 路由死循环和终止条件的三次迭代
自适应编排跑起来以后,我遇到的第一个严重问题是死循环。场景是这样的:编排器发现计划不对,重新规划了一次,结果新计划还是指向同一个失败的工具,执行又失败,又重规划……如此往复,直到把token耗尽。
第一版我用max_rounds=8硬性截断,但截断后输出质量很差,经常给用户一个"我尽力了但没完成"的半截答案。第二版改成失败计数:同一个工具连续失败两次,直接把这个工具从本轮规划的候选里剔除。效果好了很多,但引入了新问题——剔除了唯一能解决问题的工具,Agent彻底卡住。
第三版我采用了重路由次数限制+降级路径的组合:
if state["same_failure_count"] >= 2: if state["reroute_count"] < 2: # 换路由目标,带着失败原因重新规划 state["route"] = router.route( f"当前路径持续失败,原因: {last_error},请换一条路径", ... ) state["reroute_count"] += 1 else: # 放弃自动执行,改为向用户澄清或输出部分结果 return self.graceful_handoff(state)这个设计的关键是把"换工具重试"和"换路径"分开。同一个工具连续失败,说明是工具本身出问题了,换一个同类工具是合理的;但如果换了路由方向还是失败,说明整条路径都不通,继续重试只会浪费资源,这时候让Agent学会"认怂",把当前进度整理给用户看,反而体验更好。
5.3 上下文水位管理的三个实操细节
关于上下文压缩,纸上谈兵很容易,实际做的时候有三个细节特别值得注意。
第一个细节是压缩时要保留工具调用之间的关系。我第一版压缩把每一步的result都替换成摘要,结果发现后面的计划调整变得很瞎——模型只看到"查询订单成功"却看不到具体的订单号,接下来想查物流却没有参数可用了。后来我在压缩时把关键参数(订单号、用户ID、时间范围)单独提取出来放进keep_fields,摘要里只压缩描述性文字,不碰参数。
第二个细节是摘要本身也要算token成本。压缩20条历史记录,每条摘要50 token,最后也有1000 token,这个开销如果发生在任务中途,反而加剧了上下文压力。我优化成只压缩最早的N条,近期的几条保持原始结果,因为Agent执行时近期信息的重要程度远高于早期信息。这其实就是一种局部窗口策略,不需要全部摘要化。
第三个细节是把压缩动作本身记录进历史。压缩完我会往history里追加一条{"system_note": "对早期未压缩历史做了摘要,累计节省2000 token"}。作用有两个:一是后续的LLM调用能感知到自己的记忆被压缩过,这能减少它产生"我刚才明明见过完整数据现在怎么没了"的困惑;二是排查问题时能清楚地知道上下文是在哪一轮发生变化的,不会对着日志一脸茫然。
6. 再往前走一步:路由经验沉淀与多Agent场景
6.1 把路由决策记录改造成"经验路由"
跑了一段时间后,你手头会积累大量路由日志:用户请求、路由结果、最终满意度(或者任务是否成功)。这些数据躺着不用太可惜,我做了个简单的经验路由机制——把"哪些请求最终被哪个Agent成功解决"的模式回写给路由描述。
具体做法是,每天凌晨对成功案例做一次聚类,找出高频的"用户表述模式-成功Agent"对应关系,把高频句式和同义词扩充进对应路由目标的emb_text里。比如我做过一个财税项目,用户喜欢说"报税""申报""汇算清缴",第一次跑的时候embedding匹配得分都不高,聚类跑了两周后,这些词全部进入了tax_agent的emb_text,路由准确率肉眼可见地涨了一截。
这不算什么高级的在线学习,但胜在闭环:路由决策影响任务结果,任务结果又反过来优化路由决策的依据。比手动维护规则省心得多。
6.2 多Agent协作下,路由升级为协议协商
如果系统里不止一个Agent,而是有多个各司其职的Agent(数据分析Agent、文档处理Agent、对话交互Agent等),路由就从"选一条路"变成了"多个Agent之间怎么达成协作共识"。
我的做法是把路由层升级为一个协调者,它不直接调用工具,而是先做一次"任务拆解-分配-汇总"的协商:
def coordinator(task, agent_list): # 先把任务拆成多个子任务 subtasks = decomposer.split(task) assignments = [] for st in subtasks: route_result = router.route(st) assignments.append({ "agent": route_result["route"], "subtask": st, "confidence": route_result["confidence"], }) # 执行时允许Agent之间互相调用结果 shared_state = SharedMemory() for assign in assignments: result = agent_list[assign["agent"]].execute( assign["subtask"], shared_state ) shared_state.set(assign["subtask"]["key"], result) return synthesizer.merge(shared_state, task)这里的核心区别是,每个子任务都独立走一次动态路由,而不是整个任务只选一个Agent。因为多Agent场景里,任务几乎都是混合型的,"写代码顺便查个数据"这种需求太常见了,单一路由显然覆盖不了。共享状态(SharedMemory)则是让拆开的子任务最后能拼接回同一个完整答案。
6.3 调试多Agent路由时最该盯的指标
最后聊个实在的。多Agent场景下我调试时只看四个指标,比什么可视化面板都有用:
| 指标 | 定义 | 我常用的健康线 |
|---|---|---|
| 路由准确率 | 人工抽样判断路由是否合理 | >= 90% |
| 重路由率 | 执行中发生路径改变的占比 | 10%-30%,过低说明编排太死板,过高说明路由不稳定 |
| 死循环率 | 同一工具连续失败3次以上的任务占比 | < 2% |
| 上下文压缩率 | 触发过压缩的任务占比 | 30%-60%,长期不压缩说明可能没处理大结果集 |
重路由率这个指标尤其值得玩味。如果你的系统重路由率永远接近0,大概率不是路由准,而是编排器根本没有根据结果调整路径的能力;如果重路由率超过30%,说明路由的目标粒度过小,频繁在不同路由目标之间跳来跳去,反而浪费上下文和延迟。我一般保持在15%-25%区间,这个区间下用户体感和成本都比较平衡。
写到这里,动态路由和自适应编排的核心框架基本讲完了。回头看我自己从"一个模型选一个工具"走到"三层路由+四旋钮编排",最大的体会是:Agent的智能不完全来自模型本身,很多时候来自系统给模型留了多少"纠错的余地"。路由和编排做的事情,本质上就是给不确定性兜底——让模型选错的时候能发现,走弯路的时候能绕回来,记不住的时候能靠摘要把关键信息留住。这些机制看着不起眼,但真正决定了一个Agent能不能从Demo走向能干活的生产系统。我后续会把记忆模块和路由日志如何联动做进一步细化,到时候再单独开一篇聊。