Skip to content

ai 域开发 ​

适用端:后端 | 类型:参考

上游模块,本项目只改了 14 个文件——但这 14 个文件管着所有模型调用

enlev-module-ai 来自上游 yudao,是「AI 功能的应用层」:对话、绘画、写作、音乐、思维导图、知识库。 本项目对它的改动量在四个上游模块里最小:

指标实测值
src/main/java 文件数199
src/main/java 行数19,425
其中 @author 走进新时代(本项目)14 文件 / 7.0%(全仓四个上游模块里最低)
controller/admin 子域7(chat image knowledge mindmap model music write)
自有表13(全部 ai_*)
错误码34

对比一下:infra 是 199 / 42.6%,system 与 bpm 也在几十文件的量级。

但那 14 个文件的分量不成比例——它们里面装着 AiModelApi(agent 拿模型的唯一入口)、AiRagApi(agent 拿知识库的唯一入口)、 AiModelFailoverService(故障切换与任务级粘滞)、AiApiKeyEncryptTypeHandler(密钥落库加密)。 agent 的每一次模型调用都要穿过这一层。

🔴 而且这是全仓测试网最薄的模块

43 个测试文件 / 187 个 @Test,其中 86 个 @Disabled(全部不带原因), 且 src/test/resources/ 目录根本不存在 ⟹ 没有 application-unit-test.yaml ⟹ 跑不了 DB 测试。 详见本页「测试」。

:::

定位:ai 的模块边界、模型客户端工厂的 LRU 与超时覆盖面、故障切换的三档分型、 任务级粘滞为什么按 taskKey + modelId 两个维度、向量存储为什么写死了 SimpleVectorStore、 API Key 的三层防护、13 个禁用菜单为什么拦不住端点。

前置阅读:后端架构与模块依赖、 Agent 模块总览(主要消费方)、 Agent 对话与 ReAct 循环(taskKey 的生产方)、 用量与计费(切换之后记的是哪个模型)。

它解决什么问题 ​

ai 不依赖 agent,agent 依赖 ai

依赖是单向的 agent → {ai, infra, bpm, system},没有任何模块反向依赖 agent。 所以 ai 侧要暴露能力给 agent,只能在 ai/api/ 下开接口,不能 import agent 的任何类。

