AgentScope-Java 2.0 如何接入模型供应商:从 DashScope 切换到 DeepSeek

在 AgentScope-Java 的 agentscope-builder 示例里,BuilderConfig 默认从 application.yml 读取 DashScope 的 API Key、模型名和流式开关,然后创建一个 DashScopeChatModel Bean。

如果想把它换成 DeepSeek,第一反应通常很直接:把 builder.dashscope 改成 builder.deepseek,再把 DashScopeChatModel 换成一个 OpenAI 兼容客户端。

这当然能作为修改的起点,但它还没有触及 AgentScope-Java 2.0 模型接入的真正边界。

我的判断是:AgentScope-Java 的 provider-neutral 不是“所有供应商都一样”,而是应用层不用理解每家 SDK,适配层仍然忠实处理每家协议差异。

从 DashScope 切换到 DeepSeek,表面只涉及一个配置类,背后实际会经过依赖模块、模型注册中心、Provider、创建上下文和 Formatter。把这条链路看清楚,以后再接 GLM、Kimi、MiniMax 或公司内部模型网关,就不需要为每家供应商重新设计 Agent 层。

在这里插入图片描述

先看这个示例现在是怎么接 DashScope 的

当前 agentscope-builder 在三个位置绑定了 DashScope。

第一处是 Maven 依赖:


    io.agentscope
    agentscope-extensions-model-dashscope

第二处是 application.yml

builder:
  dashscope:
    api-key: ${DASHSCOPE_API_KEY:}
    model-name: ${BUILDER_MODEL_NAME:${CLAW_MODEL_NAME:qwen-max}}
    stream: true

第三处是 BuilderConfig。它通过 @Value 读取配置,在 API Key 非空并且 Spring 容器里没有其他 Model Bean 时,创建 DashScopeChatModel

@Bean
@ConditionalOnMissingBean(Model.class)
@ConditionalOnExpression(
        "'${builder.dashscope.api-key:${claw.dashscope.api-key:}}' != ''")
public Model dashscopeModel() {
    return DashScopeChatModel.builder()
            .apiKey(dashscopeApiKey)
            .modelName(dashscopeModelName)
            .stream(dashscopeStream)
            .build();
}

后面的 BuilderBootstrap 并不知道这个 Bean 来自 DashScope。它只注入 Optional,存在就传给所有 Agent,不存在就让应用在无模型状态下启动。

这已经体现了第一层抽象:Agent 和 BuilderBootstrap 依赖的是 Model,不是具体供应商。

但如果只看到这一层,很容易得出一个过于简单的结论:换供应商,就是再创建一个不同的 Model 实现。

替换成 DeepSeek,最少要改三个位置

DeepSeek 在 AgentScope-Java 里没有独立的 model artifact。它和 GLM、Kimi、MiniMax 一样,放在 OpenAI 模型扩展中,通过专用 Provider 和 Formatter 提供一等适配。

因此,第一步不是寻找一个 agentscope-extensions-model-deepseek,而是把依赖换成:


    io.agentscope
    agentscope-extensions-model-openai

这一步解决的是 classpath。只有 OpenAI 扩展进入 classpath,Java ServiceLoader 才能发现 DeepSeekModelProvider

第二步,把配置改成 DeepSeek:

builder:
  deepseek:
    api-key: ${DEEPSEEK_API_KEY:}
    model-name: ${BUILDER_MODEL_NAME:${CLAW_MODEL_NAME:deepseek-chat}}
    # 留空时使用 Provider 内置的 https://api.deepseek.com
    base-url: ${DEEPSEEK_BASE_URL:}
    stream: true

第三步,不直接在配置类里拼 OpenAIChatModel,而是把配置转成 ModelCreationContext,再交给 ModelRegistry

import io.agentscope.core.model.ModelCreationContext;
import io.agentscope.core.model.ModelRegistry;

@Value("${builder.deepseek.api-key:${claw.deepseek.api-key:}}")
private String deepseekApiKey;

@Value("${builder.deepseek.model-name:${claw.deepseek.model-name:deepseek-chat}}")
private String deepseekModelName;

@Value("${builder.deepseek.base-url:${claw.deepseek.base-url:}}")
private String deepseekBaseUrl;

@Value("${builder.deepseek.stream:${claw.deepseek.stream:true}}")
private boolean deepseekStream;

@Bean
@ConditionalOnMissingBean(Model.class)
@ConditionalOnExpression(
        "'${builder.deepseek.api-key:${claw.deepseek.api-key:}}' != ''")
