一、问题背景:为什么我不用现成的Agent框架

最近在做一个内部数据洞察工具,需要让大模型自主调用多个内部API(用户行为查询、订单统计、库存快照)并生成分析报告。我试了LangChain的AgentExecutorAutoGPT项目,但遇到三个痛点:

  1. LangChain 0.1.0的AgentExecutor对工具调用的错误处理过于“温柔”——工具抛异常直接返回错误字符串,大模型容易陷入死循环重试同一个错误动作。
  2. AutoGPT原版的记忆管理是简单的List存储,上下文超长后要么截断丢失早期关键信息,要么Token爆掉报错。
  3. 两者对“连续失败”没有自愈策略,比如某个工具连续报错3次,应该主动切换方案而不是继续死磕。

所以我决定手写一个轻量级Agent核心循环,只保留我认为必要的骨架:工具注册、滑动窗口记忆、三级错误处理、循环控制

二、环境与版本

  • Python 3.10.12
  • LangChain 0.1.0(仅用langchain_core.messageslangchain_openai,不用高层Agent封装)
  • OpenAI Python SDK 1.3.0
  • 模型:gpt-4-1106-preview(temperature=0.2,max_tokens=1024)
  • 工具调用方式:手动解析function_call字段,不依赖LangChain的Tool抽象(为了更透明)

三、方案设计:核心循环架构

我参照AutoGPT的“思考-行动-观察”循环,但做了三个关键改造:

while True:
    1. 组装上下文(系统提示 + 滑动窗口记忆 + 当前任务)
    2. 调用LLM,强制开启function_call
    3. 解析输出:
       - 若包含function_call → 执行工具
       - 若为普通文本且任务未完成 → 判断是否陷入死循环
    4. 执行工具时捕获异常,走三级错误处理:
       Level 1: 简单重试(间隔1s)
       Level 2: 修改参数重试(去掉可疑字段)
       Level 3: 标记工具不可用,向LLM反馈错误并建议替代方案
    5. 更新记忆(滑动窗口),若连续N次无有效进展则强制终止

设计要点:
- 记忆管理:每个记忆项带时间戳和Token数,维护一个总Token预算(设为模型上限的60%,即4800 Token)。当超出预算时,优先丢弃最早的“观察”类记忆,保留“行动”和“思考”。
- 循环控制:设定最大迭代步数10,同时监控“连续无进展步数”——若连续3步工具调用返回相同错误,则直接终止并生成失败报告。

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

我定义了一个轻量级Tool类,不依赖LangChain的BaseTool,因为后者会强绑定args_schema,不利于动态参数修正。

# agent_core.py
import json
import time
from typing import Dict, Callable, Any

class Tool:
    def __init__(self, name: str, func: Callable, description: str, parameters: Dict):
        self.name = name
        self.func = func          # 普通Python函数,接收**kwargs
        self.description = description
        self.parameters = parameters  # JSON Schema格式,用于传给LLM
        self.consecutive_failures = 0
        self.is_available = True

    def run(self, **kwargs) -> str:
        if not self.is_available:
            return f"Tool {self.name} 已被标记不可用,请选择其他工具。"
        try:
            result = self.func(**kwargs)
            self.consecutive_failures = 0  # 成功则清零
            return json.dumps(result, ensure_ascii=False)
        except Exception as e:
            self.consecutive_failures += 1
            if self.consecutive_failures >= 3:
                self.is_available = False
                return f"Tool {self.name} 连续失败3次,已自动禁用。最后错误: {str(e)}"
            return f"Tool {self.name} 执行失败 (第{self.consecutive_failures}次): {str(e)}"

# 示例工具1:查询用户行为(模拟API)
def query_user_behavior(user_id: str, days: int = 7) -> Dict:
    # 模拟延迟和可能的数据异常
    time.sleep(0.3)
    if int(user_id) % 17 == 0:
        raise ConnectionError("上游API超时")
    return {"user_id": user_id, "active_days": days, "page_views": 1560}

# 示例工具2:计算统计指标
def compute_stats(data: list) -> Dict:
    if not data:
        raise ValueError("数据为空")
    avg = sum(data) / len(data)
    return {"avg": round(avg, 2), "max": max(data), "min": min(data)}

