LangChain设计与实现-第15章-工具调用与Agent模式

第15章 工具调用与 Agent 模式

本书章节导航


开篇引言

上一章我们剖析了 Agent 的底层架构 -- 数据模型、基类接口和 AgentExecutor 执行循环。那是 Agent 系统的"引擎"。本章我们将目光转向"车身":LangChain 提供的各种具体 Agent 实现。

LangChain 中的 Agent 构建函数遵循一个统一的模式:接收 LLM、工具列表和提示模板,返回一个 Runnable。这个 Runnable 可以直接传给 AgentExecutor。不同的构建函数体现了不同的 Agent 范式:有的利用模型原生的工具调用能力(Tool Calling Agent),有的通过文本提示实现推理-行动循环(ReAct Agent),有的使用 JSON 格式指定工具参数(Structured Chat Agent),还有的用 XML 标签来组织交互(XML Agent)。

这些 Agent 的内部结构惊人地相似 -- 都是由四个阶段组成的 LCEL 管道。理解了一个,就理解了全部。本章将逐一拆解每种 Agent 的实现,分析它们的设计取舍,并在最后进行全面对比。

:::tip 本章要点

  • create_tool_calling_agent 如何利用模型原生 tool_calls 能力
  • create_react_agent 实现的经典 ReAct 推理-行动范式
  • create_openai_tools_agent 与 Tool Calling Agent 的关系和区别
  • create_structured_chat_agent 和 create_xml_agent 的文本解析方案
  • ToolsAgentOutputParser 如何解析 AIMessage 中的 tool_calls
  • 各种 Agent 格式化中间步骤(format_scratchpad)的不同策略
  • Agent 类型全面对比与选型指南 :::

15.1 Agent 管道的统一结构

在深入具体实现之前,先看一个关键洞察:所有 LangChain Agent 构建函数返回的都是一个四阶段 LCEL 管道。

