LangChain核心架构:Chain、Agent、Tool三大组件剖析

引言

去年双十一前夕,我接手了一个客服工单自动处理系统的重构。原系统用硬编码的 if-else 串联了十几个 LLM 调用:先分类工单、再查知识库、然后判断是否需要转人工、最后生成回复。上线三个月后,产品经理要求新增"退款流程自动审批"能力,我打开代码一看——process_ticket() 函数已经膨胀到 800 多行,里面嵌套了 6 层 if-else,每次新增一个业务分支,都要在函数中间小心翼翼地插入代码,稍有不慎就会踩到前面的分支。

这不是个例。几乎所有从"调 API 写 Demo"走向"构建生产系统"的团队,都会撞上同一堵墙:LLM 应用的本质是一次次"决策 + 执行"的循环,而 if-else 是最不适合表达循环和决策的抽象

LangChain 用三个组件试图解决这个问题:

  • Chain:把"固定的多步流程"抽象成可组合的管道
  • Tool:把"外部能力"抽象成 LLM 可调用的函数
  • Agent:把"决策循环"抽象成由 LLM 驱动的状态机

很多人把这三者当成"三个 API 类"来用,结果写出来的代码比 if-else 还乱。真正理解它们,需要看穿它们背后各自解决的是流程编排、能力边界、决策循环这三件本质不同的事。

本文不打算复述官方文档,而是带你从源码层面看清楚:Chain.invoke() 到底做了什么?AgentExecutor 的 while 循环里藏了哪些坑?Tool 的 schema 为什么能约束住"幻觉"?最后我会给出三个可直接运行的生产级示例,以及一份踩坑清单。


核心概念:从"餐厅"到"车间"

先用一个生活类比把三者的分工讲清楚。

Chain 像餐厅的固定套餐流程:客人点"A 套餐",后厨就按"前菜 → 主菜 → 甜点"的固定顺序出餐。每一步的输入是上一步的输出,顺序写死在菜单上。这种流程确定性高、延迟低,但客人没法说"今天主菜换成牛排"。

Tool 像后厨的一件专用设备:比如那台只能做意式浓缩的咖啡机。它有一个明确的接口(放咖啡豆、按按钮),有明确的输入输出(豆子进、浓缩出),而且它自己不会主动工作——必须有厨师去操作它。

Agent 像一位能看菜单、会指挥后厨的主厨:客人说"我想吃点清淡的,预算 100",主厨会自己判断:要不要先看看今天有什么食材?要不要问问客人忌口?然后决定调用哪台设备、按什么顺序做菜。主厨的决策是动态的,是"看一眼情况、做一个动作、再看一眼情况"的循环

用工程语言重新定义:

组件 本质 控制流归属 典型场景
Chain 有向无环图(DAG)的静态编排 开发者 固定的 RAG 流程、文档摘要、格式转换
Tool 带 schema 的外部函数封装 被调用方 搜索、数据库查询、API 调用、代码执行
Agent 由 LLM 驱动的 ReAct 状态机 LLM(运行时决定) 开放域问答、多步任务、需要动态规划的场景

关键区别在于控制流归属:Chain 的下一步是代码写死的,Agent 的下一步是 LLM 在运行时"想"出来的。这个区别决定了两者在可预测性、延迟、成本上的巨大差异。

下面这张图展示了三者在一个典型 Agent 执行流程中的协作关系:

graph TD U[用户输入] --> A[AgentExecutor] A --> P[构建 Prompt: 系统指令 + 历史 + 工具描述] P --> L[LLM 推理] L --> D{输出类型?} D -->|Final Answer| F[返回给用户] D -->|Action + ActionInput| T[解析工具名和参数] T --> V[Tool Schema 校验] V -->|校验通过| E[执行 Tool] V -->|校验失败| R[生成错误反馈] E --> O[Observation] R --> O O --> H[追加到中间步骤历史] H --> P A -.调用.-> C[Chain: 可选的预处理/后处理]

注意图中从 O 回到 P 的那条线——这就是 Agent 的"心脏",即 ReAct 循环。理解 Agent,本质上就是理解这个循环的每一次迭代在做什么、什么时候会停下来、什么时候会失控。


