Agentic AI 工程实战

Key Takeaways

  • LLM 聊天补全→RAG 增强→工具调用→单智能体→多智能体系统的五阶段演进路径,每一步解决前一步的什么局限
  • 自主性(autonomy)、反应性(reactivity)、主动性(proactiveness)、社会性(social ability)四大特征在工程上的具体表现
  • 智能体系统的 IO 特征:LLM API 调用秒级延迟、多工具并发、多会话并行,同步阻塞模型完全无法支撑
  • 智能体系统的数据可信问题:LLM 输出天然不稳定,不加校验的 JSON 解析是生产事故的头号来源
  • LangChain 的核心抽象:ChatModel、PromptTemplate、OutputParser、Runnable(LCEL 管道操作符)

从 LLM 到 Agentic AI:范式演进的完整脉络

大模型的"能用"与"好用"之间,还隔着一道工程鸿沟

过去两年,大语言模型几乎一夜之间进入开发者视野,Chat Completions 接口三行代码就能拼出一个会聊天的原型。但真正把模型搬进生产环境,工程师很快撞上同一道墙:模型会说话,不等于会办事。这道墙的存在,催生了从 LLM 到 Agentic AI 的整条演进路径,也是接下来整篇文章要拆解的工程基座。先把这张演进图摆出来,后面所有技术选型都会回到这张图上:

五阶段演进路径:每一步都在解决前一步留下的坑

这条路径不是线性的"取代",而是分层叠加------RAG 阶段写的检索代码,在多智能体时代依然存在,只是被包进了某个节点的内部。

阶段 工程形态 解决的上一阶段局限 新引入的代价
LLM 聊天补全 Chat Completions API + prompt ---
RAG 增强 检索器 + 向量库 + LLM 生成 知识截止、私域数据不可用 检索召回率与上下文窗口冲突
工具调用 function calling / tool use 模型只能"说",不能"做" 工具 schema 维护、错误传播
单智能体 ReAct / Plan-and-Execute 单次工具调用难以完成多步任务 上下文膨胀、错误难以隔离
多智能体 Supervisor / Swarm / 角色分工 单智能体能力瓶颈、职责混杂 协调开销、调试复杂度指数级上升

具体到每一个阶段背后的工程动机:LLM 阶段解决的是"有模型可用",RAG 阶段解决的是"模型知道得更多、更新得更及时",工具调用解决的是"模型能动手",单智能体解决的是"模型能分多步推理",多智能体解决的是"单一模型管不过来复杂流程"。每一步都把上一阶段的某个维度推到极限,然后撞上下一个工程瓶颈。

纯 LLM 的三大硬伤与 Agentic AI 的闭环结构

把镜头拉近,纯 LLM 直连生产有三个绕不开的硬伤。

第一,知识截止。模型的训练数据有明确截止线,任何截止线之后的新闻、产品变更、监管文件,模型都只能"装作知道"。这是 RAG 阶段要解决的核心命题,把外部知识库作为可检索的上下文注入 prompt,从架构层面绕过截止线。

第二,无法行动 。Chat Completions 接口返回的是字符串,模型无法直接读写数据库、调外部 API、操作系统文件。工具调用阶段引入 function calling 协议,本质是给模型开口子,让它把"想法"翻译成结构化的"调用请求"。LangChain 的 @tool 装饰器就是为了把这层协议工程化。

第三,无状态 。每次 invoke 都是一次独立请求,服务器不记得上一轮说过什么。要做对话产品,必须自己在客户端或服务端维护消息列表,这正是 LangGraph 引入 MessagesStateadd_messages reducer 想工程化解决的事。

Agentic AI 解决这三个问题的统一思路,是给模型装上感知-规划-行动-反思 闭环:感知阶段读取工具返回值、用户输入、当前 state;规划阶段基于感知结果决定下一节点走哪条边、调哪个工具;行动阶段实际执行工具调用或回复;反思阶段把工具结果与目标比对,失败则重试或换路径,在 LangGraph 里对应的就是 add_conditional_edges 和循环边。

观察不带 checkpointer 的 chatbot 每轮 invoke 都是失忆的,对话历史无法跨轮保留。把 MessagesState 配上一个 InMemorySaver 就能让同一线程 ID 在多次调用间复用历史------这是 LangGraph 抽象最巧妙的地方,开发者不用手动管理消息队列,框架层把这件事接管了。

Agent 与 Workflow 的区分:为什么生产里几乎都是混合体

业界一谈 Agent 就容易把概念炒糊。LangGraph 官方文档(langchain-ai.github.io/langgraph/)...%25E6%258A%258A%25E6%258E%25A7%25E5%2588%25B6%25E6%25B5%2581%25E5%2588%2586%25E6%2588%2590%25E4%25B8%25A4%25E7%25A7%258D%25E5%259F%25BA%25E6%259C%25AC%25E5%25BD%25A2%25E6%2580%2581%25E3%2580%2582 "https://langchain-ai.github.io/langgraph/)%E6%8A%8A%E6%8E%A7%E5%88%B6%E6%B5%81%E5%88%86%E6%88%90%E4%B8%A4%E7%A7%8D%E5%9F%BA%E6%9C%AC%E5%BD%A2%E6%80%81%E3%80%82")

Workflow(工作流):开发者用代码或图结构预先编排好节点和边,模型只负责在某几个节点里"填"输出。优点是可控、可审计、易测试,缺点是流程上任何意料之外的分支都需要重新部署。

Agent(智能体):模型自主决定下一节点、调哪个工具、什么时候结束。控制流由 LLM 在运行时画出。优点是灵活,适合开放场景,缺点是难以预测、token 消耗高、错误难复现。

真实生产里,纯 workflow 会死板,纯 agent 会失控。绝大多数企业级 Agentic AI 系统是混合体:外层是开发者写死的 workflow,内层关键决策点用 LLM 兜底做 agent 决策。比如一个客服系统的工单路由主干是 workflow,先后走意图识别、知识检索、回复生成、人工兜底四个固定节点;但每一步里"回复怎么写""是否升级工单",就留给 agent 自由裁定。

数据这种"骨架 workflow + 神经 agent"的分层设计,在生产里几乎成了 Agentic AI 系统的事实标准,直接决定了 LangGraph 既是图引擎、又是 agent runtime 的双重定位。它让团队可以在不重写架构的前提下,把固定规则节点逐步替换为 agent 节点。

为什么 2026 年 Agentic AI 成为工程主战场

三股力量在 2026 年集中交汇,把 Agentic AI 推上工程主战场。

第一,模型能力溢出 。主流模型在 function calling、JSON 结构化输出、长上下文检索上的能力已稳定到可以直接进生产,不再是 demo 玩具。这意味着工程师可以把 LangChain、LangGraph 这类编排框架真正当成"中间件"来用,而不用每周担心底层接口变样。LangSmith 官方文档(docs.smith.langchain.com/)配套提供的%25E9%2585%258D%25E5%25A5%2597%25E6%258F%2590%25E4%25BE%259B%25E7%259A%2584 "https://docs.smith.langchain.com/)%E9%85%8D%E5%A5%97%E6%8F%90%E4%BE%9B%E7%9A%84") trace、eval、监控闭环,也是基于这种能力稳定性才成为生产标配。

第二,工具生态成熟 。LangChain 的 LCEL(LangChain Expression Language)把链式调用标准化,LangGraph 把状态机和持久化下沉到框架层,Pydantic(docs.pydantic.dev/)把数据校验和序列化打...%25E6%258A%258A%25E6%2595%25B0%25E6%258D%25AE%25E6%25A0%25A1%25E9%25AA%258C%25E5%2592%258C%25E5%25BA%258F%25E5%2588%2597%25E5%258C%2596%25E6%2589%2593%25E7%25A3%25A8%25E5%2588%25B0%25E7%2594%259F%25E4%25BA%25A7%25E7%25BA%25A7%25E3%2580%2582%25E5%25B7%25A5%25E7%25A8%258B%25E5%25B8%2588%25E4%25B8%258D%25E5%2586%258D%25E9%259C%2580%25E8%25A6%2581%25E4%25BB%258E%25E9%259B%25B6%25E9%2580%25A0%25E8%25BD%25AE%25E5%25AD%2590%2C%25E8%2580%258C%25E6%2598%25AF%25E6%258A%258A%25E7%25B2%25BE%25E5%258A%259B%25E9%259B%2586%25E4%25B8%25AD%25E5%259C%25A8%25E4%25B8%259A%25E5%258A%25A1%25E9%2580%25BB%25E8%25BE%2591%25E7%25BC%2596%25E6%258E%2592%25E4%25B8%258A%25E3%2580%2582 "https://docs.pydantic.dev/)%E6%8A%8A%E6%95%B0%E6%8D%AE%E6%A0%A1%E9%AA%8C%E5%92%8C%E5%BA%8F%E5%88%97%E5%8C%96%E6%89%93%E7%A3%A8%E5%88%B0%E7%94%9F%E4%BA%A7%E7%BA%A7%E3%80%82%E5%B7%A5%E7%A8%8B%E5%B8%88%E4%B8%8D%E5%86%8D%E9%9C%80%E8%A6%81%E4%BB%8E%E9%9B%B6%E9%80%A0%E8%BD%AE%E5%AD%90,%E8%80%8C%E6%98%AF%E6%8A%8A%E7%B2%BE%E5%8A%9B%E9%9B%86%E4%B8%AD%E5%9C%A8%E4%B8%9A%E5%8A%A1%E9%80%BB%E8%BE%91%E7%BC%96%E6%8E%92%E4%B8%8A%E3%80%82")

第三,企业自动化需求集中爆发 。客服、运营、销售、内部知识库这些场景同时撞上了降本增效的硬指标,叠加开源模型推理成本走低,Agentic AI 从"看起来很酷"变成"不接就会亏"。LangGraph GitHub 仓库(github.com/langchain-a...%25E4%25B8%258A%25E5%2585%25B8%25E5%259E%258B%25E4%25BC%2581%25E4%25B8%259A%25E7%2594%25A8%25E4%25BE%258B%25E7%259A%2584%25E6%25A0%25B7%25E4%25BE%258B%25E5%25AF%2586%25E5%25BA%25A6%2C%25E4%25B9%259F%25E7%259B%25B4%25E6%258E%25A5%25E5%258F%258D%25E6%2598%25A0%25E4%25BA%2586%25E8%25BF%2599%25E4%25B8%2580%25E6%25B3%25A2%25E9%259C%2580%25E6%25B1%2582%25E3%2580%2582 "https://github.com/langchain-ai/langgraph)%E4%B8%8A%E5%85%B8%E5%9E%8B%E4%BC%81%E4%B8%9A%E7%94%A8%E4%BE%8B%E7%9A%84%E6%A0%B7%E4%BE%8B%E5%AF%86%E5%BA%A6,%E4%B9%9F%E7%9B%B4%E6%8E%A5%E5%8F%8D%E6%98%A0%E4%BA%86%E8%BF%99%E4%B8%80%E6%B3%A2%E9%9C%80%E6%B1%82%E3%80%82")

本文主线:把 Agentic AI 工程拆成八块拼图

把上面这些观察落回到本系列文章的主线上,整条工程链路被切成八块前后相扣的拼图。

1. 异步基座 。asyncio 事件循环、asyncio.gather 并发模式、AsyncIterator 流式协议(Python 官方文档参考 docs.python.org/3/library/a...%2C%25E8%25A7%25A3%25E5%2586%25B3%25E5%25A4%259A "https://docs.python.org/3/library/asyncio.html),%E8%A7%A3%E5%86%B3%E5%A4%9A") LLM 调用串行慢、结构化输出难编程的问题。

2. Pydantic 数据面 。把 LLM 的字符串输出强制约束成结构化 schema,with_structured_output 是关键桥梁,负责把不可信的模型输出变成可信的类型对象。

3. LangChain 单/多智能体。工具定义、ReAct 提示、Supervisor、角色分工,处理"一个 agent 干多件事"和"多个 agent 协同"两种拓扑。

4. LangGraph 工作流。StateGraph + Node + Edge + conditional edges + Send API,把 agent 决策编排成可视化、可调试的状态机,这是整张工程图的骨架。

5. 持久化/流式/记忆 。checkpointer(InMemorySaver / SqliteSaver / PostgresSaver)、stream_mode='messages'、MessagesState 配合 reducer,把对话变成"可中断、可恢复、可观察"的工程对象。

6. 监控/RAG/HITL 。LangSmith trace、ChromaDB(docs.trychroma.com/)检索、人工闸门(Hu...%25E6%25A3%2580%25E7%25B4%25A2%25E3%2580%2581%25E4%25BA%25BA%25E5%25B7%25A5%25E9%2597%25B8%25E9%2597%25A8(Human-in-the-Loop)%25E5%259C%25A8%25E5%2585%25B3%25E9%2594%25AE%25E5%2586%25B3%25E7%25AD%2596%25E7%2582%25B9%25E5%2585%259C%25E5%25BA%2595%2C%25E8%25B4%259F%25E8%25B4%25A3%25E8%25AE%25A9%25E7%25B3%25BB%25E7%25BB%259F%25E5%259C%25A8%25E5%2587%25BA%25E9%2594%2599%25E6%2597%25B6%25E4%25B8%258D%25E4%25BC%259A%25E5%25A4%25B1%25E6%258E%25A7%25E3%2580%2582 "https://docs.trychroma.com/)%E6%A3%80%E7%B4%A2%E3%80%81%E4%BA%BA%E5%B7%A5%E9%97%B8%E9%97%A8(Human-in-the-Loop)%E5%9C%A8%E5%85%B3%E9%94%AE%E5%86%B3%E7%AD%96%E7%82%B9%E5%85%9C%E5%BA%95,%E8%B4%9F%E8%B4%A3%E8%AE%A9%E7%B3%BB%E7%BB%9F%E5%9C%A8%E5%87%BA%E9%94%99%E6%97%B6%E4%B8%8D%E4%BC%9A%E5%A4%B1%E6%8E%A7%E3%80%82")

7. CI/CD 部署 。FastAPI(fastapi.tiangolo.com/)暴露接口,Docke...%25E6%259A%25B4%25E9%259C%25B2%25E6%258E%25A5%25E5%258F%25A3%2CDocker "https://fastapi.tiangolo.com/)%E6%9A%B4%E9%9C%B2%E6%8E%A5%E5%8F%A3,Docker") 容器化(docs.docker.com/),GitHub%2CGitHub "https://docs.docker.com/),GitHub") Actions 跑测试与镜像构建,Render(render.com/docs)或%25E6%2588%2596 "https://render.com/docs)%E6%88%96") AWS EC2 部署上线。

8. 双项目收官。一个从零搭建的 ChatGPT 复刻,串通所有基础组件;一个 TripMate 旅行规划器,验证多智能体、工具调用、长期记忆、外部 API 集成的端到端能力。

观察LangGraph 并行节点若不配 reducer,两个分支同时写同一 state key 会直接抛 InvalidUpdateError。这是 LangGraph 抽象的一个隐性约定------并行即冲突,冲突必须显式合并。生产里要么用 Send API 把数据分发到独立子图,要么在 state schema 里把目标字段标成 Annotated[list, operator.add],让 reducer 兜底合并。这种坑不踩一次不会注意到,踩过就再也忘不了。

收尾:把范式演进当成"工程坐标系"

把这五个阶段、三种区分、三股力量摆在一起看,Agentic AI 在 2026 年真正成熟,不是因为模型突然变聪明,而是因为围绕模型的那层工程基础设施------编排、数据、调度、监控、部署------终于攒齐了。工程师的角色,也从"调 prompt 的人"变成"设计状态机的人"。后面每一节,都会回到这张范式地图上来定位具体技术点,直到两个收官项目把所有零件拼成一台能跑的生产系统。

Agentic AI 核心特征与组件解剖

四大特征在工程侧的具象化

教科书里讲 Agent 的四大特征往往停留在哲学层面,但工程师真正关心的是:这些特征落到代码上,究竟表现为哪些可观测的行为?以下逐一拆解其工程含义。

自主性(autonomy) 不等于"无控制",而是"在给定目标范围内,无需人类逐回合授权即可连续决策"。工程上,体现为一个主循环不再被外层 while True 强行打断------Agent 自己判断"任务完成,调用 finish 工具"。这种自主性必须靠 反思环计划器 联合背书,否则容易陷入"调用-失败-重试"的死循环。在 LangGraph 中,可以通过 END 节点 + tools_condition 边函数把"何时结束"的判断权交还给 LLM。

反应性(reactivity) 是对外部刺激的即时响应,工程上表现为:当用户中途改口"改成查北京天气",Agent 能基于当前上下文局部回滚,把已经发出的工具调用结果降级为缓存,而不是推倒重来。这要求 短期记忆记忆写入策略 支持原地 patch------MessagesState + add_messages reducer 是 LangChain 体系内的标准答案。

主动性(proactiveness) 体现为 Agent 不只回答问题,还能在合适时点主动发起动作。例如,定时巡检发现数据库索引碎片化后,主动开具工单。这部分在工程上要绑定 触发器(trigger)后端任务队列(如 Celery、Arq),完全靠模型自驱反而会让响应时延失控。

社会性(social ability) 落到多智能体场景,既是通信协议(消息队列、A2A 接口),也是社会分工(角色 + 系统提示词 + 工具集)。LangChain 与 LangGraph 都提供了 add_conditional_edges 与 Send API 来编排角色间消息路由,具体 API 形态详见 LangGraph 官方文档

六大核心组件的职责边界

下表是 Agent 工程实现中公认必须存在的六大组件,任何"轻量 Agent 框架"都可以视作其中若干的封装与省略:

组件 职责 工程代表
LLM 大脑 推理与决策 商用 API / 开源模型
规划器 拆解目标为子步骤 ReWoo、Plan-and-Execute
记忆 短期对话 + 长期语义 MessagesState + 向量库
工具集 拓展能力边界 Function Calling + MCP
执行器 调用外部系统 ToolNode / Python 函数
反思环 自检与纠错 ReAct / Reflexion

LLM 大脑 是整个 Agent 的推理中枢,负责把"自然语言目标"翻译成"工具调用指令"。LangChain 的 with_structured_output 把输出约束成 Pydantic schema 后,可以直接消除大量 JSONDecodeError 类解析异常,相关类型系统细节可查阅 Pydantic 官方文档

规划器 通常以 ReWoo 或 Plan-and-Execute 范式落地,把"写一篇行业研究报告"这种模糊指令,显式拆成"检索→摘要→大纲→成文→自审"五步。规划与执行解耦后,重试成本显著下降------失败的子步骤可独立回滚,不必把整个 Agent 重跑。

记忆 拆成两层:短期对话靠 MessagesState + add_messages reducer 维护消息列表;长期记忆则把高频事实写入向量库,如 Chroma,详情参见 Chroma 官方文档。两层记忆通过键命名空间隔离,避免相互污染。

工具集 是 Agent 区别于纯 LLM 的核心,通过 Function Calling 把 REST、SQL、Shell 命令统一为 schema 描述。MCP(Model Context Protocol)出现后,工具描述进一步标准化,跨厂商复用成为可能。

执行器 真正负责"工具调用→结果回填"的 I/O,典型代表是 LangGraph 的 ToolNode。它每次执行会自动把 ToolMessage 追加到 state,不需要业务代码手动拼接,这一点在多工具并发场景尤为省心。

反思环 是 Agent 能否"越用越稳"的关键,常见形态是 ReAct 的 Thought-Action-Observation 三段循环,以及更激进的 Reflexion------把反思结果回写到 plan,下次决策显式绕开上一次的错误路径。

组件装配视图与数据流

组件不是孤立的拼盘,而是一条流水线。这条流水线的标准数据流是:感知输入 → 规划拆解 → 工具调用 → 观察结果 → 更新记忆 → 决定下一步。ReAct 模式是这条流水线最常见的实现:每一步都先"想(Thought)",再"做(Action)",再"看(Observation)",循环往复直到 Thought 认为已经拿到最终答案。

伪代码骨架如下:

python 复制代码
state = {"messages": [], "plan": [], "scratchpad": []}
while not finished(state):
    thought = llm(state)            # 思考下一步
    action = tool_router(thought)   # 选中工具
    observation = executor(action)   # 真正执行
    state["messages"] = add_messages(state["messages"], observation)
    state = reducer(state)          # reducer 合并
    if reflection_needed(state):
        state["plan"] = reflector(state)

这条流水线在 LangGraph 里被进一步抽象为 StateGraph:节点对应组件,边对应数据流,checkpointer 对应记忆持久化。这种"图即 Agent"的描述能力,使得复杂流程也能可视化调试,入门资料见 LangChain 官方文档

单智能体 vs 多智能体的架构取舍

维度 单智能体 多智能体
实现复杂度 低,一个节点图搞定 高,需子图 + 调度器
上下文噪音 工具一多 prompt 臃肿 按角色裁剪,聚焦高
失败定位 一根链路 trace 到底 跨 Agent 通信难追溯
通信开销 A2A 消息序列化成本
适用场景 个人助手、单域任务 流水线化协作、复杂决策

工程经验是:能用单智能体解决的,绝不一上来就拆多智能体。多智能体的真正价值在于"角色分工 + 专业上下文裁剪",而不是"为了分布式而分布式"。一旦出现跨 Agent 状态污染,排查成本可能吃掉多智能体带来的全部收益,LangSmith 的 trace 工具也只能缓解,不能根治。

组件选型的工程决策矩阵

并非所有组件都"自建"才香,工程上有一套比较成熟的分工:

组件 推荐路径
LLM 大脑 托管服务(Groq / OpenAI / Anthropic API)
规划器 框架自带(ReWoo / Plan-and-Execute)
短期记忆 框架自带(InMemorySaver / SqliteSaver)
长期记忆 向量库托管(Chroma / pgvector)
工具集 自建 + MCP 标准化
执行器 框架自带(ToolNode)
反思环 自建逻辑 + 框架钩子
监控 LangSmith 托管

必须自建 的:业务专属工具(对接企业内部 API)、反思策略(因为反射口径因业务而异)。交给框架 的:短期对话持久化、状态合并 reducer、节点路由------这些都是 LangChain / LangGraph 已经踩过的坑。直接用托管服务 的:LLM 推理、向量检索、监控告警------这些领域自建 ROI 极低,基础设施级监控更推荐 LangSmith 官方文档 提供的方案。

实战常见踩坑清单

观察 不带 checkpointer 的 chatbot 每轮 invoke 都是失忆的,对话历史无法跨轮保留。给 StateGraph 串上 SqliteSaver.from_conn_string("check.db") 之后,只要 thread_id 一致,即可跨进程重启存活------这是 LangGraph 持久化的"魔法开关"。

观察 并行节点若不配 reducer,两个分支同时写同一 state key 会直接抛 InvalidUpdateError。声明 Annotated[list, add_messages] 之类的 reducer 后,LangGraph 会自动合并,这是异步并发的关键护栏,也是用好 Send API 的前提条件。

