一、为什么还要手写Agent?直接上LangChain不香吗?

先说结论:LangChain的AgentExecutor确实能跑,但当你需要精细控制每一步的上下文、记忆的遗忘策略、以及错误恢复的粒度时,框架的抽象反而成了束缚。

我最近在做一个内部知识库问答机器人,需要Agent能够自主调用搜索、数据库查询、代码执行三类工具。最初用LangChain的initialize_agent,发现两个痛点:
1. 记忆无差别累积:超过5轮对话后,token消耗暴涨3倍,且旧信息干扰新决策。
2. 错误处理黑盒:工具执行失败时,框架只返回一个错误字符串,Agent经常陷入“反复调用同一个失败工具”的死循环。

所以决定参考AutoGPT的“计划-执行-反思”循环,结合LangChain的工具装饰器,手写一个轻量级Agent。最终效果:在30个混合型任务上,相比默认实现,成功率提升28%,平均轮次减少2.1次。

二、环境与版本:2024年10月的技术栈

Python 3.11.5
langchain 0.2.14
langchain-openai 0.1.9
openai 1.40.3
faiss-cpu 1.8.0

注意:LangChain 0.2.x起,Tool类推荐直接用@tool装饰器,比旧的BaseTool子类写法简洁得多。模型我用的是gpt-4o-mini,每千token成本约$0.00015,跑完整个测试集花了大约$0.4。

三、方案设计:把AutoGPT的循环“瘦身”成四个模块

AutoGPT的完整实现太臃肿(光文件解析就几百行),我提取了它的核心思想,设计如下架构:

  1. 工具层:用@tool装饰器定义三个函数,每个工具附带description,这是模型选择工具的唯一依据。
  2. 记忆层:用ConversationBufferWindowMemory,但手动设置k=4,只保留最近4轮。另外维护一个长期记忆文件,存关键结论。
  3. 循环控制层:手动写while循环,每次迭代调用一次LLM,解析返回的JSON动作,执行工具,收集观察结果。
  4. 错误处理层:针对三种高频异常(JSON解析失败、工具不存在、工具超时),分别设定重试或降级策略。

核心流程伪代码:

while step  3 and last_action == current_action:
        force_switch_tool()  # 防止死循环

四、核心实现:从工具到循环的完整代码

4.1 工具定义:用@tool让模型理解参数

这里的关键是描述要带数据样例。比如搜索工具,我明确写了“输入应为关键名词,非完整句子”,否则模型会传整个问题进去,导致搜索结果噪音大。

from langchain_core.tools import tool
import requests, json, time

@tool
def search_web(query: str) -> str:
    """搜索互联网。query应为2-5个关键词,不要带疑问词。返回前3条结果的标题和URL。"""
    params = {"q": query, "count": 3}
    resp = requests.get("https://api.duckduckgo.com/", params=params, timeout=5)
    data = resp.json()
    results = [f"{item.get('Text', '')[:80]} - {item.get('FirstURL', '')}"
               for item in data.get("RelatedTopics", []) if isinstance(item, dict)]
    return "\n".join(results) if results else "无结果"

@tool
def query_database(table: str, condition: str) -> str:
    """查询SQLite数据库。table可选值: employees, orders。condition是WHERE子句。"""
    import sqlite3
    conn = sqlite3.connect("company.db", timeout=3)
    sql = f"SELECT * FROM {table} WHERE {condition} LIMIT 5"
    try:
        cur = conn.execute(sql)
        rows = cur.fetchall()
        return json.dumps(rows, ensure_ascii=False)
    except Exception as e:
        return f"SQL错误: {e}"
    finally:
        conn.close()

4.2 循环控制与错误处理:核心中的核心

这一步我踩了很多坑。最开始直接用eval()解析模型的输出,结果模型偶尔多输出一个反引号就崩。后来改用正则提取JSON块 + json.loads,失败时重试一次,并把错误信息反馈给模型。

