1. 为什么 Java 后端一碰 Agent 就容易“翻车”
1.1 从一次线上事故说起:幻觉是怎么把接口打挂的
去年年底我接手了一个客服工单自动分类的项目,技术栈是典型的 Spring Boot 加 MyBatis,业务侧想加一个“智能摘要”功能,让大模型读完用户对话后输出结构化的分类标签和摘要。第一版做得很糙,直接在 Service 里调模型接口,把用户原文拼进 Prompt,然后JSON.parse返回值。上线第三天就出事了:模型那天心情不好,返回了一段带解释性文字的结果,前面多了一句“好的,以下是分类结果”,JSON.parse直接抛异常,整个工单队列堵了两千多条。
这件事让我彻底想明白一个道理:大模型是一个概率系统,而 Java 后端是一个确定性系统,把概率系统直接嵌进确定性链路,等于在承重墙上开盲盒。所谓“幻觉”,在业务视角下不是模型胡说八道那么简单,而是它输出的格式、字段、枚举值、数量都可能偏离你的契约。Java 的强类型、编译期校验、事务边界,在模型面前全部失效,因为模型返回的永远是一个字符串。
后来我把这套链路重构了一遍,核心思路是:让模型只负责它擅长的“语义理解”,把所有确定性的事情交给工作流引擎和 Java 代码。具体落地时我用了 n8n 做编排层,Java 只做业务网关和最终校验。重构之后不仅稳定性上来了,Token 消耗还降了大约八成。这篇文章就把这套思路完整拆开讲,包括为什么这么选、每一步怎么配、踩过哪些坑。
1.2 谁适合看这篇:三类读者的不同收获
如果你是中高级 Java 后端,正在被“要不要把大模型接进核心链路”这个问题困扰,这篇能给你一套可落地的分层方案,告诉你哪些活该给模型、哪些活必须自己扛。如果你是刚接触 Agent 开发的新手,对 n8n、MCP 这些词还比较模糊,文中会用生活化的类比把概念讲清楚,你至少能看懂一条完整工作流是怎么跑起来的。如果你已经在用扣子、Dify、FastGPT 这类平台做原型,想往企业级生产环境迁移,那这篇里关于幂等、重试、Token 预算控制的部分,应该能帮你少走不少弯路。
需要提前说明的是,本文不涉及任何具体模型的接入凭证配置,重点在架构思路和编排方法,你换成任何一家模型服务,逻辑都是通的。
2. 整体架构设计:把“不确定”关进确定的笼子里
2.1 核心思路:模型做语义,代码做契约
我最终定下来的分层是这样的:最外层是 Java 的业务网关,负责鉴权、限流、参数校验、落库;中间是 n8n 工作流,负责编排模型调用、条件分支、重试、格式清洗;最内层才是模型本身。三者之间用 HTTP 和结构化数据通信,谁也不越界。
这么设计的关键考量是职责隔离。模型最擅长的是“把一段自然语言变成另一段自然语言”,它不擅长的是“保证输出一定是合法的 JSON 且字段齐全”。所以我在 n8n 里加了一个“结构校验节点”,模型输出先过一遍 schema 校验,不合法就触发重试或者走兜底分支,绝不让脏数据流到 Java 侧。Java 侧则完全不信任上游,收到数据后还要再做一次 Bean Validation,双重保险。
打个比方,这就像餐厅后厨:模型是那个手艺很好但偶尔会自由发挥的厨师,n8n 是传菜员兼质检员,Java 是前台收银系统。厨师可以创意发挥,但传菜员必须确认每道菜都符合菜单,前台只认标准订单。任何一环想“越权”,整个系统就会乱。
2.2 为什么是 n8n,而不是纯 Java 编排
很多人第一反应是:我 Java 写得好好的,为什么要引入 n8n?直接用 Spring 的@Async加线程池编排不行吗?我一开始也是这么想的,直到我发现纯 Java 编排有三个绕不过去的痛点。
第一是可视化调试成本。模型调用链路经常要改 Prompt、改分支条件、改重试策略,纯 Java 每次改动都要重新编译部署,调一次 Prompt 等五分钟。n8n 的工作流是配置化的,改完即时生效,调试时能直接看到每个节点的输入输出,这个效率差距在迭代期是数量级的。
第二是重试和降级的表达力。模型调用失败的原因五花八门:超时、限流、返回格式错误、内容被拦截。纯 Java 里这些逻辑会散落在各种 try-catch 里,越写越乱。n8n 原生支持节点级重试、错误分支、条件路由,把这些横切逻辑收敛到一处。
第三是和 MCP 生态的衔接。MCP 协议现在越来越多地被用来给 Agent 挂载工具能力,n8n 对 MCP 的支持比较直接,Java 侧要自己实现一套 MCP 客户端不是不行,但没必要重复造轮子。让 n8n 去对接工具生态,Java 专注业务,分工更清晰。
当然,n8n 不是银弹。它不适合做高并发的主链路,也不适合承载复杂事务。我的定位很明确:n8n 是编排层,不是业务层。它处理的是“调用顺序和容错”,不处理“业务规则和持久化”。
2.3 Token 直降 80% 的账是怎么算出来的
标题里说 Token 降 80%,这不是拍脑袋的数字,我拿实际数据算过。重构前的做法是:每次请求把完整对话历史(平均 3000 字)加上一大段 Prompt 模板(约 800 字)一起发给模型,单次输入约 3800 字,按中文大约 1.5 字一个 Token 估算,输入约 2500 Token,输出约 300 Token,单次合计约 2800 Token。
重构后做了三件事:一是历史压缩,n8n 里加了一个预处理节点,把超过 5 轮的对话先用规则截断加摘要,压到 800 字以内;二是Prompt 瘦身,把原来塞在 Prompt 里的枚举定义、示例全部移到 n8n 的 schema 校验节点,模型只收到必要的指令,模板降到 200 字;三是缓存命中,相同意图的请求走缓存分支,直接返回历史结果,不调模型。
算下来单次输入降到约 700 字,输出还是 300 Token 左右,合计约 750 Token。750 除以 2800,大约是 27%,也就是降了 73%。再加上缓存分支的命中率(我们场景下约 15% 的请求能命中),综合下来 Token 消耗降幅稳定在 80% 上下。这个账的关键不在模型本身,而在你喂给模型的东西有多少是冗余的。
3. n8n 工作流的核心节点拆解与配置要点
3.1 入口节点:Webhook 接收与参数预校验
工作流的入口我用的是 Webhook 节点,Java 侧通过 HTTP POST 把请求打过来。这里有个细节值得说:Webhook 节点一定要开启“Raw Body”模式,否则 n8n 会尝试自动解析 JSON,遇到格式稍微不规范的请求体就会报错。开启 Raw Body 后,原始字符串原封不动传进来,后续节点自己解析,可控性更强。
参数预校验我放在 Webhook 之后的第一个 Function 节点里做,主要检查三件事:必填字段是否存在、字段类型是否正确、请求 ID 是否重复(幂等检查)。幂等这块我是用 n8n 的静态数据存储做的简易去重,生产环境更稳妥的做法是把请求 ID 落到 Redis,n8n 里通过 HTTP 节点查一下。
注意:Webhook 节点默认的超时时间比较短,模型调用链路长的时候容易触发超时。建议在节点设置里把超时调到 60 秒以上,同时 Java 侧也要相应调整 HTTP 客户端的读超时,两边要匹配,否则会出现 n8n 还在跑、Java 已经断开的情况。
3.2 历史压缩节点:把长对话“挤干水分”
历史压缩是整个 Token 优化的核心。我的做法是在 Function 节点里写一段 JavaScript,逻辑分三步:先按轮次切分对话,保留最近 3 轮原文;再对更早的轮次做规则化摘要,比如只保留用户的核心诉求和已确认的关键信息;最后把摘要和最近原文拼起来,控制在 800 字以内。
这里有个经验:不要用模型去做摘要,用规则。我一开始图省事,让模型先摘要再分类,结果 Token 没省下来反而翻倍,因为摘要本身也要消耗 Token。规则化摘要虽然粗糙,但对工单分类这种场景足够用,而且零 Token 成本。规则怎么写?我的做法是提取每轮对话里的关键词(用简单的词典匹配),拼成一句话,比如“用户反馈:登录失败;已确认:账号存在;待解决:验证码收不到”。
3.3 模型调用节点:Prompt 模板的极简主义
模型调用节点我配置得很克制。Prompt 模板只保留三部分:角色定义一句话、任务描述一句话、输出格式要求一句话。原来那些“你是一个专业的客服助手,请仔细阅读以下对话……”的客套话全部删掉,模型不需要这些也能干活。
输出格式要求我写得很硬:“只输出 JSON,不要任何解释文字,字段为 category 和 summary。”实测下来,加了“不要任何解释文字”这句之后,格式错误率从大约 8% 降到了 2% 以下。剩下的 2% 靠后面的校验节点兜底。
模型参数方面,温度我设的是 0.1,尽量压低随机性。最大输出 Token 限制在 500,防止模型话痨。这两个参数是配合使用的:低温度保证稳定,低上限防止跑偏。
3.4 结构校验与重试节点:幻觉的“最后一道闸”
这是我认为整个工作流里最重要的节点。模型返回后,先进一个 Function 节点做 JSON 解析和 schema 校验。校验逻辑包括:能否解析成 JSON、是否包含必需字段、字段类型是否正确、枚举值是否在允许范围内。任何一项不通过,就抛出一个带错误码的异常。
n8n 的节点级重试配置在这里派上用场。我把模型调用节点和校验节点包在一个错误处理分支里,校验失败时触发重试,最多重试两次。重试时会在 Prompt 里追加一句“上次输出格式错误,请严格只输出 JSON”,实测这个提示能显著提高第二次的成功率。
如果两次重试都失败,就走兜底分支:返回一个默认分类“待人工处理”,同时把原始请求记到日志表,后续人工介入。兜底分支的存在,是让整个链路“永不失败”的关键。用户永远能拿到一个结果,哪怕这个结果是“需要人工看”。
4. Java 侧如何与 n8n 配合:网关、校验与降级
4.1 网关层设计:把 n8n 当成一个“不稳定的下游”
Java 侧我把它定位成业务网关,核心原则是把 n8n 当成一个可能超时、可能返回脏数据的普通下游服务,而不是什么特殊存在。基于这个原则,网关层做了四件事。
第一是超时隔离。给 n8n 的调用单独配一个线程池,超时时间设 8 秒,超过就快速失败,绝不拖垮主线程。第二是熔断降级。用 Resilience4j 做熔断,n8n 连续失败超过阈值就自动降级到本地规则引擎,先保证业务能跑。第三是响应校验。收到 n8n 返回后,用 Bean Validation 再校验一遍,字段缺失或类型不对直接走降级。第四是异步落库。业务结果先返回给前端,落库操作异步做,避免数据库抖动影响响应时间。
这套设计下来,n8n 挂了业务也不会挂,只是智能分类退化成规则分类,用户体验有损但不中断。
4.2 幂等与重试:别让一次请求变成三次扣费
模型调用是有成本的,重试必须谨慎。我在 Java 侧给每个请求生成一个全局唯一的 requestId,n8n 侧用这个 ID 做幂等键。如果同一个 requestId 在短时间内重复到达,n8n 直接返回缓存结果,不重复调模型。
重试策略上,我只对可重试错误做重试:网络超时、限流、格式错误可以重试;内容被拦截、参数非法这类错误重试也没用,直接失败。重试次数上限设为 2,且每次重试间隔递增(1 秒、3 秒),避免雪崩。
提示:幂等键的存储建议用 Redis 并设置合理的过期时间,比如 10 分钟。太短起不到去重作用,太长会占用内存。n8n 侧可以通过 HTTP 节点访问 Redis,也可以用它的静态数据做轻量级缓存,看你的部署条件。
4.3 降级方案:规则引擎兜底怎么写
降级方案我准备了两套。第一套是关键词规则引擎,维护一个分类关键词表,请求进来先做关键词匹配,命中就直接返回,不调模型。这套规则覆盖了我们场景下大约 40% 的高频请求,等于给模型减负。第二套是默认兜底,所有规则都没命中且模型不可用时,统一返回“待人工处理”。
关键词表怎么维护?我的经验是从历史数据里挖。把过去一个月的工单拿出来,统计每个分类下出现频率最高的词,人工筛一遍,形成初始词表。之后每周复盘一次误判案例,持续补充。这套规则引擎虽然土,但在模型不可用的关键时刻,它就是业务的救命稻草。
5. 常见问题与排查技巧实录
5.1 模型返回格式错误的排查路径
格式错误是最常见的问题,排查我一般按这个顺序走:先看 n8n 里模型节点的原始输出,确认是模型本身跑偏还是后续节点处理错了;再看 Prompt 里有没有容易被模型误解的表述,比如“请尽量输出 JSON”这种模糊指令,要改成“只输出 JSON”;最后看温度参数,温度高于 0.3 时格式错误率会明显上升。
有个小技巧:在 Prompt 末尾加一句“输出前请自检 JSON 是否合法”,实测能再降一两个百分点的错误率。另外,如果模型支持结构化输出(比如 JSON Mode),一定要开启,这是最省心的方案。
5.2 Token 消耗异常增长的定位方法
Token 突然涨了,先查三个地方。第一是历史压缩节点是否失效,有时候对话轮次统计逻辑有 bug,导致压缩没生效,原文全量传给了模型。第二是缓存命中率是否下降,缓存键设计不合理会导致大量请求穿透到模型。第三是Prompt 模板是否被误改,团队协作时经常有人手滑加了一堆示例进去。
我的做法是在 n8n 里加一个统计节点,每次调用后记录输入输出 Token 数,定期汇总。这样一旦有异常,能快速定位到是哪天、哪个节点开始涨的。
5.3 n8n 工作流超时与并发瓶颈
n8n 默认是单进程执行的,高并发下容易成为瓶颈。我的处理方式是:把 n8n 部署成队列模式,主进程只负责接收请求,实际执行交给 worker 进程,可以横向扩展多个 worker。同时给工作流设置合理的并发上限,避免模型侧被限流。
超时问题前面提过,核心是两边超时时间要匹配。另外,n8n 里耗时长的节点(比如模型调用)建议单独设置更长的超时,不要用全局默认值。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 模型返回非 JSON | Prompt 指令模糊、温度过高 | 检查 Prompt 和温度参数 | 加“只输出 JSON”指令,温度降到 0.1 |
| Token 消耗突增 | 历史压缩失效、缓存穿透 | 检查压缩节点和缓存命中率 | 修复压缩逻辑,优化缓存键 |
| n8n 响应超时 | 超时配置不匹配、并发过高 | 对比两边超时设置 | 统一超时时间,扩展 worker |
| 重复调用模型 | 幂等键失效 | 检查 requestId 生成和存储 | 用 Redis 存幂等键,设合理过期 |
| 分类结果不稳定 | 温度过高、Prompt 有歧义 | 检查温度和 Prompt 表述 | 降温度,消除 Prompt 歧义 |
6. 几个我踩过的坑和最后的经验
第一个坑是过度依赖模型做格式转换。我一开始让模型直接把结果转成 Java 对象需要的格式,结果模型经常自作主张加字段。后来改成模型只输出最简 JSON,格式转换全部在 n8n 和 Java 里用代码做,稳定多了。模型就该干语义的活,格式的活交给代码。
第二个坑是重试没有区分错误类型。早期所有错误都重试,结果内容被拦截的请求重试了三次还是被拦截,白白浪费 Token。后来加了错误分类,只重试可恢复的错误,Token 浪费少了一大截。
第三个坑是忽略了 n8n 的版本兼容性。n8n 迭代很快,某些节点在不同版本间行为有差异,升级前一定要在测试环境跑一遍完整工作流。我有次升级后 Webhook 节点的 Raw Body 默认值变了,导致线上解析全挂,排查了半天。
最后一个经验:这套架构的价值不在于用了多新的技术,而在于把不确定性收敛到了可控的范围内。模型可以幻觉,但工作流不能,Java 更不能。把每一层的职责划清楚,让模型只做它擅长的事,剩下的交给确定性的代码,这才是 Java 后端驾驭 Agent 的正确姿势。至于 Token 降 80%,那只是这个思路顺带带来的收益,真正值钱的是系统的稳定性和可维护性。