降SpringAI阿里第4掌-鸿渐于陆-御模对话

降SpringAI阿里第4掌-鸿渐于陆-御模对话

一个接口切换两类模型,业务层不许碰 SDK。

[读者] 已经跑通第 3 掌数字人底座的 Java 后端
[痛点] 聊天逻辑写死 DashScope,换个模型要动业务代码
[现在读] 第 4 掌要把“模型”做成可替换的插槽
[读完] 同一个数字人接口能在两类模型间切换,业务层零改动
[旧方案] 业务类直接依赖 DashScopeChatOptions 与 DashScopeChatModel
    |
    v
[新需求] 同一个数字人既要能跑通义千问,也要能跑 DeepSeek
    |
    v
[冲突] 模型 SDK 的类型漏进了 Service 层,编译期就把选型钉死
    |
    v
[后果] 每接一个模型就要改一遍业务代码、回归一遍全链路

我是老李,做了十几年 Java 后端,这几年主要在带团队做企业级 AI 应用的落地。

这个系列用的框架是 alibaba/spring-ai-alibaba,版本统一锁定 v1.1.2.2,JDK 21 LTS,配套仓库在 https://github.com/lifuchun522/springaialibabapractice 。前面 3 掌把环境、依赖、数字人 CRUD 和 Web 运行页都铺完了。运行页上有一个输入框,按下去之后,后端永远回同一句写死的占位文案——因为聊天接口当时根本没接模型。

第 4 掌要做的事,就是把这句占位文案换成真模型的回复,并且把“用哪个模型”从代码里挪到配置里。

可是,真模型接进来之后,事情就只有一个方向吗?

先把功能依赖着 DashScope 的类型做出来,等要换模型时再重构,这两条路选哪条?

如果选错,代价是每引入一个模型提供方,业务类都要重新编译、重新回归、重新发版——这真的是我们想要的结果吗?

01、故事

周三下午,demo 环境已经能注册、登录、建数字人项目、打开运行页。运行页上的输入框按下去,后端返回的是这么一行:

@PostMapping("/{id}/chat")
public Map<String, String> chat(@PathVariable Long id, @RequestBody Map<String, String> req) {
    return Map.of("reply", "您好,我是您的数字人助手。");
}

任务是把它换成模型真实回复。我第一版方案很短:引入 DashScope Starter,建一个 ChatClient,业务类里注入并直接调用。

第一版代码在业务类里长这样:

package com.lifuchun.digitalhuman.service;

