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 是本项目加的 |
image | AI 绘画 + 绘画管理 | 上游 |
write | AI 写作 + 写作管理 | 上游 |
music | AI 音乐 + 音乐管理 | 上游 |
mindmap | AI 思维导图 + 导图管理 | 上游 |
「13 个子域」是错的,13 是 Controller 数
早期材料里出现过「ai 有 13 个子域」的说法。实测 controller/admin/ 下只有 7 个一级目录; 13 是 @RestController 的个数(有的子域拆了 admin / 多个 controller)。 数子域要 ls 目录,数端点要数注解,两者不是一回事。
本项目自研的 14 个文件(逐个列,便于 review 时定位)
| 路径 | 作用 |
|---|---|
api/model/AiModelApi · AiModelApiImpl | agent 拿模型、发起对话的唯一入口 |
api/model/dto/AiChatReqDTO · AiChatRespDTO | 跨模块的对话请求/响应 |
api/rag/AiRagApi · AiRagApiImpl | agent 拿知识库、检索、增删文档的唯一入口。本轮纯新增五个方法:带 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/AiApiKeyEncryptTypeHandler | API 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:
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:
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.timeout | 120s | :322 | 流式路径,量的是相邻分片间隔 |
Http.connectTimeout | 10s | :377 | 建连 |
Http.readTimeout | 300s | :390 | 同步 call 的读超时 |
readTimeout 是 300 秒而不是常规的 30 秒,:383 给了实测依据:
实测过「CHAT 单次 284 秒」(思考模式下思考字数达正文的 100~4500 倍)
所以「超时没生效」和「超时太长」是两件不同的事
- 走已接那 5 个平台:有超时,但读超时 300 秒——一次挂起的请求会占住 Tomcat 线程五分钟。
- 走未接那 13 个平台:完全没有超时。前提是有人给
getDefaultChatModel接上调用方, 目前src/main下零调用点,所以这条路走不到。
改超时前先确认自己在哪一档。
故障切换:三档分型,QUOTA 不切、CONFIG 切但锁 30 分钟
AiModelFailoverService 是本项目自研的核心之一。它的接口注释先给了统一语义:
- 默认使用「启用 + 密钥启用 + sort 排序最前」的模型;
- 模型不可用(禁用、密钥禁用、客户端构建失败、瞬时调用失败)时,按 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——没有异常、没有日志,只有结果不对。
三条边界(接口注释原文):
- 额度耗尽不切换 —— 判
MODEL_QUOTA_EXHAUSTED失败; - 任务内粘滞 —— 传了
taskKey的调用,任务内后续调用只用第一次成功选中的那个模型, 连候选链都不再进; - 流式首包后不切换 —— 前端已收到内容,撤不回来(这条早于裁定,保持不动)。
粘滞表的生命周期:
| 常量 | 值 | 理由(注释原文) |
|---|---|---|
TASK_PIN_TTL_MILLIS:115 | 30 分钟 | 「每次读到都会续期,所以『活跃中的任务』永不掉钉;掉钉的只有已经被放弃的任务」 |
TASK_PIN_PURGE_THRESHOLD:124 | 4096 | 「逐次调用都全表扫会把一个 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 逐字:
@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 等
它的实现:
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):
.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 三块配置也都在:
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 排除了其中两个的自动配置:
- 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 这个场景特有的:
- 要放过
${ENV_VAR}占位符。库里存的可能不是密钥本身,而是一个环境变量引用 (AiUtils.resolveSpringPlaceholders在用的时候才解析)。 引用不是秘密,加密它只会让运维在库里看不懂自己配了什么- 解密失败要能降级。存量数据是明文,上线那一刻如果直接抛异常, 所有模型调用会一起挂掉 —— 而这是个安全加固,不该把可用性搭进去。 所以解不开就按明文处理并告警,迁移脚本跑完告警自然消失
它防什么、不防什么,注释写得很清楚:
⚠️ 这一层防的是什么,不防什么:密钥写在
application.yaml里、跟着代码走。 所以它防的是数据库被单独拖走(备份泄漏、只读账号、SQL 注入读表), 不防拿到应用服务器或代码仓库的人。接口侧的泄漏由AiApiKeyRespVO的脱敏注解挡,两层各管一段。
② 接口脱敏——AiApiKeyRespVO:18:
@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 的端点注册成免鉴权:
@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 是关的:
mcp:
server:
enabled: false
name: enlev-mcp-server
version: 1.0.0
instructions: 一个 MCP 示例服务
sse-endpoint: /sseenabled: 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 定义了一个白名单:
/**
* 不传 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:
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_* 表 | 13 | ai_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),不是「禁用」
| 菜单 | id | status | deleted |
|---|---|---|---|
| AI 对话 / AI 绘画 / AI 写作 / AI 音乐 / AI 思维导图 | 792 816 825 829 831 | 1 | 1 |
| 聊天角色 / 聊天管理 / 绘画管理 / 音乐管理 / 写作管理 / 导图管理 | 806 811 817 821 826 832 | 1 | 1 |
工具管理(ai/model/tool/index.vue) | 855 | 0 | 1 |
菜单:活着的 5 个全开
| 菜单 | id | type | status |
|---|---|---|---|
| AI(目录) | 791 | 1 | 0 |
| └ 控制台(目录) | 793 | 1 | 0 |
| ├ API 密钥 | 794 | 2 | 0 |
| └ 模型配置 | 800 | 2 | 0 |
| └ AI 知识库 | 850 | 2 | 0(上一版记的是 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 自己那套页面。
接口清单
| 维度 | 实测值 |
|---|---|
| Controller | 13(全部在 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,本轮已修脚本并重跑对齐。
要准确数字就跑脚本,或者直接数注解。
怎么扩展
接一个新厂商平台
AiPlatformEnum(在enums/model/,实测 21 个值)加一个。- 在
AiModelFactoryImpl里加build*Api。这里要做决定:- 如果该平台的
*Api.Builder暴露了restClientBuilder/webClientBuilder⟹ 照「已接」那 5 个平台的写法接上超时(用javap核对,别猜)。 - 如果客户端是在
AiAutoConfiguration里内联构造的 ⟹ 本文件拿不到 builder, 只能去那边补超时;同时更新:393-410那段覆盖面注释,把它从「未接」挪到「已接」。
- 如果该平台的
- 三个
switch (platform)都要加分支::192(chat)、:238(image)、:283(embedding)。 EnlevAiProperties加对应的嵌套配置类(现有 16 个:Gemini:103…TongYi:342)。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 并带上原因,是集成测试缺凭据时的通行做法。
写新的联网集成测试时照抄这个模式,别再往字段初始化里塞密钥。
换向量存储
- 先给
AiKnowledgeDO加一列(比如vector_store_type),否则只能全局一刀切。 - 补迁移脚本,把存量知识库都标成
SIMPLE。 - 改
AiModelServiceImpl:471,按那一列分派到SimpleVectorStore/QdrantVectorStore/RedisVectorStore/MilvusVectorStore(AiModelFactoryImpl:365-375四个分支都已存在)。 - 去掉
application-local.yaml:10-11对应的 exclude,并把application.yaml:150-166的配置填对。 - 迁移向量数据:
SimpleVectorStore的落盘格式是它自己的 JSON (~/vector_store/simple_*.json),其它三个库各有各的 schema,没有现成的搬迁工具。 最省事的做法是从ai_knowledge_segment表重建索引(正文在库里,向量可以重算)。
换库之前先确认那个磁盘文件里没有「只此一份」的数据
ai_knowledge_segment 存的是分片正文,向量是可重算的。 但如果有分片只存在于 simple_*.json 而没落库(例如写入流程中途失败), 换库就等于永久丢掉它。迁移前先比对两边的分片数。
加一类失败分型
AiModelFailureReason加枚举值 + 一组*_MARKERS。- 决定它切不切——这是最要紧的一步,见故障切换那张表。
- 决定冷却多长,并在
AiModelFailoverServiceImpl加对应的 Map +isXxxActive判定。 - 特征串只收逐字实测到的原文或厂商公开错误码,不收裸词。
CONFIG_MARKERS的注释把这条纪律和它的代价都写了。 - 在
classify里决定扫描顺序——现有实现是 QUOTA 先扫全链、CONFIG 再扫全链, 两轮独立,不是「谁先命中谁赢」。改顺序会改变边界情况的判定。
坑与注意
静默 / fail-open
- 🔴 13 个 AI 菜单全部
status = 1(禁用),但 34 条ai:*权限按钮全开 ⟹ 端点仍注册、仍可用 token 直接调通。菜单禁用不是访问控制。 - 🔴
taskKey放循环内取 ⟹ 粘滞一次都不生效,两轮跑的不是同一个模型,无异常无日志。 - 🔴
SimpleVectorStore的落盘文件名只按 EmbeddingModel 类名区分 ⟹ 同平台不同模型的两个知识库互相覆盖对方的索引文件,重启后一方的向量消失。 (查询侧靠knowledgeId元数据过滤,不会串库——串的是磁盘。) - 🔴
AiModelFailureReason认不出来一律按TRANSIENT⟹ 一个新厂商的额度耗尽错误 会被当成瞬时故障、静默切到下一个模型。这是有意的 fail-open(注释里写了权衡), 但意味着接新厂商时必须同步补特征串。 - 🔴 冷却与粘滞都是纯内存态 ⟹ 进程重启即清零;多实例各自记忆,坏模型最多被白撞 N 次。
- 解密失败按明文处理(
log.warn后原样返回)⟹ 存量明文能继续用, 但「密钥换过了」这种情况只会得到一条 warn,然后被厂商以 401 拒绝。 AiApiKeyDO少写autoResultMap = true⟹ typeHandler 查询方向不生效, 读出密文当密钥发出去 ⟹ 401。getDefaultChatModel在src/main下零调用点 ⟹ 那 13 个「未接超时」的平台目前走不到;一旦有人接上调用方,超时缺口立刻变成真问题。application-local.yaml排除了 Qdrant 与 Milvus,没排除 Redis ⟹ Redis 向量索引会被建出来但没人用;Redis 没起或没 RediSearch 时启动失败,报错指向一个没在用的组件。- MCP 的
permitAll判据是 properties bean 在不在,不是enabled⟹ 把mcp.server.enabled改成true的那一刻,/sse等端点即免鉴权,无需再动安全配置。 buildCandidateChain两参重载不做粘滞 ⟹ ai 自己的聊天/会话/思维导图/写作四条链路 每次调用独立选模型(这是刻意的,但改口径时容易顺手改错)。- 上游两处 TODO 仍在:
AiChatMessageServiceImpl:92「后续优化下对话的 Prompt 整体结构」、:409「历史的知识库;历史的搜索,要不要拼接?」。 FileTypeUtils有两份(infra 与 ai 各一),改一份不影响另一份。
fail-closed
validateApiKey/validateModel/AiPlatformEnum.validatePlatform→ 密钥或模型不可用即抛。QUOTA分型 → 不切换,该次调用直接判MODEL_QUOTA_EXHAUSTED失败 (这是裁定 R-01 的要求:额度没了就算失败)。CONFIG/QUOTA的 30 分钟冷却 → 期间该模型不进候选链; 但改配置或禁用再启用会立刻解封(以update_time为指纹)。@SliderDesensitize(prefixKeep = 6, suffixKeep = 4)→ 接口返回的密钥中段被遮。LogRecordConstants→apiKey字段永不进操作日志。containsAnyMarkerInChain限深 10 层 + 显式处理自引用 cause → 分类工具不会把调用方栈溢出打挂。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 | @Disabled | application-unit-test.yaml |
|---|---|---|---|---|
| infra | 124 | 1364 | 10 | ✅ |
| system | 87 | 1028 | 8 | ✅ |
| bpm | 36 | 194 | 5 | ✅ |
| ai | 43 | 187 | 🔴 86 | 🔴 无(连 src/test/resources/ 都没有) |
复现(Git Bash,在 ainb/ 下;@Disabled 必须行首锚定,否则会多数 2 个——VendorApiKeys.java 的 javadoc 里用 {@code @Disabled} 提到了它。⚠️ 别用 paste -sd+ | bc 求和,Git Bash 没有 bc):
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)"
doneai 的 @Disabled 数量接近 @Test 数量的一半,而且 86 个全是裸注解、 不带任何原因说明——想知道为什么被禁,只能逐个点进去看。
✅ 这 86 个已逐个归类完(2026-10-25),不再靠推断:
| 落在哪 | @Disabled 数 | 是什么 |
|---|---|---|
29 个 *Tests.java(复数) | 81 | 上游带来的厂商集成测试,必须联网 + 必须有真密钥。29 个文件全都挂着 @Disabled,一个不漏 |
3 个 *Test.java | 5 | AnthropicChatModelTest、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/main | 1 | AiUtils.API_KEY_PLACEHOLDER = "sk-xxxx"——占位常量,用来识别「用户没填真 key」,不是密钥 |
src/test 的 *Tests.java | 12 | "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 个—— ✅ 2026-10-25 已逐个归类,不再靠推断:81 个落在 29 个@Disabled里「绝大多数是联网集成测试」*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(的出现次数, 如果有错误码是用别的方式定义的(比如循环生成),这个数会偏小。
相关页面
- Agent 模块总览 ——
AiModelApi的主要消费方 - Agent 对话与 ReAct 循环 ——
taskKey的生产方、粘滞失效的现场 - 用量与计费 —— 切换之后
model_id记的是哪个 - Agent 工作流引擎 ——
LlmNodeExecutor读node.configLong("modelId") - Agent 安全边界 —— 跨模块依赖红线
- bpm 域开发 —— 另一个「上游模块 + 少量定制」的对照样本
- infra 域开发 —— 测试网的另一端(123 文件 / 1344
@Test/ 12@Disabled) - system 域开发 —— 菜单禁用为什么拦不住端点
- 后端架构与模块依赖 ——
BaseDbUnitTest与application-unit-test.yaml - 数据库约定与表总览 —— 13 张
ai_*表在整库里的位置 - 测试策略与运行方式 —— 各模块测试密度对比
