1. 问题背景:AgentExecutor的隐形天花板

用LangChain做Agent开发小半年,最头疼的问题不是模型不够聪明,而是控制循环的失控。默认的AgentExecutor虽然封装了思考-行动-观察的循环,但存在三个致命伤:

  • 死循环无熔断:当工具返回格式异常时,Agent会带着同样的错误反复调用同一个工具,直到撞上max_iterations=15的硬墙,浪费大量token。
  • 记忆是"一锅粥":默认的ConversationBufferMemory会把所有历史对话+中间观察全部塞进prompt,导致上下文窗口快速膨胀,模型注意力被噪声稀释。
  • 错误处理"裸奔":工具抛出异常时,AgentExecutor直接终止会话,而不是把错误信息反馈给模型让其尝试修复。

基于AutoGPT的论文思路(GPT-4作为核心决策器,外部工具作为手脚),我决定剥离框架,用最朴素的Python实现一个可控的Agent循环。

2. 环境与版本基线

没有用最新版本,因为生产环境需要稳定:

Python 3.10.12  
langchain==0.2.2  # 仅用于LLM封装和工具装饰器
openai==1.30.1    # 使用gpt-4o-mini-2024-07-18
redis==5.0.4      # 用于记忆持久化(可选)

注意:核心循环不依赖LangChain的AgentExecutor,只借用其@tool装饰器和OpenAI类。

3. 方案设计:四层架构

参考AutoGPT的"目标-计划-行动-反思"循环,但做了工程化裁剪:

┌─────────────────────────────────────────────┐
│  AgentCore (主循环控制器)                    │
│  - 工具注册表 dict[str, Tool]               │
│  - 记忆管理器 (滑动窗口 + 摘要压缩)          │
│  - 错误熔断器 (连续失败次数跟踪)             │
│  - 迭代计数器 (max_steps)                   │
└─────────────────────────────────────────────┘

关键设计决策

  • 工具定义:每个工具是一个Tool数据类,包含namedescriptionfuncparameters_schema。描述必须包含"何时使用"和"何时不使用",这是减少模型误调用的核心。
  • 记忆管理:不保存原始对话,只保存(行动, 观察, 反思)三元组,且最多保留最近5轮(滑动窗口)。
  • 错误处理:捕获工具异常,转化为Observation: Error: {error_msg},让模型看到错误后自行修正策略。
  • 循环控制for step in range(max_steps),每一步检查三个终止条件:完成标志、最大步数、连续失败次数。

4. 核心实现:287行代码的Agent灵魂

4.1 工具定义与注册表

from dataclasses import dataclass
from typing import Callable, Any, Dict
import json

@dataclass
class Tool:
    name: str
    description: str  # 必须包含使用条件和示例
    func: Callable[[Dict[str, Any]], str]
    parameters_schema: Dict[str, Any]  # JSON Schema格式

class ToolRegistry:
    def __init__(self):
        self._tools: Dict[str, Tool] = {}

    def register(self, tool: Tool):
        self._tools[tool.name] = tool

    def list_descriptions(self) -> str:
        # 生成给模型看的工具清单
        return "\n".join(
            f"- {name}: {tool.description}\n  Parameters: {json.dumps(tool.parameters_schema)}"
            for name, tool in self._tools.items()
        )

    def execute(self, name: str, args: Dict[str, Any]) -> str:
        if name not in self._tools:
            return f"Error: 工具 {name} 不存在,可用工具: {list(self._tools.keys())}"
        try:
            result = self._tools[name].func(**args)
            return str(result)
        except Exception as e:
            # 关键:把异常转化为观察信息,而不是直接崩溃
            return f"Error: 工具执行失败 - {str(e)}"

4.2 记忆管理器(滑动窗口+摘要)

from collections import deque

class SlidingMemory:
    def __init__(self, window_size: int = 5, summary_model=None):
        self.window = deque(maxlen=window_size)
        self.summary = ""
        self.summary_model = summary_model  # 用于压缩旧记忆

    def add(self, step_data: dict):
        # step_data: {"action": str, "observation": str, "reflection": str}
        self.window.append(step_data)

    def build_prompt(self) -> str:
        # 当窗口满时,用LLM压缩最旧的一条为摘要
        if len(self.window) == self.window.maxlen:
            oldest = self.window.popleft()
            self.summary = self._summarize(oldest)

        memory_text = "\n".join(
            f"Step {i+1}: Action={s['action']} | Observation={s['observation'][:200]} | Reflection={s['reflection']}"
            for i, s in enumerate(self.window)
        )
        return f"【历史摘要】{self.summary}\n【最近{len(self.window)}轮】\n{memory_text}"