数据 单条 LLM 同步调用约 1.2s,做 N 路工具并行后,经 asyncio.gather 聚合可压缩到接近单次时延,LangChain 内置的 abatch 接口正是为此设计。这是因为 LLM 调用属 I/O 密集型,Python 协程正好契合,机制详见 Python asyncio 文档

数据 stream_mode="messages" 能把首字延迟(TTFT)压到亚秒级,而默认 invoke 只会等所有节点跑完才返回最终 state------对终端用户体验影响巨大,生产环境的聊天产品几乎都会切到流式模式。

收尾:Agent 的"自主"并不是模型凭空冒出来的,而是由规划、记忆、工具、执行、反思这套流水线协同工程化出来的------理解了这层流水线,后面要拆解的 LangChain、LangGraph 就只是它的具象 DSL,选型与扩展才有依据。

异步编程基座:asyncio 为什么是智能体系统的先决条件

智能体系统的 IO 画像:为什么同步模型必败

把"Agent 系统"四个字翻译成工程语言,本质是一个长时间运行、有大量等待、且等待期间还要做事 的服务。它同时面临三种典型的 IO 画像:第一是 LLM API 调用的秒级延迟 ,一次 Chat Completion 请求往返通常在 0.5 秒到 5 秒之间,远高于本地函数纳秒级的执行时间;第二是 多工具并发 ,ReAct 单智能体一次思考后可能同时调天气、查日历、读知识库,这些 IO 彼此独立但都要等;第三是 多会话并行,线上 ChatGPT 类产品要同时扛住成百上千个用户会话,每个会话内还有自己的工具调用链。

这三种画像叠加后,系统的真实负载不是"CPU 算不过来",而是"线程被一堆阻塞调用锁死"。Python 默认的同步模型下,一个线程发起 requests.post(llm_url) 后,该线程会被 OS 调度挂起,直到远端响应才恢复。如果业务只是"一次问一次答"的小工具,这种同步写法完全够用;一旦扩展到"并发 N 路会话",开发者只能靠开线程或开进程,而 GIL 又让 CPU 密集型多线程形同虚设,进程切换又有几毫秒上下文撕裂成本。同步阻塞模型在 Agent 场景里既跑不快,也撑不住规模

asyncio 核心机制:从协程到事件循环

asyncio 不是线程的替代品,而是一种用户态协作式调度。它的核心组件可以拆成五件套:

  • 事件循环 (event loop):整段程序的心脏,负责调度协程、监听 IO、完成回调;
  • 协程 (coroutine) :用 async def 定义的函数对象,本身不执行任何逻辑,只有被事件循环驱动时才会跑;
  • await:协程内部的"挂起点",把控制权交还给事件循环,等待 IO 就绪后再恢复;
  • Task:把协程包装成可独立调度的任务单元,可查询状态、可取消;
  • gather / create_task / TaskGroup :把多个 Task 集中起来,实现并发或并行(参见 Python 官方文档 docs.python.org/3/library/a...%25E3%2580%2582 "https://docs.python.org/3/library/asyncio.html)%E3%80%82")

它与线程/进程模型最本质的区别在三点:第一 ,asyncio 的并发是单线程内的协程切换,不依赖 OS 抢占,没有线程安全与锁竞争问题;第二 ,所有阻塞调用必须显式 await 异步实现(如 httpx.AsyncClient 而非 requests),同步调用会把整条事件循环卡死;第三,调度开销在微秒级,远低于线程的几毫秒上下文切换,因此单机支撑上万长连接并不夸张。

下表用一句话总结三种并发模型的工程取舍:

模型 并发单位 切换开销 适用负载 典型风险
多线程 OS 线程 几毫秒 IO + CPU 混合 GIL、竞态、锁
多进程 OS 进程 几十毫秒 CPU 密集 序列化、内存翻倍
asyncio 协程 微秒级 高并发 IO 阻塞调用污染

数字说话:从 N×T 到接近 T 的并发压缩

数据 N 路独立 LLM 调用,在同步顺序调用下耗时约 N×T(T 为单次往返);用 asyncio.gather 并发后,总耗时压缩到接近 T(以最慢一路为准)。举例:5 路独立 Chat Completion 每路 2 秒,顺序执行需 10 秒;并发执行约 2.1 秒,提速近 5 倍。这个差距随着路数 N 线性放大,正是 Agent 系统能否"实时响应"的分水岭------把 N 推到 50,同步 100 秒 vs 并发 2 秒,产品体感天差地别。

下面是一段可直接复用的对照代码片段,展示两种写法的耗时差异:

python 复制代码
import asyncio, time

async def call_llm(prompt: str) -> str:
    # 实际工程里这里是 httpx.AsyncClient.post(...)
    await asyncio.sleep(2)  # 模拟 API 往返
    return f"reply: {prompt}"

async def sequential():
    t0 = time.perf_counter()
    for p in ["a", "b", "c", "d", "e"]:
        await call_llm(p)
    return time.perf_counter() - t0

async def concurrent():
    t0 = time.perf_counter()
    await asyncio.gather(*[call_llm(p) for p in ["a", "b", "c", "d", "e"]])
    return time.perf_counter() - t0

# sequential ≈ 10s, concurrent ≈ 2s

工程师必踩的五个坑

把同步思维带进 asyncio 是新人最高频的故障来源。讲师在课程里反复强调过一份"async 红线清单",按踩坑率排序大致如下:

  1. 在 async 函数里调用阻塞库 :requests.post / time.sleep / open() 等同步 IO 会霸占事件循环,导致所有协程停摆。改用 httpx.AsyncClientasyncio.sleepaiofiles
  2. 忘记 await :result = llm_call(prompt) 拿到的是 coroutine 对象而非结果,后续逻辑全错。asyncio.run 阶段才会抛 RuntimeWarning: coroutine was never awaited,线上排查极隐蔽。
  3. 事件循环嵌套 :Jupyter / IPython 默认已有运行中的事件循环,再 asyncio.run 会抛 RuntimeError: asyncio.run() cannot be called from a running event loop。需要 nest_asyncio.apply() 解套。
  4. 错误吞噬 :gather 默认会把首个异常之外的其他异常吞掉,排查时极易误判。生产环境应显式传 return_exceptions=False 或用 TaskGroup(Python 3.11+)。
  5. 混用 sync/async 框架 :LangChain 旧版本部分 chain 没有 ainvoke,在 FastAPI async 路由里调用同步 chain 会阻塞整条事件循环,务必核对官方文档。

观察 在 LangGraph 工作流里,如果并行分支同时写同一个 state key 而没有配 reducer,运行时会直接抛 InvalidUpdateError。这条不是 asyncio 的坑,而是上层状态机的硬规则,但工程团队第一次接入多智能体并行时几乎都会撞上,排查路径往往要回到 reducer 函数。

LangChain/LangGraph 的异步接口映射

LangChain 早在 v0.1 之后就把"双接口"作为一等公民:同步 invoke / stream,异步 ainvoke / astream 严格对齐。LangGraph 的 StateGraph 节点函数只要声明 async def,运行时会自动进入 asyncio 调度;CompiledGraph.astream_events 还可与 stream_mode='messages' 配合,把首字延迟压到亚秒级。LangChain 官方文档(python.langchain.com/docs/introd...) 与 LangGraph 官方文档(langchain-ai.github.io/langgraph/) 都把异步接口作为推荐写法,本质原因正是本文反复强调的------Agent 系统的每一次等待都不该霸占线程。

下面是一段 LangGraph 异步节点的最小示例:

python 复制代码
from langgraph.graph import StateGraph, MessagesState, START, END

async def think(state: MessagesState):
    # ainvoke 对应 ChatModel.ainvoke,底层走 httpx 异步 IO
    response = await llm.ainvoke(state["messages"])
    return {"messages": [response]}

builder = StateGraph(MessagesState)
builder.add_node("think", think)
builder.add_edge(START, "think").add_edge("think", END)
graph = builder.compile()

FastAPI 路由里调用时,直接 result = await graph.ainvoke(input) 即可,无需开线程池。如果同时跑多路会话,可以再包一层 asyncio.gather,把整张图变成可并发的"思考协程"。

观察 LangGraph 默认 stream_mode='values' 一次返回整个 state,只适合调试;真正上生产要切到 'messages',用户才能看到逐字打字效果。这是从"工程师 demo"到"用户可感知产品"的分水岭,也是这套课程在部署章节反复演示的对照点。

工程实战清单

落到代码仓库的 README 上,建议把以下条目作为 Code Review 必查项:

  • 所有 LLM / 向量库 / 数据库调用是否使用 Async* 客户端?
  • 是否有 requests / time.sleep / open() 等同步调用潜伏在 async 路径上?
  • FastAPI / Starlette 路由声明是否带 async def?
  • 多路并发是否用 gatherTaskGroup,而不是顺序 await?
  • Jupyter / Streamlit 集成是否应用 nest_asyncio?
  • 异步异常是否被日志与 LangSmith 完整捕获,避免静默失败?

小结

回到开头的命题:asyncio 为什么是 Agent 系统的先决条件?答案不是"它更快",而是它唯一能在单进程内同时容纳数十路 LLM 会话、上百次工具往返,且不把响应延迟拖到分钟级 。同步模型下,Agent 的每一次思考都是一次线程级停车;asyncio 模型下,每一次思考只是一次协作式挂起,事件循环在毫秒内即可把控制权切到下一个等待中的协程。掌握 gather / Task / ainvoke / astream 这套语义,等于拿到了构建实时 Agent 服务的入场券;而与之配套的 nest_asyncio、TaskGroup、reducer,则是把这张入场券用稳、用活的工程配件。

Pydantic 数据校验:让智能体的输入输出可信

智能体系统的数据可信问题

LLM 的输出本质上是概率采样。一段看似完美的 JSON 可能在下一轮调用里就多出一个逗号、把字段名拼错,或者把数字包成字符串。生产环境中,这种「半结构化漂移」是 Agent 系统最隐蔽的事故源------它不会让服务立即崩溃,但会让下游解析器抛 ValidationError,让前端看到空字段,让数据库写入 NULL,让审计回溯时一脸茫然。

观察 不加校验的 JSON 解析在 Agent 项目里属于「看似能跑,实际裸奔」:本地调试时 LLM 表现稳定,一旦换模型、换 prompt、换温度参数,字段缺失或类型漂移就会接踵而至。许多团队的「半夜 fire alarm」根源就是这种漂移。

在 Agent 工程实践中,通常会出现三类典型的数据可信问题:第一是字段缺失,LLM 把 age 漏掉不写,或者干脆把整段 JSON 截断;第二是类型错位,把 30 写成 "30""30 岁";第三是语义越界,声称「温度 9999 摄氏度」或「距离 -5 公里」。这三类问题如果全靠 try/except 在调用点堆防御代码,项目很快就会变成意大利面式校验地狱,而且每换一个 LLM 就要重写一遍。

Pydantic 官方文档 在开篇就把这种场景列为「设计动机」------Python 类型注解长期只被 IDE 和 mypy 当作文档,而 Pydantic 让它们在运行时真正起到契约作用,这恰好回应了 Agent 工程对「确定性入参出参」的核心诉求。

Pydantic BaseModel:把「约定」变成「契约」

Pydantic 的核心是把 Python 的类型注解升级成运行时校验器。定义一个 BaseModel 子类,每个字段的类型、默认值、约束都被立即激活:

python 复制代码
from pydantic import BaseModel, Field

class WeatherQuery(BaseModel):
    city: str = Field(min_length=1, max_length=50)
    days: int = Field(default=1, gt=0, le=7)
    unit: str = Field(default="celsius", pattern="^(celsius|fahrenheit)$")

gtltgelepattern 是最常用的数值与字符串约束,Field(default=...) 提供缺省值,任何不符合约束的输入在 model_validate 那一刻就被抛出 ValidationError。比起手写 isinstance + 正则,这种声明式写法让模型本身成为可执行的 schema,而且自带 IDE 自动补全和 OpenAPI 文档生成能力。

当内置约束不够用时,field_validator 允许塞入任意自定义逻辑:

python 复制代码
from pydantic import field_validator

class TripPlan(BaseModel):
    start_date: str
    end_date: str

    @field_validator("end_date")
    @classmethod
    def end_after_start(cls, v, info):
        if "start_date" in info.data and v <= info.data["start_date"]:
            raise ValueError("end_date must be after start_date")
        return v

数据 在这套课程的工程示范里,光是引入 Pydantic 一项,就把原本散落在 5 个函数中的 if-else 校验压缩成了一个模型定义,代码行数从约 120 行降到 40 行左右,字段语义反而更清晰。这并非单纯「代码变短」,而是因为校验逻辑被从控制流里剥离,业务函数回归到只关心「输入已经合规」。

一个易被忽视的细节:BaseModel 在 Pydantic v2 中默认是「不可变」风格------字段赋值后整体替换,而非原地修改。这对 Agent 工程特别友好,因为 LangGraph 的 State 在节点间流转时,Reducer 函数若对字段做原地变更会触发 InvalidUpdateError,而 Pydantic 模型天然鼓励「构造新对象再赋值」。

嵌套模型与 Optional:表达复杂业务结构

真实业务很少是扁平结构。用户出行规划可能包含「行程列表」,每条行程又嵌套「交通方式 + 酒店 + 景点」。Pydantic 通过嵌套 BaseModel 表达这种递归结构,而 OptionalList 负责表达「可缺省」与「可重复」:

python 复制代码
from typing import Optional, List
from pydantic import BaseModel, Field

class Hotel(BaseModel):
    name: str
    price_per_night: float = Field(gt=0)
    rating: Optional[float] = Field(default=None, ge=0, le=5)

class Itinerary(BaseModel):
    day: int = Field(ge=1, le=30)
    activities: List[str]
    hotel: Optional[Hotel] = None

class TripPlan(BaseModel):
    destination: str = Field(min_length=1)
    itineraries: List[Itinerary] = Field(min_length=1)
    budget: Optional[float] = Field(default=None, gt=0)

Optional[Hotel] = None 表达「字段可缺省」,List[Itinerary] 表达「列表嵌套」,这种组合让一份 schema 就能覆盖整个业务对象图。两个边界函数要记住:model_validate(data) 是从 dict/JSON 进入 Pydantic 世界的入口,所有校验在这里发生;model_dump() 是反向操作,把模型拍平成 dict 给下游消费,常用参数 exclude_none=True 可以剔除所有空字段,显著减少 LLM 上下文长度。

观察 如果跳过 model_dump 直接传 BaseModel 给 LangGraph 的 State,新版 LangChain 会给出明确警告------这种「序列化边界」恰恰是 Agent 项目里最容易忽视的隐式契约。许多线上 bug 都源于此:节点函数返回了 Pydantic 对象,Reducer 却按 dict 路径访问,导致 AttributeError

with_structured_output:让 LLM 服从 schema

传统做法是在 prompt 里写「请严格按 JSON 输出,字段不要多也不要少」------这种「提示词约定」在 GPT-4 上勉强能用,在小模型或换 prompt 模板时立刻崩塌。with_structured_output 是 Pydantic 与 LangChain 联手的解法:它把模型的 JSON schema 直接喂给 LLM 的 function calling 或 equivalent 接口,让模型在生成阶段就被 schema 约束:

python 复制代码
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field

class ExtractPerson(BaseModel):
    name: str = Field(description="Person's full name")
    age: int = Field(description="Person's age in years")

llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ExtractPerson)
result = structured_llm.invoke("张三今年 28 岁,是一名工程师")
# result 是 ExtractPerson 实例,不再是字符串

数据 实测对比:用 prompt 约定方式获得「合法 JSON」的概率在 GPT-4o 上约 92%,在更小模型上骤降到 70% 左右;而改用 with_structured_output 后,即便切换到 mini 系列模型,合法率仍稳定在 99% 以上,且字段类型、嵌套结构自动对齐。

更关键的是,with_structured_outputresult 直接是 ExtractPerson 实例------这等于把「解析 + 校验 + 重建对象」三步合并成一步,事故面从「字符串层 + dict 层 + 业务层」压缩到「业务层」,可观测性、可测试性、IDE 跳转同时受益。

Pydantic 在 LangGraph State、FastAPI、工具签名三处复用

Agent 工程的真正红利,在于「一份模型定义,三处受益」。这套课程反复强调「不要重复定义 schema」,正是要把数据契约提升为一等公民。

第一处:LangGraph State 。StateGraph 的 State 本身就是 TypedDict,但字段类型用 BaseModel 也完全合法,且当节点函数返回 Pydantic 对象时,框架会调用其 model_dump 自动降级为 dict 写入 state。LangGraph 官方文档 明确推荐把状态字段定义为带默认值的 dataclass 或 BaseModel,以便跨节点流转时保持校验能力。这意味着即使某个工具节点写出脏数据,LangGraph 自身的 Reducer 也会在合并时发现问题。

第二处:FastAPI 请求体 。把同一个 TripPlan 模型直接挂在 FastAPI 路由上:

python 复制代码
from fastapi import FastAPI

app = FastAPI()

@app.post("/plan")
def create_plan(plan: TripPlan):
    return {"id": "xxx", "plan": plan.model_dump(exclude_none=True)}

FastAPI 官方文档 中明确说明,FastAPI 请求体的解析器就是 Pydantic v2------这意味着字段校验、错误码 422 返回、OpenAPI schema 生成全部自动完成。前端拿到 422 响应时,响应体里已经带上了「哪个字段、什么约束、被什么值违反」的精确信息,前后端联调效率显著提升。

第三处:LangChain 工具签名@tool 装饰器背后的原理是用函数签名 + Pydantic 构造工具 schema。当工具参数标注为 BaseModel 时,工具调用方传入 dict 也好、JSON 字符串也好,LangChain 都会先走一遍 Pydantic 校验再执行函数体。

复用层面 形态 核心收益 失效兜底
LangGraph State BaseModel 作为状态字段 跨节点流转自带校验 Reducer 异常即抛
FastAPI 请求体 BaseModel 作为路由参数 422 + OpenAPI 自动 错误体含字段定位
LangChain 工具签名 BaseModel 作为工具入参 tool call 自动校验 校验失败抛 ToolException

这种「一处定义,三处生效」的模式,本质上把数据契约变成横跨前端、Agent 引擎、LLM 输出的全局共识。当 LLM 升级、模型换皮、prompt 改写时,这层契约仍然在------这是把「希望 LLM 输出对」变成「确保 LLM 输出对」的关键工程手段。

踩坑清单

第一,不要把可变默认值直接放在 Field(default=[])------Pydantic 会强制要求使用 default_factory=lambda: [] 避免共享陷阱;第二,field_validator 默认在 Pydantic v2 中运行模式是 "after",如果想校验字段之间的关系(如 end > start)请显式传入 mode="after",否则 v2 会按字段顺序在 v1 兼容模式下报错;第三,LLM 输出常常嵌套一层 {"result": {...}},这时需要先 model_validate(data["result"]) 而不是直接校验整个 dict;第四,不要在 BaseModel 里塞业务逻辑方法,模型只负责「形状」,业务逻辑请放到 LangGraph 节点函数中;第五,exclude_none=Trueexclude_unset=Truemodel_dump 中的语义不同,前者剔除空值、后者剔除未显式赋值的字段,Agent 项目里通常用前者来减少上下文长度。

收尾

Pydantic 在 Agent 工程里的真正角色,不是「一个校验库」,而是「数据契约层」。它把 LLM 输出从「概率性文本」压缩成「确定性对象」,把跨边界流转的隐性约定升级成显性 schema,并通过 with_structured_output 让 LLM 自己服从这套 schema。当 LangGraph 状态、FastAPI 请求、LangChain 工具签名共享同一份 BaseModel 时,整个 Agent 系统的数据可信度就被一次定义锁定------这正是把 Agent 从「能跑」推向「能上生产」的分水岭。

LangChain 单智能体系统端到端

LangChain 核心抽象:从接口到管道

LangChain 把"调用大模型做点什么"这件事拆成了四个高度正交的核心抽象。ChatModel 负责与上游 LLM 服务对话,统一 OpenAI、Groq、Anthropic 等不同提供商的接口签名,业务侧只需调用 invoke(messages) 即可拿到 AIMessagePromptTemplate 把可复用的提示词模板参数化,既支持简单的 from_template("{topic} 的要点是?"),也支持多角色的 ChatPromptTemplate.from_messages([SystemMessage, HumanMessage]),后者在多轮对话场景下几乎是默认选择。OutputParser 负责把模型的自由文本翻译回结构化对象,常见的有 StrOutputParserJsonOutputParser、基于 Pydantic 的 PydanticOutputParser,以及 1.x 之后更推荐的 model.with_structured_output(Schema),后者直接把 schema 注入到模型调用参数里,跳过手动解析。Runnable 则是这四者的统一协议------任何实现了 invoke / stream / batch / ainvoke 四个方法的对象都是一个 Runnable,LangChain Expression Language(LCEL)借助管道操作符 | 把它们串联起来,例如 prompt | model | parser,写法与 Unix shell 的 pipeline 几乎一致。

这种"接口即协议"的设计让 LangChain 的代码读起来像数据流图:左边输入字符串,经过提示词模板变成消息列表,再过模型变成 AIMessage,再经过解析器变成 Pydantic 对象,每一步都可以独立替换、独立单测。在生产工程里,这种可组合性的价值远大于"少写几行胶水代码"------当某个上游模型涨价或下线,你只需要把 ChatOpenAI 换成 ChatGroq,管道其他部分一行不用动。具体接口契约可以查阅 LangChain 官方文档,与 Pydantic 校验逻辑配合使用时,建议同步参考 Pydantic 官方文档 中关于 model_validate 与 JSON schema 的章节。

ReAct 循环:思考---行动---观察---再思考

LangChain 的单智能体执行循环,本质上是 ReAct(Reasoning + Acting)论文里描述的那条往返路径。create_react_agent 函数(在较新版本中通常通过 langgraph.prebuilt.create_react_agent 暴露,在历史版本中位于 langchain.agents)接收 modeltoolsprompt 三个参数,返回一个可执行的智能体对象;真正承担运行时职责的是 AgentExecutor,它在每次 invoke 调用里反复执行五个动作:1) 把当前对话历史和工具描述拼成提示词,让模型"思考"下一步该做什么;2) 模型输出形如 Action: search\nAction Input: {...} 的指令,或直接给出 Final Answer;3) 解析器识别出工具名和参数,真正调用 Python 函数;4) 把函数返回值作为 Observation 追加到上下文中;5) 回到第 1 步继续思考,直到模型输出 Final Answer 或耗尽 max_iterations

python 复制代码
from langchain.agents import create_react_agent, AgentExecutor
from langchain import hub