import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class DigitalHumanChatService {

    private final ChatClient chatClient;

    public DigitalHumanChatService(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    public String reply(DigitalHuman dh, String userText) {
        return chatClient.prompt()
                .system(dh.getSystemPrompt())
                .user(userText)
                .options(DashScopeChatOptions.builder()
                        .withModel(dh.getModel())
                        .withTemperature(dh.getTemperature().floatValue())
                        .build())
                .call()
                .content();
    }
}

跑通了。通义千问回了话,运行页上终于有了像样的输出,我自己也觉得这一版很干净:一个 ChatClient,一行 call(),业务逻辑不到十行。

两周后需求变了。客户 A 用通义千问没问题,客户 B 明确要求“必须跑 DeepSeek,我们的数据要过我们自己那套合规链路”。

我去翻代码,发现要换模型,第一件事是改 DigitalHumanChatService:DashScopeChatOptions 得换成 OpenAiChatOptions,ChatClient 这个 Bean 也得另建一个。业务类里 import 了一行 com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions——就这一行,把“换模型”从一次配置变更,变成了一次代码变更。

占位一时稳
换模便崩盘
耦合在暗处
决断在此时

02、问题

旧方案失效的地方非常具体:模型选型这个变量,被写进了业务类的 import 语句里。

业务影响:客户 B 的接入从“改一条配置”变成“排一次迭代”。更麻烦的是,需求方会在一次迭代窗口里反复问“能不能先跑一版 DeepSeek 看看效果”,而你每次都拿不出一个不改代码就能演示的版本。

技术表现有四处:

第一,类型耦合。DigitalHumanChatService 依赖了 DashScopeChatOptions,这个类型只属于 DashScope 一家的 SDK。

第二,构建耦合。ChatClient 这个 Bean 是在配置类里用 DashScopeChatModel 直接构造的,换句话说这个聊天能力天然只认一个模型。

第三,参数耦合。模型名 qwen-plus 和模型提供方 dashscope 这两个概念被混成了一件事——我把 model 字段当成路由键在用。

第四,异常耦合。DashScope 抛出来的异常体系和 OpenAI 兼容协议的完全不是一回事,业务层如果直接 catch,等于把两家的报错都写死。

可验证的完成标准,我在动手前先写清楚,免得做完再找理由:

  • 同一个 POST /api/digital-humans/{id}/chat 接口,只改数据里的 provider 和 model 字段,就能在两类模型之间切换,不重新编译;
  • DigitalHumanChatService 所在包内不出现 com.alibaba.cloud.ai.dashscope 的任何 import;
  • 模型不可用、超时、鉴权失败三类错误都有确定的 HTTP 响应体,不出现默认 500 页面;
  • 切换模型前后,Web 运行页的表现一致。

旧策止于稳
新需逼其宽
无标难言成
有界方可行

03、原理

Spring AI 的分层其实很短,一层一层看:

  • ChatModel:模型接口,只认 Prompt 进、ChatResponse 出;
  • ChatClient:对话门面,管系统提示、Advisor、结构化输出、流式;
  • Prompt / Message:可序列化的请求模型,SystemMessage、UserMessage、AssistantMessage 都在这一层;
  • Advisor:请求前后的横切链,记忆、RAG、日志都挂在这里;
  • Starter:把上面这些拼成 Bean 的自动配置。

反直觉判断:ChatClient 不是“某家模型的客户端”。它内部没有 socket、没有鉴权、不认识任何模型协议,真正连出去的是 ChatModel。所以把 ChatClient 理解成“DashScope 的客户端”,方向从一开始就反了。

反直觉判断:模型名不是路由键,provider 才是。qwen-plus 和 deepseek-chat 只是两个 provider 下面的普通字符串,请求打到哪个 endpoint、用哪套鉴权头,跟你填的模型名没有任何关系。把模型名当路由键用,03 章这套抽象就白搭了。

还有一层需要提前说清楚:call() 返回 ChatResponse,一次对话就是一次请求-响应;stream() 返回 Flux,是一次调用被拆成的事件序列。前者像一次 RPC,后者像一条流。超时语义、错误边界、下游消费姿势都不一样。在 call() 上先把链路跑通,再上 stream(),顺序反了会很难查——这一掌只做前者,下一掌再碰后者。

门面不识模
真器在接口
名非路由键
界分方得安

04、架构

[输入] POST /api/digital-humans/{id}/chat  {text}
    |
    v
[模块] ChatController → DigitalHumanChatService → ModelRouter
    |
    v
[数据/状态] DigitalHuman(provider, model, systemPrompt, temperature)
    |
    v
[处理] 按 provider 取 ChatClient → 按 provider 造 ChatOptions → 组装 Prompt
    |
    v
[输出] ChatClient.call().content() → reply 文本

边界画得很清楚,一共三圈:

适配器层——ChatClientConfig 和 ChatOptionsFactory。整个工程里唯一允许出现 DashScopeChatOptions、OpenAiChatOptions 这些 provider 专有类型的地方。

业务层——DigitalHumanChatService、ChatController。只认 ChatClient、ChatOptions、String,不认任何一家 SDK。

配置层——provider、model、temperature、systemPrompt 全部落在 DigitalHuman 这条数据记录上,不进代码。

收益是换模型变成一次数据变更,代价是多了一跳路由和一个工厂类;适用条件是模型提供方数量可控(个位数),且切换频率低但必须可切换。如果一辈子只跑一家模型,这套抽象是纯负担——这一点要在心里说清楚,不要为了架构而架构。

一图见链路
一层隔两界
得在可替换
失在多一跳

05、实战一次

环境与版本:

  • JDK 21 LTS
  • alibaba/spring-ai-alibaba v1.1.2.2
  • 分支 chapter/04-chat-model

这一掌只做 V1,也就是“单模型跑通”。starter 的准确坐标和属性键建议对照 https://github.com/spring-ai-alibaba/spring-ai-extensions/tree/main/spring-boot-starters 与 Release 页面核验后使用;下面给的写法如果与当前文档不一致,需按当前官方文档核验。

依赖:

<dependency>
    <groupId>com.alibaba.cloud.ai</groupId>
    <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>

配置:

spring:
  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY}
      chat:
        options:
          model: qwen-plus
          temperature: 0.7

