LangChain 框架知识点详解 --- 以智旅云图项目为例
本文档以
agent_travle/zhilv-yuntu项目为实战案例,逐一讲解项目中涉及的所有 LangChain 框架知识点,适合边看代码边学 LangChain。
目录
- [LangChain 生态与包结构](#LangChain 生态与包结构 "#1-langchain-%E7%94%9F%E6%80%81%E4%B8%8E%E5%8C%85%E7%BB%93%E6%9E%84")
- [ChatOpenAI --- LLM 客户端](#ChatOpenAI — LLM 客户端 "#2-chatopenai--llm-%E5%AE%A2%E6%88%B7%E7%AB%AF")
- [OpenAIEmbeddings --- 文本嵌入](#OpenAIEmbeddings — 文本嵌入 "#3-openaiembeddings--%E6%96%87%E6%9C%AC%E5%B5%8C%E5%85%A5")
- [消息格式与 invoke 调用](#消息格式与 invoke 调用 "#4-%E6%B6%88%E6%81%AF%E6%A0%BC%E5%BC%8F%E4%B8%8E-invoke-%E8%B0%83%E7%94%A8")
- [Prompt Engineering 实战](#Prompt Engineering 实战 "#5-prompt-engineering-%E5%AE%9E%E6%88%98")
- [结构化输出与 Pydantic 解析](#结构化输出与 Pydantic 解析 "#6-%E7%BB%93%E6%9E%84%E5%8C%96%E8%BE%93%E5%87%BA%E4%B8%8E-pydantic-%E8%A7%A3%E6%9E%90")
- [RAG 检索增强生成](#RAG 检索增强生成 "#7-rag-%E6%A3%80%E7%B4%A2%E5%A2%9E%E5%BC%BA%E7%94%9F%E6%88%90")
- [Query Rewrite(查询改写)](#Query Rewrite(查询改写) "#8-query-rewrite%E6%9F%A5%E8%AF%A2%E6%94%B9%E5%86%99")
- 优雅降级与容错设计
- 知识点速查表
1. LangChain 生态与包结构
1.1 四个核心包
项目 requirements.txt 声明了四个 LangChain 包:
txt
langchain>=0.2,<0.4
langchain-core>=0.2,<0.4
langchain-community>=0.2,<0.4
langchain-openai>=0.1,<0.3
| 包名 | 作用 | 本项目中的使用 |
|---|---|---|
langchain |
顶层框架,组装链/Agent 的高层 API | 作为依赖引入(框架基础) |
langchain-core |
核心抽象:消息、提示词、Runnable 接口 | 消息元组格式 ("system", ...) |
langchain-community |
社区集成(向量库、文档加载器等) | 作为依赖引入(ChromaDB 互操作) |
langchain-openai |
OpenAI / 兼容 API 的集成 | ChatOpenAI 、OpenAIEmbeddings |
1.2 关键认知
LangChain 从 0.2 版本开始拆分为多个独立包。你不需要安装全部 ------ 用到什么装什么。本项目只实际调用了 langchain-openai 中的两个类,其他包提供底层抽象支撑。
📁 代码位置: requirements.txt
2. ChatOpenAI --- LLM 客户端
2.1 是什么
ChatOpenAI 是 LangChain 中对 OpenAI Chat Completions API(及兼容 API)的封装。本项目用它对接阿里云 DashScope (兼容 OpenAI 接口规范),底层模型为 qwen-max。
2.2 初始化参数详解
项目中出现了两处 ChatOpenAI 实例化,分别在行程生成 Agent 和 Query Rewrite 工具中:
📁 代码位置: trip_planner_agent.py:124-131
python
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
model=LLM_MODEL, # 模型名称,如 "qwen-max"
temperature=0.3, # 生成温度,0~2,越低越确定
api_key=LLM_API_KEY, # API 密钥
base_url=LLM_BASE_URL, # 兼容接口地址(DashScope 的 OpenAI 兼容端点)
timeout=LLM_TIMEOUT_SECONDS, # 请求超时(秒)
max_retries=LLM_MAX_RETRIES, # 失败重试次数
)
| 参数 | 说明 | 本项目取值 |
|---|---|---|
model |
模型名,必须与 API 提供商支持的名称一致 | qwen-max(可配置) |
temperature |
控制随机性:行程生成用 0.3(需要一定创意),Query Rewrite 用 0.2(更确定) |
0.2 / 0.3 |
api_key |
API 密钥,传给兼容端点做认证 | 从 .env 注入 |
base_url |
关键参数:设为 DashScope 的 OpenAI 兼容端点,实现非 OpenAI 模型的接入 | https://dashscope.aliyuncs.com/compatible-mode/v1 |
timeout |
单次请求超时,避免长时间卡住 | 60 秒 |
max_retries |
网络异常时自动重试次数 | 1 |
2.3 base_url 的价值
这是 LangChain ChatOpenAI 最实用的参数之一。因为国内大量模型厂商(阿里 DashScope、DeepSeek、智谱等)都提供 OpenAI 兼容接口,你只需要:
- 把
base_url指向厂商的兼容端点 - 把
api_key设为厂商的密钥 - 把
model设为厂商的模型名
不需要换 SDK,不需要改调用逻辑。
2.4 懒加载 + 优雅降级
项目中 ChatOpenAI 的创建采用了懒加载函数模式:
📁 代码位置: trip_planner_agent.py:114-131
python
def _build_chat_llm():
"""创建通用 ChatOpenAI 实例。"""
if not LLM_API_KEY:
return None # ① 没有 API Key 就直接返回 None
try:
from langchain_openai import ChatOpenAI
except ImportError:
return None # ② 包没装也返回 None
return ChatOpenAI(...) # ③ 一切就绪才创建
设计要点:
- 不在模块顶层导入,而是函数内部
import,避免缺少依赖时整个模块无法加载 - 返回
None而非抛异常,让调用方走 fallback 逻辑 - 这是生产环境 LLM 调用的标准做法
3. OpenAIEmbeddings --- 文本嵌入
3.1 是什么
OpenAIEmbeddings 将文本转换为向量(一组浮点数),用于语义相似度计算。本项目用它为旅行攻略片段生成向量,存入 ChromaDB 做语义检索。
3.2 初始化与 API 兼容性处理
📁 代码位置: vector_db.py:107-132
python
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(
model=EMBEDDING_MODEL, # 如 "text-embedding-v4"
api_key=LLM_API_KEY,
base_url=LLM_BASE_URL,
chunk_size=EMBEDDING_BATCH_SIZE, # 批量嵌入时每批文本数
check_embedding_ctx_length=False, # 不做上下文长度校验
)
| 参数 | 说明 |
|---|---|
model |
嵌入模型名,DashScope 上常用 text-embedding-v4 |
api_key / base_url |
同 ChatOpenAI,指向兼容端点 |
chunk_size |
调用 embed_documents 时每批发多少条文本 |
check_embedding_ctx_length |
设为 False 跳过 token 长度检查(交由服务端处理) |
3.3 API 参数名兼容
不同版本的 langchain-openai 对参数名有差异(api_key vs openai_api_key),项目中做了兼容:
python
try:
return OpenAIEmbeddings(
model=EMBEDDING_MODEL,
api_key=LLM_API_KEY, # 新版本参数名
base_url=LLM_BASE_URL,
...
)
except TypeError:
return OpenAIEmbeddings(
model=EMBEDDING_MODEL,
openai_api_key=LLM_API_KEY, # 旧版本参数名
openai_api_base=LLM_BASE_URL,
...
)
3.4 两个核心方法
| 方法 | 输入 | 输出 | 用途 |
|---|---|---|---|
embed_documents(texts) |
list[str] --- 多段文本 |
list[list[float]] --- 多个向量 |
离线:批量将攻略片段写入向量库 |
embed_query(text) |
str --- 单个查询 |
list[float] --- 一个向量 |
在线:将用户查询转为向量去检索 |
📁 代码位置: vector_db.py:170-171 和 vector_db.py:199
python
# 离线入库 ------ 批量生成向量
vectors = embeddings.embed_documents(documents)
# 在线检索 ------ 单条查询生成向量
query_embedding = embeddings.embed_query(query)
3.5 嵌入维度说明
不同模型产生的向量维度不同:
| 模型 | 维度 |
|---|---|
text-embedding-3-small |
512 / 1536(可调) |
text-embedding-3-large |
256 / 1024 / 3072(可调) |
text-embedding-v4(DashScope) |
1024 |
ChromaDB 的 cosine 相似度对维度不敏感,换模型不影响检索逻辑。
4. 消息格式与 invoke 调用
4.1 消息元组格式
LangChain 支持多种消息格式。本项目使用最简单的 元组格式:
python
messages = [
("system", "你是一个旅行规划助手..."), # 系统消息 → 设定 AI 行为
("human", "目的地:大理\n天数:3..."), # 人类消息 → 用户输入
]
这是 langchain-core 定义的标准格式。元组的第一个元素是角色,第二个是内容。支持的角色:
"system"--- 系统级指令,定义 AI 的角色和行为边界"human"--- 用户输入"ai"--- AI 之前的回复(多轮对话时使用)
4.2 llm.invoke() 调用
📁 代码位置: trip_planner_agent.py:214-219
python
response = llm.invoke(
[
("system", system_prompt),
("human", human_prompt),
]
)
invoke() 是最基础的同步调用方法。输入消息列表,返回 AIMessage 对象。
4.3 提取响应内容
📁 代码位置: trip_planner_agent.py:226-231
python
raw_text = getattr(response, "content", "")
if isinstance(raw_text, list):
raw_text = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in raw_text
)
AIMessage.content 通常是字符串,但某些模型可能返回列表(多个 content block)。项目做了兼容处理:
- 优先当作字符串使用
- 如果是列表,逐个提取
text字段拼接
4.4 LangChain 调用方法的区别
本项目只用到了 invoke(),但 LangChain 提供了三个调用层级:
| 方法 | 返回 | 适用场景 |
|---|---|---|
llm.invoke(messages) |
单个 AIMessage |
单次调用,本项目使用 |
llm.batch([msgs1, msgs2]) |
list[AIMessage] |
批量并行调用 |
llm.stream(messages) |
迭代器,逐 token 返回 | 流式输出(前端打字机效果) |
5. Prompt Engineering 实战
5.1 System Prompt 设计
项目中 System Prompt 遵循了几个原则,这些是 LangChain 社区总结的最佳实践:
📁 代码位置: trip_planner_agent.py:152-158
python
system_prompt = (
"你是一名旅行规划助手。" # ① 角色设定
"请用中文生成简洁的结构化旅行草稿。" # ② 输出语言和格式要求
"需要遵守用户给出的目的地、预算、节奏和本地攻略上下文。" # ③ 约束条件
"你必须只输出一个 JSON 对象,不要输出 Markdown,不要输出解释文字,不要输出代码块。" # ④ 格式强制
"输出内容必须严格符合给定的结构化字段要求。" # ⑤ 强调
"如果用户在额外备注里提出了明确诉求,例如看日落、不想早起、少辣、拍照等,你要优先把这些诉求落实到具体某一天的主要景点或当天安排里,而不是只写成泛泛的提示。" # ⑥ 优先级
)
设计原则:
- 角色先行:开篇明确"你是谁"
- 格式约束前置:在 System Prompt 中就要求 JSON 输出,减少 Human Prompt 中的干扰
- 行为优先级:明确"用户诉求 > 通用提示"的优先级
- 负面约束:说清楚"不要做什么"(不要 Markdown,不要代码块,不要解释)
5.2 Human Prompt 设计
Human Prompt 负责传递具体的、结构化的输入数据:
📁 代码位置: trip_planner_agent.py:161-205
python
human_prompt = f"""
目的地:{request.destination}
出发日期:{request.start_date.isoformat()}
结束日期:{request.end_date.isoformat()}
天数:{day_count}
人数:{request.travelers}
预算:{request.budget}
偏好:{'、'.join(request.preferences) if request.preferences else '无特别偏好'}
节奏:{request.pace or '适中'}
...
本地攻略上下文:
{guide_context} # ← RAG 检索结果注入点
要求: # ← 对 System Prompt 的补充和强化
1. 输出一个整体 summary。
2. 输出 {day_count} 天的 daily draft。
...
10. 只返回 JSON 对象,不要返回任何额外说明。
JSON 结构示例:
{{ # ← 注意:f-string 中 {{ 转义为 {
"summary": "...",
"tips": ["..."],
"days": [
{{
"day_index": 1,
"theme": "...",
"spot_name": "...",
...
}}
]
}}
"""
设计要点:
- 结构化字段 :用
key:value格式逐行列出,便于 LLM 解析 - RAG 上下文注入 :
{guide_context}把检索到的攻略片段拼入 Human Prompt - JSON 示例:用 Few-shot 方式给出期望的输出结构,大幅提高 JSON 格式遵守率
- 编号约束:用数字列表强化每一条要求
5.3 Query Rewrite 的 Prompt 设计
Query Rewrite 的 Prompt 更短更聚焦:
📁 代码位置: rag_tool.py:92-100
python
system_prompt = (
"你是一个 RAG 检索 query 改写专家。"
"你的任务是把用户的旅行需求改写成适合向量检索的关键词组合。"
"输出要求:"
"1. 只输出检索关键词,用空格分隔"
"2. 不要输出解释、标点或任何多余文字"
"3. 关键词要具体,优先包含景点名称、活动类型、场景特征"
"4. 包含目的地城市名"
)
为什么需要 Query Rewrite: 用户的自然语言("我想去大理放松几天,最好能看日落")直接做向量检索效果不好,需要改写为关键词组合("大理 日落 洱海 双廊 轻松 慢节奏")再检索。
6. 结构化输出与 Pydantic 解析
6.1 问题:LLM 返回的是文本
LLM 本质上是文本生成器。即使你在 Prompt 中要求 JSON,它也可能返回:
- 带 Markdown 代码块的 JSON:
json {...} - 带解释文字的 JSON:
这是结果:{...} - 格式正确但字段缺失的 JSON
6.2 解决:三层解析管道
项目实现了一条健壮的解析管道:
javascript
LLM 原始文本 → ① 提取 JSON 字符串 → ② 解析为 dict → ③ Pydantic 校验 → 结构化对象
第 1 层:从原始文本中提取 JSON
📁 代码位置: trip_planner_agent.py:75-94
python
def _extract_json_object(raw_text: str) -> str | None:
text = raw_text.strip()
# 1. 去掉 Markdown 代码块标记
if text.startswith("```"):
lines = text.splitlines()
if lines and lines[0].startswith("```"):
lines = lines[1:] # 去掉开头的 ```
if lines and lines[-1].strip() == "```":
lines = lines[:-1] # 去掉结尾的 ```
text = "\n".join(lines).strip()
if text.lower().startswith("json"):
text = text[4:].strip() # 去掉 "json" 语言标识
# 2. 找到第一个 { 和最后一个 }
start_index = text.find("{")
end_index = text.rfind("}")
if start_index == -1 or end_index == -1 or end_index <= start_index:
return None
return text[start_index : end_index + 1]
第 2 层:JSON 解析
python
payload = json.loads(json_text)
第 3 层:Pydantic 模型校验
📁 代码位置: trip_planner_agent.py:17-36
python
class PlannerDayDraft(BaseModel):
day_index: int = Field(..., ge=1)
theme: str = Field(..., description="当天的简短主题")
spot_name: str = Field(..., description="当天主要景点名称")
spot_description: str = Field(..., description="推荐该景点的简短理由")
meal_name: str = Field(..., description="当天的餐饮或餐厅建议")
meal_notes: str = Field(..., description="简短的用餐说明")
daily_note: str = Field(..., description="当天的一条简短规划备注")
class PlannerDraft(BaseModel):
summary: str = Field(..., description="整趟旅行的简短概述")
tips: list[str] = Field(default_factory=list, description="旅行提示")
days: list[PlannerDayDraft] = Field(default_factory=list)
校验调用:
python
result = PlannerDraft.model_validate(json.loads(json_text))
Pydantic 的价值:
- 类型强制 :
day_index: int自动把字符串"1"转成整数1 - 约束校验 :
ge=1确保天数 >= 1 - 默认值 :
default_factory=list避免可变默认值的坑 - 失败即知:字段类型不匹配时抛 ValidationError,不会默默产生脏数据
6.3 天数校验
LLM 可能返回错误天数的行程。项目在解析后做了业务校验:
python
if len(result.days) != day_count:
print(f"结构化结果天数不匹配,expected={day_count}, actual={len(result.days)}")
return None # 返回 None 触发 service 层的 fallback
6.4 模型返回格式的兼容处理
LLM 的单日编辑结果偶尔会改变字段结构(例如把 spot_name 变成 spots[0].name),项目做了兼容归一化:
📁 代码位置: trip_planner_agent.py:49-72
python
def _normalize_day_edit_payload(payload: dict) -> dict:
"""兼容模型返回的两种单日编辑格式。"""
if "spot_name" in payload and "meal_name" in payload and "daily_note" in payload:
return payload # 格式正确,直接返回
normalized = dict(payload)
# 兼容 spots 数组格式 → spot_name 字符串格式
spots = payload.get("spots")
if isinstance(spots, list) and spots:
first_spot = spots[0] or {}
normalized.setdefault("spot_name", first_spot.get("name", ""))
normalized.setdefault("spot_description", first_spot.get("description", ""))
# 兼容 meals 数组格式 → meal_name 字符串格式
meals = payload.get("meals")
if isinstance(meals, list) and meals:
first_meal = meals[0] or {}
normalized.setdefault("meal_name", first_meal.get("name", ""))
normalized.setdefault("meal_notes", first_meal.get("notes", ""))
return normalized
7. RAG 检索增强生成
7.1 什么是 RAG
R etrieval A ugmented Generation = 检索 + 增强 + 生成。
不依赖 LLM 自身的训练知识(可能过时或幻觉),而是在生成前先从知识库中检索相关内容,嵌入 Prompt 作为"参考材料"。
7.2 本项目 RAG 流水线
css
本地 Markdown 攻略
↓ ① 文档分块(chunking)
攻略片段(含标题、正文、来源)
↓ ② 向量嵌入(embedding)
ChromaDB 向量库
↓ ③ 用户查询 → Query Rewrite → 向量检索
候选片段 × N
↓ ④ Rerank(重排序)
Top-K 片段
↓ ⑤ 作为上下文注入 Prompt
LLM 生成
7.3 文档分块(Chunking)
📁 代码位置: vector_db.py:20-51
python
def _split_markdown_into_chunks(markdown_text: str, source_name: str) -> list[dict[str, str]]:
"""按二级、三级标题切分 Markdown,返回可检索片段。"""
chunks = []
current_title = "文档开头"
current_lines = []
for line in markdown_text.splitlines():
stripped = line.strip()
if stripped.startswith("## ") or stripped.startswith("### "):
if current_lines:
chunks.append({
"title": current_title,
"text": "\n".join(current_lines).strip(),
"source": source_name,
})
current_lines = []
current_title = stripped.lstrip("#").strip()
elif stripped:
current_lines.append(stripped)
# 收尾最后一段
if current_lines:
chunks.append({...})
return chunks
分块策略:按标题切分 (不是固定长度滑动窗口),因为 Markdown 攻略本身就是按 ## 景点、## 美食 等组织的,标题天然是语义边界。
7.4 向量嵌入与入库
📁 代码位置: vector_db.py:149-186
python
def ingest_guide_chunks_to_chroma() -> int:
embeddings = _build_embeddings()
collection = _get_chroma_collection()
chunks = load_guide_chunks()
documents = [_build_document_text(chunk) for chunk in chunks]
vectors = embeddings.embed_documents(documents) # LangChain:批量生成向量
collection.upsert(
ids=[chunk["id"] for chunk in chunks],
documents=documents, # 原文
metadatas=metadatas, # 标题、来源
embeddings=vectors, # 向量
)
7.5 向量检索
📁 代码位置: vector_db.py:189-222
python
def _search_guide_chunks_by_chroma(query: str, top_k: int = 3) -> list[dict[str, str]]:
embeddings = _build_embeddings()
collection = _get_chroma_collection()
query_embedding = embeddings.embed_query(query) # LangChain:单条查询向量化
result = collection.query(
query_embeddings=[query_embedding],
n_results=top_k,
include=["documents", "metadatas"],
)
# ... 解析结果
7.6 双层检索策略
python
def search_guide_chunks(query: str, top_k: int = 3) -> list[dict[str, str]]:
chroma_results = _search_guide_chunks_by_chroma(query=query, top_k=top_k)
if chroma_results:
return chroma_results
return _search_guide_chunks_by_keywords(query=query, top_k=top_k) # fallback
| 层级 | 方法 | 触发条件 |
|---|---|---|
| 优先 | Chroma 向量检索(语义匹配) | embedding 模型可用且向量库非空 |
| 回退 | 关键词匹配(字面匹配) | embedding 不可用或向量库为空 |
7.7 Rerank(重排序)
向量检索返回的候选不一定是最相关的。本项目用 Rerank 对候选做精排:
📁 代码位置: retriever.py:97-171
双策略:
- Cross-encoder Rerank (优先):调用 DashScope 的
qwen3-rerank模型 - 规则级 Rerank(fallback):基于关键词的标题/正文匹配打分
规则打分的核心逻辑:
| 条件 | 分数调整 | 原因 |
|---|---|---|
| 关键词在标题中 | +3 | 标题匹配权重更高 |
| 关键词在正文中 | +1 | 正文匹配权重较低 |
| "文档开头"片段 | -8 | 噪声片段,信息量极低 |
| 标题含"行程" | +4 | 行程类片段与规划需求高度相关 |
| 标题含"行程参考" | -4 | 过于泛化的参考内容 |
| 标题含"目的地简介" | -2 | 偏介绍而非实用信息 |
| 餐饮/预算片段 + 非相关查询 | -3 | 查询意在"日落/拍照"时,餐饮片段不相关 |
| 目的地不匹配 | -5 | 跨目的地的片段污染 |
7.8 RAG 缓存
检索和 Rerank 结果都支持 Redis 缓存:
📁 代码位置: retriever.py:261-279
python
def retrieve_travel_guide(query: str, top_k: int = 3) -> list[str]:
cache_key = f"rag:guide:{normalized_query}:{top_k}" # 缓存键
cached_value = get_cached_json(cache_key)
if cached_value is not None:
return cached_value
# ... 检索 + Rerank ...
set_cached_json(cache_key, results, expire_seconds=REDIS_RAG_TTL_SECONDS)
return results
8. Query Rewrite(查询改写)
8.1 为什么需要
用户查询:"我想去大理放松几天,最好能看日落和拍照"
直接向量检索效果差,因为:
- 口语化表述与攻略文档的书面语有 gap
- 干扰词("我想去""几天")降低语义密度
改写后:大理 日落 洱海 双廊 拍照 摄影 出片 轻松 慢节奏
8.2 双策略实现
📁 代码位置: rag_tool.py:157-179
python
def build_destination_query(destination, preferences, pace, special_notes) -> str:
# 策略 1:LLM 改写(优先)
llm_query = llm_rewrite_query(destination, preferences, pace, special_notes)
if llm_query:
return llm_query
# 策略 2:规则改写(fallback)
return _rule_based_query(destination, preferences, pace, special_notes)
LLM 改写
用 ChatOpenAI 调用 LLM 完成改写,temperature 设为 0.2(更确定):
python
def llm_rewrite_query(destination, preferences, pace, special_notes) -> str | None:
llm = _build_chat_llm()
if llm is None:
return None
response = llm.invoke([
("system", "你是一个 RAG 检索 query 改写专家..."),
("human", f"目的地:{destination}\n偏好:{preferences}\n..."),
])
query = response.content.strip()
return query if query else None
规则改写(fallback)
基于预定义的关键词映射表:
python
rule_keywords = [
(("日落", "傍晚"), "大理", ["日落", "傍晚", "洱海", "双廊"]),
(("拍照", "出片"), None, ["拍照", "摄影", "出片"]),
(("美食", "小吃"), None, ["美食", "小吃"]),
(("熊猫",), "成都", ["大熊猫", "熊猫"]),
(("潜水",), "三亚", ["潜水", "蜈支洲岛"]),
# ...
]
- 如果用户备注包含触发词且目的地匹配 → 追加对应关键词
- 最后追加固定词:
["景点", "行程", "攻略", "推荐"]
9. 优雅降级与容错设计
这是本项目 LangChain 使用中最值得学习的工程实践。
9.1 降级链路全景
scss
LLM 不可用?
├─ API Key 缺失 → _build_chat_llm() 返回 None
├─ 包未安装 → ImportError → 返回 None
├─ 网络超时 → Exception → 返回 None
└─ JSON 解析失败 → 返回 None
↓
_build_chat_llm() 返回 None
↓
generate_planner_draft() 返回 None
↓
trip_service.generate_trip_itinerary() 走规则生成分支
9.2 每一层的具体实现
LLM 创建层:
python
def _build_chat_llm():
if not LLM_API_KEY:
return None # ① 无密钥
try:
from langchain_openai import ChatOpenAI
except ImportError:
return None # ② 无依赖
return ChatOpenAI(...)
LLM 调用层:
python
try:
response = llm.invoke([...])
except Exception as exc:
print(f"大模型调用失败: {exc}")
return None # ③ 网络/服务异常
解析层:
python
json_text = _extract_json_object(raw_text)
if json_text is None:
return None # ④ 无法提取 JSON
try:
result = PlannerDraft.model_validate(json.loads(json_text))
except Exception:
return None # ⑤ JSON 解析或校验失败
Service 编排层(最终 fallback):
python
llm_draft = generate_planner_draft(request, rag_contexts, day_count)
for index in range(day_count):
llm_day = None
if llm_draft is not None:
llm_day = next((d for d in llm_draft.days if d.day_index == day_number), None)
# LLM 结果存在就用,不存在就用规则生成的值
spot_name = llm_day.spot_name if llm_day else fallback_spot_names[index]
theme = llm_day.theme if llm_day else f"{destination} 第 {day_number} 天轻松游"
# ...
9.3 降级设计原则总结
| 原则 | 说明 |
|---|---|
| 不在模块顶层 import | 函数内部懒加载,避免缺少依赖导致整个模块不可用 |
| 返回 None 而非抛异常 | 让调用方统一判断,不破坏调用链 |
| 每一层独立容错 | 创建失败、调用失败、解析失败各自处理 |
| 最终 fallback 在编排层 | Service 层用 llm_value if llm_value else rule_value 做最终兜底 |
| 缓存降级透明 | Redis 不可用时 get_cached_json 返回 None,不阻塞检索 |
9.4 Retrieval 的降级
检索层有独立的降级链路:
markdown
向量检索(Chroma)
↓ 失败
关键词检索(规则打分)
↓ 失败
空列表(不阻塞生成)
Embedding 的降级:
python
def _build_embeddings():
if not LLM_API_KEY:
return None
try:
from langchain_openai import OpenAIEmbeddings
except ImportError:
return None
return OpenAIEmbeddings(...)
10. 知识点速查表
10.1 LangChain 类
| 类 | 来源包 | 项目中的使用 | 代码位置 |
|---|---|---|---|
ChatOpenAI |
langchain_openai |
行程生成 LLM、编辑 LLM、Query Rewrite LLM | trip_planner_agent.py:120 rag_tool.py:68 |
OpenAIEmbeddings |
langchain_openai |
攻略片段向量化、用户查询向量化 | vector_db.py:113 |
10.2 LangChain 方法
| 方法 | 所属类 | 输入 | 输出 | 项目中的使用 |
|---|---|---|---|---|
invoke(messages) |
ChatOpenAI |
list[tuple] |
AIMessage |
所有 LLM 调用 |
embed_documents(texts) |
OpenAIEmbeddings |
list[str] |
list[list[float]] |
离线攻略入库 |
embed_query(text) |
OpenAIEmbeddings |
str |
list[float] |
在线查询向量化 |
10.3 核心概念
| 概念 | 一句话解释 | 章节 |
|---|---|---|
| Chat Model | 封装 LLM 调用的客户端,统一不同提供商的接口 | [§2](#概念 一句话解释 章节 Chat Model 封装 LLM 调用的客户端,统一不同提供商的接口 §2 Embeddings 将文本转为向量的模型,用于语义检索 §3 Message Format ("role", "content") 元组,LangChain 标准消息格式 §4 System Prompt 设定 AI 角色和行为的系统消息 §5.1 Human Prompt 携带用户输入和上下文的人类消息 §5.2 Structured Output 强制 LLM 返回 JSON + Pydantic 校验 §6 RAG 检索增强生成:先检索知识库,再把结果注入 Prompt §7 Chunking 将长文档切成可检索的短片段 §7.3 Query Rewrite 将用户自然语言查询改写为检索优化关键词 §8 Rerank 对向量召回的候选片段做更精细的相关性排序 §7.7 Graceful Degradation 每层独立容错,失败时回退到更简单的实现 §9 "#2-chatopenai--llm-%E5%AE%A2%E6%88%B7%E7%AB%AF") |
| Embeddings | 将文本转为向量的模型,用于语义检索 | [§3](#概念 一句话解释 章节 Chat Model 封装 LLM 调用的客户端,统一不同提供商的接口 §2 Embeddings 将文本转为向量的模型,用于语义检索 §3 Message Format ("role", "content") 元组,LangChain 标准消息格式 §4 System Prompt 设定 AI 角色和行为的系统消息 §5.1 Human Prompt 携带用户输入和上下文的人类消息 §5.2 Structured Output 强制 LLM 返回 JSON + Pydantic 校验 §6 RAG 检索增强生成:先检索知识库,再把结果注入 Prompt §7 Chunking 将长文档切成可检索的短片段 §7.3 Query Rewrite 将用户自然语言查询改写为检索优化关键词 §8 Rerank 对向量召回的候选片段做更精细的相关性排序 §7.7 Graceful Degradation 每层独立容错,失败时回退到更简单的实现 §9 "#3-openaiembeddings--%E6%96%87%E6%9C%AC%E5%B5%8C%E5%85%A5") |
| Message Format | ("role", "content") 元组,LangChain 标准消息格式 |
[§4](#概念 一句话解释 章节 Chat Model 封装 LLM 调用的客户端,统一不同提供商的接口 §2 Embeddings 将文本转为向量的模型,用于语义检索 §3 Message Format ("role", "content") 元组,LangChain 标准消息格式 §4 System Prompt 设定 AI 角色和行为的系统消息 §5.1 Human Prompt 携带用户输入和上下文的人类消息 §5.2 Structured Output 强制 LLM 返回 JSON + Pydantic 校验 §6 RAG 检索增强生成:先检索知识库,再把结果注入 Prompt §7 Chunking 将长文档切成可检索的短片段 §7.3 Query Rewrite 将用户自然语言查询改写为检索优化关键词 §8 Rerank 对向量召回的候选片段做更精细的相关性排序 §7.7 Graceful Degradation 每层独立容错,失败时回退到更简单的实现 §9 "#4-%E6%B6%88%E6%81%AF%E6%A0%BC%E5%BC%8F%E4%B8%8E-invoke-%E8%B0%83%E7%94%A8") |
| System Prompt | 设定 AI 角色和行为的系统消息 | [§5.1](#概念 一句话解释 章节 Chat Model 封装 LLM 调用的客户端,统一不同提供商的接口 §2 Embeddings 将文本转为向量的模型,用于语义检索 §3 Message Format ("role", "content") 元组,LangChain 标准消息格式 §4 System Prompt 设定 AI 角色和行为的系统消息 §5.1 Human Prompt 携带用户输入和上下文的人类消息 §5.2 Structured Output 强制 LLM 返回 JSON + Pydantic 校验 §6 RAG 检索增强生成:先检索知识库,再把结果注入 Prompt §7 Chunking 将长文档切成可检索的短片段 §7.3 Query Rewrite 将用户自然语言查询改写为检索优化关键词 §8 Rerank 对向量召回的候选片段做更精细的相关性排序 §7.7 Graceful Degradation 每层独立容错,失败时回退到更简单的实现 §9 "#51-system-prompt-%E8%AE%BE%E8%AE%A1") |
| Human Prompt | 携带用户输入和上下文的人类消息 | [§5.2](#概念 一句话解释 章节 Chat Model 封装 LLM 调用的客户端,统一不同提供商的接口 §2 Embeddings 将文本转为向量的模型,用于语义检索 §3 Message Format ("role", "content") 元组,LangChain 标准消息格式 §4 System Prompt 设定 AI 角色和行为的系统消息 §5.1 Human Prompt 携带用户输入和上下文的人类消息 §5.2 Structured Output 强制 LLM 返回 JSON + Pydantic 校验 §6 RAG 检索增强生成:先检索知识库,再把结果注入 Prompt §7 Chunking 将长文档切成可检索的短片段 §7.3 Query Rewrite 将用户自然语言查询改写为检索优化关键词 §8 Rerank 对向量召回的候选片段做更精细的相关性排序 §7.7 Graceful Degradation 每层独立容错,失败时回退到更简单的实现 §9 "#52-human-prompt-%E8%AE%BE%E8%AE%A1") |
| Structured Output | 强制 LLM 返回 JSON + Pydantic 校验 | [§6](#概念 一句话解释 章节 Chat Model 封装 LLM 调用的客户端,统一不同提供商的接口 §2 Embeddings 将文本转为向量的模型,用于语义检索 §3 Message Format ("role", "content") 元组,LangChain 标准消息格式 §4 System Prompt 设定 AI 角色和行为的系统消息 §5.1 Human Prompt 携带用户输入和上下文的人类消息 §5.2 Structured Output 强制 LLM 返回 JSON + Pydantic 校验 §6 RAG 检索增强生成:先检索知识库,再把结果注入 Prompt §7 Chunking 将长文档切成可检索的短片段 §7.3 Query Rewrite 将用户自然语言查询改写为检索优化关键词 §8 Rerank 对向量召回的候选片段做更精细的相关性排序 §7.7 Graceful Degradation 每层独立容错,失败时回退到更简单的实现 §9 "#6-%E7%BB%93%E6%9E%84%E5%8C%96%E8%BE%93%E5%87%BA%E4%B8%8E-pydantic-%E8%A7%A3%E6%9E%90") |
| RAG | 检索增强生成:先检索知识库,再把结果注入 Prompt | [§7](#概念 一句话解释 章节 Chat Model 封装 LLM 调用的客户端,统一不同提供商的接口 §2 Embeddings 将文本转为向量的模型,用于语义检索 §3 Message Format ("role", "content") 元组,LangChain 标准消息格式 §4 System Prompt 设定 AI 角色和行为的系统消息 §5.1 Human Prompt 携带用户输入和上下文的人类消息 §5.2 Structured Output 强制 LLM 返回 JSON + Pydantic 校验 §6 RAG 检索增强生成:先检索知识库,再把结果注入 Prompt §7 Chunking 将长文档切成可检索的短片段 §7.3 Query Rewrite 将用户自然语言查询改写为检索优化关键词 §8 Rerank 对向量召回的候选片段做更精细的相关性排序 §7.7 Graceful Degradation 每层独立容错,失败时回退到更简单的实现 §9 "#7-rag-%E6%A3%80%E7%B4%A2%E5%A2%9E%E5%BC%BA%E7%94%9F%E6%88%90") |
| Chunking | 将长文档切成可检索的短片段 | [§7.3](#概念 一句话解释 章节 Chat Model 封装 LLM 调用的客户端,统一不同提供商的接口 §2 Embeddings 将文本转为向量的模型,用于语义检索 §3 Message Format ("role", "content") 元组,LangChain 标准消息格式 §4 System Prompt 设定 AI 角色和行为的系统消息 §5.1 Human Prompt 携带用户输入和上下文的人类消息 §5.2 Structured Output 强制 LLM 返回 JSON + Pydantic 校验 §6 RAG 检索增强生成:先检索知识库,再把结果注入 Prompt §7 Chunking 将长文档切成可检索的短片段 §7.3 Query Rewrite 将用户自然语言查询改写为检索优化关键词 §8 Rerank 对向量召回的候选片段做更精细的相关性排序 §7.7 Graceful Degradation 每层独立容错,失败时回退到更简单的实现 §9 "#73-%E6%96%87%E6%A1%A3%E5%88%86%E5%9D%97chunking") |
| Query Rewrite | 将用户自然语言查询改写为检索优化关键词 | [§8](#概念 一句话解释 章节 Chat Model 封装 LLM 调用的客户端,统一不同提供商的接口 §2 Embeddings 将文本转为向量的模型,用于语义检索 §3 Message Format ("role", "content") 元组,LangChain 标准消息格式 §4 System Prompt 设定 AI 角色和行为的系统消息 §5.1 Human Prompt 携带用户输入和上下文的人类消息 §5.2 Structured Output 强制 LLM 返回 JSON + Pydantic 校验 §6 RAG 检索增强生成:先检索知识库,再把结果注入 Prompt §7 Chunking 将长文档切成可检索的短片段 §7.3 Query Rewrite 将用户自然语言查询改写为检索优化关键词 §8 Rerank 对向量召回的候选片段做更精细的相关性排序 §7.7 Graceful Degradation 每层独立容错,失败时回退到更简单的实现 §9 "#8-query-rewrite%E6%9F%A5%E8%AF%A2%E6%94%B9%E5%86%99") |
| Rerank | 对向量召回的候选片段做更精细的相关性排序 | [§7.7](#概念 一句话解释 章节 Chat Model 封装 LLM 调用的客户端,统一不同提供商的接口 §2 Embeddings 将文本转为向量的模型,用于语义检索 §3 Message Format ("role", "content") 元组,LangChain 标准消息格式 §4 System Prompt 设定 AI 角色和行为的系统消息 §5.1 Human Prompt 携带用户输入和上下文的人类消息 §5.2 Structured Output 强制 LLM 返回 JSON + Pydantic 校验 §6 RAG 检索增强生成:先检索知识库,再把结果注入 Prompt §7 Chunking 将长文档切成可检索的短片段 §7.3 Query Rewrite 将用户自然语言查询改写为检索优化关键词 §8 Rerank 对向量召回的候选片段做更精细的相关性排序 §7.7 Graceful Degradation 每层独立容错,失败时回退到更简单的实现 §9 "#77-rerank%E9%87%8D%E6%8E%92%E5%BA%8F") |
| Graceful Degradation | 每层独立容错,失败时回退到更简单的实现 | [§9](#概念 一句话解释 章节 Chat Model 封装 LLM 调用的客户端,统一不同提供商的接口 §2 Embeddings 将文本转为向量的模型,用于语义检索 §3 Message Format ("role", "content") 元组,LangChain 标准消息格式 §4 System Prompt 设定 AI 角色和行为的系统消息 §5.1 Human Prompt 携带用户输入和上下文的人类消息 §5.2 Structured Output 强制 LLM 返回 JSON + Pydantic 校验 §6 RAG 检索增强生成:先检索知识库,再把结果注入 Prompt §7 Chunking 将长文档切成可检索的短片段 §7.3 Query Rewrite 将用户自然语言查询改写为检索优化关键词 §8 Rerank 对向量召回的候选片段做更精细的相关性排序 §7.7 Graceful Degradation 每层独立容错,失败时回退到更简单的实现 §9 "#9-%E4%BC%98%E9%9B%85%E9%99%8D%E7%BA%A7%E4%B8%8E%E5%AE%B9%E9%94%99%E8%AE%BE%E8%AE%A1") |
10.4 设计模式
| 模式 | 说明 | 示例 |
|---|---|---|
| 懒加载 | 函数内部 import,避免模块级依赖 | def _build_chat_llm(): from langchain_openai import ChatOpenAI |
| Null Object | 不可用时返回 None 而非抛异常 |
if not LLM_API_KEY: return None |
| Fallback Chain | 优先级:LLM → 规则 → 默认值 | 生成、改写、检索、重排序全部采用 |
| Parser Pipeline | 原始文本 → JSON 提取 → dict 解析 → Pydantic 校验 | _extract_json_object → json.loads → model_validate |
| Format Adapter | 兼容 LLM 返回的多种格式 | _normalize_day_edit_payload |
附录:文件索引
| 文件 | 涉及的知识点 |
|---|---|
| config.py | ChatOpenAI / OpenAIEmbeddings 的配置参数来源 |
| trip_planner_agent.py | ChatOpenAI、消息格式、Prompt 设计、结构化输出、容错降级 |
| rag_tool.py | ChatOpenAI 用于 Query Rewrite、规则 fallback |
| vector_db.py | OpenAIEmbeddings、ChromaDB 向量入库与检索 |
| retriever.py | RAG 检索、Rerank、缓存 |
| trip_service.py | 编排层 fallback、LLM 结果与规则结果的融合 |
| schemas.py | Pydantic 模型设计(结构化输出的 Schema) |
本文档基于智旅云图项目 v0.1.0 版本编写,专注于 LangChain 框架知识点的实战讲解。