ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java版Personal Agent实战:基于Spring Boot与Spring AI构建生产级智能体

Java版Personal Agent实战:基于Spring Boot与Spring AI构建生产级智能体

1. 这个 Java 版 Personal Agent 到底解决了什么问题

1.1 从“调个 API”到“跑一个 Agent”,中间差了什么

过去一年,围绕大模型的工程实践基本分成了两条路。一条是 Python 生态里各种 Agent 框架快速迭代,另一条是 Java 生态里大量业务系统想接入大模型,却卡在“怎么把模型能力稳定地嵌进现有服务”这一步。很多团队一开始的做法很朴素:在 Spring Boot 里写个RestTemplate或者WebClient,拼一段 prompt,调一次模型接口,把返回的字符串塞回业务逻辑。这个做法在 Demo 阶段没问题,一旦要上线,问题就全冒出来了。

我见过太多这样的场景:一个客服辅助系统,第一版就是“用户问题 + 固定提示词 → 模型 → 返回答案”。上线第一天还行,第二天开始出现上下文丢失、多轮对话串台、工具调用失败、超时没有兜底、模型返回格式不稳定导致解析异常。更麻烦的是,这些逻辑散落在各个 Service 里,没有统一的会话管理、没有可观测性、没有重试和降级策略。说白了,这不是 Agent,这只是“带大模型的一次性接口调用”。

所谓 Personal Agent,核心不在于“能聊天”,而在于它具备几个工程特征:有记忆、能调用工具、能按目标拆解任务、有状态管理、可观测、可降级。Java 版 Personal Agent 这个项目之所以值得拿出来讲,就是因为它把上面这些生产级细节,用 Spring Boot + Spring AI 这套组合拳摊开来了。它不是教你写一个聊天机器人,而是教你搭一个能在生产环境里跑住的智能体服务。

1.2 为什么是 Spring Boot + Spring AI 这套组合

选型这件事,很多人只看“能不能跑”,不看“跑起来之后谁来维护”。Java 团队选 Spring Boot 几乎是默认动作,因为现有系统、监控、配置中心、鉴权体系大概率都在这套生态里。真正需要解释的是为什么用 Spring AI,而不是自己封装 HTTP 客户端。

自己封装的问题在于,你要重复造一堆轮子:模型提供方的请求格式差异、流式响应的解析、函数调用的协议适配、向量库的对接、对话记忆的存储抽象。这些轮子每一个单独看都不难,但拼在一起就是持续维护成本。Spring AI 的价值在于它提供了一层统一的抽象:ChatClient、ChatModel、EmbeddingModel、VectorStore、Advisor这些接口把模型交互标准化了,切换模型提供方时业务代码基本不用动。

提示:选框架不是选功能最多的,而是选“出问题时你能看懂它内部在干什么”的。Spring AI 的抽象层次相对克制,调试时能顺着调用链找到实际请求,这一点在生产排障时非常关键。

这个项目的定位很清晰:面向已经有一套 Spring Boot 业务系统、想在不推翻现有架构的前提下引入 Agent 能力的团队。它适合有一定 Java 后端基础、了解 Spring 生态、但对大模型工程化还比较陌生的开发者。如果你只是想快速做个聊天 Demo,那用什么都行;但如果你要的是一个能接进生产、能被监控、能扛住并发和异常的系统,那这套东西的参考价值就体现出来了。

2. 整体架构设计与核心模块拆解

2.1 分层设计:把“智能”和“工程”分开

这个项目最值得学的一点,是它的分层思路。很多 Agent 项目把提示词、工具逻辑、业务规则全揉在一个类里,改一处牵动全身。而这个项目的做法是把系统切成几层,每层职责单一。

第一层是接入层,负责 HTTP 接口、参数校验、鉴权、限流。这一层完全不关心模型怎么调,只负责把请求转成内部命令对象。第二层是编排层,也就是 Agent 的核心,负责会话管理、任务规划、工具调度、上下文组装。第三层是能力层,包括模型调用、向量检索、工具执行。第四层是基础设施层,涵盖持久化、缓存、消息、监控。

这样分层的好处是,当你想换模型、加工具、改记忆策略时,改动被限制在特定层内。比如把对话记忆从内存换成数据库,只需要替换记忆存储的实现,编排层代码不动。这种可替换性在生产环境里就是救命稻草,因为需求变化是常态。

2.2 会话与记忆:Agent 的“记性”是怎么做出来的

Agent 有没有记忆,是它和普通问答的分水岭。这个项目里,记忆被拆成了两个维度:短期记忆和长期记忆。

