技术深入

项目面试深度复盘:智枢知识库 & AgentForge 智能体编排引擎

智枢项目 · 面试复习笔记

🔴 核心


1. 大文件上传整体流程

问:大文件上传是怎么设计的?

前端用 SparkMD5 计算文件 MD5,以 5MB 为一片分片上传。每片到后端后直接转存 MinIO(路径 chunks/{md5}/{index}),同时用 Redis Bitmap 标记该分片已上传。全部传完后前端调合并接口,后端调 MinIO ComposeObject 在服务端直接合并分片,数据不经过 JVM 内存。合并完发 Kafka 事务消息异步触发文档解析和向量化。

追问 1:为什么分片大小选 5MB? MinIO 兼容的 S3 协议规定 multipart 除最后一片外最小 5MB,再小协议不支持。同时 5MB 在断点续传粒度和 HTTP 请求数量之间做了平衡——1GB 约 200 片,重传代价可接受,请求数也不会太多。

追问 2:分片顺序怎么保证?完整性怎么校验? 顺序靠 chunkIndex——合并时按序号升序排列 ComposeSource 列表。完整性分两层:每个分片上传后单独算 MD5 存 MySQL chunk_info 表;合并完 statObject 拿文件大小跟前端传的 totalSize 比对。

追问 3:幂等怎么保证? 三重兜底:Redis SETBIT 标记了 → 跳过;MySQL chunk_info 已有记录 → 跳过;MinIO statObject 分片物理存在 → 校正 Redis 后跳过。

追问 4:合并时服务挂了怎么办? 分片物理数据在 MinIO,元数据在 MySQL,状态标记在 Redis。只要合并没成功,三者都不会被清理。重启后前端重调合并接口即可。


2. 断点续传(Redis BitMap)

问:断点续传是怎么实现的?

核心是 Redis Bitmap。Key = upload:{userId}:{fileMd5},每个 bit 位代表一个分片的上传状态。上传前 GETBIT 检查跳过,上传后 SETBIT 标记。查询进度时用 GET 一次性取出整个 byte 数组,逐位解析出已上传分片列表,比逐片 GETBIT 减少 N 次网络往返。

追问 1:内存占用多少? 每个分片占 1 bit。1GB ÷ 5MB ≈ 200 片,200 bit ≈ 25 字节。10GB 文件(2000 片)也才 250 字节。如果用 Set 存分片序号,每个 int 4 字节,差了 30 倍以上。

追问 2:Redis 挂了怎么办? 预防层面——Redis 部署哨兵或集群保证高可用。兜底层面——先查 MySQL chunk_info 表 → 再调 MinIO statObject 验证 → 存在则校正恢复。上传功能不会因 Redis 挂掉而中断,只是暂时退化为慢路径。

追问 3:文件唯一标识为什么用 MD5? 用文件内容的 MD5 而非文件名——同名不同内容 MD5 必然不同,不同名相同内容 MD5 相同可走秒传。MD5 碰撞概率 2⁻¹²⁸,业务上可忽略。防御性加 totalSize 双重校验。


3. 秒传

问:秒传是怎么实现的?

两阶段判定。第一阶段(上传分片时):查 file_upload 表是否存在同 MD5 + 已完成记录,且 MinIO statObject 确认 merged 文件实际存在——满足条件直接返回 100% 进度,跳过所有分片上传。第二阶段(合并时):从原用户的 document_vectors 表复制文本分块、从 ES 复制向量数据,修改 userId/orgTag/isPublic 后重新写入,向量完全复用不调 Embedding API。

追问 1:秒传后原文件被删了怎么办? 秒传前必定 statObject 确认 MinIO 文件存在,不存在则降级为正常上传。

追问 2:A 用户秒传 B 用户文件后,ES 数据独立吗? 完全独立。向量数据以新 userId 重新写入 ES,两份数据各自享受独立的权限控制和配额。

追问 3:秒传还做 Embedding 预估吗? 预估 Token 和分块数直接复用原文件数据,但实际消耗标记为 0(因为没调 API)。用户能看到省了多少 Token。


4. Kafka 异步解耦

问:Kafka 在流程中起什么作用?为什么不用 @Async?

文件解析和向量化是大耗时操作,必须异步解耦避免阻塞 HTTP 响应。选 Kafka 三个原因:消息持久化落盘重启不丢;自带重试机制(1 次原始 + 4 次重试 = 5 次总尝试,全失败进死信队列);削峰填谷防止并发上传打爆 Embedding API 限流。@Async 有线程池满丢任务和重启丢消息的风险。

追问 1:消息里传什么?URL 过期怎么办? 传的是 MinIO 预签名 URL(有效期 1 小时)。消费者收到后立即下载为内存流处理。优化方向:改为传 bucket + objectKey,消费者自己生成下载链接,彻底消除过期风险。

追问 2:消息重复消费怎么办? 业务层幂等——消费前根据 fileMd5 + userId 查 document_vectors 表是否已有数据,已有则直接 ACK 跳过。

追问 3:为什么生产端用事务? 合并(写 MySQL 状态)和发消息必须原子——如果 MySQL 更新了但消息没发出,文件变成僵尸状态。executeInTransaction 包裹,要么都成功要么都回滚。Producer 端配了 acks=all + enable-idempotence=true。

追问 4:死信队列怎么处理? 5 次全失败进入死信主题 file-processing-dlt,保留原始消息。管理员后台可查看死信列表,支持一键重新投递或导出排查。


5. 混合检索 KNN + BM25

问:混合检索 KNN + BM25 具体怎么做的?

在一个 ES 请求里完成三段:KNN 向量召回(HNSW 近似搜索,recallK = topK × 30,内置权限 filter)→ match 关键词过滤(Or 算子,命中任意词即可)→ BM25 rescore 重排序(And 算子精排,全命中加分冲顶)。最终分 = 0.2 × 原始分 + 1.0 × 重打分。

追问 1:KNN 权重 0.2 贡献大吗? 贡献极小——KNN 分数量纲 0~1,BM25 是几十上百。KNN 的核心价值在召回阶段:把语义相关但关键词不完全匹配的文档也捞回候选池,提升候选池质量。排序交给 BM25。

追问 2:为什么 rescore 用 And 算子? And 要求所有查询词全部命中才给加分,目的是让"完全匹配"的文档绝对冲顶。没全命中的文档不会被剔除——前一步的 match(Or) 已经留在了候选集里,只是没有 rescore 加分排在后面。

追问 3:为什么不用 RRF(倒数排名融合)? 两个原因。架构上 RRF 需要两次独立查询后在应用层融合,多一次网络往返。业务上,RRF 把所有路平等对待,KNN 排第 1 和 BM25 排第 1 给一样的加分——但我们希望全命中关键词的文档能绝对冲顶,rescore 的超高分机制更适合这种精准匹配优先的场景。

追问 4:权限过滤怎么做? 三层 should 的 bool filter:term userId = 当前用户(自己文档)、term isPublic = true(公开文档)、terms orgTag in 用户有效组织标签列表(含层级展开的子标签)。KNN 和 query 两个阶段都加了同样的 filter。

