☰
Spring Boot + Spring AI 构建生产级 Java Personal Agent 实践指南
2026/10/16 4:33:13 网站建设 项目流程

这段时间搞了个 Java 版的 Personal Agent,核心组合是 Spring Boot + Spring AI,从原型到上线折腾了好几个迭代。今天这篇不是标题党,也不做概念包装,我想把生产环境里真正跑得通的设计、代码、配置和踩过的坑全部摊开,包括记忆怎么管、工具怎么调、上下文怎么控、成本怎么省、日志怎么脱敏。适合正在用 Spring Boot、想把大模型接进业务系统、又不想只是写个 demo 的开发者参考。如果你是团队里负责落地 AI 能力的那个 Java 后端,这篇文章应该能帮你少走不少弯路。

1. 为什么是 Java 版 Personal Agent:Spring Boot + Spring AI 的组合逻辑

1.1 从 Python 原型转 Java,不是矫情

最早我搞的 Personal Agent 原型是用 Python 写的,效果确实惊艳,两三周就能把对话、提示词模板、简单的工具调用跑通。但一提到生产化,问题全冒出来了:团队里熟悉 Python 的人少,代码维护全靠我一个人的感觉走;公司现有的认证体系、消息队列、监控告警、数据库事务,几乎所有基础设施都是 Java 生态,Python 服务要接进来得额外写一堆胶水代码;更要命的是,Python 那边对类型约束比较宽松,Agent 业务逻辑一旦复杂起来,重构成本很快失控。

后来我做了个决定:用 Spring Boot 重写。不是 Python 不好,而是技术栈必须跟团队和基础设施匹配。一个项目能不能长期维护,比它用什么语言更关键。Spring Boot 的自动装配、配置中心、链路追踪、指标监控都是现成的,Agent 服务并不是什么特殊存在,它本质还是一个需要稳定性的后端服务。

有一个点容易被忽略:Java 生态里做 LLM 应用,不需要自己重复造轮子。我当时对比过几个方案,直接用 HTTP Client 调大模型接口,再手动拼 Prompt、轮流管理上下文,能跑,但每次加需求都像在打补丁。而 Spring AI 这套框架把模型调用、Prompt 模板、消息历史、工具注册这些都抽象成了标准接口,让我可以把注意力放在业务逻辑上,而不是 SDK 细节。

1.2 Spring AI 解决的是“集成”,不是“模型”

很多第一次接触 Spring AI 的同学有个误解,以为它是一个大模型,或者封装了某一家厂商的能力。实际上 Spring AI 更多扮演的是“集成层”的角色,重点解决这些事:

  • 统一模型访问接口:无论底层接的是哪个服务商的大模型,应用层面向的都是 ChatModel / ChatClient 这一套 API。
  • 模板化 Prompt 管理:类似 View 模板,把 Prompt 中的变量和逻辑分离,方便业务人员调整话术。
  • 记忆抽象:会话记忆不再由开发者自己拼字符串,可以用 MessageWindow、ChatMemory 等机制管理。
  • 工具调用支持:用注解就能暴露 Java 方法给模型,让模型决策何时调用。
  • 结构化输出:让模型返回 JSON 并绑定到 Java 对象,省掉大量手写解析和容错。

我实际用下来最舒服的是配置驱动。底层换模型服务商的时候,很多情况下只需要改配置文件和依赖坐标,业务代码几乎不动。这就解决了生产环境里最常见的一个问题:不能把 Agent 绑死在单一模型上。

1.3 为什么不像很多人那样自己封装一个 LLM Client

说实话,最开始我也动过心思,用 Spring 的 RestClient 直接调接口,然后自己写一个 LLM 请求模板。对只有两三个接口的小功能来说,这样很直接。但是 Personal Agent 跟普通的接口对接不一样,它天然需要多轮对话、上下文记忆、工具调用、输出约束,这些功能耦合在一起,如果自己写,工作量远比你想象的大。

举个具体例子:工具调用(Function Calling)。底层接口需要你把可用函数以 JSON Schema 形式传给模型,然后模型会返回一个“你想调用哪个函数、参数是什么”的结果,程序再执行函数并把结果回传给模型。这个过程涉及函数描述生成、参数解析、上下文拼接、多轮循环控制。自己实现得写一千行不止,而 Spring AI 提供了 @Tool 注解和 ToolCallback 机制,核心链路几行代码就能接上。稳定性还比手写的强得多。