核心实现,ChatClient 的构建:

package com.lifuchun.digitalhuman.ai;

import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ChatClientConfig {

    @Bean
    public ChatClient dashScopeChatClient(DashScopeChatModel model) {
        return ChatClient.builder(model)
                .defaultSystem("你是数字人助手,回答简洁、口语化,不要输出 Markdown 表格。")
                .build();
    }
}

业务调用(就是第 01 章那段,这里不再重复粘贴),Controller:

@RestController
@RequestMapping("/api/digital-humans")
public class ChatController {

    private final DigitalHumanRepository repository;
    private final DigitalHumanChatService chatService;

    public ChatController(DigitalHumanRepository repository, DigitalHumanChatService chatService) {
        this.repository = repository;
        this.chatService = chatService;
    }

    @PostMapping("/{id}/chat")
    public ChatReply chat(@PathVariable Long id, @RequestBody ChatRequest request) {
        DigitalHuman dh = repository.findById(id).orElseThrow();
        return new ChatReply(chatService.reply(dh, request.text()));
    }

    public record ChatRequest(String text) {}

    public record ChatReply(String reply) {}
}

启动:

./mvnw spring-boot:run

请求:

curl -s -X POST http://localhost:8080/api/digital-humans/1/chat \
  -H 'Content-Type: application/json' \
  -d '{"text":"用一句话介绍你自己"}'

示例输出:

{"reply":"你好,我是你的数字人助手,可以陪你聊天、介绍产品、回答常见问题。"}

未在当前环境实测,以上为预期结果。

验证动作只有两步:运行页的输入框能收到真模型回复;DigitalHumanChatService 所在包内 grep -r "dashscope" 的结果是空的。第二步现在其实还没通过——DigitalHumanChatService 里的那行 import 还在。V1 能跑,但它只做到了“能让模型说话”,没做到“能让模型被换掉”。这就是留给下一章的问题。

先跑通一事
再谈其可换
未证不言成
已验方为据

06、排查

现象一:加入第二个模型之后,应用启动失败。

为了接 DeepSeek,我加了 OpenAI 兼容的 starter,并声明了第二个 ChatClient Bean:

@Bean
public ChatClient deepSeekChatClient(OpenAiChatModel model) {
    return ChatClient.builder(model).build();
}

重启之后,DigitalHumanChatService 的构造参数注入直接抛 NoUniqueBeanDefinitionException。

怀疑:是不是 Spring AI 的 ChatClientAutoConfiguration 又来掺和了一脚?

检查:先看 ConditionEvaluationReport(启动加 --debug 即可),确认 ChatClient 相关自动配置这一掌到底是生效还是被条件挡住了;再用 ApplicationContext.getBeansOfType(ChatClient.class) 把容器里所有 ChatClient 类型的 Bean 打出来。

证据:容器里出现了两个——dashScopeChatClient 和 deepSeekChatClient。

根因:V1 阶段注入 ChatClient 之所以能成功,靠的是“整个容器里只有一个”;加入第二个之后,按类型注入就失去了唯一性。换句话说,V1 的“能用”是运气,不是设计。

错误尝试: 我第一反应是在 dashScopeChatClient 上加 @Primary。

这之所以是错的,是因为 @Primary 只是把报错压住。它把“用哪个模型”的决定权从调用方悄悄挪到了 Bean 声明上——哪天某个客户要用 DeepSeek,还是得改 ChatClientConfig 再发一次版,正好踩中第 02 章那条“业务代码零改动”的验收标准。更糟的是它掩盖了真正的问题:我们从来没有设计过“谁来决定用哪个模型”。

现象二:把模型字段改成 deepseek-chat,接口返回 401。

怀疑:Key 配错了?Key 过期了?还是账户欠费?

检查:在 ChatClient 外面垫一层日志,把实际请求地址和请求体都打出来。

证据:请求打到了 dashscope.aliyuncs.com,请求体里确实带着 deepseek-chat 这个字符串。

根因:模型名只是请求体里的一个字段,endpoint 和鉴权方式由 provider 决定。“改了模型名就该换家”这个假设本身就不成立。DeepSeek 走的是 OpenAI 兼容协议,需要单独的 starter、单独的 base-url 和单独的 Key,这三样缺一不可。

修复方式见第 07 章。

现象引疑处
证据定根因
错试须记下
正解自昭然

07、优化

