用 Python + LangChain 从零搭了一个能查文档、能调工具、还能记住对话的 AI 助手
第八周用 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),每一轮三步:
- Thought:我要做什么、为什么
- Action:调用哪个工具、传什么参数
- 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 做生产级的时候发现,检索质量才是决定回答好坏的关键。生产级做了四个升级:
- 多格式加载:PDF、CSV、TXT、HTML 统一处理,根据文件后缀自动选 Loader
- 智能切分:按标题和段落递归切分,不是固定字数一刀切
- 混合检索 + Re-Ranking:向量检索和 BM25 关键词检索并行,Cross-Encoder 精排
- 带引用的回答:每个 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 怎么根据中间结果走不同分支"的问题。
更多推荐



所有评论(0)