第八周用 Java + Spring AI 跑通了大模型应用开发,对话、Prompt 工程、Tool Calling、RAG 四件事都做了。但 Spring AI 的生态确实不如 Python 社区丰富——组件少,RAG 和 Agent 的现成方案也少。第九周切到 LangChain,五天时间把 RAG 的每一个环节都拆开看了一遍,最后拼成一个生产级的知识库问答系统。

一、LangChain 核心组件:管道符搭乐高

1.1 和 Spring AI 的对应关系

第 8 周用 Spring AI 做四件事,每件事都要自己写不少胶水代码。LangChain 把这些封装成标准化的可插拔组件,用管道符 | 串起来就行。

LangChain 组件 职责 Spring AI 对应
Models 对接大模型 ChatClient / ChatModel
Prompts 模板化 Prompt System Message / Few-shot
Output Parsers 结构化输出 Jackson 手动解析 JSON
LCEL 管道符串联组件 手写调用链

1.2 LCEL:这周用得最多的东西

LCEL(LangChain Expression Language)是 LangChain 1.0 的核心设计。数据像工厂流水线一样,用管道符 | 从左流到右:

chain = prompt | llm | parser
result = chain.invoke({"question": "我想学Java"})

对比不用 LCEL 的嵌套写法:

# 嵌套调用,改一个环节要改好几处
messages = prompt.invoke({"question": "我想学Java"})
response = llm.invoke(messages)
result = parser.invoke(response)

# LCEL 管道符,一行搞定
result = (prompt | llm | parser).invoke({"question": "我想学Java"})

LCEL 还有两个好处。一是可组合,chain1 | chain2 直接把两条链拼成一条更长的链。二是自动流式,任何 LCEL 链都能直接调 .stream() 逐 token 推送。

1.3 Output Parsers:不用手动 json.loads 了

Spring AI 里要手动用 Jackson 解析模型返回的 JSON。LangChain 的 JsonOutputParser 配合 Pydantic 模型,自动把 schema 注入 Prompt 告诉模型格式要求,返回后自动解析成 Python 字典:

from langchain_core.output_parsers import JsonOutputParser
from pydantic import BaseModel, Field

class BookRecommendation(BaseModel):
    title: str = Field(description="书名")
    author: str = Field(description="作者")
    reason: str = Field(description="推荐理由")

parser = JsonOutputParser(pydantic_object=BookRecommendation)
chain = prompt | llm | parser

result = chain.invoke({"question": "我想学Elasticsearch"})
# 直接就是字典:{'title': '...', 'author': '...', 'reason': '...'}

不用手写"请返回 JSON 格式",不用手动 json.loads()

二、RAG 六步流水线:每一步都拆开看

2.1 完整流程

Day 58 把 RAG 拆成了六个组件,每个各管一段:

加载文档(Loaders)→ 切分文档(Splitters)→ 文本转向量(Embeddings)→ 存入向量库(VectorStore)→ 检索相关文档(Retrievers)→ LLM 基于文档回答。

左边三步是准备阶段(把文档变成向量存起来),右边三步是查询阶段(根据问题检索文档、生成回答)。

2.2 文档切分:chunk_size 和 overlap

RecursiveCharacterTextSplitter\n\n\n → `` 的优先级递归切分,尽量在段落和句子边界断开。

两个关键参数:

参数 太小 太大 推荐值
chunk_size 信息碎片化 超出上下文窗口,噪声多 300-500
chunk_overlap 边界信息丢失 重复数据多 chunk_size 的 10%-20%

overlap 的作用:相邻 chunk 之间留一段重叠区域,防止关键信息在切分边界被一刀两断。比如 chunk_size=500、overlap=50,Chunk 1 是字符 0-500,Chunk 2 是字符 450-950,中间 450-500 这段是重叠的。

2.3 Embedding:把文字变成"语义坐标"

Embedding 模型把一段文字变成一个浮点数向量,比如 [0.12, -0.34, 0.56, ...](通常 512 或 1536 维)。语义相近的文本,向量在空间里离得近。

from langchain_community.embeddings import HuggingFaceEmbeddings

embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-small-zh-v1.5")
vector = embeddings.embed_query("设备温度过高怎么办?")
# [0.12, -0.34, 0.56, ...] 512维浮点数

2.4 向量库选型

向量库 定位 适合场景
ChromaDB 轻量级,开箱即用 学习、原型开发
FAISS Facebook 出品,单机极速 百万级向量
Milvus 分布式,生产级 大规模生产环境