第 06 章留下两条根因:一是业务层选择模型时依赖了 provider 的具体类型;二是模型名被误当成路由键。V2 就针对这两条改。

修改一,把所有 provider 专有的 options 收进一个工厂类:

package com.lifuchun.digitalhuman.ai;

import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import org.springframework.ai.chat.model.ChatOptions;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.stereotype.Component;

@Component
public class ChatOptionsFactory {

    public ChatOptions of(String provider, String model, Double temperature) {
        return switch (provider) {
            case "dashScope" -> DashScopeChatOptions.builder()
                    .withModel(model)
                    .withTemperature(temperature.floatValue())
                    .build();
            case "deepSeek" -> OpenAiChatOptions.builder()
                    .model(model)
                    .temperature(temperature)
                    .build();
            default -> throw new IllegalArgumentException("未知模型提供方: " + provider);
        };
    }
}

修改二,把“按 provider 取 ChatClient”这件事收进路由:

package com.lifuchun.digitalhuman.ai;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
public class ModelRouter {

    private final Map<String, ChatClient> clients;

    public ModelRouter(@Qualifier("dashScopeChatClient") ChatClient dashScope,
                       @Qualifier("deepSeekChatClient") ChatClient deepSeek) {
        this.clients = Map.of(
                "dashScope", dashScope,
                "deepSeek", deepSeek
        );
    }

    public ChatClient client(String provider) {
        ChatClient client = clients.get(provider);
        if (client == null) {
            throw new IllegalArgumentException("未知模型提供方: " + provider);
        }
        return client;
    }
}

修改三,业务层只依赖抽象:

@Service
public class DigitalHumanChatService {

    private final ModelRouter router;
    private final ChatOptionsFactory optionsFactory;

    public DigitalHumanChatService(ModelRouter router, ChatOptionsFactory optionsFactory) {
        this.router = router;
        this.optionsFactory = optionsFactory;
    }

    public String reply(DigitalHuman dh, String userText) {
        ChatClient client = router.client(dh.getProvider());
        ChatOptions options = optionsFactory.of(dh.getProvider(), dh.getModel(), dh.getTemperature());
        return client.prompt()
                .system(dh.getSystemPrompt())
                .user(userText)
                .options(options)
                .call()
                .content();
    }
}

这个类里已经没有 com.alibaba.cloud.ai.dashscope 了。所有 provider 专有类型只出现在 ChatClientConfig 和 ChatOptionsFactory 这两个文件里。

DeepSeek 侧的配置长这样:

spring:
  ai:
    openai:
      api-key: ${DEEPSEEK_API_KEY}
      base-url: https://api.deepseek.com
      chat:
        options:
          model: deepseek-chat

新行为:同一个接口,看数据里的 provider 决定走谁。

curl -s -X POST http://localhost:8080/api/digital-humans/1/chat \
  -H 'Content-Type: application/json' -d '{"text":"你好"}'
curl -s -X POST http://localhost:8080/api/digital-humans/2/chat \
  -H 'Content-Type: application/json' -d '{"text":"你好"}'

示例输出(数字人 1 的 provider=dashScope,数字人 2 的 provider=deepSeek):

{"reply":"你好,我是你的数字人助手,随时可以陪你聊。"}
{"reply":"你好,我在,有什么想聊的?"}

验证方式和我认可的标准一致:两次请求走的是同一个 Controller、同一个 Service,只有数据里的 provider 不同。切换模型这件事,从头到尾没有碰过一行业务代码。

根因定其改
改动有其因
新行为人验
旧疾不复还

08、演进

[同一输入] POST /api/digital-humans/{id}/chat  {text}
    |
    +--[V1] Service 直接 import DashScopeChatOptions / 容器里只有一个 ChatClient
    |      代价:换模型 = 改业务代码 + 重新编译 + 全链路回归
    |
    +--[V2] ModelRouter + ChatOptionsFactory,业务层只认抽象
    |      代价:多一跳路由 + 一个工厂类 + 每新增 provider 要补一个 Bean 和一条 switch 分支
    |
[Trade-off]
得到:模型提供方可配置、可替换,业务层零改动,验收标准可验证
失去:一层间接调用;provider 列表变成一份需要维护的代码清单
适用边界:provider 数量可控(个位数)、切换频率低但必须可切换的场景

