给老 Spring 项目装个 AI Agent

摘要:老系统想接 AI,别急着搭 Python sidecar。用 Spring AI 在原有 JVM 里跑起一个三段式 Agent:planner 拆活、executor 拿白名单工具干活、reporter 汇报。能力边界靠运行时隔离强制,比在提示词里反复叮嘱管用。

我们内部那套工单系统,2020 年上线,跑了快六年------框架这些年从 Spring Boot 2.x 一路升到 3.5,但业务代码、表结构还是当年的老底子。一线客服每天被问:这周哪些高优先级工单还没关?哪个部门积压最多?以前这些得人肉查列表、拼 SQL、再敲一段回复。慢,还容易漏。

我想让它自己回答。需求一句话:把老系统"能查什么、能改什么"交给模型,让它自己动手,回话像个懂业务的同事。

先说结论,省得往下读还绕:真正要的不是更聪明的聊天接口,是 Agent------一个能调用老系统能力的循环。而这个东西在 Java 里就有现成做法:Spring AI 1.1.x(官方框架,2026 年 6 月发到 1.1.8),不用换技术栈,不用起 Python 服务。


先想清楚:老系统缺的是"手"

只调大模型 API 的路子,试过的人都知道三个坎:

模型不会"查"。你只能把数据整个塞进提示词,一次塞不下,塞下了也是过期快照。

输出靠运气。让它列个工单清单,它可能给你编一段 Markdown 表格,列名还是它猜的。

它管不住自己。提示词里写"只读、不要改数据",它照样可能在你没注意的地方自作主张。

ChatGPT 是问答------你问一句,它答一句。Agent 是循环------理解目标、选工具、执行、看结果、不行再来。对后端工程师来说,这个概念其实不陌生:Agent 就是一个有 LLM 大脑的微服务,只是以前你写死 if-else 的分支,现在由模型决定调哪个工具、传什么参数。

给老系统装 Agent,本质是给它装"手":把 Service 方法暴露成模型能调的工具,让它在你的代码里干活,而不是在你的提示词里猜。

三段式:拆活、干活、交差分开

第一个坑来得很快:让同一个模型既拆任务、又执行、又检查,长任务里它必串台------拆着拆着开始编结果,执行时忘了边界。

解法是把一次请求拆成三个角色,各用独立的 ChatClient:

角色 职责 手里有什么
planner 看懂目标,拆成任务清单 没有工具,只输出结构化清单
executor 一次干一个任务,把结果交回去 只拿当前能力组的工具
reporter 汇总各步结果,组织成人话回复 只读上一步的结果

代码长这样------三个角色共享同一个 ChatModel,但 prompt 和工具完全分开:

java 复制代码
record Plan(List<String> steps) {}

@Service
class TicketAgentService {
    private final ChatClient planner, executor, reporter;

    TicketAgentService(ChatModel model, ToolCallbacks queryTools) {
        this.planner = ChatClient.builder(model)
            .defaultSystem("你是工单客服助手。把用户目标拆成 3 步以内的执行清单,输出 JSON,不要调用任何工具。")
            .build();
        this.executor = ChatClient.builder(model).build();   // 工具按请求动态给
        this.reporter = ChatClient.builder(model)
            .defaultSystem("根据执行结果,用中文回复用户,注明数据来源,不要编造没有查到的数字。")
            .build();
    }
}

拆开之后每个角色的上下文都很短:planner 不需要猜执行细节,executor 一次只看一个任务,reporter 只做归纳。实践下来,模型编造结果的次数明显变少------它手里的事情少了,能编的空间也就小了。

capability:边界靠结构,不靠提示词

这是整篇最想讲的部分。

Spring AI 2.0.1 的发布公告里有一个安全修复(CVE-2026-59318):某些配置下,即使某个工具没有公开给当前请求,模型仍可能被提示注入"拐"到全局兜底解析,把没暴露的工具调起来。官方原话的意思很直接:Agent 的边界不能只靠"告诉模型"维持,必须由运行时强制执行。

所以老系统的能力要分级,我分了两个 capability:

  • query:只读。查工单、按部门聚合、按状态筛选。
  • handle:可写。改状态、派单、加备注。(真实客服流程里写操作往往还要人工确认一环,这里先不展开。)

实现上每个 capability 是一组独立的 ToolCallback。调用时用 .tools() 显式传入------Spring AI 1.1 的规则是运行时工具完全覆盖默认工具,也就是说这次请求里,模型手里只有你给的那几个,多一个都没有:

java 复制代码
// 只读会话:模型手里只有查询工具,物理上碰不到写操作
String answer = executor.prompt()
    .system("你只能查询工单,禁止任何修改操作。")
    .user(userQuestion)
    .tools(queryCapability.callbacks())   // 只有 query 组的工具
    .call()
    .content();

想走"处理流程"(关单、派单)?那需要另一个带着 handle 工具的 executor 实例,并且由业务代码决定什么时候构造它------是否放权是代码逻辑,不是模型自觉

落地:老 Service 怎么变成 Agent 的手

老系统的 Service 不需要大改,加注解就行:

java 复制代码
@Service
class TicketQueryService {
    @Tool(description = "按部门统计未关闭工单的数量,按数量降序返回")
    List<DeptCount> countOpenByDept() { ... }   // 老方法原样保留
}

@Tool 是 Spring AI 的声明式工具注解(org.springframework.ai.tool.annotation),一个方法一个工具,方法签名自动变成模型可理解的参数协议。

