LangChain与LangGraph协同开发LLM智能体实战指南

📅 2026/7/8 0:12:30 👁️ 阅读次数 📝 编程学习
LangChain与LangGraph协同开发LLM智能体实战指南

1. 项目概述:为什么说 LangChain 和 LangGraph 是 LLM 应用开发中真正能“落地”的两把利剑?

我从 2023 年初开始在生产环境里跑第一个带记忆的客服对话系统,那时候连ChatPromptTemplate都还没进主干分支,大家全靠手拼 system/user/assistant 消息、硬写messages.append()、用json.loads()解析 LLM 返回的字符串——结果就是模型一 hallucinate,整个流程就卡死在 JSONDecodeError 里,日志里全是红色 traceback。直到 LangChain v0.1 推出 Runnable 接口,我才第一次意识到:原来 LLM 不该被当“黑盒 API”调用,而该被当“可组合的函数单元”来编排。两年过去,我亲手交付过 7 个不同行业的 LLM 应用:保险核保规则解释器、制药企业 SOP 智能检索助手、制造业设备维修知识图谱问答、地方政府政策匹配引擎……所有项目里,LangChain 是默认起点,LangGraph 是必经拐点。这不是概念炒作,而是工程现实——当你需要让大模型“做决定”(比如先查数据库、再调外部 API、最后生成报告),LangChain 的链式调用会迅速变成面条代码;而 LangGraph 的状态机建模,能把这种多跳、有分支、需重试的逻辑,压缩成一张清晰可测的状态流转图。你不需要成为图论专家,但必须理解:LangChain 解决的是“怎么把 LLM 和工具连起来”,LangGraph 解决的是“怎么让 LLM 和工具按正确顺序、正确条件、正确次数连起来”。热搜词里反复出现的agentLCELlanggraph教程,本质都是围绕这两个问题展开的实操需求。如果你正在写一个需要调用天气 API + 搜索历史订单 + 生成邮件草稿的智能助理,或者要构建一个能自主拆解复杂查询、分步调用多个专业工具的分析系统,那这篇内容就是为你写的——它不讲原理推导,只讲我在 Ubuntu 22.04 + Python 3.11 环境下,用miniconda创建隔离环境、部署deepseek-v2本地模型、接入自研维修知识库、最终上线稳定运行 187 天的完整路径。

2. 核心设计思路拆解:LangChain 与 LangGraph 的分工本质不是“替代”,而是“演进”

2.1 LangChain 的定位:LLM 应用的“基础建设层”,解决连接性问题

很多人误以为 LangChain 是个“框架”,其实它更像一套标准化接口规范。它的核心价值不在功能多,而在统一了三类关键抽象:

  • Runnable:把任何东西(LLM、提示词、解析器、甚至普通 Python 函数)都包装成.invoke()可调用的对象。这解决了最原始的“胶水代码”问题。比如你写一个get_weather(city: str) -> str函数,LangChain 要求你把它封装成RunnableLambda(get_weather),这样它就能和ChatOpenAI模型一样,被塞进.pipe()链里。我试过直接用原生openai.ChatCompletion.create(),结果发现一旦要加重试逻辑、加缓存、加日志埋点,就得在每个调用点重复写try/exceptredis.get()logging.info()——而 Runnable 的.with_retry().with_config()方法,一行代码就全局生效。

  • MessageHistory:不是简单存聊天记录,而是定义了add_message()get_messages()的契约。这意味着你可以把 Redis、PostgreSQL、甚至本地 SQLite 当作消息后端,只要实现这几个方法,上层业务代码完全不用改。我们给某银行做的理财顾问系统,初期用InMemoryChatMessageHistory快速验证,上线后无缝切换到 Redis,只改了两行初始化代码。

  • Tool:把外部能力(API、数据库查询、文件读取)抽象为namedescriptionargs_schema三要素。重点是args_schema——它强制你用 Pydantic 模型声明参数类型和校验规则。这直接堵死了 90% 的前端传参错误。比如一个search_knowledge_base(query: str, top_k: int = 3)工具,如果前端传top_k: "five",Pydantic 会在进入工具执行前就抛出ValidationError,而不是让 LLM 拿着字符串去数据库里瞎搜。

LangChain 的局限也正源于此:它默认假设流程是线性的。.pipe()是单向流水线,.assign()是单次状态更新。一旦你需要“如果 A 成功则走 B,否则走 C;B 失败则重试 2 次,C 失败则降级为人工”——这套线性范式就崩了。这时候,不是 LangChain 不好,而是它没设计去处理状态驱动的决策流。

2.2 LangGraph 的定位:LLM Agent 的“操作系统内核”,解决状态与控制流问题

