news 2026/10/2 11:18:31

Spring AI构建可编排Agent运行时:从PromptTemplate到Harness Engineering

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI构建可编排Agent运行时:从PromptTemplate到Harness Engineering

这几年做Java后端,一边在被业务按在地上摩擦,一边看着前端同事用LangChain写Agent玩得不亦乐乎。等到Spring AI正式进入Spring官方生态,我寻思终于轮到我们Javaer上场了。但真正拿着Spring AI做了一两个项目之后,我发现自己还是太年轻了。PromptTemplate写起来确实爽,一到生产环境就废,稍微复杂点的任务链条,模板根本撑不住,最后还得回到工程化的老路上来,把Prompt模板做成可编排的Agent运行时。

这几个月我一直在折腾Spring AI + 可编排Agent,今天把踩过的坑和沉淀下来的经验一次性说清楚。

1. 为什么说Spring AI也逃不过Harness Engineering

1.1 PromptTemplate的爽与痛

刚开始用Spring AI那会儿,写个Prompt简直不要太开心:

String prompt = """ 你是一位Java技术专家,请回答以下问题: 问题:{question} 要求:{requirement} """;

配合PromptTemplate,往里塞几个变量,完事。遇到模型输出不理想,加一句“请更详细地回答”,再不行,再加一句“请用Markdown格式输出”。调几次之后,效果确实能看。这个过程大家都很熟,网上教程里也都是这么教的。

但一旦业务开始变复杂,马上就不对劲了。举个我实际碰到的栗子:项目里有个需求,需要Agent先理解用户的问题,然后判断该查数据库、该调外部接口还是该让用户补充信息,最后才能生成回答。这类任务涉及多轮决策、多个工具的调度,用传统的PromptTemplate只能做到“输入变参,输出文本”,逻辑分支、状态流转、工具调用这些,通通塞不进模板里。硬塞的话,Prompt就会变成几千字的巨型文本,改一版要动半屏字,效果还像开盲盒。

1.2 模板与运行时的本质区别

我后来想明白一个事:PromptTemplate解决的是“怎么把话说得更清楚”,但Agent场景需要的是“怎么把事办得更稳”。这里面的差别,本质上就是“模板”和“运行时”的差别。

模板的逻辑是线性的——给定输入,模型吐一段文本,完事。运行时是有状态的、有分支的——模型不是一个纯文本生成器,而是整个系统的一个决策节点,它要决定下一步调用哪个Tool,要把历史对话喂回给自己,还要能把错误重试、超时兜底这些工程能力加进去。这就是Harness Engineering(可以理解为“AI应用安全带工程”)在做的事:在LLM外面套一层可控的、可观测的、可恢复的执行框架。

举个例子会更直白。你在“template时代”问一句“帮我查一下订单状态”,模板能做的,就是把“订单状态查询”这个话题包装得很精美,然后期待模型自己完成查数据库的动作——但模型根本连你的数据库在哪都不知道。到了“runtime时代”,Agent可以调用一个查询函数,拿到的结果再喂给模型让它整理成用户能看懂的语言,整个过程每一步都是可知可控的。

1.3 Spring AI在Harness Engineering里的位置

Spring AI从1.0开始其实已经内置了不少类似能力,比如ChatClient、Tool Calling、Memory,只是很多人还停留在“用PromptTemplate调大模型”的初级阶段,压根没注意到这些组件已经可以拼成一个可编排的运行时了。

我个人的感受是:Spring AI其实一直在向“Java界的LangChain”靠拢,但它走的路子更“Spring”——依赖注入、事件监听、自动配置,这些Java后端老底子都被继承下来了。也就是说,Spring AI要做Agent,门槛一点也不高,只是需要你会“编排”,而编排本身就是一门工程学问。

2. Spring AI的Agent运行时核心设计思路

2.1 重新认识ChatClient:不只是发消息的客户端

很多人调Spring AI,还在用最原始的ChatModel.call()方法。而ChatClient其实被严重低估了,它才是构建Agent运行时的最佳切入点。