import re, json, time
from langchain_openai import ChatOpenAI
from langchain.memory import ConversationBufferWindowMemory

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0, max_tokens=500)
memory = ConversationBufferWindowMemory(k=4, return_messages=True)

SYSTEM_PROMPT = """你是任务规划Agent。请严格输出JSON格式:
{"action": "tool_name", "action_input": {"param": "value"}}
或 {"action": "finish", "action_input": {"answer": "最终答案"}}
可用工具: {tools}"""

def extract_json(text):
    match = re.search(r'\{.*\}', text, re.DOTALL)
    if not match:
        raise ValueError("无JSON块")
    return json.loads(match.group())

def run_agent(task, max_steps=5):
    tools = {"search_web": search_web, "query_database": query_database}
    step = 0
    last_action = None
    repeat_count = 0

    while step  0:
            repeat_count += 1
            if repeat_count >= 2:
                print("[警告] 检测到重复动作,强制切换")
                action = {"action": "search_web", "action_input": {"query": "替代方案"}}
        else:
            repeat_count = 0

        # 执行工具
        if act in tools:
            try:
                result = tools[act].invoke(action["action_input"])
                memory.save_context({"input": prompt}, {"output": f"观察: {result}"})
                print(f"[步骤{step}] {act} -> {str(result)[:50]}...")
            except Exception as e:
                # 错误处理3: 工具执行异常
                err_msg = f"工具{act}执行失败: {str(e)}。请换一个工具。"
                memory.save_context({"input": prompt}, {"output": err_msg})
                print("[错误]", err_msg)
        else:
            print(f"[错误] 未知工具: {act}")

        last_action = act
        step += 1

    if step >= max_steps:
        print("[超时] 达到最大步数,返回当前记忆")
        return memory.load_memory_variables({})

run_agent("找出员工表中工资最高的名字,并在网上搜索该公司的最新动态", max_steps=4)

五、踩坑与优化:三个真实教训

坑1:记忆污染
最初用ConversationBufferMemory,结果第3轮时,模型把第1轮的工具输出当成“用户指令”来执行。解决:改用ConversationBufferWindowMemory(k=4),并手动在sava_context时加上“观察:”前缀,让模型区分事实与指令。

坑2:工具超时无响应
requests库默认无超时,导致搜索工具卡死整个Agent。优化:所有HTTP调用加timeout=5,并在工具内部捕获Timeout异常,返回“搜索超时,请改用其他工具”。

坑3:模型输出带Markdown代码块
即使提示词里写明“严格JSON”,模型偶尔还是输出json {...}。用正则提取时,re.search(r'\{.*\}', text, re.DOTALL)能处理,但要注意.*是贪婪的,如果模型输出两个大括号会误提取。优化:先去除代码块标记text.replace('```json', '').replace('```', '')

六、效果数据:从61%到89%的调优记录

在30个测试任务(10个单工具调用、15个双工具组合、5个需多步推理)上的结果:

配置版本 成功率 平均步数 总token消耗
初始版(无记忆窗口) 61% 4.8 18,200
+记忆窗口k=4 73% 4.1 12,500
+重复动作检测 82% 3.6 10,800
+错误重试与降级 89% 3.2 9,400

最明显的变化:重复动作检测减少了20%的无意义工具调用,token消耗降了22%。而错误重试机制,让那些“模型幻觉出不存在工具”的情况,能优雅降级为搜索任务。

七、总结与思考

手写Agent的核心价值不在于“不用框架”,而在于你能在循环里插入任何自定义逻辑。比如我在生产环境中加了“成本控制”——当某一步token消耗超过0.01美元时,自动降低模型温度或切换为更便宜的模型。

如果你也想做类似的事,建议按以下顺序迭代:
1. 先用LangChain跑通基础工具调用
2. 再手动接管循环控制,加入JSON解析与重试
3. 最后根据日志分析失败模式,针对性地加错误处理

最后留个问题:AutoGPT的“短期记忆”与“长期记忆”分离机制,在LangChain里有没有更优雅的实现?欢迎评论区讨论。