短期记忆就是当前会话的上下文窗口,通常用一个滑动窗口来管理。这里有个容易被忽略的细节:不是把所有历史消息都塞给模型,而是要根据 token 预算做裁剪。项目里一般会维护一个MessageWindow,按 token 数而不是消息条数来限制。为什么按 token?因为不同模型上下文长度不同,按条数限制会导致有的模型爆窗口、有的模型浪费空间。

长期记忆则依赖向量库。把历史对话、用户偏好、知识片段做 embedding 后存进向量库,需要时按语义相似度召回。这里的关键是召回时机和召回数量。召回太频繁会拖慢响应,召回太多会污染上下文。项目里通常的做法是:在每轮对话开始前做一次召回,取 top-k(一般 3 到 5 条),并且设置相似度阈值,低于阈值的不注入。

// 记忆召回的核心逻辑示意 List<Document> memories = vectorStore.similaritySearch( SearchRequest.builder() .query(userInput) .topK(5) .similarityThreshold(0.75) .build() );

注意:相似度阈值不要拍脑袋定。不同 embedding 模型的分数分布差异很大,同一个阈值在这个模型上合适,换一个模型可能就全被过滤掉了。上线前一定要用真实数据跑一遍分布,看召回结果是否符合预期。

2.3 工具调用:让 Agent 真正“能干活”

Agent 和聊天机器人的另一个区别是它能调用外部工具。这个项目里工具调用的实现基于 Spring AI 的 function calling 机制。你定义一个普通 Java 方法,加上注解描述它的用途和参数,框架会自动把它转成模型能理解的工具描述。

工具调用的难点不在“怎么调”,而在“什么时候调”和“调失败了怎么办”。模型有时候会在不该调工具的时候调,有时候该调又不调。项目里的处理方式是:在系统提示词里明确工具的使用边界,同时在编排层做二次校验。比如模型请求调用一个查询订单的工具,但当前会话没有登录用户信息,编排层就直接拒绝这次调用并返回提示,而不是傻乎乎地执行。

工具执行还要考虑超时和幂等。一个查数据库的工具可能因为慢查询卡住,如果不设超时,整个 Agent 响应就被拖死。项目里一般给每个工具设置独立的超时时间,并且对写操作类工具做幂等校验,防止模型重复调用导致数据被改两次。

3. 核心细节解析与实操要点

3.1 提示词工程:别把提示词写成散文

提示词是这个项目里最“软”的部分,但恰恰是最影响效果的部分。我见过很多团队把提示词写成一大段散文,模型表现时好时坏,还找不到原因。这个项目的做法是把提示词结构化,分成几个固定区块:角色定义、能力边界、输出格式、工具说明、当前上下文。

角色定义要短而准,一句话说清“你是谁、你负责什么”。能力边界要明确写出“你不能做什么”,这比写“你能做什么”更重要,因为模型倾向于过度承诺。输出格式最好用明确的模板,比如要求返回 JSON 时,直接给出字段结构,而不是说“请返回结构化数据”。

你是订单处理助手,只处理与订单查询、修改、取消相关的问题。 对于其他问题,回复“该问题不在我的处理范围内”。 输出必须为 JSON,格式如下: {"action": "...", "params": {...}, "reply": "..."}

提示:提示词要版本化管理。每次调整都记录改了什么、为什么改、效果如何。否则三个月后你根本不知道当前这版提示词是怎么演化来的,出了问题也无从回滚。

3.2 流式响应:用户体验和工程复杂度的平衡

流式输出几乎是现在 Agent 的标配,因为用户等一个完整响应十几秒的体验太差。但流式带来的工程复杂度不低:前端要处理 SSE 或 WebSocket,后端要处理模型流的中断、拼接、错误传播。

这个项目里流式响应的处理有几个细节值得说。第一,流式过程中如果发生工具调用,需要先暂停文本流,等工具执行完再继续。第二,流式响应的错误处理比同步调用麻烦,因为 HTTP 状态码可能已经返回 200 了,错误只能通过流内的事件传递。第三,要考虑客户端断开的场景,及时释放模型连接,否则会白白消耗 token。

// 流式响应中处理工具调用的暂停与恢复示意 Flux<ChatResponse> stream = chatClient.stream(prompt); return stream.flatMap(response -> { if (response.hasToolCalls()) { return executeTools(response.getToolCalls()) .thenMany(continueStream(response)); } return Flux.just(response); });

3.3 配置管理:模型参数不该硬编码