LangGraph 的诞生,本质上是对 LangChain 线性模型的一次必要补完。它不取代 Runnable,而是给 Runnable 加装了“调度器”和“状态寄存器”。它的核心设计哲学只有两条:

  • State 是一等公民:所有节点(node)的输入输出,都必须是同一个State类型的实例。这个 State 不是全局变量,而是每次调用.invoke()时显式传入的快照。比如我们定义class AgentState(TypedDict): messages: list[BaseMessage]; tool_calls: list[dict]; next_action: str,那么每个节点函数签名必须是def call_llm(state: AgentState) -> AgentState。这强制开发者思考:当前步骤改变了哪些字段?后续步骤依赖哪些字段?避免了隐式状态污染。

  • Graph 是控制流蓝图add_node()注册函数,add_edge()定义无条件跳转,add_conditional_edges()定义基于 State 字段值的分支判断。最关键的是END节点——它不是退出程序,而是告诉调度器“本次循环结束,返回当前 State 给调用方”。真正的循环由.stream().invoke()recursion_limit参数控制。我们做过压力测试:当recursion_limit=25时,一个包含 5 个工具调用、3 层嵌套条件判断的 Graph,在 16 核 CPU 上平均耗时 1.2 秒,失败率低于 0.3%,远优于手写 while 循环 + if/else 的方案。

LangGraph 和 LangChain 的关系,就像 Linux 内核和 Shell 命令的关系。lsgrepcurl这些命令(对应 LangChain 的 Runnable)本身功能强大,但要自动化完成“遍历所有日志目录,找出含 ERROR 的最新 10 行,发邮件给运维组”,你就得写 Bash 脚本(对应 LangGraph 的 Graph)。脚本里依然大量调用lsgrep,但控制逻辑(循环、条件、错误处理)由 Shell 解释器统一管理。LangGraph 正是这个“解释器”。

2.3 LCEL:LangChain 的“函数式编程语法糖”,降低组合门槛

LCEL(LangChain Expression Language)常被误解为新功能,其实它是 LangChain 对 Runnable 组合方式的一次语法升级。它的价值在于:把原本需要 5 行代码的链式调用,压缩成 1 行可读性强的表达式。

比如传统写法:

from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个专业客服,回答要简洁准确"), ("user", "{input}") ]) llm = ChatOpenAI(model="gpt-4-turbo") chain = prompt | llm | StrOutputParser()

LCEL 写法:

chain = ( ChatPromptTemplate.from_messages([ ("system", "你是一个专业客服,回答要简洁准确"), ("user", "{input}") ]) | ChatOpenAI(model="gpt-4-turbo") | StrOutputParser() )

表面看只是换了个|符号,但背后是重大工程改进:LCEL 表达式在构建时就完成了类型推断和错误检查。如果你在|后面接了一个返回dict的函数,而前面 LLM 返回的是AIMessage,LCEL 会在chain.invoke()执行前就报TypeError,而不是等到运行时才崩溃。我们团队曾用 LCEL 快速搭建内部文档 QA 系统,从写提示词到部署上线只用了 3 小时,因为所有类型不匹配都在本地测试阶段暴露了,没让一个错误流入测试环境。

提示:LCEL 不是万能的。当你的链需要动态分支(比如根据用户身份切换不同提示词),或需要中间状态暂存(比如先调用 LLM 生成 SQL,再用另一个 Runnable 执行 SQL),就必须退回到RunnableLambda+RunnableParallel的组合模式。LCEL 适合线性、确定性流程;复杂逻辑请拥抱 LangGraph。

3. 实操环境搭建与核心环节实现:从零开始部署一个带工具调用的 LangGraph Agent

3.1 环境准备:用 miniconda 创建纯净、可复现的 Python 环境

我坚持用miniconda而非pip全局安装,原因很实际:LLM 生态的依赖冲突太常见。langchain-core依赖pydantic<2.6,而langgraph依赖pydantic>=2.5transformers又要求packaging>=20.0——手动 pip install 极易陷入“版本地狱”。minicondaenvironment.yml文件能锁定所有依赖版本,确保团队成员、CI/CD 服务器、生产环境运行完全一致。

我的标准环境配置(environment.yml):

name: langgraph-agent channels: - conda-forge - defaults dependencies: - python=3.11 - pip - pip: - langchain==0.3.7 - langchain-openai==0.2.5 - langgraph==0.3.12 - langchain-community==0.3.7 - openai==1.50.2 - pydantic==2.7.4 - redis==4.6.0 - psycopg2-binary==2.9.9 - jieba==0.42.1 # 中文分词必备