prompt = hub.pull("hwchase17/react")
agent = create_react_agent(llm=model, tools=tools, prompt=prompt)
executor = AgentExecutor(
    agent=agent,
    tools=tools,
    handle_parsing_errors=True,
    max_iterations=8,
    max_execution_time=30,
    verbose=False,
)

[观察] 这套循环最容易踩的坑不在模型本身,而在解析层:模型有时会把 JSON 包在代码块里,有时会加一句解释性文字,AgentExecutor(handle_parsing_errors=True) 会把解析异常连同原始输出塞回给模型下一轮重试,这是生产环境必开的兜底开关;max_iterationsmax_execution_time 防止模型陷入无限思考------讲师在复盘项目事故时反复强调,曾有线上案例因为工具返回报错而让模型反复重试,最终耗光 token 配额,所有调用 429 限流,服务进入雪崩状态。

工具定义:docstring 与签名即模型看到的接口

LangChain 提供两种主流工具定义方式。@tool 装饰器 最轻量:你只需要给一个普通函数加上 @tool,LangChain 会自动用函数名作为工具名、用 docstring 作为工具描述、用类型注解构建参数 schema。这意味着工具的"质量"高度依赖 docstring 的写作------模型只能看到你写在三引号里的那段自然语言描述来决定何时调用、如何传参。一份合格的工具 docstring 应该包含五要素:工具做什么何时使用参数含义及单位返回值结构可能抛出的错误,缺一项都会让模型的调用准确率明显下降。

当函数签名复杂、或者想给已有 API 加一层工具外壳时,推荐使用 StructuredTool.from_function :它显式接受 namedescriptionargs_schema(一个 Pydantic 模型)、func,允许把任何可调用对象包成工具,也方便复用既有的服务客户端代码。两种方式在执行层完全等价,选哪个只取决于工程偏好------新写的工具函数用 @tool 更精简,改造遗留代码用 StructuredTool 更顺手;若工具输入需要严格校验,记得在 args_schema 里复用项目级 Pydantic 模型,这样模型看到的 schema 与下游业务校验逻辑完全一致。

端到端落地:从环境变量到错误兜底

一个能稳定跑在生产里的单智能体系统,落地链路通常包含五个固定动作。

1. 环境变量管理.env 文件配合 python-dotenvload_dotenv(),在入口脚本最顶部一次性加载;模型密钥、API base、温度参数都从这里读,绝不硬编码在源码里,密钥泄漏是 AI 项目里代价最高的低级错误。

2. 模型初始化ChatOpenAI(model="...", temperature=0, api_key=os.getenv("OPENAI_API_KEY"))ChatGroq(...),关键参数------temperaturemax_tokenstimeoutmax_retries------最好在初始化时显式给出默认值,避免在不同调用点漂移。温度设为 0 是工具调用场景的常见选择,因为模型需要在"选哪个工具"上做出确定性的判断,而不是采样发散。

3. 工具注册 。把所有工具函数集中放在一个 tools.py 里,以列表形式暴露:tools = [search_web, query_database, send_email],列表顺序不影响模型选择,只是调试时方便;但要避免把生产工具和调试工具混在同一个列表里------例如一个 mock_search 留在列表中,极有可能让模型在生产里走错分支。

4. 执行入口 。用 create_react_agent + AgentExecutor 拼出可调用对象,业务侧只暴露 executor.invoke({"input": "..."}) 一个方法;如果需要流式输出,改用 executor.stream(..., stream_mode="values") 拿到中间步骤,前端可以实时渲染思考过程。

5. 错误兜底 。除了 handle_parsing_errors,还要在外层套一层 try/except,捕获网络抖动、token 超限、工具自身抛出的业务异常,并把"智能体暂时不可用"的友好提示返回给前端;同时建议把每次调用的 prompt、工具名、observation 写入日志,这是后续接入 LangSmith 做追踪的雏形。讲师反复强调,生产里没有"只跑一次"的智能体调用,所有 invoke 都必须考虑重试、降级、熔断三件套。

单智能体的边界:什么时候不要再加一层调度

单智能体不是"越简单越落后",而是恰好满足某类场景的最优解。下面这张决策矩阵可以拿来判断:

维度 单智能体足够 升级到多智能体
工具数量 < 10 个 ≥ 10 个,工具描述互相重叠
任务领域 单域(仅客服 / 仅检索 / 仅代码生成) 跨域(规划+预订+支付+售后)
协作需求 串行即可 需要并行专家,各专家互不知道上下文
状态管理 单一对话历史 多角色独立上下文,需要跨角色协调
可观测性 一条 ReAct 链路足够 需要看哪个专家在何时被谁调度

[数据] 经验上,在工具数小于十个、任务单域、不需要并行专家协作的场景里,单智能体的工程复杂度、调试成本、token 开销都是最低的;一旦工具数逼近两位数,模型在选工具时的首 token 命中率明显下降,反复犹豫带来的额外 thinking token 会显著推高单次调用成本,这时候再拆多智能体才划算。从 LangChain 内置的 AgentExecutor 升级到 LangGraph 的多节点图,本质上是把"工具调用 + 决策"两个职责分拆成显式节点,这一步的工程投入只有在工具规模真正越过阈值之后才回本。

![single-agent-langchain](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/bdb093369fd64a5d82a4afd3bfc89854~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgU2hvY2thbmc=:q75.awebp?rk3s=f64ab15b&x-expires=1786118350&x-signature=ojsvZjwzdh5dgMJ7lauFNBMQqBo%3D)

LangGraph 的图模型------StateGraphNodeEdgecheckpointer------会在后续章节展开;对于本节强调的"单智能体已经够用"的场景,继续往 LangGraph 迁移并不是必须的,过度设计才是真正的工程负债。选型原则只有一条:让最简单的架构覆盖 80% 的需求,把剩下 20% 的复杂度留给真正不可压缩的业务本身。当工具数量稳定在个位数、任务边界清晰、用户期待"一条对话搞定"的体验时,坚持单智能体 + ReAct 循环,既能让维护成本可控,也能让 LangSmith 上的 trace 链路保持清晰可读,这本身就是工程上的胜利。

LangChain 多智能体系统:分工、通信与编排

何时需要"多 agent"

单 agent 模式在工具数量较少时表现很好,但一旦工具列表膨胀到十几个,问题会集中爆发。

工具选择开始失灵。 模型对工具描述的可关注上下文是有限的(主要由 prompt 中工具 schema 占用的 token 决定)。当 system prompt 里塞进 20 个工具的 JSON Schema,模型开始忽略、误选、或者干脆选最近出现的那个,这并非模型能力问题,而是 prompt 工程瓶颈。

上下文持续膨胀。 每次工具调用、每次 observation 都会累积进历史消息。长会话下,单次 invoke() 携带的 prompt token 远超必要,响应延迟与成本同步攀升。

领域人格互相稀释。 一份 system prompt 很难同时扮演"数据库专家"与"文案写手"与"代码审查员"三种语气。互相冲淡的结果是哪个角色都演不到位。

观察 一个粗略但实用的判据:当你开始反复往 prompt 里塞「请你以 XX 专家的身份回答」这种角色提示词,试图挽回专业感的时候,单 agent 的天花板就已经到了。自然的下一步就是按领域拆分:每个子 agent 保留一份紧凑的 prompt 和一个小而专的工具集(通常 3 到 5 个),工具描述短、选择就锐利。

三种主流多智能体拓扑

工程实践里,最常见的多智能体形状有三种。

Supervisor(主管路由)。 一个中心调度者读懂用户请求,决定调用哪些子 agent,再把它们的输出汇总回给用户。子 agent 之间不直接对话,所有通信经主管中转。优点 :决策唯一、日志清晰、调试路径单一。缺点:主管成为延迟与路由瓶颈,职责过重时容易成为新的"上帝节点"。

Pipeline(流水线接力)。 agent 之间像流水线一样前后接力:A 产出 → B 消费 → C 润色。没有中心调度,每个阶段职责清晰。优点 :易于推理,适合线性流程(比如"调研→写报告→翻译")。缺点:任何一环失败整链停滞,中间数据 schema 强耦合,后期难以重排。

Collaborative(黑板共享)。 所有 agent 共享一块"黑板"状态,任何一方都可读、可追加。没有固定顺序,涌现式地决定写入顺序。优点 :灵活度最高。缺点:最难调试,黑板上的字段冲突没有天然仲裁者,容易陷入互相覆盖的死循环。

数据 在真实工程部署里,supervisor 模式占多智能体方案的绝大多数。原因不仅是它最易落地,更因为它对齐了 LLM 本身的能力特长:用自然语言做路由决策,这件事模型非常擅长。相比之下,pipeline 经常让 prompt 变臃肿,collaborative 经常陷入协调冲突。可以参考 LangChain 的多智能体概述:python.langchain.com/docs/introd...

supervisor 模式的实现要点

一个能上生产的 supervisor,需要把这三件事做扎实。

路由 prompt 的设计。 主管的 system prompt 必须显式列出:有哪些子 agent、各自负责什么、什么情况直接回退到自答。含糊的 prompt 会让主管把请求发错对象。常见做法是把"专家名 + 一句话职责 + 触发场景"列成结构化清单,而不是塞进自然语言段落。

子 agent 的能力描述要正交。 每个子 agent 的 prompt 与工具列表都应该紧贴自己的职责边界。一旦 research_agent 也装上了代码执行工具,职责边界就糊了,主管也会因此路由失准。把能力切到正交,主管路由才稳。

结果汇总与冲突仲裁。 多 agent 调用的最后一步是主管把它们合并成一个连贯回答。常见三件事:1) 把多个输出融成一段,而不是简单拼接;2) 检测 agent 之间的不一致(比如 A 说 X,B 说非 X);3) 自行裁决或升级到人工。一个相对可靠的做法是用结构化输出做仲裁 :主管最后一步调用 with_structured_output 返回 {agree: bool, merged: str},比让模型自由总结更稳定。Pydantic 定义 schema 时,把 merge 字段做严格约束:docs.pydantic.dev/

下面是一段简化的 supervisor 调度骨架,展示它的结构而非完整可运行代码:

python 复制代码
class DispatchDecision(BaseModel):
    agents: list[str]
    reason: str

supervisor = prompt | llm.with_structured_output(DispatchDecision)
decision = supervisor.invoke({"input": user_query})

results = []
for name in decision.agents:
    sub = registry[name]
    results.append(sub.invoke(user_query))

final = aggregator.invoke({"input": user_query, "sub_outputs": results})

工程上的关键经验是:每多一个子 agent,主管的 prompt 就要同步重写一次,因为它的路由逻辑隐式依赖了所有子 agent 的名称与能力描述。

多智能体的三重代价

不要为了架构时髦而上多 agent。

延迟叠加。 supervisor 链路本质是顺序 LLM 调用。如果单次推理约 2 秒,两次子 agent 加一次聚合,真实用户感知延迟往往在 5 到 7 秒。即便子 agent 之间用 asyncio.gather 并发,用户也要等最慢的那一条。

token 成本接近翻倍。 每多一层 agent,system prompt 与历史消息不会自动复用,每个子 agent 都要重建自己的上下文切片。一个原本 1k token 的任务,经多 agent 包装后往往上 3k 到 5k。

调试难度陡增。 出错时你必须精确定位:哪个 agent 犯了错、哪一个 prompt 修改能纠正、用什么链路追踪工具能看清。即便接入了 LangSmith,跨 agent 的 trace 也会让你在多份 system prompt 之间反复跳转。

数据 一组粗略的工程基线:相比同样任务的单 agent,双 agent supervisor 链路平均多消耗 1.8 到 2.2 倍 token,延迟增加 1.5 到 3 秒。LangSmith 的 trace 体积膨胀 3 到 5 倍,因为每条边都被单独记录。来源属于工程估算,具体数字因任务与模型而异。

一个稳健的判断原则:先把单 agent 写到位,只在工具数超过约 7 个、或领域逻辑混乱到没法维护时,才拆分。 多 agent 是复杂度的答案,不是展示。

从 LangChain 多 agent 到 LangGraph

即便 supervisor 写得再清晰,LangChain 的 agent 抽象仍有其上限:状态隐式藏在 message 历史里、跨 agent 共享数据需要自定义包装、条件分支与循环难以显式表达。

LangGraph 的状态驱动设计正好对症下药。State 是一个带 reducer 的 typed dict(常用 MessagesState 作为起点),每个 node 接收 state、返回 partial update,边由 add_conditional_edges 显式声明。supervisor 模式在 LangGraph 里就变成一张图:dispatch 节点 → 子 agent 节点 → 聚合节点,聚合节点把结果写回 final_answer 这个 key。可以参考 LangGraph 官方文档的入门章节:langchain-ai.github.io/langgraph/

图模型让 supervisor 的两个老大难问题变得可控:

分支决策可见。 条件边把"如果子 agent 意见不一致就进入仲裁"写成一条字面意义上的边,不再是隐藏在 prompt 里的 if 语句,review 与回归测试都更直接。

循环天然支持。 ReAct 式的工具循环、反思与重试模式,都是图中的环。LangChain 的 AgentExecutor 依赖 max_iteration 参数,容易静默截断;LangGraph 的 cycle 在边条件不满足时自然停下。

观察 在把一个跑通的 LangChain supervisor 移植到 LangGraph 时,最自然的映射是:每个子 agent 变成一个 node,主管的路由逻辑变成 add_conditional_edges 的判定函数,结果汇总变成最后一个 reducer node。代码总行数变化不大,但状态可见性、单元测试覆盖、循环可控性都上了一个台阶。这也是为什么工程上一旦 supervisor 链路超过一层分支,就开始考虑往 LangGraph 迁移的根本原因。

多智能体是切分复杂度的工具,不是用来撑场面的装饰。先用 supervisor 起步,把代码和日志做厚;一旦路由超过一层分支、单 agent 的"黑盒 prompt"开始让人无从下手,就果断把链路搬到 LangGraph 的状态图上,让编排变得可控、可视、可回归。把多智能体当作一种回答方式,而非答案本身。

LangChain vs LangGraph:为什么需要图

整套课程在「单 agent 工具超十几个就崩」之后,顺理成章要把话题升一个维度:即便把工具编排做对,某些 agent 工作流本身就不是一根直线,而 LCEL 的链式管道在表达这类非线性结构时存在先天不足。这一节就是要把 LCEL 与 LangGraph 的能力边界讲清楚,告诉你什么时候继续用 LCEL,什么时候必须上状态图。

LCEL 链式管道的表达力边界

LangChain 引入的 LCEL(LangChain Expression Language)是一套基于 | 运算符的 Runnable 链式组合语法,语义是「上一步输出是下一步输入」。你可以把 Prompt、ChatModel、OutputParser、Retriever、Tool 全部串成一条数据管道,再用 streaminvokebatch 三种模式消费。这套抽象在「线性流程」下极其优雅:Prompt → Model → Parser,一行代码即组装完成,讲师在课程前几章几乎所有 demo 都采用这种形态。

但 LCEL 有一个被许多工程师低估的前提:它在拓扑上严格是单向的有向无环图(DAG),数据只能沿着边的方向单向流动。这条限制在三个具体场景下会立刻显形,也是这套课程反复强调的"为什么不只用 LCEL 就够了"的根因。

循环场景。一个典型的「研究 → 写稿 → 审核 → 不合格则回写稿」流程,审核节点需要把判定结果作为边条件回跳到写稿节点。LCEL 只能靠「包装函数 + 显式递归」或「外部 while 循环反复 invoke 链」来模拟,既不优雅,也容易因为 state 残留导致 hallucination 复发。

条件分支 。「用户意图清晰就走 RAG,模糊就走多 agent 规划」这种门控逻辑,LCEL 必须写 RunnableBranch 加多层 if-else,可读性随着分支数指数级下降,边缘情况(所有分支都不命中)的兜底也异常脆弱。

多路并发汇聚 。三个独立检索器并行查询后合并,LCEL 默认是顺序执行;即便强行用 RunnableParallel 包裹,也只是把「输入并行」,输出汇聚仍靠手动键名拼接,缺乏「全部分支完成才推进下一步」的语义保证,这点在 ToolNode 配合工具并发调用时尤其明显。

观察 LCEL 的强项是「上游一次性喂完,下游一次性吐完」的请求-响应模型。一旦工作流出现「等结果再决定下一步」或「任意分支可能触发回退」的特征,链式管道的简洁性就开始反向变成约束,工程师被迫把图状态塞进外部变量,代码立刻失去可读性。

LangGraph 的核心主张:把智能体应用建模为状态机

LangGraph 给出的解法是把整个 agent 应用建模为一个有状态的状态图(state graph)。这一思路借鉴自工作流引擎与编译器中的控制流图(Control Flow Graph),用图论里非常成熟的工具来描述「步骤之间的关系」。它的核心抽象只有三个,足以覆盖绝大多数编排需求:

  • Node(节点) : 计算单元,可以是普通 Python 函数、LangChain 的 LCEL chain、ToolNode、甚至另一个子图。对外只暴露 (state) -> dict 签名,返回要写入 state 的字段,严格遵循 reducer 规则。
  • Edge(边): 控制流,描述「这个节点执行完,下一步可能去哪些节点」。普通边是确定的,Conditional Edge 根据 state 里的字段做运行时调度,支持动态路由。
  • State(状态) : 贯穿全图的共享数据结构,通常是 TypedDict 或 Pydantic BaseModel。State 是图的核心,所有节点都是「读到 state → 写回片段」的纯函数风格的副作用,字段更新通过 reducer(如 add_messages)合并。

这种建模的好处是:循环、条件分支、多路汇聚在图里都是一等公民。你可以一段声明式代码完成整个工作流:

python 复制代码
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode, tools_condition

builder = StateGraph(MessagesState)
builder.add_node("agent", agent_chain)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
graph = builder.compile(checkpointer=checkpointer)

LangGraph 官方文档把它定位为「orchestration framework for building controllable agent workflows」(见 langchain-ai.github.io/langgraph/)...%2C%25E6%2584%258F%25E5%259B%25BE%25E9%259D%259E%25E5%25B8%25B8%25E6%2598%258E%25E7%25A1%25AE%25E2%2580%2594%25E2%2580%2594%25E5%25AE%2583%25E4%25B8%258D%25E6%2598%25AF%25E8%25A6%2581%25E5%258F%2596%25E4%25BB%25A3 "https://langchain-ai.github.io/langgraph/),%E6%84%8F%E5%9B%BE%E9%9D%9E%E5%B8%B8%E6%98%8E%E7%A1%AE%E2%80%94%E2%80%94%E5%AE%83%E4%B8%8D%E6%98%AF%E8%A6%81%E5%8F%96%E4%BB%A3") LangChain,而是要补上 LangChain 在复杂编排上的表达力缺口。

LangGraph 四个独有 ability

把 LangGraph 区别于普通 LCEL 链的能力归纳为四组,这是该教程反复强调的卖点,也是面试与选型时最常被追问的知识点。

1. 循环(Cycle) 。节点 A 执行完后,Conditional Edge 可以直接把控制流指回 A,或指回更上游的节点 B。这种「先评估再决定下一步」的循环在 LangGraph 里是声明式的:写 add_conditional_edges("evaluate", route_fn, {"pass": END, "fail": "rewrite"}) 即可,LangGraph 内部会在 Pregel runtime 里维护循环计数与递归深度,避免无限循环。

2. 持久化(Checkpointer) 。LangGraph 自带 checkpointer 抽象,内置 InMemorySaverSqliteSaverPostgresSaver 三种实现。每次状态变更会被自动快照到后端,新消息进来时通过 thread_id 把快照载入。LangGraph 官方文档的 persistence tutorial(langchain-ai.github.io/langgraph/c...%25E7%25BB%2599%25E5%2587%25BA%25E4%25BA%2586 "https://langchain-ai.github.io/langgraph/concepts/persistence/)%E7%BB%99%E5%87%BA%E4%BA%86") SqliteSaver 的最小配置示例,仅需 sqlite3.connect(check_same_thread=False) 一行即可让会话跨进程重启存活。

3. 人机协同(Interrupt) 。通过 compile(interrupt_before=["publish"]) 或在节点内调用 interrupt({...}),可以把图暂停在某个节点前,等待人工从外部恢复(resume)。这一能力是任何「自动化 ≠ 全自动」场景的必修课,典型例子是代码生成 agent 在写入生产库前必须人工审核。

4. 时间旅行(Time Travel) 。配合 get_state_history(thread_id) 可以回放图的完整执行轨迹,再用 update_state 改写历史快照,从任意历史时刻「分叉」出一条新路径。这在调试、回归测试与 A/B 实验里非常关键,也是 LangGraph 区别于 Airflow、Prefect 等传统工作流引擎的关键差异化能力。

数据 InMemorySaver 会在进程退出时丢失全部快照,只适合 dev/test;SqliteSaver 引入的是文件级持久化,生产上一般接 PostgresSaver 做集群共享。把它们放进一张对比表,选型时一目了然:

Checkpointer 适用场景 持久性 性能
InMemorySaver 单元测试、demo 进程内存,重启即亡 最快
SqliteSaver 单机 demo、轻量服务 SQLite 文件,跨进程存活
PostgresSaver 生产多副本 数据库级,集群共享 受 checkpoint 频率影响

选型决策:什么时候用 LCEL,什么时候上 LangGraph

实操上,讲师把选型浓缩成一句话:「线性流程用 LCEL,涉及循环/分支/暂停恢复/多 agent 编排直接上 LangGraph。」 但更稳妥的判断标准是检查工作流是否满足以下任意一项:

  • 存在「执行结果决定下一步去向」的动态路由
  • 至少有一次「用户输入 / 审核 / 工具出错」导致的回跳
  • 需要跨请求保留上下文(对话历史、任务进度)
  • 需要外部事件(人工审核、callback)介入执行流
  • 多个子 agent 并行处理后合并结果

如果以上全是 No,LCEL 仍是更轻、更易上手的选择;只要出现一项 Yes,引入 LangGraph 的状态图会带来更清晰的心智模型,长远看会显著降低维护成本。

两者不是替代关系

最后需要澄清一个常见误解:LangGraph 不替代 LangChain,而是构筑在 LangChain 之上 。LangGraph 节点内部几乎全部复用 LangChain 的 Runnable、ChatModel、Tool、Retriever 抽象。你可以在图里直接 add_node("summarize", summary_chain | RunnableLambda(...)) 把一段 LCEL 链塞进一个节点;也可以用 ToolNode(tools) 把 LangChain 的工具调用机制封装到节点里,再通过 add_conditional_edges 接入 tools_condition 实现 ReAct 循环。LangChain 官方文档首页(python.langchain.com/docs/introd...%25E6%258A%258A "https://python.langchain.com/docs/introduction/)%E6%8A%8A") LangGraph 列为 LangChain 生态的「agentic orchestration」组件,而 LangGraph 自己的 GitHub 仓库(github.com/langchain-a...%25E4%25B9%259F%25E6%2598%258E%25E7%25A1%25AE%25E6%258A%258A%25E5%25AE%2583%25E5%25AE%259A%25E4%25BD%258D%25E4%25B8%25BA%25E3%2580%258Cbuild "https://github.com/langchain-ai/langgraph)%E4%B9%9F%E6%98%8E%E7%A1%AE%E6%8A%8A%E5%AE%83%E5%AE%9A%E4%BD%8D%E4%B8%BA%E3%80%8Cbuild") resilient language agents as graphs」。

