周一早上打开工单群看到客服同事圈了我一句这个机器人又开始听不懂人话了用户问我的订单为什么还没发货快递到哪了它只回了订单状态物流那半句直接装没看见。我一眼就猜到是路由的锅。这个Agent系统的路由是一张写死的if-else链先判断有没有订单关键词再判断有没有物流关键词一碰撞就永远走先命中的分支另一半信息被静默吞掉。这套用了快一年的稳如老狗的代码最近一个月已经在工单里被挂了五六次。硬编码路由在Agent系统里是个绕不开的原罪简单、可控、不花钱但它不理解语言只认关键词。从入行到现在我看过太多次给关键词路由打补丁的抢救现场补丁越堆越多翻车却越来越频繁。这次我决定换个思路——把选择权交给模型让大模型自己判断某个用户请求进哪个Agent、调哪个工具、传什么参数。也就是标题里说的把写死在代码里的Agent路由改成模型自主决策。如果你现在也背着一条越改越重的规则路由或者正打算给Agent加一个更智能的分诊层这篇文章值得你看完。我会把整体方案、核心代码、翻车案例和实测数据全部拆开讲包括那些我不希望你再踩一遍的坑。1. 关键词路由的第三年我决定不再给它补if-else了1.1 Agent里的路由到底在干什么先对齐一下概念。一个稍微成形一点的Agent系统绝对不会只有一个大模型在裸奔。它背后通常有一堆子模块订单助手、物流助手、售后助手、营销问答、转人工每个模块负责一类业务。用户消息进来之后系统要决定这个问题该交给谁处理有时候还要顺带决定要不要先调一个工具再回答。这个分诊台就是Agent路由。我早期的实现非常粗暴。一个函数一堆if-else靠关键词和正则把消息分到不同分支def route(message: str) - str: if 订单 in message or 订单号 in message: return get_order_status if 物流 in message or 快递 in message: return get_logistics_info if 售后 in message or 退 in message: return create_after_sale_order if 天气 in message: return get_weather return talk_to_human当时这么写的原因很实在可控、可测、零token成本、延迟不到5毫秒出问题一行日志就能定位。在一两个业务域、几十个关键词的规模下这套方案就是最优解没什么好羞耻的。但系统跑了三年业务域从2个涨到7个关键词从几十个涨到几百个问题开始集中爆发。1.2 三个线上翻车案例暴露的是同一种病案例一多意图截断。用户问我的订单为什么还没发货快递到哪了这句话里有订单也有快递。按if顺序走订单先命中系统只查了订单状态物流那部分需求直接丢了用户自然觉得机器人在装傻。这个case被客服同事在工单里点名了好几次但也无可厚非——关键词路由天然是单选多意图对它来说就是死穴。案例二同义表达漏判。货都寄出来了没东西上路了多久什么时候能收到这三个说法一个关键词都匹配不上物流分支全部掉进兜底逻辑最后转人工。表面看是关键词覆盖不全本质上是快递物流运输到货这些词和用户口语之间的距离靠枚举是补不完的。案例三参数抽取太死。用户说帮我看看上周买的那个手机发货了没写死的取参逻辑要找订单号但这句话里根本没有订单号命中订单查询工具后因为缺参直接失败。正确做法应该是反问用户要订单号或者通过用户身份去查最近一笔订单但硬编码路由完全没有追问和推理的意识。这三个案例放在一起看问题其实只有一个关键词无法建模真实语言的多样性。每上一个新关键词就会撞上新的表达方式补丁永远打不完。到这个时候继续堆规则已经是在还技术债而不是在做产品。2. 改造思路工具注册表 决策器把路由变成一次LLM调用2.1 为什么不直接放养一个完全自由的Agent聊方案之前先说一个朋友问过我的问题你为什么不干脆做一个全自主Agent让它自己规划、自己调用工具、自己停止我得承认完全放开确实更性感。让模型自主决定调用哪个工具、自己写计划、自己决定下一步这是很多人对Agent的终极想象。但放在生产环境的客服场景里完全放开有两个要命的问题一是不可控你根本不知道它会编出什么离谱的多步计划二是贵且慢每一轮思考都要调用一次模型用户问一句快递到哪了底层可能已经跑了好几轮循环。所以我选了另一条路把路由层单独拎出来让它只做一次决策而且是一次约束式的决策。模型要做的不是自由发挥而是从一张固定的工具清单里选一个或几个工具输出结构化参数仅此而已。决策做完具体的执行仍然交给原来的老模块。用大白话说就是路由只负责选路不负责开车。这种约束式自主决策的好处是两边的好处都要了——语言理解能力交给模型业务执行能力留在可控的老系统里。上线之后就算模型抽风影响的也只是路由这一步不会让整个Agent行为彻底失控。2.2 工具注册表每个分支变成一条带schema的声明整个改造的第一步不是写决策代码而是把原来藏在if-else里的分支能力登记造册。我管这个叫工具注册表形式上就是一个结构清晰的列表每个元素描述一个工具它叫什么、什么时候该用、它接受哪些参数。TOOLS [ { name: get_order_status, description: 查询订单状态。当用户询问订单是否发货、订单当前状态、订单申请进度时使用。需要提供订单号用户未提供订单号时必须继续追问不要编造订单号。如果用户同时询问物流运输进度请交给get_logistics_info处理。, parameters: { type: object, properties: { order_id: {type: string, description: 订单号形如JD开头加10位数字} }, required: [order_id] } }, { name: get_logistics_info, description: 查询物流运输进度。当用户询问快递运输到哪、多久能到、包裹是否已发出时使用。需要订单号或物流单号。查询订单状态请交给get_order_status。, parameters: { type: object, properties: { order_id: {type: string, description: 订单号}, tracking_number: {type: string, description: 物流单号可选} }, required: [order_id] } }, # ... 其他工具 ]为什么要把分支声明成这种结构因为它是模型和旧系统之间的契约。对模型来说一份结构化的工具清单就是它决策的全部依据对旧系统来说每个工具name对应的还是原来那个handler函数不用改内部逻辑。工具注册表建好后任何新业务分支都变成新增一条声明不再需要改路由判断逻辑。2.3 决策层和执行层解耦路由只选路不开车改造后的数据流大概是这样的用户消息进请求入口先被送到决策器决策器返回一个RouteDecision对象里面包含工具名、参数、置信度。接着调度器根据这个对象找到对应的handler执行完把结果交给后续的回复生成模块。关键点在于决策器不认识任何业务handler它只知道工具清单handler也不知道自己是被模型选中的还是被规则选中的它只负责按照参数干活。这个解耦非常值钱因为模型误判的时候我要改的是工具描述或者prompt跟执行逻辑一点关系都没有。反过来某个工具升级了参数也不用动决策器。3. 决策器实现细节Prompt、结构化输出、校验缺一不可3.1 工具描述文本才是真正的路由规则改造成型后我才发现硬编码时代的路由规则是代码里的if-else模型决策时代的路由规则其实变成了工具列表里的description文本。这部分写得好不好直接决定模型会不会选错工具。一开始我写得也很随意类似查询订单状态结果模型把所有沾边的请求都往这个工具里塞。后来我总结出一套写法每条description必须回答四个问题什么时候用、什么时候不用、参数从哪来、跟相似工具怎么区分。拿get_logistics_info举例我最终版的description是查询物流运输进度。当用户询问快递运输到哪、包裹是否已发出、多久能送达时使用。需要订单号或物流单号。如果用户只是询问订单是否发货成功请使用get_order_status如果用户要申请退款或退货请使用create_after_sale_order。这段话里正向触发条件告诉模型什么情况选它负向排除告诉模型什么情况不要选它。后者尤其关键它相当于把原来if-else里的优先级判断翻译成了模型能理解的语义约束。3.2 用function calling拿到结构化决策结果工具清单准备好之后决策器的核心逻辑就很薄了。当时我用的模型支持function calling所以最省事的方案是让模型把路由决策当成一次工具调用来输出ROUTE_SYSTEM_PROMPT 你是一个任务路由器。你的职责是根据用户消息从工具列表中选择一个最合适的工具并输出符合工具参数要求的JSON对象。 硬性约束 1. 只能使用下方工具列表中的工具禁止编造工具名。 2. 用户未提供必要参数时不要编造参数可以为空交由后续追问逻辑处理。 3. 如果用户消息包含多个独立意图可以依次选择多个工具。 4. 所有工具都不适合时选择 talk_to_human。 def decide(user_message: str) - RouteDecision: resp client.chat.completions.create( modelROUTE_MODEL, messages[ {role: system, content: ROUTE_SYSTEM_PROMPT}, {role: user, content: user_message} ], toolsTOOLS, tool_choicerequired, # 强制模型必须选工具避免它直接用自然语言作答 temperature0 ) call resp.choices[0].message.tool_calls[0] tool_name call.function.name arguments json.loads(call.function.arguments) return RouteDecision(tool_nametool_name, argumentsarguments)这里tool_choicerequired是很多人容易忽略的细节。不设这个参数模型有时候会好心地用自然语言直接回一句我可以帮您查询订单状态而不是输出标准结构。把tool_choice强制设为required等于告诉模型这轮交互你别说话选工具就完事了。另外temperature固定为0因为路由决策是确定性任务不需要任何创造性。3.3 模型不支持function calling时的JSON模式兜底不是所有场景都有function calling可用。内部某个版本用的模型只支持JSON输出于是我在决策器里留了一套JSON模式兜底def decide_with_json_mode(user_message: str) - RouteDecision: resp client.chat.completions.create( modelROUTE_MODEL, messages[ {role: system, content: ROUTE_SYSTEM_PROMPT 你必须返回一个JSON对象格式为{\tool\: \工具名\, \arguments\: {}}。}, {role: user, content: user_message} ], response_format{type: json_object}, temperature0 ) raw resp.choices[0].message.content # 模型偶尔会用markdown代码块包裹JSON先做一次清洗 cleaned raw.strip().removeprefix(json).removesuffix().strip() parsed json.loads(cleaned) return RouteDecision(tool_nameparsed[tool], argumentsparsed.get(arguments, {}))JSON模式的坑比function calling多一些比如模型可能在JSON里混进注释、可能给出空参数、甚至返回一个数组而不是对象。所以我后来在JSON模式外面包了一层解析清洗函数专门处理这些脏数据。如果你也要走这条路建议把所有解析失败的原始输出落到日志里跑两周就能总结出模型的常见脏法清洗规则会越来越准。3.4 一次决策选择多个工具处理多意图请求前面说到的订单物流多意图问题改造后终于有了干净的解法。我在ROUTE_SYSTEM_PROMPT里明确告诉模型可以依次选择多个工具同时把决策器的返回结构设计成一个数组。def decide_multi(user_message: str) - list[RouteDecision]: # ... 同样的请求逻辑 calls resp.choices[0].message.tool_calls or [] decisions [] for call in calls: tool_name call.function.name arguments json.loads(call.function.arguments or {}) decisions.append(RouteDecision(tool_nametool_name, argumentsarguments)) return decisions执行策略也分了两种情况。如果多个工具之间没有依赖比如查订单状态和查物流进度两者可以并行执行最后再把结果合并成一段回复。如果多个工具之间有依赖比如先确认用户身份再查询订单我就会走顺序编排前一个工具的输出作为后一个工具的输入。对于客服场景绝大多数多意图请求都是并行关系顺序编排用到的场景不多。4. 对模型保持怀疑输出校验、三级兜底与灰度发布4.1 第一道防线先校验它输出的是不是合法JSON模型再强也扛不住token截断、特殊字符转义这些偶发问题。所以决策器返回的任何原始输出都必须过校验关不能直接拿来用。校验分为两层。第一层是格式校验json.loads能过说明基本是合法JSON过不了就触发一次重试。重试不是无脑重发同样的话而是把上一次输出解析失败这个信息拼进system prompt让模型自己修正。实测下来第二次重试的成功率在95%以上所以我只允许试两次第三次还没过就走兜底。def safe_decide(user_message: str) - list[RouteDecision]: for attempt in range(2): try: return decide_multi(user_message) except (json.JSONDecodeError, TypeError) as e: log_failure(user_message, attempt, str(e)) return fallback_route(user_message)有个细节值得提醒重试也不是免费的每一次失败都在烧token和延迟。所以我直接把重试次数上限做成配置项压测阶段调成3稳定后调回2。别让重试机制变成无限循环。4.2 第二道防线业务语义校验拦截参数幻觉格式校验只能保证这是一段合法JSON保证不了这段JSON在业务上说得通。模型编造参数的问题已经碰到过好几次最典型的一次它给get_order_status编了一个根本不存在的订单号123456789011下游查询自然查了个寂寞。后来我加了一套业务校验规则核心就几条tool_name必须在工具注册表里存在否则直接拒绝。必须参数缺失时不补造进入澄清追问流程让Agent反问用户您的订单号是多少。参数格式必须匹配schema约定比如订单号字段是JD10位数字不符合就拦截。涉及时间、金额这类高业务风险参数模型推断值一律标记为待确认不允许静默使用。这套校验说白了就是把模型说得算变成契约说了算。模型可以自由决定选哪个工具但选完之后的参数必须落在预先定义的业务边界里。4.3 第三道防线重试耗尽之后的兜底策略校验和重试都拦不住的时候兜底策略要能接住。我设计了一条三级降级链第一级降级到旧规则路由。灰度期我把旧版if-else路由完整保留了下来模型决策任何环节超过重试次数直接调用旧函数走老逻辑。虽然准确率低一点但至少不会让请求断掉。第二级标记未识别意图。如果旧规则也没命中就返回一个特殊的IntentUnknown标记让Agent回复抱歉我还没完全理解您的意思您是遇到了订单、物流还是售后问题这样既没有假装理解也没有硬编一个答案糊弄用户。第三级转人工。所有兜底都失败或者用户连续两次消息都被判为IntentUnknown直接转人工客服。转人工成本高但总比让用户跟一个听不懂话的机器人死磕强。4.4 灰度发布怎么切日志先行、分期放量这种路由改造属于底层逻辑变更我一开始就不打算一把梭全量切换。整个发布分了三步第一步流量镜像。新旧路由并行跑旧逻辑继续正常执行新模型决策的结果只写日志不参与真实业务。这一步跑了三天攒下几万条对比数据用来算基线准确率。第二步10%灰度。挑一部分单一意图的请求切到模型决策看实际翻车率。这阶段我盯得最紧的是模型选错工具后下游报错、用户反复追问的比例。第三步逐步放量到50%、100%。每一步放量前都要看前一天日志里的fallback_reason分布如果重试率或兜底率突然升高立刻把开关拉回上一档。这套灰度流程看着麻烦但真能救命。模型决策的翻车模式有时候非常诡异比如某个工具的description没写清导致所有带查字的请求都被模型改道。不通过流量镜像拿线上数据这种问题在测试集里根本发现不了。5. 上线两周实测翻车率降了多少延迟和成本涨了多少5.1 两周的对比数据灰度稳定后我统计了两周线上数据抽样方式是每天随机抽100条用户消息由运营同事按这条消息应该进入哪个工具的标准做人工标注再拿标注结果去对比新旧路由的实际输出。指标旧规则路由模型决策路由路由准确率抽样人工标注87.2%96.4%多意图请求完全处理率41.3%88.9%未识别转人工率13.4%4.2%单次决策P95延迟5ms620ms单次决策平均token成本0约0.006元客服周投诉工单量6.81.6翻车率从13%降到3.6%多意图处理率翻倍这些都在预期内。真正需要权衡的是延迟和成本路由决策从5ms飙到620ms这个数字在客服场景还能忍但放在某些实时性要求高的场景就得掂量掂量了。5.2 延迟与成本不是白白付出的怎么压缩成本优化我主要在三个方向做了动作。第一个方向是缓存。路由决策虽然只申请了一次模型调用但同一句用户消息反复出现时决策结果是稳定且可复用的。我把决策结果按用户消息哈希做了一层本地缓存命中率大约12%。别小看这12%对高频问题来说省下的token相当可观。第二个方向是轻量预筛。先用一个更便宜的小模型判断这条消息是不是单一简单意图是就交给小模型直接决策复杂情况才升级到大模型。这个方案把成本又压了40%代价是预处理多了一次额外调用延迟反而没降下来。如果你的瓶颈是成本可以这么玩瓶颈是延迟的话别加这一步。第三个方向是降级开关。当模型服务P95延迟超过2秒或者接口错误率超过5%时路由自动切回旧规则等模型服务恢复后再切回来。这套降级逻辑用配置中心的一个开关控制发布和回滚都很快。说实话这套改造的ROI很难简单用省了多少钱来算。它真正的价值是把用户话没说完就被截断这类体验问题消灭掉了同时大幅降低了转人工率。对于一个每天几万条消息的客服Agent来说这个收益完全值得。6. 复现这套方案前先把这五个坑排掉6.1 工具描述里的反例比正例更管用工具description只写什么时候用模型会过度触发。比如我只写查询订单状态时使用模型会把所有提到订单的消息都往这个工具里塞包括我要退订单这个订单为什么还没物流。后来我在描述里补了一句申请退款/退货请使用create_after_sale_order物流进度请使用get_logistics_info准确率直接从89%提到了94%。给模型划清楚边界比告诉它该干什么更重要。6.2 两个工具描述相似度过高先合并再拆分有一段时间模型经常混淆查询售后进度和申请售后这两个工具。用户说我的退款怎么还没到模型一会儿选这个一会儿选那个完全看心情。后来我把两个工具合并成一个after_sale_manager在参数里加一个action字段枚举值是query和create。从模型的角度看先选工具再选动作变成了选一个工具然后挑一个枚举值决策难度明显下降。这不是什么高深技巧但特别实用。如果你发现模型在两个工具之间反复横跳多半是这两个工具的语义距离太近了把它们合并到同一个工具的action枚举里是最快的解法。6.3 模型会过度理解把不在范围内的操作也塞进来有一次用户说你能帮我骂一下快递公司吗模型居然给get_logistics_info传了一个参数大概是打算顺着用户的话查物流。这种行为非常隐蔽因为它没有报错但完全不符合业务预期。我的对策有两个一是在每条工具的description第一行都写明它不能做什么比如get_logistics_info里注明本工具仅返回物流信息不处理情绪宣泄类请求二是在决策器的system prompt里加了一句全局约束当用户表达不满或要求超出业务范围时优先选择talk_to_human。这两条加进去之后类似的过度理解案例基本绝迹了。6.4 不要对同一条用户消息连续调用两次决策器这个坑我自己踩得比较深。当时的执行流是决策器选工具工具执行失败就报错。失败就报错这一段我一开始写的是直接把原始用户消息重新丢给决策器再选一次。结果就是工具执行失败后模型第二次决策可能因为上下文不同而选了另一个工具数据和状态全乱了。正确做法是一次用户请求只生成一次RouteDecision。如果执行失败需要重新决策也要把上一次失败的工具名和失败原因拼进prompt让模型知道刚才这个路走不通换一条。无脑重发同一段话只是让模型碰运气不是好的重试策略。6.5 决策日志完了后面想迭代就只能靠瞎猜我在灰度阶段把路由决策日志设计得特别全后面才发现这个决定太值了。每条日志至少包含七个字段用户消息原文、模型版本、完整工具列表、最终决策结果、原始模型输出、延迟和token数、兜底原因。有了这些字段每次出问题都能快速回答三个问题模型选了什么、为什么这么选、花了多少钱。尤其是原始模型输出这个字段千万别省。你可能会觉得我已经存了最终解析结果为什么还要存原始输出但调试的时候你会发现很多问题恰恰出在模型输出了合法JSON但JSON里的内容没有正确解析到最终结果这一步。没有原始输出你就永远复现不了问题源头。这次重构对我来说最大的收获不是给Agent换了一个更聪明的路由而是想明白了一个道理路由的职责不是替用户做决定而是把决定背后需要的上下文准确传递下去。模型自主决策不是银弹但理解用户到底在问什么这件事LLM确实比if-else强太多。如果让我重来一次我可能会在一开始就给路由预留好模型接管的接口把规则路由当降级方案而不是主方案。另外动手写决策器之前先把兜底方案设计清楚——不然模型一开飞车你连拉方向盘的地方都没有。