源码深度分析

Chain:为什么 invoke() 只是一个"模板方法"

打开 LangChain 的 libs/langchain/langchain/chains/base.py,你会看到 Chain 的核心实现:

class Chain(Serializable, Runnable[Dict[str, Any], Dict[str, Any]], ABC):
    @abstractmethod
    def _call(
        self,
        inputs: Dict[str, Any],
        run_manager: Optional[CallbackManagerForChainRun] = None,
    ) -> Dict[str, Any]:
        """执行链的逻辑,子类必须实现。"""
        raise NotImplementedError

    def invoke(
        self, input: Dict[str, Any], config: Optional[RunnableConfig] = None
    ) -> Dict[str, Any]:
        # 1. 参数校验
        self._validate_inputs(input)
        # 2. 准备回调管理器
        callback_manager = CallbackManager.configure(
            config.get("callbacks"), self.callbacks
        )
        run_manager = callback_manager.on_chain_start(
            {"name": self.__class__.__name__}, input
        )
        try:
            # 3. 真正的执行逻辑(子类实现)
            outputs = (
                self._call(inputs, run_manager=run_manager)
                if run_manager
                else self._call(inputs)
            )
        except BaseException as e:
            run_manager.on_chain_error(e)
            raise
        else:
            run_manager.on_chain_end(outputs)
            # 4. 输出校验
            final_outputs = self.prep_outputs(inputs, outputs, return_only_outputs=True)
            return final_outputs

这段代码是一个教科书级的模板方法模式invoke() 定义了执行骨架(校验 → 回调 → 执行 → 校验 → 返回),把真正的执行逻辑交给子类实现 _call()

这个设计带来两个重要特性:

第一,可观测性统一。所有 Chain(无论多复杂)的回调、日志、追踪都由基类统一处理。你不需要在每个子类里手动埋点。

第二,Runnable 协议的适配。注意 Chain 继承自 Runnable,这让它可以和 LCEL(LangChain Expression Language)的 | 管道运算符无缝配合。prompt | llm | parser 之所以能工作,是因为每一个组件都实现了 Runnable 接口,而 Chain 只是其中一种实现。

这里有个常被忽视的坑_validate_inputs 会检查 input_keys,如果输入字典缺了键,会在调用 LLM 之前就抛 ValueError。很多初学者在 LCEL 里混用 ChainRunnable 时,会因为输入输出键不匹配报错,根源就在这里——Chain 对输入输出有显式的键约束,而纯 Runnable 没有。

再往深看一层,SequentialChain 的实现揭示了"管道"的本质:

class SequentialChain(Chain):
    def _call(self, inputs, run_manager=None):
        known_values = inputs.copy()
        for i, chain in enumerate(self.chains):
            outputs = chain(known_values, return_only_outputs=True)
            known_values.update(outputs)
            # 检查是否有变量冲突
            if set(outputs) & set(self.input_variables):
                raise ValueError(f"变量名冲突: {set(outputs) & set(self.input_variables)}")
        return {k: known_values[k] for k in self.output_variables}

它就是一个循环:把上一步的输出 merge 进上下文,喂给下一步。没有魔法,就是一个 for 循环加字典合并。这也解释了为什么 SequentialChain 不支持条件分支和并行——它的骨架就是一个线性循环。

Tool:schema 是约束 LLM 的"合同"

Tool 的核心不是"能执行函数",而是用结构化 schema 把"LLM 的自由文本输出"翻译成"可信的函数调用"

class BaseTool(RunnableSerializable[Union[str, Dict], Any]):
    name: str
    description: str
    args_schema: Optional[Type[BaseModel]] = None

    def invoke(self, input, config=None):
        # 如果定义了 Pydantic schema,先做参数校验
        if self.args_schema is not None:
            if isinstance(input, dict):
                input = self.args_schema(**input)  # 校验并转换
        parsed_input = self._parse_input(input)
        return self._run(*parsed_input, ...)

    @property
    def args(self) -> dict:
        """生成工具描述,喂给 LLM。"""
        if self.args_schema is not None:
            return self.args_schema.schema()["properties"]
        return {}