创建步骤(Ubuntu 22.04):

# 1. 下载并安装 miniconda(国内用户建议用清华源) wget https://mirrors.tuna.tsinghua.edu.cn/anaconda/miniconda/Miniconda3-latest-Linux-x86_64.sh bash Miniconda3-latest-Linux-x86_64.sh -b -p $HOME/miniconda3 $HOME/miniconda3/bin/conda init bash source ~/.bashrc # 2. 创建环境并激活 conda env create -f environment.yml conda activate langgraph-agent # 3. 验证关键包版本(这是避免后续报错的第一道防线) python -c "import langchain; print(langchain.__version__)" python -c "import langgraph; print(langgraph.__version__)"

注意:不要用conda install langchain直接安装,因为 conda-forge 仓库的 langchain 版本通常滞后于 PyPI。务必通过pip在 conda 环境中安装,以获取最新修复。

3.2 构建核心 Agent:一个能查询设备维修知识库并生成处置建议的 Graph

我们的目标 Agent 需要完成:用户提问 → 判断是否为设备故障类问题 → 若是,则搜索维修知识库 → 若找到匹配条目,则生成结构化处置建议;若未找到,则触发人工介入流程。整个过程需支持最多 3 次工具调用,且每次调用失败自动重试。

第一步:定义 State 和 Tools

from typing import TypedDict, List, Annotated, Optional from langchain_core.messages import BaseMessage, HumanMessage, AIMessage from langchain_core.tools import tool from langchain_core.pydantic_v1 import BaseModel, Field from langgraph.graph import StateGraph, END from langgraph.prebuilt import ToolNode, tools_condition class AgentState(TypedDict): messages: Annotated[List[BaseMessage], operator.add] # 自动合并消息列表 tool_calls: List[dict] next_action: str # 'call_tool', 'generate_response', 'escalate' retry_count: int # 定义维修知识库查询工具(模拟真实数据库查询) class SearchRepairDBInput(BaseModel): query: str = Field(description="用户问题的关键词,如'电机过热'、'PLC通讯中断'") top_k: int = Field(default=3, description="返回结果数量") @tool("search_repair_db", args_schema=SearchRepairDBInput) def search_repair_db(query: str, top_k: int = 3) -> List[dict]: """模拟查询设备维修知识库""" # 实际项目中这里会连接 PostgreSQL 或 Elasticsearch mock_db = [ {"id": "R001", "fault": "电机过热", "cause": "散热风扇故障", "solution": "清洁风扇叶片,检查电机轴承"}, {"id": "R002", "fault": "PLC通讯中断", "cause": "网线松动或交换机故障", "solution": "检查物理连接,重启交换机"}, {"id": "R003", "fault": "传感器读数异常", "cause": "传感器校准失效", "solution": "执行传感器零点校准流程"} ] # 简单关键词匹配(真实场景用 BM25 或向量检索) results = [item for item in mock_db if query in item["fault"] or query in item["cause"]] return results[:top_k] # 定义人工介入工具(触发工单系统) class EscalateToHumanInput(BaseModel): user_question: str = Field(description="用户原始问题") context: str = Field(description="当前已知信息摘要") @tool("escalate_to_human", args_schema=EscalateToHumanInput) def escalate_to_human(user_question: str, context: str) -> str: """生成人工介入工单""" # 实际项目中调用 Jira 或钉钉机器人 API return f"已创建工单:紧急度-高,问题-{user_question},上下文-{context[:50]}..."

第二步:编写 Graph 节点函数

from langchain_core.language_models import BaseChatModel from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI from langgraph.prebuilt import ToolNode # 初始化 LLM(此处用 OpenAI,实际项目可用 Ollama 本地模型) llm = ChatOpenAI(model="gpt-4-turbo", temperature=0) # 节点1:路由判断(决定下一步是调用工具还是生成回复) def route_question(state: AgentState) -> str: """根据用户问题判断是否为设备故障类""" last_message = state["messages"][-1] if not isinstance(last_message, HumanMessage): return "generate_response" # 用 LLM 判断问题类型(轻量级分类) prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个设备维修领域分类器。请判断用户问题是否属于'设备故障诊断'或'维修操作指导'。只回答'YES'或'NO'。"), ("user", last_message.content) ]) response = prompt | llm | StrOutputParser() result = response.invoke({}) return "call_tool" if result.strip().upper() == "YES" else "generate_response" # 节点2:调用工具(使用 LangGraph 内置的 ToolNode) tool_node = ToolNode([search_repair_db, escalate_to_human]) # 节点3:生成最终回复 def generate_response(state: AgentState) -> AgentState: """根据工具返回结果生成用户友好的回复""" messages = state["messages"] last_message = messages[-1] # 提取工具调用结果(从 messages 中找 AIMessage.tool_calls) tool_results = [] for msg in reversed(messages): if isinstance(msg, AIMessage) and msg.tool_calls: for tc in msg.tool_calls: if tc["name"] == "search_repair_db": tool_results = tc["result"] elif tc["name"] == "escalate_to_human": return {"messages": [AIMessage(content=f"已为您创建人工服务工单,工单号:{tc['result']}")]} if not tool_results: return {"messages": [AIMessage(content="未找到相关维修知识,请描述更具体的故障现象。")]} # 用 LLM 整合结果生成回复 prompt = ChatPromptTemplate.from_messages([ ("system", "你是一名资深设备维修工程师。请根据以下维修知识,用中文生成简洁、可操作的处置建议。避免技术术语堆砌。"), ("user", f"故障现象:{last_message.content}\n\n参考知识:{tool_results}") ]) response = prompt | llm | StrOutputParser() final_answer = response.invoke({}) return {"messages": [AIMessage(content=final_answer)]}

