从一次深夜调试说起

上周三凌晨两点,我被一个诡异的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不听话时

  1. 打开verbose模式:这是最基本的,能看到Agent的思考链(chain of thought)

  2. 检查工具描述冲突:两个工具的描述太相似时,Agent容易混淆。给每个工具加上明确的适用场景说明

  3. 观察中间步骤

result = agent_executor.invoke({"input": "查询北京天气"})
print(result["intermediate_steps"])  # 能看到工具调用序列
  1. 模拟工具调用:有时候不是Agent的问题,是工具本身返回格式异常。先单独测试工具

  2. 限制工具集:如果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,会在今天这个架构上加两层:状态管理和规划器。但底层的数据流转模式,今天已经搭好了。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