追问 5:window_size 为什么是 30 倍 topK? 调参经验值。太小候选不够,太大计算开销线性增长但边际收益递减。topK=5 时 150 候选,rescore 开销可控。


6. WebSocket 流式对话

问:WebSocket 流式对话怎么设计的?

前端通过 /chat/{JWT令牌} 连接 WebSocket,握手阶段 JWT 鉴权,不合法直接关闭。连接成功后前端发 JSON 消息,根据 type 字段分发——发送消息、停止生成、重新生成、心跳保活。

用户发新消息的完整链路:限流检查 → 解析会话(前端没指定就用最近会话或自动创建)→ 检查会话是否忙 → 从 Redis 拉历史消息 → 保存用户消息和 AI 占位记录 → 发 accepted 事件通知前端"正在生成" → 混合搜索 Top5 文段 → 拼 Prompt(系统指令+检索上下文+历史对话)→ 用 WebClient 发 POST 到 LLM 的 chat/completions 接口,通过 bodyToFlux 订阅 SSE 流 → LLM 每吐一个字触发一次回调,追加到内存拼接器、更新 Redis、推 WebSocket 给前端 → 后台线程心跳检测流结束 → finalize 把消息状态从 loading 改为 finished。

停止机制:前端发 type:stop,后端在 ConcurrentHashMap 里标记该请求的 flag 为 true。后续 LLM 回调时第一步就检查 flag,已标记的直接丢弃 chunk。已生成部分作为最终回复保存。

追问 1:为什么选 WebSocket 而不是 SSE? 对话需要双向通信——发送消息、停止生成、重新生成。SSE 是单向的,停止需额外 HTTP 接口,更复杂。WebSocket 一条连接承载所有交互。

追问 2:多标签页同时打开同一会话怎么办? 当前用 userId 做连接池的 key,第二个标签页会覆盖第一个。已知问题,优化方向:改为 Map<String, List>,同一用户所有标签页都能收到推送。

追问 3:会话存在 Redis 里,7 天过期怎么实现? 五类 Key:用户会话列表 Set、会话元信息 String、消息索引列表 String、消息序号计数器 String、每条消息的 String。所有 Key 创建时设 7 天 TTL。每次新消息调 EXPIRE 续期,重新设为 7 天。7 天无活动自动清理。

追问 4:WebClient 的 bodyToFlux 是什么? WebClient 是 Spring 的 HTTP 客户端,相当于后端的 axios。LLM 返回的是 SSE 格式(一行行 data:…),bodyToFlux 把响应体转成流——不是等全部生成完一次性拿,而是每收到一行就触发一次回调。回调里解析 JSON 取 delta.content,追加到内存、更新 Redis、推前端。这就是打字机效果的原理。

追问 5:finalize 是什么意思? 流式过程中每条 AI 消息的 status 是 loading。finalize 是把消息状态从 loading 改为 finished,同时写入引用文档映射、更新会话标题和最后消息时间。不做的话,前端刷新页面后这条消息会一直显示"生成中"。

追问 6:停止生成后 LLM 还在生成,Token 怎么算? 当前版本通过标志位丢弃后续 chunk,没有显式 cancel WebClient 的 Flux 订阅,LLM 侧会继续生成白烧 Token。优化方向是拿到 subscribe() 返回的 Disposable 对象调 dispose() 中断底层 HTTP 连接。Token 结算按 responseBuilder 里实际累积内容估算,不会把丢弃的算进去。


7. 三层降级策略

问:简历写了三层降级,具体怎么设计的?

第一层:Embedding API 超时或异常 → HybridSearchService catch 后自动切换纯 BM25 全文检索。引入熔断器——连续 5 次失败熔断 30 秒,避免大量请求堆积在超时上。第二层:ES 集群不可用 → 回退纯 LLM 模式,Prompt 标注"本轮无检索结果",前端展示"知识库暂不可用"提示。第三层:LLM 不可用 → 返回明确错误信息和错误码给前端,记录完整日志等人工介入。

追问 1:降级用户能感知到吗? 第一层降级前端收到 retrievalMode:“TEXT_ONLY” 标记,基本透明。第二层降级前端展示"知识库暂不可用"提示条。第三层直接弹错误提示。

追问 2:如果降级到纯 LLM,答案质量会怎样? 坦诚说会打折扣——LLM 只能基于训练数据回答,无法引用具体公司文档。但总比完全不回复强。这种场景说明 ES 出大问题,会触发运维告警。


8. RBAC + ABAC 权限模型

问:RBAC + ABAC 怎么协作的?

RBAC 管接口级权限——Spring Security 的 hasRole 注解,ADMIN 能访问管理后台,USER 只能访问普通接口。这是"能不能做"。ABAC 管数据级权限——每个用户挂组织标签,每个文档也绑定了组织标签。搜索时 ES 查询拼接三层过滤:文档 userId=当前用户、isPublic=true、orgTag 落在用户标签集合里。这是"能看到什么"。

权限信息(角色、标签、用户 ID)全部刻在 JWT 里。签发时从 MySQL 读一次,后续请求直接从 JWT 解,不查库。

追问 1:组织标签有层级吗? OrgTagCacheService 维护父子树,获取用户有效标签时递归展开所有子标签。比如用户标签"技术部",展开后变成[“技术部”,“技术部/前端组”,“技术部/后端组”]。父组织管理员天然能看到所有下级文档。

追问 2:管理员改权限后,已签发 JWT 是旧权限怎么办? Access Token 有效期 1 小时,影响窗口有限。敏感操作(如封禁)可在关键接口加实时查库校验。


9. JWT 双令牌 + Redis 黑名单

问:为什么用 JWT 双令牌 + Redis 黑名单?这不把无状态变有状态了吗?

纯 JWT 是签出去就收不回来——无法主动让 Token 失效。但业务需要登出和封禁功能,所以必须引入有状态存储。校验分两层:第一层物理校验——确认签名合法、有效期没过;第二层逻辑校验——查 Redis 黑名单,tokenId 在黑名单里就拒绝。

JWT 的优势不是"少查 Redis",而是"少查 MySQL"——用户信息全在 Token 里自包含,验签通过就能直接用,不需要每次请求查数据库重建用户上下文。Redis 只存黑名单标记位,每次请求做一次 GET 判断是否被主动失效。用极小的有状态代价换来了主动管控能力。

我们用的是双令牌机制。Access Token 管日常请求,有效期 1 小时。Refresh Token 只管刷新,有效期 7 天,存在前端不参与日常请求。

续期分三层。第一层主动预刷新——Access Token 还剩不到 5 分钟时,后端自动签发新 Token 通过响应头返回,前端无感。第二层宽限刷新——Token 已经过期但不超过 10 分钟,后端仍允许用它换新 Token。第三层长 Token 兜底——超时太久只能拿 Refresh Token 请求刷新接口,后端签发新 Access Token 和新 Refresh Token,同时旧 Refresh Token 加入黑名单失效。Access Token 设有 4 小时绝对过期——即使一直续期,签发超过 4 小时也必须用 Refresh Token 重新换。

这样设计的好处是:正常使用无感续期,短期超时自动恢复,长期离线安全兜底。

