1. 当 Java 后端遇上会“编故事”的 Agent
做 Java 后端的兄弟这两年应该都有一个共同的感受:业务代码写得再稳,一旦把大模型 Agent 接进系统,整个链路的确定性就崩了。你让它查订单,它给你编一个不存在的订单号;你让它调接口,它自作主张把参数改成了看起来更“合理”的值;你让它按固定格式返回 JSON,它偏偏在末尾加一句“希望对您有帮助”。这就是所谓的Agent 幻觉——模型在不确定的地方倾向于“猜”,而猜出来的东西在后端系统里就是事故。
我所在的团队做的是电商中台,去年下半年开始把 AI Agent 引入到客服工单、订单查询、售后审核几个环节。一开始用的是纯 Prompt 编排,Java 侧写一个 Controller 接收用户输入,拼好 Prompt 丢给模型,拿到结果再解析。上线第一周就炸了:模型返回的 JSON 有 30% 的概率带 markdown 代码块包裹,有 15% 的概率字段名对不上,还有几次直接把“订单金额”理解成了“商品单价”。更头疼的是Token 消耗,一个多轮对话场景跑下来,单次请求动辄三四千 Token,成本压不住。
后来我们把目光转向了n8n。n8n 是一个开源的工作流自动化工具,节点式编排,支持 HTTP 请求、条件分支、循环、代码节点,最关键的是它可以把“不确定的模型调用”和“确定的业务逻辑”拆开——模型只负责它擅长的语义理解和意图识别,真正的数据查询、参数校验、格式组装全部交给确定性节点。Java 后端通过 Webhook 触发 n8n 工作流,n8n 把结果回传,Java 侧只做最终的落库和响应。这套方案跑下来,Token 消耗直降 80%,幻觉导致的异常从每周几十次降到个位数。
这篇文章适合两类人看:一类是正在做Agent 开发、被幻觉和 Token 成本折磨的 Java 后端;另一类是想把n8n 工作流接入现有 Java 系统的工程师。我会把整套方案的选型逻辑、节点设计、参数计算、踩过的坑全部摊开讲,代码和配置都能直接抄。
2. 为什么是 n8n,而不是纯 Java 编排或其它 Agent 框架
2.1 纯 Java 编排 Agent 的三个死穴
最开始我们试过在 Java 里直接编排 Agent。用 Spring Boot 写一个AgentService,内部维护对话历史,调用模型 API,解析返回结果。看起来很简单,但实际跑起来有三个绕不过去的问题。
第一个是Prompt 与业务代码耦合太深。每次调整 Prompt 措辞,都要改 Java 代码、重新打包、走一遍发布流程。产品经理说“把‘请返回 JSON’改成‘必须返回 JSON’”,后端就得发一次版。这种迭代速度根本跟不上业务对 Agent 效果的调优节奏。
第二个是多步推理的编排能力弱。一个典型的售后审核场景需要:识别用户意图 → 提取订单号 → 查询订单状态 → 判断是否符合退款条件 → 生成回复。这五步里有三步是确定性的数据库操作,两步是模型调用。在 Java 里写就是一堆 if-else 嵌套,加上异常处理和重试,代码很快就变成面条。而且模型调用的中间结果没法可视化,出了问题只能翻日志。
第三个是Token 浪费严重。纯 Java 编排时,为了让模型“记住”上下文,我们习惯把完整对话历史塞进 Prompt。一个五轮对话下来,历史消息占了 2000 多 Token,而真正有用的当前轮信息可能只有 200 Token。模型每次都要重新读一遍历史,钱就这么烧掉了。
2.2 n8n 的确定性节点为什么能治幻觉
n8n 的核心价值在于它把工作流拆成了确定性节点和非确定性节点两类。HTTP Request 节点、Set 节点、IF 节点、Code 节点、数据库节点,这些都是确定性的——输入什么,输出什么,完全可预测。只有 AI 相关的节点(比如 OpenAI 节点、AI Agent 节点)是非确定性的。
我们的策略是:让模型只做它最擅长的事,其余全部交给确定性节点。具体来说,模型只负责两件事:一是意图分类(用户想干什么),二是实体抽取(订单号、商品名、时间范围)。这两件事即使模型偶尔出错,后面的确定性节点也能通过校验规则兜住。而真正的数据查询、金额计算、状态判断、格式组装,全部由 n8n 的确定性节点完成。
举个例子。用户说“我上周买的那个蓝色杯子想退掉”。模型只需要输出{"intent": "refund", "product": "蓝色杯子", "time_range": "上周"}。n8n 拿到这个结构化结果后,用 Code 节点把“上周”转换成具体日期范围,用 HTTP Request 节点调 Java 后端的订单查询接口,用 IF 节点判断订单状态是否允许退款,最后用 Set 节点组装标准响应。整个过程中,模型没有机会“编造”订单号或金额,因为那些数据根本不经过模型。
2.3 Token 直降 80% 的账是怎么算的
很多人以为 Token 降本靠的是换更便宜的模型,其实不是。我们实测下来,Token 消耗的大头在上下文重复传输。纯 Java 编排时,每次请求都把完整对话历史发给模型,五轮对话的 Prompt 长度是单轮的 5 倍以上。而 n8n 工作流里,我们用一个 Set 节点维护“精简上下文”——只保留最近一轮的用户输入和模型输出的结构化结果,历史信息以键值对形式存在 n8n 的静态数据里,需要时按需取用。
具体数据:改造前,一个售后审核对话平均消耗 3200 Token(输入 2800 + 输出 400)。改造后,同样的对话平均消耗 620 Token(输入 450 + 输出 170)。降幅正好在 80% 左右。这里面输入 Token 的下降最明显,因为 n8n 的 Code 节点可以在本地做字符串处理,不需要把原始数据全塞给模型。
提示:n8n 的 Code 节点运行在 Node.js 环境里,做字符串截断、正则提取、JSON 解析这些操作完全不消耗 Token。把能本地做的预处理全部放在 Code 节点,是降本的关键。
3. 核心节点设计与 Java 侧的对接方式
3.1 整体架构:Java 做网关,n8n 做编排
我们的架构是这样的:Java 后端暴露一个/api/agent/trigger接口,接收前端请求后,把用户输入和会话 ID 打包,通过 HTTP 调用 n8n 的 Webhook 节点。n8n 工作流执行完毕后,把结构化结果回传给 Java 的一个回调接口/api/agent/callback,Java 侧做最终的权限校验、数据落库和响应组装。
为什么不让 n8n 直接连数据库?因为 Java 后端已经有完整的权限体系、事务管理和审计日志。n8n 只做编排,不碰核心数据,这样职责清晰,也避免了 n8n 成为新的安全漏洞。Java 侧的回调接口用 JWT 做鉴权,n8n 在 HTTP Request 节点里带上 Token,Java 校验通过后才处理。
这套架构还有一个好处:n8n 工作流可以独立于 Java 应用部署和升级。产品经理要调 Prompt,直接在 n8n 界面上改,保存即生效,不用等 Java 发版。我们甚至给运营同学开了 n8n 的只读权限,他们可以看工作流执行记录,定位是哪个节点出了问题。
3.2 Webhook 节点的参数设计与安全校验
n8n 的 Webhook 节点是整个链路的入口,参数设计要兼顾灵活性和安全性。我们用的是 POST 方法,Content-Type 为application/json,请求体包含四个字段:
{ "session_id": "sess_20250115_001", "user_input": "我上周买的蓝色杯子想退掉", "user_id": "u_10086", "timestamp": 1736899200000 }session_id用于关联多轮对话,n8n 侧用静态数据(Static Data)按 session_id 存储精简上下文。user_id用于权限校验,Java 回调时会核对这个用户是否有权操作对应订单。timestamp用于防重放攻击,Java 侧校验时间戳与当前时间差不超过 5 分钟。
Webhook 节点本身要开启Authentication,我们用的是 Header Auth,在 n8n 里配置一个固定的X-Agent-Secret,Java 调用时带上。这个 Secret 存在 Java 的配置中心里,定期轮换。另外 Webhook 节点要设置Response Mode为 “When Last Node Finishes”,这样 n8n 会把工作流最后一个节点的输出作为 HTTP 响应返回给 Java。
注意:Webhook 节点的 URL 不要暴露在公网。我们的做法是 n8n 部署在内网,Java 通过内网地址调用。如果必须公网访问,一定要加 IP 白名单和速率限制。
3.3 意图识别与实体抽取的 Prompt 模板
模型调用节点我们用的是 n8n 的 OpenAI 节点,模型选的是gpt-4o-mini。选它不是因为便宜,而是因为它在结构化输出任务上足够稳定,而且支持 JSON mode。Prompt 模板经过十几轮迭代,最终版本如下:
你是一个电商售后意图识别助手。请分析用户输入,输出严格的 JSON,不要包含任何其他文字。 用户输入:{{ $json.user_input }} 输出格式: { "intent": "refund | query | complaint | other", "product": "商品名称,没有则为空字符串", "order_hint": "订单号或时间范围提示,没有则为空字符串", "confidence": 0.0 到 1.0 之间的浮点数 } 规则: 1. 只输出 JSON,不要用 markdown 代码块包裹。 2. intent 必须是四个枚举值之一。 3. 如果无法判断意图,intent 填 "other",confidence 填 0。 4. product 和 order_hint 只提取用户明确提到的内容,不要推测。这个 Prompt 的关键在于枚举值约束和禁止推测。早期版本我们让模型自由输出 intent,结果它经常造出 “return_goods”、“ask_refund” 这种同义词,后面的 IF 节点根本匹配不上。改成枚举后,匹配率从 70% 提升到 98%。
confidence字段是给 Java 侧做兜底用的。如果 confidence 低于 0.6,Java 会直接返回“请补充更多信息”,而不是继续走工作流。这样避免了模型在低置信度下“硬猜”导致的幻觉。
3.4 Code 节点做本地预处理,砍掉冗余 Token
Code 节点是我们降本的核心武器。在调用模型之前,先用一个 Code 节点对用户输入做清洗:
const input = $json.user_input || ''; // 截断超长输入,保留前 500 字符 const truncated = input.length > 500 ? input.slice(0, 500) : input; // 移除多余空白和特殊字符 const cleaned = truncated.replace(/\s+/g, ' ').replace(/[^\u4e00-\u9fa5a-zA-Z0-9,。!?、\s]/g, ''); // 从静态数据中取最近一轮上下文 const staticData = $getWorkflowStaticData('global'); const sessionId = $json.session_id; const lastContext = staticData[sessionId] || {}; return { cleaned_input: cleaned, last_intent: lastContext.intent || '', last_product: lastContext.product || '' };这个节点做了三件事:截断、清洗、取上下文。截断是因为用户有时候会粘贴一大段聊天记录,500 字符以外的内容对意图识别几乎没有帮助,反而浪费 Token。清洗是去掉表情符号和特殊字符,这些字符在 Tokenizer 里往往占多个 Token。取上下文是为了让模型知道上一轮聊了什么,但只取关键字段,不取完整历史。
实测下来,这个 Code 节点平均能把输入长度压缩 40%,对应 Token 消耗降低约 35%。而且它是纯本地计算,零成本。
4. 完整工作流搭建与关键参数计算
4.1 从 Webhook 到模型调用的完整链路
整个工作流有 9 个节点,按执行顺序排列:
- Webhook 节点:接收 Java 请求,校验 Header Auth。
- Code 节点(预处理):清洗输入,取上下文。
- OpenAI 节点(意图识别):调用模型,输出结构化 JSON。
- Code 节点(结果校验):解析模型输出,校验字段合法性。
- IF 节点(置信度判断):confidence < 0.6 走人工兜底分支。
- HTTP Request 节点(查订单):调 Java 后端订单查询接口。
- IF 节点(状态判断):判断订单是否允许退款。
- Set 节点(组装响应):生成标准响应 JSON。
- HTTP Request 节点(回调 Java):把结果回传给 Java 回调接口。
这个链路里,只有第 3 步是非确定性的,其余 8 步全部是确定性操作。即使模型偶尔输出格式错误,第 4 步的 Code 节点也能捕获并抛出异常,触发 n8n 的错误工作流,而不是让错误结果继续往下流。
4.2 模型输出校验节点的容错逻辑
第 4 步的 Code 节点是整个工作流的“安全阀”。它的逻辑如下:
const raw = $json.message?.content || $json.text || ''; let parsed; try { // 尝试直接解析 parsed = JSON.parse(raw); } catch (e) { // 尝试提取 JSON 片段 const match = raw.match(/\{[\s\S]*\}/); if (match) { try { parsed = JSON.parse(match[0]); } catch (e2) { throw new Error('模型输出无法解析为 JSON: ' + raw.slice(0, 200)); } } else { throw new Error('模型输出中未找到 JSON: ' + raw.slice(0, 200)); } } // 校验必填字段 const validIntents = ['refund', 'query', 'complaint', 'other']; if (!validIntents.includes(parsed.intent)) { parsed.intent = 'other'; parsed.confidence = 0; } if (typeof parsed.confidence !== 'number' || parsed.confidence < 0 || parsed.confidence > 1) { parsed.confidence = 0; } parsed.product = String(parsed.product || '').slice(0, 100); parsed.order_hint = String(parsed.order_hint || '').slice(0, 100); return parsed;这段代码做了三层容错:第一层是解析容错,模型可能返回带 markdown 包裹的 JSON,用正则提取;第二层是枚举校验,非法 intent 直接降级为 other;第三层是类型校验,confidence 不是数字就置 0。经过这三层,后面节点拿到的数据一定是干净的。
实操心得:不要相信模型会“严格遵守”格式要求。即使开了 JSON mode,模型在遇到复杂输入时仍可能输出多余文字。校验节点必须写,而且要把所有可能的异常都考虑到。
4.3 订单查询接口的参数组装与超时设置
第 6 步的 HTTP Request 节点调 Java 后端的/api/order/query接口。参数组装逻辑放在前一个 Code 节点里:
const hint = $json.order_hint || ''; const product = $json.product || ''; const userId = $json.user_id; // 如果 hint 是时间范围,转换成具体日期 let dateRange = null; if (hint.includes('上周')) { const now = new Date(); const lastWeekStart = new Date(now.getTime() - 7 * 24 * 3600 * 1000); dateRange = { start: lastWeekStart.toISOString().slice(0, 10), end: now.toISOString().slice(0, 10) }; } return { user_id: userId, product_name: product, order_no: /^\d{10,20}$/.test(hint) ? hint : '', date_start: dateRange?.start || '', date_end: dateRange?.end || '' };HTTP Request 节点的超时设置为 5 秒,重试 2 次,重试间隔 1 秒。为什么是 5 秒?因为 Java 后端的订单查询接口 P99 响应时间是 800 毫秒,5 秒足够覆盖网络抖动。重试 2 次是为了应对偶发的连接超时,但不能再多,否则整个工作流的响应时间会超过前端能接受的 10 秒上限。
4.4 回调 Java 的签名与幂等处理
第 9 步回调 Java 时,请求体里除了业务数据,还要带一个签名。签名算法是 HMAC-SHA256,密钥和 Webhook 的 Secret 分开管理:
const crypto = require('crypto'); const payload = JSON.stringify($json); const secret = $env.JAVA_CALLBACK_SECRET; const signature = crypto.createHmac('sha256', secret).update(payload).digest('hex'); return { body: $json, headers: { 'Content-Type': 'application/json', 'X-Callback-Signature': signature, 'X-Session-Id': $json.session_id } };Java 侧收到回调后,先用同样的算法验签,验签通过再处理。幂等处理靠session_id + timestamp做唯一键,如果同一个 session 在 1 秒内重复回调,直接丢弃。这样即使 n8n 因为网络问题重试,也不会导致 Java 侧重复落库。
5. 踩坑记录与常见问题速查
5.1 模型输出格式不稳定的五种表现
在实际运行中,我们遇到过至少五种模型输出格式异常的情况,整理成表格方便排查:
| 异常表现 | 出现频率 | 根因 | 解决方案 |
|---|---|---|---|
| JSON 被 markdown 代码块包裹 | 高频 | 模型训练数据中代码块常见 | 校验节点用正则提取 |
| 字段名大小写不一致 | 中频 | 模型对驼峰命名不敏感 | 校验节点统一转小写 |
| confidence 输出为字符串 | 中频 | 模型把数字当文本输出 | 校验节点做类型转换 |
| 输出多余解释文字 | 低频 | Prompt 约束不够强 | 强化“只输出 JSON”指令 |
| 枚举值造同义词 | 低频 | 模型自由发挥 | 枚举校验 + 降级处理 |
这五种里,前三种靠校验节点就能解决,第四种需要调 Prompt,第五种必须做枚举白名单。我们的经验是:永远不要假设模型会按你期望的格式输出,校验节点的代码量应该和业务逻辑代码量相当。
5.2 n8n 工作流超时与并发限制的调优
n8n 默认的工作流超时是 300 秒,并发执行数取决于部署配置。我们用的是 Docker 部署,单实例,默认并发是 10。压测时发现,当并发超过 8 时,工作流执行时间从平均 2 秒涨到 6 秒,因为模型 API 的速率限制被触发了。
调优措施有三个:第一,在 n8n 的EXECUTIONS_TIMEOUT环境变量里把超时改成 30 秒,避免慢请求堆积;第二,在 OpenAI 节点里设置maxRetries: 2和timeout: 10000,让模型调用快速失败;第三,在 Java 侧做限流,用 Guava RateLimiter 控制每秒最多 5 个请求进入 n8n。这三招下来,P99 响应时间稳定在 3 秒以内。
提示:n8n 的并发数不是越大越好。模型 API 的速率限制是硬瓶颈,并发开太大只会导致大量请求排队超时。先测出模型 API 的 QPS 上限,再反推 n8n 的并发配置。
5.3 Token 用量监控与异常告警
Token 降本不是一劳永逸的,需要持续监控。我们在 n8n 的模型调用节点后面加了一个 Code 节点,把每次调用的 Token 用量写入静态数据,Java 侧定时拉取并上报到监控系统。监控看板有三个核心指标:单次请求平均 Token、Token 消耗 P99、Token 消耗日环比。
告警规则设了两条:单次请求 Token 超过 2000 触发警告,日消耗环比增长超过 50% 触发严重告警。上线三个月,触发过两次警告,一次是因为运营在 Prompt 里加了一段很长的商品描述,另一次是因为某个用户输入了超长文本绕过了截断逻辑。两次都在半小时内定位并修复。
5.4 Java 侧对接 n8n 的五个注意事项
最后分享五个 Java 侧对接 n8n 的实操注意事项,都是踩过坑总结出来的:
- HTTP 客户端选型:不要用
RestTemplate,用WebClient或OkHttp。RestTemplate默认没有连接池,高并发下会频繁创建连接,延迟抖动大。 - 超时设置:连接超时 2 秒,读取超时 10 秒。n8n 工作流本身可能跑 3-5 秒,读取超时太短会导致大量假失败。
- 重试策略:只对 5xx 和连接超时重试,不对 4xx 重试。重试次数最多 2 次,且要加指数退避。
- 日志埋点:每次调用 n8n 都要记录
session_id、请求体摘要、响应时间、响应状态。出问题时靠这些日志快速定位是 Java 侧还是 n8n 侧的问题。 - 降级方案:n8n 不可用时,Java 侧要能降级到“纯规则匹配”模式,虽然效果差一些,但至少保证核心业务不中断。
这套方案我们跑了半年多,从最初的每天几十次异常降到现在的每周个位数。Token 成本从每月四千多降到八百左右。最深的体会是:Agent 的确定性不是靠模型变聪明实现的,而是靠架构设计把不确定性关进笼子里。n8n 的工作流编排恰好提供了这个笼子,Java 后端要做的,是守好笼子的门。