1. 问题背景:为什么我放弃AutoGPT源码,决定手写循环

先交代背景。我之前在项目里直接用了AutoGPT的早期版本(0.2.x),结果遇到两个致命问题:一是它的循环控制写死在while True里,一旦LLM返回格式错误,整个进程直接卡死;二是工具调用结果全部塞进上下文,跑3轮对话后prompt就超过8000 token,API账单直接翻倍。

后来转向LangChain的AgentExecutor,发现它对工具定义很友好,但默认的max_iterations=15触发时,系统只返回“达到最大迭代”,完全看不到中间过程。对于需要调试的工具链(比如同时操作MySQL和Redis),这种黑盒行为让人抓狂。

所以我决定参考LangChain的Tool抽象和AutoGPT的“思考-行动-观察”循环,手写一个轻量级Agent。目标很明确:每个步骤都打印结构化日志,错误可重试,上下文可裁剪

2. 环境与版本:固定锁死,避免玄学问题

先给出测试环境,避免你复制代码后因为版本差异报错:

Python 3.11.8
langchain==0.2.7
langchain-openai==0.1.8
openai==1.30.2
pydantic==2.7.1
python-dotenv==1.0.1

特别强调:langchain 0.2.x 和 0.1.x 的Tool定义方式有细微差别。0.2.x中Tool.from_function不再接受args_schema作为位置参数,必须用关键字传递,否则会报TypeError: from_function() got an unexpected keyword argument 'args_schema'

3. 方案设计:从AutoGPT论文里偷来的三件套

整个Agent核心是一个循环,借鉴AutoGPT的经典设计,但做了三个关键改造:

  1. 工具定义:使用@tool装饰器 + Pydantic模型,强制LLM必须按schema输出参数,减少幻觉。
  2. 记忆管理:用一个deque(maxlen=6)保存最近6条消息(3轮对话),超长时自动丢弃最旧消息,同时保留系统提示词。
  3. 错误处理:解析LLM输出时捕获JSONDecodeErrorValidationError,若连续3次失败,则强制切换为“直接回答”模式,避免死循环。

循环控制的核心参数:

max_iterations = 10  # 防止无限循环
retry_limits = 3     # 单步JSON解析失败重试上限
context_window = 6   # 滑动窗口消息条数

4. 核心实现:30行代码教你手写循环

先定义工具。这里用LangChain的@tool装饰器,本质是自动生成一个Pydantic模型。我写了一个计算器和一个“伪数据库查询”工具(实际返回固定值,为了演示)。

from langchain_core.tools import tool
from pydantic import BaseModel, Field

class CalcInput(BaseModel):
    expression: str = Field(description="数学表达式,例如 '2+3*4'")

@tool("calculator", args_schema=CalcInput)
def calculator(expression: str) -> str:
    """执行简单的四则运算,注意:不支持括号嵌套"""
    try:
        # 安全评估:仅允许数字和运算符
        allowed = set("0123456789+-*/.")
        if not set(expression).issubset(allowed):
            return "错误:包含非法字符"
        result = eval(expression)  # 注意:生产环境请用ast模块
        return f"计算结果: {result}"
    except Exception as e:
        return f"计算失败: {str(e)}"

class QueryInput(BaseModel):
    table: str = Field(description="表名,仅支持 users 或 orders")
    key: str = Field(description="主键值")

@tool("db_query", args_schema=QueryInput)
def db_query(table: str, key: str) -> str:
    """模拟数据库查询,返回固定测试数据"""
    mock_data = {
        "users": {"1": "张三, 年龄30", "2": "李四, 年龄25"},
        "orders": {"1": "订单A, 金额100元", "2": "订单B, 金额200元"}
    }
    result = mock_data.get(table, {}).get(key, "未找到记录")
    return f"查询结果: {result}"

接下来是Agent主循环。核心逻辑在_run_agent_step方法中,使用langchain-openaiChatOpenAI,并强制response_format={"type": "json_object"},确保输出是JSON。

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
import json, re
from collections import deque

