第15章 工具调用与 Agent 模式
本书章节导航
- 前言
- 第1章 为什么需要理解 LangChain
- 第2章 架构总览
- 第3章 Runnable 与 LCEL 表达式语言
- 第4章 消息系统与多模态
- 第5章 语言模型抽象层
- 第6章 提示词模板引擎
- 第7章 输出解析与结构化输出
- 第8章 工具系统
- 第9章 文档加载与文本分割
- 第10章 向量存储与检索器
- 第11章 Chain 组合模式
- 第12章 回调与可观测性
- 第13章 记忆与会话管理
- 第14章 Agent 架构与执行循环
- 第15章 工具调用与 Agent 模式 (当前)
- 第16章 序列化与配置系统
- 第17章 Partner 集成架构
- 第18章 设计模式与架构决策
开篇引言
上一章我们剖析了 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<br/>格式化 agent_scratchpad"] --> B["Prompt<br/>填充变量生成提示"]
B --> C["LLM / LLM.bind_tools<br/>调用大语言模型"]
C --> D["OutputParser<br/>解析输出为<br/>AgentAction/AgentFinish"]
style A fill:#e3f2fd
style B fill:#f3e5f5
style C fill:#e8f5e9
style D fill:#fff3e0
四个阶段分别负责:
- 格式化:将
intermediate_steps(之前的执行历史)转换为 LLM 可理解的格式 - 提示:将格式化后的历史与用户输入组合成完整的提示
- 推理:调用 LLM 生成响应
- 解析:将 LLM 响应解析为
AgentAction或AgentFinish
不同 Agent 类型的差异仅在于:每个阶段的具体实现不同。格式化方式、提示模板、LLM 绑定方式、输出解析器,这四个维度的组合定义了不同的 Agent 范式。
15.2 Tool Calling Agent:模型原生工具调用
create_tool_calling_agent 是目前推荐的主要 Agent 构建方式。它利用现代 LLM(如 GPT-4、Claude、Gemini)原生的工具调用能力,不依赖文本解析。
15.2.1 构建函数
# 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 对象:
# 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:
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 动作:
# 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
解析逻辑有三个分支:
- message.tool_calls 存在:优先使用结构化的 tool_calls
- additional_kwargs 中有 tool_calls:回退到旧格式
- 都没有:视为最终答案,返回 AgentFinish
flowchart TD
A["AIMessage 从 LLM 返回"] --> B{message.tool_calls<br/>非空?}
B -->|是| C["使用结构化 tool_calls"]
B -->|否| D{"additional_kwargs<br/>有 tool_calls?"}
D -->|是| E["从 JSON 解析 tool_calls"]
D -->|否| F["返回 AgentFinish<br/>(message.content 作为最终答案)"]
C --> G["遍历 tool_calls"]
E --> G
G --> H["创建 ToolAgentAction 列表<br/>(包含 tool_call_id)"]
H --> I["返回 list[AgentAction]"]
15.2.4 使用示例
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 构建函数
# 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 将中间步骤转换为文本字符串,而非消息对象:
# 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 提示格式如下:
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 生成类似这样的文本:
Thought: I need to search for the weather
Action: search
Action Input: weather in Beijing
到此停止。执行器解析出 Action 和 Action Input,执行工具,将结果作为 Observation 拼接回去:
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 格式:
# 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:
# 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 几乎相同,但有两个关键差异:
-
工具渲染器:使用
render_text_description_and_args,不仅渲染工具名称和描述,还渲染参数的 JSON Schema。这让 LLM 知道每个工具接受什么结构的输入。 -
输出解析器:使用
JSONAgentOutputParser,从 LLM 输出中提取 JSON blob:
{
"action": "search",
"action_input": {"query": "weather in Beijing"}
}
或者终止时:
{
"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:
# 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 标签来表示工具调用和最终答案:
<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<br/>(消息格式)"] --> TC2["ChatPromptTemplate"]
TC2 --> TC3["llm.bind_tools(tools)"]
TC3 --> TC4["ToolsAgentOutputParser"]
end
subgraph "ReAct Agent"
direction LR
RE1["format_log_to_str<br/>(文本格式)"] --> RE2["PromptTemplate"]
RE2 --> RE3["llm.bind(stop)"]
RE3 --> RE4["ReActOutputParser"]
end
subgraph "Structured Chat Agent"
direction LR
SC1["format_log_to_str<br/>(文本格式)"] --> SC2["ChatPromptTemplate"]
SC2 --> SC3["llm.bind(stop)"]
SC3 --> SC4["JSONAgentOutputParser"]
end
subgraph "XML Agent"
direction LR
XM1["format_xml<br/>(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{模型支持<br/>bind_tools?}
B -->|是| C["create_tool_calling_agent<br/>(推荐)"]
B -->|否| D{需要结构化<br/>工具输入?}
D -->|是| E["create_structured_chat_agent<br/>(JSON 格式)"]
D -->|否| F{模型擅长<br/>XML 格式?}
F -->|是| G["create_xml_agent"]
F -->|否| H["create_react_agent<br/>(纯文本格式)"]
15.8 ToolAgentAction 与普通 AgentAction 的差异
Tool Calling Agent 引入了 ToolAgentAction,它比普通的 AgentAction 多了一个关键字段 tool_call_id:
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)
# 输出: [AIMessage(tool_calls=[...]), ToolMessage(...)]
messages = format_to_tool_messages(intermediate_steps)
优势:保持消息的结构化信息,支持并行工具调用,与 Chat API 原生对齐。
文本格式 (ReAct / Structured Chat)
# 输出: "Thought: ...\nAction: ...\nObservation: ...\nThought: "
text = format_log_to_str(intermediate_steps)
优势:简单直观,兼容所有 LLM(包括纯文本模型),可读性强。
XML 格式
# 输出: "<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) 时,发生了一系列精密的转换过程。
首先,每个 BaseTool 的 args_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=...) 配合停止词。
第四步是实现输出解析器。解析器需要继承 AgentOutputParser 或 MultiActionAgentOutputParser,实现 parse 方法,将 LLM 输出转换为 AgentAction 或 AgentFinish。
下面是一个完整的自定义 Agent 示例,它使用自定义的标记格式:
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 的场景通常包括:
- 特殊的推理格式:如思维链(Chain-of-Thought)需要特定的输出格式
- 多阶段推理:如先规划再执行的两阶段 Agent
- 领域特定的工具调用约定:如使用特定的 DSL 来表示工具调用
- 非标准的 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,包含 actions、steps 和 output 等键。这使得前端应用可以实时展示 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 如何实现对象的持久化与动态配置。