📚前言
📒FDE系列内容总纲:
🚄前置课程列表:
见文档结尾附录。
🚀阶段3·Day 77:LangChain 基础 --- LCEL 管道与三件套 🔗
FDE 学习系列教程 · 第三阶段 · 第 12 周 · Day 2 预计时长:3 小时 | 难度:★★★☆☆ | 前置知识:Day 76(徒手 ReAct 循环)、Day 71-75(Qdrant + BM25 + BGE-Reranker 检索、v0.2 冻结)、第二阶段第 6 周(FastAPI、Pydantic v2、httpx) 对标大纲课时:3.3.2 LangChain 基础 ------ 大纲原文「Chain、Memory、Tools、Output Parsers;LCEL 表达式语法」
📌 一句话目标:用 LangChain 的三件套(Prompt / Model / OutputParser)+ LCEL 管道语法,把 Day 76 那 150 行手写循环里的"拼装部分"压缩到 30 行以内,并把 v0.2 的问答链重写一遍。
🧑🤝🧑 开场:齿轮会转了,现在给你一套变速箱
昨天(Day 76)收工时,运维小张跑过来说了一句话:
"循环是跑通了。可我今天加一个工具,改了三个地方:
agent/tools.py 里加函数
同一个文件底部的 TOOLS 字典加一项
agent/prompts.py 里那段工具清单 Markdown 还得跟着改
上周五我给 list_tickets 加了个 limit 参数,忘了改提示词那一处,
模型三天都在传 5。查了半天才发现问题不在模型,在我。"
这就是没有框架的日常:所有约定靠人肉同步。而 Day 76 文末我列的五个遗留问题,本质上是同一件事的五个侧面:
| Day 76 的痛点 | 本质 | 今天谁来解决 |
|---|---|---|
| ① 工具清单手写 Markdown 拼接 | 元数据(schema)和提示词是两份,会不同步 | @tool 装饰器自动生成 schema(Day 78) |
| ② 解析靠正则,输出歪一格就崩 | 没有结构化的输出契约 | PydanticOutputParser(今天 ⑤) |
| ③ 每次重新拼 messages,复用/缓存/并行全手写 | 没有统一的"可运行单元"抽象 | LCEL 管道(今天 ②③④) |
| ④ 没有 retry、没有流式、没有异步 | 横切能力要自己写 | .with_retry() / .stream() / .ainvoke()(今天 ④) |
| ⑤ 换模型要改多处 | 模型调用没被隔离成对象 | ChatOpenAI 一个实例走天下(今天 ②) |
今天不碰 Agent,只做拼装层的工业化:
第 12 周进度条
Day76 ✅ 徒手 ReAct 循环 ------ 看见齿轮
Day77 🆕 LangChain + LCEL ------ 装上变速箱(今天)
Day78 工具与记忆 ------ 接上真正的手脚和记性
Day79 LangGraph 状态图 ------ 允许回路和分支
Day80 人在回路 + v0.3 收官 ------ 高危动作有人签字
今天七件事:① 搞清楚 LangChain 在 2026 年到底是哪一堆东西(以及哪些已经不该用了 );② 装上三件套并打通 DeepSeek;③ LCEL 管道语法与四种调用方式;④ 并行、重试、降级;⑤ PydanticOutputParser 替掉昨天的正则;⑥ 用 LCEL 重写 v0.2 问答链;⑦ 把链接回第二阶段的 FastAPI。
💡 今天的关键词是"契约"。 昨天你的链是字符串进、字符串出 ,靠正则在中间捞东西;今天变成字典/对象进、对象出 ,中间每一段都是一个有输入输出契约的
Runnable。有了契约,框架才能替你做批处理、流式、重试、并发------这些能力昨天你要写 200 行,今天是一行.batch()。
📖 一、LangChain 是什么:不是 Agent 框架,是「拼装协议」
1.1 一句话定义
LangChain = 一套统一的「可运行单元」(Runnable)协议 + 一堆现成的实现
只要一个东西实现了 Runnable 协议,它就自动获得:
invoke 单个输入 → 单个输出
batch 一批输入 → 一批输出(内部并发)
stream 单个输入 → 流式输出
ainvoke / abatch / astream 上面三个的异步版
with_retry / with_fallbacks 重试与降级
| (管道符) 和其它 Runnable 拼起来
它不替你做决策 ,Day 76 那个 while 循环、工具分派、护栏,全是你自己的逻辑。LangChain 提供的是零件和接口标准,不是"智能"。
⚠️ 一个非常常见的误解 :很多人以为"用了 LangChain 就等于有了 Agent"。不是。LangChain 里真正管"循环与决策"的是 LangGraph ,那是 Day 79 的内容。今天你写的所有东西都是直线(输入→处理→输出),只不过这条直线现在由标准零件拼成。
1.2 2026 年的版本地图(这段必读,能省你两天)
LangChain 在 2025 年 10 月发布了 1.0 ,做了一次大刀阔斧的收敛:把五年里堆出来的、功能重叠的旧 API 全部标记为废弃,并迁到一个叫 langchain-classic 的软件包里。到 2026 年,v1.x 是当前主线,你在网上搜到的大量教程(包括 2024 年的爆款)里用的 API 很多已经不该在新项目里出现了。
┌───────────────── 2026 年的 LangChain 生态 ─────────────────┐
│ │
│ langchain 核心抽象:Runnable / Prompt / Parser │
│ ⭐ 今天的主角 │
│ langchain-core 最底层协议(一般不用直接装) │
│ langchain-openai 模型适配器(DeepSeek 走这个) │
│ langgraph 状态图 / 循环 / 人在回路(Day79-80) │
│ langchain-classic 旧 API 的养老院 ⚠️ 新项目别碰 │
└────────────────────────────────────────────────────────────┘
| 旧写法(❌ 2026 年新项目不要用) | 现在怎么写 | 说明 |
|---|---|---|
initialize_agent(...) |
create_agent(model=..., tools=[...]) |
老式 Agent 构造器,已迁到 langchain-classic |
AgentExecutor(...) |
create_agent(...) / LangGraph |
曾经的"Agent 执行器",v1 里不再是推荐路径 |
create_react_agent(...) |
create_agent(model=..., tools=[...]) |
名字改了,能力也被统一进 v1 的 Agent 抽象 |
LLMChain(prompt=..., llm=...) |
`prompt | model |
ConversationBufferMemory |
RunnableWithMessageHistory + InMemoryChatMessageHistory |
记忆改由「消息历史 + 包装器」实现(Day 78) |
chain.run("...") |
chain.invoke({...}) |
run 是旧 API |
📌 为什么我要把这张表放在第 2 天? 因为这是 FDE 现场最贵的一个坑:你在客户现场复制一段 2024 年的教程代码,
from langchain.agents import initialize_agent一跑,要么报弃用警告、要么装不上,然后你花两小时查为什么。 记住一句话:2026 年写 Agent 用create_agent,写流程用langgraph,其它旧名字一律先怀疑。
1.3 我们今天只用这四个包
# 进入项目并激活虚拟环境
cd fde-ai
.\.venv\Scripts\Activate.ps1
# Day77 新增(langgraph 今天先装上,Day79 直接用)
pip install langchain langchain-openai langgraph python-dotenv httpx
装完看一眼版本,确认是 v1 系:
python -c "import langchain, langgraph; print('langchain', langchain.__version__); print('langgraph', langgraph.__version__)"
langchain 1.1.3
langgraph 1.2.1
💡 小版本不用焦虑 :LangChain v1.x 与 LangGraph v1.2.x 都是兼容的小版本迭代,本文所有写法在两个包的 v1 线内通用。真出问题,先看报错里是不是
langchain_classic------如果是,说明你抄到旧代码了。
🖥️ 二、三件套第一次通电:Model / Prompt / Parser
实操步骤 1:打通 DeepSeek,跑通最小链路
新建 agent/lc_model.py。这个文件是全项目唯一的模型出入口 ------Day 76 的 agent/llm.py 负责裸 SDK 调用,今天这个负责 LangChain 侧,两者共存一段时间,后续统一。
"""agent/lc_model.py ------ Day77:LangChain 侧的 DeepSeek 模型单例
⭐ 全项目只有这一个文件 import ChatOpenAI。
换模型、换 temperature、换超时,只改这里。
"""
from __future__ import annotations
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
load_dotenv()
DEEPSEEK_BASE_URL = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
_llm: ChatOpenAI | None = None
_llm_creative: ChatOpenAI | None = None
def get_llm() -> ChatOpenAI:
"""主力模型:低温、确定性强,用于分类/抽取/工具选择等『规划』环节"""
global _llm
if _llm is None:
api_key = os.getenv("DEEPSEEK_API_KEY")
if not api_key:
raise RuntimeError("未检测到 DEEPSEEK_API_KEY,请在 .env 中配置后重试")
_llm = ChatOpenAI(
model=DEEPSEEK_MODEL,
base_url=DEEPSEEK_BASE_URL,
api_key=api_key,
temperature=0.2, # Agent 默认低温:规划环节不要随机
timeout=30,
max_retries=0, # ⭐ SDK 自带重试会和上层 .with_retry 叠加,必须关
)
return _llm
def get_llm_creative() -> ChatOpenAI:
"""副模型:温度高一点,用于写处置方案、给师傅看的说明文字"""
global _llm_creative
if _llm_creative is None:
api_key = os.getenv("DEEPSEEK_API_KEY")
if not api_key:
raise RuntimeError("未检测到 DEEPSEEK_API_KEY,请在 .env 中配置后重试")
_llm_creative = ChatOpenAI(
model=DEEPSEEK_MODEL,
base_url=DEEPSEEK_BASE_URL,
api_key=api_key,
temperature=0.7,
timeout=30,
max_retries=0,
)
return _llm_creative
if __name__ == "__main__":
reply = get_llm().invoke("用一句话解释什么是 LCEL")
print(type(reply).__name__) # AIMessage
print(reply.text[:200]) # ⭐ v1 用 .text 属性,不是 .text() 方法
跑一下:
python -m agent.lc_model
AIMessage
LCEL(LangChain Expression Language)是一种用管道符 | 把提示词模板、模型、输出解析器
等组件串成一条链的声明式语法......(约 80 tokens / 1200ms)
观察点三个:
-
invoke()返回的不是字符串,而是一个AIMessage消息对象。这是 LangChain 与裸 SDK 最大的体感差异:消息里除了文本还有角色、工具调用、用量元数据。 -
.text是 属性 ,不是方法(v1 的改动,网上老代码写.text()会拿到一个方法对象而不是字符串,然后你会在下游看到奇怪的报错)。 -
max_retries=0是延续 Day 74 的教训:重试只能有一层,SDK 一层 + 框架一层 = 4 倍放大,超时现场直接雪崩。
⚠️
.env与.gitignore复查 :.env里只允许出现DEEPSEEK_API_KEY/DEEPSEEK_BASE_URL/DEEPSEEK_MODEL/TICKET_API_BASE;.gitignore必须含.env。全文Ctrl+Shift+F搜sk-,真实密钥命中数必须是 0。
实操步骤 2:第一条 LCEL 链(工单分类)
Day 60 你写过工单分类,当时是手拼 messages。现在用三件套重写,新建 agent/chains.py:
"""agent/chains.py ------ Day77:LCEL 三件套的第一条链
三件套:
Prompt ------ ChatPromptTemplate,输入变量用 {xxx} 占位
Model ------ ChatOpenAI,Day76 的 get_llm()
Parser ------ StrOutputParser,把 AIMessage 变成 str
LCEL:
chain = prompt | model | parser ⭐ 一个管道符搞定
"""
from __future__ import annotations
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from agent.lc_model import get_llm
# ─────── ① Prompt:把 Day59 那套提示词搬进模板 ───────
CLASSIFY_PROMPT = ChatPromptTemplate.from_messages([
("system",
"你是工厂设备运维工单的分类器。\n"
"根据用户描述判断故障类别,只能输出下面之一:\n"
"机械 / 电气 / 液压 / 温控 / 工艺 / 其他\n"
"再判断优先级:low / medium / high\n"
"输出格式严格为:类别|优先级|一句话理由(不超过 20 字)\n"
"不要输出任何解释性前言。"),
("human", "{description}"),
])
# ─────── ② LCEL:三个零件一根管道 ───────
classify_chain = CLASSIFY_PROMPT | get_llm() | StrOutputParser()
if __name__ == "__main__":
for desc in [
"A3 注塑机料筒温度 91 度,超温报警一直不消",
"B2 冲压机液压站有渗油,地上一滩",
]:
out = classify_chain.invoke({"description": desc})
print(repr(out))
python -m agent.chains
'温控|high|料筒超温 91℃ 已触发高温告警'
'液压|medium|液压站渗油暂未影响运行'
观察点: invoke() 的入参是一个 dict ,键名对应模板里 {description} 占位符。模板里有两个占位符就传两个键------这是 LCEL 最重要的契约:链的输入就是一个 dict,输出就是上一段的输出类型。
把这条链和 Day 76 的手写版本对比一下:
| 项目 | Day 76 手写 | Day 77 LCEL |
|---|---|---|
| 拼 messages | 手写 listdict,每次重建 | ChatPromptTemplate 复用一份 |
| 调模型 | client.chat.completions.create(...) |
管道里的一节 |
| 取文本 | resp.choices[0].message.content |
StrOutputParser() |
| 换模型 | 改函数内部 | 管道里换一个对象 |
| 并发 20 条 | 自己写线程池 | .batch([...]) |
| 流式 | 自己处理 SSE | .stream() |
| 重试 | 自己写 for | .with_retry(stop_after_attempt=3) |
📖 三、三件套逐个拆开:每一件都在解决一个具体痛点
3.1 Prompt:ChatPromptTemplate + MessagesPlaceholder
提示词模板解决的是"变量与固定文本分离"。多轮对话场景还需要一个洞,让历史消息插进去:
"""agent/prompt_demo.py ------ Day77:三种提示词模板的写法
"""
from __future__ import annotations
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
# ① 最常用:from_messages,元素是 (角色, 内容) 元组
qa_prompt = ChatPromptTemplate.from_messages([
("system", "你是工厂设备运维助手。回答必须用中文,关键参数加粗,"
"只依据【资料】作答,资料里没有就说『手册中未提及』。"),
MessagesPlaceholder("history"), # ⭐ 历史消息插槽,Day78 的记忆从这里进
("human", "{question}"),
])
# ② 带资料占位:变量名可以任意,invoke 时按名字传
rag_prompt = ChatPromptTemplate.from_messages([
("system", "你是工厂设备运维助手。只依据【资料】作答。\n\n【资料】\n{context}"),
MessagesPlaceholder("history"),
("human", "{question}"),
])
# ③ 部分变量固定:把不常变的先绑死,调用时少传一个
rag_prompt_a3 = rag_prompt.partial(context="(由检索节点填入,见 6.2 节)")
print(qa_prompt.input_variables) # ['history', 'question']
print(rag_prompt.input_variables) # ['context', 'history', 'question']
print(rag_prompt_a3.input_variables) # ['history', 'question']
💡
MessagesPlaceholder为什么必须是它? 因为历史消息是一组消息对象 (Human/AI 交替),不是一段字符串。如果你把历史拼成字符串塞进("system", "{history}"),模型看到的是"用户说:xxx;助手说:yyy"这种被压扁的文本------它对角色的判断会变差,长对话里尤其明显。Day 78 的记忆机制直接依赖这个插槽。
3.2 Parser:三种选择,别乱用
| Parser | 输出类型 | 什么时候用 | 失败时 |
|---|---|---|---|
StrOutputParser |
str |
给师傅看的说明文字、分类短串 | 几乎不会失败 |
JsonOutputParser |
dict |
想要 JSON 但没有固定字段结构 | 抛 OutputParserException |
PydanticOutputParser |
Pydantic 对象 | 有明确字段和类型(工单、处置方案、审批单) | 抛异常,可携带原文重试 |
判断标准只有一条:下游代码要不要按字段名取值。 要 → Pydantic;不要 → Str。
用户要看一句话 → StrOutputParser
中间结果要传给下一段代码 → PydanticOutputParser(字段有类型、有校验、IDE 能补全)
只想快速拿个 dict → JsonOutputParser(但要自己处理键缺失)
3.3 Model:ChatOpenAI 的几个真参数
| 参数 | 建议值 | 理由 |
|---|---|---|
temperature |
规划/抽取 0.0-0.2;写方案 0.6-0.8 | 让模型"选工具、填参数"时不要抖 |
max_tokens |
按场景给(分类 128,方案 1024) | 既是成本闸门也是防跑飞闸门 |
timeout |
30 | 客户现场网络抖动,宁可快速失败走降级 |
max_retries |
0 | 重试统一交给 LCEL 的 .with_retry() |
base_url |
https://api.deepseek.com |
DeepSeek 是 OpenAI 兼容协议,所以复用 ChatOpenAI |
⚠️ "OpenAI 兼容"不等于"完全一致" 。DeepSeek 在 tool calling、JSON mode 上的细节与 OpenAI 有差异(比如参数描述的容错度)。所以永远不要在生产里假设它俩行为一致------工具调用相关的代码,接新模型第一天就要跑一遍 Day 78 的"工具选择测试用例集"。
🖥️ 四、LCEL 的四种调用与三个增强件
实操步骤 3:invoke / batch / stream / ainvoke
"""agent/call_demo.py ------ Day77:LCEL 的四种调用方式
"""
from __future__ import annotations
import asyncio
import time
from agent.chains import classify_chain
QUESTIONS = [
"A3 料筒温度 91 度报警不消",
"B2 液压站渗油",
"C1 传送带异响",
"A3 冷却水压力 0.21MPa",
"D5 机械手定位偏差",
]
def demo_invoke() -> None:
t0 = time.perf_counter()
out = classify_chain.invoke({"description": QUESTIONS[0]})
print(f"[invoke] {out} ({int((time.perf_counter()-t0)*1000)}ms)")
def demo_batch() -> None:
"""⭐ batch 是 LCEL 最省钱的能力:一次等 5 条,内部并发"""
t0 = time.perf_counter()
outs = classify_chain.batch([{"description": q} for q in QUESTIONS])
cost = int((time.perf_counter() - t0) * 1000)
for q, o in zip(QUESTIONS, outs):
print(f"[batch] {q[:14]:<16} → {o}")
print(f" 5 条共 {cost}ms,单条均摊 {cost // len(QUESTIONS)}ms")
def demo_stream() -> None:
"""流式:Day74 的 FastAPI 打字机效果直接受益"""
print("[stream] ", end="", flush=True)
for chunk in classify_chain.stream({"description": QUESTIONS[0]}):
print(chunk, end="", flush=True)
print()
async def demo_ainvoke() -> None:
"""异步版:FastAPI 的 async 路由必须用 ainvoke,否则阻塞事件循环"""
outs = await asyncio.gather(*[
classify_chain.ainvoke({"description": q}) for q in QUESTIONS
])
print(f"[ainvoke] 并发 {len(outs)} 条完成")
if __name__ == "__main__":
demo_invoke()
demo_batch()
demo_stream()
asyncio.run(demo_ainvoke())
[invoke] 温控|high|料筒超温 91℃ 持续报警 (1180ms)
[batch] A3 料筒温度 91 度报警 → 温控|high|料筒超温 91℃ 持续报警
[batch] B2 液压站渗油 → 液压|medium|渗油暂未影响运行
[batch] C1 传送带异响 → 机械|medium|异响需现场确认
[batch] A3 冷却水压力 0.21MPa → 温控|high|水压低于 0.25MPa 阈值
[batch] D5 机械手定位偏差 → 电气|medium|定位偏差需标定
5 条共 1640ms,单条均摊 328ms
[stream] 温控|high|料筒超温 91℃ 持续报警
[ainvoke] 并发 5 条完成
观察点:
-
batch跑 5 条只花了 1.6 秒,而invoke一条就要 1.2 秒。并发不等于 5 倍快(服务端有限流),但 3-4 倍是稳的,评测脚本、批量回填历史数据这类活全部受益。 -
stream吐出来的是字符串片段 (因为链路末尾是StrOutputParser),可以直接往 SSE 里塞。 -
ainvoke必须配async def路由。在 FastAPI 里用了invoke而不是ainvoke,你的服务并发会掉到 1,这是第二阶段 FastAPI 知识的直接延伸。
实操步骤 4:RunnableParallel 把两路检索并行起来
Day 75 的 v0.2 里,你要先查工单、再查手册,然后合并------是串行的。现在两路一起跑:
"""agent/parallel_demo.py ------ Day77:RunnableParallel 并行取数
场景:用户问『A3 现在有什么问题,手册怎么说』
需要同时拿【工单】和【手册片段】,再交给模型合并回答。
"""
from __future__ import annotations
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda, RunnableParallel, RunnablePassthrough
from agent.lc_model import get_llm
from agent.tools import _list_tickets, _search_manual # Day76 的工具函数,直接复用
# ─────── ① 两个取数分支(普通函数 → Runnable) ───────
fetch_tickets = RunnableLambda(lambda x: _list_tickets(device_id=x["device_id"], status="open"))
fetch_manual = RunnableLambda(lambda x: _search_manual(query=x["question"], device_id=x["device_id"]))
# ─────── ② 并行容器:三个键同时算 ───────
collect = RunnableParallel({
"tickets": fetch_tickets,
"manual": fetch_manual,
"question": RunnablePassthrough(), # ⭐ 把原始输入原样透传
})
# ─────── ③ 合并后交给模型 ───────
MERGE_PROMPT = ChatPromptTemplate.from_messages([
("system",
"你是工厂运维助手。依据【工单】和【手册】回答,禁止编造。\n"
"没有的内容直接说『未查到』。用中文短句,关键参数加粗。\n\n"
"【工单】\n{tickets}\n\n【手册】\n{manual}"),
("human", "{question}"),
])
parallel_chain = collect | MERGE_PROMPT | get_llm() | StrOutputParser()
if __name__ == "__main__":
out = parallel_chain.invoke({
"question": "A3 现在有哪些没处理的问题?超温该怎么处置?",
"device_id": "A3",
})
print(out)
A3 目前有 2 条未关闭工单:
1. WO-20260930-01(high):料筒温度 91℃,超温告警未消
2. WO-20261001-03(medium):液压油管接头处渗油
超温处置(手册 §3.2):料筒设定 80℃,超过 85℃ 触发高温告警;
**料筒温度未降至 60℃ 以下禁止打开防护罩**。
(工单数据来自本地样本,工单接口当前不可达)
观察点: RunnableParallel 里三个键并发执行 (底层线程池)。如果其中一路是网络 IO(httpx 调工单 API),省下的时间是实打实的。RunnablePassthrough() 的作用是把上游输入原样带到下游,避免你手动把 question 再塞一遍。
实操步骤 5:重试与降级(.with_retry / .with_fallbacks)
"""agent/resilience_demo.py ------ Day77:给链加上重试与降级
"""
from __future__ import annotations
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda
from agent.chains import CLASSIFY_PROMPT
from agent.lc_model import get_llm
# ─────── ① 重试:网络抖、限流,自动再来 ───────
robust_chain = (
CLASSIFY_PROMPT
| get_llm().with_retry(stop_after_attempt=3, wait_exponential_jitter=True)
| StrOutputParser()
)
# ─────── ② 降级:模型彻底不可用时,返回一条能用的兜底 ───────
def _fallback_text(x: dict) -> str:
"""⭐ 降级必须是【有信息量的】,不能是空串或 500"""
return f"其他|medium|自动分类暂不可用,已转人工(原始描述:{x.get('description', '')[:30]})"
fallback_chain = RunnableLambda(_fallback_text)
final_chain = robust_chain.with_fallbacks([fallback_chain])
if __name__ == "__main__":
print(final_chain.invoke({"description": "A3 料筒温度 91 度"}))
# 模拟模型挂掉:把 DEEPSEEK_API_KEY 临时改错,再跑一次
# print(final_chain.invoke({"description": "A3 料筒温度 91 度"}))
把 .env 里的 Key 临时改错一个字符,再跑一次,你会看到降级分支生效:
正常:温控|high|料筒超温 91℃ 持续报警
Key 改错:其他|medium|自动分类暂不可用,已转人工(原始描述:A3 料筒温度 91 度)
⚠️
with_fallbacks的隐藏代价 :fallback 链的输入必须和主链同构 (都是 dict)。如果你的降级函数签名写成了def f(x: str),主链失败时它拿到的还是 dict,会在降级里二次报错------降级链自己崩了,用户看到的是 500 。写完一定要故意把 Key 改错测一遍。
📖 五、结构化输出:用 PydanticOutputParser 替掉昨天的正则
5.1 为什么这是今天最有价值的一段
Day 76 你用四个正则去捞 Thought: / Action: / Action Input: / Final Answer:。那套东西在演示时能跑,在客户现场会这样死:
模型输出: Action: list_tickets
Action Input: {"device_id": "A3", "status": "open"} ← 前面多了两个空格
你的正则: ^Action Input: (.*)$ → 匹配失败 → 整个循环崩在第 3 轮
结构化输出解析器的思路是反过来:先告诉模型"你必须输出什么结构",再由一个带校验的解析器去接。接不住就抛异常,异常里带上原文,让模型自己改。
5.2 三行代码拿到格式说明书
"""agent/parsers_demo.py ------ Day77:PydanticOutputParser 的正确用法
"""
from __future__ import annotations
from langchain_core.output_parsers import PydanticOutputParser
from pydantic import BaseModel, Field
class TicketDraft(BaseModel):
"""工单草稿:Day78 的 create_ticket 工具就吃这个结构"""
device_id: str = Field(description="设备编号,如 A3")
title: str = Field(description="故障现象一句话,不超过 30 字")
priority: str = Field(description="优先级:low / medium / high")
need_shutdown: bool = Field(description="处置是否必须停机")
parser = PydanticOutputParser(pydantic_object=TicketDraft)
# ⭐ 这三行是本节的核心:把格式要求直接塞进提示词
instructions = parser.get_format_instructions()
print(instructions[:400])
instructions 打印出来大致是这样(不同小版本措辞略有差异,以实际输出为准):
The output should be formatted as a JSON instance that conforms to the JSON schema below.
As an example, for the schema {"properties": {"foo": {...}}}
the object {"foo": "bar"} is a well-formatted instance of the schema.
Here is the output schema:
{"properties": {"device_id": {"description": "设备编号,如 A3", "title": "Device Id", "type": "string"}, ...}}
💡 Field(description=...) 不是注释,是给模型看的参数说明。 它会被渲染进格式说明书里。所以"优先级:low / medium / high"这种枚举值必须写进 description ,否则模型会给你
priority="紧急"。这是 Pydantic v2 + LangChain 组合最舒服的地方:一份 schema 同时服务运行时校验 和提示词。
实操步骤 6:带自动修复的结构化抽取
解析器接不住时会抛异常。正确的处理不是 try/except 吞掉,而是把原文和错误一起回喂给模型,让它改一次:
"""agent/structured.py ------ Day77:结构化抽取 + 解析失败自动修复
思路(Day80 的降级策略也是这个套路的两倍复杂度):
抽取失败 → 把【原文 + 报错】拼进一条修复提示词 → 再让模型输出一次
→ 还失败就走规则兜底,绝不给用户空白或 500
"""
from __future__ import annotations
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda
from pydantic import BaseModel, Field
from agent.lc_model import get_llm
class TicketDraft(BaseModel):
"""工单草稿"""
device_id: str = Field(description="设备编号,如 A3、B2")
title: str = Field(description="故障现象一句话,不超过 30 字")
priority: str = Field(description="优先级:low / medium / high")
need_shutdown: bool = Field(description="处置是否必须停机")
parser = PydanticOutputParser(pydantic_object=TicketDraft)
EXTRACT_PROMPT = ChatPromptTemplate.from_messages([
("system",
"从用户的报修描述中抽取工单要素。\n"
"只依据用户说的,缺失的字段用合理默认值,禁止编造设备编号。\n"
"{format_instructions}"),
("human", "{description}"),
]).partial(format_instructions=parser.get_format_instructions())
def _repair(raw: str, error: str) -> TicketDraft:
"""解析失败后的修复链:把原文和错误一起给模型看"""
fix_prompt = ChatPromptTemplate.from_messages([
("system",
"下面这段文本本应是符合要求的 JSON,但解析失败了。\n"
"请只输出修正后的 JSON,不要有任何解释文字。\n"
"{format_instructions}"),
("human", "原文:\n{raw}\n\n解析错误:\n{error}"),
]).partial(format_instructions=parser.get_format_instructions())
chain = fix_prompt | get_llm() | parser
return chain.invoke({"raw": raw, "error": error})
def _rule_fallback(description: str) -> TicketDraft:
"""最后一道:规则兜底,保证一定返回一个对象"""
prio = "high" if any(k in description for k in ("停机", "超温", "冒烟", "报警")) else "medium"
dev = "A3" if "A3" in description else ("B2" if "B2" in description else "未知")
return TicketDraft(device_id=dev, title=description[:30], priority=prio,
need_shutdown=prio == "high")
def extract_ticket(description: str) -> TicketDraft:
"""⭐ 三段式:解析 → 修复 → 规则兜底"""
raw = (EXTRACT_PROMPT | get_llm()).invoke({"description": description}).text
try:
return parser.invoke(raw)
except Exception as e: # noqa: BLE001
print(f"[warn] 首次解析失败:{type(e).__name__},启动修复链")
try:
return _repair(raw, str(e)[:300])
except Exception as e2: # noqa: BLE001
print(f"[warn] 修复链也失败:{type(e2).__name__},走规则兜底")
return _rule_fallback(description)
if __name__ == "__main__":
for d in ["A3 料筒温度 91 度,冒烟了,赶紧停机",
"那台机器好像有点问题,你们来看看"]:
obj = extract_ticket(d)
print(type(obj).__name__, "|", obj.model_dump())
TicketDraft | {'device_id': 'A3', 'title': '料筒温度 91℃ 冒烟需停机', 'priority': 'high', 'need_shutdown': True}
TicketDraft | {'device_id': '未知', 'title': '那台机器好像有点问题,你们来看看', 'priority': 'medium', 'need_shutdown': False}
观察点:
-
第二次输入是个"什么都没说"的描述,
device_id诚实地返回了"未知"。这比模型编一个 A3 出来强一百倍------Day 76 的铁律第 1 条在这里由 Pydantic 兜住了。 -
修复链只在失败时才跑,正常路径不多花一个 token。这是"降级策略"的标准姿势:便宜的路先走,贵的路兜底。
-
返回值是
TicketDraft对象,下游代码obj.device_id有 IDE 补全和类型检查------Day 76 里那种args.get("device_id")到处取字典的日子结束了。
🖥️ 六、用 LCEL 重写 v0.2 问答链
6.1 先看原来的样子
Day 75 冻结的 v0.2 问答链,核心是这几步串行代码:
question → 检索(Qdrant 向量 + BM25 关键词 → RRF 融合 → BGE-Reranker 精排 Top-3)
→ 拼上下文字符串
→ 手拼 messages
→ openai SDK 调用
→ 取 content
→ 后处理(补引用编号)
现在把它整条搬进 LCEL。检索部分不动 (Day 71 的 kb/retriever.py 是冻结资产),只换"检索之后"的拼装层。
实操步骤 7:agent/qa_chain.py
"""agent/qa_chain.py ------ Day77:用 LCEL 重写 v0.2 知识库问答链
⭐ 检索部分复用 Day71 冻结的 kb/retriever.py(Qdrant + BM25 + BGE-Reranker)。
检索器不可用(没起 Qdrant / 没灌库)时,自动降级到本地片段,保证链永远能跑。
"""
from __future__ import annotations
from dataclasses import dataclass
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables import RunnableLambda, RunnableParallel, RunnablePassthrough
from agent.lc_model import get_llm
@dataclass
class Chunk:
"""一个检索片段------和 Day71 的返回结构对齐"""
text: str
source: str
section: str
score: float
# ─────── ① 检索:优先真检索,失败降级 ───────
def retrieve(question: str, device_id: str | None = None, top_k: int = 3) -> list[Chunk]:
"""调 Day71 的混合检索;不可用则退回内置片段"""
try:
from kb.retriever import hybrid_search # Day71 冻结资产
hits = hybrid_search(question, device_id=device_id, top_k=top_k)
return [Chunk(text=h["text"], source=h.get("source", "unknown"),
section=h.get("section", ""), score=float(h.get("score", 0.0)))
for h in hits]
except Exception: # noqa: BLE001
from agent.tools import MANUAL_SNIPPETS # Day76 内置片段
return [Chunk(text=t, source="manual_a3.clean.md", section=s, score=0.0)
for s, t in MANUAL_SNIPPETS][:top_k]
def format_context(chunks: list[Chunk]) -> str:
"""把片段渲染成带编号的资料块------编号是后面引用溯源的关键"""
if not chunks:
return "(无可用资料)"
lines = []
for i, c in enumerate(chunks, 1):
loc = f"{c.source} · {c.section}" if c.section else c.source
lines.append(f"[{i}] {loc}\n{c.text}")
return "\n\n".join(lines)
def build_citations(chunks: list[Chunk]) -> list[str]:
return [f"[{i}] {c.source} · {c.section}" for i, c in enumerate(chunks, 1)]
# ─────── ② 提示词:沿用 Day73/75 调过的版本 ───────
QA_PROMPT = ChatPromptTemplate.from_messages([
("system",
"你是工厂设备运维助手,只依据【资料】回答用户问题。\n"
"规则:\n"
"1. 资料里没有的内容,明确回答『手册中未提及』,禁止编造参数值。\n"
"2. 引用用 [1] [2] 标注,编号必须与【资料】中的编号一致。\n"
"3. 涉及安全的条款(停机、挂牌上锁、禁止操作)必须原样写出并加粗。\n"
"4. 用中文短句,写给一线师傅看。\n\n"
"【资料】\n{context}"),
MessagesPlaceholder("history"),
("human", "{question}"),
])
# ─────── ③ LCEL:并行取【资料】和【引用表】,再生成 ───────
qa_chain = (
RunnableParallel({
"context": RunnableLambda(lambda x: format_context(retrieve(
x["question"], device_id=x.get("device_id"), top_k=x.get("top_k", 3)))),
"citations": RunnableLambda(lambda x: build_citations(retrieve(
x["question"], device_id=x.get("device_id"), top_k=x.get("top_k", 3)))),
"question": RunnablePassthrough(),
"history": RunnableLambda(lambda x: x.get("history", [])),
})
| QA_PROMPT
| get_llm()
| StrOutputParser()
)
def ask(question: str, device_id: str | None = None,
history: list | None = None) -> dict:
"""对外唯一入口:返回答案 + 引用(Day74 的 FastAPI 直接调这个)"""
answer = qa_chain.invoke({
"question": question,
"device_id": device_id,
"history": history or [],
"top_k": 3,
})
chunks = retrieve(question, device_id=device_id, top_k=3)
return {"answer": answer, "citations": build_citations(chunks),
"used_chunks": len(chunks)}
if __name__ == "__main__":
r = ask("冷却水压低于多少要停机?", device_id="A3")
print(r["answer"])
print("---")
print("参考来源:", " ".join(r["citations"]))
python -m agent.qa_chain
冷却水进水压力正常范围 0.3~0.5MPa,**低于 0.25MPa 必须停机检查** [2]。
参考来源: [1] manual_a3.clean.md · 3.2 料筒超温处置 [2] manual_a3.clean.md · 4.3 冷却系统维护
对比一下改写前后的账:
| 项目 | v0.2(Day 75) | v0.2 + LCEL(今天) |
|---|---|---|
| 拼装层代码行数 | 约 70 行 | 约 25 行 |
| 换模型 | 改函数内部 | 换管道里一个对象 |
| 多轮历史 | 手工拼字符串 | MessagesPlaceholder("history") |
| 批量评测 | 自己写并发 | .batch() 一行 |
| 流式输出 | 自己处理 SSE | .stream() / .astream() |
| 重试降级 | 无 | .with_retry() + .with_fallbacks() |
| 检索逻辑 | 不动 | 不动(冻结资产) |
💡 这段改写里最该学的是"改什么、不改什么" 。检索(Qdrant/BM25/Reranker)是 Day 71-73 花三天调出来的、有 RAGAS 数据支撑的资产,一个字都不能动 ;被替换的只有"拼装层"。FDE 在现场做重构的第一原则是:先划出冻结区,再动其它地方。
🖥️ 七、接回 FastAPI:LCEL 在第二阶段的服务里怎么放
实操步骤 8:app/routers/agent_qa.py
"""app/routers/agent_qa.py ------ Day77:把 LCEL 链挂回第二阶段的 FastAPI
⭐ 三条铁律(第二阶段养成的,这里重申):
1. 路由必须 async def,链必须用 ainvoke / astream(用 invoke 会把事件循环堵死)
2. 入参出参一律 Pydantic v2 模型
3. 异常不许裸奔,必须转成结构化响应
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from agent.qa_chain import ask
router = APIRouter(prefix="/api/agent", tags=["agent-qa"])
class AskRequest(BaseModel):
question: str = Field(min_length=1, max_length=500)
device_id: str | None = None
session_id: str | None = None
class AskResponse(BaseModel):
answer: str
citations: list[str]
used_chunks: int
@router.post("/ask", response_model=AskResponse)
async def ask_endpoint(req: AskRequest) -> AskResponse:
try:
data = await ask_async(req.question, req.device_id)
except RuntimeError as e:
raise HTTPException(status_code=503, detail=f"模型服务不可用:{e}") from e
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"问答失败:{type(e).__name__}") from e
return AskResponse(**data)
async def ask_async(question: str, device_id: str | None) -> dict:
"""⭐ 同步链在线程池里跑,别直接 await 一个同步函数"""
import asyncio
return await asyncio.to_thread(ask, question, device_id)
@router.post("/ask/stream")
async def ask_stream(req: AskRequest) -> StreamingResponse:
"""流式版:Day74 做过的 SSE,这里用 astream_events 更省事"""
async def gen():
from agent.qa_chain import qa_chain
payload = {"question": req.question, "device_id": req.device_id,
"history": [], "top_k": 3}
async for event in qa_chain.astream_events(payload, version="v2"):
if event["event"] == "on_chat_model_stream":
piece = event["data"]["chunk"].text
if piece:
yield f"data: {piece}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(gen(), media_type="text/event-stream")
uvicorn app.main:app --reload --port 8000
# 另一个终端
curl -X POST http://localhost:8000/api/agent/ask \
-H "Content-Type: application/json" \
-d '{"question":"冷却水压低于多少要停机?","device_id":"A3"}'
{
"answer": "冷却水进水压力正常范围 0.3~0.5MPa,**低于 0.25MPa 必须停机检查** [2]。",
"citations": ["[1] manual_a3.clean.md · 3.2 料筒超温处置", "[2] manual_a3.clean.md · 4.3 冷却系统维护"],
"used_chunks": 2
}
⚠️ 同步链 + async 路由的正确姿势 :如果你的链是同步的(
invoke),用asyncio.to_thread(...)把它扔进线程池;别写成async def里直接调同步阻塞函数,那跟用invoke一样堵。等 Day 78 把工具层换成httpx.AsyncClient之后,就可以整链ainvoke了。
📖 八、什么时候不要上 LangChain
8.1 三条反向清单
| 场景 | 为什么别上 | 怎么办 |
|---|---|---|
| 只有一两次 LLM 调用、链路固定 | 引入依赖换来的是"多一层抽象要学" | 裸 SDK + 一个 llm.py,Day 76 那样就够 |
| 对延迟极度敏感(< 200ms) | 框架有若干层包装开销(毫秒级,但存在) | 核心路径裸调,周边用框架 |
| 团队没人读过 LangChain 源码 | 出问题只能靠猜,猜的成本高于手写 | 先写 Day 76 那种裸循环,跑半年再评估 |
8.2 今天系统的国防 Tier
Tier 0 裸 SDK 调用 Day51-60 可控,但拼装全靠人
Tier 1 LCEL 管道(今天)🆕 链路可组合、可并发、可重试、可流式
Tier 2 + 工具与记忆 Day78 模型能动手、能记住上下文
Tier 3 + 状态图与回路 Day79 能分支、能回头重来
Tier 4 + 人在回路 Day80 高危动作有人签字
Tier 5 + 多智能体 Day81-84 职责分离
Tier 6 + 全链路 trace Day85 出问题能复盘
📊 LCEL 与三件套速查表
LCEL API 速查
| 需求 | 写法 |
|---|---|
| 建管道 | `chain = prompt |
| 单条调用 | chain.invoke({"question": "..."}) |
| 批量并发 | chain.batch([{...}, {...}]) |
| 流式 | for chunk in chain.stream({...}): ... |
| 异步单条 | await chain.ainvoke({...}) |
| 异步流式事件 | async for e in chain.astream_events({...}, version="v2") |
| 并行取数 | RunnableParallel({"a": r1, "b": r2}) |
| 原样透传 | RunnablePassthrough() |
| 函数变 Runnable | RunnableLambda(lambda x: ...) |
| 重试 | model.with_retry(stop_after_attempt=3) |
| 降级 | chain.with_fallbacks([fallback]) |
| 固定部分变量 | prompt.partial(context="...") |
| 看输入变量 | prompt.input_variables |
三件套参数速查
| 组件 | 关键参数 | 说明 |
|---|---|---|
ChatOpenAI |
model / base_url / api_key / temperature / timeout / max_retries |
DeepSeek 走 OpenAI 兼容协议;max_retries=0 交给上层 |
ChatPromptTemplate.from_messages |
元素为 ("system"/"human"/"ai", 文本) 或 MessagesPlaceholder |
占位符用 {var} |
MessagesPlaceholder |
变量名,如 "history" |
多轮历史的插槽,Day 78 会用 |
StrOutputParser |
无参 | 取消息文本 |
JsonOutputParser |
无参 / pydantic_object= |
想要 dict 时用 |
PydanticOutputParser |
pydantic_object=MyModel |
配套 parser.get_format_instructions() |
Field(description=...) |
Pydantic v2 | 描述会进格式说明书,枚举值必须写 |
常见翻车与解法
| ❌ 症状 | 原因 | ✅ 解法 |
|---|---|---|
AIMessage 打印出来是 <bound method> 之类 |
用了旧版 .text() |
v1 用 .text 属性 |
chain.invoke("字符串") 报 KeyError |
LCEL 入参必须是 dict | chain.invoke({"question": "..."}) |
| 模板报「缺少变量 history」 | 用了 MessagesPlaceholder 但没传 |
传 {"history": []} 或 .partial() |
with_fallbacks 里降级链也报错 |
降级函数签名与主链输入不同构 | 降级函数一律接 dict |
| Pydantic 解析总失败 | description 没写清枚举/格式 |
在 Field(description=...) 里写 [low/medium/high] |
| 换模型后工具调用全乱 | OpenAI 兼容≠行为一致 | 新模型上线第一天跑工具选择测试集(Day 78) |
| 抄的教程报弃用警告 | 用了 LLMChain/AgentExecutor 等旧 API |
2026 年用 create_agent + langgraph |
| 并发没变快 | 同步链在 async 路由里阻塞 | asyncio.to_thread() 或整链 ainvoke |
📝 本课小结
| 知识点 | 一句话记住 |
|---|---|
| LangChain 的本质 | 一套 Runnable 协议 + 一堆现成实现,不替你做决策 |
| 2026 版本现状 | v1.x 是主线,旧 API 都进了 langchain-classic |
| 旧 API 黑名单 | AgentExecutor / initialize_agent / create_react_agent / LLMChain |
| 新 Agent 入口 | create_agent(model=..., tools=[...])(Day 78 用) |
| 三件套 | Prompt(模板)+ Model(ChatOpenAI)+ Parser(Str/Json/Pydantic) |
| LCEL 核心语法 | `chain = prompt |
| 四种调用 | invoke / batch(并发省钱)/ stream(打字机)/ ainvoke(FastAPI 必用) |
| 并行取数 | RunnableParallel + RunnablePassthrough 透传原始输入 |
| 重试与降级 | .with_retry() 治抖动,.with_fallbacks() 治不可用 |
| 结构化输出 | PydanticOutputParser + get_format_instructions() 替掉正则 |
| 解析失败修复 | 原文 + 错误回喂模型一次,再不行规则兜底 |
| 消息对象 | v1 用 .text 属性,不是 .text() |
| 冻结区原则 | 检索(Qdrant/BM25/Reranker)不动,只换拼装层 |
| v0.2 + LCEL | 拼装层 70 行 → 25 行,且白得并发/流式/重试 |
🧠 核心认知 :框架的价值从来不是"少写几行代码",而是把隐式的约定变成显式的契约 。Day 76 里"工具清单的 schema"和"提示词里的工具描述"是两份靠人肉同步的东西,而 LangChain 让你只写一份;Day 76 里"输出格式"藏在你的正则里,而 PydanticOutputParser 把它变成了一份模型和你都看得见的 schema。契约一旦显式化,批处理、流式、重试、并发这些横切能力才有地方挂靠 ------这就是为什么同样是调一次模型,裸写 150 行只能跑,LCEL 25 行能上生产。反过来说,如果你的链路只有一步、且永不变化,那这份契约就是纯成本:判断"要不要上框架"的标准不是项目大小,而是这条链路未来会不会被复用、被并发、被替换。
📋 课后练习
练习 1:把 Day 76 的四个正则全部删掉(约 50 分钟)
-
新建
agent/plan_schema.py,用 Pydantic 定义AgentStep:thought: str、action: str(枚举为 Day 76 五个工具名)、action_input: dict、final_answer: str | None。 -
用
PydanticOutputParser重写agent/react.py里的parse_reply(),删掉那四个正则。 -
跑 Day 76 的 5 个用例,逐条对比:新的解析成功率是上升还是下降?如果下降,把失败样本记下来 ------很可能说明你的
description写得不如原来那段 Markdown 清楚。 -
把 5 个用例的成功率与 token 消耗写成一张表存进
doc/week12/day77_reflection.md。(提示:Pydantic 版通常多花 5-10% tokens,但成功率明显更高,这笔账值得。)
练习 2:batch 的加速比实测(约 40 分钟)
-
造 20 条真实的报修描述(可以从
agent/tools.py的SAMPLE_TICKETS扩写)。 -
分别用
for循环 +invoke、batch、asyncio.gather+ainvoke跑这 20 条,记录总耗时、平均单条耗时、失败条数。 -
把
batch的批量大小改成 5 / 10 / 20 各跑一次,画出"批量大小 → 总耗时"的曲线。 -
回答:在你的网络与账号限流条件下,最优批量是多大?为什么不是越大越好?(提示:服务端并发上限、单请求超时、失败重跑粒度)
练习 3:给 v0.2 问答链加上"拒答"分支(约 45 分钟)
-
在
qa_chain.py里加一个judge_chain:CLASSIFY_PROMPT | llm | PydanticOutputParser(Answerable),模型判断"资料是否足以回答",输出answerable: bool+reason: str。 -
用
RunnableParallel让"检索"与"可答性判断"并行跑(判断只需要问题,不需要等检索)。 -
主链末尾加一个
RunnableLambda:answerable=False时返回固定话术「手册中未提及该内容,建议联系设备厂家或查阅 §X」,并把citations置空。 -
用 Day 73 建的那批黄金问题里的"不可答问题"验证:确认系统会明确拒答而不是编造。
🔭 下节预告
今天把"拼装层"工业化了,但模型依然没有手 :它只能把拿到的资料组织成一段话,不能自己去查工单、不能建单。Day 76 你用手写循环给了它手,Day 77 你又把那份手写代码扔掉了------明天就是把两者合起来的时候。
明天(Day 78)主题是**「工具与记忆 --- 给模型挂上工单工具」**:
| 环节 | 做什么 |
|---|---|
① @tool 装饰器 |
把 Day 76 的五个工具函数各加一个装饰器,schema 自动生成,加字段再也不会漏同步 |
| ② 工具描述怎么写 | 描述写不好模型就不会用------用一组"工具选择测试用例"量化这件事 |
| ③ 接真实 API | 工具内部打第二阶段的 FastAPI,不通就降级(Day 76 那套降级逻辑原样保留) |
| ④ 对话记忆 | InMemoryChatMessageHistory + RunnableWithMessageHistory,让模型记住"刚才那台设备" |
⑤ create_agent |
LangChain v1 的官方 Agent 入口,一行把模型 + 工具 + 记忆组装起来 |
| ⑥ 工具错误自纠 | 工具报错时把错误字符串喂回去,看模型能不能自己改参数重试 |
一句话:今天给的是管道,明天接上的是手脚和记性------Day 78 结束时,你的 v0.2 就不再是"问答系统",而是"能干活的系统"。
明天见 👋
🌍附录:前置课程列表
阶段一:认知启蒙(AI 认知与 FDE 角色)
AI 认知
【FDE系列】阶段1Day 1:AI 层级关系 --- 四个嵌套的圈-CSDN博客
【FDE系列】阶段1Day 2:AI 三阶段发展史 --- 会认 → 会判断 → 会创造-CSDN博客
【FDE系列】阶段1Day 3:符号 AI vs 机器学习 --- 两条路线的本质区别-CSDN博客
【FDE系列】阶段1Day 4:Transformer 的历史意义 --- 2017 年的分水岭-CSDN博客
【FDE系列】阶段1Day 5:本周复习与自测 --- 检验你的 AI 认知地基-CSDN博客
【FDE系列】阶段1Day 6:Transformer 架构 --- 一张图纸盖出千千万万栋楼-CSDN博客
【FDE系列】阶段1Day 7:LLM 本质 --- 文字接龙机器-CSDN博客
【FDE系列】阶段1Day 8:Token --- 模型眼中的最小单位-CSDN博客
【FDE系列】阶段1Day 9:AI 幻觉 --- 为什么会一本正经地胡说八道-CSDN博客
【FDE系列】阶段1Day 10:上下文窗口 --- 模型的记忆力上限 + 本周复习-CSDN博客
【FDE系列】阶段1Day 11:Prompt --- 给模型立规矩-CSDN博客
【FDE系列】阶段1Day 12:Memory --- 让模型记住上下文
【FDE系列】阶段1Day 13:RAG --- 给模型配图书管理员-CSDN博客
【FDE系列】阶段1Day 14:Tool Use --- 让模型动手操作-CSDN博客
【FDE系列】阶段1Day 15:MCP --- 统一的工具接口标准 + 第三周复习-CSDN博客
FDE 基础概念
【FDE系列】阶段1Day 16:什么是 FDE --- 把 AI 变成客户结果的人-CSDN博客
【FDE系列】阶段1Day 17:FDE vs 传统实施 --- 三大本质区别-CSDN博客
【FDE系列】阶段1Day 18:FDE 三重身份 + C6 胜任力模型-CSDN博客
【FDE系列】阶段1Day 19:七阶段行动路径 + 行业经验的价值-CSDN博客
【FDE系列】阶段1Day 20:阶段总结与产出物 --- 第一阶段收官-CSDN博客
阶段二:技术地基(Python + FastAPI + SQL + Docker + API 集成)
Python基础
【FDE系列】阶段2:Day 21:Python 环境搭建 --- 写出你的第一行代码-CSDN博客
【FDE系列】阶段2:Day 22:变量、数据类型、条件判断 --- Python 的"记忆"和"判断"-CSDN博客
【FDE系列】阶段2:Day 23:循环与函数 --- 让代码跑 100 遍、把逻辑打包复用-CSDN博客
【FDE系列】阶段2:Day 24:数据结构 --- 列表、字典、集合、元组-CSDN博客
【FDE系列】阶段2:Day 25:文件读写与 JSON --- 让程序连通外部数据(第一周收官)-CSDN博客
【FDE系列】阶段2:Day 26:模块化编程 --- 把代码拆成"抽屉柜"-CSDN博客
【FDE系列】阶段2:Day 27:异常处理与日志 --- 让程序"摔不烂、查得到"-CSDN博客
FastAPI入门到进阶
【FDE系列】阶段2:Day 28:FastAPI 入门 --- 把你的函数变成 API 服务-CSDN博客
【FDE系列】阶段2:Day 29:FastAPI 进阶 --- Pydantic 模型与完整 CRUD 实战-CSDN博客
【FDE系列】阶段2:Day 30:生产代码规范 --- 测试、类型注解、配置管理(第二周收官)-CSDN博客
SQL基础
【FDE系列】阶段2:Day 31:SQL 基础 --- 增删改查一把梭-CSDN博客
【FDE系列】阶段2:Day 32:多表查询 --- JOIN 与聚合-CSDN博客
【FDE系列】阶段2:Day 33:进阶查询 --- 窗口函数与 CTE-CSDN博客
【FDE系列】阶段2:Day 34:数据清洗 --- 把脏数据捋干净-CSDN博客
【FDE系列】阶段2:Day 35:Python + SQL --- 工单接入 MySQL + 本周收官-CSDN博客
Linux基础
【FDE系列】阶段2:Day 36:Linux 入门与文件操作 --- 扔掉鼠标的第一天-CSDN博客
【FDE系列】阶段2:Day 37:权限、进程与文本三剑客-CSDN博客
【FDE系列】阶段2:Day 38:Shell 脚本 --- 把命令串起来自动跑-CSDN博客
【FDE系列】阶段2:Day 39:Linux 综合实战 --- 让服务无人值守-CSDN博客
【FDE系列】阶段2:Day 40:Shell 进阶 --- 生产级脚本与本周收官-CSDN博客
Docker
【FDE系列】阶段2:Day 41:Docker 入门 --- 把环境装进盒子-CSDN博客
【FDE系列】阶段2:Day 42:Dockerfile 实战 --- 把你的应用打包成镜像-CSDN博客
【FDE系列】阶段2:Day 43:Docker Compose --- 多容器一键编排-CSDN博客
【FDE系列】阶段2:Day 44:Nginx 反向代理 + Git 版本控制-CSDN博客
【FDE系列】阶段2:Day 45:综合实战 --- Docker + Nginx + Git 完整部署与本周收官-CSDN博客
API 集成与系统对接
【FDE系列】阶段2:Day 46:RESTful 设计与认证授权-CSDN博客
【FDE系列】阶段2:Day 47:对接企业系统 --- 飞书 / 钉钉 API-CSDN博客
【FDE系列】阶段2:Day 48:Webhook 处理与数据映射-CSDN博客
【FDE系列】阶段2:Day 49:OpenAPI 文档与接口测试-CSDN博客
【FDE系列】阶段2:Day 50:综合项目 --- 设备告警工单闭环系统 & 第二阶段收官 特殊字符-CSDN博客
阶段三:AI 应用技术(含 SDD 方法论)
AI基础:Prompt Engineering 系统训练
【FDE系列】阶段3:Day 51:从聊天窗口到代码 --- 跟 LLM 的第一次握手-CSDN博客
【FDE系列】阶段3:Day 52:Prompt 三板斧 --- 角色、示例与清晰指令-CSDN博客
【FDE系列】阶段3:Day 53:结构化输出 --- 让模型的回答能进数据库-CSDN博客
【FDE系列】阶段3:Day 54:思维链与推理任务 --- 让模型一步步想清楚-CSDN博客
【FDE系列】阶段3:Day 55:综合实战 --- 巡检报告生成器与本周收官 -CSDN博客
【FDE系列】阶段3:Day 56:评测体系入门 --- 建立你的黄金评测集-CSDN博客
【FDE系列】阶段3:Day 57:Promptfoo 实战 --- A/B 对比让数据说话-CSDN博客
【FDE系列】阶段3:Day 58:Prompt 安全 --- 注入、越狱与防护-CSDN博客
【FDE系列】阶段3:Day 59:模板化与追踪 --- Jinja2 与 Langfuse-CSDN博客
【FDE系列】阶段3:Day 60:综合实战 --- 智能工单助手 v0.1 冻结-CSDN博客
RAG 知识检索系统
【FDE系列】阶段3:Day 61:RAG 全景 --- 给模型配一间资料室-CSDN博客
【FDE系列】阶段3:Day 62:Embedding --- 文字是怎么变成向量的-CSDN博客
【FDE系列】阶段3:Day 63:文档解析 --- 把真实 PDF 手册变成可用文本-CSDN博客
【FDE系列】阶段3:Day 64:文本分块 --- 决定检索成败的那一步-CSDN博客
【FDE系列】阶段3:Day 65:向量数据库入门 --- Chroma 与本周收官-CSDN博客
【FDE系列】阶段3:Day 66:Qdrant 入门 --- 生产级向量库-CSDN博客
【FDE系列】阶段3:Day 67:混合检索 --- BM25 与 RRF 融合-CSDN博客
【FDE系列】阶段3:Day 68:重排序 --- 用 BGE-Reranker 把真正相关的顶上来-CSDN博客
【FDE系列】阶段3:Day 69:引用溯源 --- 让每个答案都能对上原文-CSDN博客
【FDE系列】阶段3:Day 68:重排序 --- 用 BGE-Reranker 把真正相关的顶上来-CSDN博客
【FDE系列】阶段3:Day 71:高级检索 --- 查询改写与 HyDE-CSDN博客
【FDE系列】阶段3:Day 72:RAGFlow 平台 --- 端到端知识库
【FDE系列】阶段3:Day 73:RAGAS 评测 --- 用数据证明改动有效-CSDN博客
【FDE系列】阶段3:Day 74:企业 RAG 架构 --- 增量更新与生产就绪检查单-CSDN博客
【FDE系列】阶段3:Day 75:知识库问答 v0.2--- 集成工单系统与本周收官-CSDN博客
Agent 框架与开发
【FDE系列】阶段3:Day 76:Agent 本质 --- 徒手实现 ReAct 循环-CSDN博客
待完成教程:
Tool Calling 与 MCP
LLM 推理与部署
规范驱动开发与 Agent 工程方法论
阶段四:平台与交付(含 Agent 治理)
阶段五:行业实战与认证