# 工具注册表
TOOL_REGISTRY = {
    "query_user_behavior": Tool(
        name="query_user_behavior",
        func=query_user_behavior,
        description="查询指定用户在最近N天的活跃行为数据",
        parameters={
            "type": "object",
            "properties": {
                "user_id": {"type": "string"},
                "days": {"type": "integer", "default": 7}
            },
            "required": ["user_id"]
        }
    ),
    "compute_stats": Tool(
        name="compute_stats",
        func=compute_stats,
        description="计算一组数值的均值、最大值和最小值",
        parameters={
            "type": "object",
            "properties": {
                "data": {"type": "array", "items": {"type": "number"}}
            },
            "required": ["data"]
        }
    )
}

五、记忆管理与错误处理的实现细节

记忆管理我用了一个deque实现滑动窗口,每个元素是(role, content, token_estimate)。关键点在于Token估算——不用tiktoken(太慢),直接按len(content) / 2.5近似(中文场景实测误差 self.max_tokens * 0.8:
role, content, est = self.messages.popleft()
self.current_tokens -= est

def get_messages(self) -> list:
    return [{"role": r, "content": c} for r, c, _ in self.messages]

三级错误处理

def execute_tool_with_retry(tool: Tool, arguments: Dict, max_level: int = 3):
# Level 1: 原样重试,间隔1秒
for attempt in range(2):
result = tool.run(**arguments)
if "失败" not in result and "异常" not in result:
return result
time.sleep(1)

# Level 2: 去掉"可疑参数"(如把days从30改为7)重试
if max_level >= 2:
    modified_args = {k: v for k, v in arguments.items() if k not in ["days", "limit"]}
    if modified_args != arguments:
        result = tool.run(**modified_args)
        if "失败" not in result:
            return result

# Level 3: 标记不可用并返回错误诊断
if max_level >= 3:
    tool.is_available = False
    return f"工具{tool.name}经三级重试仍失败,已禁用。请LLM改用compute_stats或直接基于已知数据回答。"

主循环控制

def run_agent(task: str, max_steps: int = 10):
memory = SlidingWindowMemory(max_tokens=4800)
memory.add("system", "你是一个数据分析助手。调用工具获取数据,最后用中文生成报告。")
memory.add("user", task)

tool_descriptions = "\n".join(
    f"{name}: {tool.description},参数: {json.dumps(tool.parameters)}"
    for name, tool in TOOL_REGISTRY.items()
)

# 这里是核心循环,省略具体调用代码(需构建messages并解析function_call)
# 关键参数:consecutive_no_progress = 0, last_error_signature = ""
# 若本次工具调用返回的错误信息与上次完全相同,则consecutive_no_progress += 1
# 达到3时直接break,返回部分结果

```

六、踩坑与优化:三个血的教训

坑1:LangChain 0.1.0的function_call解析bugChatOpenAI.bind_tools()在某些工具名带下划线时会出现JSON解析错误。我直接用openai.ChatCompletion.create原生接口,手动处理message.function_call,绕开了这个坑。

坑2:记忆滑动窗口导致“任务漂移”。最开始我直接丢弃最旧消息,结果模型忘了最初的任务目标,开始答非所问。优化方案:系统提示和用户初始任务永远不被丢弃,只丢弃中间的“观察”和“思考”消息。

坑3:死循环检测不能只看步数。有一次模型反复调用query_user_behavior,因为返回的错误信息每次略有不同(时间戳不同),导致我的“连续相同错误”计数不触发。优化:对错误信息做归一化处理(提取错误类型,去掉数字和冒号后的细节),再比较签名。

优化后的效果:

指标 优化前 优化后
任务成功率(30个测试任务) 62% 91%
平均Token消耗/任务 6200 3900
平均迭代步数 8.2 5.1
死循环率 23% 2%

七、总结与思考

手写Agent核心循环最大的价值不是“不用框架”,而是理解每个流程节点的边界情况——工具注册、记忆修剪、错误分级、死循环检测,这四个模块是任何Agent框架的基石。如果你要用LangChain,建议至少在0.1.0版本下看懂AgentExecutorplan-and-execute源码,再决定是否依赖它。

我的代码全部在单个文件agent_core.py中,约320行,实际生产环境还加了日志追踪和性能埋点。如果你也在做类似的事情,建议先跑通最小循环,再逐步加记忆策略。最后提醒:不要一次性把所有工具都注册进去,模型会“选择困难”,建议控制在5个以内。

(全文完)