摘要
业务系统要接 AI,似乎默认是 Python 的事。但我的管理系统跑在 Spring Boot + MyBatis 上,引入一整套 Python 技术栈做旁路太重。所以我选择在 Java 里自己造一个够用的 Agent 壳:感知 - 决策 - 行动(P-D-A)模板方法 + 双 Memory 记忆 + SSE 流式输出,再把工具层用 MCP 协议标准化暴露,最终用一个 Router Agent 把三个专业 Agent 调度起来,接入阶跃星辰大模型完成意图识别与任务分发。
做完的体会是:亲手画过一遍模板方法的人,比调过框架的人更懂 Agent 在干什么。
一、为什么在 Java 里自己造 Agent 框架
今年做 AI 应用,情绪是从"AI 只能 Python"开始的。我不是要否定这个生态,而是算了一笔账:为一个 AI 助手引入 Python 微服务旁路,意味着多维护一套技术栈、一套部署、一套监控;而我要的 Agent 能力其实很聚焦------把用户的自然语言查询转成对业务数据的结构化查询。
所以选择在 Spring Boot 里自己做一个"够用的壳"。所谓 Agent 框架,本质就是三件事:
| 维度 | 说明 |
|---|---|
| 骨架 | 定义执行流程:感知 → 决策 → 行动 |
| 工具 | Agent 能调用的业务能力:查询订单、统计用户、审核内容...... |
| 记忆 | 跨步骤、跨轮次传递上下文 |
对应的落点我后面会说:P-D-A 模板方法提供骨架,Tool 注册中心管理工具,双 Memory 管记忆。最后再加一层 LLM 让"意图识别和工具选择"从死规则变成真自然语言。
二、整体架构
整体架构不复杂:前端对话页通过 SSE 调 AgentController,AgentEngine 拿到请求后交给 BusinessQueryAgent 走 P-D-A 执行流,执行时通过工具层调用真实业务 Service(MyBatis 查询等),ThinkTrace 把"感知→决策→执行"的过程实时流式推回前端。
核心组件一览:
| 组件 | 职责 |
|---|---|
| Agent(抽象基类) | P-D-A 模板方法,提供 execute / executeStream |
| AgentRegistry | Spring 启动时 @PostConstruct 自动扫描注册所有 Agent |
| AgentEngine | 调度引擎,注入上下文后执行 |
| AgentController | SSE 入口:POST /api/agent/execute-stream |
| Perceive / Decide / Act | 三阶段的入参出参对象 |
| ConversationMemory | 按 userId 的多轮对话记忆 + 指代消解 |
| McpToolRegistry | 工具定义唯一数据源(MCP 与 LLM Prompt 共用) |
关键设计只有一句话:框架只提供骨架和执行顺序,具体 Agent 继承基类、实现自己的感知和决策,工具通过注册中心自动装配。 加一个新的专业 Agent 不需要改任何框架代码。
三、P-D-A 模板方法 + 双 Memory
Agent 基类用模板方法固定执行顺序:感知(Perceive)理解输入、提取意图和实体;决策(Decide)制定执行计划------调哪些工具、什么顺序、什么参数;行动(Act)按步骤调用工具链产出结果;最后把本轮结果存入 Conversation Memory。整个流程通过 AgentEvent 推送 STAGE / COMPLETE / ERROR 事件,前端 ThinkingTrace 组件实时渲染"思考过程"。
这个框架的简单版是这样:
java
public abstract class Agent {
public AgentResponse execute(AgentRequest request) {
Perception p = perceive(request); // 感知:意图 + 实体
Decision d = decide(p); // 决策:拆解为步骤
return act(d); // 行动:调用工具链
}
protected abstract Perception perceive(AgentRequest request);
protected abstract Decision decide(Perception perception);
}
Working Memory(执行内记忆) 解决一次执行里步骤间的数据传递。"查一下张三的订单"会被拆成两步:第一步 userTool 查用户拿到张三,第二步 orderTool 基于上一步的结果查订单。实现方式很朴素------Act 循环里把上一步的工具返回注入下一步的参数内层 Map,键名 _previousResult,下一步的工具读取它继续查询。链式意图映射用一张表声明:
json
{"user", "order"} // 先查用户 → 再查该用户的订单
{"user", "dashboard.order"} // 先查用户 → 再查该用户的订单统计
Conversation Memory(对话记忆) 解决跨轮次。存储结构按 userId 隔离,保存上一轮输入、意图、实体和最近 10 轮历史。它最值钱的能力是指代消解:第二轮用户说"他的订单呢",系统从记忆里取出上轮已解析的实体,把"他"替换成"张三",再走正常的感知决策。当前用 ConcurrentHashMap 内存存储,重启即失,后续换 Redis 只需改一个类。
四、LLM 集成:从"能聊天"到"敢上线"
这一步是整个项目里坑最多的部分。LLM 接上"能聊天"很容易,难的是让它在一个生产系统里稳定地干活。三个真实的坎,按顺序写。
4.1 第一道坎:它不好好输出 JSON
接入初期,感知层让 LLM 返回结构化意图,它返回的却是 "Identified intent type: order" 这种描述性文本------解析器直接崩,执行链路全断。
三层防御把它治住了:
第一层,请求体强制输出约束:
json
{
"model": "step-3.7-flash",
"temperature": 0.3,
"response_format": { "type": "json_object" }
}
response_format 是关键,它让响应内容只含合法 JSON。但即便强制了,它偶尔还会在外面包 ```json 代码块、或者前面附一句"以下是分析结果:"。
第二层,写一个提取方法,把响应里第一个 { 到最后一个 } 之间的内容抠出来,兜住代码块和各种前后缀。
第三层是最后一道闸:意图白名单校验。LLM 返回的 intent 必须属于预定义枚举,非法值一律拦截并降级到关键词匹配。
java
if (!validIntents.contains(intent)) {
return perceiveWithKeywords(originalInput); // 降级:关键词匹配
}
模型能力也很重要。最开始用的 step-3.5-flash 指令遵循弱,频繁输出非结构化文本;升级到 step-3.7-flash 后,配合 JSON 约束,稳定性上了一个台阶------省下的解析兜底代码,远比模型差价值钱。
4.2 第二道坎:它会把你给的信息当真
一个很隐蔽的坑:决策层的 Prompt 里带了置信度字段,结果 LLM 把 "0.85" 当成搜索关键词,去查一个叫 "85%" 的用户------查询自然无结果。
根因是:你给 LLM 的每一条信息,它都会当作用户意图的一部分。 置信度、内部标记这类程序才懂的辅助字段,落在模型眼里就是需求。解法是把决策层输入瘦身------只保留原始输入和实体,辅助字段不进 Prompt。早期还有中文引号进 Java 字符串导致编译失败的问题,后来 Prompt 里统一用方括号包同义词组([订单/报名/缴费])规避。
4.3 第三道坎:不稳定,就要有"不带 LLM 的路"
LLM 天然非确定:同一句话多问几次偶发不同意图;复合查询("查订单顺便看统计")不稳定;短输入("你好""你是谁")经常误判。这些无法根除,我的处理原则是------所有入口都保留一条不经过 LLM 的降级路径。感知和决策两层列了一整张降级表:
| 异常场景 | 降级目标 |
|---|---|
| LLM 开关关闭 / 客户端未注入 | 关键词匹配 |
| 调用超时/网络异常 | 关键词匹配 |
| 返回 JSON 解析失败 | 关键词匹配 |
| 返回非法意图值 | 关键词匹配(白名单拦截) |
| 意图为 unknown | 直接走规则映射,不让 LLM 决策 |
最后一条特意说明:意图识别为 unknown 时,LLM 容易自由发挥,所以 unknown 直接跳 LLM 决策、走规则返回"无法识别的意图"文本。LLM 在可控的场景里干活,不可控的直接绕开它。
4.4 工程细节:SSE 线程与上下文
SSE 用 SseEmitter 异步推送,执行发生在新线程,Spring Security 上下文不会自动带过去------ThreadLocal 不跨线程。处理方式是在 Controller 捕获主线程的 ServiceContext,注入新线程,finally 中清理。这个坑不深但隐蔽,排查时容易以为是权限问题。
五、工具层 MCP 化:一处定义,三处复用
工具层是 Agent 与业务世界的接口。早期工具的描述在 LLM Prompt 里硬编码,MCP 工具列表又是另一份维护------加一个工具改两处,总有漏。所以我用 MCP 协议做了统一:
暴露一个 JSON-RPC 2.0 端点 POST /eco-link/mcp,支持 tools/list、tools/call、prompts/list 三个方法。关键是 McpToolRegistry 成为工具定义的唯一数据源,三处共用:
| 消费方 | 调用方式 |
|---|---|
| MCP Server 的 tools/list | getDefinitions() |
| MCP Server 的 tools/call | callTool(name, args) |
| Agent 决策层 LLM Prompt | buildFullDecisionPromptSuffix() 动态拼接工具描述 |
加一个新工具,只需要在 McpToolRegistry.buildDefinitions() 里加一段定义,LLM 的 Prompt 和 MCP 的工具列表自动同步。Agent 内部执行工具也统一走 callTool,与外部 MCP 调用共用一条路径,保证两边的执行逻辑一致。
顺带踩了个兼容坑:项目里用的是 fastjson 2.0,它的 getInnerMap() 等 API 与旧版不兼容,构造嵌套 JSON Schema 得手动用 HashMap;MCP 标准里 required 字段是字符串数组,也要注意类型。
六、多 Agent 协作:Router + 三个专业 Agent
单个 Agent 处理不了混合需求。"帮我看看登录接口有没有问题,顺便查一下今天登录人数"------一半是开发审查,一半是数据查询,一个 Agent 做不了两件事。架构上就是加一个 Router Agent 做调度:
Router Agent 用三层路由判断请求分发给谁:
| 层级 | 规则 | 目的 |
|---|---|---|
| 第一层 | 关键词预路由:出现"查询/订单/统计/审核"等 → businessQuery | 避免 LLM 对明显业务查询误判 |
| 第二层 | 高频聊天词(你好/谢谢/在吗)→ Quick Replies 硬编码直接回 | 绕过 LLM,省钱又稳 |
| 第三层 | 模糊输入 → 交 LLM 感知,输出 targetAgent | 兜底 |
模型分工也值得一提:业务解析用 step-3.7-flash(贵一点但稳),纯聊天用 step-3.5-flash(便宜),不同 Agent 用不同模型,用配置项分离。每类 Agent 的 Prompt 全部外置到 agent-prompts.properties,由 PromptLoader 启动时加载缓存,不再硬编码在 Java 里。
扩展新 Agent 只需四步:新建类继承 Agent 实现感知决策、加 @Component、在 properties 里加 Prompt、在 Router 的可用 Agent 列表里登记------Registry 自动扫描、Engine 自动支持、前端下拉框自动出现。
七、踩坑与排查:生产环境的隐形沟
几个值得单独记的:
- 日志是唯一排查工具 。LLM 调用封装在 StepFunClient,日志带 URL、模型、消息数、完整响应;Agent 每次感知/决策结果都打 INFO 日志(
LLM 感知: 输入=... 意图=... 置信度=...);降级动作打 WARN。出问题先 grep[StepFunClient]和[McpToolRegistry],比盲猜快得多。 - 一次失败就降级太粗暴。当前设计"一次失败立即回退规则",安全性够了但体验糙,下一步计划加指数退避重试(最多 2 次)。
- 数值类型坑:LLM 返回的数字被 fastjson 解析为字符串,"123" 和 123 混用导致查询条件拼接出错,需要对数值字段统一做类型转换。
- 多轮记忆不持久:ConcurrentHashMap 内存实现,重启清空。进生产前要换 Redis。
八、三条铁律
把 LLM 塞进生产代码,坚持三条:
- 输出必须校验,假设它永远会撒谎。 JSON 约束 + 白名单 + 非法值降级,三道防线缺一不可。
- 确定性逻辑不进模型。 敏感词、黑白名单、流程编排用代码;LLM 只做单点判断------意图、实体、选工具。
- 永远留一条不带 LLM 的路。 全降级链保证模型挂了,系统还能按规则跑。
做到了这三点,你在业务系统里接 AI 才有资格说"生产级"。
文中所有实现均来自我在实际项目里的完整落地,代码与架构图可在评论区留言交流。下期预告:《把 AI Agent 接进真实业务:意图识别与业务系统的解耦》。