说在最前面所谓高大上的Agent本质无非还是一次大模型LLM调用请求就像你跟豆包发一句“你好”一样底层就是通过一次API调用把这个“你好”发送给了对应的LLM这个LLM可以是deepseek、chatGpt、或者claude等等。然后大模型回复文字内容前端再展示出来给我们看到。“LLM仅仅是一个会问答的黑盒机器”。前序Spring AI你可能会说不对啊我的AI还能给我搜索浏览器、发邮件、下订单完全不只是问答这么简单好吧。诶就是这么简单你理解的搜索浏览器、发邮件、下订单等等这里统称为工具调用ToolCallLLM只会根据对话场景思考回复出“我需要搜索浏览器”、“我需要发邮件”、“我需要下订单”这些文字指令真正执行这些操作的是谁呢答案是由背后的框架来承接实现的。由于模型的回复指令格式可能千奇百怪为了方便框架解析处理于是约定了大模型在回复要调用某个工具的时候需要使用特定Json结构来输出包含工具调用的入参框架这边收到这串工具调用输出后Json字符串后再按照对应结构来解析即可最后再真正去执行对应的工具如搜索浏览器、发邮件、下订单然后再把工具执行完成后的结果信息返回给LLM看。而约定这个Json结构该长啥样的约定就是所谓的Function Call/MCP协议。在JAVA这边Spring AI本身就帮我们把工具调用ToolCall的解析实现好了对于模型返回的 Tool Call指令Spring AI 会解析然后分发到统一的ToolCallback接口进行实现而所谓的MCP Client就只是这个接口的对应的一个远端实现。同样的考虑到不同模型厂商deepseek、chatGpt、claude等发起模型问答调用的请求协议不同Spring AI框架也在内置实现中声明了一套适配所有模型的接口不同厂商的 HTTP 协议、认证方式、JSON 格式全部被屏蔽。你恍然小悟那一次问题就是一轮LLM调用请求呗大模型收到请求后需要调用工具就调不需要就直接回复然后就结束了原来Agent就这么简单。那当然不是假设你的问题是帮我做一份竞品分析报告LLM 根本不知道你的竞品有哪些新动态它的训练数据是截止到某个时间点的。而你需要的其实是1.先搜索最新竞品信息调用搜索工具2.再整理搜索结果再一次 LLM 调用3.再生成报告又一次 LLM 调用4.如果报告质量不够好还要循环重试随之而来的就会有下面几个问题1.状态要传递每次 LLM 调用都是无状态的。步骤 2 需要知道步骤 1 搜到了什么步骤 3 需要知道步骤 2 整理出了什么。谁来管这个中间数据的流转2.条件分支搜索失败了怎么办搜到结果质量差要不要重试这需要条件路由而不是顺序执行。3.人不知道任务进展任务跑了 5 分钟你完全不知道它到哪步了出错了也不知道。你想了想灵光乍现模型的调用过程不是一蹴而就的而是一个循环这不就是前阵子爆火的React循环Reasoning Acting「思考 ⇌ 行动 (工具调用)」循环迭代吗是的没错而且巧的是这三个问题Spring AI底层都已经做好了简单版本应对复杂业务需求还不够1.状态传递内置MessageChatMemoryAdvisor自动把历史消息注入每次请求2.条件分支内置React循环实现 工具调用 → 思考 → 再调用工具的线性循环LLM 自己决定什么时候停3.人不知道任务进展内置Micrometer Observation自动记录每次模型调用的 span、token 用量、延迟你若有所思框架好像有点东西这些东西不需要自己动手写了直接用现成的就行。那既然有了这个Spring AI为啥还需要Spring AI Alibaba呢正片Spring AI Alibaba好问题让我们更进一步假设现在你做的是安全审核业务你想使用Agent来帮你进行风控审核提升效率但是特定情况下需要人工参与决策你要怎么实现呢你想了想这还不简单需要人工参与决策的部分直接提前打标识到时候执行过程中加个if判断不就好了有标识就中断React循环返回给前端让用户决策没标识继续给Agent。听着简单可行那用户审批完以后怎么带着用户决策信息从上次执行中断的位置继续向下执行呢那就再加一个标识标识执行到了那个节点再次进入的时候标识之前的节点if判断了直接跳过。那一个需要人工决策的部分加一个标识多个部分不就得挨个加标识吗而且如果我不单想在节点之前前做决策还想在节点执行后做决策呢还想记录决策的审批原因呢那得挨个加多少标识呢你顿了下这个实现思路好像确实不太优雅。还有审核过程是多纬度的不同纬度理应并行处理提升审核效率但Spring AI现在的循环是线性的你要怎么解决呢还有假如其中某个的某个循环节点也是一个AgentAgent嵌套Agent/父子Agent 你准备怎么实现呢还有......你愣住了开始意识到想要应对复杂业务场景还有很多东西需要解决。于是乎Spring AI Alibaba下面简称saa出手了。Graph既然之前的普通React循环是线性的天然不支持并行处理学过数据结构的很快就能想到把这个循环改做成图Graph不就好了。saa抽象出图Graph和图执行节点NodeAction概念。NodeAction只声明一个apply接口即节点 一个异步函数。这个接口是函数式声明的只允许一个抽象方法只干一件事接收当前状态输出更新字段开放兼容且简单直接。它的实现可以是任何东西一段业务代码处理文档、调数据库一次 LLM 调用工具调用或者另一个完整的子图完美兼容嵌套场景。节点间使用边Edge来建立顺序判断下一步去哪。边NodeANodeB表示从节点A的下一步到节点B。同时在建边的时候需要声明起始节点START和结束节点END方便图执行的开始和结束判断很好理解。实际图的构建过程也很简单定义节点一个环节该做的操作逻辑可以是任何东西- 建立边连接(节点的顺序下一个节点是哪个) - 编译成图 - 运行。那图的结构类型有很多像邻接矩阵、邻接表十字链表等等saa的Agent运行图是个什么结构呢saa的Graph在编译前只是独立的两个Set集合节点集合和边集合在编译的过程中再把这两集合组装成邻接表两HashMap分别用于映射某个节点的执行逻辑去哪找和某个节点的下一个节点去哪找。基于这两编译后的邻接表Graph最终运行的时候会将Agent运行图给拉平成了一个链表从START节点出发到END节点结束。图的运行为啥不是dfs深度遍历或者bfs层序遍历因为需要一次走一步、每步用上一步的结果决定下一步。顺带提一下节点间的状态通过一份全局状态信息传递 OverAllState本质是一个Map。每个 key 即为一个状态字段考虑到有多个节点执行过程中并行修改同一份数据的情况所以每个key需要有自己的更新策略KeyStrategy让框架知道当多个节点写同一个字段时怎么把它们合并起来。当然实际开发过程中我们肯定会把这份全局状态信息存档避免多轮会话时记忆丢失例如存到Redis中RedisSaver。而这个编译的过程除了将节点组装成图邻接表以外还会做很多检查例如检查有没有 START 节点的出发边边指向的节点是否存在等同时若检测到某个节点为一个子图时嵌套Agent会把子图铺平进父图若检测到有并行边还会自动插入并行节点ParallelNode开启并行等三个都完成后把结果合并进 OverAllState。执行过程中所有节点共有一个迭代计数器默认100每次调度都 1超过阈值则停止避免无限循环。可以看到通过抽象封装出 Graph 以后Agent的使用变的灵活起来不再只局限于线性循环各种并行、嵌套场景也能应对自如。HITLHuman In The Loop简称HITL即Agent运行过程中的人工决策操作。产生人工决策的节点一般有哪些像文件的增删改查、业务操作的执行、接口请求的发起等等这些操作其实都可以归属于工具调用范畴。还是那句话模型只知道回复指令文字外的执行操作我们都可以统称为工具调用所以HITL基本上都是工具调用粒度的。要将工具调用决策上行给用户审批必定需要进行中断等待毕竟程序不能只为某一个操作挂起等待。因此需要设计一种通用的中断机制在这些风险操作/工具调用执行前/后进行中断拦截记录拦截时刻下的相关信息提供给用户进行审批。同时由于不能保证用户立马完成审批若间隔若干时间后审批完成也需要保证能够恢复到中断点之后继续运行。实现中断机制很容易想到拦截器/AOP但是saa的实现不是这样的。saa在中断之上抽象出HookHook本身可以干活也可以中断。在Hook下面有各种不同粒度的实现例如Agent级别的AgentHook模型级别的ModelHookSkill级别的SkillHook人工决策级别的HumanInTheLoopHITL等等。每个hook可以选择是否实现InterruptableAction这个中断接口若重写实现则表示该hook拥有中断能力。那Hook是如何生效的呢saa是在图编译阶段把各类型 Hook 变成了一个真正的图节点即hook节点hook节点的执行逻辑就是hook自身的apply方法和其他节点如 LLM 节点、工具节点平起平坐。放到拦截节点执行前的hook在Agent运行图中的位置即为该拦截图节点的上一步放到拦截节点执行后的hook在图中的位置即为该拦截图节点的下一步这样抛开中断不谈首先该hook的逻辑就能保证在对应位置上正确执行了。然后在实际执行的时候若再发现当前节点实现过InterruptableAction接口则进行中断实际包含节点执行前中断和节点之前后中断否则不中断相当于这类 Hook 是纯粹的节点内嵌逻辑就是让你在某个时机做点额外的事没有中断机制。怎么样这个实现是不是很巧妙呢在这个Hook机制下Human In The LoopHITL就只需要实现定义对应hookHumanInTheLoopHook并在hook中实现InterruptableAction接口表示该操作需要中断在中断方法逻辑里和前端约定好审批卡片样式将相关信息返回给前端前端收到后渲染对应审批卡片即可。接着在hook节点自身的方法逻辑中再处理前端传回的审批结果就行。之前还有相关文章分享过咱们dataAgent对于审批拒绝时的“小巧思”处理这里就不再赘述。顺带提一下在Hook自身方法中也能通过中断传递元信息InterruptionMetadata中记录的自动批准的工具列表还有暂停原因、每个工具的审批结果等来判断自动审批当发现该中断操作在自动审批列表时则直接审批通过无需返回前端渲染等待。那断点恢复是怎么实现的呢saa的实现里每个节点执行完就会在 Checkpoint 记录下一个执行节点的相关信息nextNodeId。这个Checkpoint可以理解为是一份全局快照链当 HITL 节点即将中断时上一个节点已经存好了一条 Checkpoint恢复时会直接取出最新Checkpoint记录图引擎拿到这个值直接从这个节点开始跑——前面所有的节点LLM、工具、其他 Hook一个都不会再跑。Skills如前所述大模型只能接收文字输入这里先抛开多模态不谈其实多模态大多也是先将图片转成base64文本你的Skills本质也就是一段提示词只不过为了避免有些提示词反复输入把它先固化成SkillSkill.md的形式。当然有了框架的工具调用解析在Skill的流程内容中写对应的工具调用、脚本也都可以。在输入框中/Skill一输一堆预设规范、浓缩精华的高水平提示词就写好了多方便。有时候你发现你没有输入对应skill模型还是能自动使用上对应的Skill那这其实是Agent运行前内置加载了一些公共Skill(服务的Skill策略就是默认加载或者开发者/管理员提前添加)和你手动/Skill注入本质无异。Skill既然是提示词肯定就会占用上下文长度Skill数量多起来了每次加载注入都把一堆提示词注进去多浪费所以延伸出了Skill规范大家都先把Skill的description写在最前面声明当前Skill的定位、简介框架侧每次先只把这部分简介加载进上下文中等模型发现确实需要完整内容再按需读取显著降低token成本。这个按需读取的“渐进式披露”思想在很早之前就出现了saa显然也提供支持。有了之前的Graph地基saa的实现方式也很简单初始化时扫码Skill根目录当发现存在Skill的时候则读取Skill相关简介信息接着定义一个HookSkillsAgentHook放到Graph执行前中断执行保证实际运行前肯定能先读取Skill。Hook的方法逻辑中暴露了内置实现的三个工具search_skills、disable_skill、read_skill 顾名思义大模型在发现真正需要调用这个Skill的时候就在通过这几个工具拉取完整内容并调用。SandBox既然已经有了HITL为何还需要SandBox两者解决的其实是两个不同维度的问题。HITL 解决的是这个操作该不该做在执行之前暂停由人类决定是否放行。但放行之后操作在什么环境执行、影响哪些资源HITL 管不了。SandBox就是来补足这块空缺的它解决的是即使被允许执行在哪里执行隔离/限制影响范围。 例如无论有没有审批Agent生成的代码理应永远只能在隔离的 Docker 容器里跑碰不到宿主机的文件系统、进程、网络。提升特定操作执行的安全性。所以SandBox操作的本质和工具调用无异只不过其操作实现需要在特定的环境里。以Agent生成代码只能在隔离 Docker 容器里跑为例只需要把“代码执行”这个工具实现ToolCallback使用相关容器方法ToolkitInit.RunPythonCodeTool保证执行环境即可。在LLM视角两类工具看起来完全一样模型只需要负责输出调用指令即可。这块实现本质其实是对工具解析的实现Spring AI层面就已内置实现这里不再赘述。A2Asaa毕竟是alibaba团队的自家nacos就能提供 微服务发现 和 动态配置中心 的能力A2A直接复用实现很方便。普通的 Nacos 服务发现是注册 ip:port调用方通过服务名找到实例列表做负载均衡用 Feign/RestTemplate 发起 HTTP/RPC 调用。A2A 比这多了一层语义——注册的不是一个服务实例而是一张AgentCardAgent 名片。AgentCard 包含name / descriptionAgent 的语义身份、url实际调用地址、capabilities是否支持 streaming 等、skills声明自己能做什么。saa把远程Agent调用封装成了一个Graph 节点A2aNode从调用方视角看远程 Agent 节点对本地运行图来说和本地 Agent 节点没有区别。结语读到这里恭喜你已经成功理解主流Agent框架saa背后的设计思想了是不是发现这些高大上的Agent框架也没有想象中遥远顺带提一句咱们公司dataAgent的底座就是基于的saa。本篇主要是以saa为例挑了一些比较有代表性的点循序渐进地介绍了其背后的设计思想还有一些老生常谈的如提示词工程、上下文工程、RAG等这里便不再赘述。包括近期兴起的所谓“harness”工程本质和框架背后的设计思想无异都是想办法让Agent更可靠降低模型幻觉时带来的影响。值得一提的是saa的核心Graph编排还是倾向于人为的编排设定人为建节点组图执行会更适合流程预期稳定、安全的泛工作流场景。随着大模型发展的越来越智能上下文窗口越来越大大家慢慢倾向于把编排也交给Agent让大模型来决定整个过程打造真正自主性高的Agentic Agent所以后续也延伸出了新的Agent框架 --AgentScope简称as意图用Agentic为基座最大程度发挥出基础模型的能力。而saa团队也不再更新saa版本大多维护工作也慢慢趋于停滞整体将重心向as倾靠甚至据说未来准备将内核升级为as。附录saaGitHub - alibaba/spring-ai-alibaba: Agentic AI Framework for Java Developers · GitHubasGitHub - agentscope-ai/agentscope-java: Build distributed, production-grade, long-running agents. · GitHubquick start示例// 创建内存检查点器还有 FileSaver、RedisSaver、PostgresSaver... MemorySaver checkpointer new MemorySaver(); // 通过注册进saverConfig传入图编译阶段 SaverConfig saverConfig SaverConfig.builder() .register(checkpointer) .build(); // 定义并发修改状态时的合并策略 KeyStrategyFactory keyStrategyFactory () - { MapString, KeyStrategy keyStrategyMap new HashMap(); keyStrategyMap.put(messages, new AppendStrategy()); // 追加策略 return keyStrategyMap; }; // 创建聊天节点异步版 NodeAction AsyncNodeAction chatNode node_async(state - { ListMapString, String messages (ListMapString, String) state.value(messages).orElse(List.of()); // 使用 ChatClient 调用 AI 模型 ChatClient chatClient chatClientBuilder.build(); String response chatClient.prompt() .user(messages.get(messages.size() - 1).get(content)) .call() .content(); // 返回的全局状态信息即为模型回复的内容key 为 messages return Map.of(messages, List.of(Map.of(role, assistant, content, response))); }); // 构建图包含 START 和 END这里为了简单期间直接构建 START - chatNode - END的简单边路径 StateGraph stateGraph new StateGraph(keyStrategyFactory) .addNode(chat, chatNode) .addEdge(START, chat) .addEdge(chat, END); // 编译图同时带存储介质 CompiledGraph compiledGraph stateGraph.compile( CompileConfig.builder() .saverConfig(saverConfig) .build() ); // 声明会话配置运行时指定用哪个槽这样图运行时会以 threadId 作为 key不同会话用不同 threadId 互不干扰 RunnableConfig config RunnableConfig.builder() .threadId(conversation-1) .build(); /** 图运行invoke()的第一个参数为本轮的输入数据就是这次调用你带什么新东西进来 框架拿到它之后按 KeyStrategy 合并进 OverAllState。 第二个参数是运行配置框架用它作为 MemorySaver 的 key */ OverAllState state compiledGraph.invoke( Map.of(messages, List.of(Map.of(role,user, content, 你好我是 Bob))), config); ListMapString, String msgs (ListMapString, String) state.value(messages).orElse(List.of()); System.out.println(用户: input); System.out.println(AI: msgs.get(msgs.size() - 1).get(content)); // 第二轮对话 OverAllState state compiledGraph.invoke( Map.of(messages, List.of(Map.of(role,user, content, 我的名字是什么))), config); // AI 将能够记住之前的对话回答 Bob // ......实际运行过程第一轮invoke(你好我是Bob, threadIdconv-1) → 节点执行完 → MemorySaver.put(conv-1, state{messages: [{role:user,content:你好我是Bob{role:assistant,content:...}]}) → 存到内存的 Map 里 第二轮invoke(我叫什么名字, threadIdconv-1) → 框架先 MemorySaver.get(conv-1) 把上轮存档读出来 → 合并进新输入messages [上轮消息..., 我叫什么名字] → AI 现在有完整上下文知道你是 Bob ✓第一轮invoke(你好我是Bob, threadIdconv-1)
阅读完成 · 觉得有帮助?