☰
讲讲Agent框架背后的设计思想(以saa为例)
2026/10/16 9:25:43 网站建设 项目流程

说在最前面

所谓高大上的Agent,本质无非还是一次大模型(LLM)调用请求,就像你跟豆包发一句“你好”一样,底层就是通过一次API调用把这个“你好”发送给了对应的LLM,这个LLM可以是deepseek、chatGpt、或者claude等等。然后大模型回复文字内容,前端再展示出来给我们看到。

“LLM仅仅是一个会问答的黑盒机器”。

前序:Spring AI

你可能会说,不对啊,我的AI还能给我搜索浏览器、发邮件、下订单,完全不只是问答这么简单好吧。

诶,就是这么简单,你理解的搜索浏览器、发邮件、下订单等等,这里统称为工具调用(ToolCall),LLM只会根据对话场景思考回复出“我需要搜索浏览器”、“我需要发邮件”、“我需要下订单”这些文字指令,真正执行这些操作的是谁呢?答案是由背后的框架来承接实现的。

由于模型的回复指令格式可能千奇百怪,为了方便框架解析处理,于是约定了大模型在回复要调用某个工具的时候,需要使用特定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现在的循环是线性的,你要怎么解决呢?还有假如其中某个的某个循环节点也是一个Agent,Agent嵌套Agent/父子Agent 你准备怎么实现呢,还有......

你愣住了,开始意识到想要应对复杂业务场景,还有很多东西需要解决。

于是乎,Spring AI Alibaba(下面简称saa)出手了。

Graph

既然之前的普通React循环是线性的,天然不支持并行处理,学过数据结构的很快就能想到,把这个循环改做成图(Graph)不就好了。saa抽象出图Graph和图执行节点NodeAction概念。

NodeAction只声明一个apply接口,即节点 = 一个异步函数。这个接口是函数式声明的,只允许一个抽象方法,只干一件事:接收当前状态,输出更新字段,开放兼容且简单直接。

它的实现可以是任何东西,一段业务代码(处理文档、调数据库),一次 LLM 调用,工具调用,或者另一个完整的子图,完美兼容嵌套场景。

节点间使用边(Edge)来建立顺序,判断下一步去哪。边(NodeA,NodeB)表示从节点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的使用变的灵活起来,不再只局限于线性循环,各种并行、嵌套场景也能应对自如。

HITL

Human In The Loop,简称HITL,即Agent运行过程中的人工决策操作。产生人工决策的节点一般有哪些,像文件的增删改查、业务操作的执行、接口请求的发起等等,这些操作其实都可以归属于工具调用范畴。还是那句话,模型只知道回复指令,文字外的执行操作,我们都可以统称为工具调用,所以HITL基本上都是工具调用粒度的。

要将工具调用决策上行给用户审批,必定需要进行中断等待,毕竟程序不能只为某一个操作挂起等待。因此需要设计一种通用的中断机制,在这些风险操作/工具调用执行前/后进行中断拦截,记录拦截时刻下的相关信息,提供给用户进行审批。同时由于不能保证用户立马完成审批,若间隔若干时间后审批完成,也需要保证能够恢复到中断点之后继续运行。

实现中断机制很容易想到拦截器/AOP,但是saa的实现不是这样的。saa在中断之上抽象出Hook,Hook本身可以干活,也可以中断。在Hook下面有各种不同粒度的实现,例如Agent级别的AgentHook,模型级别的ModelHook,Skill级别的SkillHook,人工决策级别的HumanInTheLoop(HITL)等等。每个hook可以选择是否实现InterruptableAction这个中断接口,若重写实现,则表示该hook拥有中断能力。


那Hook是如何生效的呢?saa是在图编译阶段把各类型 Hook 变成了一个真正的图节点即hook节点,hook节点的执行逻辑就是hook自身的apply方法,和其他节点如 LLM 节点、工具节点平起平坐。

放到拦截节点执行前的hook在Agent运行图中的位置即为该拦截图节点的上一步,放到拦截节点执行后的hook在图中的位置即为该拦截图节点的下一步,这样抛开中断不谈,首先该hook的逻辑就能保证在对应位置上正确执行了。然后在实际执行的时候,若再发现当前节点实现过InterruptableAction接口,则进行中断(实际包含节点执行前中断和节点之前后中断),否则不中断(相当于这类 Hook 是纯粹的"节点内嵌逻辑",就是让你在某个时机做点额外的事,没有中断机制)。怎么样,这个实现是不是很巧妙呢?