所以我的建议是:先别急着否定框架。除非你的场景非常特殊,否则 Spring AI 这类抽象层带来的收益远大于它带来的复杂度。

2. 项目总览:生产级 Personal Agent 的模块怎么拆

2.1 模块规划:别把 Agent 写成一个大 Controller

很多项目一开始都是这样演进的:写一个 ChatController,里面注入一个 ChatClient,然后不断往里面加 Prompt、加工具方法。一开始没问题,但一旦开始加记忆存储、多模型切换、审计日志、限流熔断,这个 Controller 会膨胀到几百上千行,最终变成没人敢动的泥球。

我这次特意把项目拆成了 Maven 多模块,大致结构如下:

agent-parent ├── agent-core // 领域模型、Agent 核心接口、工具调用抽象 ├── agent-api // 对外接口层:Controller、DTO、鉴权入口 ├── agent-infra // 基础设施:模型配置、向量存储、缓存、日志、外部 SDK 适配 ├── agent-web // 启动模块:聚合所有模块、配置 Spring Boot 启动类 └── agent-common // 公共工具:脱敏、异常、上下文工具类

这样拆的好处是:核心业务不依赖 Web 层,将来如果要做命令行版本或消息队列消费者,直接复用 agent-core 就行;模型供应商的切换只发生在 infra 层,不会污染业务代码;每个模块的依赖方向都是单向的,代码结构一眼能看到边界。

有一点要注意,一开始不要过度设计。如果你的 Agent 只是一个简单对话接口,拆五个模块反而增加负担。我是在经历了第二个月“Controller 失控”之后才拆分重构的,这个节奏也算自然演进,不丢人。

2.2 核心依赖选型:控制你的技术债

依赖方面,我没有在 pom.xml 里直接写死某一家的 SDK,而是依赖 Spring AI 的 Starter,再通过配置切换具体实现。一个降级能力特别重要:生产环境里某家模型服务不稳定是常态,你有备选方案就能避免单点故障。

pom.xml 关键依赖大概是这样的:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-xxx</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-advisor-vector-store</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>

这里建议:如果团队对某个服务商没有历史包袱,优先选“兼容多家的包”而不是只盯着一家。因为大模型这个领域迭代太快,年初的好选择到年中可能就不是了,保持切换能力比追求某一家最强要重要。

2.3 配置驱动:模型切换只动 yml

生产级配置不能把密钥、Base URL 这些写死在代码里,一定要走外部化配置。下面是我在 application.yml 里整理的模型相关配置:

spring: ai: model: provider: xxx api-key: ${LLM_API_KEY} base-url: ${LLM_BASE_URL:https://api.example.com} chat: options: model: agent-chat temperature: 0.7 max-tokens: 2048 retry: max-attempts: 3 backoff: initial-interval: 500ms multiplier: 2 vectorstore: provider: redis namespace: agent_memory memory: window-size: 12

这里我想多说一句:temperature 不要随便乱调。有些同学为了让模型“更有创意”把它设成 1.5,结果个人助理回消息越来越飘。我觉得默认的 0.7 左右对多数场景足够,需要更严谨的任务可以降到 0.2,但不要全局一刀切,不同接口可以用不同配置。

3. 核心链路实现:一个 Chat 接口怎么同时支持多轮记忆和流式输出

3.1 最简可用的对话接口

Personal Agent 最基础的体验就是对话。我用一个 ChatController 暴露接口,用 SSE 做流式返回,这样用户可以像使用聊天软件一样看到逐字输出的效果,而不是等好几秒才看到一整段。

@RestController @RequestMapping("/api/agent") public class AgentChatController { private final AgentChatService chatService; public AgentChatController(AgentChatService chatService) { this.chatService = chatService; } @PostMapping("/chat") public ResponseEntity<Flux<ServerSentEvent<String>>> chat(@RequestBody ChatRequest request, @RequestHeader("X-User-Id") String userId) { Flux<ServerSentEvent<String>> stream = chatService.chat(userId, request.message()); return ResponseEntity.ok() .contentType(MediaType.TEXT_EVENT_STREAM) .body(stream); } }

注意几个生产细节:

  • 用户身份不能只靠前端传一个字符串就信任,这里我简化了,实际会走网关解析后的用户信息,不能直接相信 Header。
  • SSRF 之类的安全问题跟大模型接口关系不大,但用户输入内容是要拼进 Prompt 的,要考虑 Prompt 注入和输出过滤,后续会展开。
  • 流式返回一定要设超时。有次生产环境里大模型服务响应特别慢,客户端等着等着连接就断了,服务端线程还挂着,差点把连接池打满。

3.2 服务层:用 ChatClient 编排而不是裸调模型

Spring AI 的 ChatClient 是一个流式链式 API 的入口,它内部处理了 Prompt 构造、记忆注入、工具注册等细节。我的服务层大概是这样的:

@Service public class AgentChatService { private final ChatClient chatClient; private final ChatMemory chatMemory; public AgentChatService(ChatClient.Builder builder, ChatMemory chatMemory) { this.chatMemory = chatMemory; this.chatClient = builder .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .defaultAdvisors(new SafeOutputAdvisor()) .build(); } public Flux<String> chat(String userId, String message) { Prompt prompt = new Prompt(message, ChatCompletionRequestOptions.builder() .withTemperature(0.7) .build()); return chatClient.prompt(prompt) .user(message) .stream() .content(); } }

这里最核心的是 MessageChatMemoryAdvisor。它会在调用模型前,从 ChatMemory 里拉取这个用户的历史消息,拼进本次请求;在模型返回后,再把消息写回 ChatMemory。整个过程对业务代码是透明的。

有人会问:为什么不用一个 List 把用户所有消息全拼进去?因为 Token 长度有限,而且越长推理延迟越高、出错概率越大。Advisor 支持配置窗口大小,只保留最近的 N 轮对话。窗口之外的信息如果直接丢掉又可惜,那就需要后面讲的向量检索做长期记忆。

3.3 结构化输出:让模型按你的 Java 对象返回

个人助理有时候不只要聊天,还要完成一些抽取和判断任务。比如用户说“帮我把明天下午三点的会议改到四点”,你需要从这句话里提取出时间、原时间、新时间、日程标题这些结构化字段,然后去调用日历工具。

这种场景我强烈建议用 Spring AI 的 BeanOutputConverter,而不是自己写“请你输出 JSON”然后解析。

public record MeetingRescheduleInfo(String title, LocalDateTime from, LocalDateTime to) {} BeanOutputConverter<MeetingRescheduleInfo> converter = new BeanOutputConverter<>(MeetingRescheduleInfo.class); String formatInstruction = converter.getFormatInstruction(); String prompt = """ 你是一个日程助手。请从用户的话里提取改期信息。 如果缺少字段,填空字符串或 null。 %s 用户输入:%s """.formatted(formatInstruction, userMessage); String response = chatClient.prompt(prompt).call().content(); MeetingRescheduleInfo info = converter.convert(response);

为什么这么搞?因为直接让模型输出 JSON,真实返回里经常夹杂 Markdown 代码块、前后废话、键名大小写问题、多一个逗号。BeanOutputConverter 会把格式说明嵌进 Prompt,并在解析阶段做容错。我见过有人自己写了 200 行解析逻辑处理各种脏数据,最终还是转向了这个方案。

4. 记忆与知识库扩展:Personal Agent “懂你”的关键

4.1 短期记忆:会话维度的窗口管理

Personal Agent 生产化以后,肯定不能把记忆存在本地内存里。原因有两个:一是服务重启会丢,二是多实例部署时用户请求落在不同实例上,对话上下文对不上。

我一开始用 Spring AI 自带的 InMemoryChatMemory,测试没问题,一上两台实例就串戏。后来改成了基于 Redis 的 ChatMemory 实现。实现思路不复杂:以 userId + conversationId 作为 key,存一个消息列表,操作时加锁避免并发读写。

核心代码大致是这样:

@Component public class RedisChatMemory implements ChatMemory { private final StringRedisTemplate redisTemplate; private static final String KEY_PREFIX = "agent:memory:"; private static final ObjectMapper mapper = new ObjectMapper(); public RedisChatMemory(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } @Override public List<Message> get(String conversationId, int lastN) { String json = redisTemplate.opsForValue().get(KEY_PREFIX + conversationId); if (json == null) { return List.of(); } // 反序列化并截取最近 lastN 条 } @Override public void add(String conversationId, List<Message> messages) { // 追加到 Redis,同时做长度控制 } @Override public void clear(String conversationId) { redisTemplate.delete(KEY_PREFIX + conversationId); } }

这里有个容易踩的坑:ChatMemory 的 add 和 get 方法可能在并发情况下被调用。比如用户快速连发两条消息,第一条返回还没写完,第二条已经进来了。我建议在写入端做同步或乐观锁,不然多轮对话会出现记忆错乱。

4.2 长期记忆:向量检索让你想起三个月前的偏好

短期记忆窗口有限,但 Personal Agent 的价值恰恰在于记住用户长期偏好。比如“我不吃香菜”“我习惯把会议安排在上午”“最近在跟一个跨团队项目”。这些信息不能全量塞进每次的 Prompt,否则 Token 消耗巨大,而且模型容易混淆。

我用的是 RAG 的经典思路:把用户的历史偏好、聊天记录、笔记等文本切成片段,向量化后存入向量存储;每次对话前,根据当前输入检索最相关的 3~5 段内容,拼进 Prompt 作为背景信息。

Spring AI 对这块做了统一抽象,VectorStore 接口屏蔽了不同向量数据库的差异。流程可以简化成:

List<Document> docs = vectorStore.similaritySearch( SearchRequest.builder() .query(userMessage) .topK(5) .build()); String context = docs.stream() .map(Document::getContent) .collect(Collectors.joining("\n"));

检索出来的内容不能直接当“圣旨”塞给模型。我给每条命中结果额外加上了时间字段,比如“三个月前用户说过一次”,让模型知道哪些信息可能过时。这个细节对个人助理尤其重要,因为个人信息是会变的。

4.3 记忆清洗:别把脏数据喂给模型

这个坑我印象很深。刚开始我把用户原始聊天记录全部切片做向量化,结果模型学了不少坏习惯:有用户发过“今天天气真差,无语死了”,第二天模型就以为用户对某地天气有意见,开始在回复里安慰用户,非常尴尬。

后来我加了一条记忆清洗流水线:先过滤掉纯情绪化表达、无意义文本、过长的日志片段;再做实体脱敏,手机号、身份证、银行卡都替换成占位符;最后按时间衰减给旧记录设权重,太老的记忆默认不参与检索。做完之后,Agent 的回答质量明显提升,也避免了很多合规风险。

5. 工具调用:让 Agent 从聊天变成能干活

5.1 注册工具函数的完整步骤

Personal Agent 不能只会聊天,它得会查天气、建日程、查库存、发通知,否则就没有“个人助理”的意义了。Spring AI 里注册工具非常简单,用 @Tool 注解标注一个 Bean 方法即可。我举一个查询天气的例子:

@Service public class WeatherToolService { @Tool(name = "queryWeather", description = "根据城市名查询当前天气,返回温度和天气状况") public String queryWeather(String city) { // 这里接入真实的天气服务 return "城市:" + city + ",温度:26℃,天气:多云"; } }

把这个服务通过构造器注入到 ChatClient 配置里就可以。框架会自动生成函数描述,在请求模型时把工具信息带上。模型会在需要时返回调用指令,然后框架直接调用 Java 方法,把结果追加到对话里,再让模型基于结果生成最终回复。

有一点很多人不知道:工具的描述必须写清楚,最好带上参数说明。因为模型是靠着描述来决定要不要调用以及怎么传参的。描述太模糊,模型会给出乱七八糟的参数。我见过有人写“根据城市查询天气”,模型传了个英文城市名,又传了一个国家字段,方法签名不匹配,直接报错。后来把描述改成“根据城市名查询当前天气,城市是中文名称,如果用户没有指定城市,不要调用此工具”,准确率立刻上来了。

5.2 工具执行的安全与权限边界

工具就是暴露给模型的黑盒入口,相当于让模型在你的系统里执行代码,所以安全防线必须严:

  • 参数校验不能省。模型可能传负数、传超大字符串、传不存在的用户 ID,每个工具方法开头必须做字段校验。
  • 权限校验必须做。不能让模型调用一个只属于管理员的功能。我通常会在调用链路上注入当前用户上下文,在工具方法里判断 role 和 scope。
  • 审计日志必须有。每次工具调用都记录时间、用户、工具名、参数、结果摘要,而且带 traceId,方便事后回放。
  • 危险能力要隔离。比如发短信、删除数据、批量操作,不能直接暴露成普通工具。我会单独加一层“高危操作确认”逻辑,模型只能提交请求,由用户显式确认才实际执行。

5.3 工具多轮调用与死循环控制

真实场景里,一个任务往往要连续调用好几个工具。比如“帮我把周五的会改到周六下午,顺便叫一辆 2 点的车去机场”,需要先调日程工具,再调叫车工具。模型会先返回第一轮调用,框架执行后再把结果回传给模型,模型再返回第二轮调用,如此循环。

这个循环必须有终止条件。我设置的规则是:单次请求最多允许 5 轮工具调用,超过就强制结束,并告诉用户“任务太复杂,请分步操作”。不然可能出现模型反复调用同一工具,Token 烧完都停不下来。

另外,工具调用的中间结果别直接丢掉。我会把所有中间步骤都汇总在响应里,用户能看到“已帮你修改日程 → 正在为你叫车”,这种透明度对个人助理的信任感很重要。

6. 生产级细节:重试、限流、成本控制与安全

6.1 大模型 API 的失败模式与退避重试

大模型服务再好,也免不了偶尔 429 限流、5xx 波动、网络超时。生产环境的 Agent 必须处理这些,不能一报错就把异常甩给用户。

我的策略是这样的:对网络超时、429、5xx 做指数退避重试,最多 3 次;对 400、401、403 这些参数或鉴权类错误,一律不重试,因为它们重试也不会成功。在 Spring AI 里可以通过配置开启内部重试,也可以在调用链外层包一层自定义的重试逻辑。

配置上可以这样:

spring: ai: model: chat: retry: max-attempts: 3 backoff: initial-interval: 1s multiplier: 2 max-interval: 8s

需要特别提醒:重试要小心“重复执行副作用”。比如工具调用已经成功扣款了,但因为响应超时触发重试,可能会造成重复扣款。所以对非幂等工具的调用,我会用请求唯一 ID 做幂等控制,第二次到达时直接返回第一次的执行结果。

6.2 限流与并发控制:别让 Agent 成为业务事故

个人 Agent 一旦上线,同事都会觉得这是个免费的“AI 接口”,各种测试和批量任务都会往里打。如果不做限流,模型服务的账单和系统负载都会让你后悔。

我的方案是双层限流。网关层限制每个用户每分钟调用次数,比如普通用户 20 次;应用层再用 Semaphore 限制并发正在进行的模型请求数。这样做能保护的不只是大模型服务,还包括你自己的线程池和下游系统。

@Component public class ModelRequestLimiter { private final Semaphore semaphore; public ModelRequestLimiter(@Value("${agent.max-concurrent:20}") int maxConcurrent) { this.semaphore = new Semaphore(maxConcurrent); } public <T> T execute(Supplier<T> call) { boolean acquired = semaphore.tryAcquire(3, TimeUnit.SECONDS); if (!acquired) { throw new AgentBusyException("当前使用人数较多,请稍后再试"); } try { return call.get(); } finally { semaphore.release(); } } }

这个组件比我在网上看到的很多“限流方案”更实用,因为它同时处理了排队失败和释放锁两个问题。你当然可以用 Sentinel 或 Resilience4j 做更完整的控制,但并发信号量这套逻辑在团队小、基础设施轻的时候完全够用。

6.3 成本控制:Token 不是大风刮来的

接入大模型以后,每个月账单会真实地长在自己身上。我之前有一次把 Agent 的长期记忆向量检索的 topK 设置为 10,结果每次对话都额外带进几千 Token,账单直接翻倍。

现在我会做几件事:

  • 记录每个会话的 Token 消耗:每次模型调用后从响应里取 usage 字段,写入日志和统计表。
  • 给每日消耗设预算:同一用户、同一 API Key 达到日限额后自动降级到更便宜的小模型,或者直接拒绝非核心请求。
  • 对长文本做摘要存记忆,而不是全文保存。比如用户跟 Agent 聊了一个小时,我只存“用户讨论了 XX 项目,下一步目标是 XX”这种摘要,而不是把一小时对话全部塞进长期记忆。

成本优化最重要的思路是:对不同的功能用不同的模型。复杂规划任务用大模型,简单分类和小结用小模型,别一把梭全用同一个模型。

6.4 日志脱敏与链路追踪

Agent 服务的日志里天然包含大量用户输入和模型输出,这是高敏感数据。生产环境我要求:

  • 日志里不能出现完整手机号、身份证号、银行卡号;输出前自动脱敏,只保留前三位后两位。
  • 用户输入的原始内容只存 7 天,用于故障排查,到期自动删除。
  • 每条请求都要生成 traceId,通过 MDC 带入所有业务日志,包括工具调用日志、Token 消耗日志、模型调用日志。出了问题能一键串起整个链路。

MDC 的使用其实很简单,一个过滤器就够:

@Component public class TraceIdFilter implements Filter { private static final String TRACE_ID = "traceId"; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String traceId = UUID.randomUUID().toString(); MDC.put(TRACE_ID, traceId); try { chain.doFilter(request, response); } finally { MDC.remove(TRACE_ID); } } }

日志脱敏我比较推荐用字节码工具或者日志框架的自定义 Layout,不要在业务代码里手动写脱敏,否则漏网之鱼太多。

7. 常见问题与排查实录:这份表格建议收藏

这一章我用表格把实际遇到的高频问题和解决思路列出来,这些都是我在开发测试和生产环境里真实踩过的。

现象根因解决思路
流式响应经常断流,前端只看到一半网关默认 Buffer 或超时设置太短调大网关超时,SSE 响应里禁掉 Buffer,确认响应头 Content-Type 是 text/event-stream
Function Calling 一直不触发工具描述不清晰,模型不知道该何时调用重写工具 name 和 description,加入参数约束说明,比如“如果用户没有提到城市,不要调用”
输出的 JSON 偶尔带注释或 Markdown 代码块Prompt 对输出格式约束不够使用 BeanOutputConverter,它会在 Prompt 里自动加入严格格式说明
多用户对话串上下文误用了全局单例 ChatMemory,不同用户共享同一 ConversationId以 userId + conversationId 作为隔离 key,确保每次请求取的是当前用户记忆
模型反复重试同一工具导致费用飙高缺少最大迭代次数控制设置工具调用轮次上限,比如 5 次,超出就中断并提示用户
上下文太长导致模型报错窗口太大,Token 超过限制压缩窗口大小,对更早的对话做摘要,替换成长文本
重试导致业务重复操作忽略幂等性对非幂等工具调用增加唯一请求 ID,执行前先查重

接下来展开几个典型问题:

第一个是流式响应断流。这个问题排查起来很痛苦,因为在我本地环境一切正常,一到测试环境就断。后来查到是网关对未设置 Content-Length 的 SSE 响应做了缓冲,超过一定时间直接超时。解决方法是把该路由的缓冲关掉,同时把读超时从默认 30 秒调到 5 分钟。另外一个容易漏的点是反向代理层要关闭对 SSE 的压缩,否则响应会被缓冲起来,流式效果就没了。

第二个是多用户上下文串戏。这个坑相当隐蔽。某次线上用户投诉说“我前面聊的旅游计划,怎么变成跟别人聊工作”,我查了半天发现确实有一个全局 ChatMemory,所有用户共享同一个 key,因为之前测试时为了省事,把 conversationId 写成了常量字符串。这种问题只要代码 review 仔细一点就能避免,但一旦上线,影响就是灾难。建议写一个强制校验:memory key 必须包含 userId,否则直接拒绝写入。

第三个是工具调用不触发。我见过不止一个同事来问“我的 @Tool 方法明明写了,为什么模型从不调用?”原因基本就是:要么方法没有被注册到 ChatClient 的 ToolCallbacks 列表里,要么工具描述写得太抽象。排查办法是打开 Spring AI 的调试日志,看实际发给模型的请求里有没有包含 tools 字段,一目了然。

最后是模型输出质量不稳定。这个属于“玄学”范围,但有一些通用提升手段:把 System Prompt 写得更具体,包含角色、语气、边界行为;少用“不要怎么怎么样”,多用“你应该怎么怎么做”;需要枚举时给候选值;复杂任务拆成多步迭代,而不是让模型一步到位。这些手段效果立竿见影。

我个人的体会是:Personal Agent 这类项目,70% 的复杂度不在模型本身,而在工程细节。模型推理能力只是上限,下限是由日志、重试、隔离、脱敏这些“脏活累活”决定的。Spring Boot + Spring AI 这套组合最大的价值,就是让我能用熟悉的后端工程方法把这些脏活规范化,而不是每天都在跟模型接口参数较劲。

如果你也正在走这条路,我最后有几个小建议:第一,先跑通一条最小的对话闭环,别一上来就上记忆、工具、向量库;第二,把工具调用的审计和幂等设计放在功能之前,后面能省很多事;第三,模型服务商别只配一家,哪怕只是测试环境,也要留切换的口子。Personal Agent 的想象空间很大,但它首先是软件工程问题,先让它稳定,再让它聪明。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询