面试官让你把 LLM、RAG、Agent、MCP 串起来讲清楚,90% 的 Java 工程师当场露馅
📌 本文导读(建议先花 60 秒)
说句扎心的:LLM、RAG、Agent、MCP、向量、余弦相似度……这些词你个个都听过,但面试官让你“串起来讲一遍”的时候,是不是瞬间就卡壳了?
别慌,这恰恰说明你缺的不是知识点,而是一张全景图。
这篇文章不堆概念、不背定义,用一条真实的电商客服请求做主线,带你从“用户问:我的订单为什么还没发货”一路走到最后流式输出的答复,中间每个环节用到的技术、写的 Java 代码、踩过的坑,全都摆到台面上。
读完这篇,你能带走什么?
- 🧭 一句话隐喻串起所有概念:LLM 是发动机,Embedding 是坐标系,余弦相似度是尺子,向量库是图书馆索引卡,RAG 是外挂资料室,Tool 是手脚,MCP 是 USB-C 插座,Skills 是岗位手册,Agent 是那个会自己干活的数字员工
- 🏗️ 五层架构 + 依赖关系两张图:搞清每一层是谁的活,顺便把“为什么招 Java 而不是招算法”这句话说到位
- 🧮 余弦相似度的三个数学关系:归一化后余弦 == 内积、余弦越大 L2 越小、排序等价 ≠ 阈值通用——面试答不出来基本就露馅的那几个点
- 💻 可以直接抄的生产级代码:float 存储 double 累加防精度污染、零向量短路防 NaN、JDK Vector API 上 SIMD、RRF 融合、带四道防护的 ReAct 循环、三分钟把 Spring Boot 服务变成 MCP Server
- ⚡ 反直觉但救命的结论:RAG 的瓶颈在检索不在生成;Skills 别塞进 System Prompt 要用渐进式披露;工具集要从 28 个收敛到 5~8 个;MCP 的 HTTP 端点默认无鉴权,裸暴露就是裸奔
- 🕳️ 16 个生产踩坑对照表 + 13 个面试连环追问速答:建议直接收藏当检查清单,改分块、换模型、调阈值之前先过一遍
这篇适合谁? 想转 AI 方向的后端、正在做企业知识库或智能客服的 Java 研发、以及下周就要面试、现在有点慌的朋友。
这篇不适合谁? 想学怎么训练大模型、调 LoRA 的——这里一个梯度都不推。本文只讲一件事:怎么把不确定的模型能力,装进确定的工程系统里。
⏱️ 全文约 1.5 万字,建议先收藏、再细看,通勤路上刷不完的。
看到最后有个小彩蛋:文末问了大家“做 RAG 或 Agent 落地时踩过最疼的坑”,评论区聊两句,我看到会挨个回。觉得有用的话,点个赞、点个在看,别让这篇在收藏夹里吃灰 👇
--
一个请求串起 LLM、RAG、Agent、MCP:Java 工程师的 AI 工程化落地全景
面试里有道高频题:把 LLM、Agent、Skills、MCP、RAG、向量、余弦相似度串起来讲清楚。大多数人单个概念都能背,一连起来就露馅。这篇文章用一条真实的客服请求做主线,结合我们在电商客服、企业知识库项目里的落地经验,讲透每个概念的原理、Java 实现和踩过的坑。适合想转 AI 方向的后端朋友,建议先收藏再细看。
一、先立全景:一句话 + 两张图串起所有概念
学 AI 概念最大的痛点是"每个都听过,串不起来"。别急着一头扎进细节,先花三分钟建立全局认知,后面怎么学都不会迷路。
先用一句话把所有概念放进同一个隐喻体系:
LLM 是发动机;Embedding 是语言的坐标系;余弦相似度是尺子;向量库是图书馆的索引卡;RAG 是给模型配的"外挂资料室";Tool 是模型的手脚;MCP 是统一的 USB 插座;Skills 是岗位行为手册;Agent 是那个会自己拆任务、干完还会复盘的数字员工。
而 Java 工程师的价值,从来不在"造发动机",而在造整车、修高速、建加油站。
再来一张"人话版"速查表:
1.1 五层架构:搞清楚每一层是谁的活
把概念放进分层架构里看,职责一目了然——这和咱们熟悉的 MVC 分层是一个思路:
💡 技术亮点(面试加分句):这五层里,①②是买来的,③④是框架给的,⑤才是企业自己的护城河。这一句话,基本就把"为什么招 Java 而不是招算法"说透了。
1.2 概念依赖关系:它们是怎么咬合的
一句话串完:向量和余弦相似度是语义计算的地基,RAG 给大模型装知识库,Skills 和 MCP 给大模型接业务手脚,Agent 把所有能力组装成能自主完成任务的智能体。
接下来自底向上逐个拆。先从不那么性感、但决定一切的地基讲起。
二、LLM:别神化,它就是个"下一个 token 预测器"
很多人把大模型当百科全书用,这是所有工程问题的根源。先把三个关键认知钉死:
- 它是概率生成器,不是数据库。LLM 本质是函数 f(文本上下文) → 下一个 token 的概率分布,它生成的是最像样的文本,不是最真实的文本;
- 知识冻结:训练完那一天,参数里的知识就冻住了。你上周发的产品文档,它天然不知道;
- 幻觉的根因:模型优化的是流畅度而不是真实性,不知道又必须答的时候,它会用概率分布"编一个最像样的"。
再加两条:它没有记忆,所谓多轮对话,全靠我们把历史消息一遍遍塞进 messages 数组;它不联网、不查数据库,强在语言理解与生成,弱在精确计算、实时数据和私域知识。
想明白这些,Java 研发的定位就清楚了:
💡 一句话记忆:大模型在 Java 世界里,本质上就是另一个能聊天的第三方 REST 服务——只是它特别慢、特别贵、还偶尔胡说八道。我们要做的不是转行搞算法,而是当一名 LLM Application Engineer:像治理任何"不可靠依赖"一样,做超时、重试、熔断、降级、可观测。
2.1 Token、上下文、Temperature:都是预算
上下文越长越贵,也越容易"迷失中间信息"(Lost in the Middle)。所以别把上下文当硬盘用,它是稀缺资源——这个观点后面讲 Skills 时还会再用到。
2.2 别把 LLM 当数据库
这是我踩坑之后总结的分工原则,实际开发中非常管用:
事实数据:查数据库 / 知识库;流程操作:走业务接口 / MCP;语言表达:才交给 LLM。
LLM 擅长推理和组织表达,不擅长长期记忆和精确事实。让它干它不擅长的事,就是幻觉的开始。
模型本身这么多毛病,怎么补?答案是给它配外挂——先外挂知识(RAG),再外挂手脚(Tool/MCP)。但在那之前,得先解决一个数学问题:机器怎么理解"意思"?
三、向量 + 余弦相似度:把"语义"变成可计算的数字
3.1 为什么需要向量
传统 SQL 只擅长精确匹配和模糊匹配,like '%car%' 永远匹配不到 automobile。关键词检索匹配的是字,而我们要匹配的是意思。
Embedding 模型把一段文本压成一串高维浮点数(常见 768 / 1024 / 1536 / 3072 维),语义越近的文本,在向量空间里距离越近:
"如何申请年假" → [0.012, -0.231, 0.548, ...] ┐ 夹角很小 → 语义几乎等价
"年假怎么请" → [0.009, -0.198, 0.561, ...] ┘
"如何申请离职" → [-0.34, 0.087, -0.112, ...] → 夹角很大 → 语义不同
几个关键工程认知,都是血泪换来的:
- 不用关心每一维代表什么,只关心"距离能不能用";
- 向量是模型给的,不是算出来的——换 Embedding 模型,整个向量库必须全量重建,这是迁移成本的地雷;
- 入库和查询必须用同一个 Embedding 模型,否则坐标系都不一样,算出来的相似度是纯噪声;
- 生产上 Embedding 通常离线批量跑 + 增量幂等重放,别把它放进用户请求的同步链路。
维度怎么选?维度越高语义表达越细,但存储和计算成本线性增长。float 存储下 1536 维 × 4B ≈ 6KB/条,企业级常见选 1024 维再配 PQ 量化压缩。
3.2 余弦相似度:为什么不用欧氏距离
公式很简单,值域 [-1, 1]:
cos(θ) = (A·B) / (‖A‖ × ‖B‖) = Σ(aᵢbᵢ) / (√Σaᵢ² × √Σbᵢ²)
分数怎么解读,心里要有杆秤:
为什么文本场景默认选余弦而不是欧氏距离?
文本向量的模长受文本长度影响——"今天天气很好"和"今天天气很好很好很好"意思一样,欧氏距离却会变大;余弦只看方向,对长度不敏感。而且高维空间里欧氏距离有维度灾难,距离趋同,区分度越来越差。
3.3 三个数学关系:答不出来基本就露馅
⭐ 面试必答:
- 归一化后,余弦 == 内积。做 L2 归一化使 ‖a‖=‖b‖=1,代入公式即得 cosθ = a·b。工程价值:查询时只需一次乘法累加,省掉每次开方和除法。FAISS 里 IndexFlatIP 配归一化向量,算的就是余弦。
- 归一化后,余弦越大 ⇔ L2 距离越小。‖a-b‖² = 2 - 2cosθ = 2(1-cosθ),三种度量的排序结果完全等价,只差一个分数刻度。
- 排序等价 ≠ 阈值通用。如果拿余弦分数做绝对阈值过滤(比如"低于 0.6 判定无答案"),度量选择和归一化状态就至关重要——混用归一化/未归一化向量,阈值会直接失效。
3.4 三个容易被追问的坑
3.5 生产级代码:三个细节比公式本身更重要
public final class CosineSimilarity {
private CosineSimilarity() {}
/**
* 通用余弦相似度,三个工程细节:
* ① 用 double 累加——1536 维 float 连续累加会丢精度,污染 Top-K 排序;
* ② 零向量必须短路——否则 NaN 一路污染到排序结果,Comparator 行为不可预测;
* ③ 维度校验——入库与查询的坐标系必须一致。
*/
public static double cosine(float[] a, float[] b) {
if (a.length != b.length) {
throw new IllegalArgumentException("向量维度不一致: " + a.length + " vs " + b.length);
}
double dot = 0.0, na = 0.0, nb = 0.0;
for (int i = 0; i < a.length; i++) {
dot += (double) a[i] * b[i]; // 提升到 double 再累加
na += (double) a[i] * a[i];
nb += (double) b[i] * b[i];
}
if (na == 0.0 || nb == 0.0) { // 零向量短路,防 NaN
return 0.0;
}
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
/** 入库前 L2 归一化:之后检索退化为内积,省掉 2 次开方 + 1 次除法 */
public static float[] l2Normalize(float[] v) {
double n = 0.0;
for (float x : v) n += (double) x * x;
if (n == 0.0) return v;
double inv = 1.0 / Math.sqrt(n);
float[] out = new float[v.length];
for (int i = 0; i < v.length; i++) out[i] = (float) (v[i] * inv);
return out;
}
/** 归一化向量之间的内积 == 余弦相似度(检索热路径用这个) */
public static double dot(float[] a, float[] b) {
double s = 0.0;
for (int i = 0; i < a.length; i++) {
s += (double) a[i] * b[i];
}
return s;
}
}
💡 技术亮点:float 存储省一半内存与带宽,但累加用 double 防精度丢失;零向量返回 0 而不是 NaN——NaN 会让 Comparator 排序结果不可预测,这是真实踩过的坑。另外"写入时归一化、查询时退化为点积"这一招,实测百万级向量打分性能提升 2~3 倍,千万级批量检索能提升约 40%。
批量点积还可以交给 SIMD:
import jdk.incubator.vector.FloatVector;
import jdk.incubator.vector.VectorOperators;
import jdk.incubator.vector.VectorSpecies;
/**
* JDK Vector API(jdk.incubator.vector,孵化阶段,需 --add-modules):
* 让 JIT 生成 SIMD 指令,批量点积性能可达标量循环的数倍。
* 定位:离线建库/重排序阶段的自研算子;在线检索一律交给向量库。
*/
public final class SimdDotProduct {
private static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;
public static double dot(float[] a, float[] b) {
int i = 0;
int upperBound = SPECIES.loopBound(a.length);
FloatVector acc = FloatVector.zero(SPECIES);
for (; i < upperBound; i += SPECIES.length()) {
FloatVector va = FloatVector.fromArray(SPECIES, a, i);
FloatVector vb = FloatVector.fromArray(SPECIES, b, i);
acc = acc.add(va.mul(vb));
}
double sum = acc.reduceLanes(VectorOperators.ADD);
for (; i < a.length; i++) { // 尾部不足一个向量宽度,标量兜底
sum += (double) a[i] * b[i];
}
return sum;
}
}
有了向量和尺子,下一个问题很现实:数据量上来之后,难道真的一条条 for 循环去算?
四、向量库与 ANN 索引:为什么不能自己 for 循环
先算一笔账:100 万条 1024 维向量,每条查询要 1024 次乘加,单次查询约 20 亿次浮点运算;光把 4GB 向量从内存读一遍就够喝一壶。100 并发?CPU 排队到天亮。
破局思路:允许自己"不算那么准"——ANN(近似最近邻)用少量召回率换数量级速度。
理解 HNSW 一句话就够:跳表解决"跳得远",小世界图解决"跳得准"——从高层入口大步跳,逐层下降、每层贪心走向更近的邻居,用 O(log N) 的访问路径逼近目标。
生产上用 recall@K(ANN 结果与暴力结果的 Top-K 重合比例)验收,一般调到 95%~99%,同时把延迟从秒级压到毫秒级。
4.1 Java 侧选型对照表
⚠️ 踩坑提醒:不要自己去实现 ANN 索引。亮点应该是"怎么选索引、怎么调 nprobe / efSearch、怎么用 recall@K 验收",而不是手写 HNSW。千万级向量下检索延迟超 1s,标准解法是 HNSW 索引 + 热门向量本地缓存(Caffeine)+ 冷热数据分库。
pgvector 场景下,计算直接下沉到数据库,避免把全量向量拉到 Java 内存:
-- <=> 是 pgvector 的余弦距离操作符,1 - 距离 = 余弦相似度
SELECT id, content, 1 - (embedding <=> :qvec) AS score
FROM doc_chunk
WHERE tenant_id = :tenantId
ORDER BY embedding <=> :qvec
LIMIT 5;
Spring Data JPA 里用原生查询包一层即可:
@Query(value = """
SELECT id, content, 1 - (embedding <=> :queryVector) AS similarity
FROM document_chunks
WHERE 1 - (embedding <=> :queryVector) > :threshold
ORDER BY similarity DESC
LIMIT :topK
""", nativeQuery = true)
List<DocSimilarity> findTopKByCosine(@Param("queryVector") float[] queryVector,
@Param("threshold") double threshold,
@Param("topK") int topK);
Milvus 场景的声明式配置,注意维度必须和 Embedding 模型严格对齐:
@Configuration
public class MilvusVectorConfig {
@Bean
public VectorStore milvusVectorStore(EmbeddingModel embeddingModel) {
return MilvusVectorStore.builder()
.embeddingModel(embeddingModel)
.host("milvus-internal-host")
.port(19530)
.collectionName("knowledge_base")
.dimension(1536) // 与 Embedding 模型维度严格对齐
.indexType(IndexType.HNSW) // 高性能向量索引
.metricType(MetricType.COSINE) // 余弦度量
.build();
}
}
地基打完了,正式进入应用层。先讲面试第一关——RAG。
五、RAG:开卷考试,但瓶颈在检索不在生成
先纠正一个高频错误答案:
RAG 不是"向量库 + 大模型",而是"外部知识管理 + 检索系统 + 生成模型"的一整套协同机制。 它解决四个问题:知识不新、私有知识不知道、容易编(幻觉)、答案不可追溯。
5.1 完整链路:两阶段,必须按步骤讲
5.2 高分答法:先讲"朴素 RAG 为什么不行"
把解法攒起来,就是 2026 年的生产级 RAG 公式:
Production RAG = 高级解析(OCR / 版面还原)+ 混合检索(BM25 + Dense,RRF 融合)+ 神经重排(Cross-Encoder)+ Agentic 路由(按问题复杂度动态决定检索深度)+ GraphRAG(全局/多跳问题)+ 持续评测(RAGAS / TruLens,量化 recall@k、忠实度、幻觉率)
📌 这里有个分水岭级别的认知:RAG 的瓶颈在检索,不在生成。朴素管道在检索这一步大约 40% 的概率会失败,检索入口错了,后面 rerank 和生成再优化都白搭。
顺带回答一个高频疑问:现在上下文都 1M 了,还要 RAG 吗?要。三个理由:成本与延迟(每次全量灌入不可接受)、注意力衰减(塞得多不代表记得住)、实时性与权限粒度(知识库按条更新、按租户过滤)。正确姿势是:RAG 负责从海量里精准找,长上下文负责找到之后的精读推理。
5.3 五个关键设计决策
① 切块:固定长度硬切是语义腰斩的元凶。实践参数:500~1000 token 一块、10%~20% 重叠、按标题/段落结构感知切分,再上父子块——子块用于向量匹配,命中后把父块完整内容送进上下文。标题和元数据也要带着走,检索时能做过滤,回答时能做溯源。
② Embedding 一致性:Query 和 Doc 必须同一模型同一维度,前面讲过,不再展开。
③ 混合检索 + RRF 融合:纯向量会漏精确关键词(型号 RTX-5090、制度号 HR-2025-013),BM25 恰好互补。RRF(Reciprocal Rank Fusion)的打分公式是 score = Σ 1/(k + rank),不需要归一化两种量纲完全不同的分数,比加权求和稳得多。
④ 重排:向量召回是"双塔模型",Query 和 Doc 独立编码,无法深度交互;Rerank 是交叉编码,把 Q+D 拼一起算相关性,准但慢,所以只对粗召回的 Top-50 精排,取 Top-5~8。
⑤ 生成约束:Prompt 写死"仅依据资料回答,没有就说不知道"+ 强制引用来源。这是抑制幻觉的第一道闸。
5.4 核心代码:一个能上线的 RAG 闭环
Spring AI 里,RAG 可以被一个 Advisor 织入,业务代码几乎无感:
@Configuration
public class RagConfig {
/** 生产环境换成 PgVectorStore / MilvusVectorStore / ElasticsearchVectorStore */
@Bean
VectorStore vectorStore(EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel).build();
}
/**
* RAG 三个关键约束:
* ① topK + similarityThreshold:控制上下文预算,宁少勿滥;
* ② 系统提示写死"只依据资料回答,没有就说不知道"——抑制幻觉的第一道闸;
* ③ 绑定引用来源,可追溯(企业合规刚需)。
*/
@Bean
ChatClient chatClient(ChatClient.Builder builder, VectorStore vectorStore) {
return builder
.defaultSystem("""
你是企业知识助手。只能依据【检索资料】回答问题,
并在答案末尾用 [来源: 文件名#片段号] 标注依据。
资料中没有相关信息时,直接回答「资料中未涉及」,禁止推测。
""")
.defaultAdvisors(
QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.topK(8)
.similarityThreshold(0.6)
.build())
.build())
.build();
}
}
// 调用侧:检索被 Advisor 织入,业务代码里几乎看不到 RAG 的存在
@RestController
public class KnowledgeController {
private final ChatClient chatClient;
public KnowledgeController(ChatClient chatClient) {
this.chatClient = chatClient;
}
@GetMapping("/kb/ask")
public String ask(@RequestParam String q) {
return chatClient.prompt().user(q).call().content();
}
}
📎 注:Spring AI 各小版本 API 略有差异(如 SimpleVectorStore.builder 与旧版构造器),以所用版本的官方文档为准。
再往进一步,两阶段检索 + 流式输出:
@Service
@RequiredArgsConstructor
public class KnowledgeQAService {
private final VectorStore vectorStore;
private final RerankClient rerankClient;
private final ChatModel chatModel;
/** 两阶段检索:粗召回扩量 → 精排取 Top-5,各司其职 */
public List<Document> retrieve(String query) {
List<Document> candidates = vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(20) // 扩大召回,给 Rerank 留空间
.similarityThreshold(0.6) // 阈值过滤,别把噪声喂给模型
.build());
return rerankClient.rerank(query, candidates, 5);
}
/** 流式问答:首字延迟从 3s 压到 300ms 量级 */
public Flux<String> streamAnswer(String query) {
List<Document> docs = retrieve(query);
String context = docs.stream()
.map(d -> "[%s] %s".formatted(d.getId(), d.getText()))
.collect(Collectors.joining("\n---\n"));
String systemPrompt = """
你是企业知识库助手。规则:
1. 仅依据【资料】回答,资料不足必须回答"暂无相关信息"
2. 每条结论标注来源编号 [DOC-xxx]
3. 禁止编造流程、人名、金额
【资料】
%s
""".formatted(context);
return chatModel.stream(new Prompt(
new SystemMessage(systemPrompt),
new UserMessage(query)))
.mapNotNull(r -> r.getResult().getOutput().getText());
}
}
如果检索源有多路,用虚拟线程并行召回 + RRF 融合:
@Service
public class HybridRetriever {
private final VectorStore vectorStore; // Spring AI 抽象:PGVector / Milvus / Redis
private final Bm25Searcher bm25;
private final ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor();
public List<Doc> retrieve(String query, int topK) {
// 两路召回并行:虚拟线程让阻塞 IO 不占 OS 线程,万级并发也不炸线程池
CompletableFuture<List<Doc>> semantic =
CompletableFuture.supplyAsync(() -> vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(topK * 3) // 多召回一些给 Rerank 用
.similarityThreshold(0.6)
.filterExpression("tenantId == '" + TenantContext.get() + "'") // 权限下推
.build())
.stream().map(Doc::from).toList(), vt);
CompletableFuture<List<Doc>> lexical =
CompletableFuture.supplyAsync(() -> bm25.search(query, topK * 3), vt);
return rrf(List.of(semantic.join(), lexical.join()), 60).stream()
.limit(topK).toList();
}
/** RRF:score = Σ 1/(k + rank),无需归一化分数,跨
本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。