回调与调试(全课程收官章)
本章配套可运行示例:demo/democallback.py。demo 里写了两个自定义回调——控制台计时器 + 文件日志器——一次挂到同一条荐书链上;Ollama 在线用真模型,离线自动换成内置假模型,钩子触发完全相同…
本章配套可运行示例:demo/demo_callback.py。demo 里写了两个自定义回调——控制台计时器 + 文件日志器——一次挂到同一条荐书链上;Ollama 在线用真模型,离线自动换成内置假模型,钩子触发完全相同(回调挂的是「事件」,不是某个具体模型),离线也能完整跑通。运行后还会在
demo/目录生成callback_run.log,亲眼看到文件日志回调写了什么。本章你将学会
- 回调机制:
BaseCallbackHandler的生命周期钩子——on_chain_start/on_chat_model_start/on_llm_new_token/on_llm_end/on_tool_start等,链上每个环节的一举一动都能被你听见- 一个必踩的坑:聊天模型触发的是
on_chat_model_start,不是on_llm_start——只写后者,挂在ChatOllama上一个事件都收不到callbacks参数挂载:调用时传、构造时传、一次挂多个(多回调并存)- 两个实用回调:控制台计时回调(哪一步慢了一目了然)、文件日志回调(输入输出追加写进日志文件)
run_id/parent_run_id:从一串 UUID 里读出整棵「执行树」- 调试三件套:
set_debug(True)全局开关(顺带说清verbose和环境变量在 1.x 的现状)、astream_events事件流、以及一张「什么时候用哪个」的选择地图- 全课程收官:12 章知识地图、从模板到 Agent 的主线串讲、后续学习方向(LangGraph / LangSmith / 真实项目)
上一章(11 · Agent)收尾时,拾光书屋的 AI 店员已经全须全尾:会荐书(02-05 章)、懂店里的私货(06-08 章)、记得住聊过什么(09 章)、有手脚能查库存算折扣(10 章)、还能自己排工序(11 章)。然后它上线了。
上线第三天,你收到两条反馈:「有时候要干等二十秒」、「有次回答纯属胡编」。你打开代码——prompt | model | parser,或者一整个 Agent——中间发生了什么,一行日志都没有。链是一个黑盒:进去一个问题,出来一个答案,慢在哪一步、错在哪一环,全靠猜。
第 04 章我们给链上过三张保险单:with_fallbacks 换备胎、with_retry 再敲两下门、超时拔电源。但那些都是「出了事之后怎么办」——事发现场没有目击者。本章的回调(callback)补上这块:它是 LangChain 里最底层的观测机制——任何一个 Runnable(模型、链、工具、检索器)跑起来的每一步,都会向外广播事件;你只要写一个「接收器」挂上去,就能在现场听见一切。
比喻:把链想成一条流水线。04 章的 fallback/retry 是流水线尽头的保险丝——炸了知道换备用线;回调是给每个工位装监控探头——哪个工位开工了、干了几秒、交出什么货、中途有没有摔东西,实时传到你的监控室。保险丝管善后,探头管现场,一个都不能少。
顺带一提:本章也是全课程的最后一章。回调这个「旁观一切」的视角,恰好是把前面 11 章串起来复盘的最佳观景台——收官内容放在第 6 节。
技术基线:Python + LangChain 1.x(langchain + langchain-ollama),本地 Ollama 跑 qwen3:4b。没装的话回 01 章看环境准备。本章多数示例不需要模型也能跑(回调机制与模型无关),demo 离线可运行。
1. 回调是什么:给流水线的每个工位装探头
作用:不改动链的任何一行代码,把「执行过程」变成可观测的。你在 invoke / stream 时往 config 里塞一个回调对象,框架在关键节点上自动调用你的钩子方法。
核心类:BaseCallbackHandler(from langchain_core.callbacks import BaseCallbackHandler)。它本身所有钩子都是空方法,你继承它、覆写关心的那几个,其余的保持沉默。
最小示例——一个「什么都记」的回调,挂到 03 章的老朋友荐书链上(本例不需要真模型,用一个内置的假模型代替,钩子触发与真模型完全一致):
from typing import Any
from uuid import UUID
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
class EchoHandler(BaseCallbackHandler):
"""把收到的每个事件原样打印出来。"""
def on_chain_start(
self, serialized: dict[str, Any], inputs: dict[str, Any],
*, run_id: UUID, parent_run_id: UUID | None = None, **kwargs: Any,
) -> None:
name = (serialized or {}).get("name") or "Runnable"
print(f"[链开始] {name},输入:{inputs}")
def on_chain_end(
self, outputs: dict[str, Any],
*, run_id: UUID, parent_run_id: UUID | None = None, **kwargs: Any,
) -> None:
print(f"[链结束] 输出:{str(outputs)[:50]}")
def on_chat_model_start(
self, serialized: dict[str, Any], messages: list[list[Any]],
*, run_id: UUID, parent_run_id: UUID | None = None, **kwargs: Any,
) -> None:
print(f"[模型开始] 收到 {len(messages[0])} 条消息")
def on_llm_new_token(
self, token: str, *, run_id: UUID,
parent_run_id: UUID | None = None, **kwargs: Any,
) -> None:
print(f"[新token] {token!r}")
def on_llm_end(
self, response: Any, *, run_id: UUID,
parent_run_id: UUID | None = None, **kwargs: Any,
) -> None:
print(f"[模型结束] 生成:{response.generations[0][0].text[:50]}")
prompt = ChatPromptTemplate.from_messages([("human", "用一句话推荐《{book}》")])
model = GenericFakeChatModel(messages=iter([AIMessage(content="《夜航船》:夜航人的掌灯。")]))
chain = prompt | model | StrOutputParser()
for chunk in chain.stream({"book": "夜航船"}, config={"callbacks": [EchoHandler()]}):
pass
运行后你会看到一长串输出(节选、略去 run_id 细节):
[链开始] Runnable,输入:{'input': ''}
[链开始] ChatPromptTemplate,输入:{'book': '夜航船'}
[链结束] 输出:messages=[HumanMessage(content='用一句话推荐《夜航船》'...
[模型开始] 收到 1 条消息
[新token] '《夜航船'
[新token] '》:夜航'
[新token] '人的掌灯'
[新token] '。'
[模型结束] 生成:《夜航船》:夜航人的掌灯。
[链结束] 输出:《夜航船》:夜航人的掌灯。
注意看事件顺序背后的结构:整条链是一个 run,链里的每个组件(提示词、模型、解析器)各自是子 run。每个事件都带着两个身份证号:
run_id:本次 run(一次组件执行)的唯一编号;parent_run_id:父 run 的编号——组件的 parent 是整条链,整条链的 parent 是None。
把这两个号连起来,就是一棵执行树:
RunnableSequence(整条链) run_id=…981 parent=None
├── ChatPromptTemplate(提示词) run_id=…982 parent=…981
├── 模型 run_id=…983 parent=…981
│ └── (流式吐字:每块一个 on_llm_new_token)
└── StrOutputParser(解析器) run_id=…984 parent=…981
第 3 节的文件日志回调和第 5 节的 astream_events,读的都是这棵树。排查「二十秒到底慢在哪」时,你只要看每个 run 的开始与结束时间差,一目了然。
常见坑:
- 钩子名全是
on_xxx,不是 JS 版的handleXxx。从 JS 迁移的肌肉记忆要改:handleLLMStart→on_llm_start,handleChainStart→on_chain_start,首字母小写、驼峰变蛇形,详见 1.1 的对照表。 serialized参数别指望太多。1.x 里它不再是完整的序列化对象,只剩name/id等少量字段(体积和安全考虑)。想拿组件名,写(serialized or {}).get("name");拿更多细节走kwargs里的metadata/tags。- 覆写钩子时建议保留
**kwargs。框架往钩子里塞的关键字参数(tags、metadata、invocation_params……)版本间会增减,写死签名容易在新版本上直接TypeError。 - 钩子里抛异常不会炸掉主流程,但会被框架捕获并打一条告警日志——回调代码还是要自己小心,别让探头冒烟。
1.1 JS → Python 钩子名对照表
| JS 写法(camelCase 风格) | Python 写法 | 触发时机 |
|---|---|---|
handleLLMStart |
on_llm_start |
补全型 LLM 开始执行前 |
| (JS 无此区分) | on_chat_model_start |
聊天型模型开始执行前 |
handleLLMNewToken |
on_llm_new_token |
流式输出每吐一块 |
handleLLMEnd |
on_llm_end |
模型成功返回 |
handleLLMError |
on_llm_error |
模型调用出错 |
handleChainStart |
on_chain_start |
链 / Runnable 开始 |
handleChainEnd |
on_chain_end |
链 / Runnable 结束 |
handleToolStart |
on_tool_start |
工具被调用前 |
handleToolEnd |
on_tool_end |
工具返回结果 |
Python 版比 JS 版多出一类钩子值得单独认识:on_chat_model_start——这是 Python 版最容易踩的坑,1.2 节展开。
1.2 最大的坑:on_llm_start 收不到聊天模型的事件
LangChain 的「模型」分两种血统,开场的钩子不一样:
| 模型类型 | 本课程用到的例子 | 开始时触发的钩子 | 收到的参数 |
|---|---|---|---|
| 补全型 LLM | OllamaLLM(字符串进字符串出) |
on_llm_start |
prompts: list[str] |
| 聊天型模型 | ChatOllama(消息进消息出) |
on_chat_model_start |
messages: list[list[BaseMessage]] |
ChatOllama 是聊天模型——只触发 on_chat_model_start,不触发 on_llm_start。你若只覆写了 on_llm_start,挂在荐书链上会一个事件都收不到,还以为回调坏了。稳妥的写法是两个都覆写,或者在 on_chat_model_start 里干活。
钩子参数的形状也不同:on_llm_start 拿到的是拼好的字符串列表 prompts;on_chat_model_start 拿到的是「双层列表」messages——外层对应一次批量调用里的每个请求(通常长度为 1),内层才是这个请求的消息列表(system / human / ……)。取当前请求的消息写 messages[0],每条消息的正文在 msg.content、角色在 msg.type。
2. 挂载方式:callbacks 参数的三个姿势
作用:把回调接到链上。有三种接法,覆盖从「临时调试」到「生产常驻」的场景。
2.1 调用时传(最常用)
invoke / stream / batch 的 config 参数里塞 callbacks,只对这一次调用生效:
chain.invoke({"book": "夜航船"}, config={"callbacks": [EchoHandler()]})
注意 callbacks 的值是列表——这直接引出多回调并存:一次挂几个都行,事件会广播给每一个:
result = chain.invoke(
{"book": "夜航船"},
config={"callbacks": [EchoHandler(), ConsoleTimingHandler(), FileLogHandler()]},
)
控制台要计时、文件要留档、屏幕要回显——互不干扰,各干各的。第 3 节会把「计时 + 文件日志」这对组合写成完整代码。
2.2 挂在整条链上,还是挂在某个组件上?
挂在最外层(整条链)时,事件会向下继承:链上的每个组件执行时,你的 handler 都能收到对应事件——第 1 节的执行树就是这么看到的(你只挂了一次,却看见了提示词、模型、解析器各自的 start/end)。
想只盯某一个环节,就把回调挂在那个组件上;或者调用时给 run 打上 tags、命名 run_name,在回调里按这些标记挑着看(5.2 节的过滤参数同理)。
2.3 构造时传(常驻)
模型/链构造时就绑上,之后每次调用自动生效,适合生产环境的日志回调:
model = ChatOllama(model="qwen3:4b", callbacks=[FileLogHandler()])
常见坑:
config是第几个参数别搞混。chain.invoke(input, config)里config是第二个位置参数;写成关键字config={...}最稳。- 构造时传的回调会常驻。调试用的
EchoHandler挂在构造参数里,等你上线了它会一直刷屏——临时观测用 2.1 的调用时传法,用完即走。 callbacks里放的是实例,不是类。[EchoHandler()]对,[EchoHandler]错——后者不会报错但也永远不会触发,静默失败最坑人。
3. 两个实用回调:计时器与文件日志
光会打印不够,本节写两个真的能上生产的回调。场景还是那个:用户说等了二十秒,到底慢在哪? 控制台计时回调负责现场看,文件日志回调负责留档查。
3.1 控制台计时回调:每个 run 掐一块表
思路:on_chain_start / on_chat_model_start 时按下秒表(记下 run_id → 开始时刻),对应的 end 钩子里查表算差值。run_id 是天然的字典键——每个 run 一块表,互不串线。
import time
from typing import Any
from uuid import UUID
from langchain_core.callbacks import BaseCallbackHandler
class ConsoleTimingHandler(BaseCallbackHandler):
"""控制台计时回调:每个 run 开始掐表,结束报耗时。"""
def __init__(self) -> None:
self._starts: dict[UUID, float] = {}
def on_chain_start(
self, serialized: dict[str, Any], inputs: dict[str, Any],
*, run_id: UUID, **kwargs: Any,
) -> None:
self._starts[run_id] = time.perf_counter()
name = (serialized or {}).get("name") or "链"
print(f"[计时] ▶ {name} 开工")
def on_chain_end(self, outputs: dict[str, Any], *, run_id: UUID, **kwargs: Any) -> None:
elapsed = time.perf_counter() - self._starts.pop(run_id, time.perf_counter())
print(f"[计时] ■ 结束,耗时 {elapsed:.2f}s")
def on_chat_model_start(
self, serialized: dict[str, Any], messages: list[list[Any]],
*, run_id: UUID, **kwargs: Any,
) -> None:
self._starts[run_id] = time.perf_counter()
print(f"[计时] ▶ 模型开工({len(messages[0])} 条消息进来)")
def on_llm_end(self, response: Any, *, run_id: UUID, **kwargs: Any) -> None:
elapsed = time.perf_counter() - self._starts.pop(run_id, time.perf_counter())
print(f"[计时] ■ 模型收工,耗时 {elapsed:.2f}s")
用 time.perf_counter() 而不是 time.time()——前者是单调高精度计时器,不受系统改时间影响,掐表就该用它。
3.2 文件日志回调:把现场写进档案
思路:追加模式("a")打开日志文件,模型开始时记输入、结束时记输出、出错时记异常。工具调用也顺手记上——排查「胡编」类问题,先看它到底调了什么工具、拿到了什么数据。
from pathlib import Path
from typing import Any
from uuid import UUID
from langchain_core.callbacks import BaseCallbackHandler
class FileLogHandler(BaseCallbackHandler):
"""文件日志回调:模型的输入输出、工具调用、错误,追加写进日志文件。"""
def __init__(self, path: Path) -> None:
self._path = path
def _write(self, text: str) -> None:
with self._path.open("a", encoding="utf-8") as f:
f.write(text)
def on_chat_model_start(
self, serialized: dict[str, Any], messages: list[list[Any]],
*, run_id: UUID, **kwargs: Any,
) -> None:
model_name = (serialized or {}).get("name", "未知模型")
lines = "\n".join(f" [{m.type}] {m.content}" for m in messages[0])
self._write(f"[模型开始] {model_name}(run {str(run_id)[:8]})\n{lines}\n")
def on_llm_end(self, response: Any, *, run_id: UUID, **kwargs: Any) -> None:
text = response.generations[0][0].text
self._write(f"[模型结束] 生成:{text}\n\n")
def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
self._write(f"[模型出错] {error!r}\n\n")
def on_tool_start(
self, serialized: dict[str, Any], input_str: str,
*, run_id: UUID, **kwargs: Any,
) -> None:
name = (serialized or {}).get("name", "未知工具")
self._write(f"[工具开始] {name}({input_str})\n")
def on_tool_end(self, output: Any, *, run_id: UUID, **kwargs: Any) -> None:
self._write(f"[工具结束] 返回:{output}\n")
两个要点:
on_llm_end里取文本:response.generations[0][0].text。generations是「双层列表」(外层对应批量请求,内层对应每个候选生成),日常取generations[0][0]就是那个ChatGeneration,它的.text属性会把AIMessage.content拎出来。- 每条消息的角色:
msg.type是'system'/'human'/'ai'这样的字符串,日志里标上它,回放时才知道这段话是谁说的。
3.3 多回调并存:一次挂两个
把 3.1 和 3.2 一起挂上,控制台实时看耗时、文件悄悄留档——这就是 demo 的核心段落:
timing = ConsoleTimingHandler()
file_log = FileLogHandler(Path("callback_run.log"))
result = chain.invoke(
{"reader": "通勤族", "book": "夜航船"},
config={"callbacks": [timing, file_log]},
)
常见坑:
- 文件回调记得处理编码和并发。
open(..., encoding="utf-8")在中文环境不是可选项;多线程场景(batch并发)下每条日志独立 open-write-close 是最朴素的线程安全写法,追求性能再考虑加锁或队列。 - 别在回调里干重活。钩子是同步等在现场的:你在
on_llm_new_token里 sleep 一秒,流式输出就慢一秒。推送监控、写库这类操作,回调里只扔进队列,真正干活放到别处。 on_llm_new_token只在流式时逐块触发。invoke一次性等完整结果,通常不吐 token 事件——想统计 token 得走stream,或者到on_llm_end里读统计字段。
4. 钩子速查表:一网打尽
前文按需零散用了不少钩子,这里给一张全表。四组钩子按「被观测的对象」分类,全部都带 run_id / parent_run_id(下表省略):
| 分组 | 钩子 | 触发时机 | 典型用途 |
|---|---|---|---|
| 模型 | on_llm_start(serialized, prompts) |
补全型 LLM 开始 | 记提示、启动计时 |
| 模型 | on_chat_model_start(serialized, messages) |
聊天型模型开始 | 记消息、启动计时 |
| 模型 | on_llm_new_token(token, *, chunk) |
流式每吐一块 | 打字机指示、实时统计 |
| 模型 | on_llm_end(response) |
模型成功返回 | 记输出、算耗时/token |
| 模型 | on_llm_error(error) |
模型调用出错 | 告警、记错误日志 |
| 链 | on_chain_start(serialized, inputs) |
任何 Runnable 开始 | 记入口、建执行树 |
| 链 | on_chain_end(outputs) |
Runnable 结束 | 记出口、算总耗时 |
| 链 | on_chain_error(error) |
Runnable 抛错 | 定位「断在哪一环」 |
| 工具 | on_tool_start(serialized, input_str) |
工具被调用前 | 审计参数、记录调用链 |
| 工具 | on_tool_end(output) |
工具返回 | 记结果 |
| 工具 | on_tool_error(error) |
工具抛错 | 排查 Agent 翻车现场 |
| 其他 | on_text(text) |
通用文本事件(Agent 输出思考过程时) | 观察 Agent 内心戏 |
| 其他 | on_agent_action(action) |
Agent 决定下一步动作 | 记录决策链 |
| 其他 | on_agent_finish(finish) |
Agent 收尾 | 记最终答案 |
| 其他 | on_retriever_start / on_retriever_end |
检索器开始/返回(08 章) | 记召回内容 |
几个补充说明:
- 链钩子的覆盖面最广。模型、工具、检索器在框架里也都是 Runnable,你自定义的
RunnableLambda更是——它们至少会触发on_chain_start/on_chain_end。也就是说:一个只覆写了链钩子的 handler,挂在任何链上都有事件可看,这是最小可行的观测方案。 - 错误钩子是「断在哪一环」的答案。链上任何一环抛错,那一环的
on_xxx_error会先响,然后异常继续向上传播(一路上每层的 error 钩子依次响)。04 章的with_fallbacks换备胎时,主链的 error 事件照样会被回调看见——失败和回退的全过程都在监控里(demo 第 3 段演示了这个组合)。 - Agent 场景四组全用得上:
on_chat_model_start看模型每次思考的输入、on_agent_action看它决定调哪个工具、on_tool_start/end看工具拿了什么返回什么、on_agent_finish看最终交付——一章 Agent 的执行轨迹就拼出来了。
5. 调试技巧:三件套
本章的观测工具凑成三件:自定义回调(第 3 节已讲,自己写探头)、set_debug 全局开关、astream_events 事件流。后两件是框架现成的,本节上。定位一句话:set_debug 是全店大喇叭,astream_events 是跟拍镜头,自定义回调是常驻探头。
5.1 set_debug(True):一行代码打开全量日志
from langchain_core.globals import set_debug
set_debug(True) # 开
result = chain.invoke({"book": "夜航船"})
set_debug(False) # 用完关掉,别让它一直刷
打开后每次调用都会打印完整的事件流——每个 run 的进出、输入输出的 JSON、耗时,控制台直接变成监控室:
[chain/start] [chain:RunnableSequence] Entering Chain run with input:
{
"book": "夜航船"
}
[chain/start] [chain:RunnableSequence > prompt:ChatPromptTemplate] Entering Prompt run with input:
...
[llm/start] [chain:RunnableSequence > llm:ChatOllama] Entering LLM run with input:
{
"prompts": [
"Human: 用一句话推荐《夜航船》"
]
}
[llm/end] [chain:RunnableSequence > llm:ChatOllama] [1247ms] Exiting LLM run with output:
...
方括号里的 chain:RunnableSequence > prompt:ChatPromptTemplate 就是执行树的「路径表示法」——从根到当前节点;[1247ms] 直接告诉你模型这一环花了多久。
两个历史遗留问题说清楚(老教程和新版本对不上号的根源):
verbose基本退休了。老版本里链和模型构造参数有verbose=True,会打印「Entering new chain...」。1.x 里 LCEL 链上它几乎不再产生输出(它服务的旧版 Chain 类已整体移除)——别在它身上浪费时间,直接用set_debug。- 环境变量
LANGCHAIN_DEBUG/LANGCHAIN_VERBOSE在 1.x 已被收回。老教程里export LANGCHAIN_DEBUG=true的写法现在不生效——框架不再自动读这俩变量,开关收编为代码里的set_debug()/set_verbose()(从langchain_core.globals导入)。从 JS 版迁移要特别注意:JS 版传环境变量,Python 1.x 传函数调用。
5.2 astream_events:事件流方式看链内部
回调要写一个类,astream_events 是零成本的观测方式——不用写任何 handler,直接把链的执行过程当成一个事件流来消费:
import asyncio
async def main() -> None:
async for ev in chain.astream_events({"book": "夜航船"}, version="v2"):
print(f"{ev['event']:24s} {ev['name']}")
asyncio.run(main())
每个事件 ev 是一个字典,关键字段:event(事件名)、name(组件名)、run_id、data(输入输出或流式块)、tags、metadata、parent_ids(整条祖先链——执行树直接给你排好了)。运行输出长这样:
on_chain_start RunnableSequence
on_prompt_start ChatPromptTemplate
on_prompt_end ChatPromptTemplate
on_chat_model_start ChatOllama
on_chat_model_stream ChatOllama
on_parser_start StrOutputParser
on_parser_stream StrOutputParser
on_chain_stream RunnableSequence
on_chat_model_end ChatOllama
on_parser_end StrOutputParser
on_chain_end RunnableSequence
事件名比回调钩子更细一层:提示词环节有专属的 on_prompt_start / on_prompt_end,解析器有 on_parser_start / on_parser_end,流式有 on_chat_model_stream / on_parser_stream——链上每种组件的进出都有专属事件。
只想盯某个环节?用过滤参数,别的事件直接不产生:
async for ev in chain.astream_events(
{"book": "夜航船"},
version="v2",
include_names=["ChatOllama"], # 只看名字叫 ChatOllama 的组件
):
print(ev["event"], ev["data"])
include_types(按类型过滤,如 ["chat_model"])、include_tags、以及对应的 exclude_* 一家人,用法相同。
常见坑:
version="v2"参数别漏。astream_events有 v1/v2 事件格式之分,v1 是历史包袱,不传 version 会收到弃用警告。- 它是异步接口。得在
async def里async for,脚本要asyncio.run(main());同步代码里想看事件流,用 5.1 的set_debug。 ev["data"]的内容随事件类型变:on_chain_start里是{"input": ...},on_chain_end里是{"output": ...},on_chat_model_stream里是{"chunk": ...}——取值前先看事件名,别硬取。
5.3 调试工具选择地图
| 你的处境 | 用什么 | 一句话理由 |
|---|---|---|
| 开发时想知道「这条链到底干了什么」 | set_debug(True) |
一行代码,全量倾倒,看完就关 |
| 只想盯链里某一个环节 | astream_events + include_names |
跟拍镜头,指哪打哪 |
| 要计时、要指标、要自定义格式 | 继承 BaseCallbackHandler |
探头自己装,想听什么自己定 |
| 生产环境留档审计 | 文件日志回调(构造时挂) | 常驻探头,写盘留证 |
| 要可视化界面、要团队共享 | LangSmith(LANGCHAIN_TRACING_V2=true) |
官方托管平台,第 6 节收官部分细说 |
6. 全课程收官:一张地图,一条主线,几条岔路
回调是「旁观一切」的机制,用它收官正好——现在你站在监控室里,回头看这 12 章搭起来的整条流水线。
6.1 12 章知识地图
| 章 | 解决的问题 | 关键 API / 概念 |
|---|---|---|
| 01 认识 LangChain | 跑通第一次对话,看懂返回值 | ChatOllama、AIMessage、流式初体验、模型缓存 |
| 02 提示词模板与消息 | 提示词别再手拼字符串 | ChatPromptTemplate、消息类型、MessagesPlaceholder |
| 03 LCEL 与 Runnable | 组件像积木一样拼 | \| 管道、RunnableLambda / RunnableParallel / RunnablePassthrough |
| 04 流式与并发 | 等得久、一批活儿排队 | stream / batch、max_concurrency、wait_for、with_fallbacks / with_retry |
| 05 输出解析器 | 模型吐的是字符串,代码要的是数据 | StrOutputParser、with_structured_output、JsonOutputParser |
| 06 文档加载与切割 | 把本地资料喂给模型 | Document、各种 Loader、RecursiveCharacterTextSplitter |
| 07 嵌入与向量库 | 让「意思」可计算 | OllamaEmbeddings、InMemoryVectorStore、余弦相似度 |
| 08 检索与 RAG | 模型不知道书屋的私货 | as_retriever、k / MMR / filter、RAG 链、来源溯源 |
| 09 对话记忆 | 店员记不住聊过什么 | ChatMessageHistory、RunnableWithMessageHistory、会话隔离 |
| 10 工具调用 | 让模型使唤真代码 | @tool、tool_calls、参数 schema、结果回填 |
| 11 Agent | 让模型自己排工序 | ReAct 循环、create_agent、多工具编排 |
| 12 回调与调试 | 黑盒变透明 | BaseCallbackHandler、callbacks、set_debug、astream_events |
6.2 主线串讲:从一句话到一个店员
回头看,这 12 章其实是在回答一个不断升级的问题。
第 01 章,问题只是「能不能说上话」——第一次 invoke,等来 qwen3:4b 的一句介绍,你看懂了 AIMessage 里除了正文还有一坨元数据。第 02 章马上发现提示词开始重复:欢迎语、荐书规则、语气要求——于是模板和消息接管了「怎么说话」。
第 03 章是把乐高摊开的章节:提示词、模型、解析器都是 Runnable,| 一接就是一条流水线。第 04 章解决体验——流式让用户一秒见字,并发让书单不用排队,fallback/retry 给链上保险。第 05 章解决出口:模型吐的字符串被拆箱成 str / dict / 对象,业务代码终于拿到能直接用的东西。
第 06-08 章是一整段「给店员喂私货」的旅程:本地文件变成 Document、切成小块、算成向量、存进向量库、按问题检索、塞进提示词——RAG 让店员答得上「我们店的借阅规则」,而不是靠模型的出厂记忆瞎编。
第 09 章给店员装了记性——多轮对话里它记得你上一句说了什么;第 10 章给了它手脚——查库存、算折扣这些事交给真代码去干,模型只负责决定和解释;第 11 章松开了缰绳——Agent 自己决定这一轮先查库存还是先写荐语。
然后就是本章:店员上岗了,你回到监控室。计时回调盯着每一环的耗时,文件日志记着每一次模型输入输出和工具调用,astream_events 随时可以跟拍任何一环。从「说上一句话」到「一个可观测的员工」,这就是全部的主线。
6.3 我们没讲什么,以及接下来去哪
诚实地列一下这门课没覆盖的(也是你接下来可以去的方向):
- LangGraph——把「循环决策」升级成「图」。11 章的 Agent 本质是一个「想-做-看-再想」的循环;当流程复杂到需要多角色协作、条件分支、人工审批、断点续跑,就该把循环画成显式的状态图。LangChain 官方当前的发力重心也在这里。
- LangSmith——把本章的监控室换成指挥中心。回调/
set_debug是往控制台倾倒日志;LangSmith 把同样的 trace 信息(就是本章那棵执行树)送到网页上可视化,外加数据集评测、回归测试、团队共享。开启方式就是一个环境变量:LANGCHAIN_TRACING_V2=true。 - 真实项目:换掉内存向量库(FAISS / Chroma / 云托管)、把链包进 FastAPI 上线、给 RAG 加重排(rerank)、做输出评测、管成本和 token 预算——工程世界的下半场,从你自己的项目开始。
- 本课程刻意绕开的:多模态(图片/音频)、语音、微调、各家云端模型的差异细节——都不难,等你需要时它们自然会进入射程。
最后一条建议:别把这 12 章当「学完了」,当成「工具箱满了」。挑一个你身边真实的场景——你的笔记、你的文档、你的业务数据——用 RAG 加几个工具搭一个小 Agent,再用本章的回调观察它。第一次看着自己的 Agent 在监控室里一步步决策、调用、交付时,你会真正感觉到:这些东西是你的了。
7. 本章小结
一张表带走本章:
| 你的需求 | 用什么 |
|---|---|
| 观察链上每个环节的执行过程 | 继承 BaseCallbackHandler,覆写钩子 |
| 挂载回调(临时) | invoke(..., config={"callbacks": [handler]}) |
| 挂载回调(常驻) | 构造时传 callbacks=[...] |
| 控制台 + 文件双路观测 | 多回调并存:[ConsoleTimingHandler(), FileLogHandler()] |
| 聊天模型的事件收不到 | 覆写 on_chat_model_start(不是 on_llm_start) |
| 理解嵌套结构 | run_id / parent_run_id 连成执行树 |
| 快速看全量执行日志 | set_debug(True)(1.x 里环境变量已收回,用代码开关) |
| 临时盯某个环节 | astream_events(..., version="v2", include_names=[...]) |
| 可视化 trace / 团队共享 | LangSmith(LANGCHAIN_TRACING_V2=true) |
配套 demo(在本章 demo/ 目录):
demo_callback.py:两个自定义回调(控制台计时 + 文件日志)挂到荐书链上流式跑、工具钩子、错误与with_fallbacks回退的全程观测、astream_events事件流、set_debug截选展示——Ollama 离线也能完整跑(自动换内置假模型,钩子触发一致),运行产物callback_run.log当场可查。
全课程完 · 返回 00 · 使用指南
收官寄语——
还记得第 01 章的那个下午吗?五行代码,model.invoke("用一句话向新会员介绍「拾光书屋」。"),然后对着屏幕等 qwen3:4b 慢吞吞吐出第一句话。那时候「AI 店员」四个字听起来像个遥远的工程。
十二个章节走完,你已经亲手做过这些事:用模板和消息规范它的谈吐,用 LCEL 把它拼成流水线,用流式和并发守住体验底线,用解析器把它的话拆成数据,用加载器、切割器、嵌入和向量库把书屋的私货喂给它,用 RAG 让它答得上有据,用记忆让它记得住你,用工具和 Agent 让它自己动起来——而今天,你给这一切装上了探头。
更重要的是,这十二个章节教你的从来不是「背 API」:每次遇到问题——等太久、格式乱、答非所问、记不住、不会动手——你学会的都是先看清机制,再挑对工具。这个习惯比任何一行的代码都值钱。
拾光书屋打烊了,灯还亮着。带着你的工具箱,去做点真东西吧。下一本书,你自己写。