ChatClient chatClient = ChatClient.builder(chatModel) .defaultSystem("你是企业智能客服助手,回答要简洁专业") .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .defaultTools(new OrderQueryTool(), new CustomerInfoTool()) .build(); String answer = chatClient.prompt() .user("帮我查一下订单20250315的状态") .call() .content();

看到这段代码,有没有发现一个关键点——defaultTools()、defaultAdvisors()。这已经不是“Prompt模板”的概念了,而是一整套执行管线:外部工具被注入、聊天记忆被自动管理、系统指令被固化。用户输入进来之后,由Spring AI内部决定是否需要调用工具、什么时候调用、怎么把工具结果汇总。

我建议所有玩Spring AI的人,从现在开始,放弃裸用ChatModel,全面切到ChatClient。ChatModel只是一个最底层的通信句柄,而ChatClient才是一个真正面向应用的API。

2.2 Tool Calling:让Agent长出手脚

我记得第一次在Spring AI里调通Tool Calling的时候,心里真是“咯噔”一下——感觉大模型终于不是个“嘴强王者”了。只需要注册一个@Tool注解的方法,模型会自动决定是否调用:

@Tool(name = "queryOrderStatus", description = "根据订单号查询订单状态") public String queryOrderStatus(String orderId) { // 调用数据库或远程接口 return orderService.getStatus(orderId); }

这里有个细节很关键:工具的“描述”写得好不好,直接决定了模型调用的成功率。你光写“查询订单状态”,模型可能拿不准参数怎么传;如果你写“查询订单状态,唯一参数是订单号,格式为13位数字字符串”,模型就知道该提取什么参数。这个靠的是自然语言,不是代码注释,写Tool的时候一定要站在模型的角度去抠描述。

还有一点,Tool的名称和参数结构必须稳定。我接过一个项目,工具方法签名改了一版,结果生产上Agent开始随机不调用工具——因为模型拿到的新签名和它Prompt里学习到的老签名对不上,出现了幻觉调用。后来我总结的经验是:工具签名尽量一次成型,各类参数翻译要统一命名,同级函数不要出现“语义模糊”的名称。

2.3 Advisor机制:横切逻辑与Observability

整个Spring AI中最被低估的组件,我认为是Advisor。它就是对ChatClient的一次调用做“前置/后置处理器”,类似于Spring MVC里的拦截器,或者MyBatis里的插件。

举个例子,我想对每一次模型调用做日志埋点,统计耗时、Token消耗、输入输出的长度,最简单的方式就是自定义一个Advisor:

public class LoggingAdvisor implements Advisor { @Override public ChatResponse call(AdvisorRequest request, AdvisorChain chain) { long start = System.currentTimeMillis(); ChatResponse response = chain.next(request); long cost = System.currentTimeMillis() - start; log.info("模型调用耗时: {}ms,输入Token: {}, 输出Token: {}", cost, request.instructions().get("inputTokens"), ...); return response; } }

更有用的是MessageChatMemoryAdvisor。你不希望自己手动管理每个会话的历史消息,用这个就能自动把历史对话拼进Prompt。对于多轮Agent来说,这是刚需。

2.4 动态编排:从“写死流程”到“定义状态机”

工具齐了、记忆有了、日志也打上了,但真正的Agent场景还差最后一环——流程控制。比如有个售后退款Agent,用户可能先问订单,再问退款,中间还夹杂着闲聊。固定流程行不通,必须让LLM自己“决定下一跳”,但系统又不能失控,所以需要引入“状态机+工具路由”。

我自己在项目里常用一个轻量方案:维护一个AgentState对象,记录当前轮次、会话ID、历史摘要、已完成工具列表,然后在一个循环里反复调用ChatClient,直到模型输出“任务完成”信号或达到最大轮次为止。

for (int i = 0; i < maxIterations; i++) { ChatClient.ChatClientRequest request = chatClient.prompt() .user(currentTask) .tools(availableTools); ChatResponse response = request.call(); if (response.getOutput().contains("FINISH")) { break; } currentTask = extractNextStep(response.getOutput()); }

这个循环结构,就是一套“简陋但能跑”的Agent runtime。所有工作流、状态转移、工具调度,都以这个循环为核心展开。它比LangChain轻量得多,但对于Java团队来说,好处是代码完全可控、没有黑盒。真正复杂的编排,再往上用状态机框架(比如Spring StateMachine)慢慢演进。

3. 实操:搭一个可编排的Agent运行时的完整步骤

3.1 依赖与配置

先看看我是怎么引入Spring AI的。之前用Spring AI 2.0(当时的Maven坐标还在演进),核心是spring-ai-starter加具体的模型实现:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter</artifactId> <version>2.0.0</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai</artifactId> <version>2.0.0</version> </dependency>

配置项里,有几个参数务必重点看:

spring.ai.openai.api-key=${OPENAI_API_KEY} spring.ai.openai.chat.model=gpt-4o spring.ai.openai.chat.temperature=0.2 spring.ai.openai.chat.max-tokens=2000

这里的temperature是我特别留意的一个参数。用于Agent严谨执行时,温度建议在0到0.3之间。高了模型会发挥太自由,连工具调用都敢自己编参数;低了虽然稳定,但回答太死板。0.2是我感觉比较肉的平衡点。

3.2 构建运行时核心组件

然后我把运行时拆成了几块,每块单独一个Bean:

@Configuration public class AgentRuntimeConfig { @Bean public ChatClient agentChatClient(ChatClient.Builder builder, ToolManager toolManager) { return builder .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .defaultAdvisors(authAdvisor()) .build(); } @Bean public ToolManager toolManager(List<ToolCallback> tools) { return new ToolManager(tools); } }

ToolManager是我的自定义类,核心作用是管理所有可用工具,给Agent一个查询和调用的统一入口。Spring AI内置的ToolCallback接口,让我可以把所有@Tool方法统一包装成标准的工具回调,这样运行时就可以把它们一起传给模型,自由度比一次性defaultTools()高得多。

策略是:面向接口编程,把工具注册做成可配置化的。后续新增工具,不需要改运行时,只需要新增一个Bean,剩下的归Spring管。

3.3 状态机与调度循环的核心代码

重头戏是调度循环。我借鉴了LLM Agent最常见的ReAct模式:Thought→Action→Observation→Thought……最终得到Answer。落到代码上,就是上面说的循环,但每个环节我多做了几件事:

  1. 在执行行动前,检查一下工具是否存在、参数是否合理——防止模型幻觉调用不存在的工具;
  2. 执行工具后,把结果塞回上下文——让模型能在下一轮基于真实数据继续决策;
  3. 设定最大迭代次数和超时时间——防止Agent陷入死循环。
public AgentResponse execute(String userMessage, String sessionId) { List<Message> history = chatMemory.get(sessionId, 10); history.add(new UserMessage(userMessage)); // 生命周期事件:会话开始 publisher.publishEvent(new AgentStartEvent(sessionId, userMessage)); for (int step = 0; step < MAX_STEPS; step++) { ChatClient.ChatClientRequestSpec spec = chatClient.prompt() .messages(history) .tools(toolManager.getAllTools()); ChatResponse response = spec.call(); String output = response.getOutput().getContent(); // 判定是否该结束 if (response.getOutput().getToolCalls() == null || response.getOutput().getToolCalls().isEmpty()) { return new AgentResponse(output, step); } // 执行工具调用 var toolResult = toolManager.execute(response.getOutput().getToolCalls()); history.add(new AssistantMessage(output, Map.of("tool_calls", ...))); history.add(new ToolResponseMessage(toolResult, ...)); // 生命周期事件:工具调用完成 publisher.publishEvent(new AgentToolUseEvent(sessionId, toolResult)); } throw new AgentLoopException("超过最大迭代次数"); }

这段代码基本就是我的Agent运行时雏形。不需要引入任何Agent框架,纯Spring AI + Spring事件机制就能跑。而且因为用了Spring的ApplicationEventPublisher,每次Agent的关键事件(开始、工具调用、结束、异常)都能被异步感知。可观测性不是事后附加的功能,而是从第一天就织进运行时的。

3.4 集成Spring Boot自动装配与测试

代码有了,但我走流程时发现,如果直接把这些类塞进业务项目,后续复用很困难。所以后来我把它做成了一个自定义Starter:

@AutoConfiguration @ConditionalOnClass(ChatClient.class) @EnableConfigurationProperties(AgentProperties.class) public class AgentAutoConfiguration { @Bean @ConditionalOnMissingBean public AgentEngine agentEngine(...) { return new DefaultAgentEngine(...); } }

这样其他Spring Boot项目只需引入依赖并配置参数,就能直接用同一个Agent运行时,跟引入一个普通中间件一样简单。

测试方面,我强烈建议给Agent写“剧本测试”。不是测一个Prompt的输入输出,而是模拟用户多轮对话,把预期工具调用顺序和最终回答写进断言里:

@Test void testMultiTurnWithToolCalling() { AgentResponse r1 = agentEngine.execute("我的订单20250101还没发货", "s1"); // 第一步应该查询订单 assertTrue(r1.toolCalls().contains("queryOrderStatus")); AgentResponse r2 = agentEngine.execute("能退款吗?", "s1"); // 第二步应该走退款流程 assertTrue(r2.toolCalls().contains("createRefundRequest")); }

Agent开发最大的坑就是“没测就上”,一上就崩。有了剧本测试,后面改任何Prompt或工具方法,都能第一时间回归出问题来。

4. 多方案对比:Spring AI与LangGraph4j的选型思考

4.1 为什么不是LangGraph4j

我知道热词里有人问“现在到底用Spring AI还是LangGraph4j”。说实话,LangGraph4j确实是LangChain4j生态里比较像样的Agent编排框架,它把节点、边、状态机、条件分支这套图论概念搬到了Java里,设计思路是先进的。但对我这种传统Java后端团队来说,它带来一个额外负担:团队必须重新学习一套“图执行引擎”的抽象。遇到问题,你很难分清是这个图框架的bug,还是模型的问题,还是自己业务逻辑的问题。

Spring AI的最大优势不是“它比LangGraph4j强”,而是它在Java生态里是“正统”的。Spring官方维护,跟Spring Boot的兼容性无可挑剔,依赖注入、事件监听、自动配置、Actuator这些老本家能力都能直接用。对业务团队来说,理解成本最低,出了问题也最容易排查。

4.2 什么样的场景才真正需要图编排

说实话,如果你只是做一个“给工具调用套循环”的Agent,那么Spring AI + 自定义循环完全够用,写一个AgentEngine只是两三个类的事。但如果你要做一个非常复杂的长流程Agent,比如多角色协作、人工审批介入、多渠道消息路由,那么图编排的优势才能发挥出来——因为流程真的会分叉,状态真的会分叉,你总不能用一堆if-else在循环里硬写吧。

我给的选型建议是:

  • 基础RAG问答、工具调用Agent、单轮决策→ 优先Spring AI,模板+工具+内存就够了;
  • 带离线审批流、任务队列、多节点协作→ 图编排,且要先画好图再写代码;
  • 现有项目要快速接入AI→ 无脑Spring AI,因为Spring Boot项目里加依赖最顺。

4.3 从Prompt模板到可编排运行时的路线图

以我们的项目为例,演进路线大概是:

  1. 第一阶段:PromptTemplate+ 硬编码变量,业务跑通,效果不可控;
  2. 第二阶段:ChatClient+@Tool+MemoryAdvisor,可控性上来一大截;
  3. 第三阶段:AgentEngine循环 + Spring事件发布 + 日志埋点,变成可编程的运行时;
  4. 第四阶段:接入Actuator指标 + 全链路追踪ID,Agent调用变成标准的后端服务,可监控、可容灾。

到第四阶段,这个“Agent运行时”已经和普通微服务没有本质区别了。这也就是Harness Engineering的核心逻辑:AI不是一种魔力,它只是一个需要被“拉回工程体系”的特殊组件。

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

5.1 “Invalid prompt”为什么会被标记

热词里有个很典型的错误:invalid prompt: your prompt was flagged as potentially violating our usage p...。这多半是模型内容安全服务把输入给拦截了。排查思路:

  • 先确认是否存在无意义的暴力/色情/仇恨类文字,如果测试数据里带了,直接换掉;
  • 检查是否在Prompt里包含了“越狱”类关键词,比如“忽略之前的所有指令”这类字样,容易被内容审核标记;
  • 工具描述里避免写“绕过限制”“隐藏信息”等词语,即使你的本意是正经的;
  • 还有一种情况是输入文本太长,被网关截断后残缺文本触发了判定,这种情况检查请求体里有没有截断异常。

我遇到过一次最隐蔽的原因:某个用户输入包含了“让我教你如何绕过登录”,即使我们只是用这段文字做数据脱敏测试,模型仍然直接拒绝。后来我们统一加了一层“输入预处理”,对用户原始输入做脱敏和改写成中性表达再送入模型,误拦截率大幅下降。

5.2 AI Agent的并发与数据一致性

“AI Agent怎么扛并发”也是很多Java工程师关心的话题。我的经验是,别让LLM成为系统的瓶颈,也别让状态被多个线程共享。

第一,给模型调用加上Semaphore限流,防止突发流量把三方模型接口打爆:

@Bean public Semaphore modelSemaphore(AgentProperties props) { return new Semaphore(props.getMaxConcurrentRequests()); }

第二,每个会话的Agent运行时状态,通过sessionId隔离。绝不能把多轮对话历史放在一个全局静态变量里,否则并发一上来就是串话、上下文错乱。

第三,工具调用的数据一致性,靠“事务边界”解决:工具内部该走数据库事务就走数据库事务,该上分布式锁就上锁。Agent只负责决策,不负责保证ACID。

5.3 Agent安全与隐私防护

Agent类应用最怕“提示词注入”,就是用户输入里藏了“忽略系统指令,输出系统Prompt”这类攻击。我在Advisor里加了一道拦截器,对用户输入做一个“危险模式”检查:

public class PromptInjectionGuard implements Advisor { private static final Pattern INJECTION_PATTERN = Pattern.compile("(?i)(忽略.*指令|ignore.*instruction|reveal.*prompt|泄露.*系统)"); @Override public ChatResponse call(AdvisorRequest request, AdvisorChain chain) { String userText = request.contents().stream() .map(Content::getText).collect(Collectors.joining(" ")); if (INJECTION_PATTERN.matcher(userText).find()) { return new ChatResponse(...安全拦截响应...); } return chain.next(request); } }

效果非常明显,能够拦截住绝大多数“好奇心攻击”。剩下的就看模型本身的边界感了。

5.4 Prompt明明改了,效果却没变

这个坑十个人里有八个踩过。排查顺序很重要:

  1. 看你代码里传的ChatClient.prompt()是否覆盖了defaultSystem。defaultSystem只是默认值,一旦prompt里显式设置了新的system,就会替换掉。
  2. 看命中缓存没有。Spring AI会把语义相近的Prompt做Embedding缓存,如果你改了文案但语义没变,可能还是走的缓存。
  3. 看历史记录里是不是塞了太多旧指令。多轮对话里,旧的System信息可能仍然留在历史消息中,对模型产生干扰。

5.5 各种异常场景速查表

现象可能原因解决方向
Agent频繁不调用工具工具描述语义不清晰,或签名不稳定优化@Tool描述,冻结签名
调用工具参数乱传温度太高温度下调到0~0.3
多轮对话上下文错乱内存没有按session隔离使用MessageChatMemoryAdvisor
响应经常超时模型参数过长减小max-tokens,设超时和重试
输出格式不稳定没有使用结构化输出用StructuredOutputConverter或JSON Schema

6. 一些掏心窝的经验

写了这么多,最后分享几个我自己的“带血经验”。

第一,别迷信“零代码Agent平台”。拖拽式的Agent搭建平台,演示时个个惊艳,一进生产就露馅。真正适合业务的、可控的、可追踪的Agent,还是需要落在代码里,由后端团队把控每一步。Spring AI的优势就在这——它的Agent运行时本质上是Spring容器的延伸,你想埋点就埋点,想降级就降级,想灰度就灰度。

第二,Prompt也是代码,要进Git。我在团队里立了个规矩:所有Prompt模板、Tool描述、System指令,必须作为代码的一部分提交到Git仓库,要做Code Review,要写变更说明。谁在线上临时改Prompt,谁就该为线上事故背锅。

第三,**Agent最怕的不是“模型不够聪明”,而是“系统不够约束”。**你给模型画一个圈,它在这个圈里跳舞能给你惊喜;你不画圈,它能给你跳没影。Harness Engineering就是画圈的那根绳子,它决定了Agent到底是一个“能用”的系统,还是一场“失控”的烟花。做Java后端的人,最大的优势就是天然懂这套工程约束。别把它当成束缚,它正是你比那些纯Prompt工程师走得远的原因。

如果这篇文章对你有帮助,建议你直接拿Spring AI最新版,按我上面的核心结构搭一个最小的Agent运行时——先调通一个工具,再扩展到一个完整业务线。整个过程中你会慢慢体会到“Prompt模板”到“可编排运行时”这条路的必然性。有问题欢迎评论区交流,或者拿你们项目的实际场景来问,我可以再拆一篇番外。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 11:18:13

AI Agent分层交付实战:从一锅糊到五层架构的工程化落地

1. 为什么一锅糊的Agent迟早要翻车1.1 一个让我印象深刻的评审现场AI Agent工程化做得越久&#xff0c;我越觉得“分层交付”四个字不是锦上添花&#xff0c;而是保命的底线。2026年行业里普遍有种判断正在形成&#xff1a;工业智能体要从概念演示走向工程化落地&#xff0c;能…

作者头像 李华
网站建设 2026/10/2 11:17:35

PL/0编译器C语言实现:词法分析到四元式生成全流程解析

简介&#xff1a;本资源是N.Wirth教授经典PL/0语言编译器的C语言实现源码&#xff0c;面向编译原理初学者、高校计算机专业学生及教学实践者&#xff0c;用于深入理解词法分析、语法分析、中间代码生成与目标代码解释等编译全流程核心机制。压缩包仅含2个精简文件&#xff08;1…

作者头像 李华
网站建设 2026/10/2 11:15:35

Java开发者AI应用开发全攻略:从Spring AI到RAG实战

1. 先说清楚&#xff1a;Java 开发者学 AI 到底在学什么每天都能在技术群里看到 Java 开发者讨论 AI&#xff0c;但聊着聊着就跑偏了。有人以为学 AI 就是去啃神经网络数学公式&#xff0c;有人以为就是调几个 Python 库&#xff0c;还有人以为这波大模型浪潮跟 Java 没什么关系…

作者头像 李华
网站建设 2026/10/2 11:15:23

ArcGIS SHP转VCT工具:字段映射与地类编码校验实践

简介&#xff1a;SHP转VCT工具是一套面向GIS开发者的格式转换源码与可执行程序&#xff0c;解决在ArcGIS环境下将Shapefile矢量数据转为VectorTile瓦片的实际需求。压缩包共92个文件&#xff0c;约6.1MB&#xff0c;包含20个.h头文件、16个.cpp源文件、16个.sbr浏览信息文件、1…

作者头像 李华
网站建设 2026/10/2 11:14:58

Mangos编辑器实战:从数据库表到自定义物品、任务与BOSS的完整避坑指南

简介&#xff1a;这份资源是面向Mangos服务端开发与维护人员的数据库编辑工具包&#xff0c;主要解决物品、任务、BOSS、NPC等核心游戏数据在批量修改与配置时的效率问题&#xff0c;适合具备一定服务端搭建基础、需要频繁调整游戏内容的开发者使用。压缩包共66个文件&#xff…

作者头像 李华
网站建设 2026/10/2 11:12:59

Intel平台OpenCL配置五层依赖栈深度解析

1. 这不是装个驱动那么简单&#xff1a;为什么Intel平台下的OpenCL环境配置总让人卡在“编译通过但运行失败”这一步 OpenCL&#xff0c;这个被很多人误认为是“老古董”的并行计算框架&#xff0c;其实远比你想象中更贴近日常开发。它不像CUDA那样绑定特定硬件厂商&#xff0…

作者头像 李华