planner 的输出要结构化成任务清单,用 entity() 把 JSON 直接映射成 record:

java 复制代码
Plan plan = planner.prompt().user(userQuestion)
    .call().entity(Plan.class);   // 输出非法 JSON 时这里会抛异常,见下文踩坑

然后是循环与兜底,三件事缺一不可:

步数上限。 1.1.x 没有内置的工具调用次数限制,我手写:任务不超过 3 步,executor 单任务重试不超过 2 次。超过就报错退出,让 reporter 如实告诉用户"这个问题我没处理完",绝不编一个"已完成"。

超时。 整个 Agent 调用包一层超时(60 秒),超时直接回退"已转人工"。客服场景里,宁可让人等,不能让机器人空转烧钱。

校验。 planner 拆出来的任务如果不在已知能力清单里,丢弃并跳过,reporter 如实说明哪些没做。

踩坑记录

坑一:模型想调用不存在的工具。 执行"把单号 T20260901007 直接关了"时,executor 手里只有查询工具,模型仍尝试调用 closeTicket。工具名解析失败,请求直接报错------我最初把报错透传给了用户("系统错误"),后来改成捕获并让 executor 用文本返回"当前会话只能查询工单,不能改单"。拦截是结构保证的,但这个台阶要自己铺。

坑二:循环烧 token。 有次 executor 在同一个问题上连续调了十几轮工具,每次都差一个数据,始终没停下来。加了步数上限后,这类问题从"悄悄烧钱"变成"快速失败"。工具调用循环必须有预算------这条后来写进了团队的代码规范。

坑三:结构化输出翻车。 planner 偶发返回非法 JSON------被截断,或者外面多包了一层 Markdown 代码块。处理:解析失败重试一次,仍失败就把整件事降级为"暂时处理不了",转人工。

坑四:工具描述就是泄密面。 一开始我把 @Tool 的 description 写得很"内部":"查询 t_ticket 表按 dept_id 聚合"。工具描述是要发给外部模型厂商的------这等于把表结构送出去了。改成业务化描述:"按部门统计未关闭工单数"。查什么、怎么查,留在方法里。

坑五:事务边界别指望 Agent。 老 Service 方法各自带 @Transactional。Agent 一次任务调多个方法,不代表一个事务。需要原子性的操作,绝不能拆成多步让模型自己拼------拆之前先想清楚哪些是"一个动作",那部分留给普通代码。

怎么验证这套东西

我把上面这套装进一个模拟工单库的示例工程,跑了客服最常见的几类问题做对照:单轮"全量上下文"直答,对比三段式 Agent。样本小、没做严格基准,结论只能当方向参考,但有两个差异是结构上必然成立的:

越权被拦,是必然的。写工具根本不在 executor 的工具列表里,模型想调也调不到------这不是它"自觉",是它手里没有。

多条件组合查询的格式稳定,是设计出来的。reporter 只做归纳,输出口径由它的 prompt 约束,不会出现"这次表格、下次散文"的漂移。

代价也要说清楚:三段式明显更烧 token------拆解、执行、汇总,每个角色都在消耗。多花的 token 买的是可控性,值不值,取决于你的场景对"乱来"的容忍度。客服这种对用户可见的场景,值。

总结

给老系统装 Agent,不是重写一遍,是加一个会调工具的大脑。核心三件事:拆角色 (planner/executor/reporter)、分能力 (capability 隔离工具)、设上限(步数、超时、重试)。

适用场景:Service 边界清晰、操作可枚举、对一致性要求不高的查询与辅助场景。不适用:一次动作横跨多库多事务、需要强一致的场景------那是人的活,别硬塞给 Agent。

版本提示:本文代码基于 Spring AI 1.1.8(Java 17 + Spring Boot 3.5 可直接用,参考官方文档)。2.0 已于 2026 年 6 月 GA,要求 Java 21 + Boot 4,把工具循环挪进了 ToolCallingAdvisor,循环预算(maxToolCalls)和执行校验都有了原生配置(见2.0.1 发布公告)。老系统升 2.0,本质是一次 Boot 大版本升级,别当小版本顺手升。

作者:唐悦玮 | 从后端出发,用 AI 拓展到全栈的工程师。

相关推荐
她的男孩1 小时前
多租户和数据权限怎么共存?扒完拦截器注册链路,我找到 4 个隐蔽的坑
java·后端·架构
工业HMI实战笔记1 小时前
玩具制造HMI:注塑成型的快速换模与质量监控界面
人工智能·学习·交互·制造
樊小肆1 小时前
离谱,每轮请求 25% 的 token,竟在重发模型想完就扔的内心独白
前端·人工智能·agent
游戏智眼1 小时前
HarmonyOS 7 适配升级:NIM SDK 释放端侧 AI 与网络能力
人工智能·harmonyos
智驭未来掌门人1 小时前
一个网关,三种接法,一个能落地的 Agent:llm-api-gateway-cli 项目介绍
人工智能
wordbaby2 小时前
混合检索:两全其美的艺术
人工智能·算法
小溪学编程2 小时前
Java BufferedReader 详解:从基础用法到性能优化
java·python·性能优化
Amy187021118232 小时前
数据中心电气接点测温:从“被动抢修”到“主动预警”的安全革命
人工智能·安全
HZero.chen2 小时前
Java 匿名类简介
java·匿名类