第三步:构建 Graph 并添加条件边

# 初始化 Graph workflow = StateGraph(AgentState) # 添加节点 workflow.add_node("route_question", route_question) workflow.add_node("call_tool", tool_node) workflow.add_node("generate_response", generate_response) # 设置入口点 workflow.set_entry_point("route_question") # 添加边:route_question 的输出决定流向 workflow.add_conditional_edges( "route_question", lambda x: x["next_action"], # 这里我们简化,实际应返回字符串 { "call_tool": "call_tool", "generate_response": "generate_response", } ) # 工具调用后的条件边:根据工具执行结果决定下一步 workflow.add_conditional_edges( "call_tool", tools_condition, # LangGraph 内置函数,自动判断是否还有待执行的 tool_calls { "tools": "call_tool", # 还有工具要调,继续循环 "__end__": "generate_response", # 工具调用完毕,生成回复 } ) # 生成回复后结束 workflow.add_edge("generate_response", END) # 编译 Graph app = workflow.compile() # 测试运行 inputs = {"messages": [HumanMessage(content="PLC通讯中断怎么办?")]} for output in app.stream(inputs): for key, value in output.items(): print(f"节点 {key}: {value['messages'][-1].content}")

3.3 关键参数与性能调优:如何让 Graph 在生产环境稳定运行

LangGraph 的recursion_limitmax_concurrency是两个最容易被忽视却影响巨大的参数。

  • recursion_limit:默认值是 25,但这是指“最大循环次数”,不是“最大工具调用次数”。一次循环内可以并行调用多个工具(通过RunnableParallel),所以实际工具调用总数 =recursion_limit×max_concurrency。我们在压测中发现,当recursion_limit=15时,99% 的请求能在 800ms 内完成;设为 25 时,长尾延迟飙升到 3.2 秒。最终我们定为12,并配合超时机制:

    from langgraph.checkpoint.memory import MemorySaver from langgraph.graph import StateGraph # 使用内存检查点,支持中断恢复 checkpointer = MemorySaver() app = workflow.compile( checkpointer=checkpointer, interrupt_before=["call_tool"], # 在调用工具前可中断 config={ "recursion_limit": 12, "max_concurrency": 3, } ) # 调用时设置超时 try: result = app.invoke( {"messages": [HumanMessage(content="...")]}, config={"configurable": {"thread_id": "12345"}} ) except Exception as e: # 记录超时日志,触发告警 logging.error(f"Agent timeout: {e}")
  • max_concurrency:控制同一循环内并行执行的节点数。设为 1 是严格串行,设为os.cpu_count()可能导致线程争抢。我们经过实测,在 8 核 CPU 上设为3时吞吐量最高(QPS 达 42),CPU 利用率稳定在 65% 左右。超过 4 后,Redis 连接池开始出现ConnectionResetError,说明 I/O 成为瓶颈。

实操心得:不要迷信“更高并发更好”。我们曾把max_concurrency设为 8,结果发现 30% 的请求因 Redis 连接超时失败。后来改用连接池 + 连接复用,并将max_concurrency降为 3,错误率归零。记住:LLM 应用的瓶颈往往不在 CPU,而在网络 I/O 和外部服务响应时间。

4. 常见问题与排查技巧实录:那些官方文档不会写的“血泪教训”

4.1 “State 更新不生效”问题:90% 的新手都踩过的坑

现象:在节点函数里修改了state字典,但下一个节点收到的state还是旧值。

原因:LangGraph 要求节点函数必须返回一个全新的 State 字典,而不是就地修改。这是为了保证状态不可变(immutability),便于调试和回滚。

