ARTICLE DETAIL

资讯详情

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

三框架模型动态切换实现对比:AgentScope / LangGraph4J / Spring AI

三框架模型动态切换实现对比:AgentScope / LangGraph4J / Spring AI 一、问题背景在 AI Agent 项目 babi 中三个模块分别使用三种 Java AI 框架AgentScope Java、LangGraph4J基于 LangChain4J和 Spring AI 2.0。三个模块各自实现了自定义的DashScopeChatModel将阿里云 DashScope SDK 适配到各自的框架接口。但有一个共同问题模型名在构造时通过final字段绑定启动后不可变。前端页面需要一个模型选择下拉框让用户在每次对话时选择不同的模型如 Qwen3.7 Plus、DeepSeek V4 Flash 等。这意味着后端必须支持per-request 模型覆写——同一次服务运行中不同请求可以使用不同的模型。三个框架的设计哲学不同实现 per-request 模型覆写的方式也截然不同。本文从源码层面剖析三种方案的底层机制。二、共同约束final 不可变的模型名三个模块的DashScopeChatModel都遵循相同的模式// babi-spring / babi-graph 自定义 DashScopeChatModelprivatefinalStringmodel;privatefinalbooleanmultimodal;privatefinalMultiModalConversationconversation;// 多模态API客户端privatefinalGenerationgeneration;// 文本API客户端publicDashScopeChatModel(StringapiKey,Stringmodel,...){this.modelmodel;this.multimodalDashScopeEndpointUtil.isMultimodalModel(model);this.conversationmultimodal?newMultiModalConversation():null;this.generationmultimodal?null:newGeneration();}babi-agent 模块更特殊——它使用的是 AgentScope SDK 外部类io.agentscope.extensions.model.dashscope.DashScopeChatModel源码不可修改// AgentScope SDK 内部private final 不可变privatefinalStringmodelName;// line 68privatefinalEndpointTypeendpointType;// line 72关键约束有两个第一模型名是final构造后不可更改第二多模态路由MultiModalConversationvsGeneration在构造时根据模型名决定整个生命周期固定走同一个 DashScope API 端点。如果用户从多模态模型qwen3.7-plus切换到文本模型deepseek-v4-flash必须走不同的 API 端点。三、Spring AI 2.0 方案原生 ChatOptions 覆写Spring AI 2.0 的ChatClient原生支持 per-request 选项覆写这是最干净的方案。3.1 框架机制Spring AI 的ChatClient.prompt()返回一个可链式配置的ChatClientRequestSpec其中.options()方法可以注入 per-request 的ChatOptions// BabiService.streamChat() — babi-spring 模块varpromptSpecchatClient.prompt().user(userMessage).advisors(a-a.param(ChatMemory.CONVERSATION_ID,sessionId));if(model!null!model.isBlank()){promptSpec.options(ToolCallingChatOptions.builder().model(model));}returnpromptSpec.stream().chatResponse()...;ToolCallingChatOptions.builder()传入的是 builder 而非已构建对象——Spring AI 2.0 的options()方法签名是B extends ChatOptions.BuilderB options(B builder)接受 builder 泛型以支持 fluent chaining。3.2 DashScopeChatModel 改造DashScopeChatModel 需要从Prompt.getOptions()中读取覆写的模型名// resolveModel 方法 — 每请求动态解析privateStringresolveModel(ChatOptionsoptions){if(options!nulloptions.getModel()!null!options.getModel().isBlank()){returnoptions.getModel();}returndefaultModel;}关键是两个 SDK 客户端MultiModalConversation和Generation都预创建——它们是无状态轻量对象不占资源。每次请求根据resolveModel()返回的模型名通过DashScopeEndpointUtil.isMultimodalModel(model)动态选择走哪个客户端OverridepublicChatResponsecall(Promptprompt){StringmodelresolveModel(prompt.getOptions());if(DashScopeEndpointUtil.isMultimodalModel(model)){// 用 conversation 客户端走多模态 API}else{// 用 generation 客户端走文本 API}}在参数构建阶段模型名也从final字段改为动态解析privateGenerationParam.GenerationParamBuilder?,?buildTextParam(ListMessagemessages,Promptprompt,booleanstreaming){StringmodelresolveModel(prompt.getOptions());returnGenerationParam.builder().apiKey(apiKey).model(model)// 动态模型名.messages(messages)...;}这是最符合框架原生设计的方案。Spring AI 的ChatOptions机制天然为 per-request 覆写而设计改造量最小。四、LangGraph4J 方案ThreadLocal 旁路传递LangGraph4J 的情况更复杂。AgentExecutor 在构造时绑定了StreamingChatModel其内部构建ChatRequest时使用固定参数不支持从 graph state 注入 per-request 的ChatRequestParameters。4.1 为什么不能用 ChatRequestParametersLangGraph4J 的AgentExecutor.builder().chatModel(streamingChatModel)在 build 时绑定了模型实例。运行时AgentExecutor内部通过doChat(ChatRequest)调用模型ChatRequest的参数来自defaultRequestParameters()无法从外部 graph state 注入。如果强行修改ChatRequestParameters需要侵入AgentExecutor的内部逻辑这违背了不改框架源码的原则。4.2 ThreadLocal 旁路方案解决思路与 graph 模块已有的 sink 注册模式一致——用一个静态ThreadLocal传递模型覆写DashScopeChatModel在每次调用时读取// babi-common 扩展 SessionContextHolderprivatestaticfinalThreadLocalStringMODEL_OVERRIDEnewThreadLocal();publicstaticvoidsetModelOverride(Stringmodel){MODEL_OVERRIDE.set(model);}publicstaticStringgetModelOverride(){returnMODEL_OVERRIDE.get();}publicstaticvoidclear(){SESSION_ID.remove();MODEL_OVERRIDE.remove();}BabiService在启动 graph 执行的线程上设置 ThreadLocal// BabiService.streamChat() — babi-graph 模块newThread(()-{try{SessionContextHolder.setSessionId(sessionId);if(model!null!model.isBlank()){SessionContextHolder.setModelOverride(model);}varresultgraph.stream(input,config);result.stream().forEach(output-{/* 驱动图执行 */});}finally{SessionContextHolder.clear();// 同时清理 sessionId 和 modelOverride}},babi-agent-sessionId).start();DashScopeChatModel的resolveModel()改为从 ThreadLocal 读取privateStringresolveModel(){StringoverrideSessionContextHolder.getModelOverride();if(override!null!override.isBlank()){returnoverride;}returndefaultModel;}这个方案巧妙之处在于模型覆写通过 ThreadLocal 在调用线程上传递不经过 graph state对AgentExecutor和CompiledGraph完全透明。DashScopeChatModel在doChat()和buildTextParam()中通过resolveModel()读取当前线程的模型覆写。与 graph 模块已有的THINKING_SINKS/TEXT_SINKS静态 Map 传递 session-scoped sink 的模式完全一致。五、AgentScope 方案Middleware 拦截 ModelCallInputbabi-agent 模块面临最复杂的挑战。AgentScope SDK 的DashScopeChatModel是外部类modelName是private final无法修改。SDK 的GenerateOptions虽然有modelName字段但DashScopeChatModel从不读取它——始终用自己的this.modelName。HarnessAgent.call()和streamEvents()都不接受模型参数。5.1 发现拦截点ReActAgent 的中间件链深入分析 AgentScope Java SDK 源码/Users/hongxi/github/agentscope-java在ReActAgent.java的 reasoning 阶段发现了一个关键的中间件拦截点// ReActAgent.java line 2131-2144FunctionModelCallInput,FluxAgentEventmodelCallCoremci-modelCallStream(context,mci,true);returnMiddlewareChain.build(middlewares,ReActAgent.this,rc,MiddlewareBase::onModelCall,// ← 拦截点modelCallCore).apply(newModelCallInput(messages,tools,options,modelForCall()));ModelCallInput是一个 Java record// ModelCallInput.javapublicrecordModelCallInput(ListMsgmessages,ListToolSchematools,GenerateOptionsoptions,Modelmodel){}核心模型调用使用mci.model().stream(...)// ReActAgent.java line 2170FluxAgentEventmodelEventsmci.model().stream(mci.messages(),mci.tools(),mci.options())...;关键在于ModelCallInput是 record不可变但可重建MiddlewareBase.onModelCall的默认实现将input直接传给next。如果自定义中间件构造一个新的ModelCallInput、替换其中的Model实例再传给next就能实现 per-request 模型切换。5.2 ModelSwitchingMiddleware 实现// ModelSwitchingMiddleware.java — babi-agent 模块publicclassModelSwitchingMiddlewareimplementsMiddlewareBase{privatestaticfinalStringMODEL_OVERRIDE_KEYmodelOverride;privatefinalStringapiKey;privatefinalbooleanenableSearch;privatefinalMapString,ModelmodelCachenewConcurrentHashMap();OverridepublicFluxAgentEventonModelCall(Agentagent,RuntimeContextctx,ModelCallInputinput,FunctionModelCallInput,FluxAgentEventnext){StringdesiredModelctx.get(MODEL_OVERRIDE_KEY);if(desiredModel!null!desiredModel.isBlank()){ModeloverriddenmodelCache.computeIfAbsent(desiredModel,this::createModel);// 重建 ModelCallInput替换 Model 实例ModelCallInputoverriddenInputnewModelCallInput(input.messages(),input.tools(),input.options(),overridden);returnnext.apply(overriddenInput);}returnnext.apply(input);}privateModelcreateModel(StringmodelName){varbuilderDashScopeChatModel.builder().apiKey(apiKey).modelName(modelName).stream(true).enableSearch(enableSearch);if(DashScopeEndpointUtil.isMultimodalModel(modelName)){builder.endpointType(EndpointType.MULTIMODAL);}returnbuilder.build();}}5.3 控制器注入 RuntimeContextAgentScope 的RuntimeContext支持任意 string-keyed 属性put(String, Object)/get(String)控制器在构建 context 时注入模型覆写// BabiAgentController.doStreamChat() — babi-agent 模块RuntimeContext.BuilderctxBuilderRuntimeContext.builder().sessionId(sessionId);if(model!null!model.isBlank()){ctxBuilder.put(modelOverride,model);}RuntimeContextctxctxBuilder.build();这个方案的核心优势是单实例HarnessAgent不需要为每个模型创建重量级 agent。模型实例按需缓存在ConcurrentHashMap中首次切换时创建后续直接命中。对 agent 的 ReAct 循环、工具调用、状态管理完全透明。六、三方案对比维度Spring AI 2.0LangGraph4JAgentScope机制ChatClient.prompt().options()ThreadLocal 旁路MiddlewareBase.onModelCall拦截模型覆写传递路径Prompt → ChatOptions → DashScopeChatModelThreadLocal → DashScopeChatModelRuntimeContext → Middleware → ModelCallInput是否改框架接口否否否是否改 DashScopeChatModel是加 resolveModel是加 resolveModel否SDK外部类不可改框架原生支持原生 per-request options无原生 per-request 机制中间件链设计为扩展点多模态路由每请求动态判断每请求动态判断预建不同 EndpointType 的 Model 实例侵入性最低中等最低仅新增一个 middleware三种方案反映了三个框架的设计哲学差异。Spring AI 的ChatOptions天然为 per-request 覆写设计从 API 层面就支持了模型切换。LangGraph4J 的AgentExecutor将模型绑定在构造时没有暴露 per-request 参数注入的口子只能用 ThreadLocal 绕过。AgentScope 虽然模型类不可变但MiddlewareBase.onModelCall这个洋葱模型的拦截点天然允许替换ModelCallInput的任何字段——中间件链的设计就是为了这种对核心逻辑透明地插入自定义行为的场景。从实现难度看Spring AI 最简单3行代码LangGraph4J 中等ThreadLocal clearAgentScope 最需要深入 SDK 源码才能发现拦截点。但从方案的优雅程度看AgentScope 的 Middleware 拦截是最干净的——不修改任何现有类纯增量新增一个 middleware 文件控制器加一行put。这也体现了洋葱模型中间件架构的价值核心逻辑保持不变横切关注点通过中间件注入。
返回列表