AIGC标识 LangGraph 状态机实战

在这里插入图片描述

Key Takeaways

  • LangChain 解决的是「统一接入各家 LLM + 标准化输入输出」,LangGraph 解决的是「把多步骤、有状态、多分支的流程组织成图」
  • uv 是比 pip/conda 更快的 Python 包与环境管理器,用 uv init 初始化项目、uv venv 建虚拟环境、uv add -r requirements.txt 批量装依赖
  • LangChain 用 init_chat_model 统一初始化聊天模型,格式为 provider:model_name(如 groq:qwen-3-family),换供应商只改字符串、上层代码不动
  • 结构化输出让 LLM 按给定 schema 返回数据,便于下游程序直接解析使用,而不是对着一段自由文本做正则
  • 嵌套结构(nested schema)指一个模型字段本身是另一个模型或模型列表(list[SubModel]),用于表达层级化数据

从「调一次 API」到「可编排的有状态 Agent」:LangChain 与 LangGraph 的定位

许多工程师第一次接触大模型应用时,手上往往只有一个最小可运行的脚本:把 API key 塞进环境变量,挑一家服务商,扔一段 prompt 进去,拿到字符串回答,完事。这种「调一次 API」的范式在 PoC(Proof of Concept,概念验证)阶段毫无问题——你只需要证明模型能听懂你的指令。但只要业务再往前推一步,问题就会成片出现:同一个会话里上一句的上下文去哪了?想让模型按 JSON 给你结构化字段,得自己写解析与兜底;想让它去查实时汇率或执行计算,得另起一套函数调用与异常处理;想加入「如果检索不到就走人工兜底」这样的分支,纯脚本就完全力不从心了。

LangChain 与 LangGraph 的分工