追问 1:Access Token 一直续期不就永不过期了吗? 设置了绝对过期时间——签发后 4 小时内必须用 Refresh Token 换新。同时实现了 Token Rotation——每次刷新签发新 Refresh Token 并使旧 Token 立即失效。检测到用已失效的旧 Token 来刷新 → 该用户所有 Token 全部废掉,强制重新登录。

追问 2:Redis 不可用时黑名单怎么办? 选 AP——优先保证可用性,验签通过就放行,牺牲黑名单的实时一致性。兜底:JWT 1 小时自然过期 + 敏感操作查 MySQL 校验。这是分布式系统中可用性和安全性的权衡——“系统全面瘫痪"比"一个封禁用户暂时绕过"严重得多。

追问 3:JWT 相比传统 Session 还有什么优势? 三层优势。内存层面——Redis 只存 tokenId 标记位,不存完整用户对象,内存占用极小。性能层面——用户信息自包含,验签纯计算不需查数据库。扩展层面——多实例部署时每台服务器本地验签就行,认证变成纯计算而非查询,水平扩展零成本。


10. Token 预扣-结算-回滚

问:Token 配额的预扣-结算-回滚怎么实现的?

调用 LLM 前,先把系统提示词、历史对话、检索上下文拼成完整 messages,用 Token 估算器预估 prompt token 数——中文约 0.95 token/字符,英文约 0.3 token/字符。预估值加上配置的最大回复 token 数(默认 2000),通过 Redis 原子操作从用户日配额里预扣。

用户余额(MySQL 存储)在这阶段只做权限检查——判断余额够不够,不真正扣减 MySQL。真正的扣减在 LLM 回复完成后的 settle 阶段执行,用 @Transactional 保证原子性。如果服务在结算前崩溃,MySQL 里的余额一分钱不会少。

结算时用 LLM 返回的 usage 字段(prompt_tokens + completion_tokens)做精确结算,预扣多了退还、少了补扣。如果 LLM 调用前就异常(如网络超时、API 错误),执行 abort 全额回滚。

追问 1:预估不准怎么办? 基于主流 tokenizer 的统计规律,误差 ±15%。预扣用估值,最终结算用 API 返回的精确 usage——多退少补,预估不准不影响最终计费准确性。

追问 2:日配额和服务崩溃的关系? 日配额存 Redis 滑动窗口,预扣时计数器已增加。极端崩溃可能当天配额临时多占,但配额 Key 有 TTL 当天结束时自动清零。用户 MySQL 余额不受影响。

🟡 中等


11. MinIO ComposeObject 合并

问:composeObject 合并是什么?性能如何?

MinIO 的服务端对象合并接口——传入多个源对象路径和一个目标路径,MinIO 在服务端直接拼接,数据流不经过应用服务器。1GB 文件在本地 SSD 环境下合并耗时约 2~3 秒。不是 OS 内核级 sendfile/mmap 零拷贝,准确说是"服务端免中转合并”——核心价值是消除了应用层的 I/O 瓶颈和 OOM 风险。

追问:10GB 2000 片能一次合吗? 单次 composeObject 上限约 500~1000 个源。超过阈值走分段合并——每 100 片先合成一个中间段文件,再把中间段文件做第二次 composeObject 合成最终文件。两次操作都在 MinIO 服务端完成。


12. 文档解析与分块

问:文档解析用了什么技术?分块怎么做的?

Apache Tika 做格式自动检测和文本提取。PDF 用 PDFBox 逐页提取,通过统计多页前三行和后三行高频文本自动识别并移除页眉页脚。分块分两层——外层 1MB 缓冲区(Tika 流式喂数据,攒够 1MB 触发一次处理防 OOM),内层四级递进切分:按段落切 → 段落超长按句子切 → 单句超长用 HanLP 按词切 → HanLP 异常按字符硬切兜底。每个子切片带页码和锚点文本。

追问 1:IK 分词器和 HanLP 什么区别? IK 是 ES 插件,负责搜索时的索引分词(ik_max_word 细粒度索引)和查询分词(ik_smart 粗粒度匹配)。HanLP 是 Java 库,负责预处理——分块时判断句子边界、计算 Token 预估量。一个管搜索引擎侧,一个管服务端预处理侧。

追问 2:ik_max_word 和 ik_smart 的区别? ik_max_word 细粒度切分——“中华人民共和国"切成"中华人民共和国/中华人民/中华/华人/人民/共和国”,索引时最大化覆盖。ik_smart 粗粒度切分——只切"中华人民共和国",搜索时更精准。ES mapping 里 textContent 配置了 analyzer=ik_max_word、search_analyzer=ik_smart。


13. Embedding 模型选择

问:向量化模型怎么选的?为什么是 2048 维?

市场调研测试了多个平台,最终选阿里百炼 DashScope 的 text-embedding-v4。中文语义向量生成质量好,价格较低。2048 维在同价位模型中兼顾了语义区分度和检索性能——维度越高区分度越好但检索越慢,2048 是工程上的平衡点。

追问 1:换一个 Embedding 模型怎么切换? 三步:Admin 后台新增 Provider 配置 → ES 新建匹配维度的索引(dims 改为新维度)→ 后台将新 Provider 设为 active。旧文件如需迁移触发 reindex。不用改代码、不用重启。

追问 2:HNSW 是什么? 分层可导航小世界图,ES dense_vector 的近似最近邻算法。建多层图结构,从顶层流式向下搜索,复杂度 O(log N) 而非暴力搜索 O(N)。精度损失通常小于 5%,配合 BM25 兜底完全可接受。


14. ES refresh_interval

问:ES 数据写完能立刻搜到吗?refresh_interval 是什么?

不能立刻搜到。ES 写入后数据先到内存缓冲区,需等 refresh 触发才能刷成文件系统缓存里的可搜索段。refresh_interval 就是这个刷新间隔——默认 1 秒,生产配了 3 秒。延长间隔等于攒批建段:段数量少 → 搜索扫的段少 → 后台 merge 开销小 → 写入吞吐高。代价是实时性降低最多 3 秒。

追问:为什么不是每次写入都立刻刷新? 每次刷新创建一个新段。写入 10000 条就产生 10000 个段的碎片——搜索时要扫 10000 个段,后台 merge 把所有 CPU I/O 吃满。攒批把多次写入合并到一个段里,段数量少、性能高。


15. 模型 Provider 可插拔

问:模型 Provider 怎么做到可插拔的?换 LLM 要改代码吗?

三层设计:默认配置(代码硬编码 DeepSeek、通义千问等作为兜底)→ MySQL 覆盖(model_provider_configs 表存储管理员保存的配置)→ volatile 变量做本地缓存。Spring 启动时 @PostConstruct 从 MySQL 加载并覆盖默认值。管理员后台切换时写 MySQL 后立调 reloadSettings() 刷新缓存,后续调用自动走新 Provider。API Key 用 AES-256-GCM 加密存储,解密后拼到 HTTP 请求头。

追问:多实例部署缓存怎么同步? Redis Pub/Sub 广播配置变更——Admin 修改后 publish,所有实例 subscribe 后 refresh。另有 30 秒定时轮询兜底。

🟢 低优先级


16. 限流设计(已被题 7/10/13 覆盖)

