AI2026年11月4日· 约 85 分钟

面试官让你把 LLM、RAG、Agent、MCP 串起来讲清楚,90% 的 Java 工程师当场露馅

#自然语言#机器学习#技术架构
Twitter 微博

面试官让你把 LLM、RAG、Agent、MCP 串起来讲清楚,90% 的 Java 工程师当场露馅

面试官让你把 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 工程师的价值,从来不在"造发动机",而在造整车、修高速、建加油站。

再来一张"人话版"速查表:

概念 一句话人话 类比 Java 侧抓手
LLM 只会预测下一个 token 的续写机器 大脑 / 发动机 Spring AI、LangChain4j、WebClient 调 API + SSE 流式
向量 Embedding 把文本变成语义坐标 坐标系 EmbeddingModel 输出 float[],存 pgvector / Milvus
余弦相似度 看两个向量方向像不像 尺子 / 罗盘 cos = A·B / (|A||B|),用于 TopK 召回
RAG 开卷考试:先查资料再回答 外挂资料室 切块 → 向量化 → 检索 → 拼 Prompt
Skills 某类任务的标准作业流程 岗位手册 / 工具箱 @Tool 方法 + 按需加载的 SKILL.md
MCP 工具接入的统一协议 USB-C 插座 spring-ai-starter-mcp-server-*
Agent 会拆任务、调工具、循环决策 数字员工 AiServices + ReAct 循环 / 状态机

1.1 五层架构:搞清楚每一层是谁的活#

把概念放进分层架构里看,职责一目了然——这和咱们熟悉的 MVC 分层是一个思路:

image

层 一句话职责 Java 侧抓手
① 模型层 提供"理解与生成"的算力 HTTP/SSE 调用、Spring AI ChatModel
② 上下文层 把语义变成可计算的距离 MilvusVectorStore、PgVectorStore、ES 8.x dense_vector
③ 能力层 让模型看得见知识、摸得着系统 LangChain4j ContentRetriever、Spring AI ToolCallback
④ 编排层 让模型自己决定下一步干什么 AiServices、Spring StateMachine、ToolProvider
⑤ 工程治理层 让 AI 稳定、安全、便宜地上线 WebFlux + SSE、Redis 令牌桶、Micrometer、网关

💡 技术亮点(面试加分句):这五层里,①②是买来的,③④是框架给的,⑤才是企业自己的护城河。这一句话,基本就把"为什么招 Java 而不是招算法"说透了。

1.2 概念依赖关系:它们是怎么咬合的#

image

一句话串完:向量和余弦相似度是语义计算的地基,RAG 给大模型装知识库,Skills 和 MCP 给大模型接业务手脚,Agent 把所有能力组装成能自主完成任务的智能体。

接下来自底向上逐个拆。先从不那么性感、但决定一切的地基讲起。

二、LLM:别神化,它就是个"下一个 token 预测器"#

很多人把大模型当百科全书用,这是所有工程问题的根源。先把三个关键认知钉死:

  • 它是概率生成器,不是数据库。LLM 本质是函数 f(文本上下文) → 下一个 token 的概率分布,它生成的是最像样的文本,不是最真实的文本;
  • 知识冻结:训练完那一天,参数里的知识就冻住了。你上周发的产品文档,它天然不知道;
  • 幻觉的根因:模型优化的是流畅度而不是真实性,不知道又必须答的时候,它会用概率分布"编一个最像样的"。

再加两条:它没有记忆,所谓多轮对话,全靠我们把历史消息一遍遍塞进 messages 数组;它不联网、不查数据库,强在语言理解与生成,弱在精确计算、实时数据和私域知识。

想明白这些,Java 研发的定位就清楚了:

💡 一句话记忆:大模型在 Java 世界里,本质上就是另一个能聊天的第三方 REST 服务——只是它特别慢、特别贵、还偶尔胡说八道。我们要做的不是转行搞算法,而是当一名 LLM Application Engineer:像治理任何"不可靠依赖"一样,做超时、重试、熔断、降级、可观测。

2.1 Token、上下文、Temperature:都是预算#

概念 本质 工程含义
Token 模型最小计费与处理单位,是子词不是字 中文约 1 字 ≈ 1~1.5 token(不同分词器有差异),成本与延迟都按 token 计
Prompt 发给模型的输入模板 Prompt 也是"代码":要版本化、要评测、要回归
上下文窗口 模型一次能看见的全部内容 是预算而非容量:检索塞多少、历史留多少、输出留多少,都要算账
Temperature 控制概率分布的"锐度" 抽取/分类/事实问答用 0~0.3,创意写作 0.7+
结构化输出 让模型吐合法 JSON 对接 Java 类型 BeanOutputConverter + 校验 + 失败重试

上下文越长越贵,也越容易"迷失中间信息"(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ᵢ²)

分数怎么解读,心里要有杆秤:

相似度 含义 场景
1.0 完全同方向 同义句
0.7+ 高度相关 常用的召回阈值
0.0 正交,无关 —
负数 方向相反 文本向量中少见

为什么文本场景默认选余弦而不是欧氏距离?

度量 公式 区间 适用场景
余弦相似度 Σ(aᵢbᵢ) / (‖a‖‖b‖) [-1, 1] 文本语义检索默认选它(只看方向,不看长度)
内积 / 点积 Σ(aᵢbᵢ) (-∞, +∞) 推荐系统(模长可编码热度、置信度)
欧氏距离 √Σ(aᵢ-bᵢ)² [0, +∞) 图像特征等模长本身有意义的场景

文本向量的模长受文本长度影响——"今天天气很好"和"今天天气很好很好很好"意思一样,欧氏距离却会变大;余弦只看方向,对长度不敏感。而且高维空间里欧氏距离有维度灾难,距离趋同,区分度越来越差。

3.3 三个数学关系:答不出来基本就露馅#

⭐ 面试必答:

  1. 归一化后,余弦 == 内积。做 L2 归一化使 ‖a‖=‖b‖=1,代入公式即得 cosθ = a·b。工程价值:查询时只需一次乘法累加,省掉每次开方和除法。FAISS 里 IndexFlatIP 配归一化向量,算的就是余弦。
  2. 归一化后,余弦越大 ⇔ L2 距离越小。‖a-b‖² = 2 - 2cosθ = 2(1-cosθ),三种度量的排序结果完全等价,只差一个分数刻度。
  3. 排序等价 ≠ 阈值通用。如果拿余弦分数做绝对阈值过滤(比如"低于 0.6 判定无答案"),度量选择和归一化状态就至关重要——混用归一化/未归一化向量,阈值会直接失效。

3.4 三个容易被追问的坑#

