如果你现在还在用手动拼 JSON、自己管理会话轮次、试图用一堆 if/else 判断模型该调用哪个函数的方式做 Agent,那我建议你停下来先看看 Spring AI。这个 Java 版 Personal Agent 项目的最初动机,就是因为我在原型阶段被这套手工流程折磨到崩溃,后来用 Spring Boot + Spring AI 整体重写,才把复杂度真正压下来。现在这个项目已经开源,整个代码量不算大,但生产环境里该考虑的超时、限流、记忆管理、工具调用、可观测性这些细节,我全部整理进了项目文档。这篇博客把最关键的设计思路和落地细节全部摊开,给同样想在生产级场景做个人智能助理的开发者一个可以直接对照的参考。
1. 项目概述与整体设计思路
1.1 Personal Agent 到底需要解决什么问题
很多人以为 Personal Agent 就是套一个聊天界面,让用户输入问题,后端把问题丢给大模型,再把结果吐出来。真这么干,最多算一个“聊天机器人壳子”,离一个能干活儿的个人助理还差很远。我对自己这个项目的要求很简单:它能听懂日常口语指令,比如“帮我看看这周五下午有没有空档,如果没有就把下周二的例会挪过来”,并且自动去查询日历、修改日程、返回一段可读的结果。
要支撑这类需求,核心功能不是对话,而是工具调用和记忆管理。工具调用让模型可以触达真实系统,记忆管理让它在多次对话中还记得用户偏好和上下文。除此之外,还有私有知识库检索,把散落在笔记里的信息变成可以召回的知识片段。整套组合下来,用户面对的是一个能“动手做事情”的代理,而不是只会聊天的玩具。
这个项目选择 Java 生态,不是因为我偏爱 Java,而是因为生产级 Personal Agent 需要考虑线程并发、连接池、可观测性、优雅停机、权限控制。这些能力在 Java 生态里已经被 Spring Boot 打磨得非常成熟,直接用来承载 AI 应用,工程质量上限高很多。尤其当你要把 Agent 作为常驻服务长期运行,Java 在内存可控性和排障工具链上的优势就会非常明显。
1.2 整体架构与模块划分
项目整体采用前后端分离的架构,后端以 Spring Boot 作为主体框架,Spring AI 负责与大模型交互抽象。前端是一个单页聊天界面,消息走 WebSocket 或普通 REST 接口。后端的内部划分大概有五个层次:
- 接入层:处理 HTTP 请求和 WebSocket 会话,负责用户身份识别与基础参数校验。
- 编排层:Controller 收到请求后,调用一个
AgentService,把用户问题和历史会话组装成一次完整的模型交互。 - 工具层:一组以
@Tool注解注册的 Java 类,比如CalendarTool、TodoTool、NoteTool。这些工具就是模型可以调用的“手和脚”。 - 记忆与存储层:数据库存储用户会话,Redis 做短期缓存,向量存储保存私有知识库的 embedding 数据。
- 可观测层:记录每轮对话的 traceId、token 消耗、工具调用耗时,通过日志和指标接口暴露出来。
前端我没有做得很重,一个聊天窗口和一个设置页面足以验证整个链路。后端才是这个项目的重心,因为所有生产级细节都在后端处理。另一个关键点是,工具层必须是插拔式设计,模型不知道也不关心你到底接了几种工具,它只负责根据用户意图和工具描述生成调用参数。
架构上还要考虑外部依赖的最小化。个人 Agent 的部署环境千差万别,有人想一键部署到普通 VPS,有人想在本地离线环境跑。所以我在项目里默认使用轻量级存储方案,只有真正需要向量检索时才引入独立向量库,避免为了一个 Demo 把整个部署复杂度抬高。
2. Spring AI 关键能力拆解
2.1 为什么选择 Spring AI 而不是手写 HTTP 调用
手写大模型 HTTP 调用在早期原型里看起来并不难,无非就是把用户问题、历史消息、系统提示词拼成一个 JSON,然后用 HTTP 客户端发出去,再解析返回流。但一旦进入生产级,问题马上暴露:重试怎么做,超时怎么控,流式响应怎么在并发环境下不丢数据,模型返回的 JSON 格式稍微变化怎么兼容,工具调用协议怎么自动生成 Json Schema。
这些问题如果全部用手写解决,你会发现自己写的其实是一个不完整的 AI SDK。Spring AI 的价值在于把“模型访问”变成了一层标准抽象,它帮你处理了连接、重试、流式解析、消息转换、结构化输出。用一套相对统一的 API 访问不同大模型服务,如果供应商调整协议,只要换配置或换实现类就行,业务代码不用跟着大改。
Spring AI 还有一个我很看重的点:它对函数调用做了一层 Native 的封装。开发一个工具只需要写一个带注解的 Java 方法,框架会自动生成调用描述,把方法名、参数说明、返回值类型转换成模型可理解的 Schema。这一层抽象节省的不只是代码量,更重要的是降低了心智负担。手写 Json Schema 很容易搞到后面自己都不知道该维护哪个版本。
2.2 ChatClient 编程模型和多轮对话控制
Spring AI 的ChatClient编程模型和 Spring 生态里的RestTemplate、WebClient很像。它把一段 Prompt 发给模型的过程封装成链式调用,开发时直觉非常顺畅。比如最基础的一段调用:
String answer = chatClient.prompt() .user("帮我查一下这周六的天气") .call() .content();但个人 Agent 必须支持多轮对话,所以不能每次都只传一句话。Spring AI 提供了 ChatMemory 和 Advisor 机制。每个会话有唯一会话 ID,系统会把历史消息按时间顺序存储到内存或 Redis 中。每次调用时,MessageChatMemoryAdvisor会自动把该会话最近的消息读取出来,拼到上下文里,开发者不需要自己拼接一堆历史 JSON。
这里有一个很容易踩的坑:如果不控制历史消息条数,每次对话都会无限膨胀,模型输入 token 成本很快失控。我建议给每个会话设置一个动态窗口,比如最近 20 条消息,超出后把更早的内容压缩成一段摘要,再拼回去。这样做既保留了长期趋势,又控制了成本。Spring AI 也可以通过自定义 Advisor 实现这一逻辑,不必被默认策略绑死。
另一个细节是流式响应。完整等待模型返回有时需要几十秒,用户交互体验会很差。生产级做法是使用stream()方法把模型输出通过 SSE 推给前端。Spring AI 的流式接口和普通接口在用法上几乎是镜像的,唯一要注意的是小心处理 WebFlux 的背压,不要让阻塞式线程池被慢消费拖垮。
2.3 用 @Tool 把真实能力接入 Personal Agent
工具调用是这个项目里最关键的一部分。Spring AI 中注册一个工具,只需要在任意 Spring 管理的 Bean 上写一个方法,并加上@Tool注解。比如我项目里的日程查询工具:
@Component public class CalendarTool { @Tool(description = "查询指定日期内是否有空闲时间段,返回空闲时间段列表") public List<Slot> queryFreeSlots(@ToolParam(description = "日期,格式 yyyy-MM-dd") String date) { return calendarService.findFreeSlots(date); } }模型看到这个描述后,会在需要时生成一个 JSON 调用,比如{"date": "2025-06-14"},Spring AI 框架负责把 JSON 映射成 Java 方法入参,然后调用CalendarTool,再把返回值格式化成模型可读的文本,模型最终生成面向用户的自然语言回答。
这里最容易出问题的是工具描述质量。如果你描述得太含糊,模型该调用的时候不调用,不该调用的时候反而误调用。我的调参经验是:工具描述使用“动词 + 目标 + 边界条件”的结构,比如“查询指定日期内是否有空闲时间段”,明确说明日期格式,并注明如果有其他参数应该如何处理。另外,工具返回内容不是给人看的,是给模型看的。尽量返回结构化文本或列表,模型能更容易提取关键信息。
工具层还要考虑并行调用。用户一句话里可能有多个意图,比如“把周五会议取消,然后约周四和某人吃饭”,模型可能生成两个工具调用,Spring AI 支持一次返回多个调用结果。我在项目里对工具执行加了信号量限制,避免几十个工具同时触发把下游系统打挂。并发下还要注意工具方法的线程安全问题,尤其当你操作的是一个共享的本地数据结构时。
2.4 私有知识库和 RAG 检索
个人 Agent 如果没有私有知识库,那它只能靠模型自己的记忆回答问题,对于个人笔记、历史文档、项目资料这些内容就完全无能力了。我在这个项目里实现了简化版 RAG:把用户上传的文档切分成片段,用 embedding 模型转成向量,存入向量存储;用户提问时,先在向量存储中检索最相关的片段,作为上下文拼入 Prompt,再让模型生成答案。
Spring AI 对向量库和 embedding 模型的接入做了统一抽象,配置好相应的参数后,代码里基本就是:
VectorStore vectorStore = VectorStore.builder(embeddingModel, vectorStorage) .build(); List<Document> documents = vectorStore.similaritySearch( SearchRequest.builder().query("项目周报怎么写").topK(4).build() );考虑到个人部署环境,我没有默认使用很重的向量数据库服务,而是用本地文件型向量存储或轻量内存方案。当数据量超过一定规模,再迁移到独立的向量服务。这个方案的好处是部署简单,第一次 git clone 后跑起来不会因为外部依赖报错。
RAG 部分还有一个很重要却被很多人忽略的问题:切分粒度。切得太碎,检索到的内容不完整;切得太大,上下文被无关内容填满。我这里采用 300-500 字左右的滑动窗口切分,保留段落标题和语义边界。经过实测,这种粒度对个人笔记这种半结构化内容效果最稳定。
3. 生产级细节的落地实操
3.1 超时、重试与并发控制
模型 API 和数据库不一样,它的响应时间波动特别大。生产环境里如果不对超时和重试做控制,很可能遇到某个模型服务瞬间变慢,然后把你的应用线程池全部占满,整个 Agent 服务对外表现为假死。我在项目里专门梳理了三层防护。
第一层是连接超时。对模型 API 的等待时间不要设置得太长,一般生成类接口给 60 秒,普通的对话接口给 30 秒,超过就直接失败返回,不等无底洞。第二层是重试策略。Spring AI 自带重试机制,但重试不能无限重试,我给默认最大次数设成 2,而且只对网络错误和 429/5xx 做重试,对业务错误不重试。第三层是本地并发闸门。我用Semaphore限制同时进行的模型调用数量,超过阈值的请求直接排队或者快速失败,而不是让所有请求同时冲到模型服务端。
Semaphore modelSemaphore = new Semaphore(8); public String callWithLimit(ChatClient client, String userMessage) { if (!modelSemaphore.tryAcquire(5, TimeUnit.SECONDS)) { return "当前请求人数较多,请稍后再试"; } try { return client.prompt().user(userMessage).call().content(); } finally { modelSemaphore.release(); } }这段代码看起来简单,但它是生产级和 Demo 的分水岭。没有闸门,高峰期就是事故现场。有了闸门,最坏情况也只是部分用户收到稍后再试的提示,服务本身不会崩溃。
3.2 会话存储和记忆清理策略
多轮对话的数据最终要落到存储里。我只保留了极简的表结构,一张会话表,一张消息表。会话表存用户 ID、会话标题、创建时间和最后活跃时间;消息表存消息角色、内容、token 数、关联时间。为了快速读取历史,我按会话 ID 和消息序号建了索引,查询时最多取最近 50 条。
Mem 存储采用两层结构:第一层是 Spring AI 的ChatMemory,用来在运行时暂存最近消息;第二层是数据库,用来持久化。每次模型调用结束后,都异步把消息记录写回数据库,避免同步写库拖慢响应。这里有个很容易忽略的问题:不要把所有历史都直接塞进ChatMemory,一个放在那里跑了一年的会话,恢复时可能把几十万 token 历史全部载入,直接爆掉请求上限。一定要在加载历史时做裁剪,比如只取最近 20 条,或者只取“最近两天 + 包含关键决议的信息”。
成本控制上,我在会话表里加了累计 token 字段,每隔一段时间统计一次。一旦发现某个会话的 token 消耗异常增长,多半是上下文窗口设置有问题,或者工具返回内容太长。工具返回内容也要限制长度,比如每次只返回最多 50 条记录,并在结果末尾加上“如果还需要更多数据,请进一步缩小查询范围”这种提示,让模型的再次调用更收敛。
3.3 安全设计:API Key、限流和提示词注入
个人 Agent 虽然不像企业系统那样承受高并发攻击,但它往往会被暴露在公网或者局域网内,如果不加防护,很容易出现三种问题:接口被刷、Key 泄露、恶意用户通过 Prompt 注入诱导 Agent 执行危险操作。
接口层我用最基础的身份识别方案,所有请求都必须带一个自定义请求头,里面是客户端从/auth/token获取的短期令牌。网关层做一套简单的令牌校验,并把用户的身份 ID 绑定到会话维度。这样即使有人拿到某个会话 ID,也无法操作其他人的日程数据。模型 API 的 Key 绝对不允许出现在前端代码和日志里,一律放在后端环境变量,由配置中心统一管理。
限流不能只限制模型调用次数,还要限制 Web 接口的整体请求频率。针对未登录用户,限制一分钟 10 次;登录用户,一分钟 60 次。一旦命中限流,返回一个标准错误码,前端提示用户稍后重试。这个限流用小内存表就能实现,不需要启动一个独立网关。
提示词注入是当前 Agent 领域很现实的安全风险。攻击者可以通过提问让模型访问某些工具,而这些工具会修改真实数据。我在工具层强制规定:所有涉及删除、修改、发送的操作,均需要用户在二次确认弹窗里点击“允许”。Agent 在生成最终回答时会带上一个needConfirmation标记,前端如果检测到该标记为 true,必须先做确认再执行。这是目前最简单但也最有效的防误操作手段。
3.4 可观测性与轻量部署
生产级另外一个必备项是日志和指标。大模型交互非常容易出现“用户觉得回答奇怪,但不知道模型输入了什么”的情况。我在项目中统一引入了 traceId,在所有日志中都会记录一个请求的唯一编号。每次模型调用会把系统提示词、用户问题、工具返回结果、模型最终回答全部以结构化日志形式输出。出问题时,用 traceId 一查就能还原现场。
模型调用还涉及成本,我把每次调用的 token 消耗汇总到指标端点,使用 Spring Boot Actuator 暴露/actuator/metrics,默认就有请求延迟和调用次数,自己额外增加几个计数器记录 token 消耗。这样部署到服务器之后,直接拉一个 Prometheus 就能看到模型服务是否健康、成本是否在预算内。
部署方面,我用多阶段构建 Docker 镜像,先用基础镜像把项目打成 jar,再在轻量运行镜像里运行。整个镜像体积控制得比较小,启动时间也很快。docker-compose 文件里包含应用容器、Redis 容器、可选的向量存储容器。如果想部署到 K8s,也只需要把探针指向/actuator/health,扩容方面因为服务是无状态的,会话来 Redis、工具状态独立,所以可以水平扩到多实例。
4. 常见问题与排查技巧实录
4.1 模型超时和请求返回不稳定怎么排查
如果在日志里发现大量超时错误,先把问题拆成两类:是单次生成超时,还是整体排队超时。单次生成超时通常是 Prompt 太长或模型服务负载太高,这时调整超时时间没有意义,要缩短上下文或换一个更快的小模型。整体排队超时则是你本地的并发闸门配置太小,大量请求在 Semaphore 前面排队,应该提高线程池数量或者增加降级阈值。
排查时我习惯一次性打印出四个指标:请求进入时间、模型调用开始时间、模型返回时间、整体响应时间。如果“请求进入时间”到“模型调用开始时间”间隔很长,说明本地排队严重;如果“模型调用开始时间”到“模型返回时间”很长,说明模型服务端慢。根据这两个指标可以快速定位瓶颈到底在哪一侧。
4.2 工具调用进入死循环怎么办
模型有时会陷入一种反复调用同一个工具的循环,比如查询天气时不断修改参数重试,或者解析结果失败后又发起一次同样调用。如果不加防护,单轮对话可以烧掉大量 token。我的解决方案是两处限制:框架层面设置工具调用最大迭代次数,比如 5 轮,超过 5 轮后立即停止继续调用,让模型基于已有信息作答;工具层面对明显重复且结果相同的调用进行缓存,一段时间内对相同参数直接返回缓存结果。
还有一种情况是模型生成了不存在的工具名,这通常是工具描述和实际方法名不一致导致的。排查时打开工具调用日志,看看模型生成的action名称是否匹配,不匹配就调整@Tool的名字和描述,尽量让名字和日常表达一致,减少模型乱猜的概率。
4.3 上下文溢出和“对话失忆”问题
“对话失忆”是一个很常见的用户反馈,往往是因为你只保留了最近 N 条消息,并且 N 设置太小。但 N 设置太大又容易超 token 限制。我的经验是按消息条数和 token 数双维度控制。如果最近 20 条消息已经超过 3000 token,就不再继续往上下文里塞,而是先把更早的消息生成一段摘要,放在系统提示词里。摘要要包含:用户意图、已经完成的操作、未完成事项、关键偏好。
这个方案需要给摘要生成单独的模型调用,成本也不高,但能极大提升长会话的连贯性。我遇到过最典型的场景是:用户在第 10 轮提到“以后所有日程提醒都提前 15 分钟”,如果没有摘要机制,第 11 轮起模型就忘了这个偏好。有了偏好摘要后,整条对话链路会稳定很多。
4.4 并发高峰期模型 API 被打爆怎么办
对于个人 Agent,平时并发量并不高,但偶尔会因为某个活动或测试瞬间涌进来几十个请求。我的做法是把“模型调用”和“业务请求”彻底解耦。业务请求先落库,异步 Worker 从库里拉取待处理任务,再调用模型。这样即便模型 API 限流,也不会影响用户页面响应,用户看到的是“任务处理中”的状态,稍后刷新即可拿到结果。
如果是实时对话场景无法异步化,那就必须做好优先级。给简单问题用小模型,复杂问题用大模型;如果当前请求超过限流阈值,直接让用户稍后重试,不要让请求堆积。实测下来,降级策略比硬撑更稳定。
4.5 调试和测试的建议
用真实模型跑测试既花钱又慢,而且结果不稳定,不适合作为日常调试工具。我给这个项目搭了一套回放式测试基座:先把真实模型的输入输出录制成 JSON 文件,测试时由 Mock 模型从录制文件里加载结果。然后再针对工具方法单独写单元测试,因为工具方法的输入输出相对确定。等这两层都稳定了,最后才在集成测试阶段跑真实模型,只跑关键链路和版本发布前的回归。
录制回放还有一个额外好处,可以在回归测试中比较不同版本 prompt 对同样输入的回答差异,避免自己改了一句话,结果在另一个场景下效果倒退而不自知。
5. 开源项目组织与后续扩展
5.1 怎样组织一个让人愿意读的开源仓库
代码能跑只是开源的第一步,能否让别人快速上手才是关键。我把仓库组织成几个清晰模块:agent-core放 Agent 编排逻辑,agent-tools放所有工具实现,agent-web放控制器和前端静态资源,agent-infra放配置类、安全过滤器和可观测组件。每个模块一进去就知道职责。
README 第一屏不是放架构图,而是放一个 30 秒可以跑起来的 quickstart。用户只需要填几个环境变量,然后执行一条命令就能启动整个项目。配置示例使用的是开发环境默认值,不会因为缺少依赖导致启动失败。我还在 docs 目录下放了三篇核心教程:工具接入指南、记忆策略说明、生产部署手册。这些都是从实际踩坑中提炼出来的,不是文档模板堆出来的。
注意 开源项目最容易掉进“自我表达”的陷阱,觉得代码写得很直白就不用解释。实际上,不懂你背景的人读代码时,最缺的是“为什么这样设计”的上下文。所以每个关键模块的 README 我都会写一段“为什么”,而不是只写“是什么”。
5.2 后续可以扩展的方向
这个项目现阶段已经能稳定处理日常助手类需求,但我心里的路线图还有几个方向。第一个是移动端接入,把 Agent 接到手机系统,最好的方式是做一套轻量 API 给手机系统或快捷指令调用,让语音转文字后直接进入 Agent 流程。第二个是本地离线模型作为兜底,当公共模型服务不可达时,自动切换到一个本地部署的小模型,至少能保证基础对话不中断。第三个是多模态扩展,比如用户上传一张菜单图片,Agent 直接识别并提取活动信息写入日程。
插件市场也是一个很有意思的方向。现在工具像日历、待办、笔记都是内置的,如果做一个简单的 SPI 接口,让第三方可以把自己的工具打成独立模块,社区就能围绕个人助理长出很多能力。不过插件市场要处理安全边界,不能让一个恶意插件拿到所有会话数据,这块还值得继续打磨。
5.3 我实际使用中的几点体会
做了这个项目之后,我对 AI 工程化的理解变了很多。最开始总以为 Agent 的核心是模型能力,后来发现,真正决定体验上限的是工具和记忆。模型的能力就摆在那里,能不能让模型稳定调用到正确的工具,能不能在长对话中记住该记住的事,才是工程难点。
我自己现在每天都用这个 Agent 管理日程和随手记。最常用的一句话是“帮我记录一下周四之前要把季度总结资料整理好”。它能把语义拆分成时间点和待办事项,写入待办工具,然后到点提醒我。这种真实使用场景里积累的需求,比任何华丽的功能清单都更能指引项目下一步发展。
最后再分享一个小技巧:给模型设置“安静模式”。当用户说“先不用回我”的时候,Agent 只把信息记录下来,不主动生成长篇回复。这虽然只是一个很小的 prompt 规则,但用起来体验提升非常明显,让助手更像一个背景里默默干活的员工,而不是一个话痨机器人。这个技巧我强烈建议所有做 Personal Agent 的人试一下。