错误写法:

def bad_node(state: AgentState) -> AgentState: state["messages"].append(AIMessage(content="hello")) # ❌ 就地修改 return state # 返回的是原引用

正确写法:

def good_node(state: AgentState) -> AgentState: new_messages = state["messages"] + [AIMessage(content="hello")] # ✅ 创建新列表 return {"messages": new_messages} # ✅ 返回新字典

更安全的写法(推荐):

from copy import deepcopy def safe_node(state: AgentState) -> AgentState: new_state = deepcopy(state) # 深拷贝,杜绝副作用 new_state["messages"].append(AIMessage(content="hello")) return new_state

注意:deepcopy有性能开销,但在大多数场景下可忽略。如果 State 数据量极大(如包含图像 base64 字符串),则需手动构造新字典,避免深拷贝。

4.2 “Tool 调用无限循环”问题:条件边配置失误的典型表现

现象:Graph 进入call_tool节点后,反复调用同一个工具,直到recursion_limit触发异常。

原因:tools_condition函数的返回值与add_conditional_edges()中定义的键不匹配。tools_condition默认返回"tools"(表示还有工具要调)或"__end__"(表示工具调用完毕)。如果你在add_conditional_edges()中写了{"tools": "call_tool", "end": "generate_response"},那么"__end__"就找不到对应节点,Graph 会默认跳回入口点,造成死循环。

排查步骤:

  1. call_tool节点后加日志,打印tools_condition的返回值;
  2. 检查add_conditional_edges()的映射字典,确认键名完全一致(注意大小写和下划线);
  3. 最稳妥的做法是显式指定tools_condition的返回值:
    def custom_tools_condition(state: AgentState) -> str: if state.get("tool_calls"): return "continue" else: return "done" workflow.add_conditional_edges( "call_tool", custom_tools_condition, { "continue": "call_tool", "done": "generate_response" } )

4.3 “中文乱码与 Token 截断”问题:模型输入长度控制的硬伤

现象:用户输入中文问题,LLM 返回乱码或“抱歉,我无法回答”。

原因:OpenAI 等模型对输入 token 数有限制(gpt-4-turbo 为 128K),但 LangChain 的messages会自动拼接,如果历史消息过长,新问题就会被截断。更隐蔽的是:ChatPromptTemplateformat()方法在处理中文时,len(prompt.format(...))返回的是字符数,而非 token 数,导致预估严重偏差。

解决方案:

  • 前置截断:在route_question节点前,用tiktoken计算总 token 数,只保留最近 N 条消息:
    import tiktoken enc = tiktoken.encoding_for_model("gpt-4-turbo") def truncate_messages(messages: List[BaseMessage], max_tokens: int = 8000) -> List[BaseMessage]: total_tokens = 0 truncated = [] for msg in reversed(messages): # 从最新消息开始保留 tokens = len(enc.encode(msg.content)) if total_tokens + tokens < max_tokens: truncated.append(msg) total_tokens += tokens else: break return list(reversed(truncated)) # 恢复时间顺序
  • Prompt 压缩:对历史消息做摘要,用 LLM 生成一句话总结,替换原始长消息。我们用gpt-3.5-turbo做摘要,成本极低,效果显著。

4.4 “Agent 响应慢”问题:不是模型慢,而是架构没优化

现象:单次app.invoke()耗时超过 5 秒,但单独调用llm.invoke()只需 300ms。

排查清单(按优先级排序):

  1. 检查 Redis 连接langgraph默认用MemorySaver,但生产环境必须用RedisSaver。如果 Redis 服务器延迟高(>50ms),每次状态保存/加载都会拖慢整体速度。用redis-cli --latency测试。
  2. 禁用冗余日志langgraphDEBUG日志会记录每一步 State 变化,I/O 开销巨大。生产环境务必设为INFO级别。
  3. 减少消息体积:避免在messages中存二进制数据(如图片 base64)、大段 HTML。用metadata字段存 ID,实际数据从数据库查。
  4. 预热 LLM 连接池:首次调用llm.invoke()会有 TLS 握手开销。在应用启动时主动调用一次空请求:
    # 应用启动时 llm.invoke("ping") # 预热连接池

最后分享一个小技巧:我们给所有 Agent 节点加上@traceable装饰器(来自langsmith),在 LangSmith 平台里能直观看到每个节点的耗时、输入输出、错误堆栈。曾经发现search_repair_db工具里一个正则表达式.*导致回溯爆炸,耗时从 120ms 涨到 2.3 秒——这个细节,光看代码根本发现不了,必须靠可观测性工具。