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行,但成本节省是立竿见影的。