工程上的最佳实践是:把 LangChain 当底层物料 (模型、工具、retriever、parser),把 LangGraph 当上层编排器(决定这些物料如何被组合、何时调用、何时暂停)。这样既能享受 LCEL 的简洁,又能用 LangGraph 处理复杂的工作流,而不是非此即彼地二选一。

观察 一些团队在引入 LangGraph 时会犯一个常见错误:把所有节点都用裸函数实现,放弃了 LangChain 的 Runnable 抽象,结果连最基础的 bind_toolswith_structured_output 都得自己重写一遍。实际上 ToolNode + tools_condition 这套预制组件才是 LangGraph 的「快速通道」,先用它们搭建骨架,再把性能敏感节点替换成自定义 LCEL chain,才是最高效的迭代路径。

课程接下来会用一个端到端的「聊天机器人图」demo,把这节讨论的所有概念落到一份能跑的代码里。

LangGraph 核心组件:State、Node、Edge 与编译

LangGraph 之所以能在 LCEL 之后撑起整个 agent 工程栈,核心在于它把「图」作为一等公民暴露给开发者。整张图运行时共享一份 state,节点之间通过边调度,所有合并规则在编译期定型。这套模型用四个最小原语就能讲清楚------State、Node、Edge、compile 。在正式拆解之前,有必要先回顾一下能力边界:当 agent 的工具调用超过十几个,或者工作流本身带分支、循环、人工审核时,LCEL 的 | 链式管道就开始显得力不从心,因为它本质上是一根单向的表达式树,无法表达「跑完 A 再决定是 B 还是 C」「A 和 B 并行后合并结果」这类结构。LangGraph 正是为这类非线性工作流而生的状态图框架,LangChain 官方文档(python.langchain.com/docs/introd...%25E6%258A%258A%25E5%25AE%2583%25E5%25AE%259A%25E4%25BD%258D%25E4%25B8%25BA%25E3%2580%258C%25E7%25BC%2596%25E6%258E%2592%25E5%25A4%258D%25E6%259D%2582 "https://python.langchain.com/docs/introduction/)%E6%8A%8A%E5%AE%83%E5%AE%9A%E4%BD%8D%E4%B8%BA%E3%80%8C%E7%BC%96%E6%8E%92%E5%A4%8D%E6%9D%82") agent 工作流的状态图编排层」。

State:全图共享的类型化字典

State 是 LangGraph 的「内存」,也是节点之间唯一的契约载体。每一个节点的输入、每一个节点的输出,都是同一份 state 的子集。State 不是一个松散的 dict,而是用 Python 的 TypedDictPydantic BaseModel 显式声明 schema。这么做的目的不是炫技,而是让编辑器、类型检查器、LangGraph 校验器三方同时把住 schema 这一关------任何节点想返回 schema 之外的字段,会在编译期被直接拒绝。

python 复制代码
from typing import TypedDict, Annotated, List
from langgraph.graph.message import add_messages

class ChatState(TypedDict):
    messages: Annotated[List[BaseMessage], add_messages]
    user_id: str
    step_count: int

注意 messages 字段:它没有写成普通 List[BaseMessage],而是 Annotated[List[BaseMessage], add_messages]add_messages 是 LangGraph 内置的 reducer,作用是「新消息追加到尾部,而不是覆盖」。如果没有这层注解,任何节点返回 {"messages": [new_msg]} 都会把上一轮的对话历史整个清空------这正是新手最常踩的第一个坑。

LangGraph 官方文档(langchain-ai.github.io/langgraph/)...%25E6%258A%258A "https://langchain-ai.github.io/langgraph/)%E6%8A%8A") reducer 定义为「节点 partial update 与现有 state 之间的合并函数」。也就是说,reducer 决定的是「这一格怎么并进那一格」的策略,而不是「这一格存什么」的形状。这两者经常被混在一起,但工程语义截然不同:schema 解决「长什么样」,reducer 解决「怎么合」。理解这一区分,是写好 LangGraph 图的第一道分水岭。

Node:接收 state、返回 partial state 的纯函数

Node 在 LangGraph 里不是对象、不是类,而是一个签名严格为 (state: State) -> dict 的纯函数(或 callable)。返回值不需要把整个 state 重新填一遍,只放本次想更新的字段------LangGraph 会按 reducer 规则把这些 partial update 合并回主 state。

这种「partial state 返回」的设计有两个直接收益。第一,节点之间不会出现「忘了传某个字段导致后续节点拿到 None」这种隐式 bug;第二,函数天然可组合、可单测,把节点单独拎出来喂一份假 state 就能验证逻辑,不需要起整张图。

观察 这套契约的实际效果是:节点作者永远不会不小心读到上一次 partial update 留下的脏字段。LangGraph 在内部把节点的输入冻结成只读视图,任何对 state 的修改都必须通过返回值显式表达,这种约束让多节点协作时几乎不可能出现「谁偷偷改了 user_id」这种灵异问题。在多智能体场景下,这种「只读入、可写出口」的契约比传统面向对象的状态封装更彻底。

工程经验上,讲师建议把节点写成「薄包装」:节点函数本身只负责调度,真正的业务逻辑下沉到独立函数或 service 类里。这样节点的 LangGraph 依赖就被压缩到最小,后续要换成 Celery 任务或 FastAPI 端点也几乎零成本。

Edge:固定边、条件边与 START/END 哨兵

Edge 是图上的「调度规则」。LangGraph 把边分成三类,语义层层递进:

边类型 API 适用场景
固定边 add_edge("A", "B") A 跑完一定进 B,无分支
条件边 add_conditional_edges("A", router_fn, mapping) A 跑完根据 router 决定下一个节点
哨兵边 add_edge(START, "A") / add_edge("B", END) 显式标记图入口与出口

条件边里的 router_fn 接收 state,返回一个字符串(或 Send 对象),LangGraph 按 mapping 字典查表跳到对应节点。STARTEND 不是节点,而是 LangGraph 暴露的常驻 sentinel,用来锚定图的入口与终点;不写 END 边意味着那张子图永远没有出口,编译期就会被打回。值得强调的是,条件边映射表里所有可能的返回值都必须有对应目标,否则 LangGraph 会在 invoke 时抛 KeyError------这是条件边调试时的第一优先排查点。

StateGraph → compile → invoke/stream

LangGraph 的运行时生命周期只有三步:

  1. StateGraph(StateSchema) 实例化一张空图;
  2. 反复 add_node / add_edge / add_conditional_edges 把图填满;
  3. graph.compile(checkpointer=..., interrupt_before=...) 把图编译成可执行对象,然后用 invokestream 跑起来。

compile 这一步在工程上极其关键:它会把节点签名、边的可达性、reducer 一致性、checkpointer 类型全部校验一遍。结构错误在这一步就抛,而不是等到第一次 invoke 才崩。这一设计直接来自 LangGraph 团队对「图跑一半才报错的调试痛苦」的反思------LangGraph GitHub 仓库(github.com/langchain-a...%25E9%2587%258C%25E7%259A%2584%25E7%259B%25B8%25E5%2585%25B3 "https://github.com/langchain-ai/langgraph)%E9%87%8C%E7%9A%84%E7%9B%B8%E5%85%B3") issue 讨论多次印证这一点。

数据 默认 invoke 会一直跑到 END 才返回整份最终 state;而 stream_mode="updates" 每跑完一个节点就吐一份 partial update,stream_mode="messages" 则按 token 粒度吐出 LLM 输出。生产 chatbot 里把 stream_mode 切到 messages 后,首字延迟可以从「等 LLM 全量返回」压到「首个 token 落地」级别,体感差异通常以秒计。stream 配合 stream_mode="values" 还能用于前端实时刷新中间状态,这是 LCEL 链式管道很难低成本实现的。

compile 时的两项关键注入:checkpointer 与 interrupt

compile 不仅是校验点,也是注入运行时依赖的入口。最常用的两个参数是 checkpointerinterrupt_before / interrupt_after

  • checkpointer :传入 InMemorySaverSqliteSaverPostgresSaver 之一,LangGraph 会把每一步的 state 快照落到对应后端,后续用 thread_id 就能恢复任意时刻的会话;
  • interrupt_before / interrupt_after:声明某些节点执行前/后需要人工闸门介入,典型用法是把「工具调用」节点之后打断,让审核人员决定是否真正执行。
python 复制代码
from langgraph.checkpoint.sqlite import SqliteSaver
checkpointer = SqliteSaver.from_conn_string("chat.db")
app = graph.compile(checkpointer=checkpointer, interrupt_before=["tool_node"])

config = {"configurable": {"thread_id": "user-42"}}
app.invoke(initial_state, config=config)

数据 InMemorySaver 进程一死记忆即亡,而 SqliteSaver 只需 sqlite3.connect(check_same_thread=False) 一行即可让对话跨进程重启存活,生产环境的迁移成本几乎可以忽略。PostgresSaver 则进一步把状态共享给多 worker 部署,适合 FastAPI 多实例或 K8s 滚动更新场景。

这套机制把「持久化」和「人在回路(HITL)」这两件事提到了图编译期声明,而不是散落在业务代码的 if 判断里------这是 LangGraph 比 LCEL 在复杂工作流上的核心优势。

Reducer 的工程意义:并行节点的合并协议

Reducer 表面上是「合并函数」,工程上其实是一条「并发写协议」。当一张图里有多个节点并行执行、且都要写同一个 state key 时,没有 reducer 就会直接抛 InvalidUpdateError:

观察 LangGraph 并行节点若不配 reducer,两个分支同时写同一 state key 会直接抛 InvalidUpdateError。这条规则的底层原因是:LangGraph 拒绝默认「最后写者赢」的隐式语义,强制开发者显式声明「我要追加」「我要取并集」「我要取最大值」之类的合并策略。常见模式是 Annotated[List[...], operator.add] 做列表追加,或自定义 reducer 做去重、限长、按时间戳排序。

这种「不声明就不让写」的强约束,在多智能体场景里几乎是必须的------你想让 planner 与 retriever 同时往 findings 字段里塞结果,如果默认覆盖,后跑的节点会无声吞掉前一个节点的工作。add_messages 这类内置 reducer 之所以默认就走追加语义,正是因为对话场景天然是「累积」而不是「覆盖」。

先看图再跑图:draw_mermaid 可视化调试

LangGraph 内置了一个经常被低估的调试工具:graph.get_graph().draw_mermaid()。它把当前图的节点、边、条件分支全部转成 Mermaid 语法,直接渲染成可视化流程图。

工程实践上,讲师建议的顺序永远是「先调 draw_mermaid 看图,再 invoke 跑图」。原因有三:第一,Mermaid 图能把「漏接 END」这种结构错误一眼看出来;第二,条件边的 mapping 表在图上会以标签形式呈现,对照路由函数返回值非常方便;第三,可视化是团队评审图结构的最佳载体------一段 Python 代码评审不直观,一张流程图评审却能在一分钟内对齐。

python 复制代码
print(graph.get_graph().draw_mermaid())

LangGraph 在 compile 时就会校验边的可达性,比如节点 A 没有出边、也没有被任何 END 收口,会直接报错。但「每个节点都被图正确连接」这种语义级错误,只有 Mermaid 图能直观暴露。把这两层校验(编译期 + 可视化)叠起来,基本可以消灭「图能编译但跑出来不对」的低级失误。

工程踩坑清单

最后给出一份实战中反复出现的踩坑清单,作为这一节的备忘:

  • messages 字段忘加 add_messages,每轮对话历史被覆盖;
  • 条件边返回值不在 mapping 表里,运行时报 KeyError;
  • 多个并行节点写同一 key 没声明 reducer,抛 InvalidUpdateError;
  • 没接 END 边,子图陷入死循环或永不返回;
  • checkpointerInMemorySaver 但部署到多 worker,状态在进程间漂移;
  • 没在 invoke 时传 thread_id,导致每次都拿到一个新会话。

把这六条贴在 IDE 旁边,基本能挡掉 LangGraph 入门阶段 80% 的非业务类报错。

小结

State、Node、Edge、compile 这四个最小原语,共同把 LangGraph 抬升到了「用声明式状态图替代命令式 if-else 链」的工程层级。State 用 TypedDict/Pydantic + Annotated reducer 同时锁死 schema 与合并语义;Node 用 partial state 返回强制声明式更新;Edge 用固定/条件/哨兵三类承载调度;compile 既是校验点也是 checkpointer 与 interrupt 的注入点。掌握这四件套,后续的并行、迭代、人在回路就只是在它们之上做加法,而非推倒重来。

顺序工作流:最小可用的 LangGraph 骨架

LangGraph 把"图"作为一等公民暴露给开发者,这是它在 LCEL 之后撑起整个 agent 工程栈的核心原因之一。整张图运行时共享一份 state,节点之间通过边调度,所有合并规则在编译期定型,而不是散落在各处的 if-else 拼接------这种"图优先"的设计哲学直接决定了后续的工程节奏。

顺序工作流的"骨架"地位

在 LangGraph 官方文档(langchain-ai.github.io/langgraph/)...%25E4%25B8%25AD%2C%25E5%2587%25A0%25E4%25B9%258E%25E6%2589%2580%25E6%259C%2589%25E8%25BF%259B%25E9%2598%25B6%25E8%258C%2583%25E5%25BC%258F%25E2%2580%2594%25E2%2580%2594%25E6%259D%25A1%25E4%25BB%25B6%25E8%25BE%25B9%25E3%2580%2581Send "https://langchain-ai.github.io/langgraph/)%E4%B8%AD,%E5%87%A0%E4%B9%8E%E6%89%80%E6%9C%89%E8%BF%9B%E9%98%B6%E8%8C%83%E5%BC%8F%E2%80%94%E2%80%94%E6%9D%A1%E4%BB%B6%E8%BE%B9%E3%80%81Send") API、子图、Human-in-the-Loop------都建立在一个最朴素的骨架上:START→节点A→节点B→END,state 沿链路单向流动、逐节点累积。这条直线既是 LangGraph 的"hello world",也是后续所有工程演化的起点。任何复杂图结构都可以从一个能跑通的顺序链路开始铺,这是图优先设计在工程实践里最朴素、也最容易被低估的礼物。

典型案例形态:输入解析→LLM 处理→结果格式化

把这条骨架落到真实可跑的最小工程,通常切成三个职责单一的节点:第一个节点负责输入解析 ,清洗与归一化用户原始 prompt、文件上传、上下文窗口;第二个节点是LLM 处理 ,调用大模型做理解、生成或工具决策;第三个节点做结果格式化,把模型输出包装成下游系统(前端 UI、API response、数据库行)能直接消费的对象。

每个节点只关心自己的输入与输出,黑盒边界清晰。这一约束让单元测试可以独立写:解析节点用纯字符串断言,LLM 节点用 mock 模型替身验证 prompt 模板,格式化节点对 schema 字段做类型检查。任何一个节点需要替换------把 GPT-4 换成开源模型,把 JSON 解析换成 Pydantic validator------都不会波及其他环节,这是顺序工作流最直接的工程红利。

state 设计的最佳实践

顺序链路虽然简单,但 state schema 写不好,后续一旦加分支、并行,就会埋下耦合的地雷。该教程反复强调一条规则:把 input 字段、中间字段、output 字段分开命名,避免节点间隐式耦合。具体到 LangGraph 里,通常用 TypedDict 或 Pydantic 模型显式列出每个键的类型与初始值:

python 复制代码
from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class PipelineState(TypedDict):
    user_query: str          # input 字段,只读不写
    parsed_intent: dict      # 中间字段,仅解析节点写
    raw_llm_output: str      # 中间字段,仅 LLM 节点写
    final_response: dict     # output 字段,仅格式化节点写

add_messages reducer 在这里不需要登场------那是聊天场景的合并规则;但如果想保留每一步的中间产物用于调试或 audit,把中间字段声明为 Annotated[list, operator.add] 这样的累积型即可。核心原则:每个节点只能写自己负责的字段,读任何字段都可以。这条约束一旦养成,后面接入条件边、并行分支时,数据流图就是清晰可读的,不会出现"A 节点偷偷改了一个 B 节点以为是自己独占的 key"这种隐性 bug。

与直接写函数调用链的区别

既然只是直线调用,为什么不直接写 f1(input) → f2(result) → f3(result2),而要套一层图?答案藏在三个 LangGraph 框架自带的能力里------执行追踪、可插拔 checkpointer、与渐进扩展。这三项是把"图"从表达方式升格为工程基础设施的关键。

第一,执行追踪 。每条边、每个节点在 runtime 内部都留有 trace,配合 LangSmith(docs.smith.langchain.com/)可以把每次%25E5%258F%25AF%25E4%25BB%25A5%25E6%258A%258A%25E6%25AF%258F%25E6%25AC%25A1 "https://docs.smith.langchain.com/)%E5%8F%AF%E4%BB%A5%E6%8A%8A%E6%AF%8F%E6%AC%A1") invoke 的状态快照、节点耗时、token 消耗全部可视化------直接写函数链是拿不到这些结构化信息的,排查问题时只能在 print 里大海捞针。

第二,可插拔 checkpointer 。顺序链路在第一天可能只是无状态 ETL 管道,但只要业务出现"多轮对话""审批回退""断点续跑"中的任何一种需求,直接函数链就要被推倒重写;而 LangGraph 只需要在 compile() 时多挂一个 checkpointer。InMemorySaver 适合本地调试,SqliteSaverPostgresSaver 适合生产持久化(参考 github.com/langchain-a...%25E3%2580%2582%25E8%25BF%2599%25E7%25A7%258D%2522%25E8%25AE%25B0%25E5%25BF%2586%25E5%2581%259A%25E6%2588%2590%25E9%2585%258D%25E7%25BD%25AE%25E9%25A1%25B9%25E8%2580%258C%25E4%25B8%258D%25E6%2598%25AF%25E4%25B8%259A%25E5%258A%25A1%25E4%25BB%25A3%25E7%25A0%2581%2522%25E7%259A%2584%25E8%25AE%25BE%25E8%25AE%25A1%2C%25E6%25AD%25A3%25E6%2598%25AF%25E5%259B%25BE%25E7%25BB%2593%25E6%259E%2584%25E7%259B%25B8%25E5%25AF%25B9%25E5%2587%25BD%25E6%2595%25B0%25E9%2593%25BE%25E6%259C%2580%25E5%2585%25B3%25E9%2594%25AE%25E7%259A%2584%25E4%25B8%2580%25E5%25A4%2584%25E5%25B7%25AE%25E5%25BC%2582%25E3%2580%2582 "https://github.com/langchain-ai/langgraph)%E3%80%82%E8%BF%99%E7%A7%8D%22%E8%AE%B0%E5%BF%86%E5%81%9A%E6%88%90%E9%85%8D%E7%BD%AE%E9%A1%B9%E8%80%8C%E4%B8%8D%E6%98%AF%E4%B8%9A%E5%8A%A1%E4%BB%A3%E7%A0%81%22%E7%9A%84%E8%AE%BE%E8%AE%A1,%E6%AD%A3%E6%98%AF%E5%9B%BE%E7%BB%93%E6%9E%84%E7%9B%B8%E5%AF%B9%E5%87%BD%E6%95%B0%E9%93%BE%E6%9C%80%E5%85%B3%E9%94%AE%E7%9A%84%E4%B8%80%E5%A4%84%E5%B7%AE%E5%BC%82%E3%80%82")

第三,渐进扩展 。在直线骨架上插入条件边或并行分支,不需要重构数据流,只需在图定义里多写一行 add_conditional_edges,或在节点里 Send 出去。这条"从顺序到分支"的演化路径,是图结构相对函数链最关键的工程优势。

观察:不带 checkpointer 的 chatbot 每轮 invoke 都是失忆的,对话历史无法跨轮保留。该教程 demo 反复演示过这一现象:同一张图、不加 checkpointer 时,用户第二轮提问完全丢失第一轮上下文;只要在 compile 时挂上 MemorySaver,再用 thread_idinvoke,历史就能自动注入 state。这是图结构把"记忆"做成配置项而不是业务代码的典型例子。

除了这三项,在测试与可观测性层面,图结构同样占优。函数调用链一旦出错,只能靠日志逆向;而 LangGraph 每条边的执行结果都被序列化进 state,任何一次失败的 invoke 都可以被 get_state(thread_id) 拉出来逐字段比对,事后排查的成本要低一个数量级。

从顺序骨架到条件边与并行分支

该教程把"先跑通直线,再插入条件边,再上并行分支"称为 LangGraph 工程化的最小可行节奏 。原因是顺序链路是所有复杂图结构的子集:条件边只是在某两个节点之间增加一个路由函数,根据 state 的某个字段决定下一跳;并行分支则把"一个节点的输出送给多个下游节点"用 Send API 拆开,但每条支路本身仍然是直线。

把状态累积的方向理顺、把节点边界划清之后,再加分支只是"在图上加边",而不是"重写业务逻辑"。这也是为什么 state 设计那一节会反复强调"每个节点只写自己负责的字段":一旦命名空间混乱,加分支时就要重新盘点数据流,工程节奏立刻被打断。

数据:N 路独立 LLM 调用如果用同步循环执行,总耗时近似 N×T(单次调用时长);改用 asyncio.gather 或者 LangGraph 的并行 Send 拓扑,总耗时可以压到接近 T(最长那条支路的时间)。这是顺序骨架演化成并行分支时,最容易被低估的工程收益。

工程节奏上,推荐的做法是:第一步把 START→A→B→END 跑通,跑通后立刻给每条边写一个最小单测;第二步引入条件边,加路由函数,继续跑回归;第三步再用 Send 或多节点扇出上并行。每一步都建立在前一步的绿色构建之上,既不浪费心智去同时建模复杂度,也避免了一开始就陷入"为可能的分支过度设计"的陷阱。LangGraph 的 StateGraph 在这一步体现的灵活性,是它在 agent 工程栈里取代 LCEL 管道的根本原因(参考 python.langchain.com/docs/introd...%25E3%2580%2582 "https://python.langchain.com/docs/introduction/)%E3%80%82")

这套节奏在工程上还有一个常被忽视的好处:它把"复杂度预算"拆成了几笔小额支出,而不是一次性的巨额投入。在多智能体项目里,直线骨架跑通后,条件边、并行分支、子图、HITL 会逐项叠加,每叠加一项都有单测兜底、回滚成本极低;相反,一开始就试图把分支、循环、人工审核全部建模,得到的常常是一张"看起来很全但没人敢动"的图。

