做Java后端的人碰LangChain4j,多半是从一个对话Demo切入的:把用户输入直接丢给大模型,拿到回答再返回前端。这种单次调用的玩法在示例项目里很爽,一旦落到真实业务,你很快就会撞上同一个问题——流程怎么控制。比如用户问“能不能换一台”,这到底该触发售后退货处理,还是让模型自由回答?如果所有问题都让模型自由发挥,业务规则形同虚设;如果全用if-else硬编码,又完全浪费了大模型的语义理解能力。
条件链和路由链,就是用来做流程控制的。简单说,条件链负责“判断该不该走某个分支”,路由链负责“这个请求到底该送到哪个分支”。它们的核心思路是一致的:让大模型输出结构化的判断结果,再由Java代码执行后续逻辑。这篇文章我从原理讲到底层实现,最后给一个完整可跑的实战项目,适合已经跑通LangChain4j基础调用、想在真实业务里做流程编排的开发者。放心,每一步都会给出能直接抄的代码和踩坑记录。
1. 为什么需要条件链与路由链:从单轮到流程编排
1.1 单一模型调用的三个短板
用一个ChatLanguageModel直接生成回答,是LangChain4j最简单的用法。但真实业务流程不可能是单节点的,至少有三个绕不开的问题。
第一是不可控。模型是概率输出,同一个问题问十遍,可能十遍语气都不一样。在某些需要严格走合规分支的场景,这种飘忽不定是致命的。第二是没有分支。就算模型判断对了意图,后续动作还是得Java代码来做——是调接口、发工单、转人工,这些动作模型做不了,也承担不了这个责任。第三是没有审计。真实业务里领导要问你“这条内容为什么被拦截了”“这个用户为什么被转人工了”,你总得有个判断依据。裸调用拿不出解释。
所以流程控制的本质,是把“理解”和“执行”拆开。理解的部分交给模型,执行的部分由代码完成。条件链和路由链正是在两者之间架桥。
1.2 三种流程控制方案对比
怎么让模型参与流程控制?业界大体有三条路。
| 方案 | 灵活性 | 可控性 | 成本与延迟 | 适用场景 |
|---|---|---|---|---|
| 所有逻辑全塞进System Prompt | 高 | 低 | 低 | Demo、原型验证 |
| 代码硬编码规则(关键词/正则) | 低 | 高 | 极低 | 意图确定、词表固定的场景 |
| LLM做判断 + Java代码执行 | 高 | 中高 | 中 | 语义复杂、需要灵活分发的业务 |
我直接把话放这儿:生产环境我只会选第三种,最多在前端加一层规则快路径。全塞Prompt看着省事,但模型一旦乱来,你根本不知道是Prompt的问题还是模型抽风。硬编码规则适合“包含“退款”两个字就走退款流程”这种明确场景,但它理解不了“我钱都付了东西没到”这种语义。只有让模型先输出结构化结果,再由代码来硬路由,两种优势才都能拿到。
1.3 LangChain4j的流程控制设计逻辑
LangChain4j没有提供一个叫ConditionalChain或RouterChain的巨型类,它更倾向放权给Java开发者。这不是框架偷懒,而是成熟的设计选择。Python系的LangChain有Chain、Router之类的概念,但Java生态里,与其在框架内部包一层抽象,不如靠AiServices + 注解 + Java原生流程控制,组合出一套干净的路由机制。
实际用下来,这套组合有两个明显优点。一是调试直观:模型输出什么、代码走了哪个分支,日志一打全清楚。二是替换成本低:判断逻辑和业务逻辑完全分开,换个模型服务商不影响分支代码。这一点对要过公司审批流程的项目尤其重要,业务代码里不该掺模型供应商的私有概念。
2. 条件链:让流程先判断再执行
2.1 条件链的本质与应用场景
条件链解决的是“要不要做某件事”的问题。它的节点通常只有少数几个出口,比如“通过/不通过”“需要审核/不需要审核”“命中/未命中”。每个出口对应一段可执行的业务逻辑。
最经典的应用是内容发布预检。用户提交一条评价,系统不可能直接展示,得先判断有没有违规风险。传统做法是敏感词表兜底,但敏感词表搞不定“阴阳怪气”,也搞不定上下文里的隐喻。用条件链,大模型充当语义审查员,先判断是否应当放行,然后代码再决定走发布流程还是走人工审核流程。
这类场景的共同特点,是判断条件靠语义,而后续动作非常明确。让模型多做一步决策之外的业务动作,反而不稳定。
2.2 关键第一步:用结构化输出拿判断条件
条件链能不能落地,取决于一件事——模型输出的判断能不能被代码直接消费。模型回一句“我觉得这条内容可能有点问题,建议你们再人工看看”,这种回答没法直接做if。所以必须让模型输出结构化数据。
我用的方案是:在SystemMessage里硬性规定JSON格式,然后在Java端用Jackson解析。
public interface ContentGuardAgent { @SystemMessage(""" 你是内容安全预检机器人。你的任务是判断给定的用户内容是否可以安全展示。 只输出一个JSON对象,不要输出任何其他文字。 输出格式固定为:{"blocked": true 或 false, "reason": "拦截原因,不拦截则填空字符串"} """) @UserMessage("用户内容:{{it}}") String check(String userContent); }这里接口返回类型直接声明成String,而不是某个结构体。原因很朴素:LangChain4j的自动JSON映射在不同模型上的表现有差异,有些模型经常不严格匹配字段名,解析直接崩。先拿String出来,自己在代码里解析,反而最稳。
2.3 实战示例:内容安全预检条件链
接着上面的接口,看完整的条件链调用。
public class ContentGuardChain { private final ContentGuardAgent agent; private final ObjectMapper mapper = new ObjectMapper(); public ContentGuardChain(ChatLanguageModel model) { this.agent = AiServices.builder(ContentGuardAgent.class) .chatLanguageModel(model) .build(); } public String process(String rawContent) { try { String json = agent.check(rawContent); JsonNode node = mapper.readTree(json); boolean blocked = node.path("blocked").asBoolean(); String reason = node.path("reason").asText(); if (blocked) { // 命中风险,走拦截分支 return "BLOCKED:" + reason; } // 未命中,走正常发布分支 return "PASS:" + rawContent; } catch (Exception e) { // 解析失败,按安全惯例拦截 return "BLOCKED:内容预检解析失败,可能超时或模型输出异常"; } } }一个细节值得重点说:catch块里我选择“拦截”而不是“放行”。内容安全这类场景要遵循fail-closed原则,判断系统自己挂了,宁可错杀也不能让风险内容流出去。你要是做营销短信通道,判断模型超时,那可以降级成全量人工审核;但绝不推荐默认放行。
2.4 条件判断的可靠性参数
条件链的准确率,很大程度不取决于模型大小,而取决于你给的约束和参数。
温度参数必须压到最低。大多数模型服务商都支持temperature设置,条件判断场景我一般直接设成0。你不需要模型有创造性,只需要它稳定。有些模型还支持seed之类的参数,有条件也一并固定。
输出约束在User和System两侧都写一遍。别嫌啰嗦,实践下来双写的成功率比只写System高不少。我在SystemMessage里写规则,在UserMessage里补一句“严格按照SystemMessage规定的JSON格式输出”,脏数据比例能降一个量级。
还有一点,请求里尽量只带上下文,不要带历史聊天记录。条件判断是原子操作,历史对话反而会把模型带偏。要做多轮审核,就显式拼接出一个“当前待判断内容”传给模型。
3. 路由链:多分支智能分发
3.1 路由链与条件链的差异
条件链解决的是“是/否”,路由链解决的是“去哪个分支”。路由的出入口数量通常在三个以上,每个入口对应一整套业务处理逻辑。你可以把路由链看成条件链的推广:当判断条件的枚举值变多,条件链自然演变成了路由链。
举例会更直观。售后客服场景里,用户的消息进来,系统得先分清这是退款、退货、维修还是普通售前咨询。四个分支的处理流程完全不同:退款要校验支付渠道,退货要生成物流单,维修要记录设备型号,售前直接对接商品推荐。如果每个分支都独立写一个模型调用,重复计算严重;如果写一个大Prompt让模型一次性做所有事,又没法分别控制分支行为。
正确做法是:先用一次路由调用拿到“意图标签”,然后用代码switch分发到不同业务服务。
3.2 三种路由实现方式:LLM判断、规则匹配、混合路由
我实践下来,路由实现有三种层次。
纯LLM判断适用于意图复杂、词表不固定的场景,模型端直接输出意图标签。规则匹配适合“标题里有XX关键词”这种高频低成本的快路径。混合路由则在两者之间取得平衡——先跑规则,命中就直接返回;未命中再走LLM判断。混合路由在成本敏感项目里非常值得做,因为大部分常见问题可以用几十个关键词拦下来,真正需要模型判断的往往只有那两成长尾问题。
这里要注意规则和LLM的优先级。我一般让规则先跑,因为关键词命中意味着意图高度明确;如果规则和模型结果冲突,以规则为准并记录日志,方便后续调优。否则用户反馈“我明明说了退款两个字,系统却给我推荐商品”,体验会很割裂。
3.3 实战示例:意图路由器
先定义意图枚举。这里有个教训:枚举值必须语义互斥,不要出现“退款”和“投诉”这类容易混淆的标签。
public enum Intent { REFUND, RETURNS, REPAIR, SALES, HUMAN }然后定义路由接口。
public interface IntentRouterAgent { @SystemMessage(""" 你是售后客服系统的意图路由器。 从以下意图中选一个最合适的:REFUND(申请退款)、RETURNS(退货换货)、 REPAIR(维修)、SALES(售前咨询)、HUMAN(无法确定或需要人工介入)。 只输出意图标签本身,不要输出解释、标点或任何额外内容。 """) @UserMessage("用户问题:{{it}}") String route(String userInput); }下一步是结果解析。这一步必须做兜底,因为模型偶尔会输出“REFUND。”带个句号,或者画蛇添足地输出“意图是:REFUND”。
private static Intent parseIntent(String raw) { if (raw == null) { return Intent.HUMAN; } String s = raw.trim().toUpperCase() .replaceAll("[。.、::!!]", "") .replace("意图是", "") .trim(); try { return Intent.valueOf(s); } catch (IllegalArgumentException e) { return Intent.HUMAN; } }所有解析失败的情况统一扔给HUMAN,即转人工兜底。这么做不是为了偷懒,而是保证路由链路永远有出口。
3.4 路由键设计:少而准
路由键的数量和定义方式,直接决定模型表现。给你一组经验值:路由键控制在10个以内。超过10个,模型开始频繁混淆;超过20个,准确率会肉眼可见地下降,这时候你得考虑分层路由——先分两三组粗类,再在组内细分。
每个路由键都要有一段简短定义。不要只给一个孤零零的单词。模型不是字典,它需要知道边界。比如RETURNS和REPAIR的区别,你得写清楚“RETURNS指用户想退回或更换商品,REPAIR指商品需要维修但用户倾向于保留”。
键名统一用大写英文常量,不要用中文标签当键名。中文标签在输出解析、日志检索、子模块对接时都会遭遇编码坑,大写英文常量在哪个环节都干净。
4. 完整实战:一个多级路由的智能工单处理器
4.1 需求拆解与流程设计
为了把前面的东西串起来,我设计了一个相对完整的模拟项目:电商售后智能工单处理器。用户问题进入后,系统要做两件事:判断用户意图,判断紧急程度。然后根据“意图×紧急程度”的组合,将工单分发给不同的处理服务。
流程设计是这样的:
- 用户输入问题 + 订单号。
- 一次LLM调用同时返回intent和priority。
- Java代码解析成RouteInfo对象。
- 根据intent走对应的业务服务。
- priority为HIGH的工单,在服务里额外标记加急并优先处理。
这个设计的好处在于,一次模型调用解决两个维度的判断,省了一次调用成本;同时急单判断完全不影响路由准确性。
4.2 工程结构与依赖
模拟项目的目录结构如下:
support-bot/ ├── pom.xml └── src/main/java/ ├── Main.java ├── Intent.java ├── RouteInfo.java ├── IntentRouterAgent.java ├── RouterDecisionExecutor.java └── service/ ├── RefundService.java ├── ReturnService.java ├── RepairService.java ├── SalesService.java └── HumanHandoffService.javaMaven依赖只需要LangChain4j核心包和Jackson,构建模型服务的配置按你实际接入的服务商来。下面是核心依赖:
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>${langchain4j.version}</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.1</version> </dependency>4.3 核心代码实现
先看路由Agent。这里用到两个模板变量orderId和question,LangChain4j会把方法参数名映射到模板占位符。如果遇到版本差异导致变量解析不到,就把两个参数封装成一个对象传入。
public interface IntentRouterAgent { @SystemMessage(""" 你是工单意图路由器。根据用户问题和订单号,输出一个JSON对象。 JSON格式固定为:{"intent": "REFUND", "priority": "LOW"} intent只能是以下之一:REFUND(申请退款)、RETURNS(退货换货)、 REPAIR(维修)、SALES(售前咨询)、HUMAN(无法确定或需要人工介入)。 priority只能是HIGH或LOW。判断紧急程度时,包含“加急、投诉、破损、立刻”等词视为HIGH。 只输出JSON,不要输出任何其他内容。 """) @UserMessage("订单号:{{orderId}}\n用户问题:{{question}}") String route(String orderId, String question); }对应的RouteInfo和Intent定义:
public record RouteInfo(String intent, String priority) {} public enum Intent { REFUND, RETURNS, REPAIR, SALES, HUMAN }接着是路由解析器,它是整个流程逻辑的枢纽:
public class RouterDecisionExecutor { private final ObjectMapper mapper = new ObjectMapper(); private final RefundService refundService = new RefundService(); private final ReturnService returnService = new ReturnService(); private final RepairService repairService = new RepairService(); private final SalesService salesService = new SalesService(); private final HumanHandoffService humanService = new HumanHandoffService(); public String execute(String rawJson, String question, String orderId) { RouteInfo info = parseRouteInfo(rawJson); boolean urgent = "HIGH".equalsIgnoreCase(info.priority()); Intent intent = parseIntent(info.intent()); switch (intent) { case REFUND -> { return refundService.handle(orderId, question, urgent); } case RETURNS -> { return returnService.handle(orderId, question, urgent); } case REPAIR -> { return repairService.handle(orderId, question, urgent); } case SALES -> { return salesService.handle(question); } case HUMAN, default -> { return humanService.handle(orderId, question); } } } private RouteInfo parseRouteInfo(String raw) { try { JsonNode node = mapper.readTree(raw); String intent = node.path("intent").asText(); String priority = node.path("priority").asText(); return new RouteInfo(intent, priority); } catch (Exception e) { return new RouteInfo("HUMAN", "HIGH"); } } private Intent parseIntent(String raw) { if (raw == null) return Intent.HUMAN; try { return Intent.valueOf(raw.trim().toUpperCase()); } catch (IllegalArgumentException e) { return Intent.HUMAN; } } }switch表达式里用case HUMAN, default ->是Java 17以后支持的写法,目的是把解析失败和真正转人工归到同一条兜底链。
子服务不需要复杂实现,核心是把路由结果和后续业务动作解耦。以退货服务为例:
public class ReturnService { public String handle(String orderId, String question, boolean urgent) { String prefix = urgent ? "[加急]" : ""; return prefix + "退货申请已受理,订单号:" + orderId + ",我们会在1小时内为你生成退货物流单。"; } }Main方法里组装完整流程:
public class Main { public static void main(String[] args) { ChatLanguageModel model = createChatLanguageModel(); IntentRouterAgent router = AiServices.builder(IntentRouterAgent.class) .chatLanguageModel(model) .build(); RouterDecisionExecutor executor = new RouterDecisionExecutor(); String orderId = "SO-20240821-001"; String question = "昨天收到的手机屏幕裂了,能加急换一台吗?"; String raw = router.route(orderId, question); String reply = executor.execute(raw, question, orderId); System.out.println("路由原始输出:" + raw); System.out.println("最终回复:" + reply); } private static ChatLanguageModel createChatLanguageModel() { // 这里替换成你自己项目里的模型构建代码 return null; } }4.4 运行效果与性能观察
这段代码跑下来的路由原始输出大概是:
{"intent": "RETURNS", "priority": "HIGH"}因为用户明确说了“屏幕裂了”“加急换一台”,模型识别出退货意图和高优先级,工单直接进入退货服务并标记加急。整个过程多了一次模型调用,但在我的模拟测试里,一次典型路由判断的额外延迟大约在200到400毫秒,token消耗在300到800之间,跟业务服务本身的耗时相比完全可接受。
有一个数据值得你关心:HUMAN兜底率。如果这个比例超过10%,基本可以断定路由Prompt写得有问题,或者路由键之间语义重叠太严重。正常业务里,绝大多数问题应该能被清晰的意图标签收住,只有少量需要转人工。
5. 常见问题与排查技巧实录
5.1 模型乱路由、输出脏数据
最典型的现象:模型不老实输出“REFUND”,非要写一句“根据用户问题,我认为应该走退款流程,所以答案是REFUND”。这时候解析就崩了。
我的解决思路是双管齐下。Prompt层面继续压缩自由度,把“只输出标签本身,不要输出解释”写到SystemMessage里;解析层面做清洗函数,把常见噪声剥离。上面parseIntent里已经演示了replace掉句号和“意图是”这类前缀的做法。记住,解析器要宽容,Prompt要严格。两头都紧只能让模型更困惑。
如果脏数据还是频繁出现,检查模型服务商是否支持JSON Mode。支持的话直接开启,让模型从输出协议层面就约束为合法JSON;不支持就把Logit相关参数调整成固定,或者干脆换一个结构化输出能力更强的模型。
5.2 路由解析失败与JSON乱码
模型输出JSON时偶尔会包一层Markdown代码块:
{"intent": "REFUND", "priority": "HIGH"}直接把这段字符串交给Jackson解析,百分百报错。我的对策是在解析前先清掉首尾的反引号与“json”标记:
private static String cleanJson(String raw) { String s = raw.trim(); if (s.startsWith("```")) { s = s.replaceAll("```json", "").replaceAll("```", "").trim(); } return s; }还有一个坑是中文乱码。模型在reason字段里输出中文时,如果传输链路没有统一UTF-8,往往会变成一堆问号。确保项目启动参数带上-Dfile.encoding=UTF-8,HTTP客户端资源文件一致用UTF-8,这一步能避免大量诡异问题。
5.3 延迟和token成本失控
路由判断本质上是为流程可控性额外付的一次代价。如果每一次用户请求都要多一次模型调用,成本确实会积累。想要控成本,优先做规则快路径。
我在第三个章节提到的混合路由,在工单系统里落地起来是这样:先跑一个关键词表,命中“退货”“退款”“加急”“投诉”之一,直接进入对应分支,完全省掉模型调用。只有关键词没覆盖到的问题才走路由Agent。在我的实测场景里,这套方案能拦下大概六成左右的常见问题,模型调用量直接砍半。代价是关键词表需要持续维护,但相比模型费用的节省,维护成本可以接受。
另一个思路是按级别拆分模型。路由判断用一个轻量便宜的模型,内容生成用更强的模型。路由任务输出内容极短,对模型智商要求不高,便宜的型号足够胜任。我在第6节会再展开聊这件事。
5.4 路由状态与多轮对话衔接
很多人在路由场景里忽视会话状态。比如用户先说“我要退货”,下一句说“地址是XXX”,如果你只把当前这句“地址是XXX”拿去路由,模型根本无法判断意图,直接扔进HUMAN。正确做法是保留最近几轮对话摘要,把“用户上一轮已表达退货意图,当前补充收货地址”喂给路由Agent。
LangChain4j里可以给AiServices配置ChatMemory,但我更推荐在路由层单独维护一个轻量上下文窗口。只要把最近两轮的用户消息拼进路由Prompt即可。路由节点不需要完整多轮记忆,因为它只负责分类,分类依据是当前问题加最近上下文。拼太多历史会稀释重点,反而降低准确率。
6. 进阶:路由链与工具调用、多级组合的更多玩法
6.1 用函数调用做“模型主动路由”
除了让模型输出一个意图标签,再在代码里switch,LangChain4j还支持让模型直接“调用”路由目标。核心是用@Tool注解暴露一组方法,模型根据用户输入决定调用哪个方法,方法名和参数都由模型生成。
public class OrderSupportTools { @Tool("处理退款申请,输入订单号") public String refund(String orderId) { return refundService.apply(orderId); } @Tool("处理退货换货申请,输入订单号") public String returns(String orderId) { return returnService.createReturnOrder(orderId); } }这时候“调用哪个Tool”本身就是路由结果。模型如果判定用户要退货,它会在响应里带上returns这个Tool调用,LangChain4j自动触发对应Java方法。这个方案适合“模型需要自主决定是否调用、以及调用时需要传参”的场景,等于把路由和参数提取合并成一步。缺点是排查时比纯标签路由多一层间接性,日志要仔细记录模型的tool调用名。
6.2 多路组合路由:意图乘以情感
前面工单系统已经演示了intent和priority两个维度的组合。更复杂的场景可以引入情感维度,比如“正向反馈、负向投诉、中性询问”。三个维度组合起来就是意图×紧急度×情感,能产生十几个分支组合。
实现上不要写十几个if-else。把组合结果设计成路由键,比如“REFUND-HIGH-NEGATIVE”作为一条完整规则,map里放好对应处理策略即可。组合路由的优势,是多维信息只需要一次模型调用。只要让模型输出一个三维JSON,后面的分发全部由代码完成,效率和可控性都能兼顾。
6.3 路由结果的缓存与监控
路由链是纯函数式的:同样的输入,理论上应该得出同样的路由结果。所以它可以做缓存。我用一个简单的本地缓存记录“用户问题的内容哈希→路由输出”,过期时间设成10分钟。效果很直接:重复咨询同一问题的用户,第二次进来不再产生模型调用。这里要设置合理的过期,因为业务规则可能变化,缓存太久会让新策略无法生效。
监控上我建议至少记录四个指标:各路由分支的命中次数、HUMAN兜底占比、路由决策平均耗时、解析失败次数。解析失败次数尤其重要,一旦这个数飙升,说明你的Prompt约束和解析器之间已经不匹配了,得赶紧看原始日志。很多看起来玄学的问题,其实看一眼模型原始输出就豁然开朗。
写在最后的一点个人体会
条件链和路由链用了一段时间之后,我最大的体会是:这类流程控制组件不追求模型有多聪明,反而追求模型有多“老实”。把判断标准写清楚、输出格式定死、兜底分支留足,比你选一个多大的模型都重要。我现在的习惯是,路由和条件判断尽量用便宜的轻量模型,把贵的模型成本留给真正需要生成内容的环节。另外一定要留原始输出日志,路由不准时,第一件事永远是翻日志看模型到底回了什么,而不是盲目调Prompt。希望这篇实战内容能帮你把LangChain4j的流程控制用起来,少踩几个我踩过的坑。