坑 现象 本质与应对
相似 ≠ 相关 "苹果公司 CEO"和"Tim Cook"向量距离可能很远 向量衡量的是表述分布相似度,不是事实关联。靠混合检索 + Rerank + 知识图谱补
Hubness 枢纽点 某些向量成为"万能近邻",谁查都排前面 高维空间固有现象。去重、聚类打散、结果多样性约束(MMR)缓解
重复文档刷分 同一文档存 10 份,更容易被召回 入库做内容指纹去重,召回后按 doc_id 聚合

3.5 生产级代码:三个细节比公式本身更重要#

public final class CosineSimilarity {
private CosineSimilarity() {}

class=class="hljs-string">"hljs-comment">/**
 * 通用余弦相似度,三个工程细节:
 * ① 用 double 累加——class="hljs-number">1536 维 float 连续累加会丢精度,污染 Top-K 排序;
 * ② 零向量必须短路——否则 NaN 一路污染到排序结果,Comparator 行为不可预测;
 * ③ 维度校验——入库与查询的坐标系必须一致。
 */
public static double cosine(float[] a, float[] b) {
    if (a.length != b.length) {
        throw new IllegalArgumentException(class="hljs-string">"向量维度不一致: " + a.length + class="hljs-string">" vs " + b.length);
    }
    double dot = class="hljs-number">0.0, na = class="hljs-number">0.0, nb = class="hljs-number">0.0;
    for (int i = class="hljs-number">0; i < a.length; i++) {
        dot += (double) a[i] * b[i];   class=class="hljs-string">"hljs-comment">// 提升到 double 再累加
        na  += (double) a[i] * a[i];
        nb  += (double) b[i] * b[i];
    }
    if (na == class="hljs-number">0.0 || nb == class="hljs-number">0.0) {      class=class="hljs-string">"hljs-comment">// 零向量短路,防 NaN
        return class="hljs-number">0.0;
    }
    return dot / (Math.sqrt(na) * Math.sqrt(nb));
}

class=class="hljs-string">"hljs-comment">/** 入库前 L2 归一化:之后检索退化为内积,省掉 class="hljs-number">2 次开方 + class="hljs-number">1 次除法 */
public static float[] l2Normalize(float[] v) {
    double n = class="hljs-number">0.0;
    for (float x : v) n += (double) x * x;
    if (n == class="hljs-number">0.0) return v;
    double inv = class="hljs-number">1.0 / Math.sqrt(n);
    float[] out = new float[v.length];
    for (int i = class="hljs-number">0; i < v.length; i++) out[i] = (float) (v[i] * inv);
    return out;
}

class=class="hljs-string">"hljs-comment">/** 归一化向量之间的内积 == 余弦相似度(检索热路径用这个) */
public static double dot(float[] a, float[] b) {
    double s = class="hljs-number">0.0;
    for (int i = class="hljs-number">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(近似最近邻)用少量召回率换数量级速度。

索引 核心思想 优点 代价 适用
Flat 暴力全量比对 召回率 100%,无需训练 O(N×dim),慢 < 10 万条,或作为召回基准
IVF K-means 聚类分桶,只搜 nprobe 个最近簇 内存友好、可扩展 边界向量易漏召 千万级以上、可接受离线重建
HNSW 分层可导航小世界图 召回/延迟综合最优,主流默认 内存占用高、构建慢、删除不友好 通用首选
PQ 乘积量化压缩向量 内存降一个数量级 精度损失,需重排补偿 亿级规模、内存受限

理解 HNSW 一句话就够:跳表解决"跳得远",小世界图解决"跳得准"——从高层入口大步跳,逐层下降、每层贪心走向更近的邻居,用 O(log N) 的访问路径逼近目标。

生产上用 recall@K(ANN 结果与暴力结果的 Top-K 重合比例)验收,一般调到 95%~99%,同时把延迟从秒级压到毫秒级。

4.1 Java 侧选型对照表#

场景 选型 理由
已有 ES 集群 Elasticsearch 8.x dense_vector 一套系统同时吃 BM25 + 向量,混合检索最省事
已有 PostgreSQL pgvector 事务、权限、备份全复用,运维零新增
千万~亿级、要低延迟 Milvus / Qdrant 专业向量库,HNSW/PQ 参数可精细调
单元测试 / 小数据 内存 Map + 余弦 别引入重组件,SimpleVectorStore 足够

⚠️ 踩坑提醒:不要自己去实现 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(class="hljs-string">"milvus-internal-host")
            .port(class="hljs-number">19530)
            .collectionName(class="hljs-string">"knowledge_base")
            .dimension(class="hljs-number">1536)                 class=class="hljs-string">"hljs-comment">// 与 Embedding 模型维度严格对齐
            .indexType(IndexType.HNSW)       class=class="hljs-string">"hljs-comment">// 高性能向量索引
            .metricType(MetricType.COSINE)   class=class="hljs-string">"hljs-comment">// 余弦度量
            .build();
}

}

地基打完了,正式进入应用层。先讲面试第一关——RAG。

五、RAG:开卷考试,但瓶颈在检索不在生成#

先纠正一个高频错误答案:

RAG 不是"向量库 + 大模型",而是"外部知识管理 + 检索系统 + 生成模型"的一整套协同机制。 它解决四个问题:知识不新、私有知识不知道、容易编(幻觉)、答案不可追溯。

5.1 完整链路:两阶段,必须按步骤讲#

image

5.2 高分答法:先讲"朴素 RAG 为什么不行"#

朴素 RAG 的失败模式 典型表现 对应解法
召回不准 "苹果 CEO"召不回"Tim Cook" 混合检索(Dense + BM25)、查询改写
语义 ≠ 相似 搜编号 AG-2024-001,召回一堆含"产品编号"字样但号不对的文档 BM25 精确匹配兜底,权重按场景调
上下文割裂 固定 512 字符硬切,论证被腰斩 结构感知切片、父子块、语义分块
多跳推理失败 "X 公司 CEO 的母校的 QS 排名"查不到 查询分解、Agentic RAG 多轮检索
全局性问题崩溃 "这批文档的核心主题是什么" GraphRAG / 全局摘要,向量检索对此天然失效
召回多但噪音大 Top-20 全塞进去,模型被干扰 Rerank 精排,只送 top-5~8

把解法攒起来,就是 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 {
class=class="hljs-string">"hljs-comment">/** 生产环境换成 PgVectorStore / MilvusVectorStore / ElasticsearchVectorStore */
@Bean
VectorStore vectorStore(EmbeddingModel embeddingModel) {
    return SimpleVectorStore.builder(embeddingModel).build();
}

class=class="hljs-string">"hljs-comment">/**
 * RAG 三个关键约束:
 * ① topK + similarityThreshold:控制上下文预算,宁少勿滥;
 * ② 系统提示写死class="hljs-string">"只依据资料回答,没有就说不知道"——抑制幻觉的第一道闸;
 * ③ 绑定引用来源,可追溯(企业合规刚需)。
 */
@Bean
ChatClient chatClient(ChatClient.Builder builder, VectorStore vectorStore) {
    return builder
            .defaultSystem(class="hljs-string">"""
                    你是企业知识助手。只能依据【检索资料】回答问题,
                    并在答案末尾用 [来源: 文件名class=class="hljs-string">"hljs-comment">#片段号] 标注依据。
                    资料中没有相关信息时,直接回答「资料中未涉及」,禁止推测。
                    class="hljs-string">""")
            .defaultAdvisors(
                    QuestionAnswerAdvisor.builder(vectorStore)
                            .searchRequest(SearchRequest.builder()
                                    .topK(class="hljs-number">8)
                                    .similarityThreshold(class="hljs-number">0.6)
                                    .build())
                            .build())
            .build();
}

}

// 调用侧:检索被 Advisor 织入,业务代码里几乎看不到 RAG 的存在 @RestController public class KnowledgeController {

private final ChatClient chatClient;

public KnowledgeController(ChatClient chatClient) {
    this.chatClient = chatClient;
}

@GetMapping(class="hljs-string">"/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;

class=class="hljs-string">"hljs-comment">/** 两阶段检索:粗召回扩量 → 精排取 Top-class="hljs-number">5,各司其职 */
public List&lt;Document&gt; retrieve(String query) {
    List&lt;Document&gt; candidates = vectorStore.similaritySearch(
            SearchRequest.builder()
                    .query(query)
                    .topK(class="hljs-number">20)                      class=class="hljs-string">"hljs-comment">// 扩大召回,给 Rerank 留空间
                    .similarityThreshold(class="hljs-number">0.6)      class=class="hljs-string">"hljs-comment">// 阈值过滤,别把噪声喂给模型
                    .build());
    return rerankClient.rerank(query, candidates, class="hljs-number">5);
}

class=class="hljs-string">"hljs-comment">/** 流式问答:首字延迟从 3s 压到 300ms 量级 */
public Flux&lt;String&gt; streamAnswer(String query) {
    List&lt;Document&gt; docs = retrieve(query);
    String context = docs.stream()
            .map(d -&gt; class="hljs-string">"[%s] %s".formatted(d.getId(), d.getText()))
            .collect(Collectors.joining(class="hljs-string">"\n---\n"));

    String systemPrompt = class="hljs-string">"""
            你是企业知识库助手。规则:
            class="hljs-number">1. 仅依据【资料】回答,资料不足必须回答class="hljs-string">"暂无相关信息"
            class="hljs-number">2. 每条结论标注来源编号 [DOC-xxx]
            class="hljs-number">3. 禁止编造流程、人名、金额

            【资料】
            %s
            class="hljs-string">""".formatted(context);

    return chatModel.stream(new Prompt(
            new SystemMessage(systemPrompt),
            new UserMessage(query)))
        .mapNotNull(r -&gt; r.getResult().getOutput().getText());
}

}

如果检索源有多路,用虚拟线程并行召回 + RRF 融合:

@Service
public class HybridRetriever {
private final VectorStore vectorStore;   class=class="hljs-string">"hljs-comment">// Spring AI 抽象:PGVector / Milvus / Redis
private final Bm25Searcher bm25;
private final ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor();

public List&lt;Doc&gt; retrieve(String query, int topK) {
    class=class="hljs-string">"hljs-comment">// 两路召回并行:虚拟线程让阻塞 IO 不占 OS 线程,万级并发也不炸线程池
    CompletableFuture&lt;List&lt;Doc&gt;&gt; semantic =
            CompletableFuture.supplyAsync(() -&gt; vectorStore.similaritySearch(
                    SearchRequest.builder()
                            .query(query)
                            .topK(topK * class="hljs-number">3)          class=class="hljs-string">"hljs-comment">// 多召回一些给 Rerank 用
                            .similarityThreshold(class="hljs-number">0.6)
                            .filterExpression(class="hljs-string">"tenantId == '" + TenantContext.get() + class="hljs-string">"'") class=class="hljs-string">"hljs-comment">// 权限下推
                            .build())
                    .stream().map(Doc::from).toList(), vt);

    CompletableFuture&lt;List&lt;Doc&gt;&gt; lexical =
            CompletableFuture.supplyAsync(() -&gt; bm25.search(query, topK * class="hljs-number">3), vt);

    return rrf(List.of(semantic.join(), lexical.join()), class="hljs-number">60).stream()
            .limit(topK).toList();
}

class=class="hljs-string">"hljs-comment">/** RRF:score = Σ class="hljs-number">1/(k + rank),无需归一化分数,跨源可比 */
private List&lt;Doc&gt; rrf(List&lt;List&lt;Doc&gt;&gt; ranks, int k) {
    Map&lt;String, Double&gt; score = new HashMap&lt;&gt;();
    Map&lt;String, Doc&gt; index = new HashMap&lt;&gt;();
    for (List&lt;Doc&gt; rank : ranks) {
        for (int i = class="hljs-number">0; i &lt; rank.size(); i++) {
            Doc d = rank.get(i);
            index.putIfAbsent(d.id(), d);
            score.merge(d.id(), class="hljs-number">1.0 / (k + i + class="hljs-number">1), Double::sum);
        }
    }
    return score.entrySet().stream()
            .sorted(Map.Entry.&lt;String, Double&gt;comparingByValue().reversed())
            .map(e -&gt; index.get(e.getKey()))
            .toList();
}

}

💡 技术亮点:元数据过滤下推到向量库,先在租户/权限分区内检索,避免"检索到了但无权看"的越权泄漏;虚拟线程跑阻塞 IO,不用把全链路改造成 WebFlux 也能扛高并发。

5.5 入库侧:切分、异步向量化与缓存#

@Service
public class IngestService {
class=class="hljs-string">"hljs-comment">/** 离线入库:带重叠的切分,防止语义被腰斩 */
public void ingest(List&lt;Document&gt; docs) {
    List&lt;Document&gt; chunks = new TokenTextSplitter(
            class="hljs-number">800, class="hljs-number">200, class="hljs-number">80, class="hljs-number">5000, true).apply(docs);  class=class="hljs-string">"hljs-comment">// 块 class="hljs-number">800 token,重叠 class="hljs-number">200
    vectorStore.add(chunks);                        class=class="hljs-string">"hljs-comment">// 内部自动调 EmbeddingModel 向量化
}

}

@Service public class EmbeddingService {

class=class="hljs-string">"hljs-comment">/** 异步向量化 + MD5 缓存:避免同一文本重复调 Embedding API */
@Async
public CompletableFuture&lt;List&lt;Float&gt;&gt; embedAsync(String text) {
    String cacheKey = DigestUtils.md5Hex(text);
    List&lt;Float&gt; cached = cacheService.get(cacheKey);
    if (cached != null) {
        return CompletableFuture.completedFuture(cached);
    }
    List&lt;Float&gt; vector = embeddingClient.embed(text).getResult().getOutput();
    cacheService.put(cacheKey, vector, Duration.ofHours(class="hljs-number">24));
    return CompletableFuture.completedFuture(vector);
}

}

💡 技术亮点:异步非阻塞不卡主线程;MD5 缓存命中即跳过 API 调用,生产环境可省 30%+ 的 Embedding 费用。

5.6 两个绕不开的运维问题#

知识库更新了,向量库还是旧的怎么办? 事件驱动 + 版本号对比 + 事务更新:

image

怎么证明你的 RAG 效果变好了? 建 golden 评测集(100~500 条问答对),盯三个指标:Recall@K(召没召回)、忠实度 Faithfulness(有没有编)、答案相关性。每次改分块、改模型、改阈值都跑一遍回归,接进 CI 当门禁。RAG 不做自动评测就是信仰,靠数据说话,不靠感觉。

知识的问题解决了,但用户不只是来问问题的——他还想查订单、提工单、发起退款。这就轮到工具登场了。

六、Tool Calling:模型不执行工具,它只是"点了名"#

这是最容易被答错的一个概念:

❌ 错误理解:AI 自己去调用接口。
✅ 正确理解:AI 说"我需要调用 queryOrderStatus,参数 orderId=SO2026…",真正执行的是我们的 Java 程序,执行完把结果回灌给模型,模型再组织语言。

用户提问 → AI 分析意图 → AI 输出「要调哪个工具 + 参数 JSON」
        → 我们的程序执行(含鉴权、限流、审计)→ 结果回灌 AI
        → AI 基于结果生成最终回答

这个"错位"恰恰是 Java 工程师的安全边界所在:所有真正的业务动作都在你的代码里,鉴权、幂等、审计、灰度,一个都跑不掉。

6.1 @Tool:描述就是给模型看的 API 文档#

@Component
public class OrderTools {
private final OrderService orderService;

class=class="hljs-string">"hljs-comment">/** 工具描述就是给模型看的 API 文档,写不清楚模型必然误调用 */
@Tool(class="hljs-string">"按订单号查询订单状态与物流轨迹。只读操作,不会修改任何数据。")
public String queryOrderStatus(
        @P(class="hljs-string">"订单号,形如 SO202610150001") String orderId) {
    class=class="hljs-string">"hljs-comment">// 入参强校验:格式非法时返回明确错误文本,让模型有机会自我修正
    if (!orderId.matches(class="hljs-string">"SO\\d{class="hljs-number">8,}")) {
        return class="hljs-string">"订单号格式非法,应为 SO 开头加数字,例如 SO202610150001";
    }
    return JsonUtils.toJson(orderService.queryStatus(orderId));
}

}

注解式声明零侵入,框架自动完成参数解析、类型转换、结果序列化。注意防御性校验的技巧:参数不合法时别抛异常了事,返回一段模型能看懂的错误说明,它会自己修正参数重试。

工具调不通、参数乱填是常态,生产上要加重试反馈闭环:

public ToolResult executeWithRetry(ToolCall call, int maxRetries) {
    for (int i = 0; i < maxRetries; i++) {
        try {
            Tool tool = toolRegistry.get(call.name());
            if (tool == null) throw new ToolNotFoundException("工具不存在:" + call.name());
            validateParams(tool.getInputSchema(), call.arguments());  // JSON Schema 强校验
            return tool.execute(call.arguments());
        } catch (ToolValidationException e) {
            call.setFeedback("参数错误:" + e.getMessage());          // 错误回灌,让模型自我修正
        }
    }
    return new ToolResult("error", "多次重试仍失败,请换个方式提问");
}

6.2 运行时收敛工具集:一个值回票价的优化#

为什么不能把所有 @Tool 一次性挂给模型?三个理由:

  • Token 成本爆炸:28 个工具能让单次请求从 800 Token 涨到 4200 Token;
  • 模型选错:描述里"订单"出现 6 次,用户问"订单到哪了",它去调了退款接口;
  • 权限边界失守:工具列表本身就是权限边界,写死在注解里等于把鉴权交给提示词——而提示词是拦不住越权的。

🚀 技术亮点:用 ToolProvider 按租户/角色/意图运行时动态下发工具集。我们把工具从 28 个收敛到 5~8 个后,单次请求 Token 从 4200 降到 1200,误调用率显著下降。

@Bean
ToolProvider scopedToolProvider(OrderTools orderTools, RefundTools refundTools) {
    return (ToolProviderRequest request) -> {
        UserContext ctx = UserContextHolder.current();   // 当前租户/角色,由网关透传
        ToolProviderResult.Builder builder = ToolProviderResult.builder();
    if (ctx.hasPermission(class="hljs-string">"order:read") &amp;&amp; looksLikeOrderQuestion(request.userMessage())) {
        builder.add(toolSpecification(class="hljs-string">"queryOrderStatus"), executor(orderTools));
    }
    if (ctx.hasPermission(class="hljs-string">"order:refund")) {         class=class="hljs-string">"hljs-comment">// 高危写操作单独授权,默认不给
        builder.add(toolSpecification(class="hljs-string">"applyRefund"), executor(refundTools));
    }
    return builder.build();
};

}

📎 注:toolSpecification / executor 为示意写法,实际用 LangChain4j 的 ToolSpecifications 等工具类生成,方法签名随版本变化,以官方文档为准。

工具是"手脚",那"这类活该按什么流程干"谁来管?答案是 Skills。

七、Skills:别把"操作手册"塞进 System Prompt#

Skills 是把"某类任务该怎么做"沉淀成文件(SKILL.md + 脚本 + 参考资料),让 Agent 在需要时才加载的行为手册。

7.1 灵魂设计:渐进式披露(Progressive Disclosure)#

image

为什么这个设计值钱?看对比:

对比项 塞进 System Prompt Skills
加载时机 永远常驻,字字占预算 按需加载,不用就不花 Token
规模上限 十几个流程就撑爆上下文 装 100 个 Skill 也只占摘要开销
可维护性 一改全乱、无法版本化 一个文件夹一个职责,可独立演进

用 Java 的类比:Skills 之于 Agent,就是"设计模式手册 + 操作规程"之于新员工。你不会让新员工入职第一天背完 500 页 SOP——你会给他一张清单,写明"遇到 XX 场景,去翻第 3 章"。反过来,System Prompt 就是"岗位 JD",必须常驻但必须极短。

7.2 落地三条硬规则#