模型参数(温度、top-p、最大 token 数)如果硬编码在代码里,调优时就得改代码重新部署,效率极低。这个项目把这些参数外置到配置文件,并且支持按场景覆盖。比如闲聊场景温度可以高一点,工具调用场景温度要低甚至为 0,保证输出稳定。

参数闲聊场景工具调用场景说明
temperature0.70.0控制随机性
topP0.91.0控制采样范围
maxTokens1024512限制输出长度
timeout30s15s超时时间

配置外置还有一个好处是能做灰度。不同用户群体用不同参数,观察效果差异,逐步放量。这在生产环境里是标准操作,但很多 Agent 项目一开始没考虑,后期想加就很痛苦。

4. 实操过程与核心环节实现

4.1 环境搭建与依赖引入

先把工程骨架搭起来。用 Spring Initializr 生成一个标准 Spring Boot 项目,Java 版本建议 17 或以上,因为 Spring AI 对高版本支持更好。核心依赖包括 Spring Web、Spring AI 的模型 starter、以及你选用的向量库客户端。

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId> </dependency>

依赖引入后,配置文件里填模型服务的地址、密钥、模型名。这里有个实操细节:密钥不要写在配置文件里提交到代码库,用环境变量或者配置中心注入。我见过不止一个项目把密钥硬编码进application.yml然后推到公共仓库,这是典型的安全事故。

4.2 定义 Agent 的核心编排逻辑

编排层是整个项目的心脏。它的职责是接收用户输入,组装上下文,决定是否调用工具,调用模型,处理返回,更新记忆。这个流程看起来线性,实际上有很多分支。