关键在于 args_schema。当你定义一个 Tool 时:

class SearchInput(BaseModel):
    query: str = Field(description="搜索关键词")
    top_k: int = Field(default=3, description="返回结果数量")

search_tool = StructuredTool.from_function(
    func=web_search,
    name="web_search",
    description="搜索互联网获取最新信息,适用于时效性问题",
    args_schema=SearchInput,
)

args_schema.schema() 会被序列化成 JSON Schema,注入到给 LLM 的 Prompt 里。LLM 看到的不再是"有个叫 web_search 的函数",而是:

{
  "query": {"type": "string", "description": "搜索关键词"},
  "top_k": {"type": "integer", "default": 3, "description": "返回结果数量"}
}

这就是"合同":LLM 必须按这个格式输出 Action Input,Tool 在 invoke 时再用 Pydantic 校验一遍。如果 LLM 输出了 top_k: "three",Pydantic 会抛 ValidationError,Agent 会把这个错误作为 Observation 反馈给 LLM,让它重新生成。

这个"生成 → 校验 → 反馈"的闭环,是 LangChain 抑制幻觉的核心机制。它不保证 LLM 不出错,但保证错误会被捕获并反馈,而不是静默地传入你的业务函数

Agent:ReAct 循环的每一次迭代

打开 libs/langchain/langchain/agents/agent.pyAgentExecutor._take_next_step 是整个 Agent 的核心:

def _take_next_step(self, name_to_tool_map, color_mapping, inputs, 
                    intermediate_steps, run_manager):
    # 1. 把中间步骤格式化成 LLM 能理解的文本
    intermediate_steps = self._prepare_intermediate_steps(intermediate_steps)
    
    # 2. 调用 Agent.plan(),让 LLM 决定下一步
    output = self.agent.plan(
        intermediate_steps,
        callbacks=run_manager.get_child() if run_manager else None,
        **inputs,
    )
    
    # 3. 如果 LLM 输出的是 AgentFinish,结束循环
    if isinstance(output, AgentFinish):
        return output
    
    # 4. 否则是 AgentAction,解析出工具名和参数
    tool = name_to_tool_map.get(output.tool)
    if tool is None:
        # 工具不存在,生成错误 Observation
        observation = f"工具 {output.tool} 不存在,可用工具: {list(name_to_tool_map.keys())}"
        return AgentStep(action=output, observation=observation)
    
    # 5. 执行工具
    try:
        observation = tool.run(output.tool_input, ...)
    except Exception as e:
        observation = f"工具执行出错: {e}"
    
    return AgentStep(action=output, observation=observation)

而外层的 _call 是一个 while 循环:

def _call(self, inputs, run_manager=None):
    intermediate_steps = []
    iterations = 0
    while iterations < self.max_iterations:
        iterations += 1
        next_step_output = self._take_next_step(...)
        
        if isinstance(next_step_output, AgentFinish):
            return next_step_output.return_values
        
        intermediate_steps.extend(next_step_output)
        
        # 检查是否达到早停条件
        if len(next_step_output) == 1:
            step = next_step_output[0]
            if self.early_stopping_method == "force":
                return self.agent.return_stopped_response(...)
    
    return self.agent.return_stopped_response(...)

看清楚了吗?Agent 的本质就是一个带最大迭代次数的 while 循环。每次循环:

  1. 把"历史步骤"拼进 Prompt
  2. 让 LLM 输出"下一步做什么"
  3. 如果是 AgentFinish,退出
  4. 如果是 AgentAction,执行工具,把结果 append 到历史
  5. 回到第 1 步

这里有几个生产环境的致命细节

细节一:max_iterations 是硬性护栏。默认值是 15(不同版本有差异)。如果 LLM 陷入"调用搜索 → 结果不满意 → 再搜索"的死循环,15 次之后会强制返回 return_stopped_response但此时你可能已经烧掉了几十美分的 token。生产环境建议设为 5-8。

细节二:intermediate_steps 会线性增长。每一步的 Action 和 Observation 都拼进 Prompt,第 10 步的 Prompt 长度可能是第 1 步的 10 倍。这是 Agent 成本随步数超线性增长的根源。缓解方案见后文"最佳实践"。

