【FDE系列】阶段3:Day 77:LangChain 基础 — LCEL 管道与三件套

📚前言

📒FDE系列内容总纲:

【大纲】FDE 前沿部署工程师学习系列教程-CSDN博客

🚄前置课程列表:

见文档结尾附录。


🚀阶段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)

观察点三个:

  1. invoke() 返回的不是字符串,而是一个 AIMessage 消息对象。这是 LangChain 与裸 SDK 最大的体感差异:消息里除了文本还有角色、工具调用、用量元数据。

  2. .text 是 属性 ,不是方法(v1 的改动,网上老代码写 .text() 会拿到一个方法对象而不是字符串,然后你会在下游看到奇怪的报错)。

  3. 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 分钟)

  1. 新建 agent/plan_schema.py,用 Pydantic 定义 AgentStep:thought: str、action: str(枚举为 Day 76 五个工具名)、action_input: dict、final_answer: str | None。

  2. 用 PydanticOutputParser 重写 agent/react.py 里的 parse_reply(),删掉那四个正则。

  3. 跑 Day 76 的 5 个用例,逐条对比:新的解析成功率是上升还是下降?如果下降,把失败样本记下来 ------很可能说明你的 description 写得不如原来那段 Markdown 清楚。

  4. 把 5 个用例的成功率与 token 消耗写成一张表存进 doc/week12/day77_reflection.md。(提示:Pydantic 版通常多花 5-10% tokens,但成功率明显更高,这笔账值得。)

练习 2:batch 的加速比实测(约 40 分钟)

  1. 造 20 条真实的报修描述(可以从 agent/tools.py 的 SAMPLE_TICKETS 扩写)。

  2. 分别用 for 循环 + invoke、batch、asyncio.gather + ainvoke 跑这 20 条,记录总耗时、平均单条耗时、失败条数。

  3. 把 batch 的批量大小改成 5 / 10 / 20 各跑一次,画出"批量大小 → 总耗时"的曲线。

  4. 回答:在你的网络与账号限流条件下,最优批量是多大?为什么不是越大越好?(提示:服务端并发上限、单请求超时、失败重跑粒度)

练习 3:给 v0.2 问答链加上"拒答"分支(约 45 分钟)

  1. 在 qa_chain.py 里加一个 judge_chain:CLASSIFY_PROMPT | llm | PydanticOutputParser(Answerable),模型判断"资料是否足以回答",输出 answerable: bool + reason: str。

  2. 用 RunnableParallel 让"检索"与"可答性判断"并行跑(判断只需要问题,不需要等检索)。

  3. 主链末尾加一个 RunnableLambda:answerable=False 时返回固定话术「手册中未提及该内容,建议联系设备厂家或查阅 §X」,并把 citations 置空。

  4. 用 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 治理)

阶段五:行业实战与认证

相关推荐
AI办公探索者1 小时前
从“岗位为中心”到“任务为中心”:企业级Agent Hub的架构演进
人工智能·架构
fly-iot1 小时前
【下一个 AI 方向】:中小企业的数字化建设,物联网解决方案思考,使用开源项目快速搭建降低成本。
人工智能·物联网
方芯半导体2 小时前
2026工业机器人控制芯片赛道:竞争格局与关键选型指标深度解析
大数据·人工智能·机器人
科创致远2 小时前
科创致远 ESOP 赋能车间图纸全维管理实战
大数据·人工智能·制造·精益工程
技灵AI2 小时前
AI 口播视频提示词怎么写?让人物讲解和产品展示更自然
人工智能·音视频
小林ixn2 小时前
FastAPI + LangChain 实战:从 0 到 1 打造高性能 AI 后端服务
人工智能·langchain·fastapi
forest_092 小时前
Agent 系列 21|部署、发布与运维:把 Agent 变成一个能上线、能放量、能撤回的服务
人工智能·机器学习·架构·prompt·agi
企业数字化笔记2 小时前
AI视频增强后画质怎么验收?插值基线、伪细节与帧间闪烁的检查方法
人工智能·算法·音视频
十@八九2 小时前
VS Code 接入 AMD Vivado 2026.1 AI 助手:从 VSIX 到 MCP 实战
人工智能·fpga·vs code·vivado·mcp