LangChain Agent架构:AgentExecutor与工具集成
从一次深夜调试说起
上周三凌晨两点,我被一个诡异的Agent问题卡住了:明明定义好了工具,Agent却总是回复“我无法处理这个请求”。日志里工具列表正常,Prompt看起来也没问题,但Agent就是不肯调用工具。熬到三点才发现,问题出在AgentExecutor的一个默认参数上——handle_parsing_errors被设成了False,导致解析输出时的细微格式错误直接让整个执行链静默失败。
这个坑让我意识到,LangChain的Agent架构看似封装完善,但细节处的配置才是实战的关键。今天我们就拆开AgentExecutor的黑盒,看看工具到底是怎么被集成和调度的。
AgentExecutor:不只是个执行器
很多人把AgentExecutor当成一个简单的“执行循环”,其实它承担了四个关键角色:
错误处理与重试机制
默认的max_iterations是15次,但生产环境我一般会调到8-10次。曾经有个Agent在死循环里不停调用搜索工具,就是因为没设上限,API费用直接爆表。现在我的代码里一定会加这个:
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
max_iterations=8, # 别相信Agent永远能自己停住
handle_parsing_errors=True, # 这个必须开,否则解析失败就静默
verbose=True # 调试阶段一定要开,能看到思考过程
)
状态管理
每次工具调用的输入输出、中间步骤的思考内容,都被它默默记录着。这些状态不仅用于当前循环,还会影响后续的决策路径。我吃过亏的地方是:工具返回的内容太长,导致后续的Prompt超了token限制。现在我会在工具层做结果裁剪。
工具集成的三个层次
1. 基础工具定义:别迷信装饰器
LangChain提供了@tool装饰器,用起来很顺手:
from langchain.tools import tool
@tool
def search_database(query: str) -> str:
"""根据query查询数据库,返回相关记录"""
# 这里踩过坑:文档字符串一定要写清楚!
# Agent会根据描述决定是否调用这个工具
return db.search(query)
但装饰器有个隐藏问题:它默认的工具名是函数名,如果项目里有多个同名函数,Agent会分不清。我现在的习惯是显式定义:
from langchain.tools import Tool
search_tool = Tool(
name="产品数据库查询", # 名字要具体,别用泛称
func=search_database,
description="用于查询产品库存和规格,输入应该是产品型号或关键词" # 描述越详细,Agent调用越准
)
2. 工具路由:让Agent知道什么时候该用谁
工具列表的顺序其实有讲究。Agent在判断时,会按顺序计算相似度。把最常用的工具放前面,能稍微提升响应速度。但更重要的是description的写法:
# 不好的描述
"查询天气"
# 好的描述
"获取某个城市当前天气和未来24小时预报,输入格式应为'城市名',例如'北京'"
我曾经把一个“计算器”工具的描述写得太简单,结果Agent遇到数学问题就去调搜索引擎。后来把描述改成“执行加减乘除运算,输入格式如’2+2’”,调用准确率立刻上来了。
3. 工具输出处理:容易被忽略的一环
工具返回的原始数据,Agent不一定能直接理解。特别是JSON结构的数据,最好在工具内部就转成自然语言:
def query_order(order_id: str) -> str:
result = db.query_order(order_id)
# 别直接返回JSON
# return json.dumps(result)
# 应该转换成自然语言
return f"订单{result['id']}状态为{result['status']},金额{result['amount']}元,下单时间{result['time']}"
实战中的配置细节
Agent类型的选择
zero-shot-react-description: 适合工具少、场景简单的场景structured-chat-zero-shot-react-description: 工具多的时候用这个,结构更清晰conversational-react-description: 需要对话历史的场景
我大部分项目用structured-chat,它的输出格式更稳定,解析出错率低。
温度值(temperature)的影响
Agent的temperature不能设太高。曾经试过设为0.7,结果Agent的思考过程天马行空,乱调工具。现在我的经验值是:
agent = initialize_agent(
tools=tools,
llm=ChatOpenAI(temperature=0.1), # Agent需要确定性,别太创意
agent=AgentType.STRUCTURED_CHAT_ZERO_SHOT_REACT_DESCRIPTION
)
早期停止(early_stopping)策略
默认的force策略要求Agent必须返回Final Answer才结束。但在复杂场景下,Agent可能一直犹豫不决。可以试试generate策略:
agent_executor = AgentExecutor(
# ...
early_stopping_method="generate", # 允许在合适时机主动结束
return_intermediate_steps=True # 方便调试,看Agent的思考过程
)
五、调试技巧:当Agent不听话时
-
打开verbose模式:这是最基本的,能看到Agent的思考链(chain of thought)
-
检查工具描述冲突:两个工具的描述太相似时,Agent容易混淆。给每个工具加上明确的适用场景说明
-
观察中间步骤:
result = agent_executor.invoke({"input": "查询北京天气"})
print(result["intermediate_steps"]) # 能看到工具调用序列
-
模拟工具调用:有时候不是Agent的问题,是工具本身返回格式异常。先单独测试工具
-
限制工具集:如果Agent总调用错误的工具,可以先只给它一个工具,逐步添加
个人经验建议
关于工具设计:一个工具只做一件事,但要把这件事做好。我曾经写过一个“数据处理工具”,里面包含了清洗、转换、验证三个功能,结果Agent永远只调用它,即使场景不合适。后来拆成三个独立工具,准确率提升明显。
关于错误处理:在工具内部就要处理好异常,返回人类可读的错误信息。不要抛出异常让AgentExecutor捕获——Agent看到异常堆栈会懵掉,然后开始胡言乱语。
关于性能:工具函数要加缓存,特别是调用外部API的工具。我见过一个天气查询工具被反复调用,其实10分钟内天气不会大变。现在我的工具里都会加个简单的lru_cache。
关于测试:不要只测Agent的整体表现,要单独测试每个工具的描述是否清晰,测试Agent在各种边界条件下的反应。我有个检查清单:空输入、错误格式、超长输入、多轮对话中的工具选择等。
最后一点心态上的:把Agent当成一个需要指导的新人工程师。它有能力,但需要清晰的指令(Prompt)、合适的工具(Tools)和容错的环境(Executor配置)。调试Agent不是找bug,而是优化协作流程。
构建第一个简单Agent:基于LLM的问答助手
从裸Prompt到结构化Agent
直接扔Prompt给LLM就像让厨师直接在后厨切菜——能出菜,但上菜顺序、摆盘、退单处理全乱套。三周前我重构过一个客服系统,最初版本就栽在这里:
# 别这样写——这是把调度逻辑全塞给LLM了
prompt = """
用户说:{query}
请判断是否需要查天气、查时间或闲聊,然后执行对应操作。
如果是天气,调用get_weather(city);如果是时间,调用get_time()...
"""
# 实际运行时发现,LLM经常把函数调用写在回复文本里,比如:
# "我应该调用get_weather('北京'),今天北京天气是..."
问题在于LLM的输出是文本流,而我们需要的是结构化动作。后来改成调度器模式:
class QAAgent:
def __init__(self, llm):
self.llm = llm
self.tools = {
'weather': get_weather,
'time': get_time,
'calc': calculator
}
def _parse_intent(self, query):
# 这里踩过坑:早期用字符串匹配,结果“几点了”和“时间”没匹配上
# 现在交给小分类模型,或者用LLM做轻量标注
prompt = f"""
用户查询:{query}
请返回最匹配的工具名:weather/time/calc/unknown
只返回工具名,不要解释。
"""
return self.llm.generate(prompt).strip()
def run(self, query):
tool_name = self._parse_intent(query)
if tool_name == 'unknown':
# 兜底策略:直接让LLM生成回复
return self.llm.generate(f"用户问:{query}\n请直接回答:")
# 这里加个验证,防止LLM返回不在列表里的工具名
if tool_name not in self.tools:
# 降级处理
return "抱歉,我暂时无法处理这个请求"
# 提取参数——另一个容易翻车的地方
params = self._extract_params(query, tool_name)
result = self.tools[tool_name](**params)
# 最后一步:把工具返回的数据转成自然语言
return self._format_response(query, result)
参数提取的坑与解法
工具调用最头疼的是参数提取。最初我让LLM直接生成JSON,结果各种格式错误。后来发现分两步走更稳:
def _extract_params(self, query, tool_name):
# 先让LLM用自然语言描述需要什么参数
prompt = f"""
工具:{tool_name}
用户查询:{query}
请列出调用此工具所需的参数名,用逗号分隔。
例如:city, date
"""
param_names = self.llm.generate(prompt).split(',')
# 再逐个提取参数值
params = {}
for p in param_names:
p = p.strip()
if not p:
continue
# 这里有个技巧:让LLM做选择题而不是填空题
prompt = f"""
用户查询:{query}
需要提取的参数:{p}
请从查询中提取该参数的值。
如果查询中未明确给出,请返回"NOT_FOUND"。
只返回参数值或NOT_FOUND。
"""
value = self.llm.generate(prompt).strip()
if value != "NOT_FOUND":
params[p] = value
return params
实际跑起来发现,LLM有时会把“明天”这种相对时间也标为NOT_FOUND。后来加了个后处理层,把常见相对时间转成绝对时间。这种脏活累活,Agent框架得自己消化掉。
回复格式化的小心思
工具返回的数据转自然语言时,别简单做模板填充。我见过有人这样写:
# 太机械了
def _format_response(self, query, result):
return f"查询结果:{result}"
用户问“北京冷不冷”,你回“温度:17℃”——这算答了但没完全答。好的格式化器要结合查询意图:
def _format_response(self, query, result):
# 把原始查询和工具结果一起喂给LLM,让它组织语言
prompt = f"""
用户原话:{query}
工具返回的数据:{result}
请根据用户原话的语气和意图,将数据转化为自然语言回复。
保持简洁,直接回答用户的问题。
"""
return self.llm.generate(prompt)
这里有个平衡点:如果每次都要调LLM格式化,延迟和成本上去了。我们的经验是,对标准化查询(如天气)可以缓存模板,对开放式查询才用LLM格式化。
几个实战建议
第一,Agent的首次响应时间控制在1.5秒内。如果工具调用慢,先返回“正在查询”,再用异步推送结果——但简单问答助手尽量同步,体验更顺。
第二,工具列表别一开始就搞太大。我见过一个Agent挂了12个工具,意图分类准确率直接掉到70%以下。先从3-5个核心工具开始,意图边界划清楚。
第三,留好降级通道。工具调用失败、参数提取失败、LLM返回异常格式时,至少能给用户一个体面的回复。我们系统里有个“抱歉体”生成器,专门处理各种失败场景。
最后记住,简单问答助手只是Agent的起点。下周我们要做的任务规划Agent,会在今天这个架构上加两层:状态管理和规划器。但底层的数据流转模式,今天已经搭好了。
更多推荐



所有评论(0)