而 agent 侧还有一条自己的红线:跨模块只能走 <module>.api.* (AgentArchitectureTest#testOnlyDependOnApiLayer)。 这就是为什么 BpmProcessInstanceStatusEnum 这种在 bpm.enums 包里的枚举, agent 只能本地镜像常量而不能 import——见 bpm 域开发。

代码在哪 ​

7 个子域(controller/admin/ 下的一级目录,逐个 ls 数出来的):

子域干什么归属
chat对话、会话、聊天角色、聊天管理上游
knowledge知识库、文档、分片(RAG 的数据侧)上游,被 agent 经 AiRagApi 消费
model模型配置、API 密钥、工具(function call)上游,加密 TypeHandler 是本项目加的
imageAI 绘画 + 绘画管理上游
writeAI 写作 + 写作管理上游
musicAI 音乐 + 音乐管理上游
mindmapAI 思维导图 + 导图管理上游

「13 个子域」是错的,13 是 Controller 数

早期材料里出现过「ai 有 13 个子域」的说法。实测 controller/admin/ 下只有 7 个一级目录; 13 是 @RestController 的个数(有的子域拆了 admin / 多个 controller)。 数子域要 ls 目录,数端点要数注解,两者不是一回事。

本项目自研的 14 个文件(逐个列,便于 review 时定位) ​

路径作用
api/model/AiModelApi · AiModelApiImplagent 拿模型、发起对话的唯一入口
api/model/dto/AiChatReqDTO · AiChatRespDTO跨模块的对话请求/响应
api/rag/AiRagApi · AiRagApiImplagent 拿知识库、检索、增删文档的唯一入口。本轮纯新增五个方法:带 url / segmentMaxTokens 的 createDocumentByContent 重载、getDocumentContentHashes(同步判据要的库里 hash)、countSegments / countEmbeddedSegments(切分收敛判据)、listUnembeddedDocumentNames(REEMBED 自愈判据);fillDocumentName 改为批量查整行并回填 url + 从 content 现算标题
api/rag/dto/AiRagSegmentRespDTO检索结果分片。加 documentUrl、documentTitle 两个字段——来源透传到前端、渲染成可点链接的载体
controller/admin/model/vo/model/AiModelTestRespVO「测试模型」按钮的响应
framework/type/AiApiKeyEncryptTypeHandlerAPI Key 落库加密
service/model/AiModelFailoverService · AiModelFailoverServiceImpl故障切换 + 任务级粘滞
service/model/AiModelFailureReason失败分型(QUOTA / CONFIG / TRANSIENT)
service/model/bo/AiModelCandidate · AiModelInvocationBO候选与调用记录

改 ai 的时候先看这张表:表里的文件改动会影响 agent 全链路;表外的文件基本是上游的 AI 应用功能, 改坏了只影响那一个功能页(而且那些页面当前全是禁用的,见数据模型)。

核心机制 ​

模型客户端工厂:LRU 64 + 三档超时 ​

AiModelFactoryImpl 是所有厂商客户端的唯一产地。三个要点:

① 客户端缓存是 LRU 64,不是永不淘汰的 Singleton。 :138 与 :151:

java
private static final int CLIENT_CACHE_SIZE = 64;
private static final Cache<String, Object> CLIENT_CACHE = CacheUtil.newLRUCache(CLIENT_CACHE_SIZE);

注释解释了为什么换掉 hutool 的 Singleton:

Singleton 底层是一个永不淘汰的 Map。而这里的缓存键含 apiKey —— 每轮换一次密钥就多出一个再也用不到的客户端实例,连同它持有的 HTTP 连接池永久驻留。

⚠️ 它不是「会误用旧密钥」的正确性缺陷 —— 新密钥会正确地建出新实例。 换成 LRU 之后行为完全一致,只是给驻留量加了个上限。

getOrCreateClient 是双重检查 + synchronized (CLIENT_CACHE),注释说明了为什么: 「建客户端是重操作(要建 HTTP 连接池),并发首次调用时不做同步会建出好几个、然后互相覆盖, 多出来的那些连接池没人关。」建失败不缓存失败结果,原样往上抛。

② 缓存键是字符串拼接。 buildClientCacheKey:381-386:

java
private static String buildClientCacheKey(Class<?> clazz, Object... params) {
    if (ArrayUtil.isEmpty(params)) {
        return clazz.getName();
    }
    return StrUtil.format("{}#{}", clazz.getName(), ArrayUtil.join(params, "_"));
}

ArrayUtil.join 会对每个参数调 toString()。对 EmbeddingModel 这种没重写 toString 的对象, 拼进去的是对象身份(类名@hashcode)——这一点在下面的向量存储那节会咬人。

③ 超时覆盖面是逐个数过的,不是「大概都接上了」。 :393-410 的注释逐字:

⚠️ 覆盖面(逐个数过,不是「大概都接上了」):

  已接:TONG_YI(chat / image / embedding)、DEEP_SEEK、OPENAI(chat / image / embedding)、 ANTHROPIC、OLLAMA(chat / embedding)。判据是「该平台的 *Api.Builder 是否暴露 restClientBuilder / webClientBuilder」,已用 javap 对 spring-ai 1.1.8 与 spring-ai-alibaba-dashscope 1.1.2.2 逐个核对;image 那两个 Api 只有 restClientBuilder。

  未接:YI_YAN / DOU_BAO / HUN_YUAN / SILICON_FLOW / ZHI_PU / MINI_MAX / MOONSHOT / STEP_FUN / XING_HUO / BAI_CHUAN / GEMINI / GROK / AZURE_OPENAI。 前 12 个走 new AiAutoConfiguration().buildXxxChatClient(properties), 客户端在那边内联构造,本文件拿不到 builder;AZURE_OPENAI 用的是 Azure SDK 自己的 OpenAIClientBuilder,不吃 Spring 的 RestClient/WebClient。

  🔴 这不是「假装覆盖」:那 12 个平台要生效,前提是 yml 里把 enlev.ai.<platform>.enable 打开、由 AiAutoConfiguration 出 bean,而唯一的取用入口 getDefaultChatModel 在 src/main 下零调用点(已 grep 核实)。也就是说线上真正会走的只有上面「已接」那批。 若哪天有人给 getDefaultChatModel 接上调用方,必须同时去 AiAutoConfiguration 补超时。

为什么非加不可(同处注释):

本文件原本零超时设置。厂商侧一挂起,占住的就是一个 Tomcat 线程,而且是永久: Reactor 的 doOnComplete / doOnError / doOnCancel 三条都不触发, 服务端既不报错也不留日志,界面上是一个永远转不完的圈。

三个超时值在 EnlevAiProperties:

配置默认值位置兜住什么
Stream.timeout120s:322流式路径,量的是相邻分片间隔
Http.connectTimeout10s:377建连
Http.readTimeout300s:390同步 call 的读超时

readTimeout 是 300 秒而不是常规的 30 秒,:383 给了实测依据:

实测过「CHAT 单次 284 秒」(思考模式下思考字数达正文的 100~4500 倍)

所以「超时没生效」和「超时太长」是两件不同的事

  • 走已接那 5 个平台:有超时,但读超时 300 秒——一次挂起的请求会占住 Tomcat 线程五分钟。
  • 走未接那 13 个平台:完全没有超时。前提是有人给 getDefaultChatModel 接上调用方, 目前 src/main 下零调用点,所以这条路走不到。

改超时前先确认自己在哪一档。

故障切换:三档分型,QUOTA 不切、CONFIG 切但锁 30 分钟 ​

AiModelFailoverService 是本项目自研的核心之一。它的接口注释先给了统一语义:

  1. 默认使用「启用 + 密钥启用 + sort 排序最前」的模型;
  2. 模型不可用(禁用、密钥禁用、客户端构建失败、瞬时调用失败)时,按 sort 顺序自动切换到下一个候选。

agent 链路(AiModelApiImpl)与 ai 聊天链路(AiChatMessageServiceImpl)共用本组件。

为什么必须分型 ​

AiModelFailureReason 的类注释记录了一次真实的事故,逐字:

分型之前,AiModelFailoverServiceImpl 对任何异常一律切换下一个候选, 它自己的注释写的是「不做异常分类(4xx 不切换之类):各厂商异常包装不一致,首版一律切换」。 后果是额度耗尽被静默吸收:一轮 e2e 里 CHAT 从 model 76 漂到 78 再漂到 80 (实测 76×17 → 78×2 → 80×19),而库里看不出发生过切换 —— 于是「两轮跑的是不是同一个东西」无法保证,所有「跑两轮对比数字」的验收跑多少次都不算数。

三档语义:

分型切不切下一个候选冷却判据来源
QUOTA 额度/余额🔴 不切,该次调用直接判失败30 分钟QUOTA_MARKERS 6 条
CONFIG 配置错(url / 模型名 / 鉴权 / 未开通)切30 分钟CONFIG_MARKERS 10 条
TRANSIENT 网络抖动 / 超时 / 限流 / 5xx切60 秒兜底

QUOTA 与 CONFIG 的关键差别是「切不切」,不是「冷却多久」

AiModelFailureReason 的注释:

额度是账户级事实(下一个模型多半也没额度,且静默切换会破坏「同一任务同一模型」); 配置错误是模型级事实(换一个就对了)—— 切换正是故障切换存在的意义。 所以 CONFIG 切换、QUOTA 不切换,两者共用长冷却。

而 CONFIG 为什么必须从 TRANSIENT 里拆出来,注释给了一次实测:

2026-10-10 19:20 那一轮,候选链是 [77, 79, 82, 83, 84, 85, 86, 87, 88, 89, 75]: 11 个模型全部启用、密钥也启用,选择期两道过滤全过,直到真发请求才吃到厂商的 400 url error / 404 Model not exist / 400 enable_thinking restricted to True。 前 10 个接连失败烧掉 23 秒,最后靠 sort=1 的 75 兜底才跑通。 而 60 秒冷却一过期,19:24:11 又把同一场风暴原样重演了一遍 —— 因为这些都是确定性失败:同一份配置重试必然同样失败,重演是纯浪费。

判据是错误码字符串,不是 HTTP 状态码 ​

🔴 兜不住的时候一律按 TRANSIENT(fail-open 到切换)。 判据是错误码字符串,不是 HTTP 状态码 —— 各厂商把状态码包在不同的异常类型里, 且 403 既可能是额度、也可能是鉴权,按状态码判会误伤。 认不出来时按「瞬时」处理,代价是「多切一次候选」; 反过来按「额度」处理,代价是「把一次网络抖动判成额度耗尽、整个功能停摆」—— 后者的破坏力大得多,所以宁可 fail-open。

QUOTA_MARKERS 6 条:AllocationQuota.FreeTierOnly、Free quota exhausted、 insufficient_quota、QuotaExceeded、Arrearage、balance is insufficient。 前两条是现场实测到的原文(2026-10 的厂商 403 响应,逐字取自后端日志),其余按各厂商公开错误码收进来。

刻意不收裸词,这条纪律别破坏

  • 不收 "quota":会撞上 Throttling.RateQuota(每秒限流)——那是瞬时的, 切下一个候选是对的,判成额度耗尽反而会把好模型长期拉黑。
  • 不收裸 InvalidParameter、裸 400、裸 401:InvalidParameter 同时覆盖请求级参数错误 (这一次调用写错了),按它判会把一个本来好的模型锁死 30 分钟。 注释里写明了权衡:「误判成 CONFIG 的代价(30 分钟锁死)远大于误判成 TRANSIENT(60 秒后多试一次), 所以这一档收得比 QUOTA 更紧。」

加新特征串之前先读这两条。加一个宽泛的词,代价是把好模型锁半小时。

classify 的两条实现纪律:

🔴 必须遍历整条 cause 链:厂商 SDK 常把真正的响应体包在两三层异常里 (RuntimeException → WebClientResponseException → …),只看最外层的 message 往往只有一句「调用失败」,那样额度耗尽会被误判成瞬时、防线等于没加。

🔴 QUOTA 与 CONFIG 是两轮独立的全链扫描,不是在同一个循环里谁先命中谁赢: 外层异常带一句配置类措辞、而内层 cause 才是真正的额度响应,这种情况按「先命中」判 会把额度耗尽降级成可切换 —— 那正是裁定 R-01 要防的静默漂移。额度优先,逐字保住既有行为。

containsAnyMarkerInChain 限深 10 层而不是 while (true),注释: 「cause 自引用(ex.initCause(ex))在真实代码里出现过,那样会在这儿转出栈溢出 —— 一个「分类工具」把调用方打挂是不可接受的。」并且显式处理 cursor.getCause() == cursor。

长冷却靠配置指纹解锁 ​

30 分钟冷却带来一个新陷阱,CONFIG_COOLDOWN_MILLIS 的注释:

🔴 长冷却带来一个新陷阱,靠配置指纹堵掉(见 configFingerprintAt): 管理员改好 url、或把模型禁用再启用之后,这段冷却必须立刻作废。 否则「测试按钮一点就通、生产链却还锁着它最长 30 分钟」—— 那比没有测试按钮更让人抓狂。

指纹取的是 ai_model 行的 update_time。所以「改配置」与「禁用再启用」都能立刻解封。

冷却记忆是纯内存态,多实例部署下各自记忆

COOLDOWN_MILLIS 的注释:

冷却是纯内存态:多实例部署下各自记忆,坏模型恢复后每个实例最多浪费一次探测, 无需落库或引入 Redis。

三个 Map 都是 ConcurrentHashMap,容量「天然受模型总数限制,无需淘汰策略」。 代价:N 个实例 ⟹ 一个已知坏模型最多被白撞 N 次。这是有意的取舍,不是漏配 Redis。

而且进程重启即清零——重启之后所有模型都回到候选链,第一轮会重演一次风暴。

为什么不加一列 failure_reason 到 agent_usage_log

AiModelFailoverServiceImpl 的类注释:

「模型编号/厂商标识 + 厂商原文」,而 agent 侧三条主链路(INTENT / PLAN_DSL / CHAT) 本来就把 ex.getMessage() 写进 agent_usage_log.error_msg ⟹ 「哪个模型在哪一刻额度耗尽」直接落库可查。 之所以不加列,是因为本模块已经有一根死列的前车之鉴(failed_at_node 存在但全仓无人读写), 加一根没人读的列只是把同一个坑再挖一次。

任务级粘滞:按 taskKey + modelId 两个维度 ​

AiChatReqDTO 有 7 个顶层字段:modelId:23、systemPrompt:28、messages:33、 temperature:38、maxTokens:43、enableThinking:58、taskKey:77, 外加嵌套的 Message{role:88, content:93}。

taskKey 是本项目加的,注释解释了它为什么存在:

为空 = 不做任务级粘滞,行为与今天完全一致(每次调用独立选模型、失败自动切换)。

为什么需要它:一次流程执行 / 一次规划会打多轮模型,如果每轮都独立走候选链, 中途某个模型额度没了就会静默换成另一个模型继续 —— 前后半程不是同一个模型, 这轮的效果既没法归因、也没法和上一轮比。口径是「同一个任务只能用同一个模型, 额度没了就算这次失败,换模型只发生在任务边界」。

🔴 粘滞的键是两个维度,不是一个

🔴 「同一个模型」是按 taskKey + modelId 分档讲的:同一任务里 显式配了不同 modelId 的调用各钉各的,互不覆盖。

AiModelFailoverService 的接口注释把踩过的坑写得很直白:

🔴 粘滞管的是「故障切换」,不是「用户配了哪个模型」。 粘滞的键是 taskKey + 本次请求的 requestedModelId 两个维度,不是只按任务。 只按任务钉会吃掉显式配置:一次流程执行里两个 LLM 节点可以各自配不同的 modelId (LlmNodeExecutor / LlmConditionNodeExecutor 都读 node.configLong("modelId"), 而它们共用 EXEC:<executionId> 这一个 taskKey), 那样后一个节点会被前一个节点钉住的模型静默顶掉 —— 用户明明配了 A,跑出来是 B, 而且不报错。R-01 要禁的是「模型自己换来换去」,不是「把用户的选择改掉」。

失效方向:静默。这是全模块最难查的一类 bug——没有异常、没有日志,只有结果不对。

三条边界(接口注释原文):

  1. 额度耗尽不切换 —— 判 MODEL_QUOTA_EXHAUSTED 失败;
  2. 任务内粘滞 —— 传了 taskKey 的调用,任务内后续调用只用第一次成功选中的那个模型, 连候选链都不再进;
  3. 流式首包后不切换 —— 前端已收到内容,撤不回来(这条早于裁定,保持不动)。

粘滞表的生命周期:

常量值理由(注释原文)
TASK_PIN_TTL_MILLIS:11530 分钟「每次读到都会续期,所以『活跃中的任务』永不掉钉;掉钉的只有已经被放弃的任务」
TASK_PIN_PURGE_THRESHOLD:1244096「逐次调用都全表扫会把一个 Map 操作变成 O(n);而读取路径已经惰性判过期,过期的钉子逻辑上已经失效,留在这儿只占内存不产生错误行为」

🔴 粘滞是进程内的,不入库、不走 Redis:它只解决「一次任务内别换」, 不承担跨实例一致性。多实例部署下同一 taskKey 落到不同实例会各钉各的 —— 这是可接受的, 因为「换模型」的代价是效果不可比,不是数据错乱。

⚠️ taskKey 放在循环里取会静默退回旧行为

每轮生成一个新键 ⟹ 粘滞一次都不生效 ⟹ 每轮独立走候选链。 没有异常、没有日志,只有「两轮跑的不是同一个模型」。 详见 Agent 对话与 ReAct 循环 · 坑与注意第 6 条。

这是本页最容易在真实开发中犯的一个错,因为正确的写法(把 taskKey 提到循环外) 和错误的写法在编译期、运行期都没有任何区别。

「任务」这个概念只存在于带 taskKey 的那个重载 ​

buildCandidateChain(requestedModelId, type) 两参重载的注释:

🔴 ai 模块自己的聊天 / 会话 / 思维导图 / 写作四条链路都走这个入口。 本组件是平台级的,改口径不能顺手改到流程编排以外去 —— 所以「任务」这个概念只存在于带 taskKey 的那个重载里。

实际影响:ai 自己的四个功能页不受粘滞约束,每次调用独立选模型。 这是刻意的——那些是一次性请求,不存在「一个任务打多轮」。

候选链的形状(注释原文):

链 = [requestedModelId(校验可用则置顶;不可用记 warn 后丢弃,静默降级不抛异常)]
    + [其余 启用 + 密钥启用 的同 type 模型,按 sort 升序]

🔴 所有 DB 读取与客户端构建都在调用线程完成(租户上下文完整); 后续切换路径(IO 线程)上只剩内存操作与惰性网络调用。

这一条很重要:切换发生在 Reactor 的 IO 线程上,那里 TenantContextHolder 可能是空的。 把 DB 读取全部前置到调用线程,是为了不让租户上下文成为切换路径上的隐含依赖。

向量存储:写死了 SimpleVectorStore,而它自己说「仅适合本地测试」 ​

AiModelServiceImpl:460-475 逐字:

java
@Override
public VectorStore getOrCreateVectorStore(Long id, Map<String, Class<?>> metadataFields) {
    // 获取模型 + 密钥
    AiModelDO model = validateModel(id);
    AiApiKeyDO apiKey = apiKeyService.validateApiKey(model.getKeyId());
    AiPlatformEnum platform = AiPlatformEnum.validatePlatform(apiKey.getPlatform());

    // 创建或获取 EmbeddingModel 对象
    EmbeddingModel embeddingModel = modelFactory.getOrCreateEmbeddingModel(
            platform, apiKey.getApiKey(), apiKey.getUrl(), model.getModel());

    // 创建或获取 VectorStore 对象
     return modelFactory.getOrCreateVectorStore(SimpleVectorStore.class, embeddingModel, metadataFields);
//         return modelFactory.getOrCreateVectorStore(QdrantVectorStore.class, embeddingModel, metadataFields);
//         return modelFactory.getOrCreateVectorStore(RedisVectorStore.class, embeddingModel, metadataFields);
//         return modelFactory.getOrCreateVectorStore(MilvusVectorStore.class, embeddingModel, metadataFields);
}

切换向量库的方式是改代码、重新编译——注释掉一行、放开另一行。没有配置项。

根因不是懒,是数据模型缺一列:AiKnowledgeDO 的全部字段是 id / name / description / embeddingModelId / embeddingModel / topK / similarityThreshold / status ——没有任何一列能表达「这个知识库用哪种向量存储」。想做成配置项得先加列、再迁移。

buildSimpleVectorStore:767-796 的 javadoc 第一句就是:

注意:仅适合本地测试使用,生产建议还是使用 Qdrant、Milvus 等

它的实现:

java
SimpleVectorStore vectorStore = SimpleVectorStore.builder(embeddingModel).build();
// 启动加载
File file = new File(StrUtil.format("{}/vector_store/simple_{}.json",
        FileUtil.getUserHomePath(), embeddingModel.getClass().getSimpleName()));
if (!file.exists()) {
    FileUtil.mkParentDirs(file);
    file.createNewFile();
} else if (file.length() > 0) {
    vectorStore.load(file);
}
// 定时持久化,每分钟一次
Timer timer = new Timer("SimpleVectorStoreTimer-" + file.getAbsolutePath());
timer.scheduleAtFixedRate(new TimerTask() { ... vectorStore.save(file); },
        Duration.ofMinutes(1).toMillis(), Duration.ofMinutes(1).toMillis());
// 关闭时,进行持久化
RuntimeUtil.addShutdownHook(() -> vectorStore.save(file));

🔴 落盘文件名只按 EmbeddingModel 的类名区分,不按模型、更不按知识库

文件名是 simple_{embeddingModel.getClass().getSimpleName()}.json,例如 simple_OpenAiEmbeddingModel.json。

而缓存键是 buildClientCacheKey(VectorStore.class, embeddingModel, type), 里面拼的是 embeddingModel.toString()(对象身份)。于是:

场景缓存里磁盘上
知识库 A 用 text-embedding-3-small,知识库 B 用 text-embedding-3-large(同一平台)两个独立的 SimpleVectorStore 实例同一个 simple_OpenAiEmbeddingModel.json

两个实例各自的 Timer 每分钟 save() 到同一路径,后写的把先写的整份覆盖。 下次重启 load() 回来的只有最后落盘那一份,另一份的向量从磁盘上消失。

失效方向:静默数据丢失。而且丢的是索引——ai_knowledge_segment 表里的分片正文还在, 所以看起来「知识库没坏」,只是检索不到。

好消息:查询侧不会串库

AiKnowledgeSegmentServiceImpl 检索时带了元数据过滤(:293-294):

java
.filterExpression(new FilterExpressionBuilder()
        .eq(VECTOR_STORE_METADATA_KNOWLEDGE_ID, reqBO.getKnowledgeId().toString()).build());

而 SimpleVectorStore 确实支持 filterExpression——已用 javap 核对 spring-ai 1.1.8: 它有 SimpleVectorStoreFilterExpressionEvaluator 字段,doSimilaritySearch 里调 doFilterPredicate(...) 走 Stream.filter。所以 A 库的向量不会出现在 B 库的检索结果里。

串的是磁盘,不是查询结果。这一点区分很重要,否则会把问题误判成「检索串库」。

:::

另外两条与 SimpleVectorStore 有关的成本:

  • 每次检索都是全量扫描 + 全量算 embedding 相似度(数据在内存 Map 里)。分片数上去之后是线性的。
  • 一个向量存储实例一条 Timer 线程,且从不 cancel()(只有 JVM 退出时的 shutdown hook)。 线程名带文件绝对路径,可以据此在 jstack 里认出它们。

三个真向量库都配好了,但都不在读写路径上 ​

enlev-module-ai/pom.xml 里三个 starter 都是实际依赖: spring-ai-starter-vector-store-qdrant:135、-redis:142、-milvus:153。 application.yaml:150-166 三块配置也都在:

yaml
spring:
  ai:
    vectorstore: # 向量存储
      redis:
        initialize-schema: true
        index-name: knowledge_index
        prefix: "knowledge_segment:"
      qdrant:
        initialize-schema: true
        collection-name: knowledge_segment
        host: 127.0.0.1
        port: 6334
      milvus:
        initialize-schema: true
        database-name: default
        collection-name: knowledge_segment
        client:
          host: 127.0.0.1
          port: 19530

而 application-local.yaml:10-11 排除了其中两个的自动配置:

yaml
      - org.springframework.ai.vectorstore.qdrant.autoconfigure.QdrantVectorStoreAutoConfiguration # 禁用 AI 模块的 Qdrant,手动创建
      - org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusVectorStoreAutoConfiguration # 禁用 AI 模块的 Milvus,手动创建

Redis 那个没有被排除

Qdrant 与 Milvus 的自动配置在 local 被 exclude,Redis 的没有。 所以本地启动时 spring-ai-starter-vector-store-redis 的自动配置是活的, 且 initialize-schema: true——它会去 Redis 建 knowledge_index 索引。 而建出来的这个 RedisVectorStore bean 没有任何代码取用(AiModelServiceImpl:471 写死了 Simple)。

实际影响:本地 Redis 里可能有一个没人用的索引;如果 Redis 没起或没有 RediSearch 模块, 启动可能直接失败——而报错信息会指向一个你根本没用到的组件。

API Key 的三层防护 ​

密钥从「配置」到「返回前端」经过三道,各管一段:

① 落库加密——AiApiKeyEncryptTypeHandler(AiApiKeyDO:44 上挂 @TableField(typeHandler = ...))。 它的类注释说明了为什么不直接用框架的 EncryptTypeHandler:

两点差异,都是 API Key 这个场景特有的:

  1. 要放过 ${ENV_VAR} 占位符。库里存的可能不是密钥本身,而是一个环境变量引用 (AiUtils.resolveSpringPlaceholders 在用的时候才解析)。 引用不是秘密,加密它只会让运维在库里看不懂自己配了什么
  2. 解密失败要能降级。存量数据是明文,上线那一刻如果直接抛异常, 所有模型调用会一起挂掉 —— 而这是个安全加固,不该把可用性搭进去。 所以解不开就按明文处理并告警,迁移脚本跑完告警自然消失

它防什么、不防什么,注释写得很清楚:

⚠️ 这一层防的是什么,不防什么:密钥写在 application.yaml 里、跟着代码走。 所以它防的是数据库被单独拖走(备份泄漏、只读账号、SQL 注入读表), 不防拿到应用服务器或代码仓库的人。接口侧的泄漏由 AiApiKeyRespVO 的脱敏注解挡,两层各管一段。

② 接口脱敏——AiApiKeyRespVO:18:

java
@SliderDesensitize(prefixKeep = 6, suffixKeep = 4)
private String apiKey;

③ 永不进操作日志——LogRecordConstants:13:

🔴 密钥的 apiKey 字段永远不进日志,见 AiApiKeySaveReqVO 上的说明。

解密里藏着一条实测出来的坑:MySQL TO_BASE64 的换行

decrypt 的实现里有一段注释,逐字:

剥掉空白再解密:MySQL 的 TO_BASE64 每 76 字符插一个换行, 而 base64 解码不接受换行。用它写迁移脚本是最自然的做法, 踩中了却极难发现 —— 实测那次,因为 ChatModel 被缓存着, 对话照常成功,只有日志里多了几条告警

所以代码是 EncryptTypeHandler.decrypt(StrUtil.removeAllLineBreaks(value).trim())。

如果你要写自己的加密字段迁移脚本,别用 TO_BASE64,或者记得先去掉换行。

解密失败时的两条分支也值得读:

走到这里只有两种可能:① 是尚未迁移的明文;② 密钥换过了解不开。 两种都不该让调用直接失败 —— 前者会在迁移后消失, 后者原样返回密文,上游厂商会以「密钥无效」拒绝,是个响亮且明确的失败

AiApiKeyDO 上的 autoResultMap = true 不能省

AiApiKeyDO:16 是 @TableName(value = "ai_api_key", autoResultMap = true), :18-21 的注释:「⚠️ autoResultMap 必须开:不开的话 @TableField 的 typeHandler…」

失效方向:静默。不开 autoResultMap,typeHandler 在查询方向不生效, 读出来的是密文原文,然后被当成密钥发给厂商 ⟹ 401。

MCP Server:默认关着,但 permitAll 是无条件注册的 ​

ai 模块的 framework/security/config/SecurityConfiguration 全文只有一个 bean, 它把 MCP 的端点注册成免鉴权:

java
@Resource
private Optional<McpServerSseProperties> mcpServerSseProperties;
@Resource
private Optional<McpServerStreamableHttpProperties> mcpServerStreamableHttpProperties;

@Bean("aiAuthorizeRequestsCustomizer")
public AuthorizeRequestsCustomizer authorizeRequestsCustomizer() {
    return new AuthorizeRequestsCustomizer() {
        @Override
        public void customize(...registry) {
            mcpServerSseProperties.ifPresent(properties -> {
                registry.requestMatchers(properties.getSseEndpoint()).permitAll();
                registry.requestMatchers(properties.getSseMessageEndpoint()).permitAll();
            });
            mcpServerStreamableHttpProperties.ifPresent(properties ->
                    registry.requestMatchers(properties.getMcpEndpoint()).permitAll());
        }
    };
}

application.yaml:191-197 里 MCP 是关的:

yaml
    mcp:
      server:
        enabled: false
        name: enlev-mcp-server
        version: 1.0.0
        instructions: 一个 MCP 示例服务
        sse-endpoint: /sse

enabled: false 挡的是服务端 bean,permitAll 的判定条件是properties bean 在不在

这里用 Optional<...Properties> + ifPresent,判据是「自动配置有没有出这个 properties bean」, 不是「MCP server 有没有启用」。

也就是说:只要 spring-ai-starter-mcp-server 在 classpath 上、自动配置生效, /sse 与 MCP 端点就会被登记为 permitAll——即使 server 本身是 disabled。

当前 enabled: false 时没有处理器挂在那些路径上,所以打过去是 404,没有实际暴露。 但把 enabled 改成 true 的那一刻,那些端点就是完全免鉴权的, 不需要再动任何安全配置。开 MCP 之前先想清楚这件事。

⚠️ 另注意 sse-endpoint: /sse 是一个很短、很容易撞的路径。

跨模块通道:AiModelApi 与 AiRagApi ​

agent 只能通过这两个接口用 ai 的能力。

AiModelApi —— AiModelApiImpl:53 定义了一个白名单:

java
/**
 * 不传 type 时要遍历的模型类型
 *
 * 取自 AiModelTypeEnum:1=聊天、2=图像、3=音乐、4=视频、5=向量、6=重排。
 * agent 只关心聊天与向量,其余就算开着也用不上,不必查出来干扰调用方。
 */
private static final List<Integer> QUERYABLE_MODEL_TYPES = List.of(1, 5);

AiModelApiImpl 上还有一条启动相关的警告:

⚠️ @Lazy 的理由同 AiRagApiImpl:ai 模块内部存在循环依赖, 新 bean 普通注入会在循环未完成包装时取走 raw 实例,导致整个应用起不来。

AiRagApi 有 6 个方法:retrieve:30、getOrCreateKnowledge:43、 createDocumentByContent:61、deleteDocumentByName:70、findKnowledgeIdByName:82、listDocumentNames:90。 AiRagApiImpl:135 建文档时用 .setUrl("local://" + name)——因为 Spring AI 的 Document 要求有 source, 而 agent 传进来的是纯文本内容,没有真实 URL。

AiChatReqDTO 是跨模块的对话请求,7 个顶层字段见任务级粘滞那节。

检索的 topK 在有 rerank 时会被放大 4 倍

AiKnowledgeSegmentServiceImpl:70 与 :288:

java
private static final Integer RERANK_RETRIEVAL_FACTOR = 4;
...
int searchTopK = rerankModel != null ? topK * RERANK_RETRIEVAL_FACTOR : topK;
double searchSimilarityThreshold = rerankModel != null ? SIMILARITY_THRESHOLD_ACCEPT_ALL : similarityThreshold;

两个值同时变:向量检索先粗捞 topK × 4 条、且相似度阈值放开到全收,再交给 rerank 精排。 所以「配了 rerank 之后召回变多了、阈值好像失效了」是设计如此,不是 bug。

数据模型 ​

类别数量说明
ai_* 表13ai_api_key ai_chat_conversation ai_chat_message ai_chat_role ai_image ai_knowledge ai_knowledge_document ai_knowledge_segment ai_mind_map ai_model ai_music ai_tool ai_write
错误码34四个上游模块里最少
权限按钮(ai:*)代码里 34 条;system_menu 里 12 条活着、22 条已软删🔴 见下

🔴 ai 的应用面菜单已被软删,但代码、端点、Web 页面全都还在

本页上一版写的是「13 个 AI 菜单全部禁用、34 条 ai:* 按钮全部开启」。那个状态已经不存在了:DB 侧在 2026-10-22 03:02–03:05 一次性软删了一批,随 12347efcd1 refactor(系统):清除未用上的模块 进快照。以下按 ainb-professional-init 与 sql/mysql/ainb-professional-init.sql 重新实测(两边一致,2026-10-25)。

菜单:12 个已软删(deleted = 1),不是「禁用」

菜单idstatusdeleted
AI 对话 / AI 绘画 / AI 写作 / AI 音乐 / AI 思维导图792 816 825 829 83111
聊天角色 / 聊天管理 / 绘画管理 / 音乐管理 / 写作管理 / 导图管理806 811 817 821 826 83211
工具管理(ai/model/tool/index.vue)85501

菜单:活着的 5 个全开

菜单idtypestatus
AI(目录)79110
└ 控制台(目录)79310
├ API 密钥79420
└ 模型配置80020
└ AI 知识库85020(上一版记的是 1 禁用,已翻)

权限按钮:34 → 12 活 / 22 软删

活着的 12 条正好是三个还开着的页面的:ai:api-key:{query,create,update,delete}(795-798)、ai:model:{query,create,update,delete}(801-804)、ai:knowledge:{query,create,update,delete}(851-854)。软删的 22 条挂在被删的那些菜单下(ai:chat-role:* ai:chat-conversation:* ai:chat-message:* ai:image:* ai:music:* ai:write:* ai:mind-map:* ai:tool:*)。

但代码一行没删:enlev-module-ai 的 controller/admin 下仍是 13 个 Controller,@PreAuthorize("@ss.hasPermission('ai:...')") 去重后仍是 34 个权限串(含 ai:chat-role:*、ai:image:*、ai:music:*、ai:write:*、ai:mind-map:*、ai:tool:* 这些菜单已删的);Web 端 src/views/ai/ 下 chat、image、mindmap、music、write 五个页面目录也都还在。

所以失效方向仍是 fail-open,只是形式变了:端点照样被 Spring MVC 扫描注册,前端路由文件照样在仓库里,超管照样能直接调通;变的只是「非超管拿不到那 22 个权限串了」——菜单行软删后 getPermissionInfo 不再下发它们,@PreAuthorize 于是判无权。这与「菜单禁用」不同:禁用只藏导航、权限串照发;软删连权限串一起收走。 与 infra 域开发里「代码生成菜单关着、但代码生成器能用」是同一个机制家族的另一次出现。

⚠️ id 855「工具管理」这个坑还在,而且更容易踩了:它与 agent 的 id 9000050995「工具管理」(agent/tooldef/index,status = 0、deleted = 0,活着)同名不同物。前者是 ai 模块的 function-call 工具(已软删),后者是 agent 的工具体系(在用)。在文档、截图、口头沟通里说「工具管理」时必须说清是哪一个 —— 现在 ai 那个已经查不到菜单行了,只在 system_menu 的软删数据里留着,容易让人以为「工具管理」只有一个。

🔴 顺带一个口径不一致:8c53a0cb5b fix(系统):App菜单 未交付子树改硬删——软删行是悬空引用的尸体,没有保留价值 对 App菜单 子树选择了硬删,理由是软删行没有保留价值;而 ai 这 12 个菜单 + 22 个按钮走的是软删,正是那条提交所说的「尸体」。两套做法并存,按症状查菜单时会得到互相矛盾的结论。要不要统一属产品决策,本手册只记明现状。

与之对照:AI 助手那棵树是全开的

791 AI(目录,开)、793 控制台(目录,开)、794 API 密钥(开)、800 模型配置(开)、850 AI 知识库(开)、 9000050908 AI 助手(目录,开)+ agent 的 7 个菜单全开 (9000050905 智能助手、9000050912 用量看板、9000050913 助手配置、9000050931 流程编排、 9000050932 智能体管理、9000050995 工具管理、9000050999 提示词模板)。

也就是说 ai 模块留下来的是管理面(模型配置、API 密钥)加一个知识库, 应用面(对话、绘画、写作、音乐、思维导图、ai 自己的工具管理)已从菜单里删掉。 本平台的 AI 应用统一走 agent,不用 ai 自己那套页面。

接口清单 ​

维度实测值
Controller13(全部在 controller/admin,无 app 端)
端点101

跨模块的 api/ 层是两个接口:AiModelApi(+ AiChatReqDTO / AiChatRespDTO)与 AiRagApi(6 个方法 + AiRagSegmentRespDTO)。

.generated/ 里的清单是快照,不是权威

生成清单由 tools/gen-api.mjs 产出。全仓的 Controller / 端点合计不在这里重复一遍—— 它已经漂移过一次(本页写 110 / 761 时,后端架构与模块依赖 · 接口清单 已实测到 111 / 772),两处并存就一定会再次分叉。要引用总数去那张表,或者跑脚本。

改了 Controller 之后要重跑才会更新——bpm 域曾量到清单记 14/79 而源码是 15/82,本轮已修脚本并重跑对齐。

要准确数字就跑脚本,或者直接数注解。

怎么扩展 ​

接一个新厂商平台 ​

  1. AiPlatformEnum(在 enums/model/,实测 21 个值)加一个。
  2. 在 AiModelFactoryImpl 里加 build*Api。这里要做决定:
    • 如果该平台的 *Api.Builder 暴露了 restClientBuilder / webClientBuilder ⟹ 照「已接」那 5 个平台的写法接上超时(用 javap 核对,别猜)。
    • 如果客户端是在 AiAutoConfiguration 里内联构造的 ⟹ 本文件拿不到 builder, 只能去那边补超时;同时更新 :393-410 那段覆盖面注释,把它从「未接」挪到「已接」。
  3. 三个 switch (platform) 都要加分支::192(chat)、:238(image)、:283(embedding)。
  4. EnlevAiProperties 加对应的嵌套配置类(现有 16 个:Gemini:103 … TongYi:342)。
  5. application.yaml 的 spring.ai.<platform>.api-key 用 ${ENV_VAR:占位} 形式, 不要把真密钥写进去(现有配置全是 ${OPENAI_API_KEY:sk-xxxx} 这种形状)。

🔴 厂商密钥绝不写进源码——本仓有过 10 处

src/test/.../framework/ai/core/VendorApiKeys.java 的类注释记录了这件事,逐字:

厂商 API 密钥一律从环境变量读,绝不写进源码。 这一批是「必须联网才能跑」的集成测试,平时全挂着 @Disabled。 把真密钥直接写在字段初始化里,换来的结果是:密钥随 git 永久留在历史里, 而它一次也没被执行过 —— 纯亏。 2026-10-07 量到时,本模块 src/test 下有 10 处这种写法(Anthropic / OpenAI / SiliconFlow×2 / 通义×3 / Midjourney / StabilityAi / 博查),全部来自上游提交,本仓只是继承了它们。

以及一段必须一起读的话:

⚠️ 改掉源码不等于密钥不再泄露。git 历史里那 10 个值仍然存在, 任何一次 git log -p 都能捞出来。真正的补救只有去各家控制台吊销并换发; 本类只负责「HEAD 不再继续外传」。而且这些是上游作者的演示密钥, 本仓的使用者多半根本没权限吊销 —— 所以别把「已经改掉了」当成「已经安全了」。

现在 VendorApiKeys.of(envName) 的实现是 System.getenv + assumeTrue, 取不到就跳过测试并说明原因,而不是返回空串或抛异常。注释给了三条理由:

  • 返回空串最糟:会带着一个必然 401 的请求真打到厂商接口上,报错信息里看不出「是你没配环境变量」。
  • 抛异常会让「有人在 CI 里去掉了 @Disabled 但没配密钥」直接把构建打红 —— 那是环境缺失,不是代码错。
  • assumeTrue 把它记成 skipped 并带上原因,是集成测试缺凭据时的通行做法。

写新的联网集成测试时照抄这个模式,别再往字段初始化里塞密钥。

换向量存储 ​

  1. 先给 AiKnowledgeDO 加一列(比如 vector_store_type),否则只能全局一刀切。
  2. 补迁移脚本,把存量知识库都标成 SIMPLE。
  3. 改 AiModelServiceImpl:471,按那一列分派到 SimpleVectorStore / QdrantVectorStore / RedisVectorStore / MilvusVectorStore(AiModelFactoryImpl:365-375 四个分支都已存在)。
  4. 去掉 application-local.yaml:10-11 对应的 exclude,并把 application.yaml:150-166 的配置填对。
  5. 迁移向量数据:SimpleVectorStore 的落盘格式是它自己的 JSON (~/vector_store/simple_*.json),其它三个库各有各的 schema,没有现成的搬迁工具。 最省事的做法是从 ai_knowledge_segment 表重建索引(正文在库里,向量可以重算)。

换库之前先确认那个磁盘文件里没有「只此一份」的数据

ai_knowledge_segment 存的是分片正文,向量是可重算的。 但如果有分片只存在于 simple_*.json 而没落库(例如写入流程中途失败), 换库就等于永久丢掉它。迁移前先比对两边的分片数。

加一类失败分型 ​

  1. AiModelFailureReason 加枚举值 + 一组 *_MARKERS。
  2. 决定它切不切——这是最要紧的一步,见故障切换那张表。
  3. 决定冷却多长,并在 AiModelFailoverServiceImpl 加对应的 Map + isXxxActive 判定。
  4. 特征串只收逐字实测到的原文或厂商公开错误码,不收裸词。 CONFIG_MARKERS 的注释把这条纪律和它的代价都写了。
  5. 在 classify 里决定扫描顺序——现有实现是 QUOTA 先扫全链、CONFIG 再扫全链, 两轮独立,不是「谁先命中谁赢」。改顺序会改变边界情况的判定。

坑与注意 ​

静默 / fail-open

  1. 🔴 13 个 AI 菜单全部 status = 1(禁用),但 34 条 ai:* 权限按钮全开 ⟹ 端点仍注册、仍可用 token 直接调通。菜单禁用不是访问控制。
  2. 🔴 taskKey 放循环内取 ⟹ 粘滞一次都不生效,两轮跑的不是同一个模型,无异常无日志。
  3. 🔴 SimpleVectorStore 的落盘文件名只按 EmbeddingModel 类名区分 ⟹ 同平台不同模型的两个知识库互相覆盖对方的索引文件,重启后一方的向量消失。 (查询侧靠 knowledgeId 元数据过滤,不会串库——串的是磁盘。)
  4. 🔴 AiModelFailureReason 认不出来一律按 TRANSIENT ⟹ 一个新厂商的额度耗尽错误 会被当成瞬时故障、静默切到下一个模型。这是有意的 fail-open(注释里写了权衡), 但意味着接新厂商时必须同步补特征串。
  5. 🔴 冷却与粘滞都是纯内存态 ⟹ 进程重启即清零;多实例各自记忆,坏模型最多被白撞 N 次。
  6. 解密失败按明文处理(log.warn 后原样返回)⟹ 存量明文能继续用, 但「密钥换过了」这种情况只会得到一条 warn,然后被厂商以 401 拒绝。
  7. AiApiKeyDO 少写 autoResultMap = true ⟹ typeHandler 查询方向不生效, 读出密文当密钥发出去 ⟹ 401。
  8. getDefaultChatModel 在 src/main 下零调用点 ⟹ 那 13 个「未接超时」的平台目前走不到;一旦有人接上调用方,超时缺口立刻变成真问题。
  9. application-local.yaml 排除了 Qdrant 与 Milvus,没排除 Redis ⟹ Redis 向量索引会被建出来但没人用;Redis 没起或没 RediSearch 时启动失败,报错指向一个没在用的组件。
  10. MCP 的 permitAll 判据是 properties bean 在不在,不是 enabled ⟹ 把 mcp.server.enabled 改成 true 的那一刻,/sse 等端点即免鉴权,无需再动安全配置。
  11. buildCandidateChain 两参重载不做粘滞 ⟹ ai 自己的聊天/会话/思维导图/写作四条链路 每次调用独立选模型(这是刻意的,但改口径时容易顺手改错)。
  12. 上游两处 TODO 仍在:AiChatMessageServiceImpl:92「后续优化下对话的 Prompt 整体结构」、 :409「历史的知识库;历史的搜索,要不要拼接?」。
  13. FileTypeUtils 有两份(infra 与 ai 各一),改一份不影响另一份。

fail-closed

  1. validateApiKey / validateModel / AiPlatformEnum.validatePlatform → 密钥或模型不可用即抛。
  2. QUOTA 分型 → 不切换,该次调用直接判 MODEL_QUOTA_EXHAUSTED 失败 (这是裁定 R-01 的要求:额度没了就算失败)。
  3. CONFIG / QUOTA 的 30 分钟冷却 → 期间该模型不进候选链; 但改配置或禁用再启用会立刻解封(以 update_time 为指纹)。
  4. @SliderDesensitize(prefixKeep = 6, suffixKeep = 4) → 接口返回的密钥中段被遮。
  5. LogRecordConstants → apiKey 字段永不进操作日志。
  6. containsAnyMarkerInChain 限深 10 层 + 显式处理自引用 cause → 分类工具不会把调用方栈溢出打挂。
  7. TASK_PIN_PURGE_THRESHOLD 惰性过期 + 超阈值才扫 → 过期的钉子逻辑上已失效,留着不产生错误行为。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

测试 ​

维度实测值
测试文件43
@Test 方法187
@Disabled🔴 86 个注解,全部不带原因
src/test/resources/🔴 目录不存在
application-unit-test.yaml🔴 没有
引用 VendorApiKeys 的测试文件10
assumeTrue 出现次数5

🔴 这是全仓测试网最薄的模块,而它管着所有模型调用

横向对比(2026-10-25 实测;本页口径 = src/test 下全部 .java 文件数 + 行首锚定的 @Test 注解数,与测试规范那张表的口径不同——那边只数 *Test.java 且把 @ParameterizedTest 也算进方法数,所以数字对不上是正常的,两张表各自内部一致):

模块测试文件@Test@Disabledapplication-unit-test.yaml
infra124136410✅
system8710288✅
bpm361945✅
ai43187🔴 86🔴 无(连 src/test/resources/ 都没有)

复现(Git Bash,在 ainb/ 下;@Disabled 必须行首锚定,否则会多数 2 个——VendorApiKeys.java 的 javadoc 里用 {@code @Disabled} 提到了它。⚠️ 别用 paste -sd+ | bc 求和,Git Bash 没有 bc):

bash
for m in enlev-module-{infra,system,bpm,ai}; do
  echo "$m  文件 $(find $m -path '*/src/test/*' -name '*.java' | wc -l) \
@Test $(grep -rhoE '^[[:space:]]*@Test\b' --include='*.java' $m/src/test | wc -l) \
@Disabled $(grep -rhoE '^[[:space:]]*@Disabled\b' --include='*.java' $m/src/test | wc -l)"
done

ai 的 @Disabled 数量接近 @Test 数量的一半,而且 86 个全是裸注解、 不带任何原因说明——想知道为什么被禁,只能逐个点进去看。

✅ 这 86 个已逐个归类完(2026-10-25),不再靠推断:

落在哪@Disabled 数是什么
29 个 *Tests.java(复数)81上游带来的厂商集成测试,必须联网 + 必须有真密钥。29 个文件全都挂着 @Disabled,一个不漏
3 个 *Test.java5AnthropicChatModelTest、TongYiImagesModelTest、AiBoChaWebSearchClientTest——同样是打厂商接口的

所以结论比原来那句「绝大多数」更强:86 个全是联网测试,没有一个是被禁掉的纯逻辑单测。 这也解释了为什么 mvn test -pl enlev-module-ai 一片绿灯说明不了什么—— 真正断言业务逻辑的那 101 个(187 − 86)才是有效网,而它们连 application-unit-test.yaml 都没有。

实际影响:改 ai 的代码,mvn test -pl enlev-module-ai 跑完一片绿灯说明不了什么。 AiModelFailoverService 这种有真实事故背景的组件,改完之后没有测试网兜着。

要在这里加 DB 测试,得先补 src/test/resources/application-unit-test.yaml (bpm / system / infra 三个模块都有现成的可以照抄),还要补 /sql/clean.sql 里对应的表。 参照 后端架构与模块依赖 里 BaseDbUnitTest 的说明。

但 VendorApiKeys 这个改造是对的,别退回去

那 10 处硬编码的真密钥已经全部改成从环境变量读了。

🔴 但别把这件事写成「HEAD 上 0 处 "sk-..." 字面量」——那个说法一次 grep 就能证伪(本页旧版本就是这么写的)。实测 grep -rn '"sk-' --include='*.java' src 在 enlev-module-ai 下有 29 处命中,准确的表述是分层的:

位置处数是什么
src/main1AiUtils.API_KEY_PLACEHOLDER = "sk-xxxx"——占位常量,用来识别「用户没填真 key」,不是密钥
src/test 的 *Tests.java12"sk-xxxx" 占位(其中 OpenAiImageModelTests 写成 SystemUtil.get("OPENAI_API_KEY", "sk-xxxx"))
src/test 的单测夹具16"sk-10"、"sk-test-only"、"sk-real-key-0123456789"、"sk-a-very-long-api-key-..." 这类假值

没有一处是真密钥。要复核就跑上面那条 grep,然后逐条看值——判断标准是「它能不能拿去调通厂商接口」,不是「它长得像不像 key」。

不过类注释自己就写明了:git 历史里那 10 个真值仍然存在,git log -p 能捞出来,而它们是上游作者的演示密钥、本仓使用者多半无权吊销。 详见怎么扩展 · 接一个新厂商平台里引的那段。

未证实 ​

读源码读到、但没跑起来验证的

  • 「同平台不同 embedding 模型的两个知识库会互相覆盖 simple_*.json」—— 我是把 buildSimpleVectorStore:775-776 的文件名拼法与 buildClientCacheKey:385 的 ArrayUtil.join(params, "_")(对 EmbeddingModel 走 toString(),即对象身份)对着读通得出的, 没有真的建两个知识库、用同平台不同模型、然后比对那个 JSON 文件。 如果某个 EmbeddingModel 实现重写了 toString() 且带上了模型名,缓存键与文件名的对应关系会变, 但文件名仍只按类名,所以覆盖这一点应该仍然成立——只是我没验。
  • 「SimpleVectorStore 支持 filterExpression」—— 已用 javap 核对 spring-ai 1.1.8 的字节码, 确认存在 SimpleVectorStoreFilterExpressionEvaluator 字段、doFilterPredicate 方法、 doSimilaritySearch 里的 Stream.filter。但没有真跑一次跨知识库检索看结果。
  • 「Redis 向量索引会在本地启动时被建出来」—— 由「三个 starter 都是实际依赖 + application-local.yaml 只 exclude 了 Qdrant 与 Milvus + initialize-schema: true」推出, 没有真的去 Redis 里 FT._LIST 看过。
  • 「13 个未接超时的平台目前走不到」—— 由 getDefaultChatModel 在 src/main 下零调用点推出 (这条是源码注释自己写的「已 grep 核实」,我也复核过)。 没有穷尽反射/SPEL/配置驱动这类不出现字面量的调用方式。
  • 86 个 @Disabled 里「绝大多数是联网集成测试」 —— ✅ 2026-10-25 已逐个归类,不再靠推断:81 个落在 29 个 *Tests.java(这 29 个文件全都挂着 @Disabled),5 个落在 3 个厂商客户端 *Test.java,没有一个是被禁掉的纯逻辑单测。分类表与复现命令见测试。
  • 13 个禁用菜单、34 条开启的 ai:* 按钮,查的是本地库与 ainb-init.sql(两边一致),不是生产库。

推理得出、没有取证的

  • 「换向量库最省事的做法是从 ai_knowledge_segment 重建索引」—— 按「正文在库里、向量可重算」推的,没有验证过分片正文是否 100% 落库 (写入流程中途失败的情况我没查)。
  • 「把 mcp.server.enabled 改成 true 就会免鉴权暴露端点」—— 按 Optional<Properties> + ifPresent 的判据推出,没有真的开一次打一发请求。 也可能 MCP 的自动配置在 enabled: false 时压根不出 properties bean,那样 ifPresent 就不会执行。 这一条的确定性明显低于本页其它结论。
  • 「AiModelFailureReason 的 QUOTA 先扫、CONFIG 后扫是刻意的顺序」—— 注释里写了「额度优先,逐字保住既有行为」,我据此认为顺序有意义, 但没有构造一个『外层带配置措辞、内层是额度响应』的异常实测过一次。
  • 「ai 的 34 个错误码是四个上游模块里最少」—— 数的是 new ErrorCode( 的出现次数, 如果有错误码是用别的方式定义的(比如循环生成),这个数会偏小。

相关页面 ​