基于 Redis ZSet 滑动窗口。维度:注册(IP,20次/10分钟)、登录(IP,30次/分钟)、聊天消息(用户,30次/分钟)、LLM 请求(用户+全网,分钟+日双层)、Embedding(请求次数+Token 用量双层)。所有限流配置通过 rate_limit_configs 表运行时动态修改,不重启生效。ZSet 滑动窗口消除了 INCR+TTL 固定窗口的"边界突发"问题。


17. 邀请码与注册

支持两种模式:INVITE_ONLY(仅邀请码注册)和 OPEN(开放注册)。邀请码一次性使用,注册后标记 used=true 绑定用户。Admin 后台可批量生成邀请码,支持设置过期时间。


18. 微信支付

微信支付 API V3 Java SDK。流程:前端选套餐 → 后端调 JSAPI 下单 → 用户支付后微信回调 payNotifyUrl → 验签 + 更新订单 + 增加 Token 余额。幂等靠 trade_no 唯一 + 状态机(NOT_PAY → PAYING → SUCCEED 不可逆)。回调丢失:前端主动调同步接口 + 定时任务查未支付订单补单。


19. 重新生成机制

前端发 type:chat.regenerate,后端找到最后一条 AI 回复对应的用户问题,清空 AI 回复内容后重新走搜索+LLM 流程。新生成回复覆盖旧记录(Redis 覆盖写入)。仅允许重生成最后一条 AI 回复。


20. ComposeObject 分段合并(边缘场景)

超过上限走分段合并:每 100 片先 composeObject 合成一个中间段文件,再把约 20 个中间段文件做第二次 composeObject 合成最终文件。两次操作都在 MinIO 服务端完成。

🧪 场景题


场景题 1:秒传的 MinIO 文件被运维误删

运维清理 MinIO 时误删了 merged/ 下旧文件。新用户上传同 MD5 文件走了秒传判定——MySQL 有记录,但 MinIO 文件不存在。代码会怎么处理?如果频繁发生怎么优化?

回答:

代码已经处理了——instantUploadDetermination 里 statObject 抛异常 → catch → return false → 降级为正常上传流程。同时 secondStageInstantUploadDetermination 在合并阶段发现不是秒传记录也会跳过。用户最终走正常分片上传,只是多花了一些时间。

优化方向:加秒传成功率的监控指标——同一个 MD5 的秒传频繁失败,说明 MinIO 文件可能丢了。可以设阈值(如连续 3 次失败)触发自动告警,或自动清理 MySQL 里对应的"脏"file_upload 记录,同时保留 document_vectors 和 ES 数据作为重建源。


场景题 2:并发上传同一文件

用户在两个标签页同时上传同一个文件,两个请求同时进入 uploadChunk() 和 mergeChunks(),会发生什么?怎么保护?

回答:

上传分片阶段问题不大——Redis SETBIT 本身幂等,重复上传同一个分片不会出错。MySQL file_upload 表的 (file_md5, user_id) 联合唯一索引也能防止重复记录。

但 merge 阶段有冲突——两个请求同时调 composeObject 到同一个 merged/{md5} 路径,后完成的会覆盖先完成的。更严重的是,两个 Kafka 消息都会发送——消费者会重复解析和向量化同一个文件。

解决方案:在 merge 入口加分布式锁。SETNX merge:{fileMd5}:{userId},拿到锁的才能执行合并,另一个直接返回"该文件正在处理中,请稍后刷新"。锁设合理的超时时间(如 30 秒)防止死锁。消费者侧已有幂等保护——document_vectors 表根据 fileMd5+chunkId 联合唯一,重复写入会被跳过。


场景题 3:Embedding 模型切换导致搜索结果异常

管理员强行把 Embedding 从 2048 维阿里云模型切换为 1024 维 BGE 模型。新文件用 1024 维向量,旧文件还是 2048 维。KNN 搜索时会发生什么?安全切换怎么设计?

回答:

ES 的 dense_vector 字段 dims 是固定的——写入 1024 维向量到 2048 维 mapping 会直接报错。即使同时改了 mapping,旧文档 2048 维向量和新文档 1024 维向量也无法在同一 KNN 查询中比较。

安全切换方案:① 新建索引 knowledge_base_v2(dims=1024),旧索引保持只读。② 新文件写新索引。③ 搜索时根据文档的 model_version 字段决定搜哪个索引,或在 ES 里同时搜两个索引后合并结果。④ 后台任务逐步将旧文件 reindex(重新向量化)迁移到新索引。⑤ 全部迁移完后下线旧索引。

代码里的 requiresEmbeddingReindex 方法已经检测到模型/维度变化会抛异常阻止直接切换——这就是第一道防线,但需要补充索引隔离和灰度迁移机制。



AgentForge 智能体编排引擎


目录

编号类别问题核心
Q1架构DDD 六层架构 + 整体设计
Q2配置策略路由树
Q3配置YAML 配置 → 智能体全过程
Q4设计“零代码改动"如何实现
Q5适配ADK 与 Spring AI 适配
Q6编排三种编排类型
Q7工具MCP 三种模式统一管理
Q8架构DDD 如何落地
Q9推送SSE + RxJava3 流式推送
Q10业务Draw.io 三阶段拆分
Q11插件插件加载与执行
Q12并发原子整数(AtomicInteger)设计
Q13对比两个项目技术栈跨度
S-A场景增量修改
概念基础Agent / MCP / Skills / RxJava / 注解体系

一、架构与设计


Q1:这个项目的整体架构是什么样的?

主回答:

这个项目采用领域驱动设计(DDD)的六层架构。从上到下依次是:

接口契约层(API 层)——只定义服务接口和数据传输对象(DTO),不写任何实现。 触发器层(Trigger 层)——也就是控制器(Controller),负责接收 HTTP 请求、校验参数、调用领域服务、封装统一响应返回。 领域层(Domain 层)——项目的核心,所有业务逻辑都在这里,同时定义服务接口让外部来实现。 基础设施层(Infrastructure 层)——负责数据库、缓存、外部网关等技术实现,去实现领域层定义的接口。 类型层(Types 层)——放公共的异常类、响应码、常量,被多个层共用。 应用启动层(App 层)——Spring Boot 的启动入口和配置,负责串联各层,在容器就绪后触发智能体装配。

关键设计是依赖倒置:领域层定义接口,基础设施层去实现。这样领域层不依赖任何框架,换数据库、换缓存方案只改基础设施层,领域层零改动。好处是各层职责单一、通过接口解耦,扩展性和可维护性都提高了。


追问 1:这几层的依赖关系是怎样的?

应用启动层依赖所有层,因为它是项目入口负责串联。

触发器层依赖接口契约层和领域层——实现 API 层定义的接口,同时调领域层服务处理业务。

领域层只依赖类型层,不依赖 Spring、不依赖 MyBatis、不依赖任何具体框架。

基础设施层依赖领域层——去实现领域层定义的接口。

类型层是基础,被领域层、触发器层、应用启动层依赖。

接口契约层最独立——不依赖类型层也不依赖领域层,只定义对外契约。外部调用方看到的只有接口和 DTO,完全不感知内部实现。


追问 2:依赖倒置具体怎么体现?