flowchart LR A["RunnablePassthrough.assign
格式化 agent_scratchpad"] --> B["Prompt
填充变量生成提示"] B --> C["LLM / LLM.bind_tools
调用大语言模型"] C --> D["OutputParser
解析输出为
AgentAction/AgentFinish"] style A fill:#e3f2fd style B fill:#f3e5f5 style C fill:#e8f5e9 style D fill:#fff3e0

四个阶段分别负责:

  1. 格式化 :将 intermediate_steps(之前的执行历史)转换为 LLM 可理解的格式
  2. 提示:将格式化后的历史与用户输入组合成完整的提示
  3. 推理:调用 LLM 生成响应
  4. 解析 :将 LLM 响应解析为 AgentActionAgentFinish

不同 Agent 类型的差异仅在于:每个阶段的具体实现不同。格式化方式、提示模板、LLM 绑定方式、输出解析器,这四个维度的组合定义了不同的 Agent 范式。

15.2 Tool Calling Agent:模型原生工具调用

create_tool_calling_agent 是目前推荐的主要 Agent 构建方式。它利用现代 LLM(如 GPT-4、Claude、Gemini)原生的工具调用能力,不依赖文本解析。

15.2.1 构建函数

python 复制代码
# langchain_classic/agents/tool_calling_agent/base.py

def create_tool_calling_agent(
    llm: BaseLanguageModel,
    tools: Sequence[BaseTool],
    prompt: ChatPromptTemplate,
    *,
    message_formatter: MessageFormatter = format_to_tool_messages,
) -> Runnable:
    # 验证提示模板包含 agent_scratchpad
    missing_vars = {"agent_scratchpad"}.difference(
        prompt.input_variables + list(prompt.partial_variables),
    )
    if missing_vars:
        raise ValueError(f"Prompt missing required variables: {missing_vars}")

    # 验证 LLM 支持工具绑定
    if not hasattr(llm, "bind_tools"):
        raise ValueError(
            "This function requires a bind_tools() method "
            "be implemented on the LLM."
        )

    # 将工具 schema 绑定到 LLM
    llm_with_tools = llm.bind_tools(tools)

    # 构建四阶段管道
    return (
        RunnablePassthrough.assign(
            agent_scratchpad=lambda x: message_formatter(
                x["intermediate_steps"]
            ),
        )
        | prompt
        | llm_with_tools
        | ToolsAgentOutputParser()
    )

这个函数做了三件事:验证输入、绑定工具、组装管道。整个函数体不到二十行代码,却完成了一个功能完备的 Agent 的构建。这种简洁性来源于 LCEL 的组合能力和 Runnable 协议的统一接口。

其中 llm.bind_tools(tools) 是关键 -- 它将工具的 JSON Schema 描述附加到 LLM 的每次调用中,让模型知道可以使用哪些工具。绑定后的 llm_with_tools 在类型上仍然是一个 Runnable,可以自然地参与管道组合。

验证逻辑也值得注意。函数首先检查提示模板是否包含 agent_scratchpad 变量。这个变量名不是随意选择的 -- 它是所有 Agent 类型共享的约定,用于插入格式化后的中间步骤。然后检查 LLM 是否实现了 bind_tools 方法,如果没有则给出明确的错误消息。这种"提前失败"的策略避免了在运行时才发现兼容性问题,大大改善了开发体验。

15.2.2 format_to_tool_messages -- 格式化中间步骤

Tool Calling Agent 使用消息格式来表示中间步骤。format_to_tool_messages(AgentAction, observation) 元组转换为 ToolMessage 对象:

python 复制代码
# langchain_classic/agents/format_scratchpad/tools.py

def format_to_tool_messages(
    intermediate_steps: Sequence[tuple[AgentAction, str]],
) -> list[BaseMessage]:
    messages = []
    for agent_action, observation in intermediate_steps:
        if isinstance(agent_action, ToolAgentAction):
            new_messages = [
                *list(agent_action.message_log),  # 原始 AI 消息
                _create_tool_message(agent_action, observation),
            ]
            messages.extend(
                [new for new in new_messages if new not in messages]
            )
        else:
            messages.append(AIMessage(content=agent_action.log))
    return messages

核心逻辑是:对于每个 ToolAgentAction(包含 tool_call_id 的动作),同时包含原始的 AI 消息(包含 tool_calls)和对应的 ToolMessage 响应。去重逻辑 if new not in messages 确保当一次 AI 调用产生多个工具调用时,AI 消息只出现一次。

这个格式化函数的设计处理了一个微妙但重要的问题:消息的顺序和去重。考虑一个并行工具调用的场景 -- 模型在一条 AI 消息中同时调用了搜索工具和计算工具。执行后会产生两个 intermediate_steps,但它们共享同一条原始 AI 消息。格式化时需要确保 AI 消息只出现一次,后面紧跟两条 ToolMessage。如果 AI 消息被重复包含,模型可能会误解上下文。去重逻辑正是为了解决这个问题。

对于非 ToolAgentAction 类型的动作(来自旧式 Agent 的文本输出),格式化函数回退到简单地创建一个 AIMessage,其内容为动作的日志文本。这种兼容性处理确保了新旧两种 Agent 类型可以共存于同一个系统中。

_create_tool_message 则负责将观察结果包装为 ToolMessage:

python 复制代码
def _create_tool_message(agent_action: ToolAgentAction, observation: Any):
    if not isinstance(observation, str):
        try:
            content = json.dumps(observation, ensure_ascii=False)
        except TypeError:
            content = str(observation)
    else:
        content = observation
    return ToolMessage(
        tool_call_id=agent_action.tool_call_id,
        content=content,
        additional_kwargs={"name": agent_action.tool},
    )

15.2.3 ToolsAgentOutputParser -- 解析工具调用

ToolsAgentOutputParser 是 Tool Calling Agent 的"大脑翻译器",它将 AI 消息解析为 Agent 动作:

python 复制代码
# langchain_classic/agents/output_parsers/tools.py

class ToolAgentAction(AgentActionMessageLog):
    """扩展了 tool_call_id 字段"""
    tool_call_id: str | None

def parse_ai_message_to_tool_action(
    message: BaseMessage,
) -> list[AgentAction] | AgentFinish:
    if not isinstance(message, AIMessage):
        raise TypeError(f"Expected an AI message got {type(message)}")

    actions: list = []
    if message.tool_calls:
        tool_calls = message.tool_calls
    else:
        if not message.additional_kwargs.get("tool_calls"):
            # 没有工具调用 -> 视为最终答案
            return AgentFinish(
                return_values={"output": message.content},
                log=str(message.content),
            )
        # 回退:从 additional_kwargs 解析
        tool_calls = []
        for tool_call in message.additional_kwargs["tool_calls"]:
            function = tool_call["function"]
            args = json.loads(function["arguments"] or "{}")
            tool_calls.append(ToolCall(
                type="tool_call",
                name=function["name"],
                args=args,
                id=tool_call["id"],
            ))

    for tool_call in tool_calls:
        function_name = tool_call["name"]
        _tool_input = tool_call["args"]
        # 处理 __arg1 兼容旧式工具
        tool_input = _tool_input.get("__arg1", _tool_input)

        log = f"\nInvoking: `{function_name}` with `{tool_input}`\n"
        actions.append(ToolAgentAction(
            tool=function_name,
            tool_input=tool_input,
            log=log,
            message_log=[message],
            tool_call_id=tool_call["id"],
        ))
    return actions

解析逻辑有三个分支:

  1. message.tool_calls 存在:优先使用结构化的 tool_calls
  2. additional_kwargs 中有 tool_calls:回退到旧格式
  3. 都没有:视为最终答案,返回 AgentFinish
flowchart TD A["AIMessage 从 LLM 返回"] --> B{message.tool_calls
非空?} B -->|是| C["使用结构化 tool_calls"] B -->|否| D{"additional_kwargs
有 tool_calls?"} D -->|是| E["从 JSON 解析 tool_calls"] D -->|否| F["返回 AgentFinish
(message.content 作为最终答案)"] C --> G["遍历 tool_calls"] E --> G G --> H["创建 ToolAgentAction 列表
(包含 tool_call_id)"] H --> I["返回 list[AgentAction]"]

15.2.4 使用示例

python 复制代码
from langchain_classic.agents import AgentExecutor, create_tool_calling_agent
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a helpful assistant"),
    ("placeholder", "{chat_history}"),
    ("human", "{input}"),
    ("placeholder", "{agent_scratchpad}"),
])