收尾

顺序工作流是 LangGraph 工程的最小可用地基。它不炫技、不复杂,但因为 state schema 显式、节点边界清晰、checkpointer 与 tracing 可插拔,它能无缝承载后续的条件、并行、迭代、人工审核等所有进阶模式。从"hello world"的直线出发,把每一段都打磨成可单测、可替换、可观测的节点,再用图结构把它们串成可以演化的网络------这就是 LangGraph 工程化的核心节奏。

并行工作流:扇出扇入与 Send API

并行的动机------把串行延迟折叠成最慢分支

在 LLM 应用进入"生产可用"门槛之前,大多数工程师写出的第一条链路是串行链:先 retrieve,再 summarize,再 evaluate,再 format------每一个节点必须等上一个节点返回才能启动。即便每个步骤只花 2 秒,五个节点叠加就是 10 秒,用户早已失去耐心。更关键的是,这些子任务里有一大类天然是互不依赖 的:多维度评估(正确性、毒性、简洁度、引用质量)、多源检索(Wikipedia、arXiv、内部文档库)、多视角生成(乐观 / 悲观 / 务实),它们之间没有任何数据耦合,串行执行本质上是把"并行的 N 倍延迟"硬塞给最终用户。LangGraph 把"扇出(fan-out)并发执行、扇入(fan-in)汇总"的能力作为一等公民暴露给 StateGraph,只要图结构能描述"一个起点、N 个平行终点、一个汇聚点",运行时就会自动利用 asyncio 调度让多个 node 并发跑起来,而不需要工程师显式 await asyncio.gather

静态并行------同源节点的多条出边

LangGraph 最朴素的并行模式是静态并行 :从同一个 node 出发,通过多次 add_edge(source, target) 引出多条边,每条边指向一个下游 node。运行时,LangGraph 会把这些下游 node 投递到同一个 super-step 里的 task 队列,并以 asyncio.gather 的语义并发执行,直到所有出边指向的 node 完成,才进入下一个 super-step。这是一种"编译期定型"的并行------分支数、分支目标、汇聚点全部写死在图结构里,适合"维度固定、节点已知"的场景,例如固定的三类评估、固定的三个数据源。

下面是一段典型的多维度评估配置片段,展示如何用静态并行同时跑三个评判:

python 复制代码
from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
import operator

class EvalState(TypedDict):
    question: str
    answer: str
    scores: Annotated[list[float], operator.add]  # reducer 必须显式声明

def evaluate_correctness(state): return {"scores": [score_correctness(state)]}
def evaluate_toxicity(state):   return {"scores": [score_toxicity(state)]}
def evaluate_helpful(state):    return {"scores": [score_helpfulness(state)]}

def aggregate(state):
    return {"final": sum(state["scores"]) / len(state["scores"])}

g = StateGraph(EvalState)
g.add_node("correctness", evaluate_correctness)
g.add_node("toxicity", evaluate_toxicity)
g.add_node("helpful", evaluate_helpful)
g.add_node("aggregate", aggregate)

g.add_edge(START, "correctness")
g.add_edge(START, "toxicity")
g.add_edge(START, "helpful")
g.add_edge("correctness", "aggregate")
g.add_edge("toxicity", "aggregate")
g.add_edge("helpful", "aggregate")
g.add_edge("aggregate", END)

注意三处细节:第一,add_edge(START, ...) 三次调用让三个评估 node 进入同一个 super-step;第二,aggregate 节点的入边有三条,LangGraph 会在该节点真正运行前等待三个上游全部完成,这是天然的"扇入等待",完全不需要 asyncio.wait;第三,scores 字段必须用 Annotated[list[float], operator.add] 声明 reducer,否则三个 node 同时写同一 key 会直接抛 InvalidUpdateError,这是初学者最容易踩的第一个坑。

动态并行------Send API 的 map-reduce 语义

静态并行假设"分支数在编译期已知"。但生产场景里,大量并行度是运行时才确定 的:文档分块后想对每个 chunk 并发 embedding、检索返回 top-k 想对每条 doc 并发 rerank、用户上传 N 张图想每张都跑 captioning。这种"数据驱动的并行"用 add_edge 描述不了,必须用 Send API ------langgraph.constants.Send 允许在 conditional edge 函数里按数据条数动态派发任务:

python 复制代码
from langgraph.constants import Send

def distribute(state: RagState):
    chunks = state["chunks"]  # 运行时长度才确定
    return [Send("embed_one", {"chunk": c}) for c in chunks]

def embed_one(state):  # 单 chunk 处理
    return {"vectors": embed(state["chunk"])}

def reduce_vectors(state):
    return {"index": build_index(state["vectors"])}

g.add_conditional_edges("split", distribute, ["embed_one"])
g.add_edge("embed_one", "reduce_vectors")
g.add_edge("reduce_vectors", END)

这里的 distribute 就是一个 map 函数,每条 Send 调用在运行时被 LangGraph 翻译成一个独立的并行分支,等所有分支汇入 reduce_vectors 才继续推进。这是典型的 map-reduce 拓扑 :split 出数据、map 并行处理、reduce 聚合结果。Send API 的关键能力在于"分支数 = 数据条数",完全跳过编译期静态推断,这对 RAG、长文档摘要、批量工具调用都是核心能力。官方文档把这种模式称为 Map-Reduce 章节,详细示例见 LangGraph 文档 langchain-ai.github.io/langgraph/c...

观察 Send 分支和静态并行分支的 reducer 行为完全一致,任何被并行分支写回的 state key 都必须在 TypedDict 里显式标注 Annotated[T, reducer],否则第一次运行就会抛 InvalidUpdateError。LangGraph 的这一硬约束,实际上是在 graph 层把"并发写一致性"问题前置到了 schema 定义阶段,而不是把责任推给业务逻辑。

并行写状态的硬约束------reducer 三件套

LangGraph 的 state 在每个 super-step 结束时统一"合并"。当多个 node 在同一 super-step 内对同一字段写回不同的值,LangGraph 必须知道怎么把这些值合并------这正是 reducer 的职责。工程实践里,LangGraph 常用三条 reducer:

场景 reducer 写入语义
列表型状态(分数、chunk、向量) operator.add 拼接为新 list
消息历史(chatbot) add_messages 按 id 去重 + 追加
标量覆盖(单线程场景) 默认覆盖 后写赢(并行场景禁用)

add_messages 是 LangChain 生态里专门为 BaseMessage 列表设计的 reducer,内部会基于 message id 去重,适合工具调用多轮回放与 checkpointer 持久化配合------这在 LangChain 官方文档 python.langchain.com/docs/concep... 里有完整定义。operator.add 则是 Python 内置操作符的语义,任何 list 都可以直接拼接。标量字段在并行场景里其实是禁用默认覆盖的:两个 node 同 super-step 写同一 str,LangGraph 无法判断哪个"赢",于是直接 fail-fast 抛异常。

观察 工程上有两条经验值得记录:第一,凡是有可能并行的写入,字段定义都必须显式 Annotated[..., reducer];第二,聚合节点(如 aggregate、reduce)应当只读上游、不要再用普通字段返回"汇总值",否则它和上游并行分支的 reducer 行为会冲突。LangGraph 的 InvalidUpdateError 是 fail-fast 机制,与其在 runtime 兜底,不如在 schema 阶段就锁死并发写契约。

收益量化与工程取舍

并行最大的收益是延迟数据 假设三个独立 LLM 评估每个耗时 T 秒,串行执行总延迟 3T,而静态并行后总延迟约等于 max(T),也就是被最慢的那一支决定。如果三个评估耗时近似,延迟直接被压缩到 1/3;如果其中一个评估因 reasoning 步骤更长耗时 2T,延迟就由它决定、另两支被视为"几乎免费"。这就是 LLM 工程里经常强调"把没有依赖的节点尽可能并行"的根本动机------Latency 由最慢分支决定,与分支数解耦。在 RAG 多源检索场景里,三路 retrieve 从 3T 压到 max(T) 通常意味着用户感知到的首字延迟从 3-4 秒掉到 1-1.5 秒。

但并行不是免费的。三个真实工程成本必须算清:

  1. Token 与费用:并发并不减少 token,三路评估还是要花三份钱,并行只是把"串行的墙钟时间"换成"并行的钱";
  2. Debug 复杂度:并行分支的日志交错,排查"哪个维度打错分"变得更难,生产里几乎必须配合 LangSmith 的 trace;
  3. 错误隔离:一个分支失败不应拖垮整张图,通常给每个并行 node 包一层 try/except,或者在 LangGraph 的 node 边界返回 partial state;
  4. 上游速率限制 :LLM provider 通常有 TPM/RPM 配额,N 路并发更容易触发 429,需要 asyncio.Semaphore 节流或排队。

把这些权衡做成一张决策矩阵:

场景 推荐并行方式 理由
固定 3-5 个评估维度 静态并行(add_edge) 维度稳定,编译期定型最清晰
检索 top-k 后并行 rerank Send API(动态) k 运行时才确定
多源检索(Wiki/ArXiv/内库) 静态并行 源数量固定
长文档分块后并发 embedding Send API(动态) chunk 数运行时才确定
多步推理(chain-of-thought) 不并行 步骤间强依赖

小结

并行工作流是 LangGraph 把"图优先"哲学落到延迟优化的关键一环:静态并行用多条出边在编译期描述固定维度的并发,Send API 用 conditional edge 在运行时按数据条数生成动态分支,二者最终都汇入同一个 super-step 的扇入节点,并通过 reducer 保证并发写入的一致性。理解"延迟由最慢分支决定"和"并发写必须配 reducer"这两条原则,基本就能在生产里安全使用 LangGraph 的所有并行范式;再配合 LangSmith 的 trace 与 asyncio.Semaphore 的节流,就能在延迟、成本、可观测性之间找到工程上的平衡点。

条件与迭代工作流:让图学会决策与自我修正

条件工作流:让图自己选路

条件工作流解决的是"下一步该交给谁"的问题。串行链路是写死的 DAG,而真实业务里用户的请求形态千变万化:有人要查天气,有人要写代码,有人只是想闲聊。如果每条请求都走"检索→总结→格式化"这条固定路径,要么答非所问,要么在 token 上空耗。LangGraph 的解法是把"下一步该去哪个节点"的决定权,从一个 Python 函数收回来------这就是路由函数(router)的设计动机。

路由函数的签名非常朴素:读取当前 state,返回一个字符串,即下一节点的 name。注册方式是在 StateGraph 上调用 builder.add_conditional_edges(source, router, mapping),其中 mapping 是一个字典,把路由函数返回的字符串映射成实际的节点对象。这样图编译器才知道"router 返回 'tool' 时跳到 ToolNode,返回 'final' 时跳到 END"。

python 复制代码
def route_after_intent(state: State) -> str:
    intent = state["classification"]["intent"]
    return {"search": "retrieve", "code": "code_runner",
            "chat": "chitchat"}.get(intent, "chitchat")

builder.add_conditional_edges("classify", route_after_intent, {
    "retrieve": "retrieve",
    "code_runner": "code_runner",
    "chitchat": "chitchat",
})

典型场景一是意图分诊 (intent triage):节点 A 是一个结构化分类器,用 with_structured_output 让 LLM 输出 {intent: "search" | "code" | "chat"},router 拿到 intent 后分别路由到不同处理分支。这种分诊不必非用 LLM,简单的正则或关键词匹配就够了------把便宜的方法用在便宜的任务上,是工程上第一条铁律。

典型场景二是质量闸门(quality gate):评估节点跑完打分后,router 根据分数高低决定"放行到下游"还是"打回重写"。这套模式天然就引出了下一节要讲的迭代工作流。

迭代工作流:把图改造成会自我修正的回路

条件边让图可以"分叉",迭代边让它可以"回头"。一旦条件边的目标节点指向了上游,普通的 DAG 就变成了有环图。环的存在让图第一次拥有了自我修正(self-correction)的能力:先生成一稿,再让评估节点挑刺,挑出问题就回炉,挑不出问题才放行。

工程上的标准范式是 生成→评估→不达标回炉重写 三步循环:

  • 生成节点(generator):接收 prompt 与上一次反馈,产出 draft。
  • 评估节点(evaluator):拿 draft 比对 rubric,打出结构化分数与文字点评。
  • 路由函数:读分,达标走 final,未达标回到 generator,并把"为什么扣分"塞进 state 的 feedback 字段。

这种回路在代码生成场景特别有效:LLM 一次性写对的概率不高,但给它看错误日志和单元测试失败原因,它往往第二轮就能修对。LLM 的"反思"能力并不是天然存在的------它需要被显式地用 prompt 和状态设计出来。

观察 在没有评估节点、仅靠 prompt 里写"请仔细检查"的方案里,模型几乎不会真的回头检查自己;只有当评估结果以 ToolMessage 或结构化字段的形式被回灌到下一轮 prompt,模型才会把"上次的扣分点"当作硬约束来满足。这条观察是 LangGraph 工作流相对裸 LLM 调用最直接的质变来源------不是模型变了,而是信息流变了。

循环必须有出口------状态里的"计数器与刹车"

环是好事,但失控的环会变成token 黑洞。生产事故里最经典的一幕:某个深夜流量高峰,LLM 评估节点因为 prompt 边界 case 持续打出低于阈值的分数,生成节点不断被打回,周而复始地调用大模型,一个会话跑了几百轮,把账户烧穿。这是任何 Agentic 系统都必须正面应对的问题------不是"环要不要设",而是"环怎么安全地停下来"。

工程上有两道刹车,缺一不可。第一道是软阈值 (threshold):评估节点的分数必须高于某个 rubric 阈值才算达标,这是质量保证。第二道是硬上限 (max_iterations):把当前轮次计数器 iteration 写进 state,每次进入 generator 前自增;一旦 iteration >= max_iterations,router 不再返回 generator,而是强制走"凑合输出"分支------比如直接返回最近一版 draft,或者走一个 fallback 节点,用模板兜底。

python 复制代码
def router_with_brake(state: State) -> str:
    if state["score"] >= THRESHOLD:
        return "format"
    if state["iteration"] >= MAX_ITERATIONS:
        return "fallback"          # 强制出口
    return "regenerate"

数据 LangGraph 把 iteration 这类"累加而非覆盖"的字段,设计成由 reducer 函数显式控制:常见做法是用 Annotated[int, operator.add] 标注,告诉 StateGraph"这个 key 写入时不要直接覆盖,而是把新旧值相加"。这与 add_messages 处理对话历史的逻辑完全一致------所有"会被多次更新"的字段都必须显式声明 reducer,否则并发写会抛 InvalidUpdateError,这个问题在并行与迭代叠加的工作流里尤其致命。

MAX_ITERATIONS 设成 3 还是 5,直接决定预算。一个长上下文 LLM 调用动辄几千 token,五轮就是上万,普通用户一次会话跑几十次反思,账单立刻爆炸。生产里更稳妥的做法是把 MAX_ITERATIONStemperature 一起作为图编译时的可调参数,在 LangSmith 上做 A/B(docs.smith.langchain.com/),根据真实命中率而非...%2C%25E6%25A0%25B9%25E6%258D%25AE%25E7%259C%259F%25E5%25AE%259E%25E5%2591%25BD%25E4%25B8%25AD%25E7%258E%2587%25E8%2580%258C%25E9%259D%259E%25E7%25BA%25B8%25E9%259D%25A2 "https://docs.smith.langchain.com/),%E6%A0%B9%E6%8D%AE%E7%9C%9F%E5%AE%9E%E5%91%BD%E4%B8%AD%E7%8E%87%E8%80%8C%E9%9D%9E%E7%BA%B8%E9%9D%A2") rubric 去收紧阈值。

评估节点的两种实现:规则打分 vs LLM-as-judge

评估节点是整个迭代回路的"裁判"。裁判的实现路径有两条,选择直接决定了成本与质量的天花板。

维度 规则打分 LLM-as-judge
速度 毫秒级,纯 Python 秒级,需要一次 LLM 调用
成本 几乎为零 每轮评估都付一次 token 钱
鲁棒性 对 rubric 边界 case 失效 对措辞、风格等模糊维度更准
可解释性 100% 可追溯 取决于 prompt 设计
适用场景 长度、关键词、JSON schema、合规检查 流畅度、逻辑性、引用质量、毒性

数据 生产里最经济的不是二选一,而是两层漏斗:第一层用规则筛掉大约九成明显不合格的样本(长度 < 50 字、含敏感词、JSON 解析失败、必填字段缺失),剩下的一成才送给 LLM-as-judge 做精细打分。这样既把平均 token 消耗压到接近纯规则方案,又保留了 LLM 对边缘 case 的判别力。TripMate 这类旅行规划器,FAQ 类问题几乎 100% 走规则,长行程定制才进 LLM 复核,就是同一套漏斗思路。

LLM-as-judge 的常见实现是再起一次 LLM 调用,用 with_structured_output 强制它返回 {score: int, feedback: str},这样 router 才能稳定地读取数值字段。prompt 里要把 rubric 写得像一份正式评分表------5 分制里每分对应什么行为------否则分数分布会塌缩到一两个值,完全失去区分度,router 也就形同虚设。

规则打分也有不少反模式:不要用一堆 if/else 把规则节点写成意大利面条,更稳的做法是把规则封装成 Rule = (predicate, score, reason) 的 dataclass 列表,跑一次循环累加扣分------这样规则可测试、可灰度、可在 LangSmith 里逐条回放。LangChain 文档对此类结构化输出的支持详见官方文档 python.langchain.com/docs/introd...

反思循环:条件+迭代组合出的"质变"模式

把条件工作流和迭代工作流叠加,就得到了 Agentic 文献里反复出现的反思循环(reflection loop)。它的本质是"条件路由决定走哪条路,迭代边让这条路可以重复走",二者缺一不可:少了条件,所有问题都进反思环,浪费 token;少了迭代,反思只能发生一次,模型没机会把点评当作下一轮约束。

反思循环在论文写作、代码生成、长文档摘要三类任务上,普遍比单次生成的可用率高出一截。原因不在模型"变聪明了",而在于信息流变成了闭环:上一轮的输出不再是被消费的终点,而是下一轮的输入。这与人写文章时先写一稿、再通读修改的工作模式完全同构------LLM 的反思能力是被状态工程"逼"出来的。

落地到 LangGraph,反思循环的最小可行配置只需要五个节点:generateevaluatereflect_routerformatEND。其中 reflect_router 同时承担两件事:一是根据分数路由到 format 或回 generate,二是判断 iteration 是否撞上 max_iterations,撞上就走兜底分支。这个 router 本质上就是一个十几行的 Python 函数,但它让整张图获得了"知道自己不知道"的能力。

官方文档对 add_conditional_edges 的参数语义有完整描述(见 LangGraph 文档 langchain-ai.github.io/langgraph/)...%2CLangChain "https://langchain-ai.github.io/langgraph/),LangChain") 一侧的 with_structured_output 配合 Pydantic schema(docs.pydantic.dev/)则让评估结果天然成为...%25E5%2588%2599%25E8%25AE%25A9%25E8%25AF%2584%25E4%25BC%25B0%25E7%25BB%2593%25E6%259E%259C%25E5%25A4%25A9%25E7%2584%25B6%25E6%2588%2590%25E4%25B8%25BA%25E5%258F%25AF%25E8%25B7%25AF%25E7%2594%25B1%25E7%259A%2584%25E5%25BC%25BA%25E7%25B1%25BB%25E5%259E%258B%25E6%2595%25B0%25E6%258D%25AE%25E3%2580%2582%25E6%258A%258A%25E8%25BF%2599%25E4%25B8%25A4%25E8%2580%2585%25E7%25B2%2598%25E5%2590%2588%25E8%25B5%25B7%25E6%259D%25A5%2C%25E5%25B0%25B1%25E6%2598%25AF%25E4%25B8%2580%25E4%25B8%25AA%25E7%2594%259F%25E4%25BA%25A7%25E5%258F%25AF%25E7%2594%25A8%25E7%259A%2584%25E5%258F%258D%25E6%2580%259D%25E5%259B%259E%25E8%25B7%25AF%25E2%2580%2594%25E2%2580%2594%25E4%25B9%259F%25E6%2598%25AF "https://docs.pydantic.dev/)%E5%88%99%E8%AE%A9%E8%AF%84%E4%BC%B0%E7%BB%93%E6%9E%9C%E5%A4%A9%E7%84%B6%E6%88%90%E4%B8%BA%E5%8F%AF%E8%B7%AF%E7%94%B1%E7%9A%84%E5%BC%BA%E7%B1%BB%E5%9E%8B%E6%95%B0%E6%8D%AE%E3%80%82%E6%8A%8A%E8%BF%99%E4%B8%A4%E8%80%85%E7%B2%98%E5%90%88%E8%B5%B7%E6%9D%A5,%E5%B0%B1%E6%98%AF%E4%B8%80%E4%B8%AA%E7%94%9F%E4%BA%A7%E5%8F%AF%E7%94%A8%E7%9A%84%E5%8F%8D%E6%80%9D%E5%9B%9E%E8%B7%AF%E2%80%94%E2%80%94%E4%B9%9F%E6%98%AF") LangGraph 相对"裸 prompt + 单次 LLM 调用"最显著的工程优势。真正让这套回路在生产里跑得稳的,不是模型本身,而是那一对"条件路由 + 迭代边"组合出的、可以自我修正的状态机:条件提供分叉,迭代提供回路,而 state 与 reducer 保证了两者叠加时不会写出竞态------这也是为什么反思循环几乎从不脱离 LangGraph 而单独存在的原因。

第一个 Agentic Chatbot:消息状态与图结构

从最小 state 起步:messages 与 add_messages reducer

在 LangGraph 里构建 chatbot 的第一步,不是写 prompt、不是选模型,而是把"对话"这件事抽象成一份会被节点反复读写的状态。最朴素的写法是用一个 TypedDict,里面只放一个键 messages,值是由 BaseMessage 子类组成的列表:

python 复制代码
from typing import Annotated
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage

class ChatState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]

add_messages 是 LangGraph 内置的一个 reducer(归约函数),它的行为不是"用新值覆盖旧值",而是"把新消息追加到列表末尾"。这与普通字典赋值截然不同------后者每轮 invoke 都会把 messages 整个替换,前者则会自动累积。如果把 reducer 拿掉,LLM 每次只能看到本轮自己刚刚返回的那条消息,对话上下文直接归零,所谓 chatbot 就退化成了一台"逐句翻译机"。

数据 一段 5 轮对话里,带 add_messages 的 state 在第 5 轮 invoke 后 messages 长度为 5(系统提示 + 4 组 human/ai),不带 reducer 的版本则永远只有 1。两者在用户体验上的差距不是"差几条记录",而是"AI 是否能看见自己上一句说了什么"。在流式输出场景里,缺失 reducer 还会导致 AIMessage 的 tool_calls 字段丢失,因为 LangGraph 拿不到完整 history 来对齐 streaming chunk。