领域层定义了聊天服务接口(IChatService),里面有处理消息的方法。如果将来要接入数据库,领域层会先定义一个智能体仓库接口,声明增删改查方法,基础设施层再用 MyBatis 去实现。领域层只认接口不认 MyBatis。将来换 MyBatis-Plus 或 JPA,领域层一行代码都不用改。


追问 3:一个请求的完整链路是怎样的?

启动阶段:应用启动层的自动配置类监听容器就绪事件,读取 YAML 里所有智能体配置,交给领域层的装配服务。装配服务走一条策略链,逐步构建 API 对象 → 对话模型(ChatModel)→ 智能体列表 → 编排工作流 → 最终生成运行器(InMemoryRunner),以智能体 ID 为名注册到 Spring 容器。

运行阶段:用户请求到触发器层的控制器 → 控制器调领域层的聊天服务 → 聊天服务按智能体 ID 从容器取出运行器 → 调运行器的异步执行方法,拿到事件流(Flowable)。非流式就阻塞收集完一次性返回,流式就通过服务端推送事件(SSE)逐个推给前端。


追问 4:当前分层有什么不足?

当前基础设施层还比较薄——MCP 客户端、工具注册、插件加载实际放在领域层,因为它们是纯业务逻辑暂不需要数据库。但这导致领域层的依赖列表里出现了 Spring AI 和 ADK。从最严格的 DDD 标准看,领域层应该只依赖 Java 标准库。如果要改进,可以把智能体构建拆成两部分——领域层定义"装配什么”(接口和配置模型),基础设施层实现"怎么装配"(真正调 Spring AI 和 ADK 的 API)。

但对当前项目规模来说,这会增加不少复杂度,目前是一个务实的折中。


Q2:这个项目里的"策略路由树"是什么?为什么不用 if-else 或责任链?

主回答:

策略路由树是把智能体的装配流程拆成五个独立的节点,每个节点只做一件事,做完之后通过 get() 方法把控制权交给下一个节点,形成一条链式的装配流水线。

不用 if-else 的原因:if-else 会把所有节点的逻辑写在一个方法里——构建 API、构建模型、构建智能体、编排工作流全挤在一起,这个方法会非常长,而且加一个新步骤就得改这个方法,违背开闭原则。

不用经典责任链的原因:责任链模式里每个节点判断"我能处理吗"——能处理就终止,不能就传给下一个,任何节点都有权终止。但装配流程每一步必须执行,步骤之间有严格的先后依赖。这不是平级关系,而是流水线上环环相扣的上下游。

1
2
3
4
5
RootNode → AiApiNode → ChatModelNode → AgentNode → AgentWorkflowNode
                                                   ↙        ↓        ↘
                                        SequentialAgentNode  ParallelAgentNode  LoopAgentNode
                                                   ↘        ↓        ↙
                                                     RunnerNode

追问 1:每个节点的 get() 方法和 Spring 依赖注入怎么结合?

分两种情况。静态路由:大部分节点下一个节点固定不变,直接通过 @Resource 注入,get() 里直接返回注入的字段。动态路由:AgentWorkflowNode 下一个节点不固定——根据 YAML 配置的工作流类型,通过 SpEL 从容器动态获取。三个子节点(串行/并行/循环)属于回环路由——它们的 get() 全部返回 AgentWorkflowNode,形成逻辑循环。循环终止条件是工作流全部处理完,路由到 RunnerNode。


追问 2:如果要新增一种工作流类型(比如条件分支),需要改哪些地方?

改四处:新建条件分支节点类 + AgentTypeEnum 枚举加一个值 + AgentWorkflowNode 的 get() 加一行路由 + YAML 配置里新增 type。已有的串行/并行/循环三种节点完全不受影响。


追问 3:这个树和经典责任链模式到底有什么区别?

三个核心区别:第一,责任链节点平级可选终止,策略路由树层级必须执行不可跳。第二,责任链靠节点自己判断终止,策略路由树靠路由决定终止。第三,扩展一个改的是插入位置的前后节点引用,扩展另一个改的是上游节点的 get() 方法。责任链像传话游戏,策略路由树像工厂流水线。


Q3:YAML 配置是怎么变成可运行的智能体的?

主回答:

分两个阶段。读取阶段:Spring Boot 启动时,属性类上的 @ConfigurationProperties 注解指定 YAML 前缀,Spring Boot 自动按字段名把 YAML 内容映射到 Java 对象。配置类上的 @EnableConfigurationProperties 激活这个绑定过程。

装配阶段:自动配置类监听容器就绪事件,读 YAML 转成的配置对象列表,交给装配服务。装配服务从默认工厂拿头节点和空白上下文,从头节点开始走策略链——五个节点依次构建 API 对象 → 对话模型(含工具回调)→ 智能体列表 → 编排工作流 → 最终生成运行器,以智能体 ID 为名注册到 Spring 容器。


追问 1:如果配置了两个智能体应用,它们的运行器怎么隔离?

每个配置对应的策略链都有独立的上下文和注册对象,各自以不同的智能体 ID 注册到容器。运行时按 ID 精准获取,天然隔离。


追问 2:YAML 里智能体指令中的 {analysis_result} 是怎么工作的?

这是 ADK 的输出键(outputKey)机制。上游智能体设好 outputKey,下游的指令里用大括号引用同一个 key,ADK 运行时自动替换为上游的实际输出内容。


追问 3:如果把 YAML 配置改成存数据库动态加载,需要改多少?

核心改动点:属性类改为从数据库读取 + 增加配置变更监听 + 旧运行器的优雅下线。策略链和聊天服务基本不用改。


Q4:简历里多次强调"新增应用无需写 Java 代码",怎么做到的?

主回答:

核心在于 YAML 配置驱动 + 工厂模式 + 动态注册三层解耦。

第一层:智能体定义全部 YAML 化——名称、指令、输出键都在配置文件里,装配时自动构建成智能体对象。第二层:工具接入通过工厂按类型分发,YAML 里声明工具类型和连接参数即可。第三层:装配结果以智能体 ID 为名动态注册到 Spring 容器。

新增一个场景,三层都只需要改 YAML,不用写 Java。局限是条件路由、私有协议工具接入、跨智能体复杂数据转换这三种场景 YAML 表达不了,必须写代码。


二、ADK 与 Spring AI 适配


Q5:ADK 和 Spring AI 为什么要适配?怎么做的?

主回答:

这个项目的智能体运行依赖两个框架协作:ADK 负责编排和会话管理——控制哪个智能体先跑、中间结果怎么传递、对话历史怎么存;Spring AI 负责底层大模型调用和工具执行。

两个框架的消息格式不兼容——ADK 用自己的一套请求和响应对象,Spring AI 用另一套。所以需要适配层来做翻译。

具体做法:自定义适配器类,继承 ADK 要求的基类(BaseLlm),内部持有 Spring AI 的对话模型(ChatModel)引用。在装配阶段构建每个智能体时,把对话模型包进适配器再传入。运行时 ADK 把请求交给适配器 → 适配器把 ADK 请求转成 Spring AI 的提示词格式 → 调对话模型 → 把 Spring AI 响应转回 ADK 格式返回。


追问 1:响应式流(Flux)怎么转成事件流(Flowable)?背压策略怎么处理?

