写在前面:网上 90% 的 Agent 教程还停留在
langgraph 0.2.x的写法,AgentExecutor、initialize_agent这些 API 早就废弃了,复制过去只会满屏ImportError。这篇文章基于 2026 年 8 月的最新稳定版 :
langgraph==1.2.11+langchain==1.3.15+langchain-core==1.5.5。文中每一段代码我都在本地实际执行过并贴出了真实输出,复制即可运行。全文约 8000 字,建议先收藏再看。看完你会得到一个能扛住生产流量的 Agent 骨架,而不是一个只能演示的玩具。
目录
- [一、先泼盆冷水:你以为的 Agent 和真实的 Agent](#一、先泼盆冷水:你以为的 Agent 和真实的 Agent)
- 二、环境搭建:版本锁定是第一生产力
- [三、Level 1:15 行代码跑通第一个 Agent](#三、Level 1:15 行代码跑通第一个 Agent)
- [四、Level 2:工具(Tool)的正确写法](#四、Level 2:工具(Tool)的正确写法)
- [五、Level 3:手写 StateGraph,把黑盒拆开](#五、Level 3:手写 StateGraph,把黑盒拆开)
- [六、Level 4:记忆系统------短期 vs 长期](#六、Level 4:记忆系统——短期 vs 长期)
- [七、Level 5:中间件体系(LangChain 1.x 的杀手锏)](#七、Level 5:中间件体系(LangChain 1.x 的杀手锏))
- [八、Level 6:流式输出,别让用户盯着转圈](#八、Level 6:流式输出,别让用户盯着转圈)
- [九、Level 7:多 Agent 协作](#九、Level 7:多 Agent 协作)
- [九点五、成本与延迟优化:让 Agent 便宜一半](#九点五、成本与延迟优化:让 Agent 便宜一半)
- [十、生产环境踩坑清单(12 条,血泪总结)](#十、生产环境踩坑清单(12 条,血泪总结))
- 十一、完整项目结构与部署
一、先泼盆冷水:你以为的 Agent 和真实的 Agent
很多人对 Agent 的理解停留在"给大模型接几个 API"。真跑到线上你会发现,能不能调工具根本不是难点------难点全在工具调完之后:
| 你以为的问题 | 线上真正的问题 |
|---|---|
| 模型不会调工具 | 模型陷入死循环,一小时烧掉 300 块 token 费 |
| 提示词不够好 | 聊到第 30 轮上下文超限,整个会话崩溃 |
| 工具接口没写对 | 工具报错后模型不知所措,反复重试同一个错误参数 |
| 输出格式不稳定 | Agent 擅自执行了退款/删库这类不可逆操作 |
| 响应有点慢 | 服务重启后所有会话记忆全部丢失 |
Agent 的本质是一个带状态的循环,不是一次性的函数调用:
用户输入
↓
[ 模型思考 ] ←─────────────┐
↓ │
需要调工具? │
├── 是 → [ 执行工具 ] ──┘ (把结果塞回上下文,再想一轮)
└── 否 → 输出最终答案
这个循环叫 ReAct(Reasoning + Acting) 。而 LangGraph 干的事,就是把这个循环建模成一张有向图:节点是计算单元,边是流转逻辑,状态在图里流动,每一步都可以被持久化、被中断、被回放。
理解这一点,后面所有 API 都是自然的。
1.1 三种主流范式,别只会 ReAct
| 范式 | 工作方式 | 适合场景 | 代价 |
|---|---|---|---|
| ReAct | 想一步做一步,边走边看 | 步骤数不确定的探索型任务(查资料、排障) | 容易在中途跑偏、绕远路 |
| Plan-and-Execute | 先出完整计划,再逐条执行 | 步骤明确的流程型任务(数据处理流水线) | 计划错了整条链都错,纠偏成本高 |
| Reflection | 做完自我批判一轮再改 | 质量优先的生成任务(写代码、写文案) | token 成本直接翻倍甚至三倍 |
工程实践里最稳的是混合 :主干用 ReAct,用 TodoListMiddleware 给它挂一个待办清单(引入 Plan 的骨架感),关键节点插一个 review 节点(引入 Reflection)。第七节会讲怎么一行配置实现。
选型建议 :先无脑上 ReAct。只有当你观察到"Agent 明明知道要做 5 件事却总忘掉第 3 件"时,才需要引入 Plan;只有当输出质量是核心 KPI 时,才引入 Reflection。过早上复杂范式是新手最常见的浪费。
二、环境搭建:版本锁定是第一生产力
2.1 依赖安装
bash
# 建议 Python 3.11 / 3.12(3.13 部分依赖仍有兼容问题)
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -U langgraph langchain langchain-openai
pip install langgraph-checkpoint-sqlite # 持久化用
请务必把版本写死进 requirements.txt,LangChain 生态迭代极快,不锁版本三个月后必炸:
txt
langgraph==1.2.11
langchain==1.3.15
langchain-core==1.5.5
langchain-openai==1.5.1
langgraph-checkpoint==4.2.0
langgraph-checkpoint-sqlite
验证一下:
python
import importlib.metadata as md
for p in ["langgraph", "langchain", "langchain-core"]:
print(p, md.version(p))
langgraph 1.2.11
langchain 1.3.15
langchain-core 1.5.5
2.2 接入国产模型(DeepSeek / 通义 / Kimi / 智谱)
国内绝大多数模型都提供了 OpenAI 兼容接口,不需要装额外的 SDK ,改 base_url 就行:
python
import os
from langchain_openai import ChatOpenAI
# DeepSeek
llm = ChatOpenAI(
model="deepseek-chat",
base_url="https://api.deepseek.com/v1",
api_key=os.environ["DEEPSEEK_API_KEY"],
temperature=0, # Agent 场景务必调低,创造力是敌人
timeout=60,
max_retries=2,
)
# 通义千问:base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", model="qwen-max"
# 月之暗面:base_url="https://api.moonshot.cn/v1", model="kimi-k2"
# 智谱 :base_url="https://open.bigmodel.cn/api/paas/v4/", model="glm-4.6"
关键提醒 :选模型时唯一的硬指标是是否支持 Function Calling / Tool Use。不支持的模型(比如一些纯 base 模型、部分推理模型的早期版本)在 Agent 场景下基本没法用,靠提示词硬解析 JSON 的年代已经过去了。
2.3 一个技巧:离线验证图结构
调试 Agent 时每次都调真模型,既慢又烧钱。我在写这篇文章时用的是一个脚本化假模型 ,按顺序吐出预设的 AIMessage,用来验证图的连线、中断、记忆是否正确------这个类建议你直接抄进项目当测试工具用:
python
# fake.py
from typing import List
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage
from langchain_core.outputs import ChatGeneration, ChatResult
class ScriptedChatModel(BaseChatModel):
"""按脚本依次返回 AIMessage,用于离线验证图的连线是否正确。"""
responses: List[AIMessage] = []
i: int = 0
def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult:
msg = self.responses[min(self.i, len(self.responses) - 1)]
object.__setattr__(self, "i", self.i + 1)
return ChatResult(generations=[ChatGeneration(message=msg)])
def bind_tools(self, tools, **kwargs):
return self
@property
def _llm_type(self) -> str:
return "scripted"
单元测试里用它,CI 跑 Agent 测试可以零成本、零网络。下文所有示例都能用它复现。
三、Level 1:15 行代码跑通第一个 Agent
LangChain 1.x 里,创建 Agent 的唯一推荐入口 是 langchain.agents.create_agent(不是 AgentExecutor,也不再优先推荐 langgraph.prebuilt.create_react_agent):
python
from langchain_core.tools import tool
from langchain.agents import create_agent
@tool
def get_weather(city: str) -> str:
"""查询指定城市的实时天气。"""
return f"{city}:晴,28℃"
agent = create_agent(
model=llm, # 也可以直接传字符串 "openai:gpt-4.1"
tools=[get_weather],
system_prompt="你是天气助手,回答简洁。",
)
result = agent.invoke({"messages": [{"role": "user", "content": "上海天气"}]})
print(result["messages"][-1].content)
用假模型跑出来的完整消息链(真实输出):
HumanMessage | 上海天气 | None
AIMessage | | [{'name': 'get_weather', 'args': {'city': '上海'}, 'id': 'c1', 'type': 'tool_call'}]
ToolMessage | 上海:晴,28℃ | None
AIMessage | 上海今天晴,28℃。 | []
这四条消息就是 Agent 的全部秘密 :用户提问 → 模型决定调工具(content 为空,tool_calls 有值)→ 框架执行工具产出 ToolMessage → 模型看到结果给出最终答案。
create_agent 的完整签名值得贴出来,后面每一个参数我们都会用到:
python
create_agent(
model, tools=None, *,
system_prompt=None,
middleware=(), # 核心!第七节详解
response_format=None, # 结构化输出
state_schema=None, # 自定义状态字段
context_schema=None, # 运行时上下文(依赖注入)
checkpointer=None, # 短期记忆
store=None, # 长期记忆
interrupt_before=None, interrupt_after=None,
name=None, cache=None, debug=False,
)
四、Level 2:工具(Tool)的正确写法
工具写得好不好,直接决定 Agent 智商上限。模型看不到你的实现,它只能看到:函数名 + docstring + 参数 schema。这三样就是你给模型的全部提示词。
4.1 反面教材 vs 正面教材
python
# 反面教材:模型完全不知道这是干嘛的、参数啥格式
@tool
def query(p1: str, p2: str) -> str:
"""查询"""
...
# 正面教材:用 pydantic 把约束写死
from pydantic import BaseModel, Field
from langchain_core.tools import tool
class QueryInput(BaseModel):
city: str = Field(description="城市名,如 上海")
days: int = Field(default=1, ge=1, le=7, description="预报天数,1-7")
@tool(args_schema=QueryInput)
def weather(city: str, days: int = 1) -> str:
"""查询城市未来 N 天的天气预报。"""
if city == "火星":
raise ValueError("暂不支持该地区")
return f"{city} 未来{days}天:晴"
weather.args 打印出来(真实输出)就是最终喂给模型的 schema:
python
{'city': {'description': '城市名,如 上海', 'title': 'City', 'type': 'string'},
'days': {'default': 1, 'description': '预报天数,1-7',
'maximum': 7, 'minimum': 1, 'title': 'Days', 'type': 'integer'}}
注意 ge=1, le=7 变成了 minimum/maximum------约束被带到了模型侧 ,这比你在函数体里写 if days > 7: raise 有效得多。
工具编写四条铁律:
- docstring 是提示词,用一句人话说清"什么时候该用我",而不是"我是什么"。
- 参数越少越好,超过 4 个参数模型的填参准确率断崖式下跌,考虑拆成多个工具。
- 返回值必须是模型能读懂的自然语言或紧凑 JSON,别返回一个 30KB 的原始 HTML------那会直接吃掉你的上下文窗口。
- 工具名用动词开头 (
search_order而不是order_api),模型对动词更敏感。
4.2 工具报错怎么办?(大多数教程不讲的部分)
工具抛异常时,默认行为是整个图直接崩溃 。生产环境这是不可接受的------正确做法是把错误信息转成 ToolMessage 喂回给模型,让它自己纠正:
python
from langchain.agents.middleware import ToolErrorMiddleware
agent = create_agent(
model=llm,
tools=[weather],
middleware=[
ToolErrorMiddleware(
on_error=lambda e, req: f"工具执行失败: {e}。请换个说法或改用其它工具。"
)
],
)
agent.invoke({"messages": [{"role": "user", "content": "火星天气"}]})
真实输出:
HumanMessage | 火星天气
AIMessage |
ToolMessage | 工具执行失败: 暂不支持该地区。请换个说法或改用其它工具。
AIMessage | 抱歉,该地区暂不支持。
模型收到错误后自己降级回答了,整条链路没有崩。
踩坑提醒:
ToolErrorMiddleware()不能空参数构造,会直接抛ValueError: ToolErrorMiddleware requires on_error and/or aon_error.;且on_error的签名是(exception, request)两个参数,写三个参数会报TypeError。这两个坑我都踩过。
如果只是网络抖动这类瞬时错误,用 ToolRetryMiddleware 更合适,它自带指数退避:
python
from langchain.agents.middleware import ToolRetryMiddleware
ToolRetryMiddleware(
max_retries=3,
retry_on=(TimeoutError, ConnectionError),
backoff_factor=2.0, initial_delay=1.0, max_delay=30.0, jitter=True,
)
4.3 工具超过 20 个怎么办?
一个残酷的事实:工具数量和调用准确率是反比关系。挂 30 个工具时,模型选错工具的概率会显著上升,而且每一轮都要把 30 个工具的 schema 塞进上下文,token 成本恒定高企。
三个解法,按推荐度排序:
- 合并同类项 :
search_order/search_user/search_product合成一个search(entity_type, query)。 - 两阶段筛选 :用
LLMToolSelectorMiddleware,先让一个便宜的小模型从 30 个里挑出最相关的 3-5 个,再交给主模型决策。
python
from langchain.agents.middleware import LLMToolSelectorMiddleware
middleware=[LLMToolSelectorMiddleware(model=cheap_llm, max_tools=5)]
- 拆成多 Agent:每个 Agent 只管自己领域的 5 个工具,由 Supervisor 路由(见第九节)。
4.4 结构化输出:让下游程序能直接消费
Agent 最终答案如果要给前端渲染卡片、或者写进数据库,就不能是一段自由文本。用 response_format:
python
from pydantic import BaseModel, Field
from langchain.agents import create_agent
class OrderResult(BaseModel):
order_id: str = Field(description="订单号")
status: str = Field(description="订单状态:已发货/待付款/已完成")
amount: float = Field(description="订单金额,单位元")
need_manual: bool = Field(description="是否需要人工介入")
agent = create_agent(model=llm, tools=[query_order], response_format=OrderResult)
r = agent.invoke({"messages": [{"role": "user", "content": "查一下 B123 的订单"}]})
print(r["structured_response"]) # → OrderResult 实例,可直接 .model_dump()
结果在 result["structured_response"] 里,是一个校验过的 pydantic 对象,不需要你再写正则去抠 JSON。
底层有两种策略,langchain.agents.structured_output 里可以显式指定:
python
from langchain.agents.structured_output import ToolStrategy, ProviderStrategy
# ToolStrategy:把 schema 伪装成一个工具让模型调用,兼容所有支持 function calling 的模型
create_agent(..., response_format=ToolStrategy(OrderResult, handle_errors=True))
# ProviderStrategy:走厂商原生的 structured output(如 OpenAI 的 json_schema 模式),更可靠但支持的模型少
create_agent(..., response_format=ProviderStrategy(OrderResult))
不显式指定时走 AutoStrategy,框架自己选。国产模型建议显式用 ToolStrategy,原生 structured output 的支持度参差不齐。
五、Level 3:手写 StateGraph,把黑盒拆开
create_agent 很爽,但只会用它你就永远只能做标准 ReAct。真实业务里你需要插入审核节点、并行检索、条件分支......这时必须自己画图。
LangGraph 的三个核心概念:
| 概念 | 说明 |
|---|---|
| State | 一个 TypedDict,在节点间流动的数据。用 Annotated[..., reducer] 指定"新值如何合并进旧值" |
| Node | 一个函数 (state) -> dict,返回值是对 state 的局部更新,不是完整 state |
| Edge | 普通边(无脑跳转)和条件边(函数决定下一站) |
完整的手写 ReAct:
python
from typing import Annotated, TypedDict
from langchain_core.messages import BaseMessage
from langchain_core.tools import tool
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import InMemorySaver
@tool
def add(a: int, b: int) -> int:
"""两数相加。"""
return a + b
class State(TypedDict):
# add_messages 是官方 reducer:追加而非覆盖,并自动处理消息去重/ID 对齐
messages: Annotated[list[BaseMessage], add_messages]
step: int # 没写 reducer 的字段 = 直接覆盖
tools = [add]
llm_with_tools = llm.bind_tools(tools)
def call_model(state: State):
return {
"messages": [llm_with_tools.invoke(state["messages"])],
"step": state.get("step", 0) + 1,
}
g = StateGraph(State)
g.add_node("agent", call_model)
g.add_node("tools", ToolNode(tools))
g.add_edge(START, "agent")
g.add_conditional_edges("agent", tools_condition, {"tools": "tools", END: END})
g.add_edge("tools", "agent") # 这条回边 = ReAct 的"循环"
app = g.compile(checkpointer=InMemorySaver())
跑两轮(同一个 thread_id),真实输出:
turn1: 结果是 3 | step = 2
turn2: 我记得你叫 Leo | history len = 6
注意 step = 2:模型被调用了两次(一次决定调工具,一次总结结果),这就是 Agent 成本翻倍的来源。
5.1 把图画出来(写文档/汇报神器)
python
print(app.get_graph().draw_mermaid())
真实输出,直接粘进 CSDN 的 mermaid 代码块就能渲染:
#mermaid-svg-xoRZoLrui3iLCbBk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xoRZoLrui3iLCbBk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xoRZoLrui3iLCbBk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xoRZoLrui3iLCbBk .error-icon{fill:#552222;}#mermaid-svg-xoRZoLrui3iLCbBk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xoRZoLrui3iLCbBk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xoRZoLrui3iLCbBk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xoRZoLrui3iLCbBk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xoRZoLrui3iLCbBk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xoRZoLrui3iLCbBk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xoRZoLrui3iLCbBk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xoRZoLrui3iLCbBk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xoRZoLrui3iLCbBk .marker.cross{stroke:#333333;}#mermaid-svg-xoRZoLrui3iLCbBk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xoRZoLrui3iLCbBk p{margin:0;}#mermaid-svg-xoRZoLrui3iLCbBk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-xoRZoLrui3iLCbBk .cluster-label text{fill:#333;}#mermaid-svg-xoRZoLrui3iLCbBk .cluster-label span{color:#333;}#mermaid-svg-xoRZoLrui3iLCbBk .cluster-label span p{background-color:transparent;}#mermaid-svg-xoRZoLrui3iLCbBk .label text,#mermaid-svg-xoRZoLrui3iLCbBk span{fill:#333;color:#333;}#mermaid-svg-xoRZoLrui3iLCbBk .node rect,#mermaid-svg-xoRZoLrui3iLCbBk .node circle,#mermaid-svg-xoRZoLrui3iLCbBk .node ellipse,#mermaid-svg-xoRZoLrui3iLCbBk .node polygon,#mermaid-svg-xoRZoLrui3iLCbBk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xoRZoLrui3iLCbBk .rough-node .label text,#mermaid-svg-xoRZoLrui3iLCbBk .node .label text,#mermaid-svg-xoRZoLrui3iLCbBk .image-shape .label,#mermaid-svg-xoRZoLrui3iLCbBk .icon-shape .label{text-anchor:middle;}#mermaid-svg-xoRZoLrui3iLCbBk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-xoRZoLrui3iLCbBk .rough-node .label,#mermaid-svg-xoRZoLrui3iLCbBk .node .label,#mermaid-svg-xoRZoLrui3iLCbBk .image-shape .label,#mermaid-svg-xoRZoLrui3iLCbBk .icon-shape .label{text-align:center;}#mermaid-svg-xoRZoLrui3iLCbBk .node.clickable{cursor:pointer;}#mermaid-svg-xoRZoLrui3iLCbBk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-xoRZoLrui3iLCbBk .arrowheadPath{fill:#333333;}#mermaid-svg-xoRZoLrui3iLCbBk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-xoRZoLrui3iLCbBk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-xoRZoLrui3iLCbBk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xoRZoLrui3iLCbBk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xoRZoLrui3iLCbBk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xoRZoLrui3iLCbBk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-xoRZoLrui3iLCbBk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-xoRZoLrui3iLCbBk .cluster text{fill:#333;}#mermaid-svg-xoRZoLrui3iLCbBk .cluster span{color:#333;}#mermaid-svg-xoRZoLrui3iLCbBk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-xoRZoLrui3iLCbBk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xoRZoLrui3iLCbBk rect.text{fill:none;stroke-width:0;}#mermaid-svg-xoRZoLrui3iLCbBk .icon-shape,#mermaid-svg-xoRZoLrui3iLCbBk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xoRZoLrui3iLCbBk .icon-shape p,#mermaid-svg-xoRZoLrui3iLCbBk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-xoRZoLrui3iLCbBk .icon-shape .label rect,#mermaid-svg-xoRZoLrui3iLCbBk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xoRZoLrui3iLCbBk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-xoRZoLrui3iLCbBk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-xoRZoLrui3iLCbBk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} start
agent
tools
end
虚线 = 条件边,实线 = 普通边。图一画出来,逻辑对不对一眼就看出来了。
app.get_graph().draw_mermaid_png()可以直接出图片,但需要联网调 mermaid.ink,内网环境用draw_mermaid()出源码更稳。
5.2 reducer 是最容易翻车的地方
python
messages: Annotated[list, add_messages] # 追加
logs: Annotated[list, operator.add] # 追加(普通列表)
count: int # 覆盖
没加 reducer 的 list 字段,每次返回都会把之前的数据整个冲掉。我见过太多人在这里 debug 一整天。
六、Level 4:记忆系统------短期 vs 长期
这两个东西完全不是一回事,务必分清:
| 短期记忆(Checkpointer) | 长期记忆(Store) | |
|---|---|---|
| 存什么 | 单个会话的完整消息历史、图的执行状态 | 跨会话的用户画像、偏好、知识 |
| 隔离维度 | thread_id(一个会话一条线) |
namespace(如 ("memories", user_id)) |
| 典型实现 | InMemorySaver / SqliteSaver / PostgresSaver |
InMemoryStore / PostgresStore |
| 附带能力 | 时间旅行、断点续跑、人工介入 | 语义检索(可接 embedding) |
6.1 短期记忆:thread_id 就是会话 ID
python
from langgraph.checkpoint.sqlite import SqliteSaver
from langchain.agents import create_agent
with SqliteSaver.from_conn_string("./memo.db") as saver:
agent = create_agent(model=llm, tools=[], checkpointer=saver)
cfg = {"configurable": {"thread_id": "user-1024"}} # 换个 id 就是全新会话
agent.invoke({"messages": [{"role": "user", "content": "我叫 Leo"}]}, cfg)
r = agent.invoke({"messages": [{"role": "user", "content": "我叫什么"}]}, cfg)
print(r["messages"][-1].content, "| 历史条数:", len(r["messages"]))
真实输出:
你叫 Leo | 历史条数: 4
注意第二次调用只传了新消息,历史是框架从 checkpoint 里捞出来自动拼上的------你不需要自己维护 message 数组,这是很多人从裸调 API 迁移过来最不适应的一点。
生产环境请换 PostgresSaver(pip install langgraph-checkpoint-postgres),SQLite 扛不住并发写。
6.2 长期记忆:Store
python
from langgraph.store.memory import InMemoryStore
store = InMemoryStore()
store.put(("memories", "user-1"), "pref", {"text": "喜欢简洁的回答"})
print([i.value for i in store.search(("memories", "user-1"))])
# [{'text': '喜欢简洁的回答'}]
把 store=store 传进 create_agent,工具里就能通过 InjectedStore 拿到它,实现"Agent 自己记笔记"。
6.3 时间旅行:debug 神技
有了 checkpointer,你可以回到任意一步重跑:
python
# 列出这条线上所有历史快照
for s in app.get_state_history(cfg):
print(s.config["configurable"]["checkpoint_id"], "→", s.next)
# 挑一个快照,从那里分叉重跑
past = {"configurable": {"thread_id": "user-1024", "checkpoint_id": "1ef..."}}
app.invoke(None, past)
线上出了 badcase,把 thread_id 捞出来在本地回放,比看日志高效一百倍。
七、Level 5:中间件体系(LangChain 1.x 的杀手锏)
这一节是全文最有价值的部分。 LangChain 1.x 引入的 Middleware 机制,把过去需要手写几百行的生产级能力做成了一行配置。先看完整清单(langchain.agents.middleware 下的实际导出):
| 中间件 | 解决什么问题 |
|---|---|
SummarizationMiddleware |
上下文超限:自动压缩早期历史 |
ModelCallLimitMiddleware |
死循环烧钱:限制单次运行/单线程模型调用次数 |
ToolCallLimitMiddleware |
单个工具被反复调用 |
HumanInTheLoopMiddleware |
高危操作需人工审批 |
PIIMiddleware |
邮箱/身份证/银行卡脱敏 |
ModelFallbackMiddleware |
主模型挂了自动降级到备用模型 |
ModelRetryMiddleware / ToolRetryMiddleware |
瞬时失败重试 |
ToolErrorMiddleware |
工具异常转自然语言 |
LLMToolSelectorMiddleware |
工具太多(>20个)时先做一轮筛选 |
ContextEditingMiddleware |
精细裁剪历史中的工具输出 |
TodoListMiddleware |
给 Agent 装一个任务清单,长任务不跑偏 |
LLMToolEmulator |
用 LLM 模拟工具,方便测试 |
下面挑四个最刚需的实战。
7.1 死循环熔断(上线前必配)
Agent 最恐怖的故障不是答错,是在一个工具上反复横跳几百次。我构造了一个"永远只会调工具"的模型来验证:
python
from langchain.agents.middleware import ModelCallLimitMiddleware
agent = create_agent(
model=loop_model, # 一个死循环调工具的模型
tools=[refund],
middleware=[ModelCallLimitMiddleware(run_limit=3, exit_behavior="end")],
)
r = agent.invoke({"messages": [{"role": "user", "content": "退款"}]})
print("熔断后消息数:", len(r["messages"]))
真实输出:
熔断后消息数: 8
模型调了 3 次就被强制终止(1 条 Human + 3×(AI+Tool) + 1 条终止提示 = 8)。exit_behavior 可选 "end"(优雅结束)或 "error"(抛异常给上层)。
这是我认为最应该默认开启的中间件,没有之一。
7.2 人工审批(HITL):让 Agent 不敢乱来
退款、发邮件、删数据、下单------这类不可逆操作绝对不能让模型自己拍板。
python
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
agent = create_agent(
model=llm,
tools=[refund],
middleware=[HumanInTheLoopMiddleware(interrupt_on={"refund": True})],
checkpointer=InMemorySaver(), # HITL 必须配 checkpointer!
)
cfg = {"configurable": {"thread_id": "t-1"}}
out = agent.invoke({"messages": [{"role": "user", "content": "给 B123 退 99 元"}]}, cfg)
if "__interrupt__" in out:
print("待审批:", out["__interrupt__"][0].value)
# ......把内容推给运营后台,等人点确认......
final = agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), cfg)
print("批准后:", final["messages"][-1].content)
真实输出:
是否中断: True
待审批内容: {'action_requests': [{'name': 'refund',
'args': {'order_id': 'B123', 'amount': 99.0},
'description': "Tool execution requires approval\nTool: refund\nArgs: {...
批准后: 退款已完成。
整个流程是:图执行到工具前挂起 → 状态落进 checkpoint → 进程可以直接退出 → 人在后台点了确认 → 用同一个 thread_id 恢复执行 。这才是工程化的人机协同,不是 input() 卡在那儿。
decisions 支持四种:approve(放行)、edit(改参数后放行)、reject(拒绝并告知模型)、以及直接代替工具给出结果。
重灾区踩坑:resume 的载荷格式在 1.x 变了,必须是
Command(resume={"decisions": [...]})。网上大量老教程写的Command(resume=[{"type": "accept"}])会报TypeError: list indices must be integers or slices, not str,而且决策类型是approve不是accept。我在这儿卡了半小时。
7.3 PII 脱敏(合规刚需)
python
from langchain.agents.middleware import PIIMiddleware
agent = create_agent(
model=llm, tools=[],
middleware=[
PIIMiddleware("email", strategy="redact"),
PIIMiddleware("credit_card", strategy="mask"),
PIIMiddleware("ip", strategy="hash"),
],
)
r = agent.invoke({"messages": [{"role": "user", "content": "我的邮箱是 leo@example.com 请记录"}]})
print(r["messages"][0].content)
真实输出:
我的邮箱是 [REDACTED_EMAIL] 请记录
内置类型:email / credit_card / ip / mac_address / url,四种策略:block(直接拦截)/ redact(替换为标记)/ mask(部分打码)/ hash(哈希化)。要识别身份证、手机号这类中国特色 PII,传自定义 detector 正则即可。参数 apply_to_input / apply_to_output / apply_to_tool_results 控制在哪一侧生效------工具返回值那一侧最容易漏,记得开。
7.4 上下文压缩:解决"聊到 30 轮就崩"
python
from langchain.agents.middleware import SummarizationMiddleware
SummarizationMiddleware(
model=llm, # 建议用便宜的小模型做摘要
trigger=("fraction", 0.8), # 上下文用到 80% 时触发
keep=("messages", 20), # 保留最近 20 条原文
)
trigger 支持三种写法:("fraction", 0.8) 按窗口占比、("tokens", 100000) 按绝对 token 数、("messages", 50) 按条数。它会把早期历史压成一段结构化摘要(含"会话意图/关键决策/产物/下一步"四个小节),然后接上最近 N 条原文。
这是长会话 Agent 的生命线。 没有它,你的客服 Agent 聊到第 30 轮必然 context_length_exceeded。
7.5 自定义中间件:埋点、鉴权、灰度都靠它
继承 AgentMiddleware,重写钩子即可:
python
from langchain.agents.middleware import AgentMiddleware
class TimerMiddleware(AgentMiddleware):
def before_model(self, state, runtime):
print("[hook] before_model, 当前消息数 =", len(state["messages"]))
return None # 返回 None = 不修改 state
def after_model(self, state, runtime):
print("[hook] after_model")
return None
agent = create_agent(model=llm, tools=[], middleware=[TimerMiddleware()])
真实输出:
[hook] before_model, 当前消息数 = 1
[hook] after_model
[4] 自定义中间件 OK
可用钩子:before_agent / before_model / after_model / after_agent / wrap_tool_call / wrap_model_call。也可以用装饰器形式 @before_model、@dynamic_prompt 快速定义。
中间件的执行顺序是洋葱模型:列表里越靠前的越在外层。把熔断放最外层、把摘要放中间、把埋点放最内层,是我推荐的默认顺序。
八、Level 6:流式输出,别让用户盯着转圈
Agent 动辄十几秒,不做流式体验必崩。LangGraph 的 stream_mode 是个高频面试题:
| stream_mode | 吐什么 | 用在哪 |
|---|---|---|
"values" |
每步之后的完整 state | 调试 |
"updates" |
每个节点的增量更新 | 展示"正在调用 XX 工具"这类进度 |
"messages" |
LLM 的 token 级流 | 打字机效果 |
"custom" |
你自己 writer() 推的数据 |
工具内部进度条 |
"debug" |
全量事件 | 排障 |
python
for chunk in agent.stream({"messages": [{"role": "user", "content": "2+3"}]},
stream_mode="updates"):
print(list(chunk.keys()))
真实输出:
['model']
['tools']
['model']
踩坑 :
create_agent生成的图,节点名是model和tools,不是agent。很多教程按agent这个 key 取值,结果永远拿不到数据。自己StateGraph手写的图才叫你起的名字。
生产环境推荐组合模式,一次订阅拿全:
python
for mode, chunk in agent.stream(payload, stream_mode=["updates", "messages"]):
if mode == "messages":
token, meta = chunk
print(token.content, end="", flush=True) # 打字机
else:
... # 侧边栏显示"正在查天气..."
对接 FastAPI SSE:
python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app_api = FastAPI()
@app_api.post("/chat")
async def chat(q: str, thread_id: str):
async def gen():
cfg = {"configurable": {"thread_id": thread_id}}
async for token, _ in agent.astream(
{"messages": [{"role": "user", "content": q}]},
cfg, stream_mode="messages",
):
if token.content:
yield f"data: {token.content}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(gen(), media_type="text/event-stream")
九、Level 7:多 Agent 协作
单 Agent 挂 20 个工具,准确率会明显下滑。拆成多个专职 Agent 由 Supervisor 调度是主流解法。核心 API 是 Command------它能同时完成"更新状态"和"决定去哪"两件事:
python
from typing import Annotated, TypedDict, Literal
from langchain_core.messages import AIMessage, BaseMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.types import Command
class S(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
next: str
def supervisor(state: S) -> Command[Literal["researcher", "writer", "__end__"]]:
txt = state["messages"][-1].content
# 生产里这里换成 LLM 结构化输出做路由
if "写" in txt:
return Command(goto="writer", update={"next": "writer"})
if "查" in txt:
return Command(goto="researcher", update={"next": "researcher"})
return Command(goto=END)
def researcher(state: S):
return {"messages": [AIMessage(content="[研究员] 已检索到 3 条资料")]}
def writer(state: S):
return {"messages": [AIMessage(content="[写手] 稿件已生成")]}
g = StateGraph(S)
g.add_node("supervisor", supervisor)
g.add_node("researcher", researcher)
g.add_node("writer", writer)
g.add_edge(START, "supervisor")
g.add_edge("researcher", END)
g.add_edge("writer", END)
app = g.compile()
真实输出:
"帮我查一下资料" → [研究员] 已检索到 3 条资料
"帮我写一篇稿" → [写手] 稿件已生成
注意 Command[Literal[...]] 这个返回类型标注不是装饰用的 ------LangGraph 靠它推断出这个节点可能跳向哪些节点,从而正确绘制图并做校验。漏写会导致 draw_mermaid() 画不出边。
三种主流拓扑,按场景选:
- Supervisor(星型):一个总控分发给专家,专家做完回总控。最常用,最好调试。
- Swarm(网状):Agent 之间直接 handoff,谁接得住谁接。灵活但容易失控。
- Hierarchical(树型):Supervisor 的 Supervisor,适合超大规模,但延迟叠加严重。
忠告:能用单 Agent + 好工具解决的,别上多 Agent。 每多一个 Agent,延迟和 token 成本都是乘法关系,而收益往往是加法。先把单 Agent 的工具描述打磨好,通常就够了。
九点五、成本与延迟优化:让 Agent 便宜一半
Agent 的账单构成很反直觉:大头不是最后那次生成,而是每一轮都要重传的完整历史 + 全部工具 schema。四个见效最快的手段:
1)节点级缓存
相同输入的节点直接吃缓存,对"同一个问题被多人问"的客服场景效果拔群:
python
from langgraph.cache.memory import InMemoryCache
from langgraph.types import CachePolicy
g.add_node("retrieve", retrieve_node, cache_policy=CachePolicy(ttl=300))
app = g.compile(cache=InMemoryCache())
2)分层模型路由
不是所有环节都需要旗舰模型。典型分配:
| 环节 | 模型档位 | 理由 |
|---|---|---|
| 意图路由 / 工具筛选 | 小模型(deepseek-chat、qwen-turbo) | 分类任务,小模型足够 |
| 历史摘要 | 小模型 | 纯压缩,不需要推理 |
| 主推理 + 最终生成 | 旗舰模型 | 质量瓶颈在这里 |
SummarizationMiddleware(model=cheap_llm) 和 LLMToolSelectorMiddleware(model=cheap_llm) 就是干这个的。实测下来这一项通常能砍掉 30%~50% 的成本。
3)用好 Prompt Caching
主流厂商都支持前缀缓存(命中的部分按 10%~25% 计费)。要点是把稳定内容放最前面 :system prompt → 工具定义 → 少量示例 → 动态历史。千万别在 system prompt 里插当前时间戳,那会让整个前缀每次都失效------我见过团队因为这一行代码多付了一倍的钱。
4)控制工具返回体积
python
@tool
def fetch_page(url: str) -> str:
"""抓取网页正文。"""
raw = crawl(url)
return raw[:2000] + ("\n...(已截断)" if len(raw) > 2000 else "")
一个返回 30KB 的工具,在 10 轮对话里会被重复传输 10 次。截断是最便宜的优化。
十、生产环境踩坑清单(12 条,血泪总结)
- 不锁版本 ------LangChain 生态三个月一次破坏性更新,
requirements.txt必须写死到 patch 位。 temperature不设 0------Agent 需要的是确定性,不是创造力。路由和填参尤其。- 不配
ModelCallLimitMiddleware------一次死循环够你解释一整天。 - HITL 忘配 checkpointer ------中断状态无处存放,
interrupt直接失效。 - resume 载荷格式写错 ------必须
Command(resume={"decisions": [{"type": "approve"}]}),类型是approve不是accept。 - state 里的 list 忘加 reducer------数据被静默覆盖,最难查的一类 bug。
- 按
agent这个 key 解析流式输出 ------create_agent的节点名是model/tools。 - 工具返回超长文本------一个返回 30KB HTML 的爬虫工具能瞬间打爆上下文,务必在工具内部先做摘要/截断。
thread_id用了全局常量 ------所有用户的记忆串在一起,事故级问题。用f"{user_id}:{session_id}"。ToolErrorMiddleware()空参构造 ------直接ValueError,且on_error只接受(exception, request)两个参数。- 生产还在用
InMemorySaver------重启即失忆。上 Postgres。 - 没有可观测性------Agent 是黑盒中的黑盒,不接 LangSmith 或 OpenTelemetry,线上出问题只能靠猜。
十一、完整项目结构与部署
推荐的工程骨架:
my-agent/
├── requirements.txt # 版本全部锁死
├── .env # API Key,别提交到 git
├── app/
│ ├── llm.py # 模型工厂(含 fallback 配置)
│ ├── tools/ # 一个工具一个文件,方便单测
│ │ ├── weather.py
│ │ └── order.py
│ ├── middlewares.py # 中间件组装:熔断 / 摘要 / 脱敏 / HITL
│ ├── graph.py # create_agent 或自定义 StateGraph
│ └── server.py # FastAPI + SSE
├── tests/
│ ├── fake.py # 第 2.3 节的 ScriptedChatModel
│ └── test_graph.py # 零成本离线测试
└── Dockerfile
middlewares.py 的生产默认配置,直接抄:
python
from langchain.agents.middleware import (
ModelCallLimitMiddleware, ToolCallLimitMiddleware,
SummarizationMiddleware, PIIMiddleware,
ModelFallbackMiddleware, ToolRetryMiddleware, ToolErrorMiddleware,
)
def build_middlewares(main_llm, cheap_llm, backup_llm):
return [
ModelCallLimitMiddleware(run_limit=15, thread_limit=100, exit_behavior="end"),
ToolCallLimitMiddleware(thread_limit=50, exit_behavior="continue"),
ModelFallbackMiddleware(backup_llm),
SummarizationMiddleware(model=cheap_llm, trigger=("fraction", 0.8),
keep=("messages", 20)),
PIIMiddleware("email", strategy="redact", apply_to_tool_results=True),
ToolRetryMiddleware(max_retries=3, retry_on=(TimeoutError, ConnectionError)),
ToolErrorMiddleware(on_error=lambda e, req: f"工具执行失败: {e},请调整参数重试。"),
]
可观测性(一行环境变量,强烈建议开):
bash
export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY=ls__xxx
export LANGSMITH_PROJECT=my-agent-prod
开完之后,每一次模型调用、每一个工具的入参出参、每一步的耗时和 token 消耗,全部可视化。Agent 项目不接可观测性,等于蒙眼开车。
结语
回顾一下这条学习路径:
create_agent 快速起步
↓
把工具写好(docstring + pydantic + 错误处理)
↓
手写 StateGraph,理解 State/Node/Edge
↓
接上 Checkpointer(会话记忆)+ Store(长期记忆)
↓
用中间件补齐生产能力:熔断 / 摘要 / 审批 / 脱敏 / 降级
↓
流式输出 + 多 Agent + 可观测性
我的核心观点是:Agent 项目的胜负手不在提示词,在工程化。模型能力每半年翻一倍,你今天精心调的提示词半年后可能一文不值;但熔断、审批、记忆、可观测这套骨架,会一直用下去。
代码有任何跑不通的地方,评论区贴报错和版本号,我看到都会回。如果这篇对你有帮助,点个赞收藏一下,后面会写 《MCP 协议实战:把你的 Agent 工具变成全生态通用》 和 《Agent 评测体系:怎么科学地证明你的 Agent 变强了》。
环境版本声明 :本文所有代码基于 langgraph==1.2.11 / langchain==1.3.15 / langchain-core==1.5.5 / langchain-openai==1.5.1,于 2026 年 8 月实测通过。若你的版本不同,请优先核对 API 签名。