  1. SKILL.md 正文控制在 500 行以内,超了就拆;
  2. 参考文件只放一层深——嵌套太深时 Agent 只会读到部分内容,拿到的信息是残缺的;
  3. 超过 100 行的参考文件,顶部必须放目录(TOC),让 Agent 在部分读取时也能看见全貌。

一个技能定义长这样:

# skill-definition.yaml —— 一个「工单分类」技能
name: ticket-classifier
version: 1.2.0
description: 用户工单自动分类与优先级判定
prompt_template: prompts/classify.st        # 提示词模板
tools:                                      # 依赖的工具(MCP 暴露)
  - query_user_profile
  - lookup_sla_policy
inputs:  [ticket_content, user_level]
outputs: [category, priority, reason]
eval_dataset: eval/ticket-golden-500.jsonl  # 回归评测集

7.3 三组高频混淆,一次掰清#

  • Skills vs MCP:Skills 是"知道怎么做"(SOP/流程),MCP 是"能碰到什么"(数据/动作)。Skill 的执行步骤里,通常会去调 MCP 工具;
  • Skills vs Agent:Skill 是"会做某件事的工具人",被动调用、输入→输出;Agent 是"会自己决定做什么的员工",主动规划、循环、调用多个 Skill;
  • Skills vs Prompt:Prompt 是这一次要做什么,Skills 是这一类任务按什么流程做。

📎 趋势提示:Agent Skills 已成为开放事实标准——同一套文件夹结构可被 Claude Code、Cursor、GitHub Copilot、Codex CLI、Gemini CLI 直接读取,"写一次,处处能跑"。JVM 侧也出现了对应抽象(如 LangChain4j 的 dev.langchain4j.skills),这个模式正在从"文档约定"走向"框架能力"。

Skills 管"怎么做",MCP 管"碰到什么",后者值得单独一大章——它是这两年 Java 圈最值得关注的新协议。

八、MCP:AI 世界的"USB-C 接口"#

MCP(Model Context Protocol)是 Anthropic 于 2024 年底开源的开放标准,统一了"AI 应用如何安全、标准化地访问外部数据和工具"。 类比:HTTP 统一了 Web API 的发现与调用;MCP 统一了 AI 工具的发现(tools/list)与调用(tools/call)。

8.1 它解决的是 M×N 集成灾难#

在 MCP 之前:OpenAI Function Calling 一套、Claude 私有格式一套、LangChain 又一套——每个工具要写 N 套适配。在 MCP 之后:实现标准协议即可被任意支持 MCP 的客户端调用。

image

8.2 三个核心原语(基于 JSON-RPC 2.0)#

原语 谁控制 含义 例子
Tools 模型控制 模型可调用的可执行函数 查询订单、写文件
Resources 应用控制 附加给模型的上下文数据 文件内容、配置、Git 历史
Prompts 用户控制 预定义提示模板 斜杠命令、菜单项

此外还有 Sampling:Server 反向请求 LLM 做推理,用于递归式智能任务。

8.3 传输方式演进(时效性知识,面试加分)#

传输 场景 状态
STDIO 本地进程,客户端直接启动 Server 适合本地 CLI 工具,无法跨网络
SSE + HTTP 远程部署 长连接占内存(约 80KB/连接)、弱网断线率高,Spring AI 2.0 起已标记废弃
Streamable HTTP 远程部署,首选 请求-响应与流式推送揉进同一套 HTTP 语义,网关/负载均衡/鉴权中间件都能正常介入
Stateless Streamable HTTP 无状态、Serverless 内存 < 5KB/请求,请求可路由到任意实例

⏱️ 2026-07-28 新版规范(第 5 版,历次最大修订)三个关键变化:

  1. 从"有状态连接"全面转向"无状态核心"——取消协议级初始化握手、移除 Session ID,每个请求自包含,可水平扩展、可跑 Serverless;
  2. 引入版本化扩展框架,Extensions 成为一等公民(MCP Apps、Tasks),新增 server/discover 能力发现、TTL/缓存语义;
  3. 生产级加固:强化 OAuth 2.0 / OIDC 适配,引入完整 JSON Schema 2020-12 和 W3C Trace Context 链路追踪——调用链和证据留存可追溯,对企业合规是决定性的。

8.4 MCP 和 Function Calling 到底啥关系#

Function Calling MCP
定位 模型能力:输出结构化调用意图 工具供给协议:发现、传输、鉴权
耦合 绑定具体模型厂商 模型无关,换模型不用改工具
复用 每个应用各写一遍 一次实现,多应用/多模型复用

一句话:Function Calling 是动词,MCP 是 USB-C 接口。前者是模型侧"决定调哪个函数",后者是工程侧"函数/资源如何标准化接入",互补不冲突。

8.5 Java 程序员为什么必须关注#

企业存量核心系统(订单、支付、风控、ERP、供应链)绝大多数是 Java 写的。MCP 的价值就在于让这些存量系统"零业务改动"地暴露给 AI。

三分钟把一个 Spring Boot 服务变成 MCP Server:

<!-- 三选一:WebMVC 项目 / WebFlux 响应式项目 / 本地 STDIO 工具 -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
# application.yml
spring:
  ai:
    mcp:
      server:
        name: order-mcp-server
        version: 1.0.0
        protocol: STREAMABLE            # 2.0 起 SSE 已废弃;可选 STREAMABLE / STATELESS
        streamable-http:
          mcp-endpoint: /api/mcp        # Streamable HTTP 端点
@SpringBootApplication
public class OrderMcpServerApplication {
public static void main(String[] args) {
    SpringApplication.run(OrderMcpServerApplication.class, args);
}

class=class="hljs-string">"hljs-comment">/**
 * 存量 Service 一行不改,包一层就变成 AI 可调用的工具:
 * 自动配置会把所有 ToolCallback 合并注册为 MCP Tools。
 */
@Bean
ToolCallbackProvider orderTools(OrderService orderService) {
    return MethodToolCallbackProvider.builder()
            .toolObjects(orderService)      class=class="hljs-string">"hljs-comment">// 内部是 @Tool 标注的方法
            .build();
}

}

客户端侧,一个 ToolProvider 可以同时挂多个 MCP Server:

// LangChain4j 风格:工具的召回交给模型运行时决策
@Bean
ToolProvider mcpToolProvider() {
    McpClient orderClient = new DefaultMcpClient.Builder()
            .transport(new HttpMcpTransport.Builder()
                    .sseUrl("http://order-mcp:8080/api/mcp")
                    .logRequests(true)
                    .logResponses(true)
                    .build())
            .build();
return McpToolProvider.builder()
        .mcpClients(List.of(orderClient))
        .failIfOneServerFails(false)     class=class="hljs-string">"hljs-comment">// 单个 Server 挂了不影响其他工具
        .build();

}

还可以运行时动态发现并注册新工具,新增外部工具只需部署一个 MCP Server,应用自动发现,无需重启,真正做到热插拔:

@Bean
public List<ToolCallback> dynamicMcpTools(McpClientManager manager) {
    return manager.getClients().stream()
            .flatMap(client -> client.listTools().stream())
            .map(tool -> (ToolCallback) tool)
            .collect(Collectors.toList());
}

8.6 安全红线与写操作纪律#

⚠️ 安全红线:Spring AI 官方文档明确——HTTP 传输的 MCP Server 默认暴露的是无鉴权的 JSON-RPC 端点,任何能访问到的客户端都能列出并调用全部工具。上线前必须前置安全边界:OAuth2 / OIDC 鉴权、网关限流、工具级权限、调用审计。绝不能把 MCP Server 裸暴露在内网。

写操作工具还要守三条纪律:权限校验、二次确认(人在回路 HITL)、幂等:

/** 写操作工具:权限 + 二次确认 + 幂等 + 审计,一个都不能少 */
@Tool("提交报销单,写操作,需要用户确认")
public ExpenseResult submitExpense(SubmitExpenseRequest request) {
    // 幂等键控制:模型重试、网络抖动都不会导致重复提交
    return idempotentService.execute(
            request.idempotentKey(),
            () -> expenseService.submit(request));
}

配套手段:idempotencyKey + 业务 outbox + 唯一索引;工具白名单、只读工具优先、高危写操作默认不开放。

手脚齐全了,最后需要一个"会自己干活的人"把它们组织起来——Agent 出场。

九、Agent:从"会思考"到"能交付"#

Agent = LLM(大脑)+ Planning(规划)+ Memory(记忆)+ Tools(手脚),核心是自主决定下一步做什么。区别于 LLM 的"一问一答",Agent 是"给目标,自己完成"。

9.1 ReAct 循环:思考 → 行动 → 观察#

image

Java 里我们熟悉的责任链 + 策略模式,就很像这个决策循环:

while (未完成目标) {
    思考 nextStep;
    选择 tool;
    执行 tool.call(params);
    观察 toolResult;
    更新上下文;
}

9.2 三层递进范式:别混为一谈#

层级 范式 解决什么问题 终止机制 风险
内层 · 推理 ReAct(Thought → Action → Observation) 单个智能体"怎么想、怎么动" 模型自判断 易死循环、易提前终止
中层 · 调度 循环工程 Loop(图状态机 / 多智能体循环) 多步骤任务"怎么跑完、怎么收敛" 结构化规则 + 状态判断 无容错、无安全管控
外层 · 管控 驾驭工程 Harness(围栏、刹车、运维底座) 生产环境"怎么可控、可上线" 全局策略 ——

公式:生产级多智能体 = 驾驭工程(外层管控)+ 循环工程(中层调度)+ ReAct(内层推理)

9.3 带"刹车和兜底"的 ReAct 循环#

Demo 里只有一个 while(true),生产里它会死在第一道坎上。一个能上线的循环必须有四道防护:

/**
 * 四道防护:① 硬性步数上限 ② 工具异常转文本回灌 ③ 预算(时间/Token)熔断 ④ 兜底收敛话术
 */
public Answer run(String userInput, int maxSteps, long budgetMillis) {
    List<ChatMessage> messages = new ArrayList<>();
    messages.add(SystemMessage.from(REACT_PROMPT + renderToolSchemas()));
    messages.add(UserMessage.from(userInput));
long deadline = System.currentTimeMillis() + budgetMillis;

for (int step = class="hljs-number">0; step &lt; maxSteps; step++) {                    class=class="hljs-string">"hljs-comment">// ① 步数上限
    if (System.currentTimeMillis() &gt; deadline) {                 class=class="hljs-string">"hljs-comment">// ③ 预算熔断
        return new Answer(class="hljs-string">"任务超时,已停止并返回已获得的部分结论。");
    }

    AiMessage ai = chatModel.generate(messages).content();
    if (!ai.hasToolExecutionRequests()) {
        return new Answer(ai.text());                            class=class="hljs-string">"hljs-comment">// 收敛:给出最终答复
    }

    messages.add(ai);
    for (ToolExecutionRequest req : ai.toolExecutionRequests()) {
        String result;
        try {
            result = toolExecutor.execute(req, UserContextHolder.tenantId());
            result = truncate(result, class="hljs-number">2000);                     class=class="hljs-string">"hljs-comment">// 截断:防工具结果撑爆上下文
        } catch (Exception e) {
            class=class="hljs-string">"hljs-comment">// ② 异常不让它抛穿:转成文本回灌,模型有机会换一条路
            result = class="hljs-string">"TOOL_ERROR: " + e.getMessage() + class="hljs-string">",请更换方式或告知用户稍后重试。";
        }
        messages.add(ToolExecutionResultMessage.from(req, result));
    }
    class=class="hljs-string">"hljs-comment">// 超过预算就压缩历史:保留最近 N 轮 + 早期摘要
    messages = contextCompactor.compact(messages, TOKEN_BUDGET);
}
return new Answer(class="hljs-string">"步骤超限,已中断。请把问题拆得更小一些。");       class=class="hljs-string">"hljs-comment">// ④ 兜底

}

💡 技术亮点:除了 MAX_STEPS,还要做工具调用去重——同一工具同参数连续调用直接短路,这是"反复调同一工具烧钱不收敛"的特效药;ToolResponse 必须截断,一个 10 万行的 SQL 结果塞进上下文等于直接爆 token。

上下文压缩的常见实现:记录每轮 token 数,超限时把旧消息做摘要,保留最近几轮原话:

public List<Message> manageContext(List<Message> history) {
    int tokenCount = countTokens(history);
    if (tokenCount < MAX_TOKENS * 0.8) return history;
class=class="hljs-string">"hljs-comment">// 取前一半旧消息生成摘要
List&lt;Message&gt; oldMsgs = history.subList(class="hljs-number">0, history.size() / class="hljs-number">2);
String summary = llm.call(class="hljs-string">"请用一句话总结以下对话:", oldMsgs);

class=class="hljs-string">"hljs-comment">// 新上下文 = 摘要 + 最近几轮原话
List&lt;Message&gt; newContext = new ArrayList&lt;&gt;();
newContext.add(new SystemMessage(class="hljs-string">"历史摘要:" + summary));
newContext.addAll(history.subList(history.size() / class="hljs-number">2, history.size()));
return newContext;

}

9.4 框架层面:AiServices 极简装配#

自己写循环是为了理解原理,实际项目直接用框架,ReAct 推理循环它帮你实现了:

@Configuration
public class AgentConfig {
@Bean
public CustomerServiceAgent customerServiceAgent(
        ChatLanguageModel chatModel,
        OrderQuerySkill orderSkill,
        LogisticsQuerySkill logisticsSkill) {

    return AiServices.builder(CustomerServiceAgent.class)
            .chatLanguageModel(chatModel)
            .tools(orderSkill, logisticsSkill)                       class=class="hljs-string">"hljs-comment">// 注入业务技能
            .chatMemory(MessageWindowChatMemory.withMaxMessages(class="hljs-number">20)) class=class="hljs-string">"hljs-comment">// 对话记忆
            .maxIterations(class="hljs-number">5)                                        class=class="hljs-string">"hljs-comment">// 最大决策步数,防死循环
            .timeout(Duration.ofSeconds(class="hljs-number">30))                         class=class="hljs-string">"hljs-comment">// 全局超时
            .build();
}
class=class="hljs-string">"hljs-comment">// 使用:agent.chat(class="hljs-string">"我的订单 class="hljs-number">1001 什么时候到?")

}

记忆分两层:短期记忆用滑动窗口(MessageWindowChatMemory),长期记忆可以落向量库,按用户画像检索。

9.5 Agent vs Workflow:别为了用 Agent 而用 Agent#

Workflow 是路径写死,Agent 是路径由模型决定。能确定性解决的流程就用 Workflow,稳定又便宜;只有任务路径真的无法预知时,才值得上 Agent——这是成本与稳定性的问题,不是技术问题。

评估一个 Agent 好不好,看这些指标:任务完成率、平均步数、工具调用准确率、端到端延迟、成本/token,再配合轨迹回放(Trace)排查失败原因。

概念全讲完了,它们到底怎么协同?来看一次完整的请求。

十、端到端串讲:一次客服请求的全链路#

场景:电商智能客服,用户问——"我的订单 SO202610150001 为什么还没发货?"

image

这条链路里,所有概念都出场了:Skills 决定流程 → LLM 做意图识别 → Tool 决定动作 → MCP 打通存量系统 → RAG 补业务知识 → 向量与余弦相似度负责召回 → 最后回到工程治理层保证稳定、安全、便宜。不是把全部知识塞给 LLM,而是 Agent 决定查什么、调什么、怎么答。

同样的骨架换个场景一样成立:

  • 企业差旅助手:"差旅报销标准是什么?我上个月有没有未报销单据?"——意图识别拆成两个子问题,RAG 查制度(回答带 [DOC-013] 引用),MCP 查报销系统拿到 2 笔未报销单据,最后还能追问"要不要帮你提交";
  • 智能运维助手:"昨天服务器 CPU 告警多少次?"——RAG 检索操作手册无果,转调监控系统的 getAlerts(...) 工具,拿到"35 次"后由 LLM 组织成自然语言回答。

知识问答 + 业务查询 + 可继续执行动作,三者齐活,这才是企业级 AI 应用。

十一、工程治理层:Java 的主场#

面试官真正想听的下一句往往是:"如果 1000 个用户同时问,你的 Spring Boot 后端怎么设计?"三道防护,缺一不可:

/**
 * ① 流式输出(SSE + WebFlux):大模型很慢,绝不能同步阻塞,边生成边返回压 TTFT;
 * ② 多租户限流:按租户做 Redis 令牌桶,防止某个租户把配额吃光;
 * ③ 超时降级:模型超时不能挂页面,降级到"知识库直出"保住可用性。
 */
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestParam String q,
                           @RequestHeader("X-Tenant-Id") String tenantId) {
return redisRateLimiter.acquire(tenantId)                 class=class="hljs-string">"hljs-comment">// ② Redis + Lua 令牌桶
        .filter(RateLimitResult::allowed)
        .switchIfEmpty(Flux.error(new BizException(class="hljs-string">"TOO_MANY_REQUESTS")))
        .flatMapMany(ok -&gt; chatClient.prompt()
                .user(q)
                .stream()                                  class=class="hljs-string">"hljs-comment">// ① 流式
                .content()
                .timeout(Duration.ofSeconds(class="hljs-number">30))           class=class="hljs-string">"hljs-comment">// ③ 整体超时
                .onErrorResume(e -&gt;                        class=class="hljs-string">"hljs-comment">// ③ 降级兜底
                        Flux.just(class="hljs-string">"模型服务繁忙,已为你返回知识库原文检索结果。")));

}