在流式路径中,Spring AI 返回的是响应式流(Flux),ADK 需要的是事件流(Flowable)。转换用的是手动创建方式(Flowable.create)——手动订阅 Flux,每收到一块数据转成 ADK 格式后发射(onNext),出错了发错误信号(onError),全部收完发完成信号(onComplete)。

背压策略选的缓冲(BUFFER)——因为大模型流式输出速率很低(一秒几十个 token),下游消费不可能跟不上,缓冲区里最多攒几个小块,不存在撑爆内存的风险。选它是因为最简单且不丢数据。


追问 2:为什么要继承基类(BaseLlm)而不是组合?

ADK 的智能体构造器里接收模型的参数类型是基类(BaseLlm),不是接口。必须继承它才能传进去,这是框架硬约束,不是设计偏好。


追问 3:换大模型需要改什么?

当前实现下,启动前只需改 YAML 配置文件里的三个值——API 地址、密钥、模型名称。适配器包装的是对话模型接口(ChatModel),不是具体实现,只要新模型兼容 OpenAI 的 API 格式,对话模型换一个 Bean 就行,适配器代码一行不用动。


追问 4:适配层怎么处理 Spring AI 的工具调用?

工具调用完全由 Spring AI 内部完成,适配层不参与。大模型流式返回的每个数据块里,delta 字段要么是普通文本要么是工具调用标记,不会同时有。Spring AI 的对话模型收到第一个块就能判断——如果是工具调用,把后续块在内部缓冲、拼成完整请求自己执行、把结果喂回模型、再把最终回复返回。适配层只看到最终文本回复,拿过来转成 ADK 格式即可。


追问 5:流式调用和同步调用在适配层里的处理路径有什么不同?

适配器的入口方法接收一个布尔值决定走哪条路径。同步路径:调消息转换器把 ADK 请求转成 Spring AI 提示词 → 调对话模型的 call 方法拿到完整响应 → 转成 ADK 格式 → 包成单个元素的流返回。流式路径:同样的第一步转换 → 调流式对话模型的 stream 方法拿到响应式流 → 手动订阅,逐块转格式发射 → 全部收完发完成信号。两条路径共享同一个转换器,只是底层调的方法不同(call vs stream)。


追问 5a:数据发射到哪儿了?完整链路是怎样的?

适配器发射的数据回到 ADK 的编排引擎。引擎根据工作流类型决定下一步——串行就把响应写会话状态给下一个智能体读,并行就等其他分支。同时每个事件也通过管道传到外层——ChatService 阻塞收集(非流式)或 Controller 通过 SSE 推前端(流式)。


三、工作流编排


Q6:三种编排类型(顺序、并行、循环)分别怎么实现的?

主回答:

三种编排都直接用的 ADK 框架自带的原生智能体类——串行(SequentialAgent)、并行(ParallelAgent)、循环(LoopAgent),没有继承修改它们。

我们做的工作是装配——在装配阶段,工作流编排节点根据 YAML 配置的 type 路由到对应子节点。子节点从智能体映射表按名字取出子智能体列表,调 ADK 原生构建器创建编排智能体,构建完成后放回映射表供后续嵌套引用。

可以嵌套——因为每个编排智能体构建完后以它的名字放回映射表,后面的工作流可以引用前面构建的编排智能体作为自己的子智能体。


追问 1:任意嵌套在代码层面怎么实现的?

核心机制是自底向上构建、把构建结果放回同一个映射表。先构建普通智能体放入映射表,工作流节点处理时从映射表取出子智能体包装成编排智能体,再放回映射表。后面的工作流就能引用前面构建的。YAML 配置里声明顺序必须和构建顺序一致。


追问 2:怎么检测循环依赖?

当前没有检测,可以加拓扑排序——把所有编排智能体的引用关系建成有向图,Kahn 算法做拓扑排序,如果存在环就说明循环依赖,启动时抛异常。


追问 3:并行智能体的结果怎么汇总?

由 ADK 框架处理——等所有子智能体执行完成后收集所有输出。汇总方式取决于配置,典型场景是多数据源并发搜索,最终拼接或选最优结果。


追问 4:循环智能体会不会死循环?

两层保护:第一层是 YAML 里配置的最大迭代次数(maxIterations),达到就强制退出。第二层是智能体自身的退出逻辑——指令里要求返回"是否继续"标记,ADK 检测到退出标记就停止。两层叠加:智能体主动退出 + 最大次数兜底截断。


追问 5:并行智能体之间能共享上下文吗?

当前设计各自独立执行。需要共享时,可通过上游智能体把公共数据写入 ADK 的会话状态,并行的各个智能体从会话状态读取。原则是并行智能体之间不应有数据依赖——如果有依赖就该用串行。


四、MCP 工具与 Skills


Q7:MCP 工具三种模式(远程 SSE / 命令行 Stdio / 本地 Bean)是怎么统一管理的?

主回答:

通过工厂模式统一管理。定义了一个工具创建接口,三种模式各自实现。工厂类检查 YAML 配置里哪个字段有值——写了远程配置就走远程服务,写了命令行配置就走命令行服务,写了本地配置就走本地服务。每种服务负责构建自己的工具回调(ToolCallback),最终所有回调汇总后注入对话模型。

这个流程发生在装配阶段的对话模型构建节点:遍历 YAML 里的工具列表,每一项调工厂分发,拿到回调汇总到一起,注入对话模型。


追问 1:本地模式的 Bean 怎么自动变成工具回调?

用的是 Spring AI 的注解扫描——在类方法上打 @Tool 注解描述工具名称、参数和功能。Spring AI 自动把这个 Bean 包装成工具回调提供器。装配时根据 YAML 里填的名称从 Spring 容器取出,调它获取全部回调。


追问 2:如果命令行子进程挂了怎么办?

下一次工具调用时连接断开,MCP 客户端抛异常。Spring AI 框架捕获后把错误信息作为工具结果返回给大模型,大模型告知用户当前工具不可用。可加重连逻辑——失败时尝试重新初始化连接,重试两三次。


追问 3:工具调用死循环了怎么处理?

两层保护。第一层是 Spring AI 框架的最大工具调用轮次限制(默认 10 次),超了就截断。第二层是编排层面的最大迭代次数,循环智能体内部死循环也会被兜底截断。


追问 4:Skills 和 MCP 都是工具接入,有什么区别?

MCP 接入的是外部成熟服务——功能已固定,协议标准化了连接和调用。Skills 是给模型配一本操作手册加脚本——模型按需读手册,按手册指导自己写代码或调脚本,核心资产是领域知识。两者互补:MCP 适合"调外部现成功能",Skills 适合"模型需要领域知识来自己动手"。


追问 5:如果要接入 GitHub 工具服务端,需要写 Java 代码吗?

不需要。只在 YAML 配置的工具列表里加几行配置——指定类型和连接地址。工厂自动创建 MCP 客户端、连接服务端、发现工具列表、生成回调。整个过程不需写任何 Java 代码。


五、DDD 落地


Q8:DDD 在这个项目里怎么落地的?

主回答:

DDD 在项目里的落地做了两件事。