细节三:工具异常不会中断循环。看第 5 步的 except——工具抛异常会被捕获成 Observation 字符串。这看起来是优点(容错),但会带来"错误被 LLM 忽略、反复调用同一个坏工具"的问题。需要在 Observation 里明确标注 [ERROR] 并引导 LLM 换策略。


实战代码

下面三个示例,从简单到复杂,覆盖 Chain、Tool、Agent 的核心用法。

示例一:用 LCEL 构建一个可观测的 RAG Chain

"""
示例一:LCEL 构建 RAG Chain
展示 Runnable 协议、流式输出、以及回调追踪
"""
import os
from operator import itemgetter

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import FAISS

# ---------- 1. 准备检索器 ----------
# 假设你已有文档列表 docs
docs = [
    "LangChain 的 Chain 用于编排固定流程。",
    "Agent 由 LLM 驱动决策,适合动态任务。",
    "Tool 需要定义 args_schema 来约束 LLM 输出。",
]
vectorstore = FAISS.from_texts(docs, OpenAIEmbeddings())
retriever = vectorstore.as_retriever(search_kwargs={"k": 2})

# ---------- 2. 定义 Prompt ----------
prompt = ChatPromptTemplate.from_messages([
    ("system", "你是技术助手。基于以下上下文回答问题,不知道就说不知道。\n\n上下文:\n{context}"),
    ("human", "{question}"),
])

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

# ---------- 3. 用 LCEL 组装 Chain ----------
# 注意:LCEL 的 | 运算符本质是 RunnableSequence 的语法糖
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

rag_chain = (
    # 并行执行:一路检索,一路透传问题
    {
        "context": retriever | RunnableLambda(format_docs),
        "question": RunnablePassthrough(),
    }
    | prompt
    | llm
    | StrOutputParser()
)

# ---------- 4. 调用并追踪 ----------
if __name__ == "__main__":
    # 普通调用
    answer = rag_chain.invoke("Agent 和 Chain 有什么区别?")
    print(f"[普通调用] {answer}\n")

    # 流式调用 —— LCEL 的杀手锏,所有组件自动支持
    print("[流式输出] ", end="")
    for chunk in rag_chain.stream("Tool 的 schema 有什么用?"):
        print(chunk, end="", flush=True)
    print()

    # 带回调的调用 —— 可观测性
    from langchain_core.callbacks import BaseCallbackHandler

    class LogHandler(BaseCallbackHandler):
        def on_chain_start(self, serialized, inputs, **kwargs):
            print(f"  >> 进入 Chain: {serialized.get('name', 'unknown')}")
        def on_retriever_end(self, documents, **kwargs):
            print(f"  >> 检索到 {len(documents)} 篇文档")

    rag_chain.invoke("什么是 ReAct?", config={"callbacks": [LogHandler()]})

关键点:LCEL 的 | 不是普通的管道,它是 RunnableSequence。每个 Runnable 都实现了 invoke/stream/batch 三件套,所以整个 Chain 自动获得流式和批量能力——这是 Chain 基类做不到的。新项目应优先用 LCEL,Chain 子类主要用于兼容旧代码。

示例二:定义带校验的 Tool 并处理幻觉

"""
示例二:定义生产级 Tool
重点:args_schema 约束、错误处理、返回值格式化
"""
from typing import Optional
from pydantic import BaseModel, Field, field_validator
from langchain_core.tools import StructuredTool
import json

# ---------- 1. 用 Pydantic 定义严格的参数 schema ----------
class WeatherInput(BaseModel):
    """查询天气的参数。"""
    city: str = Field(description="城市名称,如 '北京'、'Shanghai'")
    date: Optional[str] = Field(
        default=None,
        description="日期,格式 YYYY-MM-DD。不填则查今天",
    )

    @field_validator("date")
    @classmethod
    def validate_date(cls, v):
        if v is None:
            return v
        import re
        if not re.match(r"^\d{4}-\d{2}-\d{2}$", v):
            raise ValueError(f"日期格式错误: {v},应为 YYYY-MM-DD")
        return v

