一、为什么我放弃了LangChain的AgentExecutor
先说结论:LangChain的AgentExecutor很强大,但它的Plan-and-Execute模式在复杂数学推理任务上有个致命伤——工具返回的中间结果会无差别塞进Prompt,导致上下文爆炸。我在跑MATH-500子集时,第3轮迭代后上下文就超过12K token,单次调用成本飙到$0.08,而准确率并没有随轮次增加而提升。
AutoGPT的思路更重,它的Pinecone记忆存储对于一个单机调试脚本来说完全是过度设计。我需要的是:
- 一个能控制最大迭代轮次的循环
- 一份结构化的记忆(不是纯文本拼接)
- 工具调用失败时不杀死Agent,而是把错误信息写回上下文
于是我决定用langchain==0.1.0的BaseTool做工具基类,但自己写Agent循环。核心调度逻辑只有120行,比LangChain的AgentExecutor少了400多行。
二、环境与版本锁定
python==3.10.12
langchain==0.1.0
langchain-openai==0.0.2.post1
openai==1.12.0
pydantic==2.6.1
注意:langchain-openai必须用0.0.2.post1,因为0.0.3改了ChatOpenAI的invoke返回类型,会导致我的_parse_response函数直接报AttributeError。
三、方案设计:四个组件一个循环
我的设计参考了ReAct论文和AutoGPT的TaskCreation思路,但砍掉了所有“创造子任务”的复杂逻辑。核心是四个组件:
- 工具注册表:一个
Dict[str, BaseTool],所有工具必须继承BaseTool并实现_run方法。 - 记忆管理器:一个循环队列,只保留最近6轮
(thought, action, observation),超出就按FIFO淘汰。这比LangChain的ConversationBufferMemory更可控。 - 错误熔断器:工具抛出异常时,捕获并格式化错误信息,作为
observation返回给模型,而不是直接终止循环。 - 循环控制器:
for _ in range(max_iterations),每轮调用LLM生成(thought, action, action_input),解析JSON并执行。
关键设计决策:不把整个历史塞给模型,而是用summarizer每3轮压缩一次历史。压缩prompt是:“总结上述推理过程的关键中间结果,保留数值和结论,丢弃推导过程,用中文输出,不超过200字”。这个压缩策略让第5轮迭代的上下文长度从12K降到3K,单次调用成本下降62%。
四、核心实现:记忆管理与循环控制
先看记忆管理器的核心代码,这是整个Agent最容易写炸的部分:
from collections import deque
from typing import Dict, List, Any
from langchain_core.tools import BaseTool
class CircularMemory:
"""循环队列记忆,只保留最近N轮交互"""
def __init__(self, capacity: int = 6):
self.capacity = capacity
self.buffer: deque = deque(maxlen=capacity)
self.summary: str = ""
def add(self, thought: str, action: str, observation: str) -> None:
# 关键:把observation截断到500字符,防止数值溢出
truncated_obs = observation[:500]
self.buffer.append({
"thought": thought[-300:], # 同样截断thought
"action": action,
"observation": truncated_obs
})
def compress(self, llm) -> str:
"""每3轮调用一次,压缩历史"""
if len(self.buffer) str:
return "\n".join(
f"Thought: {item['thought']}\nAction: {item['action']}\nObs: {item['observation']}"
for item in self.buffer
)
这里有个坑:deque(maxlen=6)会自动丢弃最老的数据,但如果第3轮刚压缩完,第4轮来了新数据,第2轮的数据就被挤掉了。所以压缩触发条件必须是len(self.buffer) % 3 == 0,而不是固定轮次。
接下来是工具定义和循环控制器。我用langchain的BaseTool,但_run方法里做了异常捕获:
class CalculatorTool(BaseTool):
name: str = "calculator"
description: str = "计算数学表达式,输入必须是四则运算表达式,如'(2+3)*4'"
def _run(self, query: str) -> str:
# 安全求值:只允许数字、运算符和括号
allowed_chars = set("0123456789+-*/(). ")
if not all(c in allowed_chars for c in query):
return "Error: 非法字符"
try:
# 使用eval但限制命名空间,这是唯一敢用eval的场景
result = eval(query, {"__builtins__": None}, {})
return str(result)
except ZeroDivisionError:
return "Error: 除零错误"
except Exception as e:
return f"Error: {str(e)[:100]}"
class AgentLoop:
def __init__(self, llm, tools: Dict[str, BaseTool], max_iterations: int = 5):
self.llm = llm
self.tools = tools
self.max_iterations = max_iterations
self.memory = CircularMemory(capacity=6)
def run(self, task: str) -> str:
prompt = f"任务: {task}\n可用工具: {list(self.tools.keys())}\n"
for i in range(self.max_iterations):
# 每3轮压缩一次记忆
if i > 0 and i % 3 == 0:
memory_str = self.memory.compress(self.llm)
else:
memory_str = self.memory._format()
full_prompt = prompt + memory_str + "\n请输出JSON格式(thought, action, action_input):"
response = self.llm.invoke(full_prompt).content
# 解析JSON,这是另一个易碎点
try:
parsed = json.loads(response.replace("```json", "").replace("```", "").strip())
thought = parsed["thought"]
action = parsed["action"]
action_input = parsed["action_input"]
except (json.JSONDecodeError, KeyError) as e:
# 模型输出不合规时,强制回退到"最终答案"
thought = "模型输出格式错误"
action = "final_answer"
action_input = f"解析失败: {e}"
if action == "final_answer":
return action_input
# 执行工具,错误不杀死循环
if action not in self.tools:
observation = f"Error: 未知工具 {action}"
else:
try:
observation = self.tools[action].run(action_input)
except Exception as e:
observation = f"Tool execution failed: {str(e)[:200]}"
self.memory.add(thought, action, observation)
print(f"[轮次{i+1}] thought={thought[:50]} action={action} obs={observation[:50]}")
return "达到最大迭代轮次,未找到答案"
五、踩坑记录:三个只有手写才遇得到的坑
坑1:LLM输出的JSON不干净。GPT-4偶尔会在JSON前加一句“好的,我来分析”,或者用json代码块包裹。我试过json.loads直接崩,最后用正则\{.*\}提取最内层JSON才稳住。这个正则我已经写进生产代码了:
import re
json_match = re.search(r'\{.*\}', response, re.DOTALL)
if json_match:
parsed = json.loads(json_match.group())
坑2:工具返回的数值格式。CalculatorTool返回eval结果是int或float,但模型在下一轮可能把它当字符串用。我在_run末尾强制加str()转换,否则拼接prompt时会报TypeError。
坑3:记忆污染。第2轮如果工具返回Error: 除零错误,模型会在第3轮引用这个错误作为“已知信息”,导致推理偏离。解决方法是:在observation前加[工具错误]前缀,并修改压缩prompt:“忽略所有标记为工具错误的记录,只保留有效数值”。
六、效果数据与调优
在MATH-500的随机100题子集上测试(预算限制,全量太贵):
| 方案 | 准确率 | 平均调用轮次 | 单任务成本 |
|---|---|---|---|
| 直接GPT-4零样本 | 33.9% | 1 | $0.012 |
| LangChain AgentExecutor | 36.2% | 3.8 | $0.045 |
| 我的手写Agent | 38.4% | 4.2 | $0.021 |
| 手写Agent + 记忆压缩 | 38.4% | 4.2 | $0.017 |
关键调优点:
max_iterations=5是最优值,4轮时36.8%准确率,6轮时成本涨到$0.028但准确率只到38.1%。- 记忆容量
capacity=6,即保留最近3轮(每轮有thought+action+obs共3条记录),再多会超过LLM的注意力窗口。 - 工具数量控制在3个以内(计算器、单位换算、日期计算),超过3个模型开始混淆工具名。
总结
手写Agent循环最大的收获不是性能提升(4.5个百分点不算惊艳),而是完全掌控了错误处理流程。LangChain的AgentExecutor遇到工具异常会直接抛AgentException终止,而我的实现可以把错误信息反馈给模型,让它自己调整策略——这在处理ZeroDivisionError时特别有用,模型会改写表达式。
如果你也在纠结是否要自研Agent框架,我的建议是:如果你的任务只需要3种以内的工具、轮次不超过5轮、且需要自定义错误恢复策略,手写比LangChain更可控。反之,如果你要接向量数据库、多模态模型、复杂路由,别重复造轮子,LangChain的生态确实香。