学习阶段用 ChromaDB 够了,三行代码存完:

from langchain_community.vectorstores import Chroma

vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db"
)

persist_directory 把向量数据存到磁盘,下次打开不用重新 Embedding。

三、向量数据库深入:打开黑箱

3.1 为什么用余弦相似度

文本检索几乎都用余弦相似度,不用欧氏距离。原因很简单:文本向量的"长度"没有意义。一篇长文档和一篇短文档可能说的是同一件事,向量方向一样但长度不同。

余弦只看方向不看长度。两个向量方向一致,不管长度差多少倍,余弦相似度都是 1。欧氏距离就不行了,同样方向、十倍长度差的两个向量,欧氏距离会判为"差得很远"。

from sklearn.metrics.pairwise import cosine_similarity, euclidean_distances
import numpy as np

vec_a = np.array([[0.9, 0.1, 0.05]])   # "设备温度过高"
vec_d = np.array([[9.0, 1.0, 0.5]])    # 方向一样,长度10倍

print(f"余弦: {cosine_similarity(vec_a, vec_d)[0][0]:.4f}")  # 1.00(完全相似)
print(f"欧氏: {euclidean_distances(vec_a, vec_d)[0][0]:.4f}")  # 11.7(差得远)

3.2 ChromaDB 的元数据过滤

ChromaDB 支持给每个文档打标签,查询时用 where 过滤。实际项目中很实用——只想从"电气故障"类的文档里检索,不混入"机械故障"的内容:

collection.add(
    documents=["设备温度超过80度应立即停机"],
    metadatas=[{"category": "温度故障", "page": 12}],
    ids=["doc1"]
)

# 只在"温度故障"里检索
results = collection.query(
    query_texts=["设备异常"],
    where={"category": "温度故障"}
)

3.3 Milvus 的 HNSW 索引

Milvus 适合百万级以上的向量场景。它的 HNSW 索引把向量组织成多层图结构,查询时从顶层逐层往下搜索,每层跳过大量不相关的向量,速度比暴力搜索快几个数量级。学习阶段不需要装,知道什么时候该切过去就行。

四、Agent + Memory:让 AI 学会思考和记事

4.1 Chain vs Agent

Chain 是固定流水线,数据从左到右走一遍就结束。Agent 是自主决策——LLM 在循环中自己判断下一步该做什么,调哪个工具、还是直接回答。

对比 Chain Agent
流程 固定,写死在代码里 动态,LLM 自主决定
工具调用 程序员 if-else 控制 LLM 根据意图自动选择
适合场景 流程固定的任务 需要多步推理的任务
类比 流水线工人 有经验的师傅

4.2 ReAct 框架

Agent 的核心循环叫 ReAct(Reasoning + Acting),每一轮三步:

  1. Thought:我要做什么、为什么
  2. Action:调用哪个工具、传什么参数
  3. Observation:工具返回了什么结果

循环执行直到 LLM 觉得信息够了,输出 Final Answer。

@tool 装饰器定义工具,docstring 写清楚工具用途(LLM 靠描述判断什么时候用它):

from langchain_core.tools import tool

@tool
def query_device_temperature(device_id: int, days: int = 7) -> str:
    """查询指定设备最近N天的平均温度。参数:device_id 设备编号,days 天数"""
    return f"设备{device_id}最近{days}天平均温度: 78.6°C"

@tool
def send_alert(device_id: int, message: str) -> str:
    """向运维人员发送设备告警通知。参数:device_id 设备编号,message 告警内容"""
    return f"已发送告警:设备{device_id} - {message}"

Agent 收到"3号设备温度超标了,帮我发个告警",会自动先调 query_device_temperature 确认温度,再调 send_alert 发通知。不用写 if-else。

4.3 三种 Memory 策略

没有 Memory 的 Agent 像金鱼,每次对话都是全新的。LangChain 提供三种记忆策略:

策略 原理 适合场景
BufferMemory 保存完整对话历史 短对话(<10轮)
WindowMemory 只保留最近 K 轮 中等长度,控制 token
SummaryMemory LLM 把历史总结成摘要 长对话、客服场景

RunnableWithMessageHistory 包装 Chain,通过 session_id 区分不同用户的对话历史:

chain_with_history = RunnableWithMessageHistory(
    chain,
    lambda session_id: session_store.setdefault(session_id, ChatMessageHistory()),
    input_messages_key="input",
    history_messages_key="history"
)