model = ChatOpenAI(model="gpt-4")
tools = [my_search_tool, my_calculator_tool]

agent = create_tool_calling_agent(model, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)

result = agent_executor.invoke({"input": "3 + 5 等于多少?"})

15.3 ReAct Agent:经典推理-行动范式

create_react_agent 实现了经典的 ReAct(Reasoning + Acting)范式。与 Tool Calling Agent 不同,它不依赖模型的原生工具调用能力,而是通过文本提示和停止词来实现推理-行动循环。

15.3.1 构建函数

python 复制代码
# langchain_classic/agents/react/agent.py

def create_react_agent(
    llm: BaseLanguageModel,
    tools: Sequence[BaseTool],
    prompt: BasePromptTemplate,
    output_parser: AgentOutputParser | None = None,
    tools_renderer: ToolsRenderer = render_text_description,
    *,
    stop_sequence: bool | list[str] = True,
) -> Runnable:
    # 验证必需变量
    missing_vars = {"tools", "tool_names", "agent_scratchpad"}.difference(
        prompt.input_variables + list(prompt.partial_variables),
    )
    if missing_vars:
        raise ValueError(f"Prompt missing required variables: {missing_vars}")

    # 将工具描述填入提示模板
    prompt = prompt.partial(
        tools=tools_renderer(list(tools)),
        tool_names=", ".join([t.name for t in tools]),
    )

    # 配置停止词
    if stop_sequence:
        stop = ["\nObservation"] if stop_sequence is True else stop_sequence
        llm_with_stop = llm.bind(stop=stop)
    else:
        llm_with_stop = llm

    output_parser = output_parser or ReActSingleInputOutputParser()

    return (
        RunnablePassthrough.assign(
            agent_scratchpad=lambda x: format_log_to_str(
                x["intermediate_steps"]
            ),
        )
        | prompt
        | llm_with_stop
        | output_parser
    )

15.3.2 与 Tool Calling Agent 的关键差异

ReAct Agent 在每个阶段都与 Tool Calling Agent 不同:

格式化 :使用 format_log_to_str 将中间步骤转换为文本字符串,而非消息对象:

python 复制代码
# langchain_classic/agents/format_scratchpad/log.py

def format_log_to_str(
    intermediate_steps: list[tuple[AgentAction, str]],
    observation_prefix: str = "Observation: ",
    llm_prefix: str = "Thought: ",
) -> str:
    """将 Agent 步骤格式化为文本"""
    log = ""
    for action, observation in intermediate_steps:
        log += action.log
        log += f"\n{observation_prefix}{observation}\n{llm_prefix}"
    return log

提示 :需要三个变量 -- tools(工具描述)、tool_names(工具名列表)、agent_scratchpad(历史步骤文本)。典型的 ReAct 提示格式如下:

vbnet 复制代码
Answer the following questions. You have access to these tools:
{tools}

Use the following format:
Question: the input question
Thought: think about what to do
Action: the action, should be one of [{tool_names}]
Action Input: the input to the action
Observation: the result of the action
... (repeat N times)
Thought: I now know the final answer
Final Answer: the final answer

Question: {input}
Thought:{agent_scratchpad}

推理 :使用 llm.bind(stop=["\nObservation"]) 添加停止词。当 LLM 生成到 "Observation" 时自动停止,控制权交回执行器去实际调用工具。

解析 :使用 ReActSingleInputOutputParser,通过正则表达式从文本中提取 Action 和 Action Input。

15.3.3 停止词的关键作用

停止词 "\nObservation" 是 ReAct 模式的精髓。LLM 生成类似这样的文本:

vbnet 复制代码
Thought: I need to search for the weather
Action: search
Action Input: weather in Beijing

到此停止。执行器解析出 Action 和 Action Input,执行工具,将结果作为 Observation 拼接回去:

vbnet 复制代码
Thought: I need to search for the weather
Action: search
Action Input: weather in Beijing
Observation: Beijing is currently 25°C and sunny
Thought:

然后再次调用 LLM,它看到了完整的上下文,继续推理。

sequenceDiagram participant AE as AgentExecutor participant LLM as LLM (with stop) participant Parser as ReActOutputParser participant Tool as 工具 AE->>LLM: prompt + "Thought:" LLM-->>AE: "I need to search...\nAction: search\nAction Input: weather" Note right of LLM: 在 "\nObservation" 处停止 AE->>Parser: parse("I need to search...") Parser-->>AE: AgentAction(tool="search", tool_input="weather") AE->>Tool: run("weather") Tool-->>AE: "25°C and sunny" AE->>LLM: prompt + 之前文本 + "\nObservation: 25°C and sunny\nThought:" LLM-->>AE: "I now know the final answer\nFinal Answer: 北京25度晴天" AE->>Parser: parse("I now know...") Parser-->>AE: AgentFinish(return_values={"output": "北京25度晴天"})

15.4 OpenAI Tools Agent:过渡方案

create_openai_tools_agent 是一个针对 OpenAI 工具格式的 Agent,使用 convert_to_openai_tool 手动将工具转换为 OpenAI 格式:

python 复制代码
# langchain_classic/agents/openai_tools/base.py

def create_openai_tools_agent(
    llm: BaseLanguageModel,
    tools: Sequence[BaseTool],
    prompt: ChatPromptTemplate,
    strict: bool | None = None,
) -> Runnable:
    missing_vars = {"agent_scratchpad"}.difference(
        prompt.input_variables + list(prompt.partial_variables),
    )
    if missing_vars:
        raise ValueError(f"Prompt missing required variables: {missing_vars}")

    llm_with_tools = llm.bind(
        tools=[convert_to_openai_tool(tool, strict=strict) for tool in tools],
    )

    return (
        RunnablePassthrough.assign(
            agent_scratchpad=lambda x: format_to_openai_tool_messages(
                x["intermediate_steps"],
            ),
        )
        | prompt
        | llm_with_tools
        | OpenAIToolsAgentOutputParser()
    )

与 Tool Calling Agent 的核心差异在于:

  • 使用 llm.bind(tools=...) 手动传入 OpenAI 格式的工具描述,而非 llm.bind_tools(tools)
  • 使用 format_to_openai_tool_messages 格式化中间步骤
  • 使用 OpenAIToolsAgentOutputParser 解析输出

这个 Agent 本质上是 Tool Calling Agent 的前身,在 bind_tools 抽象出现之前的 OpenAI 专用实现。它直接使用 convert_to_openai_tool 函数将工具转换为 OpenAI 格式,然后通过 llm.bind(tools=...) 传递给模型。这种方式绕过了 bind_tools 抽象层,直接与 OpenAI 的 API 格式耦合。

两者的核心区别在于抽象层次。create_openai_tools_agent 假定底层模型理解 OpenAI 的工具格式,因此直接传入 OpenAI 格式的工具描述。而 create_tool_calling_agent 通过 bind_tools 让每个模型实现自行决定如何转换工具描述,从而支持 OpenAI、Anthropic、Google 等所有实现了 bind_tools 的提供商。

在实际应用中,如果你确定只使用 OpenAI 的模型,两者的行为几乎完全相同。但如果你需要在不同模型之间切换(比如使用 configurable_alternatives 在 OpenAI 和 Anthropic 之间动态选择),create_tool_calling_agent 是唯一可行的选择,因为它不假设任何特定提供商的工具格式。现在推荐始终使用 create_tool_calling_agent

15.5 Structured Chat Agent:JSON 格式

create_structured_chat_agent 通过 JSON blob 来指定工具调用,适用于不支持原生工具调用但支持 JSON 输出的 LLM:

python 复制代码
# langchain_classic/agents/structured_chat/base.py

def create_structured_chat_agent(
    llm: BaseLanguageModel,
    tools: Sequence[BaseTool],
    prompt: ChatPromptTemplate,
    tools_renderer: ToolsRenderer = render_text_description_and_args,
    *,
    stop_sequence: bool | list[str] = True,
) -> Runnable:
    missing_vars = {"tools", "tool_names", "agent_scratchpad"}.difference(
        prompt.input_variables + list(prompt.partial_variables),
    )
    if missing_vars:
        raise ValueError(f"Prompt missing required variables: {missing_vars}")

    prompt = prompt.partial(
        tools=tools_renderer(list(tools)),
        tool_names=", ".join([t.name for t in tools]),
    )

    if stop_sequence:
        stop = ["\nObservation"] if stop_sequence is True else stop_sequence
        llm_with_stop = llm.bind(stop=stop)
    else:
        llm_with_stop = llm

    return (
        RunnablePassthrough.assign(
            agent_scratchpad=lambda x: format_log_to_str(
                x["intermediate_steps"]
            ),
        )
        | prompt
        | llm_with_stop
        | JSONAgentOutputParser()
    )

15.5.1 与 ReAct 的区别

Structured Chat Agent 的结构与 ReAct 几乎相同,但有两个关键差异:

  1. 工具渲染器 :使用 render_text_description_and_args,不仅渲染工具名称和描述,还渲染参数的 JSON Schema。这让 LLM 知道每个工具接受什么结构的输入。

  2. 输出解析器 :使用 JSONAgentOutputParser,从 LLM 输出中提取 JSON blob:

json 复制代码
{
    "action": "search",
    "action_input": {"query": "weather in Beijing"}
}

或者终止时:

json 复制代码
{
    "action": "Final Answer",
    "action_input": "北京今天 25 度"
}

这种 JSON 格式天然支持结构化的工具输入(嵌套的字典参数),而 ReAct 的 Action Input 只能传递字符串。这正是"Structured"名称的由来。当工具需要接收多个命名参数时(如搜索工具需要同时指定查询词、结果数量和语言),结构化聊天 Agent 可以通过 JSON 的键值对清晰地表达每个参数,而 ReAct Agent 则只能把所有参数塞进一个字符串中,依赖工具自己去解析。

不过,结构化聊天 Agent 对模型的 JSON 生成能力有较高要求。如果模型生成的 JSON 格式不正确(缺少引号、括号不匹配等),输出解析器就会失败。在实践中,这种格式错误的发生频率比想象中要高,特别是对于较小的开源模型。因此,如果你的目标模型支持原生工具调用,Tool Calling Agent 几乎总是更好的选择。

