1. 为什么 Java 后端一碰 Agent 就容易“翻车”1.1 从一次线上事故说起Agent 的“幻觉”是怎么变成生产事故的去年年底我接手了一个客服工单自动分类的项目。业务方的诉求很朴素用户提交工单后系统自动判断它属于“退款”“物流”“账号”还是“其他”然后路由到对应的处理队列。听起来是个典型的文本分类任务用 Java 写个 Spring Boot 服务调一下大模型接口就完事了。第一版我确实是这么干的。Java 后端接收工单拼一段 Prompt调用大模型拿到返回的 JSON解析后写库。上线第一天准确率看着还行大概 85% 左右。但到了第三天问题来了有一批工单被分到了“退款”队列可内容明明是“我的账号登不上去”。客服同事炸了说系统在乱分。我去翻日志发现模型返回的 JSON 里category字段写的是“退款”但reason字段写的是“用户无法登录账号疑似账号问题”。也就是说模型自己都自相矛盾了。这就是典型的Agent 幻觉它不是在“判断”而是在“编造一个看起来合理的答案”。更麻烦的是这种幻觉不是每次都出现。同样的工单早上分对了下午分错了。你没法用传统的单元测试去覆盖它因为大模型的输出本质上是概率性的。Java 后端习惯了“输入确定输出确定”的世界突然面对一个“输入确定输出随机”的组件整个工程体系都开始摇晃。1.2 确定性工作流的核心思路把“自由发挥”关进笼子问题的根源在于我们把太多决策权交给了模型。模型既要理解文本又要判断分类还要生成理由最后还要输出格式正确的 JSON。任何一个环节“自由发挥”结果就不可控。我的解决思路是把 Agent 的能力拆解成多个确定性步骤每一步只让模型做一件小事并且用工作流引擎把步骤串起来中间加上校验和兜底。这就是“确定性工作流”的核心。具体来说我用了 n8n 作为工作流编排引擎。n8n 是一个开源的工作流自动化工具支持可视化编排、条件分支、循环、错误处理而且可以通过 HTTP 节点和 Java 后端无缝对接。Java 后端负责业务逻辑和数据持久化n8n 负责调度模型调用和流程控制。这样做的直接好处是Token 消耗从原来的每次请求 3000 降到了 600 左右降幅超过 80%。为什么因为原来是一次性把大段上下文塞给模型让它“自由发挥”现在是分步骤、按需调用每一步的 Prompt 都很短而且很多步骤根本不需要调模型用规则就能搞定。1.3 这套方案适合谁Java 后端 n8n Agent 的三角组合如果你是一个 Java 后端开发正在被 Agent 的幻觉问题折磨或者你正在做 AI Agent 相关的项目发现 Token 成本居高不下那这套方案就是为你准备的。它不需要你放弃 Java 技术栈也不需要你从头学 Python 的 LangChain你只需要理解 n8n 的基本用法剩下的还是你熟悉的 Spring Boot、MyBatis、Redis。另外如果你正在准备 Java 面试面试官问到“AI Agent 怎么保证输出稳定性”“Token 怎么优化”这类问题这套方案里的思路和参数计算过程可以直接拿去当案例讲。我后来面过几个候选人能讲清楚“为什么要把 Agent 拆成工作流”的人屈指可数。2. 整体架构设计Java 做骨架n8n 做神经Agent 做肌肉2.1 为什么不让 Java 直接调模型非要引入 n8n很多人第一反应是我 Java 里写个HttpClient调模型接口不就行了为什么要多引入一个 n8n这不是增加复杂度吗我一开始也是这么想的。但实际做下来发现 Java 直接调模型有几个绕不过去的坑第一流程控制太啰嗦。比如“先判断工单类型如果是退款类再调一次模型提取退款金额如果不是直接走规则路由”。这种分支逻辑在 Java 里就是一堆if-else嵌套改起来痛苦测起来更痛苦。而在 n8n 里就是一个可视化节点拖拽连线逻辑一目了然。第二重试和降级不好做。模型接口偶尔超时、返回格式错误Java 里要写一堆try-catch和重试逻辑。n8n 自带错误处理节点可以配置“失败后重试 3 次每次间隔 2 秒如果还失败就走降级分支”。第三调试和观测困难。Java 里调模型日志打出来是一大坨 JSON想看某一步的输入输出得翻半天。n8n 每个节点都有独立的执行记录点进去就能看到这一步的输入是什么、输出是什么、耗时多少排查问题效率高一个数量级。所以我的架构是Java 后端负责“重”的部分——数据库操作、事务管理、权限校验、对外 APIn8n 负责“轻”的部分——流程编排、模型调用、条件判断、重试降级。两者通过 HTTP 接口通信Java 把任务丢给 n8nn8n 处理完把结果回调给 Java。2.2 工作流拆解把一次 Agent 调用拆成五个确定性步骤以工单分类为例我把原来的一次模型调用拆成了五个步骤预处理Java 后端对工单文本做清洗去掉 HTML 标签、多余空格、敏感信息脱敏然后提取关键字段标题、描述、用户等级。规则预判n8n 里先用规则节点判断如果工单标题包含“退款”“退货”等关键词直接标记为退款类不走模型。这一步能覆盖大约 40% 的工单Token 消耗为零。模型分类剩下的工单调用模型做分类但 Prompt 只包含“请判断以下工单属于哪个类别只返回类别名称不要解释”。输出被限制在一个枚举值里。结果校验n8n 里加一个校验节点检查模型返回的类别是否在预设的枚举列表中。如果不在走降级分支标记为“待人工处理”。回调写库校验通过后n8n 把结果回调给 Java 后端Java 写入数据库并触发后续路由。这样拆下来每次模型调用的 Token 消耗从原来的 3000 降到了 600 左右。而且因为每一步的输出都是确定的要么是枚举值要么是布尔值幻觉的影响被限制在了最小范围内。2.3 数据流转与接口约定Java 和 n8n 怎么“对话”Java 和 n8n 之间的通信我用的是最简单的 HTTP JSON。Java 后端暴露一个/api/agent/dispatch接口接收工单 ID然后异步调用 n8n 的 Webhook 地址把工单数据传过去。n8n 处理完后调用 Java 的/api/agent/callback接口把结果写回。这里有几个细节要注意异步处理Java 调 n8n 的 Webhook 时不要同步等待结果否则工单量一上来线程池直接被打满。我的做法是 Java 把任务丢到消息队列RabbitMQ然后由一个消费者去调 n8nn8n 处理完回调 Java 的接口Java 再更新工单状态。幂等性n8n 的回调可能因为网络问题重复发送Java 的 callback 接口必须做幂等处理。我的做法是用工单 ID 处理批次号作为唯一键重复回调直接忽略。超时设置n8n 调模型接口的超时时间设置为 15 秒Java 调 n8n 的超时时间设置为 30 秒。为什么是 30 秒因为 n8n 内部可能有多步模型调用每步 15 秒留一点缓冲。3. 核心细节解析Prompt 设计、Token 计算与 MCP 协议3.1 Prompt 瘦身从 3000 Token 到 600 Token 的具体操作原来我的 Prompt 是这样的你是一个客服工单分类助手。请仔细阅读以下工单内容判断它属于以下哪个类别退款、物流、账号、其他。 请给出你的判断理由并按照 JSON 格式返回包含 category 和 reason 两个字段。 工单标题{title} 工单描述{description} 用户等级{userLevel} 历史工单{history}这个 Prompt 的问题在于它让模型做了太多事。模型既要理解文本又要判断类别还要生成理由最后还要保证 JSON 格式正确。任何一个环节出错整个结果就废了。优化后的 Prompt 是这样的判断以下工单类别只返回类别名称不要解释。 类别退款、物流、账号、其他。 工单{title} {description}就这么短。为什么敢这么短因为我把“生成理由”这一步去掉了。理由对业务没有直接价值客服同事只看类别。而且“只返回类别名称”这个约束让模型的输出空间被压缩到了四个词幻觉的概率大幅降低。Token 计算也很直观原来的 Prompt 大约 800 个中文字符加上历史工单和用户等级总共 3000 Token。优化后工单标题和描述加起来平均 200 字加上指令总共 600 Token 左右。按 GPT-4 的定价每次调用成本从 0.09 元降到了 0.018 元降幅正好 80%。3.2 输出校验用 Java 枚举把模型的“自由发挥”堵死模型返回的类别名称必须严格匹配 Java 后端的枚举值。我在 Java 里定义了一个TicketCategory枚举public enum TicketCategory { REFUND(退款), LOGISTICS(物流), ACCOUNT(账号), OTHER(其他); private final String label; TicketCategory(String label) { this.label label; } public static TicketCategory fromLabel(String label) { for (TicketCategory category : values()) { if (category.label.equals(label)) { return category; } } return null; } }n8n 回调 Java 时Java 用fromLabel方法校验。如果返回 null说明模型输出了枚举之外的值直接标记为“待人工处理”不写库。这样即使模型幻觉了也不会污染业务数据。这里有个经验枚举值不要用英文直接用中文。因为模型对中文类别的理解更直接你让它返回“REFUND”它有时候会返回“refund”或者“Refund”大小写不一致校验就失败了。用中文“退款”模型基本不会写错。3.3 MCP 协议在其中的角色让工具调用也变成确定性步骤MCPModel Context Protocol是最近比较火的一个协议简单说就是让模型能够调用外部工具。比如模型可以调用“查询订单状态”的工具然后根据返回结果判断工单类别。但在我的方案里MCP 不是必须的。为什么因为 MCP 的本质是让模型“自主决定调用哪个工具”这又引入了不确定性。模型可能该调 A 工具却调了 B 工具或者该调工具却直接编了个答案。我的做法是把工具调用也变成工作流里的确定性节点。比如“查询订单状态”这个操作我在 n8n 里直接写一个 HTTP 节点去调订单服务然后把结果作为上下文传给模型。模型不需要知道工具的存在它只需要基于我给的上下文做判断。这样做的代价是灵活性降低但换来了确定性。对于工单分类这种场景确定性比灵活性重要得多。如果你做的是开放式对话 Agent那 MCP 确实有用但如果是业务流程自动化我建议还是用工作流把工具调用固定下来。4. 实操过程从零搭建一套确定性工作流4.1 环境准备n8n 的部署与 Java 项目的对接配置n8n 的部署很简单官方提供了 Docker 镜像。我用的是 Docker Compose配置文件如下version: 3 services: n8n: image: n8nio/n8n:latest ports: - 5678:5678 environment: - N8N_BASIC_AUTH_ACTIVEtrue - N8N_BASIC_AUTH_USERadmin - N8N_BASIC_AUTH_PASSWORDyour_password - WEBHOOK_URLhttp://your-server-ip:5678 volumes: - ./n8n_data:/home/node/.n8n启动后访问http://your-server-ip:5678用配置的用户名密码登录。然后创建一个新的 Workflow添加一个 Webhook 节点作为入口。Java 这边我用 Spring Boot 写了一个AgentDispatchService核心代码如下Service public class AgentDispatchService { Autowired private RestTemplate restTemplate; private static final String N8N_WEBHOOK_URL http://your-server-ip:5678/webhook/ticket-classify; public void dispatch(Ticket ticket) { MapString, Object payload new HashMap(); payload.put(ticketId, ticket.getId()); payload.put(title, ticket.getTitle()); payload.put(description, ticket.getDescription()); payload.put(callbackUrl, http://your-java-server/api/agent/callback); restTemplate.postForEntity(N8N_WEBHOOK_URL, payload, String.class); } }注意callbackUrl这个字段它是告诉 n8n 处理完后往哪里回调。这样 n8n 就不需要硬编码 Java 的地址灵活性更好。4.2 n8n 工作流搭建Webhook、规则节点、模型节点、校验节点、回调节点在 n8n 里我搭建了这样一个工作流第一步Webhook 节点。接收 Java 传来的 JSON 数据包含ticketId、title、description、callbackUrl。第二步规则判断节点。用 n8n 的 IF 节点判断title是否包含“退款”“退货”“退钱”等关键词。如果包含直接设置category为“退款”跳到第五步。如果不包含进入第三步。第三步模型调用节点。用 n8n 的 HTTP Request 节点调用大模型接口。请求体里拼接 Prompt{ model: gpt-4, messages: [ { role: user, content: 判断以下工单类别只返回类别名称不要解释。类别退款、物流、账号、其他。工单{{$json.title}} {{$json.description}} } ], temperature: 0 }注意temperature设置为 0这是让模型输出尽可能确定的关键参数。温度越高模型越“有创意”幻觉越多。对于分类任务温度必须为 0。第四步校验节点。用 n8n 的 Switch 节点检查模型返回的category是否在[退款, 物流, 账号, 其他]列表中。如果在进入第五步如果不在设置category为“待人工处理”。第五步回调节点。用 HTTP Request 节点把ticketId和categoryPOST 到 Java 的callbackUrl。整个工作流跑下来平均耗时 2-3 秒其中模型调用占 1.5 秒左右规则判断和校验几乎不耗时。4.3 Java 回调接口的实现与幂等处理Java 的回调接口长这样RestController RequestMapping(/api/agent) public class AgentCallbackController { Autowired private TicketService ticketService; PostMapping(/callback) public ResponseEntityString callback(RequestBody CallbackRequest request) { boolean updated ticketService.updateCategory( request.getTicketId(), request.getCategory(), request.getBatchNo() ); if (updated) { return ResponseEntity.ok(success); } else { return ResponseEntity.ok(duplicate); } } }updateCategory方法里做了幂等处理public boolean updateCategory(Long ticketId, String category, String batchNo) { String key ticket:callback: ticketId : batchNo; Boolean success redisTemplate.opsForValue().setIfAbsent(key, 1, 1, TimeUnit.HOURS); if (Boolean.FALSE.equals(success)) { return false; } // 更新数据库 ticketMapper.updateCategory(ticketId, category); return true; }用 Redis 的setIfAbsent做分布式锁同一个工单同一个批次的回调只处理一次。为什么用批次号而不是只用工单 ID因为同一个工单可能因为人工重新触发而再次进入工作流这时候批次号不同应该允许更新。4.4 参数计算Token 消耗、并发量与成本估算假设每天有 10000 个工单其中 40% 被规则节点拦截不需要调模型。剩下 6000 个工单需要调模型。优化前每个工单 3000 Token6000 个工单就是 1800 万 Token。按 GPT-4 输入价格 0.03 元/千 Token 计算每天成本 540 元。优化后每个工单 600 Token6000 个工单就是 360 万 Token。每天成本 108 元。每天节省 432 元一个月节省约 13000 元。这还没算上因为幻觉导致的错误分类带来的客服返工成本。并发量方面n8n 单实例大概能扛住每秒 50 个请求。如果工单量更大可以部署多个 n8n 实例用 Nginx 做负载均衡。Java 这边用消息队列削峰把并发压力转移到队列里n8n 按自己的节奏消费。5. 常见问题与排查技巧实录5.1 模型返回格式不对怎么办三种兜底策略即使 Prompt 里写了“只返回类别名称”模型有时候还是会返回“这个工单属于退款类别”这种带解释的句子。我的兜底策略有三种策略一字符串匹配。在 n8n 的校验节点里不要求完全相等而是用“包含”判断。比如返回“这个工单属于退款类别”只要包含“退款”就认为是退款类。这个策略能覆盖 90% 的格式异常。策略二正则提取。如果字符串匹配也失败用正则表达式从返回文本里提取类别关键词。比如/(退款|物流|账号|其他)/匹配到哪个就是哪个。策略三降级人工。如果前两种都失败直接标记为“待人工处理”不写库。这个策略虽然增加了人工成本但保证了数据质量。这三种策略在 n8n 里用 Switch 节点串联优先级从高到低。5.2 n8n 工作流执行超时原因分析与解决路径n8n 工作流超时最常见的原因是模型接口响应慢。我遇到过几次模型接口平均响应时间从 1.5 秒突然涨到 10 秒导致 n8n 工作流超时。排查思路先看 n8n 的执行记录找到耗时最长的节点。如果是模型节点说明是模型接口的问题。检查模型接口的监控数据看是不是整体延迟升高还是个别请求慢。如果是整体延迟考虑切换模型或者增加超时时间。如果是个别请求慢考虑加缓存。我的解决路径是在 n8n 的模型节点上配置重试失败后重试 2 次每次间隔 1 秒。如果重试后还失败走降级分支标记为“待人工处理”。同时Java 后端加了一个监控告警当“待人工处理”的比例超过 10% 时发通知给运维。5.3 高频踩坑速查表问题现象可能原因排查方法解决方案模型返回类别不在枚举中温度参数过高检查 n8n 模型节点的 temperature 设置设置为 0n8n 回调 Java 失败callbackUrl 配置错误检查 n8n 执行记录中的 HTTP 请求节点确认 Java 服务地址和端口同一工单被重复处理幂等键设计不当检查 Redis 中的 key 是否存在用 ticketId batchNo 作为幂等键Token 消耗突然升高Prompt 中混入了历史数据检查 n8n 模型节点的请求体移除不必要的上下文规则节点拦截率下降关键词列表过期统计最近一周的工单标题定期更新关键词列表5.4 独家避坑技巧我踩过的三个坑坑一不要用模型的“理由”字段做业务判断。我一开始让模型返回reason字段然后 Java 里根据reason的内容做二次判断。结果发现模型有时候category写“退款”reason写“用户无法登录”两者矛盾。后来我把reason去掉了只保留category问题消失。坑二n8n 的 Webhook 节点要设置认证。默认情况下n8n 的 Webhook 是公开的任何人都能调。我在测试环境没注意结果被扫描到了有人往我的 Webhook 发了一堆垃圾数据。后来加了 Basic Auth并且只允许 Java 后端的 IP 访问。坑三模型接口的并发限制要提前确认。我用的是某个国内模型接口默认并发是 10。工单量一上来n8n 并发调模型直接触发限流大量请求失败。后来我在 n8n 里加了队列节点把并发控制在 8 以下问题解决。6. 扩展思考这套方案还能怎么用6.1 从工单分类扩展到其他场景合同审核、代码审查、数据清洗这套“Java n8n Agent”的确定性工作流不只能做工单分类。我后来把它用在了合同审核上Java 后端上传合同 PDFn8n 调用 OCR 提取文本然后用规则节点判断合同类型再调模型提取关键条款金额、期限、违约责任最后校验并写库。Token 消耗比原来一次性让模型读全文降低了 70%。代码审查也是类似Java 后端接收 Git 提交n8n 调模型分析代码变更规则节点先过滤掉纯格式变更模型只分析逻辑变更最后输出审查意见。数据清洗场景下n8n 的规则节点可以处理 80% 的格式问题模型只处理剩下的 20% 复杂情况。6.2 面试怎么讲把“Token 直降 80%”变成你的项目亮点如果你在准备 Java 面试面试官问到 AI 相关项目你可以这样讲“我做过一个客服工单自动分类的项目用 Java 做后端n8n 做工作流编排大模型做分类。一开始模型幻觉很严重Token 消耗也高。后来我把一次模型调用拆成了五个确定性步骤用规则节点拦截了 40% 的请求Prompt 从 3000 Token 压缩到 600 Token成本降低了 80%。同时用 Java 枚举做输出校验用 Redis 做幂等保证了数据一致性。”这段话里包含了架构设计、成本优化、数据一致性三个技术点面试官很容易顺着往下问。而且“Token 直降 80%”这个数字很具体比说“优化了性能”有说服力得多。6.3 后续优化方向缓存、批处理与模型微调这套方案还有几个优化方向。缓存对于重复的工单描述可以在 n8n 里加一个 Redis 缓存节点先查缓存命中直接返回不用调模型。批处理如果工单量很大可以把多个工单合并成一个请求发给模型让模型一次性返回多个分类结果进一步降低 Token 消耗。模型微调如果业务场景固定可以收集一批标注数据微调一个小模型替代通用大模型成本和延迟都能大幅降低。不过这些优化都有前提缓存要考虑失效策略批处理要考虑单条失败的影响微调要考虑数据标注的成本。我的建议是先把确定性工作流跑通再根据实际瓶颈逐步优化不要一上来就追求完美。我个人在实际操作中的体会是Agent 的幻觉问题本质上不是模型的问题而是工程的问题。你把模型当成一个“不确定的组件”然后用工程手段去约束它、校验它、兜底它问题就解决了一大半。剩下的那一小半交给人工兜底别想着 100% 自动化。