public AgentResponse handle(String sessionId, String userInput) { // 1. 加载会话记忆 ConversationMemory memory = memoryStore.load(sessionId); // 2. 召回长期记忆 List<Document> recalled = recallLongTermMemory(userInput); // 3. 组装提示词 Prompt prompt = promptBuilder.build(memory, recalled, userInput); // 4. 调用模型(可能触发工具调用) ChatResponse response = chatClient.call(prompt); // 5. 处理工具调用循环 while (response.hasToolCalls()) { List<ToolResult> results = toolExecutor.execute(response.getToolCalls()); prompt = promptBuilder.appendToolResults(prompt, results); response = chatClient.call(prompt); } // 6. 更新记忆 memory.append(userInput, response.getContent()); memoryStore.save(sessionId, memory); return AgentResponse.from(response); }

这段逻辑里,工具调用循环是最容易出问题的地方。必须设置最大循环次数,否则模型可能陷入“调工具→返回→再调工具”的死循环。一般设 5 到 10 次,超过就强制返回并记录告警。

4.3 工具的定义与注册

工具定义要遵循“单一职责”原则,一个工具只做一件事。参数要少而明确,参数越多模型越容易填错。下面是一个查询订单状态的工具示例。

@Component public class OrderQueryTool { @Tool(description = "根据订单号查询订单状态,订单号格式为纯数字") public OrderStatus queryOrder(@ToolParam(description = "订单号") String orderId) { // 实际业务查询逻辑 return orderService.query(orderId); } }

工具描述要写得让模型能理解“什么时候该用我”。描述里最好包含使用场景和参数约束。比如上面写了“订单号格式为纯数字”,模型在调用前就会做基本校验,减少无效调用。

注意:工具方法里不要抛未捕获的异常。模型拿到异常堆栈既看不懂也无法处理,应该把异常转成结构化的错误信息返回,让模型知道“这次调用失败了,原因是参数不合法”,它才有可能调整后重试。

4.4 记忆存储的落地

短期记忆用内存存的话,服务重启就丢了,多实例部署还会串台。生产环境一般用 Redis 存短期记忆,用向量库存长期记忆。Redis 的 key 设计要包含会话 ID,value 存序列化后的消息列表,设置合理的过期时间。

长期记忆的写入时机也有讲究。不是每轮对话都写,而是筛选有价值的信息写。比如用户明确表达的偏好、重要的业务事实、需要长期跟踪的任务状态。筛选可以用一个轻量的模型调用或者规则判断,避免把噪音写进向量库。

5. 常见问题与排查技巧实录

5.1 模型返回格式不稳定怎么办

这是最高频的问题。明明提示词里写了要返回 JSON,模型有时候还是返回一段带解释的文字。排查思路分三步:先看提示词是否足够明确,再看是否给了示例,最后看温度参数是否过高。

解决办法是“提示词约束 + 输出解析兜底 + 重试”。提示词里给出严格的 JSON 模板;解析时用容错解析器,能处理 markdown 代码块包裹的情况;解析失败就带着错误信息重试一次,重试时提示词里强调“上次输出格式错误,请严格按 JSON 返回”。

问题现象可能原因解决方向
返回带解释文字提示词约束弱加输出模板和示例
JSON 字段缺失模型理解偏差字段说明加约束
偶发格式错误温度过高工具场景温度调 0
解析持续失败模型能力不足换模型或加后处理

5.2 工具调用不触发或乱触发

工具该调不调,通常是工具描述不够清晰,模型没意识到该用。可以在系统提示词里显式列出“遇到 X 类问题必须调用 Y 工具”。工具乱调则相反,往往是描述太宽泛,或者工具之间职责重叠。解决办法是收紧描述,明确边界,必要时在编排层做拦截。

我踩过的一个坑是:两个工具功能相似,模型经常选错。后来把其中一个合并进另一个,用参数区分,问题就消失了。工具设计上,宁可少而精,不要多而杂。

5.3 响应慢和超时

Agent 响应慢的原因通常有三个:模型本身慢、工具执行慢、上下文太长。排查时先看链路耗时分布,定位瓶颈在哪一段。模型慢可以换更快的模型或者做流式;工具慢要加超时和缓存;上下文太长要做裁剪和摘要。

上下文裁剪有个技巧:不要简单丢弃早期消息,而是把早期消息做一次摘要,用摘要替代原文。这样既压缩了 token,又保留了关键信息。摘要可以用便宜的小模型来做,成本可控。

5.4 并发下的会话串台

多实例部署时,如果会话状态存在本地内存,用户请求被负载均衡打到不同实例,就会出现上下文丢失或串台。解决办法是把会话状态外置到 Redis,并且保证同一会话的请求尽量路由到同一实例(可以用一致性哈希)。这个坑很隐蔽,单机测试永远发现不了,一上集群就暴露。

6. 生产级细节与个人实操体会

6.1 可观测性:没有监控的 Agent 就是黑盒

Agent 系统比普通服务更难排查,因为它的行为有随机性。同一个输入,两次输出可能不同。所以可观测性必须做足。至少要记录:每次请求的完整提示词、模型原始返回、工具调用记录、耗时分布、token 消耗。

这些数据一方面用于排障,另一方面用于成本核算。大模型的 token 是要花钱的,不监控消耗,月底账单会给你惊喜。项目里一般会把每次调用的 token 数打点到监控系统,按用户、按场景做聚合。

6.2 降级与兜底:模型挂了怎么办

模型服务不是 100% 可用的,网络抖动、服务限流、额度耗尽都可能发生。生产系统必须有降级策略。常见的做法是:主模型失败时切备用模型;模型完全不可用时返回预设的兜底话术,并引导用户走人工渠道。

降级要提前设计,不能等出事再想。而且降级逻辑要测试,否则真到用的时候发现降级路径本身有 bug,那就尴尬了。

6.3 成本控制:别让 Agent 变成烧钱机器

Agent 的成本主要来自 token 消耗,而 token 消耗的大头往往是上下文。控制成本的核心就是控制上下文长度。除了前面说的摘要和裁剪,还可以做提示词缓存(相同前缀的提示词复用)、结果缓存(相同问题直接返回缓存答案)。

我在实际项目里的体会是,缓存对成本的优化非常明显,尤其是那些高频重复的问题。但缓存要注意失效策略,业务数据变了,缓存必须跟着失效,否则会返回过期信息。

6.4 安全边界:Agent 能做的事要有边界

Agent 能调用工具,就意味着它能对真实系统产生副作用。所以权限控制必须做在工具层,而不是依赖模型自觉。每个工具执行前都要校验当前用户是否有权限,写操作要有确认机制。模型可以被诱导,但代码层的权限校验不能被绕过。

这个项目在安全上的处理思路是:模型只负责“建议做什么”,真正“能不能做”由编排层和工具层决定。这个边界划清楚,系统才敢往生产放。

最后分享一个我在实际落地中总结的小经验:Agent 项目不要一上来就追求“全能”,先把一个垂直场景做深做透,把记忆、工具、降级、监控这些基础设施打磨稳定,再往其他场景扩展。基础设施稳了,加新场景就是加配置和加工具的事;基础设施不稳,每加一个场景都是新的坑。这个 Java 版 Personal Agent 项目最大的价值,就是它把这些基础设施该长什么样,用可运行的代码摊开给你看了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进