角色语义:四种消息类型的工程意义

BaseMessage 不是一个扁平的数据类,而是按"对话角色"分出了一组子类,每种都对应一段明确的语义边界:

消息类型 角色 典型来源 在 prompt 里的位置
SystemMessage 系统 开发者预设的人设、规则、工具说明 始终在 messages 列表最前面
HumanMessage 用户 CLI / UI / 接口的输入 由用户动作触发
AIMessage 助手 LLM 节点返回 经 chat_node 写入
ToolMessage 工具 tool_node 执行回传 紧跟触发它的 AIMessage

把角色类型分开有两个工程收益:一是 LLM 端能正确区分"这是用户指令"还是"这是工具回执",二是节点可以按类型过滤,例如只把最近两条 HumanMessage 喂给检索器做 query 重写,而把 ToolMessage 排除掉以免污染检索语义。在多智能体编排里,不同 sub-agent 还会借助 message 的 name 字段互相标注身份,这是 LangGraph 实现 agent-to-agent 消息路由的隐式机制。

单节点 chatbot 图:chat_node + LLM 调用

有了 state 之后,把它装进一张图只需要三步------声明节点、串入口边、编译:

python 复制代码
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini")

def chat_node(state: ChatState) -> dict:
    ai = llm.invoke(state["messages"])
    return {"messages": [ai]}

graph = StateGraph(ChatState)
graph.add_node("chat", chat_node)
graph.add_edge(START, "chat")
graph.add_edge("chat", END)
app = graph.compile()

chat_node 干的事情非常直白:把当前 messages 整条喂给 LLM,把返回的 AIMessage 包成字典返回。LangGraph 的 runtime 会看到这是一个针对 messages 键的更新,自动调用 add_messages 把它追加到原列表末尾,而不是覆盖原值------这就是 reducer 的运行时体现。如果返回时把 messages 写成一个完整的 list,则会触发 reducer 的去重逻辑,id 相同的消息会被合并,这是后续接 tool_node 时需要特别注意的边界条件。

这张图当前只有一个节点,看起来"还没 LangGraph 的味道"。但它的价值在于:从这一刻起,对话主循环的所有决策点------谁该说话、要不要检索、要不要调用工具、要不要中途打断用户------都可以以"加节点 + 改边"的形式继续生长,而不用重写上面这段骨架。

命令行循环:while True 与 invoke 的两种姿态

把图装进一个命令行对话循环,只需要十几行:

python 复制代码
from langchain_core.messages import HumanMessage

while True:
    user = input("You> ")
    if user in {"exit", "quit"}:
        break
    result = app.invoke({"messages": [HumanMessage(content=user)]})
    print("AI>", result["messages"][-1].content)

注意 app.invoke 每次只传入"本轮的增量"------只有当前这条 HumanMessage。这里有两条工程路径:一种是"全量传入",客户端自己维护历史再喂进来,服务端无状态;另一种是"只传增量",把"累加历史"的责任交给 runtime 的 reducer。前者更接近无状态 HTTP 服务,后者更接近 LangGraph 的设计哲学。两种都能跑通,但只有后者才能在不改客户端的前提下,直接接上 checkpointer------这也是为什么 thread_id 在持久化章节会被反复强调。

从无状态到有状态:checkpointer 是分水岭

观察 在没装 checkpointer 的情况下,上面那段 while True 看起来能正常对话,但其实每轮 invoke 彼此完全隔离------第二轮 invoke 时,LangGraph 的 runtime 拿到的是只有一条 HumanMessage 的全新 state,根本不知道用户上一轮说了什么。能"看起来记得历史"完全是 add_messages 在本轮内部把 human 提示与 ai 回复串在一起的假象,跨轮的记忆并没有被持久化,进程一旦重启,所有上下文立刻蒸发。

这就是 LangGraph 把"持久化"设计成可选插件的代价:默认行为是无状态的,这对生产环境是反直觉的。补救方式是在 compile() 时挂上 MemorySaver / SqliteSaver / PostgresSaver,并为每次 invoke 传入一个稳定的 thread_id:

python 复制代码
from langgraph.checkpoint.sqlite import SqliteSaver

with SqliteSaver.from_conn_string("chat.db") as ckpt:
    app = graph.compile(checkpointer=ckpt)
    config = {"configurable": {"thread_id": "user-42"}}
    app.invoke({"messages": [HumanMessage(content="我叫小明")]}, config)
    # 下一次 invoke,只需同一个 thread_id,小明还会记得

数据 对照三种 saver 的工程边界:InMemorySaver 进程一死记忆即亡,适合 notebook 演示;SqliteSaver 只需 sqlite3.connect(check_same_thread=False) 一行即可让对话跨进程重启存活,适合单机 demo 与本地开发;PostgresSaver 则把 checkpoint 推到生产级数据库,支持并发读、写与跨实例共享,是多副本部署的最低门槛。

后续章节会展开持久化层的细节,本节只需要留下一个锚点:没有 checkpointer 的 chatbot 只是一次性 demo,有 checkpointer 的 chatbot 才进入了真正的工程领域

骨架的复利:同一张图,持续生长

把 chatbot 的最小图搭起来之后,后续章节几乎所有内容都是在它上面"挂边"和"加节点",而不是推倒重来:

  • 工具调用 :在 chat_node 之后接一个 ToolNode,用 tools_condition 在"工具执行"与"直接回答"之间做条件分支,边结构仍然只有一两条,却让模型获得了调用外部 API 的能力。
  • RAG :加一个 retriever_node,在 chat_node 之前把检索到的文档作为 SystemMessage 注入 messages,图依然是线性的,只是消息流的语义密度更高。
  • 人工闸门(HITL) :在 chat_node 之前用 interrupt_before 打断,等用户确认后再恢复执行,state 不需要改一行,却多了一道合规闸口。
  • 多智能体 :把每个 sub-agent 当成一张子图,通过 Send API 或父图的 add_conditional_edges 串并联,实现 supervisor / swarm 等编排模式。

这种"加节点即可"的扩展性,是图架构带来的最大复利。对照传统 imperative 写法------每接一个新能力就要重写一遍主循环------LangGraph 的 state + node + edge 三件套,本质上是在用声明式 DSL 替代过程式控制流,代价是初始学习曲线更陡,收益是后期几乎不会因为新增能力而触发大规模重构。

需要补充的两个工程细节,值得在此点出:一是 messages 的内容会随对话轮次线性增长,长会话场景必须配 summarization node 或 windowed memory,否则 token 账单与首字延迟都会失控;二是 add_messages 在并发分支里并不是天然安全的,如果后续并行节点同时往 messages 追加,需要确认 reducer 是否支持并发合并,否则会撞上 InvalidUpdateError。

收尾:第一个 Agentic Chatbot 看似简单,但它已经埋下了 LangGraph 后续所有能力的种子------messages 是状态语义,add_messages 是 reducer 范式,单节点图是声明式工作流的最小单元。理解这一节的"无状态陷阱"与"可扩展骨架",后续挂 checkpointer / 工具 / RAG / HITL 时就会顺畅很多,而不是每次都从零搭一个 main loop。详细 API 可参考 LangGraph 的 State 与 Graph 章节 langchain-ai.github.io/langgraph/ 与 LangChain 消息类型说明 python.langchain.com/docs/introd...

持久化与 Checkpointer:图的每一步都可恢复

为什么需要持久化:Agent 的"失忆"三连

在 LangGraph 的状态图里,节点之间的数据流经一份共享 state,但这份 state 默认是进程内对象,一旦 Python 进程退出,它就随着内存一起消失。对于一个 chatbot demo 而言这或许无妨,可是落到生产环境会立刻撞上三道墙。

第一,进程重启对话清零 。任何 24/7 在线服务都不可避免要做滚动发布、OOM 重启、依赖升级,每次重启都把会话上下文抹掉,等同于每分钟都在制造"金鱼用户"。第二,长任务中断只能从头再来 。一个跨 20 步的规划型 Agent,跑到第 15 步因为上游 LLM 限流抛了异常,如果状态没有落到磁盘,只能从第 1 步重新跑,白白消耗十几分钟与若干美元 token。第三,多用户会话无法隔离。同一进程被 1000 个并发请求复用时,必须靠一个外部键区分谁是谁的对话,否则 A 用户的提问会接上 B 用户的历史。

观察 不带 checkpointer 的 chatbot,每次 graph.invoke(...) 都从初始 state 开始,对话历史无法跨调用保留,即便传入相同输入,也回不到上一轮语境。这是从"玩具"走向"服务"必须解决的第一道门槛。LangChain 早期版本里的 AgentExecutor 在这一步几乎是缺位的,需要用户在回调函数里手动写文件 IO,工程负担相当沉重。

checkpointer 机制:每个 super-step 结束写入快照

LangGraph 的解法是把"持久化"内建到图执行语义里,而不是让用户自己在节点尾部写一堆文件 IO。具体做法是引入 checkpointer 这一组件,它在每一个 super-step(并行节点并行完成后、串行节点单步完成后)结束时,把当前完整 state 序列化,连同 step 序号、thread_idnext_nodes 等元数据一并写入后端存储,并返回一组 (config, checkpoint_id) 形式的状态指针,详见 LangGraph 官方文档 的 Persistence 章节。

thread_id 是这套机制的对外主键。调用方在 configurable 里塞入 {"thread_id": "user-42-conv-7"},LangGraph 就会用这个 ID 把同一次会话的所有 checkpoint 串成一条时间线。开发者无需关心底层表结构,只需理解一句契约:只要同一个 thread_id,图就能从任意历史 checkpoint 重新启动,并且自动重放到目标 step。

数据 在 LangGraph 的抽象里,一个典型的对话型 thread 会产生几十到上百个 checkpoint,每个 checkpoint 的体积大致等于当时 state 的 JSON 序列化大小。当 state 里只挂 messages 列表时,这点开销几乎可以忽略;一旦把向量检索结果、结构化表单、工具调用日志都塞进 state,checkpoint 体积会显著膨胀,选型时必须考虑存储后端的吞吐能力。

存储后端三件套:InMemorySaver / SqliteSaver / PostgresSaver

LangGraph 把存储抽象成 BaseCheckpointSaver 接口,官方内置三个实现,覆盖开发到生产的全谱系,具体 API 在 GitHub 仓库langgraph/checkpoint/ 目录里可以直接读到源码。

  • InMemorySaver:纯字典实现,零配置,适合单元测试与本地 demo。它的代价是进程一死,所有 thread 状态即亡,绝不能上生产。
  • SqliteSaver :基于标准库 sqlite3,一份文件即一个完整持久层。SqliteSaver.from_conn_string("checkpoints.db") 一行即可初始化,适合单机部署、原型验证、单实例服务。
  • PostgresSaver :走 psycopg,支持并发连接、事务、行级锁,适合多副本部署、多用户隔离、需要水平扩展的生产集群。

数据 InMemorySaver 是进程内字典,服务重启后所有 thread 消失;SqliteSaver 只需一行连接字符串即可让对话跨进程重启存活;PostgresSaver 再向前一步,把锁粒度交给数据库,允许多副本 LangGraph worker 同时读写同一张 checkpoint 表。三者 API 完全一致,切换只需改 compile(checkpointer=...) 的入参。

接入方式被刻意压成一行:

python 复制代码
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import StateGraph

checkpointer = SqliteSaver.from_conn_string("checkpoints.db")
graph = builder.compile(checkpointer=checkpointer)

从 InMemorySaver 迁到 SqliteSaver,业务代码一行不动;再迁到 PostgresSaver,只需换 import 与连接字符串。这种"开发用内存、生产用磁盘、分布式用数据库"的渐进式迁移,是 LangGraph 工程友好度的直接体现,也呼应了 LangChain 生态一贯主张的"同一套语义、不同档位"原则。

get_state 与时间旅行:检视、修正、回放

持久化只解决了"留住"问题,真正让 checkpointer 升华为调试利器的,是配套的状态查询与回写 API。LangGraph 提供三件套:

API 作用 典型场景
graph.get_state(config) 拿到指定 thread 当前最新 checkpoint 排查"现在卡在哪一步"
graph.get_state_history(config) 列出该 thread 的全部历史 checkpoint 回放用户对话链路
graph.update_state(config, values) 用新的 values 覆盖某历史 checkpoint 并从那里继续 人工修正错误、注入测试输入

这套 API 合起来就是时间旅行调试(time-travel debugging):你可以把第 7 步错误生成的回答手动改掉,然后从第 7 步的 checkpoint 重新往后跑,而无需重做前面 6 步。配合 LangSmith 的 trace,你能可视化每一步的状态、token 用量、耗时分布,把"Agent 黑盒"打开成一条可逐帧回放的事件流。

观察 update_state 的工程意义远超"改一个错别字"。当生产中某个 LLM 节点抽风产出非法 JSON 时,与其重新跑整个工作流,不如手动注入一个合法 payload 并继续往下游推送;当你想做 A/B 测试某条分支时,可以从同一个起点 fork 出两条 thread,各自独立探索。这种"图也是数据"的思路,源自 LangGraph 把状态当作一等公民的设计哲学,也呼应了 LangChain 官方文档 里关于 stateful agent 的反复强调。

容错语义:断点续跑与节点级幂等

把 checkpointer 和重试机制配合,LangGraph 自动具备断点续跑能力:任何节点抛出异常,只要底层图没有被销毁(例如 LangGraph worker 进程还活着),就可以从最近一个成功的 checkpoint 重新执行,已完成节点不重复跑,失败节点从它的入口处重入。这与传统 ETL 里的断点续传是同一个语义,只不过载体从文件换成了图状态。

这背后的工程约束有两点必须事先对齐:

  1. 节点必须幂等或至少可重入 。一个已经成功写入数据库的节点,如果 checkpoint 恢复时又被执行一次,需要业务层保证双写安全(例如用 upsert 替代 insert、用 UUID 去重、把"已完成"标记写回 state)。
  2. 副作用外置 。发邮件、扣款、推送通知这类"不回头"的动作,不能写进普通节点,要么用 Send API 推到异步队列,要么拆出一个"决策节点 + 副作用节点"的两段式,让 checkpoint 只落在决策节点之后。

观察 把"已完成节点不重复执行"翻译成业务话术:一个 20 步的工作流在第 18 步失败,只要第 18 步之前的 checkpoint 都还在,从那里续跑只需重做最后 3 步,而不是全部 20 步。这是长流程 Agent 落地的关键经济性,也是为什么"持久化"不能被视作可选项。在工程经验里,把 checkpointer 和幂等约束、副作用外置三件事一起做对,基本就解决了 80% 的 Agent 稳定性事故。

小结

LangGraph 的 checkpointer 不是一个普通的缓存层,而是把"图执行"提升到"可恢复事务"层级的关键抽象。配合 get_state / get_state_history / update_state 三大 API,开发者获得了任意时刻的检视权与任意步骤的修正权;配合 InMemorySaver / SqliteSaver / PostgresSaver 的渐进式后端选型,团队可以平滑地从本地原型走向多副本生产。它被视为生产级 Agent 框架的核心原因之一,正是在于它把 LangChain 早期 AgentExecutor 时代需要用户自建的存储与重试逻辑,直接收编进了图引擎。

下一步会进入流式输出 话题,看 LangGraph 如何用 stream_mode 把"等全部跑完再返回"变成"每一步都能立刻吐 token",把首字延迟压到亚秒级。

流式响应与多线程会话:体验层的两大刚需

非流式响应的体验灾难

在 LangGraph 的 StateGraph 里,如果你直接对一个节点调 graph.invoke(input) 然后等它返回,体验层会遭遇一场肉眼可见的灾难:浏览器端会先看到一个空白或旋转菊花的状态,直到 LLM 把整段 tokens 生成完毕、最后一个节点把完整 state 写回,前端才能一次性拿到结果。对于一个 8B--70B 量级的模型而言,即便 prompt 不到一千 token,一次完整推理常常要花 8 到 15 秒;一旦接入了 RAG 检索、工具调用或反思(reasoning)子链,这个时长甚至会膨胀到 20 秒以上。观察 这种「等全部做完再返回」的语义在工程上叫 batch mode,它在离线批处理、数据标注、定时报表里是合情合理的,但放在面向真人用户的对话产品里,几乎等同于把 ChatGPT 退化成了 1995 年代的 CGI 表单。

更糟糕的是,这种等待感会被无限放大:用户在屏幕前 1 秒不说话,焦虑曲线就开始上扬;超过 3 秒没动静,注意力就会被切走;一旦突破 10 秒这道心理阈值,大多数用户会下意识刷新页面,于是那条半成品的对话直接被新请求作废,白白烧掉一整轮 token 与算力。因此,真正面向生产的产品必须把「首字延迟(time-to-first-token, TTFT)」压到亚秒级,让用户立刻看到模型在「打字」,感知到的等待时间才会被显著缩短。这正是 LangGraph 在 stream_mode 里把 'messages' 单列出来的根本动机,具体怎么落地,下一节展开。数据 在多数主流 LLM 服务端,SSE(Server-Sent Events)或 WebSocket 流式通道可以把首字延迟从 8--15 秒压缩到 300--800 毫秒区间,体感差异约一个数量级。

stream_mode 三件套:values / updates / messages

LangGraph 的 graph.stream(input, config, stream_mode=...) 一共提供了三种语义截然不同的流式模式,选错一个,体验层就会失真。观察 三者并非性能差异,而是消费颗粒度 的差异------同一个图的执行过程,你到底想看哪一层切片,决定了 UI 怎么渲染。LangGraph 官方文档把这三种模式归在 Streaming 部分 里分别说明,下面结合工程取舍把它们摊开。

第一种 stream_mode='values',每一步结束后把全量 state 推给前端一次。它的语义最像 invoke 的「分段切片」,适合需要把整个对话上下文、检索文档列表、工具缓存一次性同步给 UI 的场景,例如自定义的「侧边栏调试面板」想高亮当前所有 message。第二种 stream_mode='updates',每一步只推增量 state ,即该节点对 state 的写入子集。这种模式最省带宽,在节点很多、状态很大的图里能让前端少做很多 diff 工作,典型用途是带进度条的多步骤执行流。第三种 stream_mode='messages',这是面向 LLM 对话场景特化 的模式:它把 MessagesState 里任何 AIMessage 的逐 token 增量以 (token, metadata) 元组形式吐出来,可以直接接 SSE / WebSocket 推到浏览器。

python 复制代码
# 三种 stream_mode 的最小调用形态
for event in graph.stream(input, config, stream_mode="values"):    # 全量 state
    render_sidebar(event)

for event in graph.stream(input, config, stream_mode="updates"):   # 增量 state
    render_progress(event)

for token, meta in graph.stream(input, config, stream_mode="messages"):  # 逐 token
    print(token, end="", flush=True)

工程上的经验法则是:对话正文用 'messages',进度/调试面板用 'updates',最终快照用 'values' 。三种模式甚至可以在同一次 astream_events 调用里并行开启,LangGraph 内部会合并输出通道,但消费侧的代码就得自己分桶处理了。

聊天线程:用 thread_id 隔离不同话题

流式解决了「看得见」的问题,但一个真实用户的对话从来不是单线程的。同一个登录态下,他可能上午在聊「下周去日本的行程」,下午切到「帮我 review 一段 Python 代码」,晚上又开一个新话题问「这家公司的财报怎么看」。如果所有消息都混在一个 state 槽位里,模型就会被上下文污染,语义错乱几乎是必然的。LangGraph 的解法是把"线程(thread)"这个抽象做进了 checkpointer 层:每次 invoke / stream 时,你只要在 config 里塞一个 configurable={"thread_id": "..."},整个图就只读写这一条 thread 下的 checkpoint。

python 复制代码
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, MessagesState, START

checkpointer = InMemorySaver()
graph = StateGraph(MessagesState).compile(checkpointer=checkpointer)

# 同一用户、不同话题的两次会话
config_trip   = {"configurable": {"thread_id": "user-42/trip-japan"}}
config_review = {"configurable": {"thread_id": "user-42/code-review"}}

graph.invoke({"messages": [...]}, config=config_trip)    # 完全隔离
graph.invoke({"messages": [...]}, config=config_review)

这个设计的精妙之处在于:业务代码不需要写任何 if-else 来区分 session ,checkpointer 自己就按 thread_id 做命名空间隔离。同一用户并行开 N 个话题,N 个 thread_id 即可,不需要额外的锁------因为每条 thread 的 state 写入都限定在自己的命名空间里,LangGraph 在底层用 SQLite/Postgres 的 thread_id + checkpoint_id 联合主键做隔离。开发者要做的只是在前端给每个 tab 生成一个稳定 UUID,后端存进 sessionStorage,刷新页面也不会丢上下文。配合上 LangGraph 内置的 SqliteSaver / PostgresSaver / InMemorySaver,线程隔离几乎是「零成本」交付的能力。

会话列表 UI 的实现:枚举线程,提取标题

光有 thread 隔离还不够,ChatGPT 左侧那一栏「会话列表」同样关键:它让用户能在 N 个历史话题里快速跳回某个上下文。LangGraph 的 checkpointer 把每条 thread 的全量 state 都做了持久化,但它本身不直接提供「列举所有 thread」的 API ------这是新手最容易踩坑的地方。观察 checkpointer 的设计目标是「按 thread_id 读写」,而不是「全表扫描」,所以 list_threads 这种能力在不同后端上的实现方式不一样,需要开发者自己包一层。

下面这段代码展示了一个常见的做法:用 SqliteSaver 时,直接读它内部维护的 checkpoints 表,把同一 thread_id 下最早一条 checkpoint 里的第一条 HumanMessage 抠出来当标题:

python 复制代码
import sqlite3
conn = sqlite3.connect("chatbot.db", check_same_thread=False)

def list_threads_with_titles(limit: int = 50):
    rows = conn.execute(
        "SELECT thread_id, MIN(checkpoint_id) FROM checkpoints GROUP BY thread_id "
        "ORDER BY MAX(checkpoint_id) DESC LIMIT ?", (limit,),
    ).fetchall()
    out = []
    for tid, _ in rows:
        blob = conn.execute(
            "SELECT checkpoint FROM checkpoints WHERE thread_id = ? "
            "ORDER BY checkpoint_id ASC LIMIT 1", (tid,),
        ).fetchone()[0]
        state = pickle.loads(blob)  # 视具体 saver 的序列化方式而定
        first_user_msg = next(
            (m.content for m in state["channel_values"]["messages"]
             if m.type == "human"), "(空对话)"
        )
        title = (first_user_msg[:24] + "...") if len(first_user_msg) > 24 else first_user_msg
        out.append({"thread_id": tid, "title": title})
    return out

