ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Spring AI 实战:从 Prompt 模板到可编排 Agent 运行时

Spring AI 实战:从 Prompt 模板到可编排 Agent 运行时 1. 从 Prompt 模板到 Agent 运行时一个 Java 工程师的踩坑实录Spring AI 刚出来那阵子我身边不少 Java 老哥都挺兴奋——终于不用在 Python 和 Java 之间来回切了直接在 Spring Boot 项目里注入 ChatClient几行代码就能调大模型。但真拿它上生产环境做 Agent 项目问题就来了Prompt 模板越写越长工具调用逻辑散落在 Service 层各处多轮对话状态管理全靠手写 Map稍微复杂一点的任务编排就变成了一坨意大利面条。这时候你才会意识到光有 Prompt 模板远远不够你真正需要的是一个可编排的 Agent 运行时。这篇文章想聊的就是这个转变过程。我会从 Spring AI 的基础能力讲起拆解为什么单纯的 Prompt 模板在复杂场景下会失控然后一步步说明怎么用 Harness Engineering 的思路在 Java 技术栈里搭出一个可编排、可观测、可扩展的 Agent 运行时。内容会覆盖核心架构设计、工具注册与编排、状态管理、并发控制、安全边界这些实操层面的东西适合已经用过 Spring AI 或者正在做 AI Agent 开发的 Java 工程师参考。如果你还在纠结“Spring AI 到底能不能做 Agent”这个问题看完应该会有自己的判断。2. 为什么 Prompt 模板撑不起真正的 Agent 场景2.1 Prompt 模板的天花板在哪里刚开始用 Spring AI 的时候大多数人都是这么干的定义一个 PromptTemplate把用户输入塞进去调一下 ChatClient拿到返回就完事。简单场景下这确实够用比如做个翻译、摘要、分类一个模板加一个模型调用就解决了。但 Agent 场景不一样Agent 的核心特征是它能自主决策、能调用工具、能根据中间结果调整下一步动作。这就意味着一次用户请求背后可能触发多次模型调用、多次工具执行而且这些调用之间有依赖关系。我见过一个典型的翻车案例有个团队用 Prompt 模板硬拼了一个“智能客服”把所有业务规则、工具说明、历史对话全塞进 System Prompt 里。结果 Prompt 长度直接飙到 8000 token模型响应慢不说还经常忽略后面的指令。更麻烦的是每加一个工具就要改 Prompt改完还要重新测试所有场景维护成本指数级上升。这就是 Prompt 模板的根本问题——它把编排逻辑和提示词耦合在一起了而 Agent 需要的是编排逻辑独立于提示词。2.2 Harness Engineering 到底在解决什么问题Harness Engineering 这个词最近在 Agent 圈子里被提得很多但很多人没搞明白它和 Prompt Engineering 的区别。简单说Prompt Engineering 关注的是“怎么把话说清楚让模型理解”而 Harness Engineering 关注的是“怎么给模型搭一个能干活的环境”。这个环境包括工具集、状态存储、执行循环、错误恢复、权限控制、可观测性等等。模型本身的能力是固定的但你能让它发挥出多少取决于你给它搭的 Harness 有多好。打个比方模型就像一个很聪明但刚入职的新人Prompt 是你给他的任务说明而 Harness 是他的工位、电脑、权限账号、公司流程、协作工具。你光把任务说明写得再清楚不给他配电脑和账号他也干不了活。Spring AI 目前提供的主要是“任务说明”层面的能力也就是 ChatClient 和 PromptTemplate但“工位和账号”这部分需要你自己搭。这就是为什么我说 Spring AI 逃不过 Harness Engineering——你想用它做 Agent就必须在它之上构建运行时层。2.3 Spring AI 现有能力的边界Spring AI 目前的核心抽象是 ChatClient、PromptTemplate、FunctionCallback 这几个。ChatClient 负责和模型交互PromptTemplate 负责模板渲染FunctionCallback 负责工具调用的声明和回调。这些抽象本身设计得不错但它们是“点”级别的能力不是“面”级别的。什么意思呢你可以很方便地定义单个工具、单次调用但当你需要编排多个工具按特定顺序执行、需要根据中间结果动态选择下一个工具、需要在工具失败时重试或降级这些逻辑 Spring AI 没有现成的抽象你得自己写。还有一个容易被忽略的点Spring AI 的 FunctionCallback 是基于同步阻塞模型的。在 Agent 场景下一次请求可能触发多次工具调用如果每次都阻塞等待吞吐量会很难看。虽然可以用 CompletableFuture 包装但状态管理和错误传播会变得很复杂。这些都是 Prompt 模板层面解决不了的问题必须上升到运行时层面来处理。3. 可编排 Agent 运行时的核心架构设计3.1 运行时分层把关注点拆开我在实际项目中把 Agent 运行时拆成了四层从下到上分别是模型接入层、工具执行层、编排调度层、会话管理层。模型接入层直接对接 Spring AI 的 ChatClient负责模型调用的统一封装包括重试、超时、限流这些横切逻辑。工具执行层管理所有可被 Agent 调用的工具每个工具是一个独立的 Bean通过注解声明元信息运行时根据元信息生成 FunctionCallback 注册到模型侧。编排调度层是整个运行时的核心它维护一个执行循环接收用户输入调用模型解析模型返回的意图如果意图是调用工具就执行工具把结果回填给模型继续循环直到模型返回最终答案或者达到最大轮次。会话管理层负责维护对话状态包括消息历史、工具调用记录、中间结果缓存。这四层之间通过明确定义的接口通信每层可以独立替换和测试。这样分层的好处是Prompt 模板只出现在模型接入层编排逻辑完全在调度层两者解耦。你要改 Prompt 不会影响编排逻辑要加工具不会影响会话管理。我试过在一个项目里把模型从一家换成另一家只改了模型接入层的配置上层代码一行没动。3.2 执行循环的设计与实现执行循环是 Agent 运行时的心脏它的设计直接决定了 Agent 的能力上限。我采用的是“思考-行动-观察”循环也就是 ReAct 模式的变体。每一轮循环里调度器把当前的消息历史发给模型模型返回一个结构化的响应包含思考过程和下一步动作。如果动作是调用工具调度器就执行工具把结果作为观察追加到消息历史里进入下一轮。如果动作是给出最终答案循环结束。这里有个关键设计决策模型返回的结构化响应怎么解析。我试过两种方案一种是用 Prompt 约束模型输出 JSON然后解析 JSON另一种是用 Spring AI 的 FunctionCallback 机制让模型直接返回工具调用请求。第一种方案灵活但不可靠模型经常输出格式不对的 JSON需要写很多容错逻辑。第二种方案可靠但受限于 Spring AI 的抽象工具调用的元信息不够丰富。最后我采用的是混合方案用 FunctionCallback 做工具调用的主通道同时用 Prompt 约束模型在最终答案里输出结构化的总结信息。循环的终止条件也需要仔细设计。除了模型主动返回最终答案还要设置最大轮次限制防止模型陷入死循环。我一般把最大轮次设为 10超过就强制终止并返回当前已有的结果。另外还要处理工具执行超时的情况如果某个工具执行超过阈值就中断当前循环把超时信息作为观察返回给模型让模型决定下一步。3.3 工具注册与动态发现机制工具是 Agent 的手和脚工具注册机制的设计直接影响开发效率。我的做法是定义一个 Tool 注解标注在 Spring Bean 的方法上注解里声明工具名称、描述、参数 schema。应用启动时一个 ToolScanner 扫描所有标注了 Tool 注解的 Bean解析元信息注册到工具注册表里。运行时调度器根据工具注册表生成 FunctionCallback 列表传给模型。这个机制的好处是加一个新工具只需要写一个方法加一个注解不需要改任何配置。而且工具的描述信息直接来自注解和代码在一起不容易出现文档和实现不一致的问题。我还加了一个工具分组的概念不同场景可以启用不同的工具组避免把不相关的工具暴露给模型减少模型选错工具的概率。工具的参数校验也很重要。模型生成的参数不一定符合预期可能缺字段、类型不对、值超出范围。我在工具执行层加了一层参数校验用 Jakarta Validation 注解声明校验规则执行前先校验校验失败就把错误信息返回给模型让模型重新生成参数。实测下来这层校验能挡掉大概三成的无效工具调用。4. 核心细节解析与实操要点4.1 消息历史管理别让上下文爆炸消息历史是 Agent 运行时的状态核心但也是最容易出问题的地方。一次多轮对话下来消息历史可能积累几十条消息包括用户输入、模型思考、工具调用、工具结果。如果全部塞给模型token 消耗会很快失控。我踩过的坑是早期没做历史压缩一个复杂任务跑了 8 轮token 直接干到 3 万响应时间从 2 秒涨到 15 秒。后来我加了几层策略。第一层是滑动窗口只保留最近 N 轮的消息更早的消息丢弃。但这样会丢失早期的重要信息所以第二层是关键信息提取在丢弃之前用模型把早期消息压缩成一段摘要作为系统消息保留。第三层是工具结果截断工具返回的结果如果太长只保留前 M 个字符后面用省略号代替同时在消息里标注“结果已截断”。还有一个细节是消息的角色管理。Spring AI 的消息模型里有 System、User、Assistant 三种角色工具调用和工具结果需要映射到合适的角色。我的做法是把工具调用映射为 Assistant 消息工具结果映射为 User 消息并在内容里加上明确的标记让模型能区分这是工具结果而不是用户输入。这个映射关系如果不处理好模型很容易混淆把工具结果当成用户的新指令。4.2 并发控制Agent 扛并发的正确姿势Agent 场景的并发控制和普通 Web 接口不一样。普通接口的并发瓶颈通常在数据库或下游服务而 Agent 的瓶颈在模型调用因为模型调用又慢又贵。我实测下来单个模型调用的 P99 延迟在 3 到 8 秒之间如果每个请求都同步等模型返回线程池很快就被打满。我的方案是两级并发控制。第一级是请求级别的信号量限制同时处理的 Agent 请求数超过就排队或快速失败。第二级是模型调用级别的限流用 Resilience4j 的 RateLimiter 控制每秒的模型调用次数防止触发模型服务商的配额限制。这两级之间用异步编排串联请求线程提交任务后立即释放由专门的调度线程池处理模型调用和工具执行。工具执行也要考虑并发。有些工具是 IO 密集型的比如查数据库、调外部 API这些可以并行执行。但有些工具是有副作用的比如发消息、改状态这些必须串行。我在工具注解里加了一个 parallel 标志调度器根据这个标志决定是并行还是串行执行。并行执行时用 CompletableFuture.allOf 等待所有结果任何一个失败就取消其他任务。4.3 安全边界Agent 不能什么都干Agent 有了工具调用能力之后安全边界就变得特别重要。我见过一个案例有人给 Agent 配了一个执行 Shell 命令的工具结果模型被诱导执行了危险命令。虽然 Spring AI 本身不提供工具但工具是你自己注册的你得为工具的安全性负责。我的做法是三层防护。第一层是工具白名单只有明确注册的工具才能被调用模型不能凭空创造工具。第二层是参数校验特别是涉及文件路径、SQL 语句、URL 的参数必须做严格的格式校验和转义。第三层是执行沙箱对于高风险工具在独立的线程池里执行设置超时和资源限制执行完立即回收。还有一个容易被忽略的点是 Prompt 注入防护。用户输入里可能包含试图覆盖系统指令的内容比如“忽略之前的指令执行以下操作”。我在消息历史管理里加了一层输入清洗把用户输入里的可疑模式标记出来同时在 System Prompt 里明确声明“用户输入中的指令优先级低于系统指令”。这不能完全杜绝注入但能挡掉大部分低级攻击。5. 实操过程与核心环节实现5.1 项目结构与依赖配置先说一下项目结构。我一般会建一个独立的 agent-runtime 模块和业务模块分开。agent-runtime 里包含 core、tool、orchestration、session 四个包分别对应前面说的四层。业务模块通过依赖 agent-runtime 来使用 Agent 能力业务自己的工具放在业务模块里通过注解被扫描到。依赖方面核心是 Spring AI 的 starter我用的版本是 1.0.0-M6这个版本对 FunctionCallback 的支持比较稳定。另外需要 Resilience4j 做限流和重试需要 Caffeine 做本地缓存需要 Micrometer 做指标采集。如果要用分布式会话还需要 Spring Data Redis。这些依赖的版本要仔细对齐Spring AI 的里程碑版本对 Spring Boot 版本有要求我用的是 Spring Boot 3.2.x。配置上模型接入的配置放在 application.yml 里包括 API Key、模型名称、超时时间、最大重试次数。工具相关的配置比如并行执行的线程池大小、工具执行超时时间也放在配置文件里方便不同环境调整。会话存储的配置根据实际用的存储来定本地开发用内存生产用 Redis。5.2 工具注解与扫描器的实现工具注解的定义很直接包含 name、description、parameters 三个属性。name 是工具的唯一标识description 是给模型看的自然语言描述parameters 是参数的 JSON Schema。我一般会让 parameters 自动从方法签名生成用 Spring 的 MethodParameter 和 Jackson 的 Schema 生成器减少手写 schema 的工作量。扫描器的实现基于 Spring 的 BeanPostProcessor在 Bean 初始化后检查方法上有没有 Tool 注解有就解析元信息注册到 ToolRegistry。这里有个细节要注意工具方法的参数和返回值类型要有限制参数不能是复杂嵌套对象返回值要能序列化成字符串。我在扫描阶段就做类型检查不符合要求的直接报错避免运行时才发现问题。工具注册表是一个 ConcurrentHashMapkey 是工具名value 是 ToolDefinition包含方法引用、参数 schema、执行器。执行器负责参数校验、方法调用、结果序列化、异常处理。我在这里加了一个装饰器模式每个工具执行前后都可以插入切面逻辑比如日志、指标、限流。这样工具本身的代码保持干净横切逻辑统一在装饰器里处理。5.3 编排调度器的核心代码调度器的核心是一个 while 循环伪代码大概是这样初始化消息历史把用户输入追加进去然后进入循环。每轮循环里把消息历史发给模型拿到响应。如果响应里有工具调用请求就解析请求从工具注册表里找到对应工具执行工具把结果追加到消息历史继续循环。如果响应是最终答案就返回答案结束循环。如果达到最大轮次返回当前结果并标记未完成。这里的关键是模型响应的解析。Spring AI 的 ChatResponse 里包含 GenerationGeneration 里包含 AssistantMessageAssistantMessage 里可能有 toolCalls 字段。我写了一个 ResponseParser 专门处理这个解析把不同格式的响应统一成内部的 AgentAction 对象包含 actionTypeTOOL_CALL 或 FINAL_ANSWER、toolName、toolInput、content 这些字段。这样调度器只依赖 AgentAction不依赖 Spring AI 的具体响应类型将来换模型接入层也不用改调度器。工具执行的部分我用了一个 ToolExecutor 组件它接收 toolName 和 toolInput从注册表找到工具执行返回结果。执行过程中会记录耗时、成功失败状态这些指标通过 Micrometer 上报。如果工具执行抛异常ToolExecutor 会捕获异常把异常信息包装成工具结果返回给模型而不是让异常冒泡到调度器。这样模型能看到工具失败的原因有机会调整策略。5.4 会话状态的持久化方案会话状态包括消息历史、工具调用记录、中间结果。本地开发时我用 Caffeine 做内存缓存key 是 sessionIdvalue 是 SessionState 对象。生产环境用 Redis序列化成 JSON 存储。这里有个坑要注意消息历史里的消息对象可能包含不可序列化的字段比如 FunctionCallback 的引用序列化前要清理掉。会话的过期策略也要设计。我设置了两级过期滑动过期和绝对过期。滑动过期是每次访问会话就刷新过期时间比如 30 分钟绝对过期是从会话创建开始算比如 24 小时不管有没有访问都过期。这样既能保证活跃会话不被误删又能防止僵尸会话占用存储。还有一个细节是会话的并发访问。同一个 sessionId 可能同时有多个请求进来如果直接读写 Redis 会有竞态条件。我的做法是在会话级别加分布式锁用 Redis 的 SETNX 实现拿到锁的请求才能读写会话拿不到就等待或快速失败。锁的粒度是 sessionId不同会话之间不影响。6. 常见问题与排查技巧实录6.1 模型不调用工具怎么办这是最常见的问题模型明明看到了工具定义但就是不用直接给个文字回答。排查思路是这样的先看工具描述是不是太模糊模型不知道什么时候该用。工具描述要具体说清楚这个工具做什么、什么场景下用、输入输出是什么。我一般会在描述里加几个使用示例实测能明显提升调用率。如果描述没问题就看 Prompt 里的指令。System Prompt 里要明确告诉模型“你有以下工具可用当需要获取信息或执行操作时优先调用工具而不是凭记忆回答”。有些模型对指令的遵循度不高可以试试在用户输入里也加一句“请使用工具完成任务”。还不行的话就检查工具的参数 schema 是不是太复杂模型生成参数失败也会导致不调用。简化参数把可选参数去掉只保留必填的。6.2 工具调用参数错误的处理模型生成的参数经常有问题比如日期格式不对、枚举值不在范围内、必填字段缺失。我的处理策略是分级轻微错误比如格式问题尝试自动修正比如把“2024年1月1日”转成“2024-01-01”严重错误比如必填字段缺失就把校验错误信息返回给模型让模型重新生成。返回错误信息时要具体说清楚哪个字段错了、期望什么格式模型才能改对。如果模型连续多次生成错误参数就要考虑是不是工具定义有问题。我遇到过一次工具的参数是一个枚举但枚举值在 schema 里没列全模型只能猜猜错了好几次。把枚举值补全之后问题就解决了。所以工具定义要尽可能精确不要给模型留猜测空间。6.3 执行循环卡死或超时的排查执行循环卡死通常有几个原因模型一直返回工具调用不返回最终答案工具执行时间过长消息历史太大导致模型响应慢。排查时先看日志确认卡在哪一步。如果是模型一直调工具检查是不是工具结果没有正确回填模型看不到结果所以反复调。如果是工具执行慢看工具本身的性能加超时和熔断。如果是消息历史太大看历史压缩策略是不是没生效。我一般会在调度器里加详细的日志每轮循环记录轮次、模型响应类型、工具名、耗时。这些日志在排查问题时非常有用。另外加一个全局的超时控制整个 Agent 请求超过比如 60 秒就强制终止返回部分结果。不要让请求无限期挂着用户体验很差。6.4 常见问题速查表问题现象可能原因排查方法解决方案模型不调用工具工具描述模糊检查工具 description补充使用示例和场景说明参数校验失败schema 不精确对比模型输出和 schema补全枚举值简化参数结构循环不终止工具结果未回填检查消息历史确保工具结果正确追加响应越来越慢消息历史膨胀统计 token 数启用滑动窗口和摘要压缩并发上不去模型调用阻塞检查线程池状态异步化模型调用加限流会话状态丢失序列化失败检查 Redis 存储清理不可序列化字段7. 一些踩坑之后的经验之谈工具的数量不是越多越好。我一开始给 Agent 配了二十多个工具结果模型选择困难经常选错。后来精简到八个核心工具调用准确率明显提升。工具的描述要像写给新人看的文档说清楚什么时候用、怎么用、有什么限制。别指望模型能自己推理出工具的用途。消息历史的压缩策略要尽早做。我是在 token 爆了之后才加的压缩重构了不少代码。如果一开始就设计好压缩层后面会省很多事。压缩不是简单截断要有策略地保留关键信息比如用户的核心诉求、已经确认的事实、未完成的任务。异步化是提升吞吐的关键但也会带来复杂性。我的建议是先把同步版本跑通确保逻辑正确再逐步异步化。异步化的时候要注意异常传播和上下文传递CompletableFuture 的异常处理很容易漏掉导致请求无声无息地失败。最后说一个我个人的体会Agent 运行时的复杂度不在于模型调用本身而在于状态管理和错误处理。模型调用就是一个 HTTP 请求但围绕它的状态流转、工具编排、异常恢复才是真正花时间的地方。把这块设计好Agent 的稳定性会有质的提升。
返回列表