# ChatModel Abstraction Decisions ## Question Pool | # | 维度 | 问题 | 模式 | 状态 | |---|---|---|---|---| | Q1 | 术语 | ChatModel 注入方式:Spring Boot 自动注入 vs 手动工厂创建 | evidence-driven | 已解决 | | Q2 | 边界 | RagService 流式对话:Spring AI ChatModel.stream() 替代 DashScope Generation | evidence-driven | 已解决 | | Q3 | 验收 | VECTOR_DIM 是否需要动态化 | user-interview | 已解决 | | Q4 | 边界 | VectorEmbeddingService 批量向量化:EmbeddingModel 支持批量调用 | evidence-driven | 已解决 | ## Evidence-driven | 结论 | 证据来源 | 是否已汇报用户 | |---|---|---| | ReactAgent.builder().model() 接受 ChatModel 接口 | javap 反编译 | 已汇报 | | ChatModel 应通过 Spring Boot 自动注入 | DashScope starter 自动注册 ChatModel Bean | 已汇报 | | RagService 可用 ChatModel.stream() 替代 Generation | Spring AI 接口有 stream(Prompt) 返回 Flux | 已汇报 | | EmbeddingModel 支持批量调用 | EmbeddingModel.call(EmbeddingRequest) 接受多条文本 | 已汇报 | ## User-interview | 问题原文 | 用户原话 | 确认状态 | OpenSpec 回写 | |---|---|---|---| | VECTOR_DIM 怎么处理? | "配置文件动态化" | 已确认 | 已回写 proposal | ## 关键取舍 - 决策:本次只解耦不替换实现 - 原因:先验证抽象层正确再换模型 - 影响:代码改动不改变运行行为 - 风险接受:用户同意先只做解耦 - 决策:VECTOR_DIM 从配置文件读取 - 原因:换模型时改 yml 即可 - 影响:MilvusConstants.VECTOR_DIM 改为从 MilvusProperties 读取 ## 架构审计 - 风险1:RagService 流式适配 — DashScope Generation 和 Spring AI ChatModel.stream() 返回结构不同,需验证 thinking/content 分离逻辑 - 风险2:DashScopeConfig 通用性 — 硬编码 dashscope 配置键,换模型后需改为通用键 - 风险3:ChatModel Bean 冲突 — 多 starter 并存时需 @Primary 或条件注解 - 低风险/无风险:VectorEmbeddingService、MilvusClientFactory 直接替换无问题 ## Commit Preflight ### 检查结果 | 检查项 | 状态 | |---|---| | proposal: 为什么做/做什么/范围/非目标 | ✅ | | design: 上下文约束/技术决策/架构风险/接口影响 | ✅ | | specs: 可观察行为/验收口径(S1-S5) | ✅ | | tasks: 可执行纵向切片(T1-T7) | ✅ | | cross-artifact 对齐: proposal→design→specs→tasks 闭环 | ✅ | | decisions.md → OpenSpec 回写 | ✅ 所有影响实现的发现已回写 | | 接口影响判级: L2(内部接口) | ✅ | | 未解决 evidence-driven 问题 | ✅ 0 | | 未确认 user-interview 问题 | ✅ 0 | | devflow/OpenSpec 冲突 | ✅ 0 | ### Committed OpenSpec - 状态:**已提交**(2026-05-29) - 文件清单: - `openspec/changes/chatmodel-abstraction/proposal.md` - `openspec/changes/chatmodel-abstraction/design.md` - `openspec/changes/chatmodel-abstraction/specs.md` - `openspec/changes/chatmodel-abstraction/tasks.md` ## Range Extension: ModelRoutingConfig + 跨厂商切换 ### 新增需求(apply 期间用户追加) | # | 需求 | 模式 | 状态 | |---|---|---|---| | Q5 | Chat 和 Embedding 不同厂商时如何路由 | user-interview | 已确认 | | Q6 | 用什么做 Embedding(替代 DashScope) | user-interview | 已确认:SiliconFlow BGE-M3 | ### 新增实现 - T8: `ModelRoutingConfig.java` — `@Primary` ChatModel/EmbeddingModel,`List` 自检 Bean - T9: `SiliconFlowEmbeddingConfig.java` — 独立 `OpenAiApi` + `OpenAiEmbeddingModel`,指向 SiliconFlow - 最终模型:Chat = DeepSeek V4 Flash(原生),Embedding = BGE-M3(SiliconFlow) ## 问题追踪 | # | 问题 | 现象 | 根因 | 解决 | |---|---|---|---|---| | P1 | `@Qualifier("dashscopeEmbeddingModel")` 找不到 Bean | Spring 容器启动失败 | DashScope starter 实际 Bean 名是 `dashScopeEmbeddingModel`(小写 s) | 改用 `List` 自检 | | P2 | `@Qualifier("deepSeekChatModel")` 找不到 Bean | 容器启动失败 | `spring.ai.deepseek.api-key` 未配置,AutoConfig 跳过注册 | yml 加 `spring.ai.deepseek.api-key` | | P3 | `OpenAiChatModel` 调 DeepSeek 报 400 Model does not exist | curl 能通,Spring AI 不通 | Spring AI 1.1.0 `OpenAiChatModel` 发请求包含 DeepSeek V4 不识别的字段 | 升级到 1.1.7 + 换原生 `spring-ai-starter-model-deepseek` | | P4 | SiliconFlow Embedding 返回 404 | Embedding 调用失败 | `base-url: .../v1` + Spring AI 自动加 `/v1/embeddings` → `/v1/v1/embeddings` | base-url 去掉末尾 `/v1` | | P5 | Milvus 搜索报 `collection not loaded` | 搜索 101 错误 | collection 创建后未 load 到内存 | `MilvusClientFactory.createClient()` 末尾加 `loadCollection()` | | P6 | MCP 客户端禁用后 `ToolCallbackProvider` 缺失 | 容器启动失败 | `ChatService` `@Autowired ToolCallbackProvider` 无可用 Bean | 测试中加 mock ToolCallbackProvider | | P7 | `spring-ai-starter-model-deepseek` 未利用 | 仍用 OpenAI 兼容模式调 DeepSeek | 用户升级 Spring AI 后才可用原生 starter | pom 加 deepseek starter,yml 用 `spring.ai.deepseek.*` | | P8 | MCP 禁用后启动失败 | `ToolCallbackProvider` Bean 缺失, ChatService/ChatController NPE | MCP `enabled: false` 后框架不注册该 Bean | `@Autowired(required = false)` + null 兜底 | ## 经验教训 ### L1: Spring AI version 决定模型兼容性 - Spring AI 1.1.0 的 `OpenAiChatModel` 不完全兼容 DeepSeek V4(2026年4月发布) - 升级到 1.1.7 + 原生 `DeepSeekChatModel` 才解决 - **教训**:新模型发布后,优先检查 Spring AI 是否有原生 starter,而非用 OpenAI 兼容模式凑合 ### L2: `@Qualifier` Bean 名不要猜 - 不同 starter 的 Bean 名无统一规范(`dashScopeChatModel` vs `dashscopeEmbeddingModel`) - Auto-config 可能因缺少配置跳过 Bean 注册(如缺 api-key) - **教训**:用 `List` 自检 + 类名筛选,比硬编码 `@Qualifier` 更稳 ### L3: base-url 末尾不要带 API 版本路径 - Spring AI 的 `OpenAiApi` 自动追加 `/v1/embeddings`、`/v1/chat/completions` - yml 的 base-url 带 `/v1` 会导致双重路径 - **教训**:配 base-url 只写 `https://host`,不写后缀版本号 ### L4: 多 starter 并存需要 `@Primary` 路由 - `DeepSeekChatModel` + `OpenAiChatModel` + Embedding Bean 同时存在 - 不加 `@Primary` 会导致注入歧义 - **教训**:集中路由(ModelRoutingConfig)比分散在 Service 里加 `@Qualifier` 好 ### L6: yml 驱动路由优于硬编码 @Qualifier - 最终方案:`model-routing.chat=deepseek` / `model-routing.embedding=siliconflow`,ModelRoutingConfig 用 `List` + `Map` 按关键字匹配 - 匹配优先级:Bean 名 > 类名 > 回退第一个 - 换模型只改 yml,不改 Java - **教训**:写死 @Qualifier 是为了运行时安全,但 yml 驱动才是真正达到"只改配置不改代码"的目标 ### L5: `EmbeddingModel.embed()` 返回值是 `float[]` - Spring AI 的 `EmbeddingModel.embed(String)` 返回 `float[]`,不是 `List` - `embed(List)` 返回 `List` - **教训**:API 变化时直接看接口定义,不要沿用旧 SDK 的类型习惯 ## 验收记录 ### Chat 验证 - ✅ Bean 注入:`DeepSeekChatModel` 路由成功 - ✅ API 调用:`deepseek-v4-flash` 返回正常回答 - ✅ Agent 兼容:`ChatService.createReactAgent(ChatModel)` 创建成功 ### Embedding 验证 - ✅ Bean 注入:`OpenAiEmbeddingModel` → SiliconFlow 路由成功 - ✅ 单条:`generateEmbedding("测试")` → 1024 维 - ✅ 批量:`generateEmbeddings(["a","b","c"])` → 3×1024 维 ### Milvus 验证 - ✅ 连接:Zilliz Cloud 连接成功 - ✅ Collection:`biz` 存在并 load 成功 - ✅ 搜索:向量搜索返回结果(或空集合正常返回) ### 测试结果 - `ChatAndEmbeddingSmokeTest`: 5/5 ✅ - `FullPipelineSmokeTest`: 5/5 ✅