第一是分层。把整个项目拆成六层——接口契约层、触发器层、领域层、基础设施层、类型层、应用启动层。每一层只做自己的事,层与层之间通过接口解耦。核心是领域层定义接口,基础设施层去实现(依赖倒置)。

第二是领域层内部的类按 DDD 规范分类。分了三类:实体——有唯一标识的对象,比如聊天命令实体,靠用户 ID 和智能体 ID 区分不同请求。值对象——没有独立标识、靠属性值判断是否相同的对象,比如配置表值对象,它只是 YAML 配置的内存映射。领域服务——负责业务逻辑的类,比如装配服务负责把配置变成运行器。


追问 1:领域层真的完全没有框架依赖吗?

当前项目没有做到完全零依赖——领域层的依赖配置文件里引用了 Spring AI、ADK 和策略路由框架。这是一个务实的折中——当前规模下强行拆成接口加实现会增加很多额外代码。如果要做到零依赖,可以把框架调用代码全部抽到基础设施层,领域层只留接口、数据模型和纯规则逻辑。


追问 2:值对象和实体的区别,结合项目举例。

实体有唯一标识,值对象靠属性值判断。聊天命令实体是实体——靠用户 ID 加智能体 ID 来区分不同请求。配置表值对象是值对象——两个配置内容相同就可以认为等价,不需要独立 ID。


追问 3:分层的好处是什么?换个 Web 框架要改多少?

只需改触发器层。接口契约层、领域层、基础设施层全都不需要动。如果换底层大模型框架,只需在基础设施层新增实现类。


追问 4:这个项目的聚合根是什么?

智能体注册对象可以看作聚合根。它聚合了应用名称、智能体 ID、描述以及运行器。外部通过智能体 ID 从工厂获取整个聚合,拿到运行器直接执行。


追问 5:领域服务和应用层服务的区别是什么?

领域服务包含纯业务逻辑,不依赖 HTTP、数据库等具体技术。比如装配服务——它的逻辑是"怎么把 YAML 配置变成可执行的运行器",这是纯业务问题。应用层服务负责协调技术细节,比如自动配置类——它监听容器就绪事件,在合适时机调用装配服务。


六、SSE 流式推送 & RxJava/Reactor


Q9:SSE + RxJava3 流式推送是怎么实现的?和非流式有什么区别?

主回答:

流式和非流式共用同一个执行流程——都是调运行器的异步执行方法拿到事件流(Flowable)。区别在于事件流怎么处理。

非流式路径:聊天服务内部用阻塞遍历(blockingForEach)把所有事件收集成列表,取最后一条解析为 JSON 一次性返回。

流式路径:控制器创建一个发射器(ResponseBodyEmitter),设 3 分钟超时。聊天服务把事件流原样返回给控制器,控制器订阅它——每收到一个事件就把内容转成字符串通过发射器推给 Spring 框架,Spring 以 SSE 格式发给浏览器。浏览器逐块接收,显示打字机效果。


追问 1:ResponseBodyEmitter 超时设了多少?为什么?

设了 3 分钟。因为绘图流程涉及多个智能体串行调用和可能的工具调用(百度搜索),单次请求可能需要较长时间。3 分钟能覆盖绝大多数正常场景,同时防止连接无限挂起。


追问 2:blockingForEach 会阻塞线程,有没有更好的方式?

在 Spring MVC 多线程模型下可接受——每个请求一个线程,阻塞了不影响其他。如果要全链路异步,可改用 WebFlux 的非阻塞收集(Mono<List<String>>)。


追问 3:客户端断开后服务端能感知到吗?

能。发射器会触发完成加错误的回调。额外保护是 3 分钟超时——即使异常断连双方都未感知,超时后也会强制关闭连接。


追问 4:Flowable 和 Flux 怎么互转?

一行代码。Flowable.fromPublisher(flux) 把 Flux 转 Flowable,Flux.from(flowable) 反过来。两者都是响应式流规范的实现,底层协议互通。


RxJava / Reactor 专项知识

它们是什么?

RxJava 和 Reactor 都是响应式流规范的实现,用来处理异步数据流。在你项目里,Spring AI 用的是 Reactor 的 Flux(响应流),ADK 用的是 RxJava 的 Flowable(事件流)。适配层在中间做桥接——通过 Flowable.create() 手动创建事件流,内部订阅 Flux,逐块转格式后发射。

为什么用响应式流而不是同步调用?

大模型生成复杂图表可能需要几十秒,同步调用会把线程一直占着。响应式流数据来一块推一块,后端通过 SSE 逐块推前端,用户看到打字机效果不卡顿。同时流是异步非阻塞的,不会长时间占用线程。

背压是什么?

背压(Backpressure)是响应式流的核心概念——上游生产快、下游消费慢时,需要策略处理积压。五种策略:缓冲(BUFFER,存内存)、丢弃(DROP,丢掉新数据)、最新(LATEST,只保留最新)、异常(ERROR,抛异常)、忽略(MISSING,不管)。你项目选 BUFFER 是因为大模型输出慢(一秒几十 token),下游一定跟得上。


七、Draw.io 绘图应用


Q10:为什么拆成三个智能体?

主回答:

单个智能体要同时理解需求、生成 XML、检查质量,系统指令会非常长,容易出错。拆成三个后各司其职——分析智能体只管理解需求和输出结构化 JSON,绘图智能体只管把 JSON 转成 XML,质检智能体只管检查 XML 质量并修正。每个的指令聚焦,输出格式更可控。

工程上也有好处——绘图智能体生成错了,质检智能体可以单独修正,不需要整个流程重来。


追问 1:智能体之间数据怎么传递?

用的是 ADK 的输出键(outputKey)机制。分析智能体设 outputKey 为 analysis_result,绘图智能体的指令里写 {analysis_result} 引用。ADK 运行时自动替换为上一个智能体的实际输出内容。


追问 2:如果绘图智能体出错、质检也修不好怎么办?

质检智能体的指令里列了六项检查(XML 语法、必需结构、节点布局、连线正确性等),被要求"直接在 XML 中修正"。如果问题严重到修不了,当前系统会把错误内容透传到前端。可扩展方向:质检修不好时返回特定标记,外层自动触发绘图智能体重新生成。


追问 3:为什么选 mxGraphModel XML 而不是 PlantUML 或 Mermaid?

因为目标平台是 Draw.io,mxGraphModel 是它的原生格式。生成的 XML 可直接粘贴渲染,无需中间转换。选 PlantUML 或 Mermaid 还需要额外渲染步骤,且失去了 Draw.io 的交互编辑能力。技术选型服务于最终使用场景。


八、插件系统


Q11:项目里的插件是怎么加载和执行的?

主回答:

插件加载在装配阶段的运行器构建节点完成。YAML 里配插件名称列表,运行器节点遍历列表,通过 Spring 应用上下文按名称取出插件 Bean,全部打包传给运行器(InMemoryRunner)的构造器。

插件都继承 ADK 的插件基类(BasePlugin),有两个钩子方法——用户消息回调(onUserMessageCallback,用户消息到达时触发)和智能体执行前回调(beforeAgentCallback,每个智能体执行前触发)。ADK 在运行工作流时自动调用这些钩子。


追问 1:插件能改变智能体的执行结果吗?