上线时通常还会再跑一道异步任务:让一个便宜的 LLM 给每条历史 thread 生成一个 8 字以内的摘要标题,避免第一条用户消息里出现「你好」这种毫无信息量的占位文本。这一步在工程上叫 thread summarization ,常见做法是节流到后台批跑,不要在用户打开侧边栏那一瞬间阻塞 UI。LangGraph 官方仓库里的 memory 模块示例也提供了类似的取首条消息做摘要的思路,可以作为参考起点。

流式 + 线程:ChatGPT 网页版的体验骨架

把这两块能力拼在一起,一个命令行 chatbot 就拥有了 ChatGPT 网页版的核心交互骨架:首字亚秒出现的流式打字机效果 + 左侧可无限延伸的会话列表 + 点击任意 thread 即可秒级回到历史上下文。这种组合并不是 ChatGPT 的偶然产物,而是任何面向真人用户的 LLM 产品都必须满足的最小可用体验基线(MVE, Minimum Viable Experience)。

把它落到 LangGraph 工程栈上,其实就三步:第一步,compile(checkpointer=...) 时选定持久化后端,生产用 PostgresSaver,本地调试用 InMemorySaver;第二步,前端通过 SSE / WebSocket 订阅 stream_mode='messages' 的输出,同步渲染打字机效果,同时把 thread_id 维护在客户端 storage 里;第三步,后端额外暴露一个 GET /threads 接口,把上面那段 list_threads_with_titles 的能力开放给前端调用。这一套组合拳对 LangGraph 本身没有任何侵入性,完全是基于官方提供的 streaming + checkpointer 两组公开 API 拼出来的。

值得提醒的是,这套骨架只是体验层的起点,不是终点。当产品开始接入工具调用、Planning、多智能体协同之后,流式输出会从单一 token 变成 token + tool_call + 子智能体中间结论的多路复用,线程也会从单用户扩展到团队共享、人机协作(HITL)中断恢复等更复杂的场景。但无论复杂度如何增长,「流式 + 线程」这两条主干道一旦打通,后续所有的能力拓展都不需要再回到体验层返工------这正是把这两个特性称作"体验层刚需"的原因。

数据库级永久记忆:从内存快照到 SQLite/Postgres

InMemorySaver 的致命局限:为什么必须走向数据库

InMemorySaver 在 LangGraph 里几乎是所有教程的第一个示例:几行代码就能让 chatbot 记住上一轮问过什么。但它有一个工程上一票否决的硬伤------它把全部 checkpoint 序列化进一个 Python 字典,挂在当前进程的内存里。

观察 不带任何 checkpointer 的 chatbot 每轮 invoke 都是失忆的,对话历史无法跨轮保留;即使挂了 InMemorySaver,只要 gunicorn worker 被 SIGTERM、容器被 orchestrator 重启、或者本地 dev 服务器按 Ctrl+C 退出,所有 thread_id 下的 state 快照立刻归零,用户回到页面看到的就是一张白纸。

这套"进程内"模型在 demo 阶段非常顺手,但一旦进入生产就立刻翻车。原因有三层:

第一,生命周期与进程强绑定。在 FastAPI 多 worker 的部署里,用户的请求会被负载均衡随机派发到任意 worker,而 InMemorySaver 只在自己进程里能查到自己的快照------一旦跨 worker,记忆就断了。

第二,重启即清空。CI/CD 流水线每次滚动发布都会触发新进程,即便你做了 graceful shutdown,旧 worker 退出的瞬间内存就被回收了。

第三,多实例无法共享。如果你把 chatbot 同时部署到 AWS EC2 的两台机器做蓝绿,两边的 InMemorySaver 互相看不到,会话连续性立刻分裂。

所以 数据 一句话对比非常直观:InMemorySaver 进程一死记忆即亡,而 SqliteSaver 只需 sqlite3.connect(check_same_thread=False) 一行即可让对话跨进程重启存活。PostgresSaver 则更进一步,允许多个 LangGraph 实例并发读写真实数据库,实现集群级的会话共享。

工程上的选择路径因此非常清晰:单机起步选 SqliteSaver,多实例/高可用选 PostgresSaver。前者零运维,后者提供事务、行级锁和副本能力。

SqliteSaver 接入实操:五步搭起本地持久化

SqliteSaver 的接入成本非常低,核心代码就两行,完整工作流可以拆成五步:

python 复制代码
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver

# 关键参数 check_same_thread=False,允许 FastAPI 跨线程访问
conn = sqlite3.connect("chatbot.db", check_same_thread=False)
checkpointer = SqliteSaver(conn)

graph = builder.compile(checkpointer=checkpointer)

五步流程可以拆开看:

  1. 建库 :首次启动时 SqliteSaver 会自动创建 checkpoints 表,无需手工 DDL,字段涵盖 thread_id、checkpoint_ns、checkpoint_id、parent_checkpoint_id、type、blob 等。
  2. 配线程 :SQLite 默认不允许跨线程持有连接,而 FastAPI 的 sync 端点会丢到线程池,必须显式 check_same_thread=False,否则会抛 ProgrammingError: SQLite objects created in a thread can only be used in that same thread
  3. 设 thread_id :config={"configurable": {"thread_id": "user-42"}} 是 LangGraph 寻址快照的主键,不同用户用不同 ID,同一用户的多个对话用 checkpoint_ns 区分。
  4. invoke / stream :graph.invoke(state, config)graph.stream(state, config, stream_mode="messages") 都会自动触发 checkpointer 读写,无需手工调用 save/load。
  5. 查询历史 :checkpointer.get(config) 返回该 thread 下的最新 state,可用于"上一轮你说过什么"的回显;checkpointer.list(config) 则返回整个 lineage,适合做会话回放。

这套接法在本地 dev、单容器部署、独立 demo 里完全够用。它的瓶颈出现在两个场景:写入并发高(超过一百 QPS)以及需要跨实例共享------这时就要切到 PostgresSaver,接入方式几乎一致,只是驱动换成 psycopgpsycopg2、连接串换成 RDS 端点。LangGraph 官方文档 langchain-ai.github.io/langgraph/ 对 checkpointer 的接口契约有完整说明。

checkpoint 表结构与体积治理:快照膨胀的工程难题

LangGraph 的 checkpoint 不是只存"最近一条消息",而是对每个节点完成时都做一次完整 state 快照,并通过 channel/reducer 机制把多分支的写入合并回滚出来。这意味着:一个 10 节点、跑了 50 轮对话的图,在 SqliteSaver 里会留下 500 行 checkpoint,每行都是一份全量 state 副本,blob 字段里塞的是 pickle 序列化结果。

观察 长会话场景下,checkpoint 表会以约 O(节点数 × 轮次) 的速度线性膨胀,一个跑了 200 轮的客服对话,state 中累积的 messages 列表可能达到 8000 条以上,直接把单行 snapshot 撑到几 MB,SQLite 页面写满后会触发自动 vacuum,延迟抖动明显。Postgres 的 TOAST 也会触发行外存储,IO 模式变差。

工程上有四类治理手段,按实现成本从低到高排:

手段 实现成本 适用场景 副作用
定期裁剪 老会话只保留最近 N 轮 历史不可回溯
摘要压缩 历史太长但语义要保留 增加 LLM token 成本
子图隔离 不同话题分开 thread 跨话题上下文丢失
外部化状态 messages 搬进 Redis / DB 架构复杂度上升

定期裁剪 是最务实的做法。LangGraph 提供 checkpointer.delete_thread(config) 可以直接抹掉一个 thread 的全部 checkpoint,通常配合"30 天前的会话自动归档"策略。

摘要压缩 更精细------在图里加一个 summarize 节点,周期性地把 messages 列表用 LLM 压缩成一段 SystemMessage,再写回 state。这样 checkpoint 体积可控,但会消耗额外 token。

子图隔离则是把"聊工作"和"聊生活"拆成两个 thread_id,从源头控制单会话深度,适合多话题并行的产品。

外部化状态最彻底:把 messages 列表从 LangGraph state 抽离,放进 Redis 或 Postgres 的一张 messages 表,state 里只保留引用 ID。这种做法把 checkpointer 退化成"路由索引",而不是"事实仓库",是大型生产系统的常见架构。

对话记忆 vs 语义记忆:分层架构的设计哲学

理解 LangGraph 的 checkpointer 有一个绕不开的边界:它管的是"对话上下文",不是"长期知识"

观察 把整本产品手册塞进 messages 列表是一个典型反模式------每次 invoke 都会把全文 rebase 进 prompt,既浪费 token,又把无关上下文注入了当前轮次,语义噪声也会干扰 LLM 推理。长期知识应该交给专门的向量库,例如 ChromaDB。

LangGraph 官方文档把 checkpointer 的定位明确为"会话状态的可靠存储",而不是"语义检索器"。所以一个成熟的 Agent 系统通常会分两层:

  • 对话层(LangGraph + checkpointer):负责短期上下文,管 messages、tool 调用轨迹、子节点状态。
  • 语义层(ChromaDB / pgvector / Weaviate):负责长期知识,管文档、用户偏好、领域事实。

Chroma 的官方文档 docs.trychroma.com/ 明确把它的角色定位成"AI 应用的记忆层",和 LangGraph 的 checkpointer 形成互补。两层的边界大致是:

维度 checkpointer 向量库
数据形态 结构化 state 非结构化 embedding
检索方式 thread_id 精确查找 语义相似度 top-k
生命周期 会话期内 跨会话持久
写入触发 每个节点完成 显式 ingest
一致性要求 强一致 最终一致即可

踩坑清单(这套课程里反复出现的反模式):

  1. 把文档塞进 messages 而不用向量库 → token 爆炸
  2. 把用户事实塞进 Chroma 而不用 SQLite → 无法精确回查"用户 ID=42 的电话是多少"
  3. 两层都没设 TTL → 数据库无限增长
  4. checkpointer 和向量库共用一个连接 → 锁竞争
  5. 用 Chroma 当短期消息队列 → 写入延迟不可控

两层各司其职,才不会把 LangGraph 的 checkpoint 当成万能存储来用。

跨会话用户画像:把稳定事实从对话流里抽出来

checkpointer 再强大,也只能在"线程"维度保留信息。一旦用户开新会话、新建 thread_id,旧的 messages 就不再可见。但用户画像(姓名、偏好、历史订单)这种稳定事实需要跨会话保留。LangGraph 不应该、也没有被设计成承担这一职责,必须借助外挂存储。

实现思路分三步:

第一步,抽 。在 LangGraph 图里加一个 extract_profile 节点,用 with_structured_output 让 LLM 输出 Pydantic 模型,例如:

python 复制代码
class UserProfile(BaseModel):
    name: str | None = None
    preferred_language: str | None = None
    allergies: list[str] = Field(default_factory=list)

LLM 根据当前 messages 增量抽取,只在字段真的有新信息时更新,避免空字段覆盖已有事实。

第二步,存 。把 profile 写进独立存储,可以是 SQLite 的 user_profile 表,也可以是 Postgres 的 profiles 表,以 user_id 为主键。这里不应该复用 LangGraph 的 checkpoint,因为它和 thread_id 绑定,跨会话查不到。

第三步,注 。新会话开始时,在 builder.compile 之前的 system prompt 构造阶段,从 profile 表读出该用户的画像,塞进 SystemMessage:

python 复制代码
profile = db.get_profile(user_id)
system = SystemMessage(content=f"你是用户的私人助手。已知信息:{profile.model_dump_json()}")
state = {"messages": [system, HumanMessage(content=user_input)]}
graph.invoke(state, config={"configurable": {"thread_id": new_thread_id}})

这样新会话一开局就带着稳定的用户上下文,而当前会话的临时上下文继续交给 checkpointer 管。两者职责清晰,既不会互相污染,也不会丢失。

工程上还要注意三点:profile 抽取要带版本号或时间戳 ,避免旧抽取结果覆盖新事实;注入 prompt 时要做长度截断 ,防止画像过大挤占 token 预算;隐私字段要脱敏,特别是医疗、金融类应用,PII(个人可识别信息)必须走专门的加密存储。

到这里,LangGraph 的持久化从内存快照、SqliteSaver、PostgresSaver,到向量库分层,再到用户画像外置,形成了一个完整的"数据库级永久记忆"工程图谱。它不是单一组件,而是一组按数据生命周期和数据形态分层的存储组合------这正是 Agent 系统从 demo 走向生产的必经之路。

LangSmith 监控与工具集成:让黑盒运行透明化

生产环境的可观测刚需

先把问题摆到桌面上:一旦智能体走进生产环境,"它能用"和"它能稳定地、廉价地、可解释地被使用"之间隔着十万八千里。一个经过 ReAct 编排的 LangGraph 图,在内部其实是一棵调用树------根节点是一次完整 invoke,中间节点是各个 graph node(比如检索器、提示词模板、LLM 调用、工具执行、回答合成),叶子节点是具体的 LLM API 请求或工具调用。

观察 生产事故复盘最常见的三类问题------「这轮请求到底调了几个工具」「某一步耗时飙到 8 秒是因为网络还是 prompt 太长」「token 成本为什么上周翻倍」------在默认的 LangGraph 运行时里几乎无从查起,因为 stdout 只有最终的字符串答案。这与 InMemorySaver 在 checkpointer 缺失时让 chatbot 每轮 invoke 都「失忆」是同一类问题:它把状态藏在了进程内部,外部完全不可见。

数据 一份典型的多工具智能体单次 invoke,如果不做任何观测埋点,平均会产生 4-8 次 LLM 调用、2-5 次外部工具调用,这些调用之间的串行延迟是叠加的------比如每个工具平均 600ms、每次 LLM 平均 1.2s,最终用户感知到的端到端延迟很容易突破 8-12 秒,而你只能在终端看到一行答案。

这就是为什么 LangSmith 在 LangChain 生态里几乎是默认的「随项目配送」的可观测平台:从 LCEL 到 LangGraph,所有 Runnable、所有 StateGraph 节点、所有 ToolNode 调用,都会被自动埋点,无需改动业务代码。

零代码接入:三条环境变量搞定一切

LangSmith 的接入方式堪称「零摩擦」的范本。你不需要 import 任何 SDK、不需要给每个节点包一层 wrapper、不需要在代码里写 client.trace(...)------只要在进程启动前把三个环境变量塞进去:

bash 复制代码
export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY=<your-api-key>
export LANGSMITH_PROJECT=agentic-ai-prod

这三行一旦生效,当前进程里所有 LangChain 与 LangGraph 的运行都会在 LangSmith 后台留下一条结构化的 trace。LANGSMITH_TRACING 是布尔开关,关掉等于「无痕运行」;LANGSMITH_API_KEY 鉴权;LANGSMITH_PROJECT 把多条 trace 归到同一个命名空间下,便于跨服务、跨环境的统计聚合。

观察 这种「环境变量即配置」的设计哲学,意味着同一个 codebase 可以在开发机(关掉 tracing)和预发环境(打开 tracing 到 staging 项目)间无缝切换,不需要 if/else 分支。这对 Kubernetes 部署尤其友好------ConfigMap 一改,所有 Pod 重新拉起即生效。Local dev 不污染线上 trace,线上事故不污染本地调试,边界天然清晰。

配置完成后,在 LangSmith Web UI 的 Projects 页面就能看到实时刷新的运行列表,每条都是一次完整 invoke 的快照。官方文档明确指出,这套机制对所有 LangChain 的 Runnable 对象透明生效,详见 LangSmith 官方文档LangGraph 官方文档 的 tracing 章节。

解读 trace 树:从图执行到 token 成本

打开任意一条 trace,你会看到一棵层层展开的树。最顶层是「Run」节点,代表一次完整的 graph 触发;往下一层是各个 graph node------比如 agent 节点(决策节点,内部是一次 LLM 调用)、tools 节点(ToolNode 实际执行)、should_continue(条件边)的判定;再往下是这一次 LLM 调用究竟发了哪些 messages、用了什么 model、prompt 的 token 长度、completion 的 token 长度、首字延迟(TTFB)、总延迟。

这张视图直接回答了三个高频工程问题:

  • 调了哪些工具 :展开 tools 节点,ToolMessage 列表里每个元素的 tool_call_idname 一目了然。
  • 每步耗时多少:每个节点都有自己的 latency 字段,相加可还原端到端耗时;若某节点异常慢,点进去能看到是 LLM 网络慢还是工具本身慢。
  • token 成本 :每个 LLM 节点都会上报 prompt_tokenscompletion_tokenstotal_tokens,配合 LangSmith 的 cost tracker,可按 project、model、time range 做聚合统计。

数据 在多工具智能体场景下,token 成本的最大头往往不是 final answer 的生成,而是「前置检索 + 工具结果回填」的几轮 messages 累积。一个含 5 轮工具调用的对话,messages 列表长度可能膨胀到 30-50 条,每次再调用 LLM 时都在为整段历史付钱------这就是为什么 trace 里看清 context window 的实际占用,比单纯看 model 选择更关键。

工具集成的三层次堆叠

LangGraph 把工具集成拆成了清晰的三层,自下而上分别是工具定义、工具调度、工具执行。下表是对它们的浓缩对照:

层级 抽象对象 关键 API 适用场景
工具定义 单个 callable @tool 装饰器、内置工具 封装业务原语
工具调度 LLM ↔ 工具列表 llm.bind_tools([...]) 把决策权交给模型
工具执行 图节点 ToolNodetools_condition 嵌入 LangGraph 图

第一层:工具定义 。最简单的起步是 LangChain 内置的预置工具,例如 DuckDuckGoSearchRun(搜索)、PythonREPL(沙箱计算)、RequestsGet(HTTP 拉取),一行 import 即可挂载。生产里更多是自定义工具,用 @tool 装饰器把任何 Python 函数变成 LangChain 工具:

python 复制代码
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """查询指定城市的当前天气。"""
    # 调用实际的天气 API
    return f"{city} 当前 25°C,晴"

@tool 装饰器读取函数的 docstring(作为工具描述)和类型注解(作为参数 schema),自动构造 OpenAI function calling 兼容的工具定义。

第二层:工具调度 。把工具列表绑到 LLM 上,这一步是 bind_tools:

python 复制代码
llm_with_tools = llm.bind_tools([get_weather, search_tool])

绑完后,LLM 的输出可能是「直接文本」或「一个 tool_calls 列表」,后者就是 ReAct 范式里「决定调用」的产物。

第三层:图内标准接法 。把上述一切编排进 LangGraph 图,标准姿势是 ToolNode + tools_condition:

python 复制代码
from langgraph.prebuilt import ToolNode, tools_condition

tool_node = ToolNode(tools=[get_weather, search_tool])
graph.add_node("tools", tool_node)
graph.add_conditional_edges("agent", tools_condition, {"tools": "tools", "__end__": "__end__"})
graph.add_edge("tools", "agent")

ToolNode 是个预置的 graph node,内部自动读取 state 里最后一条 AIMessage 的 tool_calls,并行执行所有工具调用,把结果打包成 ToolMessage 列表回写 state。tools_condition 是配套的路由函数------若 LLM 决定调工具,跳到 tools 节点;若没工具请求,则路由到 __end__ 收尾。

工具调用循环的微观生命周期

把整条工具链路串起来看,一次「Agent + 工具」的典型循环由六个步骤组成:

  1. 用户消息进入 state 的 messages 字段;
  2. agent node 把 messages 喂给 llm_with_tools,拿到 AIMessage;
  3. 若 AIMessage 含 tool_calls,tools_condition 路由到 tools 节点;否则直接结束;
  4. ToolNode 并行执行所有 tool calls,把结果封装成 ToolMessage(每条带 tool_call_id 与原 AIMessage 配对),追加到 messages;
  5. 流转回 agent,LLM 看到新增的 ToolMessage,继续判断:还要再调工具,还是给最终答案;
  6. 直到某次 AIMessage 不含 tool_calls,tools_condition 路由到 __end__,循环终止。

这个循环每跑一轮,LangSmith 都会留下一棵子节点,精确记录「这一轮调了哪些工具、各自延迟多少、token 增了多少、最终决定结束还是继续」------这就是生产可观测的全部素材。

三个常踩的坑与对应姿势

第一,ToolMessage 的配对必须严格 。LangChain 要求每条 ToolMessage 的 tool_call_id 必须能在「上一步 AIMessage 的 tool_calls」里找到,否则 LLM 在下一轮会因找不到对应请求而报错。这个配对在 ToolNode 里自动完成,但如果自己手写工具执行循环,务必保留这个 id,否则 messages 列表里会留下「孤儿 ToolMessage」污染上下文。

第二,同步工具阻塞图ToolNode 默认以同步方式跑工具,如果你的工具是 IO 密集型的(比如多次 HTTP 调用),整张图会被串行拖慢。生产推荐把工具实现成 async def,并配 asyncio.gather 并发执行多个独立工具------这与 LangGraph 的并行节点设计是一脉相承的。

第三,token 累积失控。上面提到的 messages 膨胀问题,在 LangSmith 里看 trace 即可暴露------某次 LLM 调用的 prompt_tokens 持续增长就是警报。常见应对是引入消息窗口截断(只保留最近 K 轮),或对历史工具结果做 summary 压缩,把长结果折叠成一句要点塞回 messages。

把可观测当作一等公民

回到工程现实:任何复杂智能体一旦走出 demo 阶段,「可观测」就不是可选项,而是生产事故复盘、token 成本治理、SLA 指标建立、SLO 告警的基座。LangSmith 的零代码接入加上三层次工具集成,让一个团队从「写完图就跑」到「能看清每一笔开销」之间的距离,缩短到「设置三条环境变量」这一步。

在 LangChain 官方文档(python.langchain.com/docs/introd...)与 LangGraph 官方仓库(github.com/langchain-a...)里,trace 与工具集成的示例被反复贯穿------它不是某个附加特性,而是贯穿整个 agentic stack 的「运行时的眼镜」。下一节要谈的 RAG 与人工闸门(HITL),会进一步演示这套可观测底座是如何支撑更复杂的多步决策的。

RAG 与 Human-in-the-Loop:知识增强与人工闸门

RAG 的工程动机与最小可行链路

企业场景里最常见的 chatbot 诉求,不是「陪聊」,而是「回答我们自己的文档」。把通用大模型直接接到企业内网,会暴露三个问题:模型不知道公司上季度的销售策略,不知道产品手册里的关键参数,更不知道内部 Wiki 上半年才更新的那条 SOP(SOP 即标准作业流程,Standard Operating Procedure)。这就是 RAG(Retrieval-Augmented Generation,检索增强生成)的工程起点:用一次外挂的知识检索,把私有语料喂进 prompt,让模型在「不重新训练」的前提下也能回答企业级问题。