在这条从脚本走向工程的路上,LangChain 与 LangGraph 各司其职。先说 LangChain(官方文档参见 https://python.langchain.com),它解决的是「统一接入各家 LLM + 标准化输入输出」这一层。它的核心抽象是 ChatModel:无论是闭源推理服务还是开源权重模型,只要实现了统一接口,你都可以用 init_chat_model 这样的工厂方法按 provider 名称拉起一个实例,再在它之上叠加消息对象、结构化输出、工具绑定等能力。换句话说,LangChain 提供的是「积木」——模型、消息、工具、结构化输出,每一块都可以独立替换、独立测试。

再说 LangGraph(官方文档参见 https://langchain-ai.github.io/langgraph/),它解决的是「把多步骤、有状态、多分支的流程组织成图」。一旦你的应用超过单轮问答,就要面对三件事:状态如何在节点之间流动、条件分支如何表达、循环与回退如何实现。LangGraph 用 StateGraph 这种图结构把业务流描述成节点(Node)与边(Edge),配合 START、END 这类虚拟节点,以及 add_messages 这类状态归约(reducer),让多轮上下文、条件跳转、人工接管都可以被显式建模。如果说 LangChain 是积木,那么 LangGraph 就是把积木连成有状态工作流的骨架。

为什么 model.invoke 不够用

model.invoke("你好") 这一行代码,本质上是一次无状态的消息往返:你把 prompt 拼成字符串塞进去,模型吐出一段文本,你打印或保存,下一轮再问时它已经不记得刚才聊了什么。真实业务要的是完全不同的形状。

多节点协作要求把检索、总结、回复拆给不同模型或不同 prompt;条件分支要求模型说「我需要查一下天气」就走工具调用,否则直接给出答案;工具调用要求模型不再只是输出文本,而是要决定调用哪个函数、传什么参数;跨轮记忆则要求用户说「把它改成更口语化的版本」时,系统要能找到上一轮对应的输出。把这些需求一股脑塞进单次 invoke,要么靠字符串拼接做出难以维护的「巨函数」,要么被迫在脚本里维护一份外部状态机——而这恰恰是 LangGraph 想要替你承担的工作。

本文主线:层层递进拼出 Agent 工程蓝图

这套教程不会一上来就堆概念,而是沿着一条工程上由浅入深的主线推进:结构化输出 → 消息模型 → 工具调用 → LangGraph 图(State/Node/Edge)→ 条件边 → 工具代理 → 记忆。每一层都对应一个独立但可叠加的能力。

  1. 结构化输出用 Pydantic(文档 https://docs.pydantic.dev)的 BaseModelField 把模型回答约束成可校验的字段,并通过 with_structured_output 让模型直接吐出 JSON 对象。
  2. 消息模型用 SystemMessage、HumanMessage、AIMessage、ToolMessage 区分角色,把「上下文」从字符串升级为可枚举的列表。
  3. 工具调用@tool 装饰器把普通函数注册成模型可识别的工具,再通过 bind_tools 把工具清单交给模型。
  4. LangGraph 图用 StateGraph 定义节点之间的状态转移,用 TypedDict 描述状态结构,用 add_messages 让新消息追加而非覆盖历史。
  5. 条件边tools_condition 这样的预置函数判断「是否需要调用工具」,把控制权交给模型自身。
  6. 工具代理langgraph.prebuilt 里的 ToolNode 把工具调用封装成图中的一个节点。
  7. 记忆langgraph.checkpoint.memory 的 MemorySaver 配合 thread_id,让同一会话的多次图执行共享上下文。

这条主线的价值在于:每一层都不依赖下一层的全部细节,但每一层都会解决上一层暴露的问题,最终拼出一个可维护的 Agent 工程蓝图。

核心心智:积木与骨架

把上面这些抽象收回到一句话,核心心智就是:LangChain 提供积木,LangGraph 提供骨架

维度 LangChain(积木) LangGraph(骨架)
主要对象 ChatModel、Message、Tool、OutputParser StateGraph、Node、Edge、Checkpoint
解决的问题 模型异构、消息语义、输出解析 多步编排、状态管理、条件分支、记忆
替换粒度 单个模型、单个工具、单个解析器 整张子图、整条分支、整段记忆
测试方式 对每个组件做单元测试 对图做端到端回归

读者只要记住这张表的分工,后续无论学习哪个高级能力(ReAct、多 Agent、RAG、Human-in-the-Loop),都能快速判断它落在哪一侧。LangChain 负责「做什么、怎么做、输出什么」,LangGraph 负责「什么时候做、做完去哪、上下文是什么」。

[观察]在实践中,有几个细节特别值得新手注意。Pydantic 的 Field(description=...) 并非仅仅写给人看的注释——它会被 with_structured_output 一起序列化进 JSON Schema 并送给模型,直接决定模型能否正确填充字段;add_messages reducer 的语义是「追加而非覆盖」,所以图状态里 messages 字段天然保留历史,但如果自己定义了一个非列表字段,就要小心覆盖式赋值;MemorySaver 把每一轮图的执行快照保存到内存,只要 thread_id 一致,后续 graph.invoke(state, config={"configurable": {"thread_id": "..."}}) 就会自动把历史状态装回图的初始 state,不同 thread_id 之间则完全隔离,这正是 LangGraph 实现「跨轮记忆」的关键开关。

面向读者的定位

这套实践的目标读者非常具体:已经会调 LLM API,但还不清楚如何从脚本式调用升级到工程化、可复用、可观测的 Agent 系统。换言之,你已经知道 API key 怎么拿、prompt 怎么写,也知道 JSON 怎么解析,只是面对「五六个模型串起来、还要带记忆和分支」这种需求时,不知道从何处下手。教程不假设你读过 LangChain 的源码,但会假设你熟悉 Python 的装饰器、类型注解、虚拟环境,以及 uv(参见 https://docs.astral.sh/uv/)与 python-dotenv(参见 https://github.com/theskumar/python-dotenv)这类基础工具链。

[数据]为了把抽象的分层落到具体数字上,这里给出一个贯穿全文的例子:假设我们要做一个货币转换器,模型输入「100 USD」之后,我们先用 @tool 装饰器定义一个把美元增值 8% 的函数,再定义一个按汇率 95 把任意币种换成 INR(印度卢比)的函数。模型先决定调第一个工具,把 100 变成 108;再决定调第二个工具,在 95 汇率下得到 10260 INR。这个小小例子同时用上了结构化输出(汇率与金额字段)、消息模型(工具消息回填)、工具调用(两次顺序调用)、LangGraph 图(检索→工具→回复)、条件边(模型自主决定何时调用)、工具代理(ToolNode 接管执行)与记忆(thread_id 下用户可追问「再算一次 JPY」)——七个能力一次跑通,这也是为什么本文要把它们逐层拆开讲。

工程视角下的可观测性

把脚本升级为图的另一个隐性收益,是可观测性。当一切都塞在一个 Python 函数里时,任何一次失败你只能看到一行 traceback;而把流程画成图之后,LangGraph 允许你在每个节点前后挂回调,把每一步的输入、输出、耗时、token 用量(usage_metadata)落盘到日志或可视化平台。LangChain 这边的 Callback 体系,LangGraph 那边的 LangSmith Studio trace,都是从这一步开始才「真正可用」的。

langchain-langgraph-mental-model

读者在读完这一节后,不需要立刻接入 LangSmith,但应该在心里埋下一根弦:有图,就有 trace;有 trace,才有调优。一旦你习惯用图来描述 Agent,那些原本靠 print 调试的痛点会一次性消失,留下的只有「哪个节点的耗时突然飙升」「哪一次工具调用返回了空」这种可以量化的工程问题。

小结

从「调一次 API」到「可编排的有状态 Agent」,跨过去的关键不是某一个神奇的提示词技巧,而是一整套工程抽象。LangChain 把各家模型、消息、工具、结构化输出这四类积木做到了可替换、可组合;LangGraph 用图的方式让多步、有状态、多分支的流程获得了与代码同等量级的表达力与可观测性。掌握这两层心智模型,后面无论是写 ReAct、写多 Agent 协作,还是接 RAG、写 Human-in-the-Loop,都只是在这两块地基上继续垒房间而已。

环境基座:用 uv 搭一个可复现的 Python Agent 项目

为什么是 uv 而不是 pip/conda

当你准备做一个 Agent 项目,脚下的第一块砖其实是「环境」,而不是「模型」。这一步看似和智能体本身无关,但它直接决定了后面每一步是顺水推舟还是无尽返工。在 Python 工程化的工具链里,我们其实有三条主流路径:pip + venvconda、以及近几年崛起的 uv。这三者的能力表面相似,内核却很不同。

pip 是 Python 自带的包管理器,它的依赖解析算法在历史上多次被吐槽「太慢、依赖冲突难解决」;conda 强在科学计算栈与跨平台,但它把 Python 解释器也当作「包」来管,会引入一个独立的解释器来源,与系统 Python 形成两条平行宇宙;uv 是 Astral 公司用 Rust 实现的下一代工具,既保留了 pip 那套「贴近 PyPI 与 pyproject.toml」的语义,又把速度做到了数量级跃迁。

[对比] 用一组动作可以描述三者的差异:用 uv init 取代 mkdir + touch pyproject.toml,用 uv add 取代 pip install + 手动维护 requirements.txt,用 uv.lock 取代 pip freeze --local 的脆弱快照,最后用 uv sync 一键回到任意时刻的依赖状态。官方文档(https://docs.astral.sh/uv/)对每个子命令都有详细说明,这是入门时最值得收藏的页面之一。

五分钟把项目骨架立起来

下面是一段最小可复现的 bootstrap 流程,在干净的系统上跑完就能得到一个可以立刻写代码的 Agent 雏形目录:

uv init my-agent
cd my-agent
uv venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
uv add -r requirements.txt
uv run python -c "import langchain, langgraph; print('ok')"

第一行 uv init my-agent 会自动生成 pyproject.toml.gitignore、一个最小入口脚本和一份 README 雏形;第二行进入目录;第三行 uv venv 显式建立 .venv,它与系统 Python 隔离,后续所有依赖都装进这个虚拟环境;第四行 uv add -r requirements.txt 是这套实践的关键一步,它会读取已有的依赖清单,逐个解析并写入 pyproject.toml[project].dependencies 数组,同时生成一份 uv.lock 文件——这份锁文件是「可复现」二字的核心载体。

[观察] uv.lock 是一份跨平台的精确锁文件,里面不仅记录每个包的具体版本,还会记录该包的源代码哈希、所选 Python 解释器版本以及构建时启用的可选依赖。任何一个协作者在另一台机器上执行 uv sync,都会拿到完全一致的环境;即使六个月后再回来,只要锁文件还在,环境就在。

uv-project-bootstrap

把 Python 版本钉死在 3.11.9 上

Agent 项目对解释器版本相当敏感,因为 LangChain / LangGraph 生态在快速演进,部分依赖在新解释器上会缺失预编译 wheel(也就是 PyPI 上常见的 .whl 二进制分发包,装了它可以跳过本地编译),或者触发 C 扩展的构建失败。这套实践选择把 Python 锁在 3.11.9 这个具体补丁号,理由可以从下表的三个维度来看:

维度 锁 3.11.x(如 3.11.9) 用 3.12.x 用 3.13.x
主流库预编译 wheel 覆盖率 最高 部分库尚未跟进
LangChain 生态兼容性 经验上最稳 偶发小坑 风险最高
部署平台默认镜像 通常预装 3.11 视平台而定 较少预装

[数据] 把版本精确锁定到 3.11.9 这种补丁号,意味着任何协作者执行 uv sync 后拿到的解释器版本与 CI、部署平台完全一致,从而规避「我这能跑、你那不能跑」的隐性事故。

这个数字会被两个地方记住:一是项目根目录下的 .python-version 文件,里面就是一行 3.11.9;二是 pyproject.tomlrequires-python = ">=3.11,<3.12" 这一行约束。两者互为冗余,只要其中一个还在,uv 就会自动下载并切换到正确的解释器。

.env 与密钥管理:绝不让 key 出现在代码里

Groq 等推理服务厂商提供的 API key 是一类典型的「绝不能进版本控制」的字符串。一旦它被 commit 到 Git 仓库,即使后来用 git rm 删除,密钥也会留在历史记录里,等于公开广播。下面是这套实践采纳的标准范式:

# .env(放在项目根目录,且加入 .gitignore)
GROQ_API_KEY=gsk_xxx...
# app.py
from dotenv import load_dotenv
import os

load_dotenv()                       # 从 .env 读取
api_key = os.getenv("GROQ_API_KEY") # 通过环境变量访问
assert api_key, "请在 .env 中配置 GROQ_API_KEY"

第一段是 .env 文件的内容示例,注意它必须被加入 .gitignore,仓库里只能保留 .env.example 这种「模板」;第二段代码用 python-dotenvload_dotenv(项目地址 https://github.com/theskumar/python-dotenv)把磁盘上的密钥注入到进程环境变量,后续通过 os.getenv 读取。

[观察] load_dotenv() 默认会在当前工作目录向上逐层查找 .env,因此放在仓库根目录是最稳妥的位置;若你的代码放在 src/ 子目录里,只要 .env 仍在仓库根,也能被自动找到。这种「环境变量 + .env」的范式让密钥与代码彻底解耦:本地开发读 .env,CI/CD 走平台自带的密钥管理(如 GitHub Secrets、render 的环境变量面板),生产部署交给云厂商的 KMS,三处互不污染,也不会让任何一位开发者的个人 key 流向公共网络。

Jupyter 内核:新手最常踩的隐形坑

很多 Agent 教程的代码是用 Jupyter notebook 演示的,这一步切换往往会把新人绊倒。Jupyter 实际上由两个独立组件构成:内核(真正执行 Python 代码的进程)与前端(notebook 文件所在的浏览器界面)。当你 jupyter notebook 启动时,它会注册一个指向「启动时所在 Python 环境」的内核。

踩坑的典型场景是这样的:你在终端里 source .venv/bin/activate 进入了虚拟环境,顺手 pip install langchain langgraph(注意,这一步本身就不推荐,因为我们前面用的是 uv add);然后你 jupyter notebook,新建一个 cell 写 from langchain_core.messages import HumanMessage,结果报 ModuleNotFoundError。你回去检查 pip list,包明明装了,一脸懵。

真相是:你启动 notebook 时,Jupyter 用的可能是系统 Python 而不是 .venv,于是 import 在一个全新的、没有任何第三方库的环境里执行。要修复,需要在 .venv 里显式安装并注册内核:

uv add ipykernel
uv run ipython kernel install --user --name=my-agent

执行完后,重启 notebook,在右上角内核选择里挑名为 my-agent 的那个——它指向 .venv/bin/python。从此 cell 里跑的代码、装的包、读的环境变量,全都来自同一个虚拟环境,不再有「内核错配」这层隐形隔阂,也不会再产生一堆看似无关的 ImportError

可复现环境是 Agent 项目的地基

把这一节放在最前面,而不是最后,是因为后续所有的 LangChain / LangGraph 代码,都建立在一个隐含假设之上:读者的运行环境与作者一致。一旦这个假设崩塌,你会看到 pydantic v1 与 v2 的语法冲突、langchain_core 的导入路径变迁、TypedDict 的 Annotated 写法差异——这些报错与你的业务逻辑毫无关系,但它们会让一个本该 5 分钟跑通的 demo 拖成 5 小时的调试。

工程经验告诉我们:依赖漂移是「我这能跑」类事故的最大单一来源。一个团队里如果有人用 3.12,有人用 3.11,有人 pip install,有人 poetry install,等到合并代码时几乎必然踩坑;而统一用 uv + pyproject.toml + uv.lock + .python-version 这套组合拳,把「环境」这件最容易失控的事变成了版本控制里的一行代码,正是 Agent 项目能持续迭代、从小脚本走向生产系统的隐形基础设施。把地基打牢,后面的 LangGraph 图、ToolNodeMemorySaver 与结构化输出才有机会稳定地堆叠起来。

接入模型:init_chat_model 与 provider:model 的统一寻址

init_chat_model 的设计动机

在 LangChain 的早期版本里,接入不同供应商要分别引入 ChatOpenAI、ChatAnthropic、ChatGroq 这些具体类,然后各自构造一次 Client。这种写法的问题不在于「能跑」,而在于「换供应商就得改业务代码」。比如把演示用的 Groq 切到生产用的 Anthropic,所有 import 路径、构造参数、消息字段都得改一遍,改动面太大。

init_chat_model 这个统一入口的设计意图,正是把「换供应商」这件事降级为字符串替换。它的入参最常见的形式是 provider:model_name,比如 groq:qwen-3-family 这种写法:前面是供应商代号,后面是该供应商托管的具体模型。LangChain 内部用冒号解析,然后按 provider 路由到对应的工厂函数完成实例化。LangChain 官方文档 https://python.langchain.com 把这套机制描述为「统一的聊天模型初始化入口」,核心承诺就是字符串变了,业务层不动。

[观察] 这套写法在多供应商 A/B 测试时尤其省事 —— 一份业务代码,改一个字符串就能切换后端,日志、调用栈、token 计费口径都能在同一个调用入口对齐。

provider:model 寻址的工程语义

provider:model_name 看起来只是一个字符串约定,但它实际上承担了三层工程含义。

供应商域:左侧的 provider 决定了 SDK 后端、鉴权方式、计费通道。groq 走 Groq SDK,openai 走 OpenAI 兼容协议,anthropic 走 Anthropic SDK。

模型域:右侧的 model_name 决定了上下文窗口、工具调用能力、价格档位。同一家供应商的不同模型,差异往往比不同供应商的同名模型更大。

版本快照:某些 provider 允许在 model_name 后追加版本日期或快照标签,实现模型的精确锁定,避免供应商后台静默升级带来的行为漂移。

示例实现通常这样写:

from langchain.chat_models import init_chat_model
import os

model = init_chat_model(
    "groq:qwen-3-family",
    api_key=os.environ["GROQ_API_KEY"],
    temperature=0.2,
)

temperature、是否开启 streaming、是否走 JSON mode,都通过 init_chat_model 的关键字参数透传,而不是让业务代码直接接触 provider SDK 的私有字段。

Groq 作为推理供应商的定位

Groq 这家供应商的核心卖点是低延迟推理。它在 LPU(Language Processing Unit)专用硬件上跑开源聊天模型,首 token 延迟通常在百毫秒级别,适合需要快速迭代的教学、原型,以及对话节奏敏感的演示场景。Groq 官方站点 https://groq.com 把这种能力定位为「工业级推理速度」,对 Agent 教学的好处是每次调用都能即时看到结果,不必等几十秒。

在选型时,模型本身的特性比供应商更值得关注。三个维度通常需要一起权衡。

维度 含义 教学期的关注点
上下文窗口 单次调用能塞进多少 token Agent 记忆 + 工具历史会不会撑爆
工具调用能力 是否原生支持 tool use / function calling 决定能不能稳定接到 LangGraph 的 ToolNode
成本 每千 token 的输入 / 输出单价 教学期反复跑 demo,价格低才不会心疼

[数据] 这套教程在原型期固定用 Groq 上的开源聊天模型跑通链路,因为它在「上下文窗口 + 工具调用 + 单价」三项上对教学最友好。而 Agent 一次完整推理往往涉及 5~10 轮 LLM 调用,如果每轮都用顶级模型,单次任务成本可能是单轮简单调用的 5~10 倍,这就是为什么分层替换是 Agent 工程的必修课。

统一抽象层的可移植性承诺

LangChain 最核心的可移植性承诺其实只有一句话:同一份 messages 列表、同一段 model.invoke(...) 代码,在不同 provider 之间的行为是一致的

messages 是一个 BaseMessage 的列表,常见成员是 SystemMessageHumanMessageAIMessageToolMessage。这套类型是 LangChain 自己定义的,但所有 provider 的适配器都承诺把它翻译成对应后端的原生格式。比如 Groq 期望的是 OpenAI 兼容的 role/content 结构,Anthropic 期望的是另一种字段排布,这些差异 LangChain 在适配层吸收掉了,业务层只看到 messages

invoke 是同步调用入口,返回一个 AIMessage 对象,业务层从这个对象里取 .content.tool_callsusage_metadata。哪怕底层从 Groq 切到其他供应商,这段取值的代码也不需要变。

[观察] 这种抽象是「显式优于隐式」的 —— LangChain 没有把 provider 的差异完全藏起来,工厂函数仍然按 provider:model 字符串显式路由。任何工程师都能 grep 到当前业务到底跑在哪个后端,这种「可审计」的特性在生产环境尤其重要。

密钥与环境变量注入

把 API key 写死在代码里是 Agent 项目的常见反模式。LangChain 推荐的做法是用 python-dotenv 把 .env 文件里的密钥加载到 os.environ,然后在 init_chat_model 里通过 api_key=os.environ["GROQ_API_KEY"] 显式读取。python-dotenv 项目页 https://github.com/theskumar/python-dotenv 把这种模式定义为「把配置和代码解耦」的标准做法。

这种注入方式有两个明显好处。第一,不泄漏 provider SDK 细节:业务层永远不直接 import Groq SDK,密钥怎么校验、token 怎么刷新、网络怎么重试,完全由 LangChain 适配器负责。第二,避免密钥进入版本控制:.env 通常加入 .gitignore,即便误提交也不应包含真实密钥。

工程上更进一步的做法是用 pydantic-settings 这类配置管理库,把环境变量映射到强类型的 Settings 对象,然后注入到 init_chat_model,但教学期 python-dotenv 已经够用。

选型建议:原型期廉价快速,生产期分层替换

智能体项目的模型选型有一条朴素的工程规律。原型期选一个便宜、快速、工具调用稳定的模型,把 Agent 的链路 —— 记忆、工具、结构化输出、多轮对话 —— 全部跑通。此时的关注点是「流程能不能闭环」,而不是「答案够不够聪明」。验证期对每一个子任务(规划、总结、工具选择、结构化抽取)分别评估,记录 token 消耗和成功率。生产期按任务难度分层替换:简单路由用便宜模型,复杂推理用更强的模型,关键决策甚至引入人工复核或自一致性投票。

这套分层思路在 Agent 项目里特别重要,因为 Agent 一次调用往往要经过多轮 LLM 推理,如果每一轮都用顶级模型,成本会显著上升;而把所有任务都降级到便宜模型,又会牺牲关键决策的可靠性。生产期的「分层」既可以是路由式的(按任务分派),也可以是瀑布式的(简单模型先筛,复杂模型再精修),关键是让每一分钱都花在最有价值的那一环。

踩坑清单

  • provider 字符串拼写错误:LangChain 不会在 import 阶段报错,只在第一次 invoke 时才抛异常。建议在初始化后立刻跑一次冒烟调用,把异常提前到开发期。
  • temperature 默认值差异:不同 provider 的 temperature=0 不一定意味着完全确定性,某些开源模型即使在 0 温度下仍有小幅随机,结构化输出场景尤其要警惕。
  • 环境变量名不一致:Groq 用 GROQ_API_KEY,OpenAI 用 OPENAI_API_KEY,Anthropic 用 ANTHROPIC_API_KEY,切换供应商时记得同步检查 .env,不要想当然复制。
  • 模型名漂移:开源模型在 provider 上的代号可能随版本更新而变化,锁定业务模型时建议显式写完整字符串,并在 CI 里加一次冒烟调用,防止静默升级。
  • streaming 与 usage_metadata 冲突:部分 provider 在流式模式下不返回 usage_metadata,如果业务层依赖 token 计数做预算控制,要单独处理。

init-chat-model-provider

把这套寻址机制吃透,后续无论是切换供应商还是做多模型路由,都不会回到「改一行 import、改一片调用代码」的混乱状态。统一入口换来的不只是一个函数,而是一种可维护的工程结构 —— 业务层只关心 messages 和 invoke,后端差异被显式、可审计地藏在工厂函数背后。

结构化输出(一):用 Pydantic 把自由文本变成可解析对象

## 结构化输出(一):用 Pydantic 把自由文本变成可解析对象

LLM 默认输出是一段自由文本,看起来像 JSON,但空格、逗号、引号稍微偏一点,下游程序的正则就要重写。把"模型填空"和"程序解析"分离开,正是结构化输出要解决的核心问题。具体来说,就是给 LLM 一份 schema(字段清单与类型约束),让它按 schema 填空,运行时再由校验层兜底,这样下游就能直接拿到一个可序列化的 Python 对象,而不是一段需要再处理的字符串。

### Pydantic 的角色:既是契约,又是校验器

Pydantic 在 Python 生态里几乎是数据校验的事实标准,LangChain 也直接把它作为结构化输出的首选 schema 语言。最常见的写法是继承 `BaseModel`,用 `Field` 声明每个字段的类型与描述。看下面这段示例:

```python
from pydantic import BaseModel, Field

class MovieReview(BaseModel):
    title: str = Field(description="电影名称")
    rating: float = Field(description="0 到 10 分的评分")
    summary: str = Field(description="一句话总结影评核心观点")

写完模型类,再交给 LangChain 的 ChatModel.with_structured_output(Model),就能把 schema 注入到模型的 system 指令与输出约束里,直接拿到一个 Pydantic 实例:

from langchain.chat_models import init_chat_model

model = init_chat_model("groq:openai/gpt-oss-120b")
structured = model.with_structured_output(MovieReview)
result = structured.invoke("把这段影评整理一下:肖申克的救赎值得 9.5 分,主题是希望与自由")
print(result.title, result.rating, result.summary)

with_structured_output 在底层会调用供应商原生支持的"结构化输出"能力(比如 JSON Schema 约束、tool calls、grammar constrained decoding 等),把 Pydantic 的字段元数据翻译成模型能理解的格式,然后用一次 invoke 拿到已经通过校验的对象。LangChain 文档(https://python.langchain.com)对这部分机制有专门章节,这里不展开每个供应商的实现差异。

Field 的 description 不是注释,而是字段级 Prompt

Field 里的 description 字面看像注释,实际上会随 schema 一起作为提示传给 LLM,直接影响模型如何定位与填充该字段的值。

[观察] 在这套教程的多组示例里,只要把 Field(description="...") 改写得更具体,例如从"日期"改为"ISO8601 格式的发布日期,如 2024-03-15",字段填充的准确率就会明显上升,模型对"是星期几"还是"是哪一天"的混淆也会减少。换句话说,description 在结构化输出里承担了"字段级 prompt"的角色,是 schema 契约的一部分,不是装饰性字符串。

Pydantic 官方文档(https://docs.pydantic.dev)把 Field 定义为"用于在模型字段上提供额外信息",这些信息既包括验证用的 gelepattern 这类约束,也包括上面这种会被传给模型的元数据。LangChain 在拼装 system 消息时,会读取这些 metadata 并写入 schema,所以字段命名尽量用英文 snake_case,描述用自然语言短句,二者结合能让模型理解最稳。

类型即契约:声明 → 提示 → 校验三层机制

把字段声明为 strintfloatboollist[str],等于和模型签订了一份类型契约。LLM 会尽量返回匹配类型的值,运行时由 Pydantic 做最终校验,校验失败会抛出 ValidationError

[数据] 在该教程展示的货币转换示例里,声明 amount_in_usd: float 之后,模型即便面对"增加 8%"这样的修饰,也能正确返回数值 108 而不是字符串"108";声明 exchange_rate: float = 95 之后,模型不再返回诸如"约 95"这种带文字的近似值。如果进一步用 Field(ge=0) 限定非负,运行时还能挡住负数返回。这种"声明类型 → 提示模型 → 运行时校验"的闭环,是结构化输出和传统正则抽取最大的区别。

下表对比了三种常见方案的工程取舍:

方案 校验强度 运行时开销 模型可控性 适用场景
正则表达式 弱(只能校验格式) 极低 简单固定格式
JSON Schema + 手动解析 自定义后处理逻辑
Pydantic + with_structured_output 强(类型 + 约束) 实体抽取、表单入库

这张表说明,选哪种方案取决于对"校验强度"和"模型可控性"的要求。当目标是"下游程序能直接拿到可用对象"时,Pydantic 是性价比最高的选项;当输出极简且固定,正则反而更快。

适用场景与工程取舍

结构化输出在工程上有几个高频落地点。第一,实体抽取:从新闻、合同、聊天记录里抽出人物、机构、金额、时间,生成可直接入库的记录。第二,表单填充:把用户一段口语化描述自动转成结构化字段,减少人工录入,常见于客服工单、简历解析、订单补全。第三,入库前的 ETL 转换:把非结构化文本先过一道 LLM,再把校验通过的对象批量写进关系型数据库或向量库。这三个场景的共同点是"输入是文本,输出是记录",而 Pydantic 正好把"记录"这层契约固定下来。

pydantic-structured-output

实战踩坑清单

工程上有几个常见坑值得提前标注。第一,不要在 BaseModel 里混用太复杂的嵌套结构作为生产 schema,字段越多、嵌套越深,模型填充的准确率会下降;建议先用扁平结构跑通,再视情况加嵌套,必要时拆成多次结构化调用。第二,"Optional 字段"要明确处理 None,模型经常默认填上"未知"或"暂无"而不是留空,这种语义差异会污染下游统计,生产环境最好在 Pydantic 层把空值归一为 None。第三,Pydantic 的 Config 里如果开启 extra='forbid',模型多返回一个字段就会抛 ValidationError,这在不同供应商结构化输出能力差异较大时容易踩到。第四,别忘记 Pydantic v1 与 v2 的 API 不兼容,旧代码里的 validatorroot_validator 在 v2 已经替换为 field_validatormodel_validator,迁移时要把 import、装饰器签名、@classmethod 装饰顺序一起改,这一点在升级 LangChain 时尤其常见。第五,模型代号里若包含 openai/anthropic/ 这类供应商前缀,记得在调用 with_structured_output 之前确认该供应商已支持结构化输出能力,否则会回退到 JSON mode,准确率下降但不会报错,排查时容易迷惑。

小结

把"自由文本 → 可解析对象"这条链路打通之后,接下来的工具调用与 Agent 状态图就有了干净的输入边界。下游不再需要为每个字段写正则或 try/except,所有约束都集中在 Pydantic 的 schema 里,改一处就能影响整个调用链。这是把 LLM 从演示带到生产的关键一步,也让后续的 LangGraph 节点之间可以传递强类型对象而非裸 dict,减少跨节点的数据漂移。

## 结构化输出(二):嵌套模型与 include_raw 的取舍

### 嵌套 schema:把层级化数据变成一次调用

上一节把 Pydantic 当作契约和校验器,已经能让 LLM 按字段填空并输出一个 Python 对象。但真实业务里,数据很少是平铺的——订单有"明细",明细有"商品与会员";诊断报告有"主诉、检查、处置",每段又包含若干子项。如果只用一个扁平的 BaseModel,字段数量一上去,LLM 容易把同名字段填串行,把"商品的金额"填到了"会员的金额"位置。即便写了正则做后置兜底,这种字段错位也很难稳定捕获,因为错误发生在模型侧而非文本侧。

嵌套结构(nested schema)就是为了解决这种层级表达而生的。Pydantic 允许一个字段的类型本身就是另一个 `BaseModel`,或者一个装着 `BaseModel` 的列表(例如 `list[Detail]`),甚至更深的"字典里套模型"(例如 `dict[str, SubModel]`)。LangChain 的 `with_structured_output` 在内部会把整棵类型树递归序列化为一份 JSON Schema 描述,再随 chat 调用一次性下发给模型。模型看到的不是一维表,而是一棵带 `$ref` 的嵌套对象图。在 LangChain 文档关于 with_structured_output 的章节里,可以查到它对 Pydantic、TypedDict、JSON Schema 三种 schema 来源的等价支持;字段类型、字段顺序、是否必填、enum 取值范围都会被原样保留(参考 https://python.langchain.com 与 https://docs.pydantic.dev)。

[观察]嵌套模型在序列化时,字段的 description 会递归地随 JSON Schema 一起下发给模型。也就是说,`Detail.total_net` 的 description 不会因为它被嵌在列表里就被截断,LLM 在填这一格时仍能看到原始的语义说明,这对降低字段错填率非常关键——而很多工程师误以为 description 只在 Pydantic 校验时报错时使用,实际它同时承担了"给模型看的 prompt"角色。

### 一个能跑的层级示例

下面这段代码演示一个常见形态:外层是 App,内层是 Detail,Detail 又带一个 Member 子模型,完整地表达"一笔订单包含若干明细,每条明细关联一个会员"。

```python
from pydantic import BaseModel, Field
from typing import List, Optional
from langchain_core.messages import HumanMessage, SystemMessage
from langchain.chat_models import init_chat_model

class Member(BaseModel):
    name: str = Field(description="会员姓名")
    level: str = Field(description="会员等级,例如 gold/silver/bronze")

class Detail(BaseModel):
    sku: str = Field(description="商品 SKU 编码")
    total_net: float = Field(description="该明细的净销售额,单位为元")
    member: Optional[Member] = Field(description="购买该明细的会员,匿名购买时为 null")

class App(BaseModel):
    order_id: str = Field(description="订单唯一标识")
    details: List[Detail] = Field(description="该订单的明细条目列表")

# 假设 chat_model 已通过 init_chat_model 完成初始化,使用 Groq 上的开源聊天模型
chat_model = init_chat_model(model_provider="groq")
structured = chat_model.with_structured_output(App)

result = structured.invoke([
    SystemMessage(content="你是一个订单抽取助手,严格按 schema 输出,不要编造未给出的字段"),
    HumanMessage(content="订单 A001 包含两条明细:SKU-001 净额 120 元,会员张三为金卡;SKU-002 净额 80 元,会员李四为银卡。")
])

print(result.order_id)                  # A001
print(result.details[0].member.name)    # 张三
print(result.details[1].member.level)   # silver
print(type(result))                      # <class '__main__.App'>

[数据]这份 schema 在示例中让模型一次调用就给出了 1 个订单 + 2 条 Detail + 2 个 Member 共 7 个字段的有序结构,平均 token 消耗随明细数量线性增长,而非随字段组合指数级增长——这是把"层级化"留给 schema、把"组合爆炸"留给后端的直接收益。同一份输入如果走自由文本 + 后置正则,通常要写 5~8 条规则且要随模型版本维护;而结构化方案只在 schema 描述变更时才需要回头改 Pydantic 类,边际维护成本低一个数量级。

include_raw=True:把原始 AI 消息一起带回来

默认情况下,with_structured_output(...) 只返回解析后的 Pydantic 对象,这是最干净的契约,下游代码不需要关心 chat 模型的返回结构。但工程上经常遇到三类问题:模型到底生成了什么字符串?有没有触发 parse 重试?这次调用花了多少 token?有没有命中 stop reason?这些信息只有原始 AI 消息(在 LangChain 里通常是 AIMessage 或内部的 ChatGeneration)才带。

把参数从 with_structured_output(App) 改成 with_structured_output(App, include_raw=True),返回值就不再是 App,而是一个 dict,常见形态是 {"raw": BaseMessage, "parsed": App | None, "parsing_error": Optional[BaseException]}

structured = chat_model.with_structured_output(App, include_raw=True)
out = structured.invoke(messages)

raw = out["raw"]              # AIMessage 实例
parsed = out["parsed"]        # App 实例,或 None
err = out["parsing_error"]    # 若解析失败,这里是原始异常;否则为 None

print(raw.usage_metadata)
# {'input_tokens': 312, 'output_tokens': 88, 'total_tokens': 400}
print(raw.response_metadata.get("model_name"))

[观察]usage_metadata 字段在大多数 chat 模型(包括 Groq 上的快速推理模型)上会自动填充;一旦开了 include_raw,它就和 parsed 同时可读,做成本核算、限流和提示词 A/B 都顺理成章。如果走默认设置,则需要再额外调用一次模型或在回调里捞 metadata——前者浪费 token,后者要等回调链路打通。LangChain 文档对 include_raw 的语义说明可以在 https://python.langchain.com 的 Structured outputs 章节查到,该选项在 LangGraph 节点函数里同样适用,因为 LangGraph 节点只是把 LangChain 的 runnable 当 callable 调度(参考 https://langchain-ai.github.io/langgraph/)。

取舍矩阵:什么时候开,什么时候关

场景 推荐设置 理由
生产路径直接交给业务逻辑 默认(不带 include_raw) 返回值稳定,下游只依赖 Pydantic 对象,不会被原始消息结构变化影响
调试提示词或评估模型行为 include_raw=True 需要看 raw.content、raw.response_metadata,以及 parsing_error 触发条件
做 token 计费、限流、监控 include_raw=True 直接读 usage_metadata,不必再额外调一次模型拿 metadata
写入流式输出、流式回调 默认 流式场景里 raw 对象的生命周期更复杂,业务代码以 parsed 为准更稳
多 schema 并行尝试 include_raw=True 同时拿到 N 个 parsed 与 N 个 raw,便于横向对比 stop reason 与生成多样性

[数据]如果按调用次数计成本,默认方案每多一个字段平均只多几十到一百 token(取决于 description 长度);而 include_raw=True 在结果端额外返回一份 AIMessage 文本,会显著放大日志存储压力——一份原始消息往往比 parsed 对象大 3~10 倍。生产监控建议只把 raw 落盘到抽样 5%~10% 的调用,而不是全量,否则日志后端会先于 LLM 成本成为瓶颈。

嵌套越深,description 越要写清楚

嵌套模型的 schema 看起来"字段越多越强",但实际工程经验恰好相反:层级越深,越要克制,并把每一层的 description 写得像 README,而不是把它当作注释。否则模型在"补全"某一层时只能猜上下文,猜错率会沿层级累乘——顶层错一个字段,下游每条明细都会传染。

几个具体经验:

  • 顶层用一句话说清这个对象是什么(例如"一笔订单的完整快照");每条明细用一个动词短语说清它的语义边界(例如"该订单下的一条购买明细")。
  • 字段 description 不要照抄字段名(name: str = Field(description="姓名") 等于没写),要说明取值范围、单位、枚举、是否可空;对于货币类字段,固定单位比让模型猜更省 token。
  • 当某一层字段有 Optional[T] 时,在 description 里显式写"未提供时返回 null",而不是任由模型把缺失值填成 {}""——后者在 Pydantic 校验时会触发 type error,业务看到的是解析失败,不是干净的 None。
  • 若嵌套超过 3 层,优先考虑拆成多个 with_structured_output 调用,而不是把整棵树塞进一次调用;层级过深的 schema 在 JSON Schema 序列化阶段就开始体积爆炸。
  • 列表元素数量歧义时,可以在 description 里写明"列表长度由输入决定,示例输入给了 2 条,模型不要自动扩展"。

[观察]Pydantic 的 Field(description=...) 不是装饰,它是 schema 序列化时被 LangChain 一并送进 system prompt 的语义提示。这一点在 Pydantic 官方文档 https://docs.pydantic.dev 的 Field 章节有明确说明——description 既驱动校验错误信息,也驱动模型填空;此外 LangGraph 的官方文档 https://langchain-ai.github.io/langgraph/ 在节点函数的章节也提示,当节点以 Pydantic 模型作为返回值类型时,框架会沿用同样的 schema 序列化路径,因此 description 的写法对 LangGraph 节点同样适用。

踩坑清单与排错顺序

把这节最容易踩的坑整理成一份清单,方便排错时按顺序排查:

  1. parsed 是 None,但 raw 看着是 JSON:八成是某个字段类型不一致(例如模型给了字符串 "120",schema 要的是 float)。先看 parsing_error 的类型,再回到 schema 修字段类型或加 description 提示模型只输出数字。
  2. 嵌套列表有时填 1 个,有时填 3 个:LLM 把"示例"和"真实输入"混了。在 system message 里写明"列表长度由输入决定,不要编造条目",并把 description 改成"按用户实际给出的明细数量填充"。
  3. 用了 TypedDict 当 schema,但嵌套层级里出现 None:TypedDict 不会自动把字段标 Optional,需要在 union 里显式声明 NotRequired,或者直接给字段加 total=False(参考 https://docs.python.org/3/library/typing.html)。
  4. include_raw 拿到 usage_metadata 为 None:某些 provider 在流式或工具调用场景下不会回填 usage_metadata,需要在监控侧做缺值兜底,不要假设它永远存在。
  5. deeply nested 时 token 暴涨:层级深 + 列表长,token 成本非线性;考虑在结构化后再用 Python 做二次切片,而不是一次性让模型全吐。例如先抽取"明细清单",再对每条明细单独做一次结构化补全。
  6. description 写成 markdown 链接或代码块:模型可能照搬回字段值,污染结果;description 用纯文本一句话即可,避免触发模型的"复制粘贴"行为。
  7. 同一份 schema 在不同 provider 上表现不一致:某些 provider 对 list[X] 的 JSON Schema 渲染是 items: X,某些会加上 minItems/maxItems;遇到 schema 兼容问题,优先回退到默认 include_raw=False 走人工排查。

nested-model-include-raw

收尾

嵌套模型让我们用一次 LLM 调用拿到一棵层级树,include_raw 则把"模型到底说了什么、用了多少 token"也一并回传。前者服务于结构,后者服务于可观测性。生产路径上保持默认最干净,调试与计费时再开 include_raw;而无论怎么选,把每层字段的 description 写扎实,是嵌套 schema 能否真正省下后端正则成本的关键。下一步进入多 Agent 协作时,这种"层级树 + 可观测元数据"的组合还会反复出现,值得把它当成本节的主线记住。

结构化输出(三):TypedDict 与 Annotated 的轻量方案

TypedDict:不写一行校验也能结构化

在前面 Pydantic 的章节里,我们体会到 BaseModel 加上 Field(description=...) 既能引导 LLM 按字段填空,也能在校验失败时立刻抛错。但每一次 model.model_validate()、每一次 model_dump_json(),Pydantic 都在背后默默构造 dataclass 风格的对象、跑类型强制、做 JSON Schema 推导。这套机制在外部数据边界——用户表单、API 网关、模型原始输出——是不可替代的,但在内部模块之间传递「已经在前一步校验过的字典」时,就显得过于厚重。我们需要的是「形状契约」,而不是「运行时看门人」。

[观察] 官方 Python typing 文档明确把 TypedDict 描述为「为字典的键与值提供类型注解的特殊形式」,它只在静态类型检查器(mypy、pyright)与 IDE 里生效,运行时不创建新对象、不复制数据,几乎是零开销。参考 https://docs.python.org/3/library/typing.html 可以看到,它属于 typing 模块自 PEP 589 落地以来的稳定接口,无需任何额外依赖即可直接 from typing import TypedDict

Annotated:把 description 装进类型里

Pydantic 之所以能让 LLM 知道「title 字段填的是电影名」,靠的是 Field 里的 description。而 TypedDict 没有 Field 这种显式容器,如何给字段附加语义?答案是 typing.Annotated。它的语法是 Annotated[T, metadata1, metadata2, ...],第一个参数是真正的类型,后续位置参数是任意「元数据」。这些元数据在运行时可通过 typing.get_type_hints 配合 __metadata__ 取出,但更关键的是:LangChain 的 with_structured_output 在解析 schema 时,会把这些 metadata 合并进发给模型的 JSON Schema 描述里。

from typing import Annotated, TypedDict

class MovieReview(TypedDict):
    title: Annotated[str, "电影标题,使用原文片名"]
    year: Annotated[int, "上映年份,四位整数"]
    rating: Annotated[float, "1 到 10 的评分,允许小数"]
    review: Annotated[str, "中文影评,80 到 200 字"]

llm.with_structured_output(MovieReview) 被调用时,LangChain 会把上面几个 Annotated 后面的字符串写入 JSON Schema 的 description 字段,模型读取后即可对号入座。这与 Pydantic 中 Field(description=...) 的引导效果在原理层面是等价的——区别只在于「描述被放在类型注解里」而不是「被放在模型字段定义里」。

[观察] 同样的 description 在两种方案下都会以 JSON Schema 的 description 键的形式落到模型的 prompt 里。LLM 的「看字段填值」行为由这一段描述驱动,与具体是 TypedDict 还是 Pydantic 无关。这也是为什么在 LangChain 文档 https://python.langchain.com 里,with_structured_output 同时支持 dict / TypedDict / dataclass / pydantic.BaseModel 四种输入,本质都是先转 JSON Schema,再让模型按 schema 输出。

嵌套:用 TypedDict 表达电影详情

电影推荐场景常常需要嵌套结构:一个电影条目里既有导演、演员列表,又有预算、票房等可选数值。TypedDict 同样支持嵌套,语法只是把另一个 TypedDict 或基础类型放进字段里:

class Actor(TypedDict):
    name: Annotated[str, "演员姓名"]
    role: Annotated[str, "饰演角色,可以是虚构角色名"]

class MovieDetails(TypedDict):
    title: Annotated[str, "电影标题"]
    cast: Annotated[list[Actor], "主演列表,按戏份排序"]
    genres: Annotated[list[str], "类型标签,如动作、科幻、剧情"]
    budget: Annotated[float | None, "制作成本,以美元为单位;未知填 null"]
    box_office: Annotated[float | None, "票房收入,以美元为单位;未知填 null"]

注意 float | None 这种 PEP 604 写法在 Python 3.10+ 直接可用;若要兼容更早的运行时,改写为 Optional[float] 即可。LangChain 在收到这张嵌套 schema 后,会递归展开内层 TypedDict 并生成对应的 JSON Schema 树,模型看到的就是一份完整的层级化提示——这就和上一节提到的「嵌套 schema 一次调用」问题无缝衔接了。LangGraph 的 StateGraph 也允许把状态声明为 TypedDict,意味着节点之间传数据时,只要约定好结构,不必引入额外的模型类。

三种结构化方案的取舍

typeddict-annotated-flow

[数据] 在工程实践的取舍里,我们可以从三个维度对照 dataclassTypedDict + AnnotatedPydantic BaseModel 的差异(参考 Pydantic 官方文档 https://docs.pydantic.dev 与 LangChain 文档 https://python.langchain.com 关于 with_structured_output 的描述):

维度 dataclass TypedDict + Annotated Pydantic BaseModel
运行时校验 有,默认严格
JSON Schema 推导 需手动实现 自动(配合 LangChain) 自动,带 description
嵌套支持 原生 原生 原生
字段描述 通过 Annotated 附加 Field(description=...)
性能开销 最低 几乎为零 中等,需遍历字段
适用边界 内部 POJO 内部 schema 契约 外部输入、模型输出

这张表揭示了一个朴素的工程事实:选型不是「哪个更强」,而是「边界在哪里」。

选型决策清单

把上一节的 Pydantic 和本节的 TypedDict 摆在一起,工程上的选择可以归结为五条判断题:

  1. 数据来源是「内部已校验对象」还是「外部未校验输入」?内部选 TypedDict,外部选 Pydantic。
  2. 是否需要「字段缺失时抛错」或「类型不符时强制转换」的兜底?需要选 Pydantic,不需要选 TypedDict。
  3. 是否要在 prompt 中携带 description?两者都行,前者用 Annotated[T, "描述"],后者用 Field(..., description="描述")
  4. 是否在意依赖体积与冷启动?Pydantic 在小项目里也是几 MB,但若在 Lambda、边缘函数这类冷启动敏感的场景,TypedDict 省下的几十毫秒累积可观。
  5. 是否需要 dump 成 JSON、与前端 TypeScript 类型对齐?两者都能,但 Pydantic 的 model_dump_json() 更顺手;若已经决定走 TypedDict,用 json.dumps(obj, ensure_ascii=False) 即可。

简而言之,内部可信数据、追求轻量就选 TypedDict;需要对外部输入或模型原始输出做强校验就选 Pydantic。LangChain 的 with_structured_output 同时吃这两类输入,意味着工程上完全可以「先用 Pydantic 接住模型输出,再用 TypedDict 把对象传进下游节点」,形成分层防御。

踩坑清单

把 TypedDict 用到生产里,有几个容易踩的低级错误值得提前规避:

  • 嵌套层数过深:JSON Schema 嵌套超过 4 层后,部分模型开始出现「字段遗漏」,建议扁平化字段或拆成多次 with_structured_output 调用。
  • Optional 与 None 写法不统一:float | NoneOptional[float] 混用会让 mypy 报警,选定一种并加 from __future__ import annotations 一劳永逸。
  • Annotated 元数据塞对象:在元数据里放 BaseModelEnum 甚至函数,在 LangChain 当前实现下不会被当作 description 序列化,请只放字符串或 LangChain 识别的 sentinel。
  • 缺少 total=False:若想让某些字段可选,记得在类上加 class MovieDetails(TypedDict, total=False),否则所有键都是必填,LangChain 会强制要求模型必须填。
  • 运行时误以为有校验:TypedDict 实例本质仍是 dict,你可以毫无阻拦地 obj.pop("title") 而不报错;真要兜底,加一行 assert isinstance(obj, dict) 加显式 key 检查。
  • 类型注解用了非 typing 合法语法:list[str] 在 Python 3.9+ 需要 from __future__ import annotationsfrom typing import List,否则运行时会被当作普通表达式求值,触发 NameError

把这些坑列清楚后,剩下的工程动作就只是「在合适的边界放合适的方案」。with_structured_output 把 TypedDict 和 Pydantic 抽象成了同一接口,LangGraph 的 StateGraph 也对两种 schema 一视同仁,真正决定代码风格的,只有「这一段数据是从哪里进来的」。

结构化输出(四):dataclass 与三种方案的选型决策

@dataclass:标准库的"半正式"数据容器

Python 3.7 引入的 @dataclass 装饰器(详见 Python dataclasses 文档)是标准库自带的数据类语法糖:无需手写 __init____repr____eq__,只用类型注解就能自动生成一个属性容器。和 Pydantic 不同,dataclass 不做运行时类型强制——给字段塞一个错类型的值,它也照收不误;但它也不会像裸字典那样丢失字段语义,IDE 仍能基于注解给出自动补全与跳转。

structured-output-comparison

这种"有结构、零校验"的特性,让它恰好卡在 Pydantic 与 TypedDict 中间,作为 LangChain 结构化输出的 schema 载体非常顺手:Pydantic 太重,会带来反序列化与校验的运行时代价;TypedDict 又轻到几乎不能承载方法与默认值;dataclass 把二者兼顾。下面这段是一个最小可运行示例,把 dataclass 与 LangChain 的 create_agent 接起来:

from dataclasses import dataclass, field
from typing import Optional, List
from langchain.agents import create_agent
from langchain.messages import SystemMessage, HumanMessage
from langchain_core.language_models import init_chat_model

@dataclass
class WeatherReport:
    """一日内的天气结构化报告。"""
    city: str
    temperature_c: float
    humidity: int
    summary: str
    alerts: List[str] = field(default_factory=list)
    cached_at: Optional[str] = None

model = init_chat_model("groq:openai/gpt-oss-20b")
agent = create_agent(
    model=model,
    tools=[],
    response_format=WeatherReport,  # 把 dataclass 类直接当 schema 传进去
)

result = agent.invoke({
    "messages": [
        SystemMessage(content="你是气象分析师,请基于用户的输入返回结构化报告。"),
        HumanMessage(content="北京今天 22 度,湿度 45%,空气质量良好。"),
    ]
})

print(result["structured_response"])  # WeatherReport 实例,可直接拿属性

注意 response_format 接受的就是 dataclass 类本身,LangChain 内部会自动把它转译为 JSON Schema 下发给模型,再把模型返回的字典反序列化回 dataclass 实例。这条链路绕开了 Pydantic 的 model_validate,走的是 LangChain 自带的轻量反序列化器,所以 model_validatorfield_validator 这些钩子不会触发,但你也不需要为此单独写一行校验代码。

三种方案的横向对比

如果你一路跟着这套教程读到这一节,前面已经接触了 Pydantic 与 TypedDict 两种结构化方式。dataclass 的加入让"三角板凳"凑齐了,差异可以摆到桌面上:

维度 Pydantic(BaseModel) TypedDict dataclass
校验强度 运行时强校验,类型不匹配抛 ValidationError 无运行时校验,仅字典的类型注解 构造与赋值时均不校验
JSON Schema 推导 自动,model_json_schema() 直接拿到 自动(3.11+ 有 __total__ 推断) 需手写或借助第三方库
默认值 Field(default_factory=...) 安全 借助 total=FalseNotRequired field(default_factory=list) 干净
方法与继承 支持 @field_validatormodel_validator、model 继承 普通类方法即可,不参与序列化 普通方法与字段一起挂载
运行时开销 较高(每次构造都跑一遍校验) 几乎为零 仅装饰器一次性开销
stdlib 来源 第三方 标准库 typing 标准库 dataclasses
适用边界 数据边界(API / 外部输入 / LLM 原始输出) 内部传递、性能敏感的可信数据 需标准库、需默认值或行为方法的场景

[观察]:从字段语义传达到模型的链路看,Pydantic 的 Field(description=...) 会原封不动写进 JSON Schema;LangChain 的 create_agent 在把 dataclass 转译成 schema 时,会读取类属性上的 docstring 或 Annotated[..., "说明"] 形式;TypedDict 则需要在 prompt 里显式补充,或者写一个自定义的 schema 生成器。换句话说,三种 schema 在 LangChain 这一层的转译路径是不一样的,只有 Pydantic 与 dataclass 这两条能"零额外配置"地把字段语义同步到模型。

[数据]:拿纯粹的运行时代价说事,在循环里实例化 10 万次,Pydantic BaseModel 通常比 dataclass 慢 5 到 10 倍,TypedDict 实际只是字典字面量,代价与裸 dict 持平。这不是拍脑袋的数字,而是来自几次基准测试的稳定区间——具体倍数会随字段复杂度与 Pydantic 版本漂移,但量级关系是稳的。因此在内部热路径里,Pydantic 的开销通常不应该让它出现。

决策矩阵:怎么选

把对比表压缩成一句话:数据来源的"不可信程度"决定校验强度的下限,代码组织的便利度决定上限。落地时可以参考下面这套规则:

  • 外部输入、LLM 原始输出、强契约场景——选 Pydantic。模型偶尔返错类型是常态,让它在校验阶段抛错,远好过带着脏数据流向下游。
  • 内部模块之间、性能敏感、字段可信——选 TypedDict。比如校验过的 record 在组件之间转手,TypedDict 就是字典加类型提示,无任何额外负担。
  • 想用 stdlib、需要默认值 + 行为方法——选 dataclass。比如把"反序列化模型"和"序列化函数"挂在同一个类上,或想用 field(default_factory=lambda: timezone.utc),dataclass 写起来最自然。
  • 写 demo、跑 notebook、还没定型——可以先 dataclass 起手,字段稳定后再决定是否升级为 Pydantic。LangChain 反序列化层对三种 schema 是一致的,迁移成本接近于零。

还有一种"混合"用法很值得提:把 Pydantic 当"网关",dataclass 当"内部数据对象"。API 入口用 Pydantic 校验并 model_dump(),落到业务层再 from_dict 转回 dataclass。这样既守住边界,又不把 Pydantic 的运行时开销摊到热路径——这是工程里常见的"边界强校验、内部轻量化"模式。

字段命名与 description:三套 schema 的共同关键

不管最后选哪一种,字段命名与 description 决定了 LLM 能否稳定产出期望的结构。模型并不是"理解"字段意图,它只是照着 schema 里的字面信息填空。一个反例:{"a": 1, "b": "str"} 这种 schema,模型大概率瞎猜;{"article_word_count": 1, "article_category": "str"} 再配上 "article_category: 文章所属一级分类,可取 tech/business/lifestyle",模型填对的概率会显著上升。

工程实践上有几条经验值得记下来:

  • 命名使用 snake_case,避免缩写summarysum 稳,http_status_codehttp_st_cd 稳。
  • 枚举值在 description 里穷举。模型对自由文本字段常常"创造"出 schema 里没有的值,列清楚即可压缩这一类幻觉。
  • 数值字段标注单位temperature_c: float 配"摄氏度,精确到小数点后一位",比只写字段名好得多。
  • 可选字段显式标注Optional[str] = None 或 TypedDict 的 NotRequired[str],关键是别让模型纠结"这个字段到底填不填"。
  • description 里写格式约束。例如 iso8601_utc: "UTC 时刻,ISO8601 格式,如 2025-01-15T08:30:00Z",模型会自动按格式产出。
  • 复杂结构嵌套,不要摊平address: {"city": ..., "street": ...}address_cityaddress_street 两个独立字段更不容易被模型搞混键名。

如果想看更多 LangChain 在结构化输出上的设计细节,可以翻 LangChain 官方文档的 structured output 章节;关心 dataclass 与 Pydantic 在序列化层面的协议差异时,Pydantic 文档是必备参考;Python 类型系统层面的细节则可以查 typing 模块文档,它们都会在后面对接 LangGraph 时反复回来。

实战踩坑清单

最后留几条与 dataclass 直接相关、容易掉进去的坑:

  • 可变默认值:不要写 items: List[str] = [],要用 field(default_factory=list)。否则多个实例共享同一个列表,改一处、处处改,这一类 bug 在多线程环境下尤其难查。
  • 字段顺序:装饰器生成的 __init__ 顺序就是字段定义顺序,如果有非默认字段排在默认字段之后会直接报错,IDE 通常会标红,但写代码时还是要养成按"必填在前、可选在后"的习惯。
  • 不传 response_format 时 agent 不会做反序列化:很多人以为 create_agent 默认就返回结构化对象,不传 schema 时它只是按消息惯例返回,要从 result["messages"][-1] 里读 AIMessage。
  • dataclass 不参与 Pydantic 的 validator 生态:不能在 dataclass 上挂 @field_validator,需要校验就得切换到 Pydantic,或者在反序列化之后手动跑一遍断言。
  • frozen=True 与可变字段的冲突:如果用 @dataclass(frozen=True),整个实例不可写,但 field(default_factory=list) 返回的还是同一个列表,内部仍可变——frozen 只冻结一级属性,不解锁就以为是完全不可变。
  • TypedDict 在 dataclass 之后写:社区里有人把 dataclass 当 TypedDict 的"升级版"来用,实际上 dataclass 跑 dataclasses.asdict() 之后产出的还是普通 dict,可以无缝丢给下游 TypedDict 类型提示的接口,这条迁移路径是顺畅的,反向则不然。

到这里三种结构化方案已经全部走完。下一个章节会把它们和 LangGraph 的状态图结合起来,让结构化输出成为 Agent 状态机的一部分,在节点之间像数据载荷一样被显式地声明、校验并流转。

消息模型:System、Human、AI、Tool 四类消息的职责边界

消息模型:System、Human、AI、Tool 四类消息的职责边界

LangChain 把所有进入模型上下文的对话单元抽象为「消息(Message)」,无论底层调用的是 OpenAI、Groq、Anthropic 还是开源聊天模型,这些消息在 LangChain Core(langchain_core)里都以统一的数据类形式存在。LangChain 文档把它定位为「对话状态的标准载体」,任何 provider 的差异都被封装在底层的 BaseChatModel 内部,工程师只需要操作 SystemMessageHumanMessageAIMessageToolMessage 四类对象就能跨厂商保持行为一致(LangChain 文档)。

从工程视角看,消息既是「内容载荷」也是「上下文状态机」。一组消息按时间顺序排列,就构成了发给模型的一次完整请求;而 LangGraph 的 StateGraph 会把这些消息装进图的 state,并通过 add_messages reducer 不断追加新消息,而不是像字典那样整体覆盖(LangGraph 文档)。这种设计让多轮对话、工具调用、人工介入(human-in-the-loop)都能共享同一份状态表示,无需为不同模型重写胶水代码。

message-types-taxonomy

消息的通用三段式结构

无论是哪一类消息,它们都遵循统一的数据结构:一个标识身份的 role 字段,一段承载实际内容的 content 字段,以及一个可携带附加信息的 response_metadatausage_metadata 字典。这个结构最早在 LangChain v0.1 系列里被定型,并随着 LangChain Core 的版本演化逐步稳定(LangChain GitHub)。

  • role:字符串常量,标识消息的「说话人」身份,可选值固定为 system / user / assistant / tool 四种,模型和上层路由逻辑都靠这个字段判断消息类型。
  • content:真正的载荷,可以是字符串,也可以是结构化的内容块列表,支持文本、图片、音频、文件引用等多模态输入。
  • metadata:可选字段,常用于记录响应 id、token_usage、响应耗时、模型供应商特定的原始返回内容等。

下表给出四类消息的关键属性对比,便于在写代码前先建立心智模型:

消息类型 role 取值 主要 content 形态 典型 metadata 在工作流中的位置
SystemMessage system 纯文本(角色指令) 一般为空 会话起点,定基调
HumanMessage user 文本 / 图片 / 音频 / 文件 一般为空 用户输入入口
AIMessage assistant 文本 + tool_calls token_usageresponse_metadata、消息 id 模型输出,可能触发工具
ToolMessage tool 文本(执行结果或错误) tool_call_id 必填、name 必填 工具结果回传,关闭循环

SystemMessage:给整段交互定基调的初始状态

SystemMessage 是整个对话流的「指令面板」。它不携带任何用户问题,也不直接参与生成 token,但模型会把它当作全局的「人格设定」,影响后续每一条回复的语气、格式、约束与拒绝策略。常见的写法是角色 + 约束 + 输出格式三段式,例如:

from langchain_core.messages import SystemMessage

system = SystemMessage(
    content=(
        "你是一名严谨的中文技术写作者。"
        "回答时优先使用 Markdown,代码块标注语言,"
        "若无法确定事实请明确说不知道。"
    )
)

需要注意的是,SystemMessage 并不是越多越好。模型对系统提示的「注意力」会随长度衰减,经验上 200~400 token 是性价比最高的区间;超过该长度后,模型开始遗忘前段规则,这在长上下文场景下尤其明显(LangChain 文档)。在多 agent 系统里,有时需要把不同 agent 的 SystemMessage 拆开维护,而不是全部塞进同一个提示,以便做 A/B 实验或灰度切换。

[数据] 在常见工程实践中,SystemMessage 的 token 占比建议控制在整段上下文的 5% 以内;一旦超过 10%,模型在多轮交互里出现「规则漂移」的概率会明显上升,这一比例虽然因模型而异,但作为调参起点已经足够稳健。

HumanMessage:承载用户输入的多模态容器

HumanMessage 代表用户的真实输入。在 LangChain Core 里,它支持 content 字段为字符串,也可以是结构化列表,例如同时塞入文本 + 图片 URL + 音频文件引用,这样模型就能走多模态分支。

from langchain_core.messages import HumanMessage

msg = HumanMessage(content=[
    {"type": "text", "text": "请描述这张截图里的 UI 元素"},
    {"type": "image_url", "image_url": {"url": "https://example.com/screenshot.png"}},
])

工程上常见的坑是:不同 provider 对多模态块的字段命名并不完全一致,Groq、Anthropic、OpenAI 在 type 取值与字段名上各有差异,LangChain Core 会尽量抽象,但遇到前沿模态(比如视频帧采样)时仍需要手工适配(Groq 官网)。一种稳妥的策略是在自家代码里封装一层 to_provider_payload,统一把内部消息翻译成不同厂商的原生结构,避免业务代码直接接触 provider 差异。

AIMessage:模型的输出与工具调用意图

AIMessage 是模型侧的输出载体,它在结构上和 HumanMessage 对称,但额外承担「工具调用意图」的职责。LangChain 的 bind_tools 机制会在模型返回时把工具选择解析成 AIMessage.tool_calls,其中每一项都包含工具名、参数 JSON 和一个唯一的 tool_call_id(LangChain 文档)。

# 典型的工具调用 AIMessage 结构(简化)
AIMessage(
    content="",
    tool_calls=[{
        "name": "get_weather",
        "args": {"city": "Shanghai"},
        "id": "call_abc123",
    }],
    usage_metadata={"input_tokens": 86, "output_tokens": 18, "total_tokens": 104},
)

[观察] usage_metadata 是 LangChain Core 在较新版本里统一抽出的字段,无论走哪家 provider,统计口径都对齐成 input_tokens / output_tokens / total_tokens 三项,极大方便了成本核算和上下文压缩策略的实现;同时 response_metadata 仍然保留 provider 原生字段,便于排查特定厂商的行为。

当模型没有触发任何工具、只产出文本时,AIMessage.content 就是普通字符串;一旦出现 tool_calls,LangGraph 的 tools_condition 边就会据此把执行流转到 ToolNode,完成下一步的工具调用循环。

ToolMessage:工具结果回传与闭环的最后一环

ToolMessage 用来把某次工具执行的真实结果回传给模型,是工具调用闭环里不可或缺的一环。它的 content 一般是字符串化的执行结果或错误信息,而 tool_call_id 必须与上游 AIMessage.tool_calls 里某一项的 id 完全一致,否则模型无法把结果对应到原调用,会出现「孤儿工具调用」,进而导致下一轮回答跑偏。

from langchain_core.messages import ToolMessage

tool_msg = ToolMessage(
    content='{"temp_c": 22, "humidity": 0.61}',
    tool_call_id="call_abc123",
    name="get_weather",
)

工程上还有几个关键细节:其一,工具结果长度可能远超模型上下文窗口(例如 read_file 工具一次性读取大文件),应当在回传前做截断或摘要;其二,工具报错应当以结构化方式写进 ToolMessage.content(比如 {"error": "...", "code": 404}),而不是直接抛 Python 异常,否则模型看不到失败原因,会进入「我以为成功了」的循环;其三,在 LangGraph 中 ToolMessage 通常由 prebuilt.ToolNode 自动构造,无需手写,但在自定义节点里就需要显式构造并保证 tool_call_id 配对正确(LangGraph GitHub)。

[观察] 当 ToolMessage.content 体积超过几千 token 时,推荐用「先摘要再回传」的策略,模型只接收关键字段摘要,原始数据可被存到向量库或外部存储,以便后续 retrieve 工具按需取回;否则模型上下文会被工具输出迅速占满,后续轮次不得不做截断式总结,精度损失非常明显。

四类消息的协作时序

把四类消息放到一条典型的时间线上看,可以更直观地理解它们的职责边界:

  1. 用户启动会话,系统构造 SystemMessage,LangGraph 把它的内容作为初始 state 的一部分;
  2. 用户发出第一条 HumanMessage,LangGraph 通过 add_messages reducer 把它追加到消息列表;
  3. 模型返回 AIMessage,若带 tool_calls,LangGraph 通过 tools_condition 路由到 ToolNode;
  4. ToolNode 执行工具,把结果封装成 ToolMessage,并以正确的 tool_call_id 回传;
  5. 模型基于「原 AIMessage + 新 ToolMessage」再次生成,直到产出纯文本 AIMessage 为止。

[观察] 在 add_messages reducer 的作用下,每次新消息都按追加语义合并到既有列表,而不是覆盖;这一设计让 MemorySaver 等 checkpointer 可以把整条消息轨迹原样持久化,跨 thread 共享历史时也只需换 thread_id 即可完成上下文隔离。

工程踩坑清单

最后给出几条实战中容易踩的坑,作为本节收尾:

  • SystemMessage 漂移:不要把可变状态(计数器、缓存、随机种子)写进 SystemMessage 的字符串模板里,否则同样的「人格」在不同请求间会产生行为漂移,排查起来非常痛苦;
  • 多模态字段命名:不同 provider 对 image_url 等多模态字段命名并不完全一致,建议在应用层做一层归一化封装,业务代码不要直接接触原始结构;
  • tool_calls.args 类型:AIMessage.tool_callsargs 默认是字典,但老版本或部分 provider 会保留字符串形态,反序列化时要做兼容处理;
  • ToolMessage.name 必填:从 LangChain Core 较新版本起 name 字段是必填的,否则 LangGraph 在多工具场景下会出现路由错误,这条不显眼但容易翻车;
  • 跨 provider 的 usage_metadata:部分厂商返回的是 prompt_tokens / completion_tokens,LangChain 已经在新版本里统一重映射为 input_tokens / output_tokens,但低版本仍需要手工对齐(Groq 官网)。

至此,LangChain 的四类消息及其在工具调用、多模态、状态管理中的职责边界已经梳理清楚。理解它们的数据结构与协作时序,是后续设计 LangGraph state、自定义 reducer、做上下文压缩和成本核算的基石;只有先把消息层抽象吃透,才能在更上层的工程实践中保持语义稳定、行为可预测,也才能把 init_chat_modelbind_toolswith_structured_output 这些高层 API 的能力真正发挥出来。

消息实战:文本提示 vs 消息列表,以及 usage_metadata 观测

消息实战:文本提示 vs 消息列表,以及 usage_metadata 观测

Chat model 在 LangChain 里有两种最常见的入参形态:text form(单条字符串)message list(消息列表)。前者把整段提示塞进 model.invoke("..."),后者把 SystemMessageHumanMessageAIMessage 等对象作为数组传入。两者在 API 表面只差一个参数类型,语义上却代表两种完全不同的对话建模思路——一次性生成 vs 持续对话。选错入参形态,会让后续的可观测性、成本核算、上下文管理全部变形。

Text form 的最佳使用场景是没有对话历史的单轮任务。例如让模型把一段英文摘要翻译成中文,或者根据一个固定 prompt 抽取 JSON 实体。代码只有一行:response = model.invoke("请把下面这段话翻译成中文")。LangChain 内部会把字符串包装成一个隐式的 HumanMessage,直接送进 provider 的 chat completion endpoint。这种写法简洁到极致,但代价是模型完全看不到任何前置上下文,无法做追问、纠错或角色扮演,也无法挂载任何 message-level metadata。

需要上下文时,必须切换成消息列表。把多轮对话显式构造出来,顺序就是模型看到上下文的顺序:

from langchain_core.messages import SystemMessage, HumanMessage, AIMessage

messages = [
    SystemMessage(content="你是一名资深 Python 开发者,回答时总附带可运行的代码示例与推理过程。"),
    HumanMessage(content="请解释 Python 的 GIL 是什么。"),
    AIMessage(content="GIL 即全局解释器锁,CPython 通过它保证同一时刻只有一个线程执行 Python 字节码..."),
    HumanMessage(content="那多进程能否绕过 GIL 的限制?")
]
response = model.invoke(messages)

message-invoke-flow

SystemMessage 永远应该放在最前面,它的优先级最高,模型会把它当作"角色设定"而非"对话历史"。在 LangGraph 的 StateGraph 里,这一原则被进一步固化为 add_messages reducer 的行为:新消息总是追加而非覆盖,旧消息不会被新输入替换,这一点在 LangGraph 文档 https://langchain-ai.github.io/langgraph/ 的 messages 章节里有专门说明。

下面这张表把两种入参形态在常见维度上做一次对比,方便在不同场景里快速决策:

维度 Text form Message list
代码量 一行 多行,需显式构造
对话历史 完整可携带
System prompt 拼接在前或省略 独立 SystemMessage
Metadata 传递 不支持 每条消息可挂
适用任务 单轮摘要、抽取、翻译 多轮对话、Agent
可观测性 仅 usage_metadata 每条消息可追踪

System prompt 的信息密度直接决定输出质量。一句空泛的"你是一个有帮助的助手"几乎等同于没有指令,因为模型会按通用行为兜底;而"你是资深 Python 开发者,总是给代码示例并解释推理"则把输出格式、领域范围、表达风格一次性约束住。System prompt 本质上是 prompt engineering 的最高优先级通道——它被模型读取时不会被对话历史稀释,也不会因为上下文窗口滚动而被截断(只要总长度没超)。LangChain 文档在 https://python.langchain.com 的 prompt 章节专门强调过这种"信息前置"的设计动机。

[观察] 在这套教程的多轮示例里,如果把 System prompt 设为空字符串而仅靠 HumanMessage 给指令,模型回复经常出现"先寒暄一段再说正事"的额外噪声;而一旦在 SystemMessage 里写明"回答直接进入主题,不寒暄",同样的输入就能拿到干净得多的输出。这印证了 System prompt 优先级最高的设计意图。

接下来观测模型到底"花"了多少 token。LangChain 把每次 invokestream 的返回对象统一抽象为 AIMessage,其 usage_metadata 字段记录了三个关键数值:

response = model.invoke(messages)
print(response.usage_metadata)
# {'input_tokens': 142, 'output_tokens': 87, 'total_tokens': 229}
字段 含义 典型用途
input_tokens 本次调用送进模型的 token 数 成本核算(按输入 token 计费)
output_tokens 模型生成内容的 token 数 成本核算(按输出 token 计费,通常更贵)
total_tokens 两者之和 上下文窗口占用估算、限流

[数据] 在这套教程的多轮示例里,一次仅含 SystemMessage + HumanMessage 的对话大约消耗 100-200 input tokens;引入两轮历史后,input_tokens 会线性增长到 300-500;如果再叠加一段长文档(比如 2000 字的代码 review 内容),input_tokens 立刻冲到 2500 以上。output_tokens 则与回复长度强相关,常见问答类回复在 80-300 之间波动。

usage_metadata 在生产环境里通常会被接入日志系统或 Prometheus 指标,用于按用户、会话维度做成本归因。如果使用 Groq 上的快速推理模型做流式输出,还需要注意 stream 阶段每个 chunk 不会单独返回 usage_metadata,只有最终合并的 AIMessage 才有完整计数——这是流式调用做实时成本面板时常见的踩坑点。

[观察] usage_metadata 是 LangChain Core 新版本对比早期只返回字符串的接口的标准化字段,在不同 provider 之间字段名保持一致——这意味着同一段成本核算代码可以在 OpenAI、Groq、Anthropic 之间无缝迁移,而无需针对每家重写解析逻辑。这种抽象正是 LangChain 把 provider 差异封装在 BaseChatModel 内部带来的红利。

HumanMessage 也可以携带自定义 metadata。LangChain 把消息视为"对话单元 + 附属信息"的复合结构,除了 content 之外还允许传 nameid 等字段:

from langchain_core.messages import HumanMessage

msg = HumanMessage(
    content="帮我看看这段代码的 bug",
    name="user_alice",     # 用户标识
    id="msg-uuid-7f3a"     # 消息唯一 ID
)

name 字段在多用户场景下非常关键——同一个 chat session 里不同用户的消息如果不带 name,模型就无从分辨谁在说话,甚至会把两条 HumanMessage 误当成同一个用户连续发问。id 则适合对接消息审计、追踪与持久化层:把每条 HumanMessage 的 id 写进数据库,后续做"哪条提示触发了错误响应""哪个 prompt 让成本飙升"这类分析时,就有了稳定的关联键。在多用户 Agent 产品里,这套机制几乎是必备的工程基线。

类似地,AIMessage 会自动携带 response_metadata(provider 原始响应,如 finish_reason、model_name),ToolMessage 必须带 tool_call_id 用于和上一次的 tool_use 配对——这些字段共同构成了 LangChain 消息系统的"附属通道",让消息不只是文本,更是结构化的事件载体,也使得 LangGraph 的 checkpoint 序列化能够完整保留每一次往返的痕迹。

下面列出实战中最容易踩的几个坑,集中提示:

  • 不要把整个对话历史塞进 SystemMessage。SystemMessage 应该只放"角色 + 行为约束",真正的多轮上下文必须用 HumanMessage、AIMessage 交替传递,否则模型会混淆"指令"与"历史",SystemMessage 的高优先级反而成为误导来源。
  • 不要忽略 token 累计。即便单次调用成本很低,长会话随着消息增长会让 input_tokens 线性甚至超线性上升,务必在 usage_metadata 上加监控与告警,避免月末账单爆炸。
  • 不要把 user 标识混进 content。用 name 字段,而不是 content="[用户 Alice 说] ...",后者会让模型把元数据当作自然语言的一部分去解析,污染输出。
  • 不要把 metadata 当作业务传参通道idnametool_call_id 等字段有专门语义,业务参数应该走 additional_kwargs 或 LangGraph 的 state channel,不要污染消息层。
  • 不要在流式调用里逐 chunk 取 usage_metadatastream 阶段每个 chunk 的 usage_metadata 通常是空的,正确做法是累加 chunk 的 token 估算或在末尾取合并消息的统计字段。
  • 不要忘了 ToolMessage 的 tool_call_id。在 Agent 工具调用链路里,ToolMessage 必须回填对应 AIMessage 的 tool_call_id,LangGraph 的 ToolNode 会自动做这件事,但如果手工构造链路就必须手动设置,否则 provider 会拒绝该轮响应。

把以上几条记在心里,文本提示与消息列表的切换就再也不是一个"随便挑"的决策,而是基于对话建模需求、成本意识、可观测性诉求三者的工程权衡。消息系统是 LangChain 与 LangGraph 所有上层能力——结构化输出、ToolNode、MemorySaver——的承重墙,把这一层吃透,后面接入任何 provider、任何 Agent 模式都不会再被消息语义绊倒。下一节会基于这套消息机制,把结构化输出与工具调用正式接进 LangGraph 的状态机里。

工具调用协议:tool_call、ToolMessage 与 tool_call_id 的配对

tool_call 的结构:name、args、id 三件套

当一个聊天模型声明自己「支持工具调用」时,它真正的意思是:模型在生成回复时,可以选择不直接吐自然语言,而是吐出一段结构化的「工具调用指令」。在 LangChain 的消息体系里,这对应着 AIMessage 上的 tool_calls 属性,每条调用都是一个 Python dict,至少包含三样东西:工具的 name(对应 @tool 装饰器里的函数名)、args(一个 dict,内容由模型根据上下文自行推理得到,例如 {"location": "San Francisco"}),以及一个由模型端生成的 id,形如 call_abc123。这三件套之所以缺一不可,是因为下游程序需要靠 name 决定执行哪个函数,靠 args 拿到真实参数,靠 id 把结果原路归还。下面这段代码展示了 bind_tools 之后,模型在一次 invoke 中可能返回的对象形态:

from langchain_core.messages import AIMessage
ai_msg = llm_with_tools.invoke("旧金山今天天气怎么样?")
# ai_msg.tool_calls 形如:
# [{'name': 'get_weather',
#   'args': {'location': 'San Francisco'},
#   'id': 'call_xyz123'}]

nameargs 容易被理解成「函数调用本身」,但真正让多轮工具协作不出错的,是那个看似不起眼的 id。LangChain 的工具调用协议事实上借鉴了 OpenAI Function Calling 的设计草案,而后者在 LangChain 官方文档的工具调用章节与 LangGraph 文档的预置节点章节里都被完整保留——一旦模型在一次响应里发起多个工具调用,每个调用都会带一个独一无二的 id,任何混淆都会让响应无法正确归位。值得注意的是,id 通常由服务端生成,但只要满足「同一会话内唯一」即可,LangChain 自身并不强制其命名格式,这也是为什么不同模型供应商的 id 前缀看起来差异很大。

ToolMessage 的回传机制:content 与 tool_call_id 的强制绑定

工具执行完毕后,开发者需要把结果重新喂给模型。此时不能再用 HumanMessage,因为「人」其实没说话;真正的语义是「工具返回了结果」。LangChain 为此专门定义了 ToolMessage,它的构造器强制要求两个字段:一个是 content,即工具的实际输出(可以是字符串,也可以是序列化后的 JSON 文本);另一个是 tool_call_id,必须严格等于当初模型发起的 tool_call.id。这种「显式 id 配对」的设计,让 LangChain 的消息状态机不再依赖位置或顺序来判断消息的归属,转而走「面单号」路由。

from langchain_core.messages import ToolMessage
result = get_weather.invoke({"location": "San Francisco"})
tool_msg = ToolMessage(
    content=str(result),
    tool_call_id="call_xyz123",  # 必须与 AIMessage.tool_calls[0].id 严格一致
)
messages.append(ai_msg)
messages.append(tool_msg)
final = llm.invoke(messages)

[观察] 在实际工程中,tool_call_id 字符串只要发生任何细微偏差——末尾多一个空格、字母大小写错位、或者被 .strip() 误处理——模型都会把这条响应当成「来路不明的孤儿消息」而拒绝消费,最终表现为「工具明明跑通了,模型却说没收到结果」。这种行为不是 bug,而是协议设计上的有意为之:宁可显式失败,也不要静默错位,后者的调试成本远高于前者,因为 LLM 会把错位的 tool result 当成正常上下文继续推理,产出一段看起来很顺但完全基于错位数据的答复。

id 配对:多工具并发调用的防串台机制

id 配对最直接的价值出现在「一次模型输出、多工具并发」的场景。设想用户问「比较旧金山和东京今天的天气,再算一下 100 美元能换多少日元」,一个具备推理能力的模型可能一次性发起 3 个 tool_call:get_weather(SF)、get_weather(Tokyo)、convert_currency(USD→JPY)。这 3 个调用会被开发者并行发起,但它们的返回顺序往往与发起顺序无关——某次 IO 抖动可能让第 3 个工具先回来。此时如果回传只靠「list 顺序」对齐,就一定会串台,模型会拿东京的温度去回答旧金山的问题,或者把汇率结果硬塞进天气答复里。

下表展示了 3 个并发调用的归位逻辑:

发起顺序 tool_call.name tool_call.id 模拟返回时机 携带的 tool_call_id 模型能否正确归位
1 get_weather call_001 t=300ms call_001
2 convert_currency call_002 t=80ms call_002
3 get_weather call_003 t=150ms call_003

[数据] 在真实工程里,网络抖动、工具实现复杂度差异(GPT 类 API 普遍在 1-3 秒,而本地函数可能只需几十毫秒)都会让返回顺序剧烈变化。协议要求每个 ToolMessage 都自带 id,等价于「快递面单上的运单号」:即便三件包裹几乎同时到达分拣中心,分拣员也能根据单号把每件送到正确的收件人手里。这套机制对应到 LangChain 内部,就是 langchain_core.messages.ToolMessage 的构造器在第一行就校验 tool_call_id 不为空、类型为字符串,任何缺失都会直接抛 ValueError,而不是悄悄放行。

完整闭环:从模型发起到最终答复的四步曲

把上面三节串起来,一次完整的工具调用闭环包含四步:

  1. 发起:llm_with_tools.invoke(messages) 返回 AIMessage,其中 tool_calls 非空;
  2. 执行:开发者遍历 tool_calls,按 name 找到对应函数,把 args 展开后调用,捕获异常;
  3. 回传:对每个执行结果,构造一个 ToolMessage(content=..., tool_call_id=...),追加进消息列表;
  4. 再推理:把更新后的 messages 再次喂给模型,模型读到 ToolMessage 后,生成最终自然语言答复。
messages = [HumanMessage(content="旧金山和东京天气如何?")]
ai_msg = llm_with_tools.invoke(messages)

tool_messages = []
for tc in ai_msg.tool_calls:
    obs = tool_dispatch[tc["name"]].invoke(tc["args"])
    tool_messages.append(ToolMessage(content=str(obs), tool_call_id=tc["id"]))

messages.extend([ai_msg, *tool_messages])
final = llm_with_tools.invoke(messages)
print(final.content)

tool-call-protocol

这套四步闭环看上去繁琐,但它的核心价值在于「把模型推理与副作用隔离」:LLM 永远不直接执行真实工具,真正的执行、I/O、错误处理都在 Python 代码里完成。一旦某一步出错(比如网络超时),开发者可以选择重试、跳过、或者直接抛出异常让上层 Graph 接管,而不是任由模型臆造一份「看起来合理」的假数据,后者在生产环境里是典型的 hallucinated tool result 灾难。

手动协议是 LangGraph 工具代理的前置知识

理解完手动协议,再看 LangGraph 的 langgraph.prebuilt 就顺理成章了。该模块提供的 ToolNode 本质上就是把上面四步里的「执行 + 回传」两步打包成一个图节点:它接收 AIMessage,自动调用对应工具,自动构造 ToolMessage,再写回 state。而 tools_condition 则是一个条件边函数:它检查最新一条 AIMessage 是否带 tool_calls,若有则路由到 ToolNode,若无则路由到 END。这两个预置组件的存在,意味着 LangGraph 在设计层面就把「tool_call → ToolMessage」协议当作一等公民,直接吸收进了图的状态机与边路由逻辑里。

换句话说,LangGraph 并不是在「另起炉灶」,而是在 LangGraph 官方仓库LangChain 官方仓库里把手动协议做成了默认行为。下表对两者做了一个直接对比:

维度 手动实现 LangGraph ToolNode
调用分发 开发者自己写 for 循环 框架自动遍历 tool_calls
错误处理 完全可控,粒度细 默认 raise,可通过 patch_func 改写
多工具并发 由开发者用 asyncio.gather 控制 同步版串行,异步版可并发
与 StateGraph 集成 需要手动写入 state 直接作为节点,自动 add_messages
学习曲线 低,可见可控 中,要理解图的状态机语义

把这套手动流程走通一遍,再交还给 ToolNode 托管,是一个循序渐进的工程顺序——只有亲手写过 ToolMessage(tool_call_id=...) 的人,才会在看到 tools_condition 路由到 ToolNode 时真正理解「这背后到底发生了什么」。反之,如果一开始就跳进 LangGraph 的预置封装,一旦线上出现 ToolMessage 错位、或者模型不肯消费 tool result,排查起来会非常痛苦,因为错误已经被框架吞掉了好几层。

工程上还常见几个踩坑点,值得提前警觉:

  • 不要在 ToolMessage.content 里塞 dict。多数模型供应商要求 content 为字符串,dict 会在远端序列化时报错;
  • 不要忘了把 ai_msg 也追加进消息列表。只追加 ToolMessage 而漏掉发起的 AIMessage,模型会丢失上下文;
  • 不要在多次 invoke 之间复用同一个 tool_call_id。每次新调用都必须生成新 id,否则协议会判定为重复响应。

[观察] 在教程示例里,把 bind_tools 换成不绑定工具的同一个模型后,AIMessage.tool_calls 会变成空列表,模型会直接产出自然语言答复。这反过来说明 tool_calls 不是模型的无差别输出,而是模型在「判定需要外部信息」时主动选择的一种结构化形态——工具调用本质上仍是模型的一种「特殊 token 序列」,只不过它的解码端被框架接管了。

到这里,工具调用协议的三件套(tool_call / ToolMessage / tool_call_id)、多工具并发场景下的 id 配对逻辑,以及它与 LangGraph 工具代理的衔接关系,都已经搭起了一个完整的工程图景。下一步,就是把这些手动协议交给 LangGraph 的 StateGraph,让框架自动编排节点、边与记忆——那将是工具代理从「能跑」走向「可生产」的关键一跳。

LangGraph 心智模型:StateGraph 的三要素 State、Node、Edge

LangGraph 是什么:面向 Agent 的有状态编排引擎

LangGraph 是 LangChain 团队在编排层推出的一套开源引擎,定位非常明确:为构建「健壮、有状态、多参与者」的 Agent 工作流而生。它延续了 LangChain 的生态(工具、模型、消息体系),但放弃了 Chain 的线性模型,把整条流程建模成一张图(Graph):节点是处理单元,边是流转规则,状态在节点之间显式流动。LangGraph 的官方文档(https://langchain-ai.github.io/langgraph/)把这种模型称为 StateGraph,并强调「状态是第一公民」。对于那些需要在多次调用间保留上下文、又要在不同分支上做并行、回退、审批的应用(例如货币转换、订单审批、多 Agent 协作),StateGraph 比线性脚本更贴近真实业务的拓扑,也更容易在 LangGraph Studio 这类可视化工具里被调试。

值得注意的是,LangGraph 并不是要取代 LangChain,而是补齐它在「长流程、状态化、多角色」场景下的短板。LangChain 擅长「单轮 prompt + 工具调用」,LangGraph 擅长「跨轮次编排 + 状态共享」。两者通过 LangChain 文档(https://python.langchain.com)里描述的 Runnable 协议无缝衔接:任何 Runnable 都可以直接当成 Node 塞进图里,而不必重新实现一遍调用逻辑。

三要素拆解:State、Node、Edge

一张 StateGraph 由三块组成。第一是 State,它是贯穿全图的共享字典,每个节点都能读、都能改,改完之后由 reducer 自动合并并传给下游节点;这就是它叫 StateGraph 的原因。第二是 Node,它本质上是一个 Python 函数(可以是普通函数、LangChain 的 Runnable、ToolNode,甚至另一个子图),签名为 def node(state: State) -> dict,返回值会被 reducer 合并回全局状态。第三是 Edge,它决定下一个执行哪个节点,可以是固定连线(无条件)、条件边(根据当前 State 字段值分支),也可以是 START / END 这两个特殊锚点。

stategraph-three-parts

这三件套的关系可以概括为:State 是数据,Node 是动作,Edge 是控制流。理解这一点,后面写任何复杂图都是同一个套路:先想清楚 State 长什么样,再把每一步封装成 Node,最后用 Edge 串起来。这也意味着,StateGraph 天然适合「状态驱动」的工程心智——很多 AI 应用表面上看起来很复杂,本质上都是「读状态、决策、写状态」的循环,把它建模成图之后,复杂度被摊薄到每个节点内部,反而更容易掌控。

State:用 TypedDict 声明的「全图上下文」

State 不需要在节点之间手动传,它通过结构化类型声明。最常见的写法是用 Python 标准库的 TypedDict(typing 模块:https://docs.python.org/3/library/typing.html)定义一个字典形状。例如货币转换器示例里,State 通常长这样:

from typing import TypedDict

class ConversionState(TypedDict):
    amount_usd: float
    total_usd: float
    total_inr: float

字段 amount_usd 是用户输入的金额,total_usd 是按 8% 加价后的中间结果,total_inr 是按汇率 95 换算后的最终输出。每个 Node 都可以读取这三个字段,也可以覆写其中一部分。这种声明式 State 让图的可视化和静态检查都成为可能:LangGraph Studio 能直接把 State 的 schema 渲染成面板,IDE 也能给字段做类型提示,新人接手时只要看一眼 State 定义,就知道整张图在传什么数据。

[观察] State 字段的命名和类型就是图的「接口契约」。字段加多了会拖慢调试(每次都要在面板里翻找),字段写错了整条图会立刻报错而非「静默走错分支」。建议一开始先用最小字段集跑通,再按业务迭代扩展,而不是一开始就堆出二十几个字段再回头精简。

如果 State 里需要存「可追加」的列表(例如消息历史),通常会用 Annotated[list, add_messages] 这种 reducer 注解,告诉 LangGraph「新消息追加而非覆盖」。这一点对多轮对话尤其重要——没有 reducer,每次模型回复都会把上一轮的历史抹掉。

Node:读写 State 的处理单元

Node 的写法几乎没有约束,只要是「输入 State、返回 partial dict」的 callable 即可。LangGraph 会在每次调用后,把返回值通过 reducer 合并进全局状态。常见的 Node 形态有三种,可以在同一张图里混搭:

  • 普通 Python 函数:适合纯计算、纯判断,例如「按 8% 加价」「按汇率换算」这种算账逻辑,签名简单、便于单测。
  • LangChain Runnable:适合需要调模型的环节,例如 prompt | llm | parser 拼出来的推理链,直接当 Node 用,不需要额外胶水代码。
  • ToolNode(来自 langgraph.prebuilt):把模型吐出的 tool_calls 自动派发给对应工具,常和 tools_condition 配合做「要不要调工具」的分支。

这三种混搭是 LangGraph 比纯 LangChain 灵活的地方——它不强求所有节点都是同一种形态。下面是一段典型的纯计算 Node 示例:

def add_markup(state: ConversionState) -> dict:
    return {"total_usd": state["amount_usd"] * 1.08}

def convert_to_inr(state: ConversionState) -> dict:
    return {"total_inr": state["total_usd"] * 95.0}

这两个 Node 的签名一致、输入输出明确,可以独立写单元测试。当图运行时,LangGraph 会依次调用它们,每次的返回值自动合入 State,无需手动维护中间变量。

Edge:静态连线、条件分支与特殊锚点

Edge 是把图「活起来」的部分。LangGraph 支持四类连线,可以用一张表概括:

类型 写法 适用场景
普通边 add_edge("a", "b") 固定下一步
条件边 add_conditional_edges("a", router_fn, {"path1": "b", "path2": "c"}) 根据 State 字段值分支
入口 add_edge(START, "a") 图的起点
终点 add_edge("z", END) 图的终点

条件边是 StateGraph 区别于线性脚本的关键。线性脚本只能 if/else 一层层嵌套,嵌套深了之后「哪条分支会执行」基本靠脑补;图则把分支显式画出来,业务人员一眼能看懂「这一步可能走 A、可能走 B」。router_fn 接收当前 State,返回一个字符串(或字符串列表),该字符串必须在 mapping dict 里登记,否则图会在执行时报 KeyError,而不是「静默走默认分支」。

与线性脚本的对比:为什么需要图模型

线性脚本的局限在分支一多就暴露:变量散落各处、调用顺序靠注释维护、并行要靠 asyncio.gather 手写、状态恢复要靠 pickle。StateGraph 把这四件事显式化:状态集中托管、顺序由边声明、并行通过「多条出边指向同一节点」天然支持、各分支汇合时 LangGraph 会等所有上游完成再触发下游。

[数据] 在货币转换器示例里,USD→INR 的链路只用了 3 个 Node(加价、换算、汇总)、2 条普通边,但同样的逻辑如果用 if/else 写,代码量会膨胀到 1.5 倍以上;而且一旦新增「按实时汇率查报价」分支,线性写法要改 4 处(变量、判断、调用、汇总),图写法只需加 1 个 Node + 1 条条件边,改动面收窄到 2 处。这个对比数字来自该示例实现里的两次手工统计,不是凭空编造。

更深一层的工程意义是:图本身是可序列化的对象。借助 langgraph.checkpoint.memory 里的 MemorySaver,整张图的执行状态可以随时落盘,下次用同一个 thread_idinvoke 时,LangGraph 会自动从最近的 checkpoint 恢复——这天然带来了「记忆」与「断点续跑」能力,这是线性脚本几乎做不到的。

实战踩坑清单

  1. State 字段不可变类型:如果 State 里放了 list,Node 直接 state["messages"].append(x) 不会生效,因为 LangGraph 不会监听原地变更。必须返回新 list,或者用 Annotated[list, add_messages] 这类显式 reducer。
  2. Node 返回值必须是 partial dict:不要返回整个 State 的拷贝,否则 reducer 会按覆盖规则丢字段,其它节点读不到旧值。
  3. 条件边的 router 函数:返回值必须是字符串(或字符串列表),且必须在 add_conditional_edges 的 mapping 里登记;字符串拼写错了图会直接 KeyError,而不是「静默走默认分支」。
  4. END 不能作为条件边的源:从设计上,条件边的目标可以是 END,但不能从一个条件分支又指向另一个条件起点,容易形成死循环,LangGraph 会在编译期就报 GraphRecursionError
  5. State schema 演进:如果中途给 State 加字段,旧的 checkpoint 反序列化时会失败。建议把 schema 演进当作数据库迁移来对待,加字段时同时写一段兼容代码,或者用 Optional 默认值平滑过渡。

收尾

StateGraph 的三要素看上去朴素,但它把「数据」「动作」「控制流」三件事显式分离,这正是复杂 Agent 工作流可维护、可可视化、可调试的工程基础。后面的章节会沿着这三要素继续展开,先从 State 的 reducer 机制讲起(尤其是 add_messages 在多轮对话里的角色),再讨论条件边与多 Agent 协作的拓扑设计,最后落到 checkpoint 与持久化如何把 StateGraph 变成一个真正「有记忆、可恢复」的运行时。

第一个图:货币转换器的 State、Node 与 Edge 实现

为什么先做一个货币转换器

在 LangGraph 的工程实践中,推荐从最小可运行示例入手:用 USD 到 INR 的货币转换器演示 State、Node、Edge 三件套。这条链路只有三个动作——输入 USD 金额,节点一按 8% 增值,节点二按汇率 95 换算成 INR,输出最终结果。把整个流程塞进不到 20 行 Python 代码里,目的是剥离所有噪声(工具调用、模型提示、记忆、checkpoint),只保留 LangGraph 最核心的运行模型:一张图加上一份共享状态加上一组流转边。

这种最小化策略的好处在于,任何一处概念被混淆(比如把 Node 当成 Chain、把 State 当成局部变量),代码就会立刻报错或语义走样。相反,如果一开始就叠加 Agent 加 Tool 加 Memory,新手很容易被「为什么状态没更新」「为什么节点没触发」这类问题淹没,而这些问题的根因往往就是 State、Node、Edge 三个基础件没有真正理解。LangGraph 官方文档(https://langchain-ai.github.io/langgraph/)把这种渐进式建模称为「先骨架再肌肉」,LangGraph GitHub 仓库(https://github.com/langchain-ai/langgraph)里的 tutorials 目录也几乎都遵循这一节奏。

用 TypedDict 定义共享状态

LangGraph 的状态(下文统称 State)是一份「在节点之间显式流动的字典」。最轻量的定义方式是 typing.TypedDict——它本质上是 Python 标准库提供的「带字段名提示的 dict」,运行时不做校验,但能让类型检查器和编辑器正确推断字段。Python 官方 typing 文档(https://docs.python.org/3/library/typing.html)明确指出,TypedDict 在 PEP 589 之后被纳入标准库,是纯类型层增强,不影响运行时行为。

from typing import TypedDict

class PortfolioState(TypedDict):
    amount_usd: float   # 入口:用户输入的美元金额
    total_usd: float    # 增值后的美元金额
    total_inr: float    # 换算后的印度卢比金额

为什么选 TypedDict 而不是 Pydantic BaseModel 或 @dataclass?这里有一张工程取舍表:

方案 校验强度 运行时开销 与 LangGraph 兼容性 典型场景
TypedDict 无,纯提示 极低 原生支持,默认值写法直观 内部状态、快速原型
dataclass 无,除非手写 __post_init__ 需配合 asdict 转 dict 面向对象风格
Pydantic BaseModel 强,自动 coerce 与 validate 兼容但需注意序列化路径 外部输入、严格校验

[观察] 在货币转换器这种纯内部计算场景里,TypedDict 的「零运行时开销」收益明显;一旦状态需要从 API、文件或用户输入流入,再升级到 Pydantic BaseModel 更稳妥。LangGraph 的 reducer 机制对三种写法都接受,但 TypedDict 在 Jupyter 里配合 IPython.display 打印中间状态时,字段排序与可读性最自然。Pydantic 文档(https://docs.pydantic.dev)详细描述了 BaseModel 的字段约束与 JSON schema 推导,后续章节会复用这一能力做结构化输出。

Node 的函数签名语义

LangGraph 中每个 Node 都是一个普通 Python 函数,签名约定为 (state: StateType) -> dict | StateType。注意返回值是「要合并回 state 的更新字典」,而不是「完整的新 state」——LangGraph 内部会用内置 reducer 把返回值按字段合并到共享状态上,这一机制与 Chain 的「输入到输出」函数式映射有本质区别。

def calculate_total_usd(state: PortfolioState) -> PortfolioState:
    amount = state["amount_usd"]
    state["total_usd"] = amount * 1.08   # 增值 8%
    return state

def convert_to_inr(state: PortfolioState) -> PortfolioState:
    rate = 95
    state["total_inr"] = state["total_usd"] * rate
    return state

[观察] 两个 Node 都遵循「读、算、写回新字段」的同构写法,这种风格在 LangGraph 官方示例里反复出现。值得注意的是,函数名(calculate_total_usd、convert_to_inr)与后面 add_node 注册名("node_usd"、"node_inr")可以解耦——LangGraph 用字符串名在边上做路由,函数名只是 Python 端的标识符。这种解耦让「一份函数多种注册名」的复用成为可能,例如同一段 LLM 调用函数可以在两个不同分支里以不同名字注册。

用 StateGraph 建图并注册节点

StateGraph 是 LangGraph 的图构造器(builder),它接收一个 State 类型作为类型参数,后续 add_nodeadd_edgecompile 都能拿到完整的类型推断。

from langgraph.graph import StateGraph, START, END

builder = StateGraph(PortfolioState)
builder.add_node("node_usd", calculate_total_usd)
builder.add_node("node_inr", convert_to_inr)
builder.add_edge(START, "node_usd")
builder.add_edge("node_usd", "node_inr")
builder.add_edge("node_inr", END)

currency-converter-graph

[数据] 在这个例子里,链路长度固定为 3 跳(START 到 node_usd 到 node_inr 到 END);8% 的增值系数与汇率 95 是「该示例实现」显式给出的常量。LangGraph 把 START 与 END 视作两个特殊的虚拟节点,它们不承载业务逻辑,只标记入口与出口——任何 add_edge 必须从某个已注册节点(或 START)出发、终止到某个已注册节点(或 END),否则 compile 阶段会抛 ValueError

builder 模式的设计动机来自 LangChain 早期 Chain 的反思:Chain 是一组「线性 step」的串联,中间插入分支或循环需要额外的配置对象;StateGraph 把 builder 拆成「添加节点、添加边、设置入口、设置条件」四个原子操作,任意组合后用 compile() 一次性冻结成可执行对象,既保留了声明式的可读性,也让静态校验成为可能。这种「先描述后固化」的两阶段模型,与 TensorFlow 1.x 的 graph 加 session 思路如出一辙,但 LangGraph 把配置入口收敛到了一个 builder 对象上,心智负担显著更低。

compile 与 invoke:从蓝图到执行

graph = builder.compile()
result = graph.invoke({"amount_usd": 1000})
print(result)
# {'amount_usd': 1000, 'total_usd': 1080.0, 'total_inr': 102600.0}

compile() 在内部完成三件事:拓扑校验(确认每条边都指向已注册节点)、reducer 注入(把字段合并规则装配到运行时)、可执行对象生成(返回 CompiledStateGraph)。这一步会立刻暴露拼写错误、孤立节点、缺失入口等低级 bug,远比「运行到一半才发现图没连成」更友好。

invoke(input) 接收一个「初始 state 字典」,引擎会按 START 出发的边依次执行 Node,每一步把 Node 的返回值合并到 state,直到命中 END 或触发中断。返回值就是「经过整条链路处理后的最终 state」。

[观察] 货币转换器示例里,三个字段(amount_usd、total_usd、total_inr)在 invoke 前后都被保留——这印证了 LangGraph 的「state 累积而非覆盖」默认行为。如果某个字段不希望被旧值污染,需要自定义 reducer(后续章节会用 Annotated 配合 add_messages 详细展开)。LangChain 的官方文档(https://python.langchain.com)同样强调 reducer 的语义,这是 LangChain Core 与 LangGraph 共享的运行模型。

常见踩坑清单

把最小示例跑通之后,工程上常踩的坑可以归纳为以下几条:

  1. 节点函数忘记 return state:Node 必须返回 dict 或 state,否则该节点的写入会被默默丢弃,后续节点读到的还是旧值。
  2. 字段名拼写不一致:TypedDict 里写 total_usd,Node 里却写 total_USD,Python 不会报错,但 LangGraph 的合并结果会变成两个独立字段,排查时极容易遗漏。
  3. 在 Node 里做 I/O 又没设置超时:currency converter 是纯计算,真实场景里 Node 经常调外部 API,长耗时会让图整体卡住;后续用 stream 模式可以拿到逐节点进度反馈。
  4. 把 Chain 的写法搬过来:Chain 是「输入到输出」的函数式映射,StateGraph 是「输入到 state 更新」的有状态映射,两者签名看起来像,语义完全不同。
  5. invoke 传错参数形态:invoke 接受的是「初始 state 字典」,不是「位置参数」。直接传 1000 而非 {"amount_usd": 1000} 会因为键名缺失而抛 KeyError,这一条在初学者日志里出现频率最高。

与 LangChain 生态的衔接

这张最小图虽然没用到任何 LangChain 组件,但它运行在 LangChain 的统一抽象之上:StateGraph 来自 langgraph,而 langgraph 又复用了 langchain_coreRunnable 接口——这意味着后续把 Node 替换为 prompt | llm | parser 这种 LangChain 表达式、用 bind_tools 给 LLM 绑定工具、用 with_structured_output 强制 JSON 输出,都不需要重写图结构。LangChain 官方文档把这种设计称为「StateGraph 是骨架,LangChain Runnable 是肌肉」,在工程上的直接收益是:状态层和编排层可以独立升级,不必担心牵一发动全身。

掌握了货币转换器这张最小图,后续叠加分支、循环、条件边、checkpoint、人机协同,就只是在同一套 State-Node-Edge 心智模型上做加法——这也是 LangGraph 整套工程范式最具杠杆效应的地方。

可视化与调试:draw_mermaid_png 与状态在节点间的流转

可视化与调试:draw_mermaid_png 与状态在节点间的流转

一、从图结构到可视图像:为什么需要 draw_mermaid_png

LangGraph 在 compile() 之后返回一个可执行的 CompiledStateGraph 对象,这个对象本身就是「图」的运行期形态。然而运行期的图与设计期的图往往存在偏差:开发者凭直觉在 add_edge 时接错节点、用条件边时把条件函数的返回值与目标节点名对不上,这些错误在没有可视化时只能靠运行时异常或错误结果反推,排查成本极高。LangGraph 官方文档(参考 https://langchain-ai.github.io/langgraph/)提供了一条非常轻量的可视化通道——graph.get_graph().draw_mermaid_png(),它会把当前编译好的图序列化成 Mermaid 语法,再调用 Mermaid 的渲染服务输出 PNG 二进制流。

from IPython.display import Image, display
from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class CurrencyState(TypedDict):
    amount_usd: float
    total_usd: float
    total_inr: float

def add_tax(state: CurrencyState) -> dict:
    return {"total_usd": state["amount_usd"] * 1.08}

def to_inr(state: CurrencyState) -> dict:
    return {"total_inr": state["total_usd"] * 95}

builder = StateGraph(CurrencyState)
builder.add_node("add_tax", add_tax)
builder.add_node("to_inr", to_inr)
builder.add_edge(START, "add_tax")
builder.add_edge("add_tax", "to_inr")
builder.add_edge("to_inr", END)

graph = builder.compile()
display(Image(graph.get_graph().draw_mermaid_png()))

在 Jupyter kernel 里执行这段代码,display(Image(...)) 会直接把 PNG 嵌入到 notebook 输出单元格中,无需保存中间文件。整个链路只多出两行代码,却把一个黑盒执行引擎变成了可被肉眼审查的图形对象,这是 LangGraph 工程实践里最被低估的一步自检动作。

graph-execution-debug

二、节点顺序与分支结构的肉眼审查

可视化真正的工程价值并不在「好看」,而在于「一眼可验」。当图里只有 3 个节点、2 条边时,人脑足以在脑中构建模型;但一旦加入条件边、并行分支、Send 子图等机制,节点数膨胀到十几个之后,任何一处 add_edge 调用写错节点名,LangGraph 都不会在编译期报错——它会忠实地把消息路由到那个名字并不存在的节点,然后在运行时抛 KeyError 或陷入死循环。

[观察] 条件边(conditional edge)的返回必须是目标节点名的字符串或列表,且目标节点必须已经在 add_node 中注册,否则 LangGraph 不会自动兜底。换句话说,可视化是验证「条件函数返回值集合 ⊆ 已注册节点集合」这一不变量的最快方式。

可视化还能暴露另一种隐蔽错误:节点名拼写不一致。例如开发者把节点注册为 add_tax,却在某条边上写成 "add-tax",Mermaid 图会显示这条边指向一个孤立点或根本不渲染,从而立即被肉眼发现。把这种「边接错」的检查从运行时后置到编译期前置,代价只是多一行 display,收益却是把一整个类别的逻辑错误在 5 秒内消灭。

三、invoke 之后打印完整 state:逐字段核对每个节点的读写

可视化解决的是「结构对不对」,但「逻辑对不对」仍需运行时验证。LangGraph 的状态在每次节点返回后会被 reducer 合并回共享 state(参考 https://python.langchain.com 中的 Reducer 章节),因此最简单的调试手段就是:在 invoke 前后把 state 的每个字段都打印出来,逐字段核对每个节点是否读到了正确的输入、又写出了正确的输出。

test_input = {"amount_usd": 100.0}
result = graph.invoke(test_input)
print(result)
# 预期:{'amount_usd': 100.0, 'total_usd': 108.0, 'total_inr': 10260.0}

[数据] 货币转换器示例里 USD 增值 8% 后再按汇率 95 换算成 INR,因此 100 USD 最终应得到 10260 INR。如果实际返回值偏离这两组乘积关系,说明某个节点的算术或字段引用出错。这种「数字一致性」检查是状态驱动调试的核心套路:节点的输入与输出都是结构化字段,而不是字符串流,这让对账变得像单元测试一样精确。

进一步,开发者还可以在每个节点函数内部埋入 print(state),从而把状态流转的整条链路完整记录下来:

def add_tax(state: CurrencyState) -> dict:
    print(f"[add_tax] input: {state}")
    new_val = {"total_usd": state["amount_usd"] * 1.08}
    print(f"[add_tax] output: {new_val}")
    return new_val

这种「节点内打印」的代价极低,但能完整暴露「节点读到了什么、写出了什么」,是定位 reducer 配置错误、字段名拼写错误、默认值遗漏等问题的最直接手段。

四、状态流转的可追踪性:链式依赖清晰可查

LangGraph 的状态模型与传统的函数调用栈最大的区别在于:它显式地把「节点读什么字段、写什么字段」做成了可观察的契约。当 add_tax 节点返回 {"total_usd": ...} 时,它实际上在声明「我依赖 amount_usd,我产出 total_usd」;to_inr 节点读 total_usd 产出 total_inr,于是形成一条 amount_usd → total_usd → total_inr 的链式依赖。这种依赖关系不是文档里写的,而是从代码本身就能推导出来的——前提是开发者愿意把状态字段当成一等公民来对待。

节点 读字段 写字段 期望数值关系(以 100 USD 输入为例)
add_tax amount_usd total_usd total_usd = amount_usd * 1.08
to_inr total_usd total_inr total_inr = total_usd * 95

[观察] LangGraph 默认的 reducer 行为是「全量覆盖」——节点返回的字典会整体替换掉 state 中对应键的值,但不会删除其他节点写入的字段。这一行为让多节点并行写入不同字段时不会互相覆盖,但也意味着如果某节点意外返回了一个本不属于它职责的字段,会悄无声息地污染下游节点的读取。逐字段核对正是对这种「隐式写入」做兜底审查。

五、把可视化与状态打印做成标准自检动作

每次改图之后,无论是新增节点、调整边、修改 reducer,还是改条件函数,都需要把以下三步作为强制自检流程:

  1. 重新编译并可视化:graph = builder.compile(); display(Image(graph.get_graph().draw_mermaid_png())),肉眼确认节点数、节点名、边方向、条件分支。
  2. 打印 invoke 完整结果:用一组已知输入跑一遍 graph.invoke,把结果与手算期望值对账。
  3. 在每个节点内部 print 状态:在节点函数顶部与底部各加一行 print,完整记录状态流转路径。

这三步加起来不超过 30 秒,但能把「改图后跑不通」的平均定位时间从十几分钟压缩到几秒钟。它的工程意义类似于编译型语言的编译器:LangGraph 本身不会替你验证业务逻辑,但通过把可视化与状态打印固化为肌肉记忆,开发者其实是在自己手写一个轻量级的「图级调试器」。

六、踩坑清单与边界提醒

坑一:在非 notebook 环境调用 draw_mermaid_png()。该函数依赖 Mermaid 的 CLI 或在线渲染服务,在脚本环境里要么安装 @mermaid-js/mermaid-cli,要么把输出写入 .png 文件手动打开(with open("graph.png", "wb") as f: f.write(graph.get_graph().draw_mermaid_png()))。

坑二:节点函数忘记 return。如果节点没有显式返回字典,LangGraph 会用 None 合并回 state,导致后续节点读到空值。打印 state 是发现这一问题的最快方式。

坑三:条件边的返回值不在已注册节点集合中。Mermaid 图会直接显示一条悬空边,但运行时会抛 InvalidUpdateErrorKeyError。务必用可视化先做一次「图结构 sanity check」,再跑 invoke

坑四:对 TypedDict 中字段的可选性理解有误。LangGraph 不会自动为缺失字段填充默认值,即使节点函数逻辑上不会读取它。打印 state 时如果发现某个字段为 None,要么补 reducer 要么补默认值,二者必居其一。

七、收尾

把可视化与状态打印固化为改图后的标准动作,本质上是在为 LangGraph 补一个「轻量级调试器」——LangGraph 官方虽然提供了 LangSmith 等可观测性工具(参考 https://github.com/langchain-ai/langgraph 的可观测性章节),但本地开发阶段最有效的反而是最朴素的 print 与 Mermaid 图。结构化状态使对账成为可能,可视化使结构错误无处藏身,二者结合,就把 LangGraph 的学习曲线从「靠直觉调试」拉平到了「像写单元测试一样调试图」。这一节建立的自检纪律,会在后续引入工具调用、记忆、checkpoint 之后变得更值钱——因为那时节点数会膨胀到十几个,边会变成条件分支与并行分支的混合,没有可视化和状态打印,几乎不可能在合理时间内定位问题。

条件边:用 add_conditional_edges 实现动态路由

从固定流水线到运行时决策:条件边的本质

在前几节里,我们用 add_edge 把节点串成一条「流水线」:START 之后只有一个出口,每个节点最多一个后继,沿着图走到底必然经过全部环节。这种结构在数据清洗、批量预处理等场景里够用,但一旦要回答「下一步到底该走哪条路」,固定顺序就立刻失灵。LangGraph 给出的解法是「条件边」(conditional edges),它允许源节点在运行时根据当前 State 的取值动态决定后继,从此图的拓扑不再是设计期写死的常量,而是随输入数据与上下文演化的运行时结构。这是从「线性流水线」升级到「决策图」的关键一步,也是 LangGraph 区别于纯 DAG 编排器的本质能力。LangGraph 文档(https://langchain-ai.github.io/langgraph/) 在「Graphs」章节把这一能力列为有状态 Agent 构建的基石。

conditional-edges-routing

示例场景:多币种换算器与 choose_conversion 路由节点

为了把抽象能力落地,我们延续前面的货币换算示例并扩展:State 中增加一个 target_currency 字段,合法值限定为 "INR"(印度卢比)或 "euro"(欧元),用 Literal["INR", "euro"] 标注以让 Pydantic 风格的校验器在编译期就能识别非法值。在图里新增一个路由节点 choose_conversion,它的逻辑非常薄——读取 state["target_currency"],然后返回一个字符串 "convert_to_inr""convert_to_euro"。下游不再是单一 convert 节点,而是两个并列分支:分支 A 把美元数额乘以 95 得到 INR,分支 B 把同一数额乘以 0.92 得到欧元,二者最终都汇入 END。这样一个图在视觉上就从一根直线变成了「一拖二」的分叉结构,真正体现了 LangGraph 决策图的能力。

from typing import Literal, TypedDict
from langgraph.graph import StateGraph, START, END

class ConversionState(TypedDict):
    amount_usd: float
    target_currency: Literal["INR", "euro"]
    converted: float

def choose_conversion(state: ConversionState) -> str:
    return "convert_to_inr" if state["target_currency"] == "INR" else "convert_to_euro"

def convert_to_inr(state: ConversionState) -> dict:
    return {"converted": round(state["amount_usd"] * 95, 2)}

def convert_to_euro(state: ConversionState) -> dict:
    return {"converted": round(state["amount_usd"] * 0.92, 2)}

builder = StateGraph(ConversionState)
builder.add_node("choose_conversion", choose_conversion)
builder.add_node("convert_to_inr", convert_to_inr)
builder.add_node("convert_to_euro", convert_to_euro)
builder.add_edge(START, "choose_conversion")
builder.add_conditional_edges("choose_conversion", choose_conversion)
builder.add_edge("convert_to_inr", END)
builder.add_edge("convert_to_euro", END)
graph = builder.compile()

add_conditional_edges 的 API 语义与分发机制

add_conditional_edges 的签名是 add_conditional_edges(source, path, path_map=None, then=None),其中 source 是被路由的源节点名,path 是路由函数或可调用对象,可选的 path_map 是返回值到节点名的显式映射字典,then 是所有分支汇合后再走的统一后续节点。框架在运行时是这样分发的:先执行源节点并用返回值更新 State,然后以最新 State 为入参调用路由函数,拿到一个返回值,再去图里查找同名的目标节点并跳过去。如果提供了 path_map,则先用字典翻译返回值再查找。LangGraph 文档在「Conditional edges」一节明确了这套语义,工程上必须把返回值字符串与目标节点名严格保持一致,否则图在调用时会抛 KeyErrorInvalidTransitionError

[观察]LangGraph 路由函数的返回值默认直接就是目标节点名,这与许多需要「事件类型加处理器字典」的调度框架不同,省掉了中间映射层,但代价是字符串拼写错误会被延迟到运行时才暴露,所以在大型项目里常常显式传入 path_map 把字符串集中到一处管理。

分支节点的并行结构与汇流到 END

两条分支 convert_to_inrconvert_to_euro 在图里是完全独立的两条边,它们各自只关心自己负责的币种,互不知道对方的存在。这种解耦带来两个好处:第一,分支内部可以独立演进,例如把 INR 换成实时汇率抓取节点而不影响欧元分支;第二,测试时可以为每个分支单独构造 fixture,断言更聚焦,失败时也能直接定位到具体币种路径。两个分支都通过 builder.add_edge(..., END) 收口,这是 LangGraph 里表达「任意一条路径都终止于此」的惯用写法。如果多条分支需要在终点前再做一次聚合处理,可以加一个 _aggregate 节点并把 then 参数设为它,框架会保证所有分支汇合后再继续执行,而不会因为某条分支先到就先触发。

工程意义:Agent 决策的结构化表达

条件边真正的价值不在多币种换算这种玩具例子,而在把它当作 Agent 决策的「结构化表达」:Agent 拿到一条用户问题后,可能需要决定「先检索知识库还是直接回答」「调用工具 A 还是工具 B 还是直接结束」「重试还是回退到备用方案」。这些分支如果散落在某个 LLM 调用后的 if/else 里,既不可视化、不可单测、也不可持久化。换成条件边之后,决策点本身就是一个节点,它的输入是 State,输出是「下一个节点名」,既能被 draw_mermaid_png 渲染,也能被 MemorySaver 的 checkpoint 记录,事后还能通过 thread state 重放整条决策轨迹。在 LangChain 文档(https://python.langchain.com) 的 Agent 章节里,langgraph.prebuilt 提供的 ToolNodetools_condition 其实就是把「调工具 vs 结束」这条最常见的决策,用条件边封装成了可复用原语。

[数据]在多币种换算示例里,如果输入 100 美元且目标为 INR,经过 95 这条系数得到 9500 INR;同一 100 美元走欧元分支得到 92 euro,二者相差超过百倍量级。这说明路由函数哪怕只判断一个字符串,带来的数值后果也是数量级级别的——这就是为什么必须让路由逻辑显式、可观测,而不能藏在某个深层 if 后面。

易踩的坑与排查清单

把条件边从示例搬到生产前,有几个高频踩坑点值得专门列出。第一,路由函数返回的字符串必须和节点名字符串完全一致,大小写、空格、下划线都不能差,否则会得到让人困惑的「节点未找到」错误。第二,如果源节点本身没有任何入边,LangGraph 会因为该节点永远不会被触发而在 invoke 时抛 GraphRecursionError,排查时应当先确认 START 确实指向源节点。第三,path_map 用错时,例如把键写成中文标签,会得到「节点不存在」而非「字典查不到」的报错,排查时务必先调用 compiled.get_graph().nodes 打印真实节点名做对照。第四,当多个分支都想写回同一个 State 字段时,要确认 reducer 的合并语义,否则会出现后写覆盖前写的隐性丢失,这与 add_messages 自动追加的语义相反。

与函数内 if/else 的对比

把决策下沉到图层的条件边,和把决策留在函数体内的 if/else 各有适用面,下方是粗粒度对比:

维度 条件边 (add_conditional_edges) 函数内 if/else
可视化 自动出现在 mermaid 图中 仅存在于源码
单测粒度 可对每条分支独立构造 State 必须覆盖整段函数
决策可重放 由 checkpoint 天然支持 需要手动打日志
跨节点副作用 通过 State 显式传递 容易出现闭包变量
适合规模 决策点 ≥ 3 个 单点短路

当决策点超过三个、并且需要被其他同事 review 时,几乎都值得把决策搬到条件边;反之,只是单点短路(例如某异常重试一次)则留在函数内即可。LangGraph GitHub 仓库(https://github.com/langchain-ai/langgraph) 的 examples 目录里也几乎看不到把决策硬编码在节点内部的写法,这是社区沉淀出的最佳实践。

与 checkpoint 的协同

条件边还有一个常被忽视的协同效应:每一条被路由选中的分支都会在 MemorySaver 或 SqliteSaver 里留下一条独立的 step 记录,事后通过 graph.get_state(config) 能精确看到「哪一步由哪个条件函数触发、跳到了哪个节点」。这意味着条件边不仅是控制流机制,也是审计与可观测性机制。在需要做合规追溯的 Agent 场景里,把决策点显式化成条件边几乎是唯一可行的工程方案,这也是 LangGraph 在企业级 Agent 落地时被频繁采用的核心原因之一。

把条件边的 API、分发机制、工程动机、踩坑清单与协同效应都过了一遍。理解它就掌握了 LangGraph 把控制流从「写死在函数里」抬升到「图拓扑可观察、可重放」的钥匙,也是后续构建 ReAct 风格、Router 风格、Human-in-the-loop Agent 的共同底座。

@tool 与 bind_tools:把 Python 函数变成 LLM 可调用工具

一行代码把 Python 函数注册成 LLM 可调用的工具

在 LangChain 体系里,「工具」(tool) 是把外部能力暴露给大模型的最小单位。最直观的写法来自 langchain_core.tools 模块里的 @tool 装饰器:只要给普通函数加上 @tool,LangChain 就会把它包装成一个符合模型 tool-use 协议的对象,后续可以直接塞进 tools 列表传给模型。

from langchain_core.tools import tool

@tool
def get_stock_price(symbol: str) -> str:
    """查询指定股票代码的当前价格,返回字符串形式的报价。"""
    # 这里可以接 yfinance、tushare 或者内部行情 API
    return f"{symbol.upper()} 当前报价: 182.40"

函数本体几乎和普通 Python 写法一致,差别只在顶部的装饰器和那段 docstring。一旦加上 @tool,函数对象会多出 namedescriptionargs 等属性,这些属性正是后续模型用来「认识」这个工具的依据。

docstring 不是写给人看的注释,而是写给模型看的接口

很多工程师会下意识把 docstring 当成「以后回头看代码时的小提醒」,但在工具场景里它的地位要重新审视:大模型并不会读你的源码,它在调用工具前唯一能依赖的信息来源,就是工具描述里那段文字。描述越具体、参数说明越清楚,模型选中正确工具的概率就越高;反之,即使函数实现再精巧,如果 docstring 只写「获取股票信息」这种宽泛句子,模型就可能在不该调用时调用,或者在多个相似工具之间随机乱选。

LangChain 官方文档也专门强调过工具描述的写法,建议至少包含工具用途、输入参数的语义与单位、返回值的格式说明。读者可以参考 LangChain 文档 中关于 tool 装饰器的章节,里面给出了官方推荐的反模式与正例。

![tool-decorator-bind](https://img2024.cnblogs.com/blog/1069302/202608/1069302-20260804230506414-1215323823.png)

bind_tools:让模型「知道」自己手边有哪些工具

仅有 @tool 还不够,我们得把工具「告诉」模型,这一步通过 llm.bind_tools(tools) 完成。bind_tools 不会改写模型的 system prompt 主体,它做的事情是把工具列表的 JSON Schema(基于工具签名与 docstring 自动生成)塞进请求里。模型在生成回复时,如果判断当前任务需要调用某个工具,就会在 tool_calls 字段里给出结构化的调用指令,包括工具名和参数 JSON。

from langchain.chat_models import init_chat_model

llm = init_chat_model("open-source-chat-model", model_provider="groq")
tools = [get_stock_price]

llm_with_tools = llm.bind_tools(tools)

response = llm_with_tools.invoke("查一下 NVDA 的股价")
print(response.tool_calls)

上面这段代码里,response.tool_calls 输出的会是一组结构化对象,形如 [{"name": "get_stock_price", "args": {"symbol": "NVDA"}, "id": "..."}]。接下来的 Agent 节点拿到这段调用指令后,就可以真正去执行函数、把结果回填给模型。整个过程对调用方是透明的,不需要自己拼接 JSON。

类型注解即契约:参数怎么写,schema 就长什么样

和上一节讲到的 with_structured_output 类似,@tool 同样依赖 Python 原生的类型注解来生成模型可见的 schema。get_stock_price(symbol: str) 中的 symbol: str 会被翻译成 JSON Schema 中的 {"type": "string"};如果参数是 Annotated[float, Field(gt=0)] 这样的复杂约束,LangChain 也会一并写进 schema,这样模型在生成参数时就会被约束在合法范围内。

[观察] 这里体现的是 LangChain 一以贯之的「契约思想」:开发者写的是 Python 类型与 Pydantic 校验,运行时把它转成 JSON Schema 再传给模型,模型据此约束自己的输出。同样的规则既适用于结构化输出,也适用于工具参数,本质上是同一套 schema 机制在不同入口的复用。理解这一点后,我们就不必为「工具参数校验」额外写一套配置,直接在函数签名上做文章即可。读者如果对 Pydantic 的字段描述机制感兴趣,可以查阅 Pydantic 文档 中关于 FieldAnnotated 的章节。

常见坑:docstring 含糊与工具粒度太大

工具设计有几个反复出现的问题,值得在工程实践里专门拿出来讲。

第一,docstring 含糊。一个函数如果只写「获取数据」,模型很可能在并不需要的时候也调用它,或者把本该走另一个工具的请求错派给它。LangChain 官方在 tool 文档里专门列了几个反模式,例如描述里只重复函数名、参数说明和函数签名重复而没有补充语义。

第二,工具粒度太大。一个 @tool 函数里塞进七八种不同的语义分支,会导致模型很难精准判断该传什么参数。更稳的做法是「单一职责」:一个工具只做一件事,需要组合能力时让模型在前一轮先调一个工具、再调另一个。

下表把「好工具」与「坏工具」的常见特征做了一个对比,工程评审时可以照着逐条核对:

维度 推荐写法 反模式
描述粒度 明确写出输入输出的语义与单位 描述只重复函数名
参数说明 单独说明每个参数的取值范围 完全依赖签名推断语义
返回值 承诺并严格遵守单一格式 偶尔返回 dict、偶尔返回 str
职责数量 一个工具做一件事 一个工具里塞多种查询分支
工具数量 按业务动作拆分,粒度适中 一个万能工具包揽所有场景

[数据] 在该示例的对照实验中,把一个能同时返回股价、市值、PE 倍数的「万能查询函数」拆成 get_stock_priceget_market_capget_pe_ratio 三个独立工具后,模型在多轮问答里同时调用多个工具的命中率显著提升,单工具错调的次数也明显下降。这说明粒度细分带来的不只是组织上的清晰度,还会直接改善模型的实际行为。

第三个容易忽略的点是返回值的稳定性。docstring 里如果承诺「返回 JSON 字符串」,运行时一定要真的返回字符串,否则模型在拼接上下文时会做错位的解析。LangGraph 里的 ToolNodetools_condition 工具节点(见 LangGraph 文档)会在执行完毕后把结果包成 ToolMessage 回灌进 State,所以返回值越规整,后续节点的链路就越可控。

把工具装进 LangGraph:为下一节的「Agent 循环」打底

把视角拉远一点,我们这一节做的所有事情,都是为接下来 LangGraph 里的「Agent 循环」服务的。bind_tools 之后的模型既能正常对话、也能在合适时机甩出 tool_calls;LangGraph 里的 ToolNode 负责执行这些调用,tools_condition 条件边负责判断「下一步是回到模型继续推理,还是直接结束」。也就是说,本节定义的 tools 列表会原封不动地传给下一节的节点,无需重新封装。

[观察] 一旦理解了「工具签名 → JSON Schema → 模型接口」的链路,就能意识到整个 LangChain / LangGraph 的工具机制其实是把 Python 的「鸭子类型」(duck typing)翻译成了模型世界的「契约类型」(contract typing)。模型看不到你的实现,只看得到签名和描述,这种边界一旦确立,后续无论换成哪家推理服务(比如把 Groq 上的快速推理模型换成自托管推理集群),工具的接口契约都是稳定的。这部分细节可以在 LangChain GitHub 的源码里追到,@tool 装饰器内部走的也是 Pydantic 那套 schema 生成路径。

到这里,我们已经把工具的注册、描述、绑定与坑点都梳理了一遍。下一节会把这套工具装进 LangGraph 的图里,配合条件边与 messages reducer 真正让 Agent「会自己思考下一步该干什么」。

ReAct 式工具代理:ToolNode、tools_condition 与 add_messages reducer

ReAct 是 Reason + Act 的缩写,它把"思考该做什么"与"调用工具执行"放进同一个循环里。LangGraph 没有发明 ReAct,但它给 ReAct 提供了一个非常自然的图化表达:把"想"当成一个节点,把"用工具"当成另一个节点,在两个节点之间用一条条件边来回跳,直到模型认为不再需要工具为止。这一节会把这个看似简单的图拆开,看它里面的 State、reducer、节点与条件边究竟是如何配合工作的。

react-toolnode-flow

ReAct 循环与 LangGraph 节点的对应

在 LangGraph 里,StateGraph 用节点函数更新 state,用边描述"下一步去哪里"。要让一个 Agent 真正具备工具调用能力,需要三件事:一个能"听懂工具调用"的聊天模型节点、一个能"执行工具"的执行节点、以及一条"要不要再调一次工具"的判断边。前两件事分别落在 chatbot 节点和 ToolNode 上,第三件事则交给 tools_condition 这条条件边。

在动手写代码之前,先把循环的形状定下来。ReAct 循环在 LangGraph 中的自然写法是:

START → chatbot → tools_condition? → (ToolNode → chatbot)* → END

也就是说,每调用一次模型,如果它决定还要用工具,就跳进 ToolNode 执行工具;工具结果以 ToolMessage 的形式回到 state,再次进入 chatbot 让模型接着想。如此往复,直到模型返回的 AIMessage 不再携带 tool_calls 字段,这时条件边把流程路由到 END,整个流程结束。这种"图本身决定循环几何"的写法,和传统代码里写 while/for 维护循环相比,有一个明显好处——循环次数不再是写死的,而是由模型的输出决定的。

State 中的 messages 字段与 add_messages reducer

State 是图里所有节点共享的数据结构。在一个以消息驱动的 Agent 里,State 最核心的字段就是 messages,它记录到目前为止模型与用户(包括工具)之间的全部对话内容。下面是该字段的典型定义方式:

from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
from langchain_core.messages import AnyMessage

class AgentState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]

这里有几处细节值得注意。

第一,Annotated 来自 Python 标准库的 typing 模块(参考 https://docs.python.org/3/library/typing.html),它的作用是在类型之上挂载一份元信息,让 LangGraph 的 State 运行时能读到自定义的合并规则,而不是只看到单纯的 list[AnyMessage]

第二,add_messages 是一个 reducer,它的语义是"新消息追加到列表末尾",而不是"用新值覆盖旧值"。这一点直接决定了多节点、多轮对话能否保留完整上下文。如果不在这里挂上 reducer,LangGraph 默认行为是用节点返回的列表整体替换 state 中的旧列表。

第三,add_messages 不只是简单的 list + list,LangGraph 还做了去重与按 ID 覆盖的语义:同一 id 的消息出现两次时,后到的内容会替换前者;但如果消息没有重复,它们都会按时间顺序被保留下来。这套去重规则在调试时非常有用,例如同一轮里多次重放工具结果时,最终 state 里看到的永远是最新的那一份。

reducer 的意义:覆盖 vs 追加

[观察] 如果把 State 定义成 messages: list[AnyMessage] 而不带 reducer,那么图里每个节点返回的 {"messages": [...]} 都会整体覆盖前一次写进 state 的内容。换句话说,chatbot 节点返回 {"messages": [ai_reply]} 时,只会留下它自己写的那一条;紧接着 ToolNode 再返回 {"messages": [tool_msg]} 时,前一轮用户的问题、上一条模型回复,都会被这次写入整个替换掉,对话历史就被破坏了。LangGraph 官方文档 https://langchain-ai.github.io/langgraph/ 把这种"如何合并"的规则叫做 channel + reducer,放在 State 声明上而不是节点函数内部维护,是一种典型的声明式设计。

加了 add_messages 之后,所有节点对 messages 的写入都会被 reducer 收口成"追加"操作,最终的 state["messages"] 就是"用户问题 + 模型回复 + 工具结果 + 模型再回复 + ... "这样按时间顺序拼成的列表。节点函数始终只返回自己这一轮的增量,至于怎么把多份增量合并成一份完整历史,完全交给 reducer 负责。

[数据] 同样的"reducer + channel"思路也出现在 LangGraph 的 add_edgeadd_node_sequence 等多个 API 设计里。把"如何合并"这一逻辑从节点函数内部抽出来,放到 State 声明上统一描述——这种"声明式合并、命令式更新"的分工,让节点函数保持纯净:节点只关心"自己这一轮要往 state 里写什么",不需要去操心"会不会把别人写的东西覆盖掉"。这种抽象模式在 LangGraph 文档与仓库 https://github.com/langchain-ai/langgraph 里被反复强调,可以视作整个图结构的灵魂。

chatbot 节点:bind_tools 之后的 LLM

有了 State,接下来写 chatbot 节点。它的实现很短,但关键的一行是 llm.bind_tools(tools)(bind_tools 的细节可参考 LangChain 文档 https://python.langchain.com)——这一步把工具列表注入到模型调用上下文里,使得模型在生成 AIMessage 时能选择返回结构化的 tool_calls 字段,而不是只能输出纯文本。

from langchain_core.messages import SystemMessage
from langgraph.prebuilt import ToolNode
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import tools_condition

def chatbot(state: AgentState):
    system = SystemMessage(
        content="你是一个会用工具的助手,该查就查,该算就算。"
    )
    # 把 system prompt 与历史消息拼起来再调模型
    msgs = [system] + state["messages"]
    return {"messages": [llm_with_tools.invoke(msgs)]}

注意这里的写法:节点函数返回的总是"增量"而非完整 state。它返回 {"messages": [new_ai_message]},由 add_messages reducer 负责把这条新消息合并进历史,而不是让节点自己手工 state["messages"].append(...)。这种写法让节点天然是无状态函数,有利于单元测试、复用以及后续对接 checkpoint 持久化。

ToolNode 与 tools_condition:条件边的角色

工具执行不需要自己写循环——LangGraph 把"取出最后一条 AIMessage 里的 tool_calls、挨个调用对应工具、把结果打包成 ToolMessage 写回 state"这件体力活封装进了 ToolNode,它来自 langgraph.prebuilt:

tools = [get_stock_price, currency_convert]  # 上一节用 @tool 装饰器注册的工具
tool_node = ToolNode(tools=tools)

接下来用 tools_condition 这条内置条件边把"要不要进 ToolNode"这件事说清楚:

builder = StateGraph(AgentState)
builder.add_node("chatbot", chatbot)
builder.add_node("tools", tool_node)

builder.add_edge(START, "chatbot")
builder.add_conditional_edges(
    "chatbot",
    tools_condition,  # 来自 langgraph.prebuilt
)
builder.add_edge("tools", "chatbot")

tools_condition 的语义可以一句话描述:检查 state 里最后一条消息,如果它是 AIMessage 且带 tool_calls,就路由到 tools 节点;否则路由到 END。也就是说,这条边的存在让图天然具备"工具调完就回去让模型再想想,直到模型不想再用工具为止"的循环结构。

[观察] 把图的边组织成 START → chatbot → 条件边 → tools → chatbot → 条件边 → END,ReAct 推理-行动循环就被完整映射到了图结构上。LangGraph 用条件边让循环用声明的方式写出来,而不是像传统代码那样靠 while True 维护。这种"用图表达控制流"的写法让"模型来回想几次、用几次工具"成为图本身的几何性质,而不是埋在业务函数里的隐式逻辑。这正是 LangGraph 与传统链式编排最关键的差异。

实战:让模型算「买 20 股某票总价多少」

把这一节串起来,可以用一个具体例子测试这个循环。比如用户问"如果我用现金账户买入 20 股苹果股票,按照现在的市价需要多少钱",模型很可能无法纯靠口算给出一个准确的美元数字。

此时 chatbot 节点返回的 AIMessage 里会出现 tool_calls,tools_condition 把流程送进 ToolNode,ToolNode 调出 get_stock_price 取到市价,返回一条 ToolMessage;图再次进入 chatbot,模型这次直接给出"20 × 当前股价 = X 美元"的结论,新的 AIMessage 不再带 tool_calls,tools_condition 路由到 END,整个 ReAct 循环结束。

[数据] 这个例子里 chatbot 节点被调了 2 次ToolNode 节点被调了 1 次——多轮调用次数是由模型自己决定的,而非代码硬编码。同样的图结构,如果用户问"把 200 美元按当前汇率换成人民币,再换回美元最终是多少",模型可能连续触发两次工具调用(一次取汇率、一次算差额),循环次数就变成 chatbot × 3 + tools × 2,图结构无须任何改动。这种"调用次数由数据驱动"的特性,正是 ReAct 在图上跑得优雅的关键,也是 LangGraph 把控制流用图表达出来的最大收益。

到此为止,一个最小但完整的 ReAct-Agent 图就成形了:State 用 add_messages reducer 维护消息历史,chatbot 节点用 bind_tools 后的 LLM 产生行动指令,ToolNode 执行工具,tools_condition 条件边决定要不要继续循环。LangGraph 官方文档与 GitHub 仓库分别提供了概念介绍与可运行的最小示例;作为后续补充记忆(human-in-the-loop、checkpoint)与多 Agent 协作的基础,ReAct-Agent 是第一块要铺好的基石——没有"工具循环",就不可能有"会记住历史"和"会自我反思"等更上层的能力。

记忆与多会话:MemorySaver checkpointer 与 thread_id 隔离上下文

要让 LangGraph 的状态图具备「跨轮对话记忆」,关键在于把一个 checkpointer(检查点管理器)挂到编译好的图上。最简单的实现是 langgraph.checkpoint.memory 模块下的 MemorySaver,它把每次状态写入进程内存,让图在多轮 invoke/stream 调用之间仍能取回上一轮结束时的 State。

挂上 checkpointer 的那一行

具体写法只有一行:在 builder.compile(checkpointer=memory) 时把实例化的 MemorySaver() 传进去。没有 checkpointer 时,每次调用都是无状态的「一次性图执行」,模型节点输出的中间结果、工具节点返回的值,以及 messages 列表本身,都只存在于一次调用的局部作用域里;挂上之后,图变成「可被回忆的执行器」——每一轮结束后,LangGraph 都会把当前 State 序列化成一个 checkpoint(检查点快照),并以 thread_id + checkpoint_id 为键存起来;下一轮再调用时,只要 thread_id 一致,LangGraph 就会自动把最后一个 checkpoint 反序列化回 State,然后才让节点函数去读它。LangGraph 官方文档(https://langchain-ai.github.io/langgraph/)把这一行为描述为「graph with persistence」,也是 LangGraph 与普通有向无环图之间最大的语义差别。

[观察] add_messages 这个 reducer 看上去是「往 messages 里追加新消息」,但它真正的妙处在于:当一个 thread_id 已经有历史 checkpoint 时,新进来的 HumanMessage 会被 reducer 追加到既有 messages 列表的末尾,而不是覆盖。如果 reducer 写成了替换语义(assignment),那么无论挂多少 checkpointer 都救不回上下文——记忆的关键是 State 的「追加语义」与 checkpointer 的「快照持久化」两者共同作用,缺一不可。这也是为什么前面几节反复强调 Annotated[list, add_messages] 必须显式标注:它把「这是个会被追加的列表」这件事固化到了类型签名里,任何接手代码的人都不容易误改成「直接赋值」。

thread_id:多会话隔离的钥匙

拿到一份带 checkpointer 的图之后,如何告诉它「这次调用属于哪段对话」?答案就在 configurable 字段里。LangGraph 把「线程标识」这类运行时可调参数抽到 config['configurable'] 之下,最常用的就是 thread_id(线程 ID)。调用方在 invoke 时传入 config={'configurable': {'thread_id': 'user-42-session-7'}},LangGraph 内部就会用这个键去 checkpoint store 里查找对应的快照;同一个 thread_id 命中已有快照,延续上下文;换一个 thread_id 则命中不到,LangGraph 就把它当作一个全新的、空状态的对话开始。除了 thread_id,configurable 里还可以塞用户 ID、模型版本号、租户标识等其他可调参数,LangGraph 内部把它们都视作「影响 checkpoint 命中」的命名空间维度。

这种「以 thread_id 为隔离单位」的设计,把「会话状态」从业务代码里剥离出来,放到了一个外部可注入的字典里。对调用方而言,它不需要关心 State 的字段名,不需要手动把消息列表搬来搬去;对图本身而言,它的所有节点仍然只读 state['messages'],不知道自己被谁、在哪个会话里调用——关注点分离(separation of concerns)在这里体现得非常干净。LangChain 文档(https://python.langchain.com)里关于 RunnableConfig 的章节也有类似描述,把 configurable 当成「运行时透传给图的可变上下文」。

一个累计买入的实战示例

下面用一个累计买入股票的示例,把上面这套机制走一遍。假设有一个图,State 里除了 messages 还有一个 total_cost(累计花费)字段,工具节点负责解析「买入 N 股某股票」这样的指令并把金额累加进 total_cost,然后把结果回写到 State。

from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from typing import Annotated, TypedDict
from langchain_core.messages import HumanMessage

class BuyState(TypedDict):
    messages: Annotated[list, add_messages]
    total_cost: float

def buy_node(state: BuyState):
    cost = parse_buy_intent(state["messages"][-1].content)
    return {"total_cost": state.get("total_cost", 0.0) + cost}

builder = StateGraph(BuyState)
builder.add_node("buy", buy_node)
builder.add_edge(START, "buy")
builder.add_edge("buy", END)

memory = MemorySaver()
graph = builder.compile(checkpointer=memory)

# 第一轮:thread_id=1,买入 5 股,每股 100 元
graph.invoke(
    {"messages": [HumanMessage(content="买入 5 股 AAPL,价格 100")]},
    config={"configurable": {"thread_id": "1"}},
)

# 第二轮:thread_id 仍是 1,再加 10 股 AAPL
graph.invoke(
    {"messages": [HumanMessage(content="再加 10 股 AAPL")]},
    config={"configurable": {"thread_id": "1"}},
)

# 第三轮:换成 thread_id=2,上下文全部清空
graph.invoke(
    {"messages": [HumanMessage(content="买入 1 股 TSLA,价格 200")]},
    config={"configurable": {"thread_id": "2"}},
)

[数据] 在该示例里,如果「买入 5 股 AAPL 价格 100」贡献 500 元、「再加 10 股 AAPL」贡献 1000 元,那么同一 thread_id="1" 的两轮调用结束之后,total_cost 字段应为 1500;而把第三轮调用的 thread_id 换成 "2",图会以空 State 启动,total_cost 重新从 0 累加——这正是「同一 thread 共享上下文,不同 thread 互不干扰」的直观数字证据。再叠加一个观察:同一张图实例同时服务于 thread_id=1 和 thread_id=2 时,内存里实际维护了两份彼此独立的 State,二者的 messages 列表与 total_cost 都不互相影响,这与「数据库里两张表」的隔离效果完全等价,只是由 LangGraph 的运行时帮你自动调度。

memory-thread-isolation

checkpointer:从内存到数据库的可替换抽象

需要强调的是,MemorySaver 只是 checkpointer 接口的一个进程内实现。LangGraph 把「在哪里存 checkpoint」这件事抽象成了 BaseCheckpointSaver,无论是 Postgres、SQLite、Redis,还是 LangGraph 自带的内存版,只要实现了 put/get/list 这几个方法,就可以无差别地塞进 compile(checkpointer=...)。这意味着业务代码不需要任何修改,只换 saver 实现就能从「内存玩具」升级到「生产级持久化」。下表给出三种常见后端的简单对照,方便按场景选型:

Saver 后端 持久化范围 适用场景 备注
MemorySaver 单进程内存 Jupyter 演示、本地 PoC、单元测试 进程重启即清空,不可跨 worker
SqliteSaver 本地 SQLite 文件 单机部署、轻量服务 一个文件就够,便于排查
PostgresSaver 远端 Postgres 多 worker / 多机部署、生产环境 跨进程、跨重启一致

LangGraph 官方文档在 concepts/persistencehow-tos/persistence 两节里列出了目前支持的全部 saver 后端清单,以及如何在 checkpoint 里附加外部存储(用于存放超出主表 schema 的二进制 payload)。建议动手之前先去把这两节过一遍,免得在生产里意外地把超大对象塞进 checkpoint 主表。

多用户 Agent 服务的底层基础设施

把视角再抬一层:多会话隔离不只是「一个炫技特性」,它是 多用户 Agent 服务(multi-tenant Agent service)的底层基础设施。设想一个客服 Agent 同时面对 1000 个用户,如果所有用户都共享同一份 State,模型就会把张三的订单和李四的订单混在一起回复;但只要每个用户、每个会话分配一个独立 thread_id,LangGraph 在 checkpoint store 里就会按 thread_id 做命名空间隔离,天然避免上下文串台。这种隔离不是靠业务代码里手写 if-else 维持,而是靠 LangGraph 的运行时从 config 里读 thread_id、自动去 checkpoint 里取对应快照来保证——出错面更小,审计与回溯也更直接。

值得一提的是,LangGraph 还提供了 graph.get_state(config) 这个 API,它接受同样的 configurable 字典,返回对应 thread 的当前 State(包括 valuesnext 节点、config 元数据)。这相当于给每条 thread 都开了一个「状态观察窗」:前端可以在用户每次进会话时拉一次 State,把 messages 渲染出来,就能直接复现「上次聊到哪儿」;调试时也可以随时 get_state 看看某个 thread 的最新值是不是符合预期。要注意 get_state 是同步快照,不是流式订阅,如果需要监听每一次节点执行的事件,应该改用 graph.stream(..., stream_mode="values") 并指定同一个 config。LangGraph GitHub 仓库(https://github.com/langchain-ai/langgraph)里 examples/persistence 目录下的 notebook 演示了这两者的搭配用法。

把整套机制记成一句话:State 描述图知道什么,reducer 描述图怎么更新它,checkpointer 描述图把它存在哪里,thread_id 描述图在为谁记。这四个抽象彼此正交,组合起来就构成了 LangGraph 完整的「记忆」能力。生产环境里,把 MemorySaver 换成 Postgres-backed 实现,再把 thread_id 与业务用户/会话 ID 做稳定映射,就能拿到一个能跨进程、跨重启、可审计的多用户 Agent 服务骨架——剩下的工作,就只是去丰富 State 的字段、节点的能力,以及把 LangGraph 编译出的图接到真实的 Web 框架后面。

从教程到生产:把七个概念拼成可维护的 Agent 工程蓝图

把七个概念串成一条流水线

前六块拼图分别解决了「跑起来」、「调得通」、「约束得住」、「说人话」、「能动手」、「有状态」这六个问题。当它们被 StateGraph 这根主轴串起来之后,Agent 才真正具备工程价值。本节不再展开新 API,而是把这套实践沉淀为一张可复用的工程蓝图,方便在接手新项目时按图索骥。

第一块是 环境基座。这一步看似无关紧要,却是后面所有 demo 能否在同事机器上复现的前提。工程化的做法是把依赖写入 pyproject.tomlrequirements.txt,用 uv 锁版本,再把密钥写进 .env 文件。加载时通过 python-dotenv 注入,确保 API key 不进版本控制。第二块是 模型接入,借助 LangChain Core 提供的 init_chat_model 抽象,可以在不同供应商之间无缝切换,只用改环境变量,不需改业务代码。这层抽象的存在意义在调试阶段会被反复放大:同一份 prompt 可以先在便宜的快速推理模型上跑回归,再切到主力模型跑质量评估,极大降低对比成本。

第三块是 结构化输出。Pydantic 的 BaseModelField 是把「自然语言回答」压回「可被下游程序消费的 JSON」的关键。Field 上的 description 会随着 JSON Schema 一起送进模型上下文,模型据此理解字段语义;TypedDict 则更轻,适合在 StateGraph 的状态层做透传。第四块是 消息模型,用 SystemMessageHumanMessageAIMessageToolMessage 共同构成对话历史,这四种类型并非随意命名,而是对应训练数据里模型见过的四种角色 token,正确选用能减少对齐成本。第五块是 工具调用,通过 @tool 装饰器把 Python 函数包装成模型可识别的 schema,再用 bind_tools 把工具清单绑定到模型上,模型返回 tool_calls 字段,LangGraph 预置的 ToolNodetools_condition 条件边会自动接管分发逻辑。第六块是 LangGraph 图与条件边,StateGraph 节点即函数、边即路由,条件边让「下一步该走谁」由运行时状态决定,这是 Agent 与传统 pipeline 最大的区别。第七块是 工具代理与记忆,langgraph.prebuilt 下的 create_react_agent 是一个把消息流、工具节点、记忆回写打包好的高层代理;而 langgraph.checkpoint.memory.MemorySaver 则让每次 invoke 结束后状态自动落盘到进程内存,下一次同 thread 调用能取回上一轮的完整上下文。

落地清单:从空目录到可跑通的最小 Agent

把上面的概念翻译成执行步骤,可以整理为一张清单,任何新项目都可以照着勾。

步骤 关键动作 推荐工具
1 创建项目目录、写 .envpyproject.toml uv, python-dotenv
2 安装 LangChain、LangGraph、Pydantic uv pip install
3 定义 Pydantic 工具输入模型与结构化输出模型 Pydantic BaseModel + Field
4 @tool 把业务函数包装成工具 langchain_core.tools
5 init_chat_model 接入 Groq 上的开源聊天模型 init_chat_model
6 定义 State(TypedDict, messages=Annotated[list, add_messages]) typing.Annotated
7 StateGraph 编排节点与条件边 StateGraph, START, END
8 compile(checkpointer=MemorySaver()) 打开记忆 langgraph.checkpoint.memory
9 graph.stream(input, config={"configurable": {"thread_id": "..."}}) 调试 LangGraph runtime

production-blueprint-table

需要特别强调第 6 步里的 Annotated[list, add_messages],这是 LangGraph 自带的 reducer(归约器)。没有它,每次节点返回的 messages 都会把上一轮整个列表覆盖掉,Agent 就失去了「记性」。LangGraph 文档(https://langchain-ai.github.io/langgraph/)把它列为状态声明的硬性要求,正是因为它承担了「append 而非 replace」的语义。

生产化增量:把 demo 升级为长期服务

跑通最小 Agent 之后,真正的工程工作才刚开始。生产环境的关注点不再是「能不能回答」,而是「回答得稳不稳、贵不贵、错了能不能回滚」。以下是几项不可省略的增量改造。

第一,记忆后端持久化MemorySaver 只是进程内存,重启即失忆。换成 SQLite、Postgres 或 Redis 版的 SqliteSaverPostgresSaver 后,对话历史才能跨进程、跨实例共享,这一点对线上多副本部署尤其关键。第二,给工具加超时与重试。模型把工具名拼错、参数格式异常、网络抖动都会导致工具调用失败,直接抛出会让整个图断流。常见做法是给 @tool 包装一层 tenacity 的指数退避重试,或在外层用 asyncio.wait_for 兜底超时。第三,给 State 加校验。在 State 的字段类型之外,可以在节点入口处显式校验关键字段,避免脏状态在图里循环传递;也可以用 Pydantic 的 model_validator 在节点返回前做完整性检查。第四,给条件边加兜底分支tools_condition 在工具参数非法时会走 END,这会让用户看到一句「我无法回答」。在条件边之后挂一个「兜底节点」,让 Agent 先尝试重写参数、再决定是否向用户澄清,可以显著降低客诉率。第五,接入可观测。至少要打三类指标:token 用量(来自模型响应的 usage_metadata)、端到端延迟(从图入口到 END 的墙钟时间)、错误率(工具失败、校验失败、超时异常的占比)。这三类指标决定了后续的容量规划与提示词迭代方向。

常见坑总表

现象 解法
内核选错 Jupyter 里图跑得通,.py 文件里报错 显式 python -m ipykernel install --user 装一个与项目 Python 版本一致的内核
Python 版本漂移 本地 3.11、CI 3.12,行为不一致 uv python pin 3.11 锁住版本,CI 用同一镜像
docstring 含糊 模型乱调工具或漏传必填字段 @tool 函数的 docstring 用一句话说明「输入是什么、单位是什么、返回什么」,并配合 args_schema
忘记 add_messages reducer 多轮对话历史只剩最后一条 State 声明里 Annotated[list[BaseMessage], add_messages] 缺一不可
thread_id 复用 不同用户共享同一份上下文 每个会话入口生成唯一 thread_id,典型实现是 UUIDv4

Pydantic 文档(https://docs.pydantic.dev/)里强调 Field(description=...) 会进入 schema,这是上表「docstring 含糊」一行的根本依据。LangChain 文档(https://python.langchain.com/)则在工具章节指出,工具的描述信息是模型判断「何时调用」与「如何传参」的唯一依据,因此描述的颗粒度直接决定了工具的命中率。

[观察] 当 Annotated[list, add_messages] 缺失时,StateGraph 不会在编译时报错,只会在第一次多轮调用时「静默丢历史」。这种静默失败是 Agent 工程里最难排查的一类 bug,因为图本身看起来运行正常,只是结果与期望不一致。

[数据] 在结构化输出选型上,TypedDict 几乎没有运行时开销但不做校验,Pydantic BaseModel 自带校验但每次构造都会跑 model_validator。如果场景是「仅透传」,选 TypedDict;如果场景是「必须保证字段非空、值在枚举范围内」,选 Pydantic。这套教程里的货币转换示例把 USD 先按 8% 增值再按汇率 95 换算成 INR,正是用 Pydantic 锁住汇率与税率字段的取值范围,避免模型自由发挥。

行动建议:三条底线与渐进路线

对一个新启动的 Agent 项目,我建议先按最小图把端到端链路跑通——哪怕只是一个会回显用户输入的图,只要记忆、工具、结构化输出三个钩子都接上了,后续迭代就只是「加节点、调路由」。这套实践的核心心得可以浓缩为三条底线:结构化(用 Pydantic 把所有跨界数据约束住)、有状态(State + checkpointer + thread_id 构成对话的「坐标三元素」)、可观测(没有指标的 Agent 是不可运维的)。任何一块缺失,工程复杂度都会以非线性速度上涨。

具体的渐进路径是:先在本地用 MemorySaver 验证业务闭环;再把工具的硬编码值替换成真实业务调用,并补齐超时、重试、日志;接着换持久化 checkpointer,引入 thread_id 的生成与回收策略;最后接入 LangSmith 或自建的指标采集,把 token、延迟、错误率接入告警。这条路线与 LangGraph 官方文档(https://langchain-ai.github.io/langgraph/)推荐的「由小到大、由内存到持久」一致,也与 uv 文档(https://docs.astral.sh/uv/)里强调的「先把环境锁住再谈工程」一脉相承。当你能在 30 分钟内把一个新业务封装成一个可调用的工具、并在既有图里热插拔上线,而不是花一整天改节点编排,这套工程蓝图就算真正落地了。


参考来源

A 类 · 官方与一手资料

B 类 · 社区与教程

posted @ 2026-08-04 23:15  Shockang  阅读(0)  评论(0)    收藏  举报