可以。钩子方法返回的是可能值类型(Maybe),可以选择性地返回修改后的内容。在用户消息回调里可以改写用户消息,在智能体执行前回调里可以修改请求参数。


追问 2:日志插件对排查问题有什么帮助?

日志插件继承 ADK 内置的日志插件,自动记录每次大模型调用的完整请求和响应——系统指令、用户消息、返回内容、执行耗时。如果用户请求卡住了,看日志能定位卡在哪个智能体、哪个步骤。


追问 3:插件和 MCP 工具有什么区别?

插件钩在流程上——ADK 框架在智能体执行前后自动调,用于日志、校验等横切逻辑。工具挂在模型上——大模型生成时自己决定是否调用,用于搜索、读文件等外部操作。一个钩在流程上,一个挂在模型上。


九、跨项目对比


Q13:两个项目技术栈差别很大,你怎么看待?

主回答:

两个项目代表了我两个阶段的技术成长。智枢练的是工程深度——怎么把 Redis、Kafka、Elasticsearch、MinIO 这些中间件组合起来解决真实业务问题。AgentForge 练的是架构设计能力和快速学习能力——怎么用 DDD 分层、怎么在两层框架间做适配桥接、怎么设计可扩展的装配管线。

两者互补——智枢让我理解"怎么用轮子",AgentForge 让我理解"怎么造轮子和把不同类型的轮子拼在一起"。这种跨度不是分散,是视野的扩展。


追问 1:把 AgentForge 的设计思想用到智枢里会怎么做?

最想引入两层。第一是 YAML 驱动的配置化——智枢的文档处理流程(上传 → 解析 → 向量化 → 入库)可以用策略链重构,每个步骤拆成独立节点,通过 YAML 串联。第二是 DDD 分层——把业务逻辑和基础设施代码解耦,让换解析引擎或换嵌入模型只需改基础设施层。


追问 2:两个项目哪个技术挑战更大?

智枢挑战在广度——要同时驾驭五六个中间件。AgentForge 挑战在深度——ADK 和 Spring AI 的适配桥接、响应式流管道搭建,参考资料极少,基本靠读源码和试错。如果要选,AgentForge 更难。


十、场景题


场景 A:增量修改

问:用户画完图后说"把刚才那个登录节点改成红色",现有架构怎么支持?

这靠的是 ADK 的会话机制,不是新增"修改智能体"。

第一轮对话中三智能体生成完整 XML,ADK 自动写入会话的对话历史。第二轮用户说"把那个登录节点改成红色"时,前端继续携带同一个会话 ID,ADK 的会话服务自动把上一轮的完整 XML 和对话历史注入本次请求上下文。分析智能体看到的不只是修改指令,还包括历史 XML——它能理解"那个"节点是哪个,然后按同样流程重新生成修改后的图表。

前端可选是否传会话 ID——不传就新建会话画新图,传了就复用会话继续修改。同一个流水线,靠会话上下文区分"新建"和"修改"。


场景 B:输出格式容错

问:大模型返回的 JSON 格式不对,后续智能体无法处理,怎么设计容错?

三层:上游强约束(指令里加 Few-shot Examples 和硬规则)→ 中间格式校验层(用代码检查 JSON Schema,不合法就自动重试上游)→ 下游兜底分支(无法解析时输出错误标记,前端友好提示)。


场景 C:高并发

问:并发 100 个用户同时画图能撑住吗?

每个用户独立运行器线程模型不冲突,瓶颈在大模型 API 的并发限制。优化方案:线程池限流 + API 调用信号量做令牌桶限流 + 请求排队。极端场景异步化——提交任务返回任务 ID + WebSocket 通知完成。


场景 D:线上故障排查

问:某个用户的绘图请求卡住 5 分钟没返回,怎么排查?

查日志插件定位卡在哪个智能体 → 检查是不是 MCP 工具调用超时(百度搜索挂了)→ 检查大模型 API 是否响应 → 优化方向:MCP 调用加独立超时 + 健康检查端点 + 分布式链路追踪。


十一、基础概念速查


Agent(智能体)

智能体 = 大模型 + 系统指令(instruction) + 工具(tools) + 会话记忆(session)。它不是简单的"增强版大模型",而是把大模型包装成一个能执行任务的独立单元。单个智能体能力有限,多个智能体可以编排协作完成复杂任务。


MCP(模型上下文协议)

MCP 是 Anthropic 提出的标准协议,目标是统一大模型和外部工具的交互方式。它定义了标准接口——工具怎么被发现、参数怎么传、结果怎么返回,全部规范化。支持 SSE(远程服务)、Stdio(命令行工具)、本地 Bean 三种传输模式。


Skills(技能)

Skills 是"操作手册 + 可执行脚本"打包成的工具回调。和 MCP 的区别:MCP 给模型"封装好的外部功能"(调百度搜索),Skills 给模型"领域知识说明书"(读 SKILL.md 按手册自己操作 PDF)。核心资产是知识,不是代码。


@ConfigurationProperties vs @EnableConfigurationProperties

@ConfigurationProperties 贴属性类上,标记"这个类的字段可以跟 YAML 绑定",但不自动注册为 Bean。@EnableConfigurationProperties 贴配置类上,激活绑定——把指定属性类注册成 Bean 并把 YAML 值注入进去。两个必须配合使用。分成两个类是分层需要——属性类在 Domain 层只管数据模型,配置类在 App 层管启动逻辑。


@Configuration vs @Component

都能注册为 Bean。区别:@Configuration 多了 CGLIB 代理——类内一个 @Bean 方法调另一个 @Bean 方法时,代理拦截调用,先去容器查缓存,有就直接返回已有的单例,不再真的执行方法。@Component 没有这个代理,每次调用都创建新对象。


@Resource vs @Autowired

@Resource 是 Java 标准注解,先按名字找,再按类型匹配。@Autowired 是 Spring 注解,先按类型找,再按名字匹配。你项目里注入接口时用 @Resource,Spring 先拿字段名找同名的 Bean,找不到再找实现了该接口的 Bean。


@Bean 方法

@Bean 打在一个方法上,告诉 Spring"这个方法返回的对象,帮我放进容器管理"。Spring 启动时会主动逐一调所有 @Bean 方法,拿返回值注册到容器。这是饿汉式——启动完就全在容器里了,之后注入的都是同一个对象(单例)。除非加 @Lazy 才延迟到第一次注入时创建。


事件流(Flowable)vs 响应流(Flux)

两者都是响应式流规范的实现,底层协议完全一样。ADK 用的是 RxJava 的 Flowable,Spring AI 用的是 Reactor 的 Flux。适配层在中间桥接——通过 Flowable.create() 手动订阅 Flux,逐块转格式后发射。之所以需要桥接,是因为两个框架各选了一个库,不是设计偏好。

都像"水管"——创建水管时里面没有水(冷流),有人订阅(拧水龙头)之后才开始流水(执行内部逻辑)。流式是来一块发一块,非流式是攒完一起发。


工具回调(ToolCallback)

工具回调是告诉大模型"这个工具叫什么、接受什么参数、怎么调"的一份说明书。包含三部分信息:名称、描述、参数规则。注入到对话模型后,每次请求时随提示词一起发给大模型,大模型按需选择调用。


📑 文章目录