# ---------- 2. 实现工具函数,注意异常处理 ----------
def query_weather(city: str, date: Optional[str] = None) -> str:
    """
    真实场景这里会调用天气 API。
    关键原则:永远不要在工具里抛未捕获异常,返回结构化错误字符串。
    """
    try:
        # 模拟 API 调用
        if city not in ["北京", "上海", "广州", "深圳"]:
            return json.dumps(
                {"status": "error", "message": f"暂不支持城市 {city},支持: 北京/上海/广州/深圳"},
                ensure_ascii=False,
            )
        # 模拟返回
        return json.dumps(
            {"status": "ok", "city": city, "date": date or "today", "temp": "22°C", "weather": "晴"},
            ensure_ascii=False,
        )
    except Exception as e:
        return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)

# ---------- 3. 封装成 Tool ----------
weather_tool = StructuredTool.from_function(
    func=query_weather,
    name="query_weather",
    description=(
        "查询指定城市的天气。适用于用户询问天气、温度、是否下雨等场景。"
        "支持城市: 北京、上海、广州、深圳。"
    ),
    args_schema=WeatherInput,
    return_direct=False,  # 结果是否直接返回用户(不经过 LLM 再加工)
)

if __name__ == "__main__":
    # 正常调用
    print(weather_tool.invoke({"city": "北京", "date": "2026-09-10"}))

    # 触发 Pydantic 校验错误 —— 模拟 LLM 幻觉
    try:
        weather_tool.invoke({"city": "北京", "date": "明天"})
    except Exception as e:
        print(f"[校验失败] {type(e).__name__}: {e}")

    # 查看喂给 LLM 的 schema
    print(f"\n[LLM 看到的 schema]\n{json.dumps(weather_tool.args, ensure_ascii=False, indent=2)}")

关键点

  1. args_schema 是强约束。LLM 输出 date: "明天" 会被 Pydantic 直接拒绝,错误信息反馈给 LLM 让它重试。
  2. 工具内部不抛异常,返回结构化错误。这样 Agent 能把错误当作 Observation 继续推理,而不是整个流程崩溃。
  3. description 是给 LLM 看的"使用说明",要写清楚适用场景和边界。很多 Agent 调错工具,根源是 description 写得太含糊。

示例三:带护栏的生产级 Agent

"""
示例三:生产级 Agent
重点:max_iterations、超时、中间步骤裁剪、成本控制
"""
import time
from typing import List, Tuple

from langchain.agents import AgentExecutor, create_openai_tools_agent
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.messages import SystemMessage
from langchain_openai import ChatOpenAI

from example2_weather_tool import weather_tool  # 复用示例二的工具

# ---------- 1. 定义一个简单计算工具 ----------
from langchain_core.tools import tool

@tool
def calculator(expression: str) -> str:
    """计算数学表达式,如 '2 + 3 * 4'。只支持四则运算。"""
    import re
    # 安全校验:只允许数字和运算符
    if not re.match(r"^[\d\s\+\-\*/\(\)\.]+$", expression):
        return "[ERROR] 表达式包含非法字符,仅支持数字和 + - * / ( )"
    try:
        result = eval(expression, {"__builtins__": {}}, {})
        return str(result)
    except Exception as e:
        return f"[ERROR] 计算失败: {e}"

tools = [weather_tool, calculator]

# ---------- 2. 构建 Prompt ----------
prompt = ChatPromptTemplate.from_messages([
    SystemMessage(content=(
        "你是一个严谨的助手。规则:\n"
        "1. 只在必要时调用工具,能直接回答的直接回答。\n"
        "2. 工具返回 [ERROR] 时,不要重复相同调用,换策略或告知用户。\n"
        "3. 最多调用 5 次工具,超出则总结已有信息回答。"
    )),
    MessagesPlaceholder("chat_history", optional=True),
    ("human", "{input}"),
    MessagesPlaceholder("agent_scratchpad"),
])

# ---------- 3. 组装 Agent ----------
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
agent = create_openai_tools_agent(llm, tools, prompt)