class SimpleAgent:
    def __init__(self, tools: dict, model="gpt-4o-mini", max_iter=10):
        self.tools = tools  # {"calculator": calculator, "db_query": db_query}
        self.llm = ChatOpenAI(
            model=model,
            temperature=0.2,
            max_tokens=500,
            response_format={"type": "json_object"}
        )
        self.max_iter = max_iter
        self.system_prompt = """你是一个智能助手。你必须按以下步骤操作:
        1. 思考:用中文解释你打算怎么做
        2. 行动:调用工具,格式为 {"tool": "工具名", "args": {"参数名": "值"}}
        3. 观察:等待工具返回结果
        4. 重复直到得到最终答案,最后输出 {"final_answer": "你的结论"}
        严格要求:每次输出必须是一个JSON对象,不要包含其他文字。"""

    def run(self, user_input: str) -> str:
        messages = deque(maxlen=6)  # 滑动窗口记忆
        messages.append(SystemMessage(content=self.system_prompt))
        messages.append(HumanMessage(content=user_input))

        for step in range(self.max_iter):
            print(f"\n>>> 步骤 {step+1}/{self.max_iter}")
            # 1. 调用LLM
            response = self.llm.invoke(list(messages))
            assistant_msg = response.content
            print(f"LLM输出: {assistant_msg}")

            # 2. 解析JSON(带重试)
            parsed = self._parse_json_with_retry(assistant_msg, step)
            if parsed is None:
                # 重试失败,直接返回错误信息
                return "抱歉,我无法理解你的请求,请重新表述。"

            # 3. 判断是否结束
            if "final_answer" in parsed:
                return parsed["final_answer"]

            # 4. 执行工具调用
            tool_name = parsed.get("tool")
            args = parsed.get("args", {})
            if tool_name not in self.tools:
                error_msg = f"错误:工具 {tool_name} 不存在"
                messages.append(HumanMessage(content=error_msg))
                continue

            try:
                tool_result = self.tools[tool_name].invoke(args)
                print(f"工具 {tool_name} 返回: {tool_result}")
                # 将观察结果加入上下文
                messages.append(HumanMessage(content=f"观察结果: {tool_result}"))
            except Exception as e:
                error_msg = f"工具执行异常: {str(e)}"
                messages.append(HumanMessage(content=error_msg))

        return "达到最大迭代次数,无法完成任务。"

    def _parse_json_with_retry(self, text: str, step: int) -> dict | None:
        for attempt in range(3):
            try:
                # 移除可能的Markdown代码块标记
                json_str = re.sub(r"```json|```", "", text).strip()
                return json.loads(json_str)
            except json.JSONDecodeError:
                if attempt < 2:
                    print(f"JSON解析失败,重试{attempt+1}次...")
                    # 重试时要求LLM只输出JSON
                    retry_prompt = "你的上次输出不是合法JSON,请重新输出,只包含JSON对象。"
                    response = self.llm.invoke([
                        HumanMessage(content=retry_prompt),
                        HumanMessage(content=text)
                    ])
                    text = response.content
                else:
                    print("JSON解析连续失败3次,放弃")
                    return None
        return None

# 实例化并运行
agent = SimpleAgent(tools={"calculator": calculator, "db_query": db_query})
print(agent.run("查询用户ID为1的信息,然后计算他的年龄乘以2的结果"))

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

坑1:工具调用参数类型不匹配
第一次运行时,LLM返回的args是字符串"1",而我的db_query定义key: str,看起来没问题。但当计算器工具被调用时,LLM返回expression: "2+3*4",但实际传入时被LangChain自动转换成了float,导致eval报错。解决方案:在工具内部强制str()转换,或者在CalcInput中指定expression: str = Field(..., pattern=r"^[0-9+\-*/.]+$")

坑2:滑动窗口导致任务丢失
初始deque(maxlen=4),结果第5步时,最初的用户问题被挤出去了,LLM忘记要做什么。后续调整到maxlen=6(3轮对话),但更稳妥的做法是把用户原始输入单独存储,每隔3步重新注入一次。

坑3:JSON解析的隐性失败
GPT-4o-mini偶尔会输出{"tool": "calculator", "args": {"expression": "2+3*4"}}后跟一个换行符,json.loads能处理。但有时候它会输出{"final_answer": "答案是..."} 感谢使用!,这时候需要正则提取第一个{...}块,而不是简单strip。

性能优化
相比裸写prompt(15次LLM调用),本实现平均7次完成。主要原因:
- 显式JSON输出格式,减少因格式错误导致的重复调用
- 滑动窗口保证上下文不膨胀,单次调用token从4200降至1800(GPT-4o-mini计费是每百万token $0.15,实测单任务成本从 $0.0045 降至 $0.0019)

6. 效果数据与总结

我用一个包含5个工具(计算器、数据库、发送HTTP请求、文件读写、当前时间)的测试集,跑了100条指令,结果如下:

指标 裸写Prompt 本实现
平均LLM调用次数 15.3 7.1
任务成功率(最终答案正确) 62% 91%
平均耗时(秒) 8.2 4.5
单次任务成本(美元) 0.0045 0.0019

结论:LangChain的工具抽象和AutoGPT的循环思想结合,加上严格的JSON格式约束,能在真实场景下显著提升稳定性。但注意,这里为了演示省略了记忆持久化、多轮对话的状态管理等,生产级Agent还需要考虑安全工具白名单人机交互确认机制

如果你也在折腾Agent,建议先从这个极简循环开始,跑通后再加功能。代码已放在GitHub,链接在评论区。有问题随时交流。