这几个数字要能报出来,它们体现的是工程体感:

  • 流式输出:首字延迟从 8s 降到 500ms 量级,"打字机效果"直接决定用户去留;
  • 语义缓存:重复问题命中缓存后跳过"检索 + 重排 + 生成"全链路,推理成本可降 40%~70%;
  • 模型分层路由:"意图识别、格式转换"给小模型,"复杂推理、关键决策"给旗舰模型,整体成本可降 40%~60%;
  • 模型抽象:因为做了统一 ChatModel 抽象,换模型 = 改一条配置;没有抽象的项目,换模型 ≈ 重新开发。

其余几件老本行的事,一件都不能少:

  • 多租户隔离:元数据带 tenantId / acl / version,在检索阶段做 pre-filter,向量库按租户分区——不能只靠"检索后再过滤";
  • 可观测:全链路 trace 打点 prompt / token / latency / 工具调用,Micrometer + OpenTelemetry,再用 W3C Trace Context 打通 MCP 全链路;
  • 模型挂了三层兜底:同模型多 Key/多地域重试 → 换备选模型(靠统一抽象层)→ 降级到知识库原文直出。可用性靠架构,不靠模型;
  • 数据安全:网关层脱敏,敏感字段不进模型,必要时私有化部署。

十二、踩坑大全:技术难点与解决方案对照表#

这张表融合了所有一线踩坑,建议直接收藏当检查清单用:

# 难点 典型表现 解决方案 Java 侧抓手
1 幻觉 一本正经编造订单号/政策 Prompt 硬约束 + 强制引用 + 低温度 + 阈值拒答 + 人工兜底;RAG 只能降低概率,不能彻底消除 系统提示 + Advisor + 引用回填
2 检索召回不准 向量相似 ≠ 语义相关;编号/术语/人名是稠密检索死穴 混合检索(Dense + BM25 → RRF)→ Cross-Encoder 重排 → top-5~8;Multi-Query 查询改写 ES 一次查询吃 BM25 + 向量;similarityThreshold 调参
3 切片语义割裂 固定长度硬切,论证被腰斩 结构感知切片 + 10%~20% overlap + 父子块(子块检索、父块喂模型)+ 元数据增强 可插拔切片器,策略变更走离线评测
4 上下文爆炸 / 成本失控 Agent 多步任务 Token 消耗是普通对话几十倍 滑动窗口 + 历史摘要 + 工具结果截断 + Skills 按需加载 + 语义缓存 + 模型分层路由 + 稳定前缀提 KV Cache 命中 Redis 语义缓存 + 网关 Token 计量 + 成本看板
5 Agent 死循环 / 失控 反复调同一工具、跑偏、无限烧钱 硬性步数上限 + 时间/Token 双预算熔断 + 调用去重 + 图状态机替代 while(true) maxIterations + Spring StateMachine
6 ���具误调用 / 参数错误 查订单却调了退款接口;参数格式乱填 优化 @Tool 描述 + Few-shot 示例 + JSON Schema 强校验 + 错误回灌重试闭环 + 运行时收敛工具集 ToolProvider 动态决策 + Bean Validation
7 工具安全 / 越权 Prompt 注入、越权查他人订单 权限过滤下推检索层 + 工具白名单 + 最小权限 + 审计日志 + 高危操作人在回路(HITL) Spring Security 工具级权限 + 网关透传用户上下文
8 MCP 生产化 HTTP 端点默认无鉴权,裸暴露即裸奔 OAuth2/OIDC 前置 + 网关限流 + 工具级权限 + 调用审计;优先 Streamable HTTP / STATELESS Spring Security + MCP Server + W3C Trace Context
9 高并发与延迟 同步阻塞打满线程池;用户等 8 秒才见字 WebFlux + SSE 流式 + 虚拟线程 + 并行召回 + 多租户令牌桶 + 超时降级知识库直出 Redis + Lua 令牌桶、timeout + onErrorResume
10 多租户数据泄露 不同客户文档混在一个向量库 元数据 tenantId 检索阶段 pre-filter + 按租户 Partition/Collection 隔离 向量库 filter 表达式 + 请求级租户上下文
11 向量数据不一致 业务库更新了,检索还是旧答案 Canal/CDC/Debezium 订阅 binlog 增量同步 + 版本号对比 + 定时全量对账 + 强制刷新 API 事件驱动 + 事务更新
12 写操作幂等 重试导致重复退款 / 重复提单 idempotencyKey + 业务 outbox + 唯一索引 + 二次确认 AOP 前置校验 + 幂等服务
13 效果无法度量 "感觉还行",上线后无法回归 黄金问答集,量化 recall@k / 忠实度 / 幻觉率;Prompt 与切片变更过回归门禁 RAGAS / TruLens 接入 CI
14 技术栈易碎 模型、协议、框架月月迭代(MCP 一年内规范大改、SSE 转弃用) 把变化关进适配层:模型统一 ChatModel 抽象、工具走 MCP 标准协议、Prompt 版本化 接口抽象 + 配置驱动 + 契约测试
15 多轮上下文丢失 "它的标准是多少?"不知道"它"指谁 基于历史会话做问题改写,补全指代再检索 Query Rewrite 组件
16 数据安全 敏感数据进入模型 网关脱敏 + 权限过滤 + 私有化部署 网关过滤器 + 数据分级