15.6 XML Agent:标签化交互

create_xml_agent 是为擅长 XML 格式的模型(如 Claude 早期版本)设计的 Agent:

python 复制代码
# langchain_classic/agents/xml/base.py

def create_xml_agent(
    llm: BaseLanguageModel,
    tools: Sequence[BaseTool],
    prompt: BasePromptTemplate,
    tools_renderer: ToolsRenderer = render_text_description,
    *,
    stop_sequence: bool | list[str] = True,
) -> Runnable:
    missing_vars = {"tools", "agent_scratchpad"}.difference(
        prompt.input_variables + list(prompt.partial_variables),
    )
    if missing_vars:
        raise ValueError(f"Prompt missing required variables: {missing_vars}")

    prompt = prompt.partial(tools=tools_renderer(list(tools)))

    if stop_sequence:
        stop = ["</tool_input>"] if stop_sequence is True else stop_sequence
        llm_with_stop = llm.bind(stop=stop)
    else:
        llm_with_stop = llm

    return (
        RunnablePassthrough.assign(
            agent_scratchpad=lambda x: format_xml(x["intermediate_steps"]),
        )
        | prompt
        | llm_with_stop
        | XMLAgentOutputParser()
    )

XML Agent 使用 XML 标签来表示工具调用和最终答案:

xml 复制代码
<tool>search</tool><tool_input>weather in Beijing</tool_input>
<observation>25°C and sunny</observation>

<final_answer>北京今天 25 度,晴天</final_answer>

停止词是 "</tool_input>",确保模型在生成完工具输入后停止,等待实际的观察结果。格式化函数 format_xml 将中间步骤转换为 XML 格式的字符串。

15.7 Agent 类型全面对比