正确性上,V1 和 V2 在“能回复”这一点上完全等价——V2 没有让回复变得更准,它只是让“谁来回复”变成可配置项。

稳定性上,V2 更好。V1 有一个隐藏的脆弱点:它依赖容器里只有一个 ChatClient,这是一个隐式假设,谁哪天加一个新模型,它就会在启动时炸掉。V2 把这个假设显式化了,变成 ModelRouter 里的一个 Map。

复杂度上,V2 更差。多了两个类、多了一次 Map 查找、多了一条 switch 分支。如果项目一开始就确定只用一家模型、三年内不会换,V1 反而是更诚实的选择。

成本上,两者差别不在运行时——多一跳 Map 查找的开销可以忽略;差别在变更成本:V1 是 O(provider 数量) 次代码改动,V2 是 O(1) 次数据改动。

适用范围上,V2 适用于模型提供方≥2、或者短期内可能切换的场景;V1 适用于原型验证和一次性 demo。

遗留问题有两个。一是 ChatOptionsFactory 里的 switch 仍然是代码,每加一家 provider 都要动它——这是可接受的,因为代码总量很小且集中在一处。二是超时和重试策略还挂在 Spring AI 的默认值上,业务层没有感知,这个问题要等到真正出现线上超时才会被逼出来。

同入不同出
两版各有价
取舍在边界
得失自分明

09、洞见

9.1 抽象要画在“谁认识协议”这条线上

判断一个抽象画得对不对,只需问一句:这个类认不认识某家厂商的协议?ChatClient 和 ChatOptions 都不认识,所以它们属于业务层可以依赖的那一侧;DashScopeChatOptions 和 OpenAiChatOptions 都认识,所以它们必须被关在适配器层。这条线一旦画歪,后面所有的复用都是假的。

9.2 反直觉判断:ChatClient 不是客户端,是端口

刚接触 Spring AI 的时候,我下意识把 ChatClient 当成“DashScope 的客户端”,于是很自然地觉得“给 DeepSeek 建一个 DeepSeekChatClient 就行”。但实际上 ChatClient 是一个端口(port),它代表的是“对话”这个能力本身,而不是某条连接。适配器是 ChatModel,一家一个。想清楚这一点,第 06 章那个 NoUniqueBeanDefinitionException 就不再是意外,而是必然。

9.3 反直觉判断:模型名不是路由键,provider 才是

qwen-plus、deepseek-chat、gpt-4o 看起来都是模型名,但它们分属不同的 endpoint 和鉴权体系。把模型名当路由键,代码能跑,但只在单 provider 世界里能跑。V2 里 provider 和 model 是两个独立字段,这个拆分不是为了好看,而是为了让第 06 章那个 401 永远不会再出现第二次。

9.4 流式与同步不是性能开关,是协议选择

call() 是 RPC 语义,stream() 是事件流语义。它们对超时、对错误、对下游消费姿势的要求完全不同。这一掌刻意只做 call(),不是因为流式不重要,而是因为两条链路同时调试时,你分不清到底是模型没回、还是流没被消费、还是下游提前关了连接。顺序是有代价的:先做对,再做快。

9.5 错误边界必须在适配器层收敛

DashScope 抛的异常和 OpenAI 兼容协议抛的异常不是同一套。如果让它们直接冒到 Controller,业务层的 try-catch 就会变成两家 SDK 报错的大杂烩,每加一家 provider 就要改一次。正确做法是在适配器层把它们映射成自定义异常,业务层只认这一套。这一掌没有完整实现,但边界已经画出来了。

洞见非空谈
皆由证中生
直觉须自省
判断乃成器

10、系统落地

原来有什么:一个能注册、能登录、能建数字人项目、能打开运行页的底座,聊天接口返回写死的占位文案。

本篇新增了什么:一个可注入的 ChatClient;一个 ChatOptionsFactory,把所有 provider 专有的参数构造收在一处;一个 ModelRouter,按 provider 决定用谁;DigitalHuman 上新增 provider 字段;DeepSeek 侧走 OpenAI 兼容 starter 的第二套接入。

现在能做什么:同一个数字人聊天接口,通过数据里的 provider 字段在通义千问和 DeepSeek 之间切换,业务类不出现任何一家 SDK 的 import。这个能力可以写进验收清单,也可以通过 grep 来复核。

还缺什么:一是对话没有上下文,每一轮都是无状态的;二是只有同步 call(),运行页的回复要等整段生成完才出现;三是超时和重试策略还挂在框架默认值上;四是错误边界只画了没实现。