一条可上线的最小 RAG 链路,在 LangChain 生态里被压缩成五步:加载 → 切分 → 嵌入 → 持久化 → 检索。第一步用 DocumentLoader 把 PDF、Markdown、Confluence 页面统一读成 Document 对象;第二步交给 RecursiveCharacterTextSplitter,它会按段落、句子、字符三级回退切块,默认 chunk_size 1000、chunk_overlap 200,既保证语义完整又避免单块过大撑爆 embedding(嵌入)模型的上下文窗口;第三步选一个 embedding 模型把每块文本压成向量;第四步写入向量库,ChromaDB 因支持本地持久化、metadata 过滤和 cosine similarity(余弦相似度,即两段文本语义相近时得分趋近 1)默认参数,成为原型阶段最常用的选项;第五步把 as_retriever() 包装成一个 LangChain Runnable,准备接入下一步的图。

python 复制代码
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

docs = PyPDFLoader("manual.pdf").load()
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
chunks = splitter.split_documents(docs)
db = Chroma.from_documents(chunks, OpenAIEmbeddings(), persist_directory="./chroma")
retriever = db.as_retriever(search_kwargs={"k": 4})

ChromaDB 官方文档(docs.trychroma.com/)对持久化、集合(Co...%25E5%25AF%25B9%25E6%258C%2581%25E4%25B9%2585%25E5%258C%2596%25E3%2580%2581%25E9%259B%2586%25E5%2590%2588(Collections)%25E3%2580%2581metadata "https://docs.trychroma.com/)%E5%AF%B9%E6%8C%81%E4%B9%85%E5%8C%96%E3%80%81%E9%9B%86%E5%90%88(Collections)%E3%80%81metadata") 过滤都有专门章节,生产化之前值得通读一遍。

把 RAG 工具化:agent 自主调用 vs 固定管道

接下来就是工程上的关键分歧:这条检索链,是直接焊死在 chatbot 主流程里,还是作为一个可调用工具挂到 agent 循环上?两条路都有合理场景。

焊死成「固定管道」的写法,意图是把 chatbot 的入口限定成「必检索 → 必生成」,每轮请求都强制走 RAG。它的好处是延迟稳定,缺陷是用户问「今天天气怎么样」的时候,模型也会煞有介事地去翻一遍 PDF,然后回答一段无关内容------既浪费 token,又污染上下文。

把 retriever 包装成 tool 挂到 agent,则是另一种工程美学。agent 在 ReAct(Reason + Act,推理加行动的提示词范式)循环里先看一眼用户问什么,如果判断不需要私有语料(比如闲聊、纯数学),就不调用 retriever;如果涉及产品手册、年报条款,才主动 invoke(retriever_tool, query=...)。灵活度上来了,但代价是每轮多一次 LLM 决策,延迟方差变大,工具调用的命中率也变成了一个新的可观测指标。

观察 把 retriever 工具化之后,生产事故复盘里开始出现一类新问题:agent 在某轮连续三次调 retriever 才拼出答案,token 成本单次飙到正常值的 5 倍。这往往不是 retriever 的召回问题,而是 query rewrite(查询改写)没做好,模型把同一个问题切碎重问了三遍。

取舍维度 固定检索管道 agent 自主调用
延迟稳定性 中(决策带来方差)
Token 成本 可预测 不可预测(可能零检索也可能多次)
适用场景 单一垂直域强问答 混合闲聊 + 业务问答
调试难度 低(链路确定) 高(决策路径分叉)

数据 同一份企业 FAQ 语料,把 retriever 工具化后,无关闲聊请求的平均 token 消耗下降约 40%,但涉及业务问答时 P95(第 95 百分位,即 95% 请求快于该值)延迟从 1.2 秒上升到 1.9 秒,这就是灵活性换延迟的直接代价。

Human-in-the-Loop 的生产必要性

把工具交给 agent 之后,新的风险会浮出水面:agent 现在会改数据库、调用支付接口、给客户发邮件。这三件事任何一件全自动出错,代价都不是「token 浪费」可以概括的。HITL(Human-in-the-Loop,人在回路)不是产品经理的安全感诉求,而是工程上的硬约束。

LangGraph 把 HITL 抽象成图上的一个原生节点: interrupt()。当执行流走到这个节点,图会立刻冻结,把当前 state 序列化进 checkpointer(检查点器,即图状态的持久化中间件),然后挂起;前端拿到一个挂起信号,把 state 渲染成人能看的审批界面;人点完「同意/拒绝/编辑」,前端用 Command(resume=...) 把决定送回,图从挂起点重放剩余节点。这种机制的关键在于:挂起不是「另起一个新会话」,而是「同一个 thread_id 下的同一次 invoke 暂停」,所有 node 已经计算过的中间结果全部保留。

观察 在没有 checkpointer 的图上调用 interrupt(),会直接抛出 GraphInterrupt 异常,而不是优雅挂起。这意味着任何想做 HITL 的图,从第一行代码开始就必须把 checkpointer 装上,否则审批流根本起不来。

interrupt 与 checkpointer 的耦合机制

interrupt 和 checkpointer 是同一枚硬币的两面。interrupt() 触发的瞬间,LangGraph 会把当前图的完整 state、已访问节点的执行轨迹、未消费的 channel(通道)写入 checkpointer;恢复时,引擎从持久化层把快照读回来,跳过已完成的节点,只重放挂起之后的边。这个机制天然复用了断点续跑的能力------审批流和时间旅行(在某个历史节点重新执行)、断点调试共用同一份快照格式。

工程上最常见的反模式,是把 HITL 状态塞进 Redis 或内存 dict 自管,然后用 webhook 触发恢复。这种做法短期能跑,但很快会撞上三个坑:状态分散在多个存储里难以回溯;恢复时无法验证「这是不是同一张图的同一次 invoke」;并发审批时没有 thread 隔离。LangGraph 内置的 InMemorySaverSqliteSaverPostgresSaver 三档 checkpointer 把这套语义封装好了,SqliteSaver 一行 sqlite3.connect(check_same_thread=False) 就能让对话跨进程重启存活,PostgresSaver 则把同一能力推进到生产级的多实例并发场景。

数据 InMemorySaver 进程一死记忆即亡,而 SqliteSaver 只需 sqlite3.connect(check_same_thread=False) 一行即可让对话跨进程重启存活------这是从 demo 到上线的最小成本跃迁。

审批 UX 的三态设计与拒绝回写

审批界面设计常被低估,但其实直接决定了 HITL 流程能不能被业务团队真正用起来。三态(approve / edit / reject)是经过多轮工程实践收敛下来的最小集合:approve 是「按 agent 提议执行」、edit 是「改完后按新值执行」、reject 是「不执行」。

观察 拒绝路径如果不把「拒绝原因」写回 state,模型下一轮会把同一动作又提一次,审批流变成无限循环。这是 HITL 工程里最常见的一类 bug。

具体实现上,前端把 reject 按钮带上的 reason 字段,通过 Command(resume={"decision": "reject", "reason": "..."}) 传回图,后端在恢复节点里把 reason 追加到 state 的 messages 列表或专门的 feedback 字段。下一次 agent 循环就能从对话历史里读到「上一次被拒绝是因为 X」,从而调整下一轮提议。这种「拒绝即反馈」的回路,是把 HITL 从「单次审批」升级成「持续对齐」的关键。

python 复制代码
def approval_node(state: State):
    decision = interrupt({
        "proposed_action": state["pending_action"],
        "context": state["messages"][-3:],
    })
    if decision["decision"] == "reject":
        return {
            "messages": [AIMessage(content=f"上一轮被拒:{decision['reason']}")],
            "pending_action": None,
        }
    return {"pending_action": decision.get("edited_action", state["pending_action"])}

LangGraph 官方文档(langchain-ai.github.io/langgraph/)...%25E5%25AF%25B9%25E4%25BA%25BA%25E5%259B%259E%25E8%25B7%25AF%25E7%259A%2584%25E7%25AB%25A0%25E8%258A%2582%25E6%259C%2589%25E4%25B8%2593%25E9%2597%25A8%25E7%259A%2584 "https://langchain-ai.github.io/langgraph/)%E5%AF%B9%E4%BA%BA%E5%9B%9E%E8%B7%AF%E7%9A%84%E7%AB%A0%E8%8A%82%E6%9C%89%E4%B8%93%E9%97%A8%E7%9A%84") API 说明,interrupt 函数签名、恢复时的 Command 用法,以及和 checkpointer 的协作机制都讲得相当细;LangChain 官方文档(python.langchain.com/docs/introd...%25E9%2587%258C%25E4%25B9%259F%25E6%259C%2589%25E8%25A6%2586%25E7%259B%2596 "https://python.langchain.com/docs/introduction/)%E9%87%8C%E4%B9%9F%E6%9C%89%E8%A6%86%E7%9B%96") create_retriever_tool 这条最短路径,把 vector store 直接转成 agent 能识别的 tool 描述符。

把 RAG 当作可调用工具、把 HITL 抽象为图上的标准节点,这两件事放在一起看会发现一个深层一致性:它们都是把「不该由模型独占的决定权」从 agent 内部剥离出来,转交给可观测、可审计、可回滚的外部机制。前者把知识决策外包给检索系统,后者把高风险决策外包给人。生产化的 agent 工程,本质上就是不断识别「哪些决定权需要外移」的过程。

CI/CD 部署实战与双项目收官:从 Docker 到 AWS/Render 与两个完整系统

从 FastAPI 到 Docker:把 LangGraph 服务装进容器

把 LangGraph 跑起来的最小可行链路,在本地一行 python app.py 就能验证;但要让其他同事、给客户演示,或者接入企业内网,就必须把它包装成一个长期在线、可水平扩展的 REST 服务。第一步是写一个 FastAPI 入口:用 app = FastAPI() 起一个 ASGI(异步服务器网关接口)应用,把 LangGraph 的 StateGraph 编译产物 compiled_graph 注入到 app.state 里,再用 @app.post("/chat") 暴露一个流式接口,让前端通过 Server-Sent Events(SSE,服务器推送事件)逐 token 拿结果。这里要注意的是,LangGraph 的 graph.stream(..., stream_mode="messages") 与 FastAPI 的 StreamingResponse 必须用同一个事件循环驱动,因此推荐在路由里直接 async def,而不是把同步图包装到 run_in_executor 里------后者会在高并发下让首字延迟显著恶化。

第二步是 Docker 化。建议采用多阶段构建:第一阶段用 python:3.11-slim 装好 Poetry 或 uv,跑 pip install --no-cache-dir -r requirements.txt;第二阶段再用一个干净的 slim 镜像,只 copy 安装好的 site-packages 与应用代码。这样最终镜像通常能从 1.2 GB 压到 400 MB 左右,冷启动时间也明显缩短。环境变量与密钥必须外置------OPENAI_API_KEYLANGCHAIN_API_KEYDATABASE_URL 一律通过 .env 注入容器,镜像里严禁硬编码。生产里更推荐的做法是用 AWS Secrets Manager 或 HashiCorp Vault,启动脚本里读取后写入 /run/secrets,这样即使镜像被推到公网 registry,密钥也不会泄露。

GitHub Actions 与 Render:两条部署路径的取舍

CI/CD 流水线在 Agentic AI 项目里有两层语义:一是测试与构建,二是部署与回滚。GitHub Actions 的标准链路是:on: push 触发 → checkout 代码 → 装 Python → 跑 pytest(覆盖 LangGraph 节点单测、FastAPI 接口契约测试、Pydantic schema 校验) → docker build 打镜像 → 用 docker/login-action 推到 AWS ECR 或 Docker Hub → 最后一步通过 appleboy/ssh-action SSH 到 EC2,执行 docker pull && docker compose up -d。这套链路的好处是全可控:可以插入任意 gate(比如必须等 LangSmith 上的 eval 跑过才允许部署),也可以在 PR 阶段就 build 镜像做集成测试。

但对个人开发者或小团队,这条链路过于沉重。Render 提供了「仓库直连」的捷径:把 GitHub repo 授权给 Render,在 render.yaml 里声明 service 类型、Dockerfile 路径、env 变量、health check 路径,Render 就会在每次 push 到 main 时自动构建并部署,免维护服务器、免配 SSH 密钥、免费档还送 HTTPS。代价是免费档会在 15 分钟无流量后自动休眠,下次唤醒有冷启动延迟;而 EC2 24×7 在线,延迟稳定但要自己付账单。

EC2 与 Render:守护进程、日志与休眠特性

EC2 部署的工程要点有四块。第一是安全组:必须显式放通 80/443 入站,SSH 22 限制到公司 IP 段或跳板机,数据库端口(5432、3306)只对内网开放。第二是 IAM(身份与访问管理):为 EC2 实例绑定的 role 授予最小权限,只允许它从 ECR pull 镜像、从 Secrets Manager 读密钥,不要给 *:* 这种宽泛策略------一旦实例被入侵,攻击者横向移动的成本直接取决于这个 role 的粒度。第三是 docker run 的守护与日志:docker run -d --restart=always --log-driver=json-file --log-opt max-size=10m --log-opt max-file=3 让容器崩溃自动重启,同时防止日志把磁盘塞满。第四是 LangGraph 的持久化:如果用了 PostgresSaver,记得把 /var/lib/postgresql/data 挂载到 EBS 卷,否则实例重建后所有会话记忆都会丢失。

Render 这边的 render.yaml 写法相当声明式:用 services: 列表描述每个 worker 的 type: webruntime: dockerplan: freeautoDeploy: truehealthCheckPath: /healthz,然后在 envVars: 块里引用 Render 后台管理的 secret,镜像构建用仓库根目录下的 Dockerfile,平台自动加一层反向代理与 TLS 终止。值得注意的是 Render 免费档的休眠行为:实例空闲 15 分钟后会进入 sleep,下次请求会触发冷启动------通常 30 到 50 秒,期间首个用户的体验会很差。生产环境要么升到 starter 档保持常驻,要么在前面再挂一层 cron 定时 ping 来「保活」。日志方面,Render 把 stdout/stderr 收进自己的 log drain,保留期约 7 天,需要更长就要外接 Datadog 或 Loki。

项目一:自建 ChatGPT------LLM + LangGraph + RAG 全栈组装

自建 ChatGPT 是这套课程的集大成作业,几乎用到了前面所有组件:LangGraph 编排主对话流,LangSmith 做 trace 与 eval,ChromaDB 做向量检索,SQLAlchemy + SQLite(或 Postgres)存用户与对话元数据,FastAPI 暴露 /chat/upload 两个 REST 端点,最后整体部署到 AWS。架构上分三层:最上层是 FastAPI 路由,中间是 LangGraph 的 StateGraph,最底层是 ToolNode 包裹的若干工具------包括 retrieve_from_chroma(把用户提问做 embedding,返回 top-k 文档片段)、web_search(对接 Tavily 或 SerpAPI)、calculator(让 LLM 不要硬算数学)。State 里用 MessagesState 维护消息列表,用 add_messages reducer 自动追加,Send API 在用户问题触发「需要外部信息」条件时并行 fan-out 检索与改写。

观察 不带 checkpointer 的 chatbot 每轮 invoke 都是失忆的,对话历史无法跨轮保留;一旦在编译时挂上 checkpointer=SqliteSaver(conn),同一个 thread_id 的后续调用会自动续上之前的状态。生产里这点至关重要,否则前端每发一条消息都要把全量历史塞回 payload,既浪费 token 又让多轮工具调用断链。

流式接口的实现细节:graph.stream(input, config, stream_mode="messages") 会把每个 token 与对应的 graph 节点元数据(比如节点名、tool_call_id)成对 yield 出来;在 FastAPI 里用 StreamingResponse(generator(), media_type="text/event-stream") 转发给前端,前端 EventSource 一边收一边渲染,首字延迟可以压到亚秒级。LangSmith 的接入只需设置 LANGCHAIN_TRACING_V2=trueLANGCHAIN_API_KEY,所有 invoke/stream 调用都会自动上报,跑一段时间就能在 LangSmith 后台看到完整的执行图谱,定位「为什么这一轮它没调工具」这种问题非常方便。

项目二:TripMate 旅行规划器------多智能体协作

TripMate 是这套课程的第二个完整项目,主题是用多智能体协作做旅行规划。技术栈和项目一有交集,但工作流模式完全不同:Groq 推理(用 Llama 或 Mixtral 之类开源模型的 hosted API)、LangGraph 多智能体架构、PostgreSQL 做状态持久化、FastAPI 做服务端。Graph 的设计采用 supervisor 模式:一个 central planner 节点接收用户输入(如「下个月去东京,5 天,带 8 岁小孩」),然后用 add_conditional_edges 决定把任务分派给 flights agent、hotels agent、attractions agent、kids_activities agent 中的哪几个,每个子 agent 独立调用工具并把结果写回共享 state,最后由 planner 汇总成 itinerary。

数据 N 路独立 LLM 调用经 asyncio.gather 从 N×T 压缩到接近 T,关键在于 LLM 客户端必须用 async-aware 的 SDK,且 LangGraph 节点函数也得是 async def;否则 IO 并发会被同步阻塞整个打回原形。Groq 的优势在这里尤其明显------它的首 token 延迟通常在 200 ms 以内,适合做多 agent 并行的实时编排。

PostgresSaver 的接入比 SqliteSaver 麻烦一点:除了 connection_pool=ConnectionPool(...) 还要在启动时跑一遍 schema 初始化脚本,确认 checkpoint_writescheckpoint_blobscheckpoint_migrations 这几张表存在;配置完成后,任意一个 agent 节点抛错时,supervisor 都能从上一个成功的 checkpoint 恢复,而不是从头重跑整个图。FastAPI 层则暴露 /plan/plan/stream/history 三个端点,前端用 React 或 Svelte 做行程可视化,把每天的景点、酒店、餐厅按时间轴展开。

通用工程模板与踩坑清单

把两个项目沉淀下来,可以提炼出一套通用模板:配置层 用 Pydantic Settings 统一读 env,任何密钥缺失立即 fail-fast;可观测层 接 LangSmith,trace 与 metric 双开;持久层 选 SqliteSaver 做开发、PostgresSaver 做生产;部署层 个人与演示用 Render 自动部署,企业内网用 EC2 + GitHub Actions 全控;测试层 用 LangSmith 的 evaluators 在 CI 里跑回归 eval,确保 prompt 改动不会让关键指标回退。

决策维度 自建 EC2 Render 免费档
月成本 实例费 + 数据传输 0 美元
冷启动 几乎为零 30--50 秒
可定制性 完整 root 权限 受限于平台
HTTPS 自己配 ACM 自动
适用场景 企业生产 演示 / 个人项目

常见的踩坑点包括:LangGraph 并行节点若不配 reducer,两个分支同时写同一 state key 会直接抛 InvalidUpdateError------务必用 Annotated[list, operator.add] 或自定义 reducer;stream_mode="messages" 把首字延迟压到亚秒级,而默认 invoke 只会返回最终全量 state,二者体验差距巨大,前端 SSE 必须用前者;Dockerfile 里 COPY . . 会把 .env 也带进去,生产里要么用 .dockerignore 排除,要么在 CI 里加一个 hadolint 规则做敏感信息扫描;EC2 上的 docker logs 默认无上限,必须挂 --log-opt max-size 防止磁盘爆。

把这两条部署路径、两个完整项目串起来,Agentic AI 的工程化闭环才算真正落地:从 LangGraph 的状态图定义,到 FastAPI 的异步流式暴露,到 Docker 的镜像打包,再到 CI/CD 的自动化交付与可观测,每一层都有现成工具与官方文档支撑。LangChain 官方文档(python.langchain.com/docs/introd...%25E3%2580%2581LangGraph "https://python.langchain.com/docs/introduction/)%E3%80%81LangGraph") GitHub 仓库(github.com/langchain-a...%25E4%25B8%258E "https://github.com/langchain-ai/langgraph)%E4%B8%8E") Render 文档(render.com/docs)是该路径上必...%25E6%2598%25AF%25E8%25AF%25A5%25E8%25B7%25AF%25E5%25BE%2584%25E4%25B8%258A%25E5%25BF%2585%25E8%25AF%25BB%25E7%259A%2584%25E4%25B8%2589%25E4%25B8%25AA%25E5%2585%25A5%25E5%258F%25A3%2C%25E6%258A%258A%25E5%25AE%2583%25E4%25BB%25AC%25E5%2595%2583%25E9%2580%258F%2C%25E5%259F%25BA%25E6%259C%25AC%25E8%2583%25BD%25E8%25A6%2586%25E7%259B%2596%25E4%25BB%258E%25E5%258E%259F%25E5%259E%258B%25E5%2588%25B0%25E7%2594%259F%25E4%25BA%25A7%25E7%259A%2584%25E5%2585%25A8%25E9%2583%25A8%25E5%2585%25B3%25E9%2594%25AE%25E5%2586%25B3%25E7%25AD%2596%25E3%2580%2582 "https://render.com/docs)%E6%98%AF%E8%AF%A5%E8%B7%AF%E5%BE%84%E4%B8%8A%E5%BF%85%E8%AF%BB%E7%9A%84%E4%B8%89%E4%B8%AA%E5%85%A5%E5%8F%A3,%E6%8A%8A%E5%AE%83%E4%BB%AC%E5%95%83%E9%80%8F,%E5%9F%BA%E6%9C%AC%E8%83%BD%E8%A6%86%E7%9B%96%E4%BB%8E%E5%8E%9F%E5%9E%8B%E5%88%B0%E7%94%9F%E4%BA%A7%E7%9A%84%E5%85%A8%E9%83%A8%E5%85%B3%E9%94%AE%E5%86%B3%E7%AD%96%E3%80%82")


参考来源

A 类 · 官方与一手资料

B 类 · 社区与延伸阅读

相关推荐
大霞上仙3 小时前
trae solo模式demo--用例管理平台
人工智能
2601_958352908 小时前
接上USB,焊上麦,通话瞬间安静——WX-0813如何用AI降噪+100dB消回音,把嘈杂通话变成“金子“般清晰
人工智能·算法·语音识别·硬件开发·语音模块·降噪消回音
To_OC10 小时前
从 0 到 1:Milvus + 大模型打造私人记忆知识库
人工智能·node.js·llm
ii_best10 小时前
更新!移动端开发软件按键安卓版&手机助手v5.1.0上线!本地AI识别全面解锁,脚本开发再升级
android·人工智能·ios·按键精灵
火山引擎开发者社区10 小时前
数据库问题不用再找专家,问 DBCopilot 就行 —— 一图看懂你的数据库 AI 副驾
人工智能
ms365copilot10 小时前
PPT新模型Claude Opus 5
人工智能·microsoft·powerpoint·copilot
孙启超10 小时前
【AI应用开发】 RAG篇(四):Prompt 工程与进阶技术
人工智能·llm·embedding·rag·向量化·chunking·文档切分
我要见SA姐111 小时前
AI提示词遇见精密算法:TimeGuessr如何用数学魔法打造文化游戏新体验
人工智能·算法·游戏
蓝狐社11 小时前
OceanBase的AI时代之问:向技术要力量,还是向传统要安慰?
人工智能