你有没有遇到过这种状况:在LangChain4j里写第一个智能体的时候,觉得一切都很简单,无非是把Prompt塞给模型,再把返回结果拼回去。等业务真正跑起来,才发现根本不是那么回事——用户说“我要补差价”和“你们发错货了”,后端要走的流程完全不一样,但消息链路却全混在同一条处理函数里做着if else判断,业务方不满意,维护成本也水涨船高。这时候,条件链和路由链就是绕不开的功课。
这篇文章我从实际项目的角度,把LangChain4j里做智能流程控制的思路拆开讲清楚。适合两种人看:一种是刚把LangChain4j跑通、正在做真实业务集成的开发者,另一种是已经在用但被“链路越写越乱”困扰的团队。内容不涉及很深的理论,全部来自我落地多轮对话系统时的真实取舍。看完你至少能知道自己该在什么时候用条件判断,什么时候用路由分发,以及怎么写一个不会把自己绕晕的分支链路。
1. 为什么流程控制是LangChain4j项目绕不开的坎
1.1 顺序链路的局限:一个函数处理所有请求
很多刚接触LangChain4j的人,第一版代码往往是这样的:定义一个大而全的系统Prompt,把所有业务规则、对话风格、输出要求全塞进去,然后所有用户消息都走同一个入口,同一个模型,同一个处理逻辑。
这在Demo阶段完全没问题,因为Demo只验证“模型能不能回答”。但真实业务里,用户意图之间的差异是巨大的。举个我实际见过的例子:有一个智能客服机器人,用户说“我想查一下订单怎么还没到”,和用户说“我要投诉快递员态度差”,这两句话背后对应的动作完全不同——前者需要查询订单系统、展示物流轨迹;后者需要创建工单、流转到客服主管。如果上面这两种请求都交给同一个“通用对话函数”,模型就会陷入两难:它既要当查询器,又要当投诉处理器,还要保持热情的语气。结果往往是两边都没做好,订单查错了,投诉也记漏了。
这就是顺序链路的根本局限:它只适合“所有请求都做同一件事”的场景,一旦业务出现分支,就必须引入控制逻辑。
1.2 条件链与路由链各自解决什么问题
条件链和路由链经常被放在一起说,但它们的职责其实不一样。
条件链解决的是“到底该走哪条路”的判断问题。它强调的是链路上的判断节点,在某个位置停下来问一句:当前消息符合条件A,还是条件B?这个判断可能在代码里完成,也可能交给模型完成,关键点在于判断本身。
路由链解决的是“判断完之后,怎么把请求送到对应处理模块”的分发问题。它像一个交通枢纽,拿到分类结果后,把请求转发给售后处理链、技术支持链、或者转人工的兜底链。
你可以这样理解:条件链是路口的那块指示牌,路由链是根据指示牌选择路线的车辆调度系统。两者配合起来,才是完整的智能流程控制。只做条件判断不做路由分发,代码就变成一堆散落的if else;只做路由不做条件判断,路由就缺少依据,无法落子。
1.3 适合上条件链和路由链的典型场景
流程控制不是所有项目都需要,但我建议你在以下几种情况出现时尽早引入:
- 智能客服:用户消息天然分售前、售后、投诉、退换货等多个方向,每个方向需要的Prompt和数据接口完全不同。
- 工单系统:收到一条工单请求后,先判断归属分类,再分派给对应处理链路,还要识别紧急程度。
- 审批流程:申请报销、请假、设备领用,每类申请需要不同审核策略。
- 内容生成平台:用户请求写文案、写代码、做翻译、做总结,每种任务用不同Prompt模板,甚至不同模型参数。
- 数据分析类Agent:用户问“昨天的销售额”“近三个月流失率”“帮我预测下周库存”,这三类问题背后的数据操作逻辑天差地别。
这些场景有一个共同点:用户的输入自然分成多个类别,每个类别有自己专属的处理链。只要出现这种多分类需求,你就需要条件链和路由链,而不是继续堆Prompt。
2. 条件链的设计:判断逻辑到底放代码还是放模型
2.1 先分清两类条件判断
设计条件链的第一步,不是写代码,而是想清楚你面对的条件是什么类型。
第一类是可枚举的规则型条件。比如“消息里包含退货关键词”“用户ID属于VIP名单”“订单状态为已发货”。这一类条件可以通过字符串匹配、正则表达式、或者简单的数据查询完成,稳定、快速、零成本。我在一个项目里处理过退款相关请求,当时用纯规则匹配,准确率做到了百分之百,因为用户申请退款时平台会提供标准操作入口,消息几乎不会偏离几个固定模板。
第二类是不可枚举的语义型条件。比如“用户这句话是在表达不满,还是在正常咨询”“这条消息属于技术问题还是账单问题”“用户意图是否包含紧急求助”。这类条件没法用关键词覆盖,因为同一句话在不同语境下意思可能完全不同。比如“这个功能怎么用”是咨询,但“这个功能怎么这么难用”就是抱怨。关键词层面你根本写不完所有变体。
2.2 代码侧判断:适合锚点清晰的场景
代码侧判断最直接的做法就是写一个分类函数,接收用户输入,返回一个枚举值。以售后场景为例,可能是这样:
enum Intent { AFTER_SALE, TECHNICAL, COMPLAINT, OTHER } Intent classifyByRules(String userMessage) { if (userMessage.contains("退货") || userMessage.contains("退款")) { return Intent.AFTER_SALE; } if (userMessage.contains("报错") || userMessage.contains("怎么配置")) { return Intent.TECHNICAL; } if (userMessage.contains("投诉") || userMessage.contains("差评")) { return Intent.COMPLAINT; } return Intent.OTHER; }这段代码足够简单,任何Java开发者都能维护。它的优点是执行结果可预期、不需要网络调用、没有token成本。缺点也很明显:你必须在写代码时把所有可能的锚点词都列出来,而语言的变体永远比你的词表丰富。
所以我的经验是:规则判断只适合做“闸门”,不适合做“全部”。比如先用规则拦截那些意图极其明确的请求,对剩余请求一律交给模型判断。两段式分级处理,既省钱又稳当。
2.3 模型侧判断:把它当成结构化抽取任务
模型侧判断的正确姿势,不是让模型写一段“分析结果”的自然语言,而是让模型返回一个结构化的枚举值。这一点很多人栽过跟头。
我用LangChain4j的AiServices接口来实现这个能力。先定义一个路由决策对象:
public class RouteDecision { private Intent intent; private String reason; public Intent getIntent() { return intent; } public void setIntent(Intent intent) { this.intent = intent; } public String getReason() { return reason; } public void setReason(String reason) { this.reason = reason; } }然后定义一个AiService方法,让模型输出这个对象:
interface IntentAnalyzer { @SystemMessage("你是消息分类器。根据用户消息判断意图,仅返回如下枚举之一:" + "AFTER_SALE表示售后退换货,TECHNICAL表示技术问题," + "COMPLAINT表示投诉不满,OTHER表示其他。") RouteDecision classify(String userMessage); }调用方式极其简单:
IntentAnalyzer analyzer = AiServices.builder(IntentAnalyzer.class) .chatModel(chatModel) .build(); RouteDecision decision = analyzer.classify(userMessage); switch (decision.getIntent()) { case AFTER_SALE -> afterSaleHandler.handle(userMessage); case TECHNICAL -> technicalHandler.handle(userMessage); case COMPLAINT -> complaintHandler.handle(userMessage); default -> fallbackHandler.handle(userMessage); }这里最关键的认知是:这段代码里的模型判断本质上是“一次文本到枚举的映射任务”,不是一个“对话任务”。所以系统Prompt里不要写“你好,我是一个智能客服,乐意为你服务”这种废话,而是直接告诉模型有几类结果,每类结果对应的标准是什么。
2.4 判断边界条件的参数设定
模型侧分类看起来稳,但一定要做好边界控制。我在项目里总结出几个关键设置:
- 模型温度调到0或接近0。分类任务不需要创造性,温度越高,枚举值就越容易漂移。
- 兜底枚举必须有。即使你是三分类场景,也要加一个OTHER,否则模型会在不确定时硬凑一个类别,把售后问题当成技术问题处理,比不处理后患更大。
- reason字段建议保留。它不参与路由,但排查问题时非常好用。你可以看到模型为什么把这句话分到了这个类别,定位是Prompt不清晰还是模型本身出了问题。
| 判断方式 | 准确率 | 成本 | 延迟 | 适用场景 |
|---|---|---|---|---|
| 规则匹配 | 高(锚点覆盖) | 零 | 最快 | 关键词明确、模板固定 |
| 模型结构化输出 | 高(语义泛化) | token成本 | 慢 | 语义复杂、变体多 |
| 模型自然语言判断 | 低(解析困难) | token成本 | 慢 | 不推荐用于路由 |
3. 路由链的核心细节:三种常见方案对比
路由链的本质是把判断结果转化为实际的分发动作。判断方式定下来之后,分发方式也是关键决策点。我把实践中用过的方案分成了三类,你可以按业务特性对号入座。
3.1 方案一:结构化输出路由(LLM Router)
结构化输出路由就是第2.3节展示的方式:模型先输出一个枚举,Java代码再做switch分发。这是最推荐的路由方式,适合大多数场景。
它最大的优点是把“智能判断”和“流程执行”干净地分开了。模型只负责它擅长的事——理解语义并归类;代码只负责它擅长的事——根据明确的结果执行分支逻辑。你不需要让模型控制执行过程,也就不容易产生失控调用。
落地这个方案有一个小技巧:路由方法不要只接收用户输入,最好把一些标准字段也传进去。比如工单路由方法可以这样设计:
public RouteDecision classify( @V("userMessage") String message, @V("channel") String channel, @V("userId") String userId ) { // 模型内部会结合渠道、用户身份综合判断意图 }渠道很关键。同样一句“我东西坏了”,在售后工单里和在技术支持群里,语义指向完全不一样。把这类信息显式传给模型,比让模型猜上下文靠谱得多。
3.2 方案二:工具调用路由(Tool Router)
LangChain4j里还有一个经典的路由思路:不显式做分类,而是把每个分支处理逻辑封装成一个工具函数,告诉模型“你有这些工具可以用”,然后让模型自己决定调用哪个工具。
举个例子,假设你有一个订单助手,有查询订单、申请退款、转人工三个分支:
public class OrderTools { @Tool("查询订单最新物流状态") public String queryOrder(String orderId) { return orderService.query(orderId); } @Tool("为订单提交退款申请") public String applyRefund(String orderId) { return refundService.submit(orderId); } @Tool("将用户转接给人工客服,当用户情绪激动或问题无法解决时使用") public String transferToHuman(String reason) { return humanHandoverService.transfer(reason); } }模型在收到“我的货到哪了”时,会自己选择调用queryOrder;收到“你们赶紧给我退款”时,会调用applyRefund;收到“我要投诉你们,赶紧让负责人来”时,可能调用transferToHuman。
这个方案的体验非常“自然”,模型像是自己长了手,能直接操作业务接口。但它也有明显的坑:你无法精确控制模型的调用序列,它可能会把工具组合调用,也可能在你意想不到的时候调用某个工具。我曾经遇到过模型在用户只是想问退货政策时,直接提交了退款申请的情况,事后排查发现是Prompt里对工具边界描述不够清晰。
所以工具路由更适合“工具本身就是最终动作”的场景,比如查询、执行、提交这类操作;而“先分类,再走不同处理链”的场景,还是结构化枚举路由更可控。
3.3 方案三:规则路由(Rule-based Router)
规则路由不涉及模型,纯用代码逻辑做转发。它的存在价值不是替代模型路由,而是做“预分流”和“兜底拦截”。
我在线上系统里做了一套两阶段路由,效果一直不错。第一阶段跑规则路由,专门识别那些特征极度明显的请求。比如消息里包含订单号正则表达式,就直接按订单处理链走;包含“转人工”三个字,直接接人工通道。第二阶段才把剩余不确定的请求交给模型路由。
这套组合拳的关键收益是省掉了大量不必要的模型调用成本。业务里可能有接近半数的请求都是重复性模板话术,根本不需要模型参与分类。同时规则路由给了一个安全阀:一旦模型服务超时、报错或者返回异常枚举,规则兜底能保证请求不会卡死在链路上。
3.4 路由方案选型:一张表说清边界
| 方案 | 智能程度 | 可控性 | 实现成本 | 最佳适用场景 |
|---|---|---|---|---|
| 结构化输出路由 | 高 | 中高 | 中 | 多意图分类、工单分流 |
| 工具调用路由 | 高 | 中 | 中高 | 工具即动作、执行型任务 |
| 规则路由 | 低 | 极高 | 低 | 预分流、兜底、模板识别 |
我的建议很简单:能靠规则解决的先用规则,规则解决不了的上结构化枚举路由,只有确定分支本身就是操作动作时,才考虑工具路由。很多人一上来就追求“让模型自己做决策”,反倒把系统搞得不稳定。
4. 实战:工单处理机器人从路由到分支的完整落地
前面讲的方法论,现在用一个接近真实业务的实战串一遍。场景是某公司的内部工单机器人,员工提交一条工单消息,机器人自动判断工单类型,分发给售后处理链、技术支持链或者转人工。
4.1 场景设定与依赖准备
工单分三类:售后类(报销、退换、赔偿),技术类(系统报错、账号问题、权限申请),以及无法归类的转人工兜底。技术栈基于LangChain4j,模型用一个兼容OpenAI SDK风格的服务,你在本地换成自己的模型服务即可。
项目依赖只需要核心包加一个HTTP客户端模型实现,我用的是Maven方式:
<dependency> <groupId>org.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.36.x</version> </dependency>具体的模型SDK依赖我这边不写死,因为不同的模型服务商接入方式不一样,但LangChain4j在这一点做得很好:只要服务接口兼容,切换模型只改配置,不动业务代码。这是后面所有流程能复用的基础。
4.2 定义工单类型与路由结果
工单类型用枚举定义,同时给了一个UNKNOWN兜底,这是经验之谈。一开始我只定义了三个类型,结果模型在遇到模棱两可的请求时,非要硬选一个,导致错分率偏高。加上UNKNOWN之后,模型不确定时会老实承认不确定,系统可以通过兜底逻辑转人工,准确率反而上去了。
public enum TicketType { AFTER_SALE, TECHNICAL, HUMAN_TRANSFER, UNKNOWN }路由结果对象包含类型和理由。理由这段看起来像是多余的,但在排障时特别关键。我经历过一次线上事故,大批工单被错误分到HUMAN_TRANSFER,核心原因就是系统Prompt里对“投诉语气”的描述用了“情绪激动或表达不满”这种模糊词,导致正常咨询也被转人工。后来加了reason字段,十分钟就定位到了问题。
public class TicketRouteResult { private TicketType type; private String reason; private String rawInput; }4.3 路由判断方法的Prompt设计
路由Prompt是整套链路里最容易被低估的部分。我见过很多人直接写“请根据工单内容判断类型”,然后期待模型奇迹般地返回正确枚举。正确做法要具体得多。
我给路由方法设计了这样的系统消息:
你是一个工单分类器。根据用户提交的工单内容,将其分类为以下类型之一: AFTER_SALE:涉及报销、退换货、赔偿、发票等问题; TECHNICAL:涉及系统无法登录、接口报错、功能Bug、权限申请等技术问题; HUMAN_TRANSFER:用户明确要求人工服务,或工单内容包含严重投诉、法律风险、人身攻击等; UNKNOWN:无法确定为以上任何类型。 注意:必须只返回枚举值,不要输出解释性文本。这里有几个细节值得说明。首先,每个枚举值后面都跟了两三个具体例子,而不是只用一句话描述。模型的Few-Shot经验越具体,分类越稳定。其次,HUMAN_TRANSFER不只包含“明确要求人工”,还包含“严重投诉、法律风险”,这是结合业务实际补进去的边界条件,属于必须人工介入的高危工单。最后,强制要求“只返回枚举值”,配合AiServices的结构化输出能力,从根源上避免了解析自然语言响应的问题。
4.4 分支处理链与路由入口实现
定义好路由结果后,写三个分支处理器。先给统一的接口,方便后续维护扩展:
public interface TicketHandler { String handle(TicketContext context); } public class AfterSaleHandler implements TicketHandler { @Override public String handle(TicketContext context) { return "【售后处理】正在为您处理报销/退换流程,预计1个工作日内审核完成。"; } } public class TechnicalHandler implements TicketHandler { @Override public String handle(TicketContext context) { return "【技术支持】已收集您的错误信息,请提供账号ID,我们将进一步排查。"; } } public class HumanTransferHandler implements TicketHandler { @Override public String handle(TicketContext context) { return "【人工通道】已为您转接人工客服,工单优先级标记为高,请留意通知。"; } }实际业务里,每个handler内部会调用不同的Service、不同的Prompt模板,甚至不同的模型实例。比如售后处理链里调用的是库存赔付服务,技术支持链里调用的是日志查询服务,它们不应该耦合在同一个方法里。用handler隔离是正确的长期结构。
接下来把路由和分支组装起来。入口方法接收原始工单消息,先过规则预分流,再走模型路由,最后分发给对应handler:
public String handleTicket(String userMessage, String userId) { // 预分流:规则能确定就不调用模型 if (userMessage.contains("转人工")) { return new HumanTransferHandler().handle(null); } // 模型路由 TicketRouter router = AiServices.builder(TicketRouter.class) .chatModel(chatModel) .build(); TicketRouteResult result = router.route( userMessage, userId, "内部工单系统,用户问题可能涉及报销、技术、投诉等" ); TicketHandler handler = switch (result.getType()) { case AFTER_SALE -> new AfterSaleHandler(); case TECHNICAL -> new TechnicalHandler(); case HUMAN_TRANSFER -> new HumanTransferHandler(); case UNKNOWN -> new HumanTransferHandler(); // 兜底转人工 }; return handler.handle(new TicketContext(userMessage, userId, result)); }如果读者对这段switch的写法不熟悉,可能版本语法会比较新,你本地的Java版本如果低于17,改成传统的if-else或case接变量也是完全一样的效果。
4.5 组装链路后的预期行为
走完这条链路后,系统呈现出来的行为是这样的:员工提交“我上周的报销还没到账”,预分流规则没命中,模型路由输出AFTER_SALE,售后handler接管;员工提交“登录系统提示504错误”,模型路由输出TECHNICAL,技术支持handler接管;员工提交“把你们领导电话给我,我要投诉”,预分流没命中,模型路由输出HUMAN_TRANSFER,人工通道接管。
整个过程外部看起来就是一个智能工单机器人,内部其实是两层判断加一层分发的组合拳。这种架构的好处是每一层都足够简单,出了故障也能快速定位到具体环节。
顺带强调一点:这里没有把模型调用和业务处理混在同一个大函数里,而是通过“接口定义路由方法、接口隔离分支处理链”的方式组织代码。后续新增一个工单类型,只需要加枚举值、加handler实现类、在switch里加一个分支,对现有代码的侵入极小。这才是流程控制的工程价值。
5. 实测中的常见问题与排查心得
5.1 模型路由结果不稳定,屡屡出现错误枚举
现象:同一类工单,有时候返回AFTER_SALE,有时候返回TECHNICAL,没有规律。排查后发现大多不是模型问题,而是Prompt分类边界太模糊。
我的处理模板是:每个枚举值的描述都改成“核心词触发”加“典型场景”双保险。比如AFTER_SALE不要只写“售后问题”,要展开成“退款、退货、换货、报销、发票、补偿”。模型对这些具象名词的判断远比对抽象概念的判断更准。
另一个有效手段是压缩类别数量。有一次一个分类器有八个枚举值,准确率始终在85%徘徊,砍掉两个低频类别后,准确率直接回到93%。类别越多,模型越容易混淆。
5.2 路由后分支处理链路丢失了原始上下文
现象:工单在路由环节分类正确,但进入售后处理链后,模型忘了用户在前一轮说过什么。这是一个很典型的LangChain4j使用误区——路由方法的调用和分支处理链的调用,是两个完全独立的ChatMemory,前者记录的信息不会自动传递给后者。
解决办法有两种。轻量做法是在路由结果对象里携带原始输入,分发handler时把rawInput一并传给分支模型,作为新的SystemMessage前置内容。重量做法是把路由结果和原始历史消息统一存入同一个Memory对象里,分支链复用同一个Memory。
TicketContext context = new TicketContext(); context.setHistory(chatMemory.getMessages()); context.setRouteResult(result);无论哪种做法,核心原则都是一样的:路由只在入口做一次判断,判断完成之后,所有对话上下文必须在同一个地方延续,不能在多个Memory之间切来切去。
5.3 路由链出现循环调用导致请求卡死
现象:系统偶尔出现一个请求在路由和分支之间反复跳转,直到超时报错。
这类问题的根源常常是分支处理链里再次调用了路由逻辑。比如技术handler内部处理失败后,把原消息重新提交给了路由入口,再次进入技术分支,再次失败,形成死循环。排查方法也很简单,在路由入口加了一个调用次数的上下文标记,超过两次直接强制转人工。
if (context.getRouteCount() >= 2) { return new HumanTransferHandler().handle(context); }这算是个不成文的防御性编程技巧,但实际救过我不少次。
5.4 Prompt过长导致路由准确率反而下降
另一个反直觉的发现是:把路由Prompt写得很长很详细,分类准确率不一定提升,有时候还下降。原因是模型在超长上下文中容易“注意力散焦”,对关键枚举的描述反而被淹没在一堆背景信息里。后来我把系统Prompt压缩到只保留分类枚举和每个枚举的两三个具体例子,把业务背景信息挪到用户消息的前缀里,准确率恢复了正常。
具体做法是:业务场景说明不要堆在系统消息里,而是作为用户消息的一部分传给模型,让模型先看到场景,再看消息内容,最后输出枚举。这个顺序对结果有明显影响。
5.5 问题排查速查表
| 故障现象 | 常见原因 | 排查思路 |
|---|---|---|
| 返回非法枚举 | Prompt边界模糊 | 检查枚举描述是否有具体例证 |
| 分类准确率低 | 类别过多 | 合并低频类别,控制在4个以内 |
| 分支丢失上下文 | Memory没有共享 | 检查路由与分支是否共用ChatMemory |
| 调用链死循环 | 分支重复触发路由 | 增加路由计数上限 |
| 长Prompt效果变差 | 注意力分散 | 精简SystemMessage,场景描述放用户侧 |
6. 写在最后:流程控制背后的工程思路
我做了好几个LangChain4j项目之后,一个越来越深的体会是:这类框架给你的不是一个现成的流程图,而是一堆积木。流程控制这件事,本质上是在回答一个工程问题——哪些判断应该交给确定性代码,哪些判断应该交给概率性模型,以及这两者之间怎么协作。
我个人现在做任何新链路,都会先画一遍“哪些分支是硬规则、哪些分支是软语义”,再决定哪一段用条件链、哪一段用路由链。如果你在动手写代码之前,能把你业务里所有可能的输入分流到最多五六个出口,这个系统的稳定性就已经超过绝大多数同类项目了。
最后分享一个小技巧:无论你的路由做得多聪明,永远保留一个“宁可转人工,也不要乱表态”的兜底原则。这个原则听起来不酷,但在线上系统里,它比任何花哨的模型路由都重要。一个工单分类错误可能只是一次体验问题,但一个售后请求被错误路由成技术处理,浪费的就是真金白银和用户信任。