1. 问题背景:AgentExecutor为何在长任务中失控?

上个月我们上线了一个基于LangChain的SQL查询Agent,用于内部数据看板。上线第三天,生产环境就出现了一个诡异现象:Agent在解析一条复杂SQL时陷入无限循环——它反复调用db_schema工具,每次返回相同的表结构,然后继续调用,直到触发OpenAI的rate limit。

排查后发现,LangChain的AgentExecutor默认使用while not finished循环,最大迭代次数设为15。当工具返回内容与LLM预期不符时,模型会陷入“重复尝试”的怪圈,而且每次循环都重新计算全部上下文,token消耗呈指数级膨胀。当时我们的单次任务成本峰值达到$0.83,而正常任务仅需$0.15。

这让我意识到:框架的通用循环控制无法适配所有业务场景。AutoGPT的架构则更激进——它把每个子任务都当作独立Agent运行,但项目已经停更,且其内存管理过于复杂。最终我们决定手写一个轻量Agent循环,只保留LangChain的工具调用与LLM封装能力。

2. 环境与版本:LangChain v0.2.3 + Python 3.10

# requirements.txt
langchain==0.2.3
langchain-openai==0.1.8
openai==1.30.1
python-dotenv==1.0.1
tiktoken==0.6.0

注意:LangChain v0.2.3中AgentExecutor位于langchain.agents,但工具装饰器改为@tool(v0.2.x已弃用Tool.from_function)。我们使用langchain_openai.ChatOpenAI,temperature固定为0,避免创造性回答干扰工具调用决策。

3. 方案设计:三层架构替代AgentExecutor

我们的设计围绕三个核心问题展开:
① 工具定义:如何让LLM正确选择并传参?
② 记忆管理:如何让Agent记住之前步骤又不撑爆上下文?
③ 循环控制:如何识别“死循环”并强制刹车?

架构图如下:

┌─────────────┐
│ LLM (GPT-4o)│←── 每次迭代只发送“工具描述+当前任务+最近3步”
└─────────────┘
       ↓ 输出JSON格式action
┌─────────────┐
│ ActionParser│→ 异常则触发错误恢复机制
└─────────────┘
       ↓
┌─────────────┐     ┌──────────────┐
│ ToolRegistry│ →  │  ToolExecutor │ 含超时熔断
└─────────────┘     └──────────────┘
       ↓ 返回observation
┌─────────────┐
│ MemoryStore │ → 滑动窗口存储(step, action, observation)
└─────────────┘

相比LangChain原生的AgentExecutor,我们砍掉了Plan-and-Execute模式(那是AutoGPT的核心),采用更直接的ReAct模式,但增加了三个关键控制点:错误重试上限(3次)、重复工具调用检测(连续5次相同工具+相同参数则终止)、动态上下文截断(基于tiktoken计算)。

4. 核心实现:300行代码的Agent循环

4.1 工具定义:用pydantic校验参数

LangChain的@tool装饰器非常适合快速定义工具,但它不强制参数校验。我们改用pydantic模型显式声明:

from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from typing import Optional

class SqlQueryInput(BaseModel):
    query: str = Field(description="SQL查询语句,必须是SELECT开头")
    db_name: str = Field(default="analytics", description="目标数据库名")

class SqlQueryTool(BaseTool):
    name = "sql_query"
    description = "执行SELECT查询并返回结果。输入必须包含query和db_name。"
    args_schema = SqlQueryInput

    def _run(self, query: str, db_name: str = "analytics") -> str:
        # 此处省略数据库连接代码
        if not query.strip().upper().startswith("SELECT"):
            return "Error: 只允许SELECT语句"
        result = execute_sql(db_name, query)
        return str(result[:500])  # 限制返回长度防止上下文爆炸

关键点:
- _run返回字符串长度硬限制500字符,避免脏数据撑爆上下文
- 工具描述中写明“输入必须包含query和db_name”,LLM才能正确生成JSON参数

4.2 记忆管理:滑动窗口+关键信息持久化

AutoGPT的向量数据库记忆体太重,我们只需要短期工作记忆。实现方式是维护一个deque,只保留最近3轮(step, action, observation):

from collections import deque
import tiktoken

class SlidingWindowMemory:
    def __init__(self, max_steps=3, max_tokens=1500):
        self.steps = deque(maxlen=max_steps)
        self.encoder = tiktoken.encoding_for_model("gpt-4o")
        self.max_tokens = max_tokens

    def add(self, step: dict):
        self.steps.append(step)
        self._truncate()

    def _truncate(self):
        # 计算总token数,超过阈值则丢弃最老步骤
        total = 0
        for s in self.steps:
            total += len(self.encoder.encode(json.dumps(s["action"]))) + \
                     len(self.encoder.encode(s["observation"]))
        while total > self.max_tokens and len(self.steps) > 1:
            removed = self.steps.popleft()
            total -= len(self.encoder.encode(json.dumps(removed)))

这里有个反直觉的设计:我们丢弃最老的步骤,而不是压缩它们。原因是Agent的任务通常具有局部性——后续决策只依赖最近一步的工具返回结果。实验数据表明,当记忆窗口从10步降到3步时,任务成功率仅下降2.3%,但token消耗减少57%。

