1. 问题背景:为什么我不直接用AutoGPT

上个月我负责一个智能投研助手项目,最初天真地接入了AutoGPT的AgentExecutor。结果在真实业务场景中暴露了三个致命问题:第一,当工具调用链超过5层时,AutoGPT经常在同一个错误工具上反复重试(比如连续7次调用同一个不存在的API);第二,它的记忆系统是简单的列表追加,聊到第50轮时,早期的关键约束(比如“只用2023年后的数据”)被淹没,导致输出结果完全跑偏;第三,异常处理基本靠try-except包裹,一旦LLM返回格式错误的JSON,整个Agent直接崩溃。

我需要的不是一个“全自动”的黑盒,而是一个我能精确控制每一步的半自主Agent。于是决定基于LangChain的底层原语(PromptTemplate + OutputParser + Tool)手写循环控制逻辑。

2. 环境与版本锁定

先给出一份经过验证的依赖清单,避免大家踩版本坑:

python=3.11.5
langchain=0.3.7
langchain-openai=0.2.8
pydantic=2.7.4
redis=5.0.7  # 用于外部记忆存储

特别提醒:LangChain 0.3.x的langchain.agents模块已经被重构,不要再使用initialize_agent(已标记为deprecated)。我们直接从langchain_core导入基础类。

3. 方案设计:ReAct范式 + 三层记忆衰减

整体架构采用经典的ReAct(Reason + Act)循环,但做了三个关键改造:

  1. 工具注册表(ToolRegistry):将所有工具函数封装成@tool装饰器,通过字典映射名称到函数,避免LLM幻觉调用不存在的工具。
  2. 分层记忆管理:短期记忆(当前会话上下文)用滑动窗口保留最近8轮;长期记忆(项目约束)用Redis存储,并模拟艾宾浩斯遗忘曲线——超过24小时未引用的关键信息,权重衰减0.3。
  3. 三级错误恢复:当输出解析失败时,先尝试修复JSON(第一级),再重试LLM一次(第二级),最后回退到保守策略(第三级)。

4. 核心实现:手写循环控制

4.1 工具定义与注册

先看工具层。我定义了一个内部收益率(IRR)计算工具和数据库查询工具,注意用args_schema严格限制参数类型:

# tools.py
from langchain_core.tools import tool
from pydantic import BaseModel, Field
import numpy_financial as npf

class IRRInput(BaseModel):
    cash_flows: list[float] = Field(description="现金流序列,负数代表投资")
    periods: int = Field(description="期数", ge=1, le=60)

@tool("calc_irr", args_schema=IRRInput)
def calc_irr(cash_flows: list[float], periods: int) -> str:
    """计算内部收益率,返回百分比格式"""
    try:
        rate = npf.irr(cash_flows)
        return f"{rate*100:.2f}%"
    except Exception:
        return "ERROR: 现金流无法收敛,请检查输入"

class QueryInput(BaseModel):
    sql: str = Field(description="SQL查询语句")
    db_name: str = Field(description="目标数据库")

@tool("query_db", args_schema=QueryInput)
def query_db(sql: str, db_name: str) -> str:
    """执行只读SQL查询,禁止写入操作"""
    # 实际接入PostgreSQL连接池
    result = execute_read_only(sql, db_name)  
    return str(result[:200])  # 截断防止token爆炸

tool_registry = {"calc_irr": calc_irr, "query_db": query_db}

4.2 Agent主循环与记忆管理

核心循环位于agent_loop函数。这里展示记忆衰减的实现——我构造了一个MemoryTier类,管理短期和长期记忆:

# agent.py
from collections import deque
import redis, json, time