下一步的演进方向很清楚:把“这一轮说什么”升级成“这几轮说了什么”。到了那个时候,ChatClient 上会多挂一个 Advisor,DigitalHuman 上会多一个会话标识,而这一掌辛辛苦苦画出来的 provider 边界不会被破坏——因为会话记忆属于 ChatClient 这一层,跟底下挂的是谁无关。

原有其底基
今添一插槽
能事又添一
所缺在后续

11、小结

Q1 → 至少两条路,取决于抽象画在哪一层:先依赖 SDK 也能跑通,先画抽象则能换
Q2 → 先依赖 SDK 更快,代价是模型选型被钉进编译期,换一次模型改一次业务代码
Q3 → 会,多一个 provider 就多一轮全链路回归,且会踩 401 这类把模型名当路由键的坑
状态 → 同一个数字人接口可在两类模型间切换,业务层零改动,验收标准可 grep 复核

一问一答定
一图一结清
一态可为证
一程自此新

12、作业

12.1 理解题:ChatModel 和 ChatClient 各自负责什么?

参考答案:ChatModel 是模型接口,负责一次 Prompt 到 ChatResponse 的转换,是真正连着 provider 的那一层;ChatClient 是对话门面,负责系统提示、Advisor 链、结构化输出、流式这些“对话”语义上固定的动作。所以调用方应该面向 ChatClient,而 provider 专有的类型应该出现在 ChatModel 的构造和 ChatOptions 的构造里。判断标准是:这个类认不认识厂商协议。

12.2 实战题:给数字人接口加第三个模型提供方,要求业务代码零改动。

参考答案:加一个 ChatClient Bean(例如走 OpenAI 兼容协议的另一家),在 ModelRouter 的 Map 里补一个 key,在 ChatOptionsFactory 的 switch 里补一条分支。DigitalHumanChatService、ChatController 一行不改。如果发现必须改业务类,说明抽象画歪了,回到 12.1 的判断标准重新看。

12.3 排障题:加了第二个 Starter 之后启动报 NoUniqueBeanDefinitionException,接口类型是 ChatClient。怎么定位?

参考答案:先确认容器里 ChatClient 类型的 Bean 有几个(getBeansOfType),再看自动配置的 ConditionEvaluationReport 判断这些 Bean 来自哪里。根因是 V1 的注入依赖“容器里只有一个”这个隐式假设。修复不是加 @Primary(那只是把选择权挪个地方、并没有让业务层可切换),而是引入路由,由调用方在运行期按数据决定用谁。

12.4 架构判断题:把 @Primary 加在 DashScope 的 ChatClient 上,算不算解耦?

参考答案:不算。@Primary 只是让容器在歧义时选一个默认值,它没有把“选哪个模型”这件事交给调用方。加了之后应用能启动,但要把默认模型换成 DeepSeek,仍然要改 Bean 声明并重新发版,第 02 章那条“只改数据不重新编译”的验收标准依旧不通过。它治的是症状(启动报错),不是根因(业务层没有模型选择权)。

四题分四境
理解在先行
实战验其能
判择见真章

13、思考

回到开篇那个问题:先把功能依赖着 DashScope 的类型做出来,等要换模型时再重构,行不行?

答案不是简单的“不行”。V1 那一版是必要的——它用最短的路径证明了“链路是通的”,如果一开始就上路由和工厂,你很可能调不通,还得怀疑是不是抽象写错了。真正的教训在于:V1 阶段必须知道自己留下的是什么。当时我留下的是“容器里只有一个 ChatClient”和“业务类 import 了 DashScope 的 options”,这两件事在第 06 章变成了一个启动异常和一个 401。

所以更准确的判断是:先用最短路径跑通,然后立刻把“厂商类型”收进适配器层。收的动作很小——两个类、一份 Map、一条 switch。但不收的代价会在下一次需求变化时一次性还清。

鸿渐于陆,说的是渐进的功夫。模型接入这件事上,渐进不是指慢慢接更多模型,而是指每接一个模型,都要让下一个模型接得更便宜。如果接第三个模型时改的代码比接第二个还多,那这一掌就没学会。

一事起于变
一术止于界
一证破其因
一役成其器

posted @ 2026-10-10 23:41  李福春  阅读(3)  评论(0)    收藏  举报