4.3 循环控制与错误处理:双保险

核心循环采用for循环而非while,配合break条件。下面是完整实现:

import json
from typing import Optional

class AgentLoop:
    def __init__(self, llm, tools, memory, max_iterations=8):
        self.llm = llm
        self.tools = {t.name: t for t in tools}
        self.memory = memory
        self.max_iterations = max_iterations
        self.repeat_threshold = 3  # 连续相同动作阈值

    def run(self, task: str) -> str:
        history = []
        last_action = None
        repeat_count = 0

        for step in range(self.max_iterations):
            # 构建提示词
            prompt = self._build_prompt(task, history)
            response = self.llm.invoke(prompt)
            parsed = self._parse_response(response.content)

            if parsed is None:
                # 错误恢复机制:重试3次,每次提示更清晰
                for retry in range(3):
                    repair_prompt = f"上次输出格式错误,请输出JSON:{response.content}"
                    response = self.llm.invoke(repair_prompt)
                    parsed = self._parse_response(response.content)
                    if parsed: break
                if parsed is None:
                    return "Agent失败:无法解析LLM输出"

            if parsed["action"] == "final_answer":
                return parsed["answer"]

            # 重复动作检测
            if parsed["action"] == last_action and \
               parsed["action_input"] == last_params:
                repeat_count += 1
                if repeat_count >= self.repeat_threshold:
                    # 强制切换策略:注入新指令
                    history.append({
                        "action": "system_hint",
                        "observation": "重复操作,请尝试不同方法或给出最终答案"
                    })
                    repeat_count = 0
            else:
                repeat_count = 0

            # 执行工具
            tool = self.tools.get(parsed["action"])
            if not tool:
                # 动态注册工具失败处理
                return f"工具 {parsed['action']} 不存在,请检查工具列表"

            try:
                observation = tool.invoke(parsed["action_input"])
            except Exception as e:
                observation = f"Tool execution error: {str(e)[:200]}"

            # 记忆存储
            self.memory.add({
                "step": step,
                "action": parsed,
                "observation": observation
            })
            history.append({
                "action": parsed,
                "observation": observation
            })
            last_action = parsed["action"]
            last_params = parsed["action_input"]

        return f"达到最大迭代次数 {self.max_iterations},任务未完成"

三个关键细节:
1. 重复动作检测:如果LLM连续3次调用相同工具且参数相同,我们认为它陷入了死循环,此时注入一条系统提示强制改变策略。
2. 错误恢复:解析LLM输出失败时,不直接终止,而是将原始输出发回给LLM要求重写。实测这个“自我纠错”机制能将成功率从61%提升至89%。
3. 工具异常捕获:每个工具调用都包裹在try-except中,异常信息作为observation返回给LLM,让它自己决定下一步——这正是AutoGPT的思路。

5. 踩坑与优化:为什么AutoGPT模式会失败?

最初我们尝试过AutoGPT的“无限子任务”架构:每个子任务新建一个Agent实例,通过网络搜索获取新的上下文。结果是灾难性的——一次“查询用户增长趋势”的任务,触发了7层嵌套Agent,最终因为父Agent丢失子Agent返回的上下文导致失败。

核心问题:AutoGPT假设每个子任务独立可解决,但实际业务中,子任务结果必须回传父任务进行聚合。我们在LangChain中模拟该模式时发现,上下文传递需要显式的sub_agent_result变量,否则模型会认为子任务尚未完成。

最终解决方案是放弃子Agent,回归单Agent循环,但增加一个“结构化输出”约束:让LLM在每次迭代时输出thought, action, action_input三个字段。这种半结构化的输出比AutoGPT的自由文本格式更稳定,且解析成本低。

另一个优化是动态截断工具描述。当工具超过15个时,把所有工具描述一次性发送会导致token超限。我们根据任务类型预筛选工具(比如SQL任务只发送3个相关工具),实测将首轮prompt从6800 token降至2100 token,成本直接下降67%。

6. 效果数据:成本降60%,响应快43%

上线后我们对100个真实查询任务做了对比测试:

指标 LangChain AgentExecutor 手写Agent循环
平均迭代次数 4.7次 3.2次
单次任务token消耗 12,500 5,200
平均成本 $0.18 $0.07
平均延迟 8.2s 4.7s
任务成功率 82% 91%

成功率提升的关键在于错误恢复机制——当LLM输出格式错误时,我们不再直接终止,而是把错误信息作为额外提示重试。另外,重复动作检测避免了至少15%的无谓token消耗。

7. 总结:框架循环控制不适合生产环境

这次经历让我深刻理解:LangChain的AgentExecutor和AutoGPT更多是“demo级”实现。生产环境需要的是:
- 精确的迭代次数控制(每多一次LLM调用就是真金白银)
- 可观测的循环状态(我们加了print每次迭代步骤到日志)
- 业务定制的错误恢复(而不是简单重试)

如果你也遇到Agent失控问题,建议不要依赖框架的max_iterations参数,而是手写一个包含重复动作检测和动态记忆窗口的循环。代码量增加不到200行,但成本节省是立竿见影的。