class MemoryTier:
    def __init__(self, redis_host="localhost", port=6379):
        self.short_mem = deque(maxlen=8)  # 滑动窗口
        self.r = redis.Redis(host=redis_host, port=port, db=0)

    def add_short(self, role: str, content: str):
        self.short_mem.append({"role": role, "content": content, "ts": time.time()})

    def add_long(self, key: str, value: dict, ttl_hours=24):
        # 长期记忆,带权重衰减
        value["weight"] = 1.0
        value["last_access"] = time.time()
        self.r.hset(f"agent:{key}", mapping=value)
        self.r.expire(f"agent:{key}", ttl_hours * 3600)

    def get_long(self, key: str) -> dict | None:
        data = self.r.hgetall(f"agent:{key}")
        if not data:
            return None
        # 遗忘曲线:超过12小时,权重减半
        elapsed = time.time() - float(data[b"last_access"])
        if elapsed > 12 * 3600:
            data[b"weight"] = float(data[b"weight"]) * 0.5
        return data

def agent_loop(task: str, max_iterations: int = 8):
    mem = MemoryTier()
    prompt = build_prompt()  # 包含工具描述和记忆格式化

    for i in range(max_iterations):
        # 组装当前对话上下文
        context = format_memory(mem.short_mem, mem.get_long("constraints"))
        response = llm.invoke(prompt.format(task=task, 
                                            context=context, 
                                            tools=list(tool_registry.keys())))

        # 三级错误恢复
        parsed = parse_action(response.content)
        if parsed is None:
            # 第一级:尝试修复JSON
            parsed = repair_json(response.content)
            if parsed is None:
                # 第二级:重试一次
                response = llm.invoke(prompt.format(task=task, 
                                                    context=context, 
                                                    tools=list(tool_registry.keys())))
                parsed = parse_action(response.content)
                if parsed is None:
                    # 第三级:保守回退
                    return {"result": "解析失败,建议简化任务", "iterations": i}

        if parsed["action"] == "finish":
            return {"result": parsed["args"]["output"], "iterations": i}

        # 执行工具并记录记忆
        try:
            tool_fn = tool_registry[parsed["action"]]
            result = tool_fn.invoke(parsed["args"])
            mem.add_short("assistant", f"调用{parsed['action']}: {parsed['args']}")
            mem.add_short("system", f"结果: {result}")
        except KeyError:
            mem.add_short("system", f"ERROR: 工具不存在,可用的有{list(tool_registry)}")
            continue

5. 踩坑与优化:三个血泪教训

坑1:LLM返回的action名称带引号。OpenAI的GPT-4o在输出JSON时经常把"action": "calc_irr"写成"action": "\"calc_irr\"",导致KeyError。我的修复方案是在parse_action中强制strip().strip('"\''),并加入模糊匹配(Levenshtein距离小于2时自动纠正)。

坑2:记忆膨胀导致上下文超限。当工具结果超过200字符时,GPT-4o的调用token直接翻倍。优化策略:在tool_registry中为每个工具定义result_truncate_len,默认200,但query_db截断到150,因为SQL结果通常包含大量冗余列名。

坑3:Redis序列化错误。Pydantic v2的model_dump()返回bytes,不能直接存入Redis hash。必须在add_long前用json.dumps()转换。这个坑让我排查了3小时,最后看Stack Overflow才解决。

6. 效果数据与对比

在自建的金融QA测试集(500条,涵盖IRR计算、财报查询)上,对比结果如下:

指标 AutoGPT默认 手写Agent(本文)
任务完成率 68% 91%
平均迭代轮次 14.3 6.8
平均延迟(秒) 42.5 23.1
错误工具调用次数 11次/百任务 2次/百任务

最明显的提升在错误恢复上:手写Agent遇到解析失败时,三级恢复机制让最终成功率提升了23个百分点。而AutoGPT在同样场景下,有37%的任务直接崩溃退出。

总结

如果你也需要一个可控的Agent,不要迷信AutoGPT的“全自动”。手写循环虽然代码量增加约200行,但换来了:精确的工具管控(杜绝幻觉调用)、记忆的持久化与衰减(长对话不跑偏)、以及可调试的三级错误恢复。下一步我会把记忆衰减算法换成基于语义相似度的动态权重,欢迎交流。

代码已上传至GitHub(仓库地址见评论区),运行python agent.py --task "计算项目A的IRR"即可复现。