一、问题背景:AutoGPT的“重量级”让我崩溃

先说结论:AutoGPT(v0.4.0)那套自动任务分解在真实业务场景里就是灾难。它默认会创建无限循环的思考链,我配置了max_iterations=5,结果有一次它为了查个天气API,自己生成了3个中间文件、调用了2次无关工具,最后返回一句“无法确定”。更致命的是它的记忆系统——基于向量数据库的长期记忆,每次对话都去检索相似历史,但没人做记忆清理。我线上跑了3天,上下文长度从1.2K涨到15K,最终直接触达OpenAI的4096 token截断。

所以这次手写Agent,我定下四个硬性指标:
1. 工具调用必须显式声明,不允许Agent自己“发明”工具
2. 记忆只保留最近N轮,用LRU缓存淘汰,防止无限膨胀
3. 错误重试必须带退避,避免瞬时故障导致死循环
4. 循环必须有硬性终止条件,不能靠模型自觉

二、环境与版本

Python 3.11.8
langchain==0.3.1
langchain-core==0.3.15
langchain-openai==0.2.8
openai==1.55.3
redis==5.0.7  # 用于记忆外部存储,可选

注意:LangChain 0.3系列中,BaseTool接口有变化,Tool类的_run方法签名必须带**kwargs,否则会报TypeError: run() got an unexpected keyword argument 'config'。我因为这个浪费了1小时。

三、方案设计:拆解Agent的四个核心组件

我的架构图(简化版):

User Input → AgentExecutor (循环控制)
                ├─ 工具注册表 (dict[str, BaseTool])
                ├─ 记忆管理器 (LRU缓存 + 时间衰减)
                └─ 错误处理中间件 (重试装饰器)

关键决策:不用LangChain的AgentExecutor内置实现,因为它的handle_parsing_errors=True参数只会捕获JSON解析错误,对工具执行异常直接抛出——这会导致整个对话中断。我选择继承AgentExecutor重写_take_next_step方法。

四、核心实现:手写工具定义与注册

4.1 自定义工具:必须显式声明输入输出

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

class WeatherInput(BaseModel):
    city: str = Field(description="城市名称,中文")
    date: Optional[str] = Field(default="today", description="日期,格式YYYY-MM-DD")

class WeatherTool(BaseTool):
    name: str = "weather_query"
    description: str = "查询指定城市当日天气,返回温度和湿度。"
    args_schema: Type[BaseModel] = WeatherInput

    def _run(self, city: str, date: str = "today", **kwargs) -> str:
        # 这里必须接收**kwargs,否则LangChain 0.3会注入config参数导致报错
        try:
            resp = requests.get(
                f"https://api.weatherapi.com/v1/current.json",
                params={"key": "YOUR_KEY", "q": city, "lang": "zh"},
                timeout=2.0
            )
            data = resp.json()
            return f"{city}当前温度: {data['current']['temp_c']}°C, 湿度: {data['current']['humidity']}%"
        except Exception as e:
            return f"天气查询失败: {str(e)}"

# 注册表
tool_registry = {
    "weather": WeatherTool(),
    "math": MathTool(),  # 另一个工具,省略实现
}

踩坑description字段必须写清楚工具能做什么,模型会靠它做路由。我一开始写“查询天气”,结果模型经常把数学计算也发给它。改成“仅用于查询实时城市天气,不处理计算”后,路由准确率从71%升到94%。

4.2 记忆管理:带时间衰减的LRU缓存

LangChain内置的ConversationBufferWindowMemory只保留最近K轮,但没有优先级。如果K=5,那么第3轮的重要信息会被第6轮的无聊闲聊挤掉。所以我写了一个带时间衰减的双级缓存:

from collections import OrderedDict
import time, json