flowchart TB subgraph "Tool Calling Agent" direction LR TC1["format_to_tool_messages
(消息格式)"] --> TC2["ChatPromptTemplate"] TC2 --> TC3["llm.bind_tools(tools)"] TC3 --> TC4["ToolsAgentOutputParser"] end subgraph "ReAct Agent" direction LR RE1["format_log_to_str
(文本格式)"] --> RE2["PromptTemplate"] RE2 --> RE3["llm.bind(stop)"] RE3 --> RE4["ReActOutputParser"] end subgraph "Structured Chat Agent" direction LR SC1["format_log_to_str
(文本格式)"] --> SC2["ChatPromptTemplate"] SC2 --> SC3["llm.bind(stop)"] SC3 --> SC4["JSONAgentOutputParser"] end subgraph "XML Agent" direction LR XM1["format_xml
(XML 格式)"] --> XM2["PromptTemplate"] XM2 --> XM3["llm.bind(stop)"] XM3 --> XM4["XMLAgentOutputParser"] end

四维对比表

维度 Tool Calling ReAct Structured Chat XML
格式化 消息 (ToolMessage) 文本 (str) 文本 (str) XML (str)
LLM 绑定 bind_tools bind(stop=) bind(stop=) bind(stop=)
输出解析 ToolsAgentOutputParser ReActOutputParser JSONAgentOutputParser XMLAgentOutputParser
停止词 无需 \nObservation \nObservation </tool_input>
工具输入类型 dict (结构化) str (纯文本) dict (JSON) str (纯文本)
并行工具调用 支持 不支持 不支持 不支持
模型要求 支持 bind_tools 任意 LLM 任意 Chat Model 任意 LLM
推荐场景 首选方案 教学/兼容 多参数工具 XML 友好模型

选型决策树

flowchart TD A[选择 Agent 类型] --> B{模型支持
bind_tools?} B -->|是| C["create_tool_calling_agent
(推荐)"] B -->|否| D{需要结构化
工具输入?} D -->|是| E["create_structured_chat_agent
(JSON 格式)"] D -->|否| F{模型擅长
XML 格式?} F -->|是| G["create_xml_agent"] F -->|否| H["create_react_agent
(纯文本格式)"]

15.8 ToolAgentAction 与普通 AgentAction 的差异

Tool Calling Agent 引入了 ToolAgentAction,它比普通的 AgentAction 多了一个关键字段 tool_call_id

python 复制代码
class ToolAgentAction(AgentActionMessageLog):
    tool_call_id: str | None

这个 ID 将 AI 消息中的 tool_call 与后续的 ToolMessage 关联起来。在 OpenAI 等 API 中,每个 tool_call 都有唯一的 ID,对应的 ToolMessage 必须携带相同的 ID 才能被正确匹配。

这种关联在并行工具调用场景下尤为重要。当一条 AI 消息包含三个 tool_calls 时,后续需要三条 ToolMessage 来一一对应。没有 tool_call_id,系统无法区分哪个结果属于哪个调用。

15.9 format_scratchpad 策略对比

不同 Agent 格式化中间步骤的方式体现了不同的设计哲学:

消息格式 (Tool Calling)

python 复制代码
# 输出: [AIMessage(tool_calls=[...]), ToolMessage(...)]
messages = format_to_tool_messages(intermediate_steps)

优势:保持消息的结构化信息,支持并行工具调用,与 Chat API 原生对齐。

文本格式 (ReAct / Structured Chat)

python 复制代码
# 输出: "Thought: ...\nAction: ...\nObservation: ...\nThought: "
text = format_log_to_str(intermediate_steps)

优势:简单直观,兼容所有 LLM(包括纯文本模型),可读性强。

XML 格式

python 复制代码
# 输出: "<tool>search</tool><tool_input>query</tool_input><observation>result</observation>"
xml = format_xml(intermediate_steps)

优势:结构清晰,与擅长 XML 的模型配合良好。

15.10 设计决策分析

为什么不是一个统一的 create_agent?

LangChain 提供了多个 create_xxx_agent 函数而非一个统一的函数,这是一个刻意的设计选择。不同 Agent 模式的差异不仅仅在参数上,更在于对 LLM 输出格式的假设、提示模板的结构要求、以及错误恢复的策略。将它们统一会导致大量条件分支和配置参数,反而降低可理解性。

为什么都返回 Runnable 而非 Agent 子类?

所有 create_xxx_agent 函数都返回 Runnable 而非 BaseSingleActionAgent 的某个子类。这意味着它们的输出可以参与 LCEL 组合,可以被进一步 pipe、map、fallback。AgentExecutor 通过 validate_runnable_agent 自动将 Runnable 包装为 RunnableAgent,实现了无缝衔接。

停止词的必要性与局限性

文本格式的 Agent(ReAct、Structured Chat、XML)都依赖停止词来控制生成。这是一种"外部约束"机制 -- 模型本身并不知道何时应该停止,是停止词在物理上切断了生成流。

Tool Calling Agent 则完全不需要停止词,因为模型原生地知道何时应该调用工具、何时应该给出最终答案。这种"内在理解"与"外部约束"的差异,正是 Tool Calling Agent 被推荐为首选方案的根本原因。

OutputParser 的容错设计

每种 Agent 的 OutputParser 都包含了大量的容错逻辑。以 ToolsAgentOutputParser 为例,它先尝试 message.tool_calls,失败后回退到 additional_kwargs["tool_calls"],再失败则视为最终答案。这种渐进式回退策略确保了 Agent 在各种模型行为下都能正常工作。

15.11 深入理解工具绑定机制

工具绑定是 Tool Calling Agent 的核心机制,值得用一个完整的小节来深入讨论。当我们调用 llm.bind_tools(tools) 时,发生了一系列精密的转换过程。

首先,每个 BaseToolargs_schema(一个 Pydantic 模型)被转换为 JSON Schema 格式。这个转换过程需要处理各种 Pydantic 特性:字段描述变成 Schema 的 description,字段类型映射为 JSON 类型(int 变成 integer,str 变成 string 等),Optional 类型生成 nullable 标记,嵌套模型递归展开为嵌套的 Schema 结构。

然后,JSON Schema 被包装成提供商特定的格式。对于 OpenAI,它被包装为 {"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}} 结构。对于 Anthropic,格式略有不同。bind_tools 方法的价值就在于屏蔽了这些格式差异 -- 开发者只需要提供标准的 BaseTool,绑定方法负责转换为正确的格式。

最后,bind_tools 实际上调用了 super().bind(tools=formatted_tools),返回一个 RunnableBinding。这个 RunnableBinding 并不修改原始的 LLM 实例,而是创建了一个新的 Runnable,在每次调用时自动将工具描述附加到请求参数中。这种不可变的设计确保了同一个 LLM 实例可以被多个不同的 bind_tools 调用复用,互不影响。

一个常见的误解是认为 bind_tools 会"教会"模型使用工具。实际上,它只是告诉 API 这次请求可以返回工具调用格式的响应。模型是否真的会调用工具、调用哪个工具,完全取决于模型自身的推理能力和提示内容。工具描述的质量 -- 特别是名称的直观性和描述的准确性 -- 对模型的工具选择行为有决定性影响。

tool_choice 参数的作用

bind_tools 还支持一个重要的可选参数 tool_choice,它可以控制模型的工具调用行为。设为 "auto" 时(默认),模型自行决定是否调用工具;设为 "required" 时,模型必须调用至少一个工具;设为特定工具名时,模型必须调用该工具。在需要强制 Agent 执行特定操作的场景下(如强制使用搜索工具检查最新信息),tool_choice 非常有用。

但需要注意,tool_choice="required" 在 Agent 循环中可能导致无限循环 -- 如果模型被强制调用工具但已经得到了答案,它就无法通过返回 AgentFinish 来终止循环。因此,这个参数在 Agent 场景中应该谨慎使用。

15.12 自定义 Agent 的构建策略

理解了四种标准 Agent 的内部结构后,你完全可以构建自己的 Agent 类型。关键是遵循统一的四阶段管道模式。

构建自定义 Agent 的步骤

第一步是确定格式化策略。你的中间步骤如何传递给 LLM?是作为消息列表、文本字符串、还是其他格式?这取决于你的 LLM 接口和提示设计。

第二步是设计提示模板。提示模板需要包含工具描述和 agent_scratchpad 占位符。agent_scratchpad 是格式化后的中间步骤,它让 LLM 看到之前的行动和观察结果。

第三步是选择 LLM 调用方式。如果你的模型支持原生工具调用,优先使用 bind_tools;否则使用 bind(stop=...) 配合停止词。

第四步是实现输出解析器。解析器需要继承 AgentOutputParserMultiActionAgentOutputParser,实现 parse 方法,将 LLM 输出转换为 AgentActionAgentFinish

下面是一个完整的自定义 Agent 示例,它使用自定义的标记格式:

python 复制代码
from langchain_core.runnables import RunnablePassthrough
from langchain_core.prompts import ChatPromptTemplate
from langchain_classic.agents.agent import MultiActionAgentOutputParser

class MyCustomOutputParser(MultiActionAgentOutputParser):
    def parse(self, text: str) -> list[AgentAction] | AgentFinish:
        if "DONE:" in text:
            answer = text.split("DONE:")[1].strip()
            return AgentFinish(return_values={"output": answer}, log=text)
        # 解析你的自定义格式
        actions = self._parse_actions(text)
        return actions

def create_my_custom_agent(llm, tools, prompt):
    llm_with_tools = llm.bind_tools(tools)
    return (
        RunnablePassthrough.assign(
            agent_scratchpad=lambda x: my_format_function(
                x["intermediate_steps"]
            ),
        )
        | prompt
        | llm_with_tools
        | MyCustomOutputParser()
    )

何时需要自定义 Agent

在大多数情况下,create_tool_calling_agent 已经足够。需要自定义 Agent 的场景通常包括:

  1. 特殊的推理格式:如思维链(Chain-of-Thought)需要特定的输出格式
  2. 多阶段推理:如先规划再执行的两阶段 Agent
  3. 领域特定的工具调用约定:如使用特定的 DSL 来表示工具调用
  4. 非标准的 LLM 接口:如通过 HTTP API 调用的内部模型,不支持标准的工具调用格式

无论哪种情况,遵循四阶段管道模式都能确保你的自定义 Agent 与 AgentExecutor 无缝配合,自动获得停止保护、错误处理、流式输出等能力。

15.13 ReAct 与 Tool Calling 的本质区别

虽然 ReAct Agent 和 Tool Calling Agent 在功能上看起来相似 -- 都是让模型选择工具、执行工具、观察结果 -- 但它们的工作原理有本质性的差异,理解这些差异对于在生产环境中做出正确选择至关重要。

ReAct Agent 是一种"文本模拟"方案。模型被要求按照特定的文本格式输出它的"思考"和"行动",然后通过正则表达式从文本中提取工具名和参数。这意味着模型需要同时做两件事:推理问题的答案,以及遵循格式约定。当模型的格式遵循能力不够强时(特别是较小的开源模型),它可能会产生无法解析的输出,导致解析错误。

Tool Calling Agent 则是一种"原生能力"方案。模型在训练阶段就被教会了工具调用的格式,它的输出中包含结构化的 tool_calls 字段,不需要从自由文本中提取。这种方式更加可靠,因为模型的输出格式由 API 层保证,而非依赖模型的文本生成行为。

从信息流的角度看,ReAct Agent 将所有信息(思考、行动、观察)编码为一个长文本字符串,通过字符串拼接传递上下文。Tool Calling Agent 则使用结构化的消息列表,每种信息(AI 消息、工具消息)都有明确的类型和字段。结构化的表示不仅更容易被模型理解,也更容易被程序处理和追踪。

最后,Tool Calling Agent 天然支持并行工具调用 -- 模型可以在一条消息中返回多个 tool_calls。而 ReAct Agent 由于文本格式的限制,每次只能指定一个 Action,实现并行调用需要额外的工程工作。

综合来看,如果你使用的模型支持原生工具调用(目前主流的商业模型都支持),应该毫不犹豫地选择 Tool Calling Agent。ReAct Agent 的价值在于教学(它的文本格式更容易理解 Agent 的推理过程)和兼容性(适用于不支持工具调用的开源模型)。

15.14 Agent 提示模板的设计原则

Agent 的行为在很大程度上取决于提示模板的设计。尽管不同类型的 Agent 有不同的模板结构要求,但有几个通用的设计原则值得遵循。

系统消息应该明确告诉模型它是一个什么角色、有什么能力、以及应该如何行事。一个好的系统消息不仅描述角色定位,还应该包含行为指南 -- 比如"如果你不确定答案,应该使用搜索工具查证,而不是凭记忆回答"这样的指导。这类行为指南对 Agent 的可靠性有显著影响。

工具使用指南也是提示模板的重要组成部分。虽然 Tool Calling Agent 不需要在提示中描述工具调用的格式(模型原生理解),但告诉模型何时应该使用工具、何时可以直接回答,仍然很有价值。例如,"对于数学计算,始终使用计算器工具,不要心算"这样的指示可以显著减少计算错误。

关于 agent_scratchpad 的放置位置,不同的策略会影响模型的行为。将它放在消息列表的末尾(作为最新的上下文)是最常见的做法,因为语言模型倾向于更关注最近的内容。但在某些场景下,将早期的关键步骤固定在前面(通过自定义的 trim_intermediate_steps 函数),可以确保重要的上下文不会被后续步骤"淹没"。

聊天历史(chat_history)的处理也需要谨慎。如果 Agent 需要在多轮对话中工作,聊天历史应该插入在系统消息之后、当前输入之前。这样模型既能看到之前的对话上下文,又能清楚地区分"历史对话"和"当前任务"。不建议将聊天历史与 Agent 的中间步骤混在一起,因为这会让模型难以区分哪些是之前的对话回忆,哪些是当前任务的工具调用结果。

15.15 Agent 管道的可视化与调试

每个 Agent 管道作为 Runnable,都可以通过 get_graph() 方法获取其执行图,并渲染为 Mermaid 或 ASCII 图。这对于理解 Agent 的内部结构非常有帮助。

在调试 Agent 时,最有价值的信息来源是 AgentExecutor 的 return_intermediate_steps=True 设置。它返回完整的中间步骤列表,让你能够逐步追踪 Agent 的决策过程。结合 LangSmith 等追踪工具,你可以看到每次 LLM 调用的完整输入输出、每次工具调用的参数和结果、以及整个执行循环的时间分布。

对于流式场景,AgentExecutor 的 stream 方法会逐步输出 AddableDict,包含 actionsstepsoutput 等键。这使得前端应用可以实时展示 Agent 的思考和行动过程,提供更好的用户体验。

15.13 Agent 模式的演进趋势

当前 Agent 模式的发展呈现出几个明确的趋势。首先是从文本解析向模型原生工具调用的迁移。随着越来越多的 LLM 提供商支持原生工具调用能力,基于停止词和正则表达式的文本解析方案正在逐步被取代。Tool Calling Agent 之所以成为推荐方案,正是因为它利用了模型的原生理解能力,不需要模型在特定格式上进行"演戏"。

其次是从单一循环向图状态机的演进。AgentExecutor 的 while 循环只能表达线性的思考-行动序列。实际的复杂任务可能需要条件分支("如果搜索结果不满意,换一个搜索引擎")、并行执行("同时搜索两个数据库")、人工审核节点("在执行危险操作前等待人工确认")等高级控制流。LangGraph 正是为这些场景设计的。

第三个趋势是多模态 Agent 的兴起。随着视觉语言模型的成熟,Agent 不仅能处理文本,还能理解图片、视频等多模态输入,并调用屏幕操作、代码执行等多模态工具。这对 Agent 管道中每个阶段的设计都提出了新的挑战,特别是中间步骤的格式化和观察结果的表示。

小结

本章全面剖析了 LangChain 提供的四种 Agent 构建方式。它们的内部结构遵循统一的四阶段 LCEL 管道模式(格式化 -> 提示 -> LLM -> 解析),差异仅在于每个阶段的具体实现。

create_tool_calling_agent 利用模型原生能力,是当前的推荐方案。create_react_agent 实现了经典的推理-行动范式,适用于教学和不支持工具调用的模型。create_structured_chat_agent 通过 JSON 格式支持结构化工具输入。create_xml_agent 则为擅长 XML 的模型提供了专用方案。

从设计角度看,所有 Agent 构建函数都返回 Runnable 而非 Agent 子类,这使得 Agent 管道可以参与 LCEL 组合。AgentExecutor 通过自动检测和包装机制,无缝地将 Runnable 管道接入执行循环。这种"接口统一、实现多样"的设计哲学,让 Agent 系统在保持灵活性的同时,为所有类型的 Agent 提供了统一的运行时保障 -- 停止保护、错误恢复、流式输出、并发执行,全部由 AgentExecutor 统一管理。

在实际项目中,Agent 模式的选择往往不是技术决策的终点,而只是起点。真正决定 Agent 表现的是三个要素的协同:工具集的设计质量(名称是否直观、描述是否准确、参数是否合理)、提示模板的引导能力(是否告诉了模型何时该用工具、何时该直接回答、如何处理不确定性)、以及错误恢复的策略(是否开启了错误处理、是否设置了合理的迭代上限、是否提供了有意义的错误提示)。这三个要素的组合质量,远比 Agent 类型的选择更加重要。

从框架设计的角度看,LangChain 通过将 Agent 实现为 Runnable 管道,实现了一个优雅的职责划分:框架负责执行循环、错误恢复和资源管理等机制性工作,开发者负责工具设计、提示工程和业务逻辑等创意性工作。这种划分让开发者可以把精力集中在真正产生差异化价值的地方,而不是反复实现相同的基础设施代码。

下一章,我们将转向另一个基础设施话��� -- 序列化与配置系统,看看 LangChain 如何实现对象的持久化与动态配置。

相关推荐
杨艺韬2 小时前
LangChain设计与实现-第12章-回调与可观测性
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第18章-设计模式与架构决策
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第16章-序列化与配置系统
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第6章-提示词模板引擎
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第9章-文档加载与文本分割
langchain·agent
杨艺韬2 小时前
langchain设计与实现-前言
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第2章-架构总揽
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第3章-Runnable 与 LCEL 表达式语言
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第4章-消息系统与多模态
langchain·agent