一、为什么我放弃了LangChain的AgentExecutor

先说结论:LangChain的AgentExecutor很强大,但它的Plan-and-Execute模式在复杂数学推理任务上有个致命伤——工具返回的中间结果会无差别塞进Prompt,导致上下文爆炸。我在跑MATH-500子集时,第3轮迭代后上下文就超过12K token,单次调用成本飙到$0.08,而准确率并没有随轮次增加而提升。

AutoGPT的思路更重,它的Pinecone记忆存储对于一个单机调试脚本来说完全是过度设计。我需要的是:

  • 一个能控制最大迭代轮次的循环
  • 一份结构化的记忆(不是纯文本拼接)
  • 工具调用失败时不杀死Agent,而是把错误信息写回上下文

于是我决定用langchain==0.1.0BaseTool做工具基类,但自己写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改了ChatOpenAIinvoke返回类型,会导致我的_parse_response函数直接报AttributeError

三、方案设计:四个组件一个循环

我的设计参考了ReAct论文和AutoGPT的TaskCreation思路,但砍掉了所有“创造子任务”的复杂逻辑。核心是四个组件:

  1. 工具注册表:一个Dict[str, BaseTool],所有工具必须继承BaseTool并实现_run方法。
  2. 记忆管理器:一个循环队列,只保留最近6轮(thought, action, observation),超出就按FIFO淘汰。这比LangChain的ConversationBufferMemory更可控。
  3. 错误熔断器:工具抛出异常时,捕获并格式化错误信息,作为observation返回给模型,而不是直接终止循环。
  4. 循环控制器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,而不是固定轮次。

接下来是工具定义和循环控制器。我用langchainBaseTool,但_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结果是intfloat,但模型在下一轮可能把它当字符串用。我在_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的生态确实香。