1. 为什么放弃现成框架,要手写一个Agent?

上周用LangChain 0.2.3的initialize_agent跑了一个需要调用三个工具的复杂任务,发现两个痛点:一是AgentExecutor的循环控制像个黑盒,一旦工具返回异常格式,整个链直接崩溃;二是AutoGPT那种“无限自我反思”的模式在真实API调用中会烧掉大量token——我的测试里,一个简单搜索任务它自我对话了14轮才收敛。

所以我决定结合LangChain的工具注册机制(清晰)和AutoGPT的“任务-反思-行动”循环(有边界感),手写一个轻量级Agent。目标很明确:工具调用准确率不低于85%,单轮任务token消耗控制在1500以内

2. 环境与版本:Python 3.11 + LangChain作为工具库

这里有个反直觉的选择:我用LangChain,但只用了它的BaseToolOpenAI模型封装,完全绕开了AgentExecutorAgent类。版本锁定很重要:

langchain==0.2.3
langchain-openai==0.1.1  
openai==1.6.1
pydantic==2.5.2  # 必须用v2,v1的schema解析会出问题
python-dotenv==1.0.0

模型用gpt-4-1106-preview,temperature=0.2。为什么不用gpt-3.5?因为工具选择的JSON输出格式不够稳定,在多次测试中3.5有18%的概率输出非法JSON。

3. 方案设计:五步循环 + 三个核心抽象

我的Agent架构参考了AutoGPT的BABYAGI变体,但砍掉了自我反思prompt(太贵了)。核心循环是:

while not task_finished:
    observation = execute_tool(next_action)  # 1. 工具执行
    memory.add(observation)                  # 2. 记忆追加
    next_action = planner.plan(memory)       # 3. 决策下一步
    if should_stop(next_action): break       # 4. 退出条件
    error_count = check_errors(observation)  # 5. 错误监控

三个核心抽象:

  • 工具定义:用Pydantic v2的BaseModel做输入schema校验,代替LangChain的BaseTool重写_run方法。
  • 记忆管理:只保留最近N轮对话(滑动窗口),超长的消息转存为摘要存入长期记忆区。
  • 错误处理:每个工具执行包一层try-except,连续失败2次触发“降级策略”——切换为更简单的工具或直接终止。

4. 核心实现:工具定义与循环控制(附代码)

工具定义——这是最容易踩坑的地方。LangChain的@tool装饰器虽好,但底层依赖pydantic v1parse_obj,我升级到v2后直接报错。手写的好处是可控:

# agent_tools.py
from pydantic import BaseModel, Field
from typing import Literal, Optional
import json

class SearchInput(BaseModel):
    query: str = Field(description="搜索关键词,最长50字")
    engine: Literal["google", "bing"] = Field(default="google")

class CalculatorInput(BaseModel):
    expression: str = Field(description="四则运算表达式,如 (1+2)*3")
    precision: Optional[int] = Field(default=2, ge=0, le=10)

class Tool:
    def __init__(self, name, description, input_schema, execute_fn):
        self.name = name
        self.description = description
        self.input_schema = input_schema
        self.execute_fn = execute_fn

    def validate_and_execute(self, raw_input: dict) -> dict:
        # 关键:用pydantic v2的model_validate替代parse_obj
        validated = self.input_schema.model_validate(raw_input)
        result = self.execute_fn(**validated.model_dump())
        return {"status": "success", "data": result}

# 注册两个具体工具
def _search(query, engine):
    return f"模拟搜索结果:关于{query}的3条摘要"

search_tool = Tool(
    name="web_search",
    description="当需要实时信息时使用",
    input_schema=SearchInput,
    execute_fn=_search
)

循环控制——这里我用了while + break的显式条件,而不是LangChain的AgentFinish。重点在于连续三步无进展就强制终止

# agent_core.py
from collections import deque

class SimpleAgent:
    def __init__(self, tools, llm, max_steps=6, memory_window=5):
        self.tools = {t.name: t for t in tools}
        self.llm = llm
        self.max_steps = max_steps
        self.memory = deque(maxlen=memory_window)  # 滑动窗口记忆
        self.step = 0
        self.last_action = None

    def run(self, task: str) -> str:
        self.memory.append({"role": "user", "content": task})
        while self.step = 2:
                    return f"工具执行失败超过2次,终止。最后错误:{str(e)}"
                self.memory.append({"role": "assistant", "content": f"工具报错:{e},请调整输入重试"})
                self.step += 1
        return "达到最大步数,任务未完成"

5. 记忆管理:滑动窗口 + 关键信息摘要

这是AutoGPT启发最大的一点:无限长的对话历史是性能杀手。我做过对比测试:

记忆策略 单任务token消耗 工具调用准确率
全量历史 2400 85%
滑动窗口(5轮) 1500 87%
滑动窗口+摘要 1200 83%

滑动窗口+摘要的准确率反而下降,因为摘要会丢失上下文细节。最终我选择了纯滑动窗口,并配合一个“关键事实”提取器——每轮结束后,用正则把工具返回中的数字地点提取出来,单独存一份:

def extract_key_facts(text: str) -> list:
    # 简单提取:包含数字或“http”的句子
    import re
    facts = []
    sentences = re.split(r'[。!?]', text)
    for sen in sentences:
        if re.search(r'\d+\.?\d*|http', sen):
            facts.append(sen.strip())
    return facts[:3]  # 最多存3条

6. 踩坑与优化:三个真实教训

坑1:Pydantic v2的parse_obj已废弃
升级到pydantic 2.5后,parse_obj直接报错AttributeError。必须用model_validate。这导致我第一版代码在Python 3.10上跑不通。

坑2:LLM输出的JSON带markdown代码块标记
gpt-4-1106-preview有8%概率输出json ```json\n{...}\n```这种格式。我的解决方案:

def _clean_json(raw: str) -> str:
    if raw.startswith("```"):
        # 去除首尾的```json和```
        raw = raw.split("\n", 1)[1].rsplit("\n```", 1)[0]
    return raw

坑3:工具返回的文本过长导致LLM乱码
搜索工具返回500字结果,LLM在下一步决策时丢失了工具名。我做了截断处理:每个工具返回内容强制截断到200字符,多余的部分用...代替。这样准确率从78%提升到85%。

7. 效果数据与总结

在自建的30个任务测试集上(包含数学计算、事实查询、多步规划三类),最终数据:

  • 工具调用准确率:87%(目标85%达成)
  • 平均步数:3.2步(AutoGPT平均7.8步)
  • 单任务token消耗:约1500(比AutoGPT节省40%)
  • 失败率:13%(主要卡在需要隐含常识的多步推理上)

总结:手写Agent的核心不是“造轮子”,而是精确控制循环的退出条件和错误恢复策略。LangChain的工具抽象值得用,但它的AgentExecutor对复杂业务场景太脆弱。AutoGPT的“反思”概念要带边界——每轮反思只要求LLM输出“是否继续”和“下一步工具名”,不要求长篇解释。这个轻量级Agent只有120行核心代码,但能解决80%的工具调用场景。如果你正在被框架的“黑盒”困扰,这套实现思路可以直接抄作业。