class DecayMemory:
    def __init__(self, max_entries=20, decay_hours=24):
        self.cache = OrderedDict()  # {key: (value, timestamp)}
        self.max_entries = max_entries
        self.decay_hours = decay_hours

    def add(self, key, value):
        # 插入时记录时间戳
        now = time.time()
        if key in self.cache:
            del self.cache[key]
        self.cache[key] = (value, now)
        # LRU淘汰:超出容量最久未用
        if len(self.cache) > self.max_entries:
            self.cache.popitem(last=False)

    def get_recent(self, k=5):
        """返回最近k条,但按时间衰减加权"""
        now = time.time()
        result = []
        for key, (val, ts) in reversed(self.cache.items()):
            age_hours = (now - ts) / 3600.0
            weight = max(0.0, 1.0 - age_hours / self.decay_hours)
            if weight > 0.3:  # 低于0.3权重直接丢弃
                result.append((key, val))
            if len(result) >= k:
                break
        return result

实际接入Agent时,每次对话结束调用memory.add(user_msg, assistant_reply),然后在构建Prompt时用get_recent(5)返回最近的上下文。测试结果:在32轮对话中,有效记忆命中率为87%,而单纯LRU只有68%。

五、错误处理与循环控制:这是最容易写崩的地方

5.1 重试机制:指数退避+最大重试次数

import random, asyncio

async def run_with_retry(func, *args, max_retries=3, base_delay=0.5):
    for attempt in range(max_retries):
        try:
            result = await func(*args)
            return result
        except Exception as e:
            if attempt == max_retries - 1:
                return f"[FATAL] 工具调用失败超过{max_retries}次: {str(e)}"
            delay = base_delay * (2 ** attempt) + random.uniform(0, 0.1)
            print(f"[RETRY] 第{attempt+1}次失败,{delay:.2f}s后重试...")
            await asyncio.sleep(delay)

关键细节:重试必须区分错误类型。网络错误(requests.Timeout)值得重试,但工具输入校验错误(ValidationError)重试100次也没用。我在装饰器里加了retryable_exceptions参数,默认只重试(ConnectionError, TimeoutError)

5.2 AgentExecutor循环终止条件

LangChain默认的循环逻辑是:只要模型输出Action就继续,直到输出Final Answer。但实际操作中模型经常陷入“调用A工具→得到结果→再调用A工具”的循环。我加了三个硬性终止:

class CustomAgentExecutor(AgentExecutor):
    def _should_continue(self, steps, iterations):
        # 1. 最大迭代次数(硬性)
        if iterations >= self.max_iterations:  # 设置为8
            return False
        # 2. 重复工具检测:连续3次调用同一工具且参数相同
        if len(steps) >= 3:
            recent = [s.tool for s in steps[-3:]]
            if len(set(recent)) == 1:
                print("[STOP] 检测到同一工具连续调用3次,终止循环")
                return False
        # 3. 结果无效检测:连续2次返回相同结果
        if len(steps) >= 2:
            last_two = [s.observation for s in steps[-2:]]
            if last_two[0] == last_two[1]:
                print("[STOP] 连续两次结果相同,认为陷入死循环")
                return False
        return True

测试数据:加入这三个条件后,平均每任务循环次数从7.2次降到3.1次,token消耗减少42%。

六、效果数据与总结

我在内部测试集(包含128个真实客服问题)上做了对比:

指标 AutoGPT 0.4 手写Agent (本方案)
平均响应时间 6.8s 2.1s
上下文平均token 11,230 4,214
工具调用准确率 62% 89%
死循环率 18% 0%

核心收获
1. 工具声明比模型聪明更重要——显式定义输入输出JSON Schema,比让模型自由发挥可靠一个量级。
2. 记忆不是越多越好——加了时间衰减后,老记忆权重下降,新记忆快速上位,效果反而提升。
3. 循环控制必须硬编码——不要指望模型自己知道“该停了”,写死规则最有效。

最后吐槽一句:LangChain 0.3的文档只有API参考,没有架构说明。我翻源码看了agent_executor.py才搞懂_take_next_step的调用链。如果你也想手写,建议直接看langchain.agents.agent.AgentExecutor源码,比看教程有用得多。

代码仓库:已脱敏,关键部分在文中都有。有问题评论区见。