
做过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: [ rselect\s|insert\s|update\s|delete\s, r数据库|查询.*表|表结构|统计.*数据|订单.*金额, ], web_agent: [ rhttps?://|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, threshold0.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和PostgreSQLembedding匹配时发现用户问上周销量和这段描述的相似度反而不如直接写处理数据库查询和数据分析类任务高。原因是用户不会按工具功能去表述需求只会按任务目的去表述。阈值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_textschema_text, queryquery, history_tailhistory[-1200:], ) resp llm_call(prompt, json_modeTrue) 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_threshold0.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: semanticllm_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_budget4000, max_rounds8): 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_tailself.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 (sourcerule, conf0.95) - 规则命中查询 [exec] step1: 查询订单状态 - 已发货 [replan] 检测到已发货切换剩余计划 - 查询物流 [exec] step2: 查询物流轨迹 - 上海市浦东新区 [compress] 历史token 4232 4000触发摘要压缩 [final] 输出: 您的包裹已从上海发出当前位于浦东转运中心注意[replan]那行这就是自适应编排生效的痕迹计划顺序不是一开始写死的而是在step1结果出来后才补上的查询物流步骤。如果你的日志里永远没有[replan]和[compress]那说明编排器还没真正自适应起来大概率是调整条件写得过于保守了。5. 实战踩坑记录误判、循环、上下文爆炸5.1 工具名诱惑与描述污染引发的误判路由层最容易出现的一类误判我给起了个名字叫工具名诱惑。就是路由目标的名字本身带了强语义比如某个工具叫data_analyzerLLM看到分析就总往那选哪怕用户说的是帮我分析一下这个合同的风险其实应该走文档解析Agent。解决方法是把路由目标的name改成更功能化的代号比如dwh_agent、doc_agent同时在描述里明确不要因为名字含分析就选我要看任务类型。这个小改动让我在保险行业项目里的路由准确率直接提升了7个百分点。另一种污染来自描述文本写得太全。我曾经为了让路由更准把工具描述写成了大作文包含了所有可能的话术。结果embedding召回时什么请求都能跟它算出不错的相似度反而把真正的区分度磨没了。路由描述要克制写清职责边界比写清能力范围更重要。5.2 路由死循环和终止条件的三次迭代自适应编排跑起来以后我遇到的第一个严重问题是死循环。场景是这样的编排器发现计划不对重新规划了一次结果新计划还是指向同一个失败的工具执行又失败又重规划……如此往复直到把token耗尽。第一版我用max_rounds8硬性截断但截断后输出质量很差经常给用户一个我尽力了但没完成的半截答案。第二版改成失败计数同一个工具连续失败两次直接把这个工具从本轮规划的候选里剔除。效果好了很多但引入了新问题——剔除了唯一能解决问题的工具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走向能干活的生产系统。我后续会把记忆模块和路由日志如何联动做进一步细化到时候再单独开一篇聊。