class GuardedAgentExecutor(AgentExecutor):
    """在 AgentExecutor 基础上加超时和成本护栏。"""
    
    max_execution_time: float = 30.0  # 秒
    
    def _call(self, inputs, run_manager=None):
        start = time.time()
        result = super()._call(inputs, run_manager)
        elapsed = time.time() - start
        if elapsed > self.max_execution_time:
            result["warning"] = f"执行超时 {elapsed:.1f}s"
        return result

executor = GuardedAgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True,
    max_iterations=5,              # 硬性迭代上限
    max_execution_time=30,          # 超时保护
    early_stopping_method="force",  # 达到上限时强制返回
    handle_parsing_errors=True,     # LLM 输出格式错误时自动重试
    return_intermediate_steps=True, # 返回中间步骤便于调试
)

if __name__ == "__main__":
    # 测试 1:需要多步推理
    result = executor.invoke({
        "input": "北京今天天气怎么样?如果温度高于 20 度,告诉我 25 * 4 是多少。"
    })
    print(f"\n[回答] {result['output']}")
    print(f"[步数] {len(result['intermediate_steps'])}")
    for action, obs in result["intermediate_steps"]:
        print(f"  - 调用 {action.tool}({action.tool_input}) -> {obs[:80]}")

    # 测试 2:触发护栏
    result = executor.invoke({
        "input": "查一下火星的天气,然后计算 1/0。"
    })
    print(f"\n[护栏测试] {result['output']}")

关键点

  1. max_iterations=5 是成本护栏。默认 15 太宽松,生产环境 5-8 足够覆盖绝大多数任务。
  2. handle_parsing_errors=True 让 LLM 输出格式错误时自动重试,而不是直接崩溃。
  3. return_intermediate_steps=True 是调试利器,能看清 Agent 每一步的决策。
  4. 自定义 Executor 加超时AgentExecutor 本身没有超时机制,长任务可能挂死。子类化是最简单的加护栏方式。

方案对比:LangChain vs 其他流派

维度 LangChain Chain LangChain Agent LangGraph LlamaIndex 裸调 API
控制流 静态 DAG LLM 动态 显式状态机 静态 + 部分动态 完全手写
可预测性 中高 取决于实现
调试难度
循环/分支 不支持 隐式支持 原生支持 有限 手写
状态持久化 强(checkpoint) 手写
适合场景 固定流程 开放任务 复杂工作流 RAG 为主 极简需求

核心洞察

  • Chain 适合"流程已知":RAG、文档摘要、格式转换。用 LCEL 写,性能好、可观测、易测试。
  • Agent 适合"流程未知":需要 LLM 现场规划的任务。但要接受它的不确定性,并用护栏兜底。
  • LangGraph 是 Agent 的进化形态:当你的 Agent 需要"人机协作""状态持久化""多 Agent 协作"时,直接用 LangGraph,它是显式状态机,比 AgentExecutor 的隐式循环可控得多。
  • LlamaIndex 在 RAG 上更专精:检索策略、索引结构比 LangChain 丰富。但编排能力不如 LangChain。
  • 裸调 API 不是落后:如果你的流程只有 2-3 步且永远不变,直接调 API 反而更简单。框架的价值在于抽象能覆盖复杂度,而不是为了用而用

一个实用的决策树:

graph TD A[需要 LLM 应用] --> B{流程是否固定?} B -->|是| C{步骤数 > 3?} B -->|否| D{需要持久化状态?} C -->|否| E[裸调 API] C -->|是| F[LCEL Chain] D -->|是| G[LangGraph] D -->|否| H[AgentExecutor] F --> I{需要动态决策?} I -->|是| H I -->|否| J[保持 Chain]

最佳实践与避坑指南

坑 1:把 Agent 当 Chain 用

症状:用 Agent 处理"永远按固定顺序执行"的任务,结果每次调用 LLM 决定下一步,延迟翻倍、成本翻倍、还偶尔走错路。

药方:先问自己"下一步能不能写死"。能写死就用 Chain。Agent 的价值在于处理你无法预判的分支

坑 2:Tool 的 description 写得太随意

症状:Agent 频繁调错工具,或者在不需要工具时也调用。