public Model deepseekModel() {
    if (deepseekApiKey == null || deepseekApiKey.isBlank()) {
        throw new IllegalStateException(
                "builder.deepseek.api-key must not be blank");
    }

    String configuredModel =
            deepseekModelName == null ? "" : deepseekModelName.trim();

    if (configuredModel.isEmpty()) {
        throw new IllegalStateException(
                "builder.deepseek.model-name must not be blank");
    }

    String modelId =
            configuredModel.startsWith("deepseek:")
                    ? configuredModel
                    : "deepseek:" + configuredModel;

    ModelCreationContext context =
            ModelCreationContext.builder()
                    .apiKey(deepseekApiKey)
                    .baseUrl(deepseekBaseUrl)
                    .stream(deepseekStream)
                    .build();

    return ModelRegistry.resolve(modelId, context);
}

如果使用需要显式开启 thinking 的模型,可以继续在 Context 中增加:

.enableThinking(true)

builderBootstrap() 不需要改变装配逻辑,因为它仍然只接收 Optional。需要同步修改的只是无模型时的提示,把 builder.dashscope.api-key 换成 builder.deepseek.api-key

项目里几个 Spring 上下文测试还显式设置了 builder.dashscope.api-key=。完整替换时最好一起改成 builder.deepseek.api-key=,避免测试配置和实际 Bean 条件脱节。

到这里,代码改动已经结束。但这套写法真正值得解释的,不是它少写了多少行,而是 ModelRegistry.resolve() 接手以后发生了什么。

在这里插入图片描述

一次 resolve,背后走过五层边界

这段代码:

ModelRegistry.resolve("deepseek:deepseek-chat", context);

实际会走过下面这条链路:

agentscope-extensions-model-openai 进入 classpath
    ↓
ModelRegistry 解析 deepseek:deepseek-chat
    ↓
ServiceLoader 发现 DeepSeekModelProvider
    ↓
Provider 消费 ModelCreationContext
    ↓
创建 OpenAIChatModel,并装配 DeepSeekFormatter

ModelRegistry 先检查有没有同名注册实例,再检查缓存,然后匹配用户注册的 ModelFactory,最后才查找 SPI Provider。用户工厂优先于内置 Provider,所以应用以后可以把相同模型 id 接到企业网关、租户路由器或者测试替身,而不必改 Agent 代码。

DeepSeekModelProvider 匹配 deepseek:.+。创建模型时,它会:

  • 优先读取 ModelCreationContext 中的 API Key,否则回退到 DEEPSEEK_API_KEY
  • 去掉模型 id 前面的 deepseek:
  • 在没有覆盖时使用 https://api.deepseek.com
  • 根据 Context 设置 stream 和 thinking;
  • 创建共用的 OpenAIChatModel
  • 注入 DeepSeekFormatter
  • 默认关闭 native structured output,并查询模型上下文窗口。

所以,ModelRegistry 不是一个字符串版 Builder。它解决的是模型如何被发现、用户配置如何进入供应商适配层,以及应用如何在不依赖具体 Builder 的情况下创建模型。

在这里插入图片描述

兼容 OpenAI,为什么还需要 DeepSeekFormatter

常见理解是:只要供应商兼容 OpenAI API,改一下 baseUrl 和 API Key 就接完了。

源码给出的答案正好相反:OpenAI、DeepSeek、GLM、Kimi、MiniMax 虽共用 OpenAIChatModel 和 HTTP 调用骨架,却分别注册 ModelProvider,并使用专用 Formatter。原因不是框架过度设计,而是它们在 thinking 参数、tool_choice、reasoning content、结构化输出和上下文窗口上并不等价。

以 DeepSeek 为例,思考模型不只是多返回一段文字。历史消息中哪些 reasoning_content 可以继续发送,哪些应该移除,工具调用所在分段是否需要保留推理内容,都会影响下一轮请求是否合法。

如果直接创建一个普通 OpenAIChatModel,只把地址改成 DeepSeek,基础文本对话可能可以返回结果,但进入多轮 thinking 和工具调用后,错误才会暴露出来。

其他兼容供应商也有类似问题:

  • GLM 的 response_format 只支持 json_object,不能当作完整的 json_schema
  • GLM 对 tool_choice 的支持范围更窄,部分选择需要降级;
  • Kimi 的部分模型在 thinking 开启时不能强制指定工具,否则可能返回 HTTP 400;
  • Kimi 不接受通用的 thinking_budget,需要根据模型使用 thinkingreasoning_effort
  • MiniMax 的 thinking 类型使用 adaptive,而 DeepSeek 和 GLM 使用 enabled

这些差异都发生在“HTTP 请求已经能够发出去”之后。返回 200 只能证明传输打通,不能证明模型已经适合 Agent 的多轮工具调用和结构化输出。

Formatter 才是 AgentScope-Java 里真正承接供应商语义差异的层。

5 个模型 artifact,为什么能解析 9 个 provider id

当前源码有 5 个主要模型 artifact:

artifact 主要模型实现
agentscope-extensions-model-openai OpenAIChatModel
agentscope-extensions-model-anthropic AnthropicChatModel
agentscope-extensions-model-dashscope DashScopeChatModel
agentscope-extensions-model-gemini GeminiChatModel
agentscope-extensions-model-ollama OllamaChatModel