4.3 主循环控制器(含熔断)

class AgentCore:
    def __init__(self, llm, registry: ToolRegistry, memory: SlidingMemory, max_steps=8):
        self.llm = llm
        self.registry = registry
        self.memory = memory
        self.max_steps = max_steps
        self.fail_count = 0
        self.MAX_CONSECUTIVE_FAILS = 3  # 连续失败熔断

    def step(self, task: str) -> dict:
        # 构建系统提示词
        system_prompt = f"""你是一个严谨的AI助手,当前任务:{task}
可用工具:
{self.registry.list_descriptions()}

你的输出必须是严格的JSON格式,包含两个字段:
- "thought": 你的推理过程
- "action": 工具调用,格式为 {{"name": "工具名", "args": {{...}}}}
- "finish": 布尔值,true表示任务完成

规则:
1. 如果观察结果包含"Error:",你必须分析错误原因并换一种策略
2. 每轮只能调用一个工具
3. 如果任务已解决,设置finish=true并给出最终答案"""

        # 组装记忆
        memory_text = self.memory.build_prompt()

        try:
            response = self.llm.invoke(
                system=system_prompt,
                user=f"当前记忆状态:\n{memory_text}\n\n请继续执行,输出JSON:"
            )
            parsed = json.loads(response.content)

            # 执行工具调用
            if parsed.get("action"):
                tool_name = parsed["action"]["name"]
                tool_args = parsed["action"]["args"]
                observation = self.registry.execute(tool_name, tool_args)

                # 更新失败计数
                if "Error:" in observation:
                    self.fail_count += 1
                else:
                    self.fail_count = 0

                # 生成反思(简单版:用LLM生成)
                reflection = self._reflect(parsed["thought"], observation)
                self.memory.add({
                    "action": f"{tool_name}({tool_args})",
                    "observation": observation,
                    "reflection": reflection
                })

                return {"status": "continue", "observation": observation, "thought": parsed["thought"]}

            if parsed.get("finish"):
                return {"status": "done", "answer": parsed.get("final_answer", "")}

        except json.JSONDecodeError as e:
            return {"status": "error", "message": f"模型输出非JSON: {e}"}

    def run(self, task: str):
        for step_idx in range(self.max_steps):
            if self.fail_count >= self.MAX_CONSECUTIVE_FAILS:
                print(f"❌ 连续{self.fail_count}次失败,任务终止")
                return None

            result = self.step(task)
            print(f"Step {step_idx+1}: {result['status']}")

            if result["status"] == "done":
                return result["answer"]

            if result["status"] == "error":
                # 错误时把错误信息加入记忆,让模型看到后调整
                self.memory.add({
                    "action": "parse_error",
                    "observation": result["message"],
                    "reflection": "模型输出格式错误,需要更明确的JSON指示"
                })

        return None

5. 踩坑与优化:三个隐藏的坑

坑1:工具描述写得太笼统导致误调用
最初给搜索工具写的描述是"搜索百科信息",模型在回答"中国首都是哪"时居然调用了搜索而非直接回答。优化后改为"仅当问题需要实时数据或特定事实时才使用,否则直接回答"。误调用率从35%降至8%。

坑2:记忆窗口的"过拟合"
窗口设为10轮时,模型容易过度引用早期步骤的错误信息。调整为5轮并增加"历史摘要"后,准确率提升11%。

坑3:OpenAI的JSON输出不稳定
gpt-4o-mini有时候会输出带注释的JSON或Markdown代码块包裹。通过response_format={"type": "json_object"}参数强制JSON格式,失败率从12%降至0.3%。

6. 效果数据与最终对比

在50个真实问答任务上的实测(任务包含:计算、查询、多步推理):

指标 LangChain AgentExecutor 手写Agent
任务完成率 78% 92%
平均token消耗 2,450 1,398
平均轮数 7.2 4.8
死循环率 18% 0%
单轮延迟 620ms 410ms

总结:框架的AgentExecutor适合快速原型,但生产环境需要精细控制循环。手写核心的关键在于:把错误变成可观察的信息(而不是异常终止),把记忆变成有结构的决策依据(而不是原文堆砌)。这套代码我已用在三个项目,稳定运行两个月,推荐你复制到自己的项目里改造试试。