在这个Hook机制下,Human In The Loop(HITL)就只需要实现定义对应hook(HumanInTheLoopHook)并在hook中实现InterruptableAction接口(表示该操作需要中断),在中断方法逻辑里和前端约定好审批卡片样式,将相关信息返回给前端,前端收到后渲染对应审批卡片即可。接着在hook节点自身的方法逻辑中再处理前端传回的审批结果就行。之前还有相关文章分享过咱们dataAgent对于审批拒绝时的“小巧思”处理,,这里就不再赘述。

顺带提一下,在Hook自身方法中,也能通过中断传递元信息InterruptionMetadata中记录的自动批准的工具列表(还有暂停原因、每个工具的审批结果等)来判断自动审批,当发现该中断操作在自动审批列表时,则直接审批通过,无需返回前端渲染等待。

那断点恢复是怎么实现的呢?saa的实现里,每个节点执行完,就会在 Checkpoint 记录下一个执行节点的相关信息(nextNodeId)。这个Checkpoint可以理解为是一份全局快照链,当 HITL 节点即将中断时,上一个节点已经存好了一条 Checkpoint,恢复时,会直接取出最新Checkpoint记录,图引擎拿到这个值直接从这个节点开始跑——前面所有的节点(LLM、工具、其他 Hook)一个都不会再跑。

Skills

如前所述,大模型只能接收文字输入(这里先抛开多模态不谈,其实多模态大多也是先将图片转成base64文本),你的Skills本质也就是一段提示词,只不过为了避免有些提示词反复输入,把它先固化成Skill(Skill.md)的形式。当然,有了框架的工具调用解析,在Skill的流程内容中写对应的工具调用、脚本也都可以。

在输入框中/Skill一输,一堆预设规范、浓缩精华的高水平提示词就写好了,多方便。有时候你发现你没有输入对应skill,模型还是能自动使用上对应的Skill,那这其实是Agent运行前内置加载了一些公共Skill(服务的Skill策略就是默认加载,或者开发者/管理员提前添加),和你手动/Skill注入本质无异。

Skill既然是提示词,肯定就会占用上下文长度,Skill数量多起来了,每次加载注入都把一堆提示词注进去多浪费,所以延伸出了Skill规范,大家都先把Skill的description写在最前面,声明当前Skill的定位、简介,框架侧每次先只把这部分简介加载进上下文中,等模型发现确实需要完整内容,再按需读取,显著降低token成本。

这个按需读取的“渐进式披露”思想,在很早之前就出现了,saa显然也提供支持。有了之前的Graph地基,saa的实现方式也很简单,初始化时扫码Skill根目录,当发现存在Skill的时候,则读取Skill相关简介信息,接着定义一个Hook(SkillsAgentHook),放到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层面就已内置实现,这里不再赘述。

A2A

saa毕竟是alibaba团队的,自家nacos就能提供 微服务发现 和 动态配置中心 的能力,A2A直接复用实现很方便。

普通的 Nacos 服务发现是:注册 ip:port,调用方通过服务名找到实例列表,做负载均衡,用 Feign/RestTemplate 发起 HTTP/RPC 调用。A2A 比这多了一层语义——注册的不是一个服务实例,而是一张AgentCard(Agent 名片)。AgentCard 包含:name / description(Agent 的语义身份)、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。

附录

saa:GitHub - alibaba/spring-ai-alibaba: Agentic AI Framework for Java Developers · GitHub
as:GitHub - agentscope-ai/agentscope-java: Build distributed, production-grade, long-running agents. · GitHub

quick start示例
// 创建内存检查点器,还有 FileSaver、RedisSaver、PostgresSaver... MemorySaver checkpointer = new MemorySaver(); // 通过注册进saverConfig传入图编译阶段 SaverConfig saverConfig = SaverConfig.builder() .register(checkpointer) .build(); // 定义并发修改状态时的合并策略 KeyStrategyFactory keyStrategyFactory = () -> { Map<String, KeyStrategy> keyStrategyMap = new HashMap<>(); keyStrategyMap.put("messages", new AppendStrategy()); // 追加策略 return keyStrategyMap; }; // 创建聊天节点,异步版 NodeAction AsyncNodeAction chatNode = node_async(state -> { List<Map<String, String>> messages = (List<Map<String, 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); List<Map<String, String>> msgs = (List<Map<String, 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", threadId="conv-1") → 节点执行完 → MemorySaver.put("conv-1", state={messages: [{"role":"user","content":"你好,我是Bob"{"role":"assistant","content":"..."}]}) → 存到内存的 Map 里 第二轮:invoke("我叫什么名字?", threadId="conv-1") → 框架先 MemorySaver.get("conv-1") 把上轮存档读出来 → 合并进新输入:messages = [上轮消息..., "我叫什么名字?"] → AI 现在有完整上下文,知道你是 Bob ✓第一轮:invoke("你好,我是Bob", threadId="conv-1")

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

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

立即咨询