但通过 SPI 可以解析 9 个 provider id:

openai
deepseek
glm
kimi
minimax
anthropic
dashscope
gemini
ollama

原因就在 OpenAI artifact。它的 SPI 服务文件一次注册了 OpenAI、DeepSeek、GLM、Kimi、MiniMax 五个 Provider。它们共用模型调用骨架,各自保留 Provider 和 Formatter。

这比“每家供应商复制一套完整客户端”和“所有兼容供应商只换地址”更接近真实边界:能共用的传输代码继续共用,不能共用的协议语义单独处理。

ModelCreationContext 解决的是动态接入,不只是配置搬家

agentscope-builder 这个例子里,ModelCreationContext 只是把 Spring 配置传给 Provider。它的价值看起来还不明显,因为整个应用只有一个全局 Model Bean。

到了多租户平台,情况会改变。同一个 deepseek:deepseek-chat,不同租户可能使用不同 API Key、代理地址、stream 策略和 thinking 配置。插件系统也可能在运行时才知道该加载哪个模型。

ModelCreationContext 为这类场景提供了供应商中立的创建输入:

  • 标准字段承载 API Key、base URL、endpoint path、stream、thinking 和缓存策略;
  • option(key, value) 传供应商约定的扩展值;
  • component(type, value) 传 Formatter、Transport、代理和 GenerateOptions 等复杂组件。

core 不需要知道这些复杂组件属于哪家供应商,真正消费它们的是扩展模块。

这里还有一个容易忽略的边界:带非空 Context 的解析默认不缓存。

这不是单纯的性能选择。它避免租户 A 的 API Key 和端点创建出的 Model 被租户 B 复用。如果显式开启缓存,又同时传了 options 或 components,框架会要求提供 cacheId,而不是猜测一个供应商对象能不能安全参与哈希。

对于多 Agent 协作平台,模型配置因此不应该只保存 baseUrl + apiKey + modelName。至少还要考虑 provider id、凭证引用、thinking、能力声明、Formatter / Transport 扩展和租户级缓存身份。

Spring Boot 配置和 ModelRegistry 是两条入口

还需要分清一件事:agentscope-builder 当前是在自己的 BuilderConfig 中创建 Model Bean,并没有直接使用模型 starter 的自动配置。

AgentScope-Java 当前为 OpenAI、DashScope、Anthropic、Gemini、Ollama 提供了对应的 Spring Boot starter。它们根据 agentscope.model.provider 和供应商属性创建 Model Bean,核心 starter 再用这个 Bean 组装 ReActAgent

这条 Spring Boot 路径与 .model("deepseek:...") 的 SPI 字符串解析不是同一个入口。

在当前示例中,保留手写 BuilderConfig 的好处是继续沿用 builder.* 配置,并且能明确控制 Bean 的创建条件。改成 ModelRegistry + ModelCreationContext 后,又可以复用 DeepSeek 的内置 Provider 和 Formatter。

如果直接使用 OpenAI starter,把 base-url 指向 DeepSeek,也不是绝对不行。但只要涉及 thinking、工具调用或结构化输出,就必须确认 Builder 最终装配的是 DeepSeekFormatter。否则只是网络端点兼容,语义适配仍然缺失。

重新理解“接入一个模型供应商”

  • 原有想法:接入供应商,就是实现统一 Model 接口,或给 OpenAI 客户端换一个地址。
  • 问题所在:这种理解只覆盖了“能发请求”,没有覆盖模型如何被发现、凭证如何注入、消息和工具如何转换、响应如何解析,以及能力边界如何声明。
  • 真正原因:一个可运行的模型接入至少横跨 classpath、注册发现、配置上下文、协议语义和能力测试五个边界。
  • 行动方向:使用内置供应商时先选对 artifact 和 provider id;接 OpenAI-compatible 厂商时先检查专用 Provider / Formatter;开发新供应商时按 SPI、Model、Formatter、Transport、测试五层补齐,而不是只写一个 Builder。

回到最开始的 agentscope-builder 示例,正确的修改不复杂:换成 OpenAI model artifact,把配置改成 DeepSeek,再让 ModelRegistry 使用 ModelCreationContext 创建模型。

真正重要的是,这样修改以后,Agent 层仍然只依赖 Model,DeepSeek 的凭证、地址和协议差异都停留在模型接入层。将来换 GLM、Kimi,或者把所有模型请求接到内部网关时,BuilderBootstrap、HarnessAgent 和多 Agent 编排都不需要跟着重写。

如果只是做一个固定供应商、短生命周期的 Agent,直接显式创建 ChatModel 已经够用。

但如果你想尝试搭建 Java 版多 Agent 协作平台,可以去了解一下 AgentScope-Java 2.0。除了 HarnessAgent、Workspace、权限和状态恢复,也值得看它如何把模型发现、租户配置和供应商语义拆开。模型层边界稳定以后,多 Agent 编排才不会被某一家供应商绑死。

更多推荐