药方:description 要写清楚三件事——做什么、什么时候用、什么时候不用。例如:

# 差
description = "搜索"

# 好
description = (
    "搜索互联网获取实时信息。"
    "适用于:新闻、股价、天气等时效性问题。"
    "不适用于:常识问答、数学计算(用 calculator 工具)。"
)

坑 3:忽略 intermediate_steps 的 token 膨胀

症状:Agent 跑到第 8 步时,单次 Prompt 已经 8000+ token,成本飙升。

药方:自定义 _prepare_intermediate_steps,只保留最近 N 步的完整内容,更早的步骤只保留 Action 摘要:

def truncate_steps(steps, keep_recent=3):
    if len(steps) <= keep_recent:
        return steps
    old = steps[:-keep_recent]
    recent = steps[-keep_recent:]
    summary = f"[已完成 {len(old)} 步,摘要: " + \
              ", ".join(f"{s[0].tool}({s[0].tool_input})" for s in old) + "]"
    return [("summary", summary)] + recent

坑 4:工具里抛未捕获异常

症状:Agent 运行到一半突然崩溃,用户看到 500 错误。

药方:工具内部永远返回结构化错误字符串,让 LLM 有机会自我修正。参考示例二的写法。

坑 5:没有设置 max_iterations

症状:LLM 陷入死循环,疯狂调用工具,账单爆炸。

药方max_iterations=5~8,加上 max_execution_time 超时。成本失控是 Agent 最常见的生产事故

坑 6:Prompt 里没写"何时停止"

症状:Agent 明明已经拿到答案,还继续调用工具"确认"。

药方:系统 Prompt 里明确写"信息足够时直接回答,不要重复调用工具"。同时设置 early_stopping_method="force"

最佳实践清单

  1. 优先 LCEL,其次 Chain 子类,最后才用 Agent。能用静态流程解决的,绝不引入动态决策。
  2. 所有 Tool 必须有 args_schema。没有 schema 的 Tool 等于把幻觉直接放进你的业务函数。
  3. Agent 必须有护栏三件套max_iterations + max_execution_time + handle_parsing_errors
  4. return_intermediate_steps=True 调试,上线后可改为 False 省 token。
  5. 复杂工作流用 LangGraph。当你的 Agent 需要状态持久化、人机协作、多 Agent 时,AgentExecutor 的隐式循环会成为负担。
  6. 给工具加幂等性。Agent 可能重复调用同一个工具,写操作类工具要做好幂等或去重。
  7. 监控 token 消耗。用 LangSmith 或自建回调,记录每次 Agent 调用的步数和 token,设置告警阈值。

总结

回到开头那个客服系统的故事。重构后,我把系统拆成了三层:

  • Chain 层:工单分类、知识库检索、回复生成,用 LCEL 编排,稳定可测。
  • Tool 层:退款审批、订单查询、用户画像,每个都有严格的 Pydantic schema。
  • Agent 层:只负责"这个工单该走哪条流程"的决策,max_iterations=5,超时 20 秒。

结果是:延迟从平均 8 秒降到 3 秒,成本降低 60%,新增业务分支从"改 800 行函数"变成"加一个 Tool"。

三个组件,三种抽象,对应三类问题:

  • Chain 解决"流程编排"——把已知的步骤串起来,是工程问题。
  • Tool 解决"能力边界"——用 schema 定义 LLM 能做什么、不能做什么,是接口问题。
  • Agent 解决"动态决策"——让 LLM 在运行时规划,是控制论问题。

理解它们的本质区别,比记住 API 更重要。因为框架会迭代,API 会改名,但"静态流程用 Chain、外部能力用 Tool、动态决策用 Agent"这个判断,五年后依然成立。

延伸思考:当 LLM 的推理能力持续增强,Agent 的"决策循环"会不会逐渐取代 Chain 的"固定编排"?我的判断是——不会完全取代,但边界会移动。就像微服务没有完全取代单体,而是让"该拆的拆、该合的合"成为工程判断。未来的主流形态,可能是"LangGraph 式的混合架构":主干流程是显式状态机(可控),局部节点用 Agent(灵活)。掌握今天这三个组件,正是理解那个未来的起点。