十三、快问快答:面试官的连环追问#

# 追问 一句话回答
1 余弦相似度为什么不用欧氏距离? 文本语义更关心方向而非长度,模长受文本长度影响会引入噪声;归一化后两者排序等价
2 归一化后为什么用内积? 省掉查询期两次开方和一次除法,检索是热路径,收益直接体现在 P99
3 向量库和 ES 有什么区别? 向量库专精 ANN 与高维检索;ES 强在"一套系统同时做文本与向量",混合检索最省事。选型看运维成本和规模
4 有了 1M 上下文还要 RAG? 长上下文是预算不是替代。资料越多噪音越多、成本延迟越高,RAG 解决"先过滤再喂"
5 RAG 和微调怎么选? 知识频繁更新、要引用可追溯 → RAG;固定风格、专业格式、压缩 Prompt → 微调。生产常组合:小模型微调 + RAG 检索 + Agent 工具
6 Agent 和 Workflow 的区别? Workflow 路径写死,Agent 路径由模型决定。能确定性解决就用 Workflow,别为了用 Agent 而用 Agent
7 MCP 和 Function Calling 的关系? FC 是模型侧"决定调哪个函数",MCP 是工程侧"函数如何标准化接入"。MCP 让工具一次实现、多处复用
8 Skills 和 Prompt 的区别? Prompt 是这一次做什么,Skills 是这一类任务按什么流程做,靠渐进式披露做到"装很多但不占上下文"
9 为什么需要 Rerank? 向量召回是双塔独立编码,无法深度交互;Rerank 交叉编码准但慢,所以只对 Top-50 精排
10 向量维度多少合适? 常见 768/1024/1536/3072,维度越高表达越细但成本线性增长,企业级常用 1024 + PQ 量化
11 模型挂了怎么办? 三层兜底:多 Key/多地域重试 → 换备选模型 → 知识库原文直出。可用性靠架构,不靠模型
12 怎么证明 RAG 有效? 黄金问答集,量化 recall@k / 忠实度 / 幻觉率,Prompt 与切片变更必须过回归门禁
13 Java 做 AI 比 Python 强在哪? 模型层 Python 生态更好,但工程层 Java 更强:虚拟线程扛高并发、强类型契合结构化输出、成熟的微服务/熔断/可观测体系,而且企业 90% 的业务系统本身就是 Java 写的——MCP Server 直接挂在现有服务上,不用重写

十四、收尾:一张速查表 + 三句忠告#

概念速查表,存好:

概念 一句话本质 关键参数 / 关注点 Java 落地
LLM 概率生成模型,不是知识库 temperature、maxTokens、上下文预算 ChatModel / ChatClient
Token 计费与长度单位 中文约 1 字 ≈ 1~1.5 token 网关侧计量与配额
Embedding 把语义变成高维坐标 维度、是否 L2 归一化、模型一致性 EmbeddingModel.embed()
余弦相似度 只看方向的相似度 [-1,1];归一化后 == 内积 手写兜底 + 交给向量库
向量库 高维向量的存储与 ANN 检索 recall@K、HNSW efSearch、IVF nprobe Milvus / pgvector / ES 8.x
RAG 外部知识管理 + 检索 + 生成 topK、阈值、切片策略、Rerank QuestionAnswerAdvisor
Tool Calling 模型点名,程序执行 描述质量、参数 Schema、权限 @Tool / ToolCallback
Agent 自己决定下一步干什么 步数上限、预算熔断、终止条件 AiServices + 状态机
MCP AI 工具的统一插座 Tools/Resources/Prompts;Streamable HTTP spring-ai-starter-mcp-server-*
Skills 按需加载的行为手册 渐进式披露、SKILL.md < 500 行、引用只放一层 文件目录 + 按需注入

最后送三句忠告,都是真金白银换来的:

  1. 把 LLM 当成一个"不可靠的第三方服务"来治理——超时、重试、熔断、降级、可观测,老本行一套都不能少,只是这次处理的是概率性输出;
  2. 用 RAG 把答案锚定在事实上,用评测集把效果变成数字——这两件事做了,项目才算从 Demo 走向生产;
  3. Skills、MCP、Agent 本质都是面向对象思想在 AI 时代的复用:封装、抽象、标准化接口。想清楚这一点,新名词再多也不慌。

记住这句话就够了:AI 应用的本质,是把"不确定的模型能力",装进"确定的工程系统"里。而这件事,恰好是 Java 工程师最擅长的事。 造发动机的不多,造整车、修高速、建加油站的,才是大多数人的战场。


聊个具体的:你做 RAG 或 Agent 落地时,踩过最疼的坑是哪个?切片把语义切碎了、检索召不准、还是模型一本正经地编?评论区聊聊你的场景和参数,我看到会挨个回复交流。

如果这篇全景图帮你把概念串起来了,点个赞、点个在看、顺手关注一下——后续还会持续更新 Java AI 工程化的实战干货,比如混合检索调参实录和 Agent 状态机设计,别错过。


原文链接:https://www.cnblogs.com/zrui-xyu/p/23544084

评论

© 2026 松岛川树