运维人员说"帮我查一下3号设备",Agent 查了温度。接着说"帮我发个告警",Agent 记得上一轮是3号设备,直接发。不用每次重复说设备编号。

五、生产级 RAG:从"能跑"到"能用"

5.1 四个升级

Day 58 做的基础版 RAG 能跑,但 Day 61 做生产级的时候发现,检索质量才是决定回答好坏的关键。生产级做了四个升级:

  1. 多格式加载:PDF、CSV、TXT、HTML 统一处理,根据文件后缀自动选 Loader
  2. 智能切分:按标题和段落递归切分,不是固定字数一刀切
  3. 混合检索 + Re-Ranking:向量检索和 BM25 关键词检索并行,Cross-Encoder 精排
  4. 带引用的回答:每个 chunk 带来源标签,回答里标注"根据《XX手册》第X页"

5.2 智能切分

基础版按固定 500 字切一刀,一个完整的操作指南可能被切成两半。生产版在 separators 里加了标题分隔符,优先在标题和段落边界断开:

splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=60,
    separators=["\n## ", "\n### ", "\n\n", "\n", " ", ""]
)

设备手册有清晰的章节结构,在标题处断开能保证每个 chunk 的内容属于同一个主题。

5.3 混合检索

向量检索擅长语义匹配(“温度过高"→"散热异常”),但对专有名词(“ISO VG68”)不敏感。BM25 关键词检索擅长精确匹配术语。两路融合,各取所长:

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever

# 向量检索 + BM25 关键词检索
ensemble_retriever = EnsembleRetriever(
    retrievers=[vector_retriever, bm25_retriever],
    weights=[0.4, 0.6]  # 设备手册术语多,关键词权重稍高
)

weights 根据文档类型调。术语多的手册调高关键词权重,自然语言描述多的调高向量权重。

5.4 Re-Ranking 精排

混合检索返回 10 个 chunk,里面可能有噪声。Cross-Encoder 对每个 chunk 重新精准打分,只保留 top-3 给 LLM:

from langchain.retrievers.document_compressors import CrossEncoderReranker

reranker = CrossEncoderReranker(model=cross_encoder, top_n=3)
retriever = ContextualCompressionRetriever(
    base_retriever=ensemble_retriever,
    base_compressor=reranker
)

粗排 10 个 → 精排 3 个 → LLM 拿到的上下文质量高很多。

5.5 带引用的回答

给每个 chunk 的 metadata 打上 source 标签,format_docs 函数拼接文档时带上来源信息,Prompt 里要求 LLM 标注 [来源: 文件名]。用户看到"根据《设备手册》第12页…"可以自己去翻原文验证。

5.6 增量更新

知识库会不断更新,不能每次都全量重建。ChromaDB 支持 add_documents() 增量写入,只需要对新文档做切分和 Embedding,已有的向量数据不受影响。

六、这周的整体感受

第 8 周用 Spring AI 做 AI 助手的时候,感觉是"在 Java 里硬塞了一个 AI 功能"。Spring Boot 的生态很成熟,但 AI 这块的封装确实不如 Python 社区丰富。

这周切到 LangChain,最直观的感受是组件化做得好。换模型改一行,换检索策略改一段,加 Re-Ranking 就是多包一层。LCEL 的管道符设计让每一步都可以独立调试和替换。

另一个感受是 RAG 的水比想象中深。Day 58 做的基础版 RAG 能跑,但 Day 61 做生产级的时候发现,检索质量才是决定回答好坏的关键。混合检索、Re-Ranking、智能切分,这些在基础教程里不会讲的东西,才是真正影响用户体验的地方。

向量数据库那块,余弦相似度 vs 欧氏距离的对比让我理解了为什么文本检索几乎都用余弦——向量的长度没有意义,方向才是关键。这种"理解了原理才知道为什么这么选"的感觉挺好的。

第 8 周和第 9 周对比:

对比 Spring AI(Java) LangChain(Python)
组件丰富度 基础够用 更丰富,RAG/Agent 生态成熟
代码风格 链式调用 + 注解 管道符 `
换模型 改 yml 配置 改初始化那一行
结构化输出 Jackson 手动解析 JsonOutputParser 自动解析
Agent 需要自己写循环 create_react_agent 一行创建
Memory 无内置方案 Buffer/Window/Summary 三种策略

下周进入 LangGraph,学状态图和多步骤 Agent 工作流。Agent 已经能自主选工具了,但流程还是线性的。LangGraph 要解决的是"Agent 怎么根据中间结果走不同分支"的问题。

更多推荐