一、为什么我要手写一个Agent,而不是直接用LangChain的AgentExecutor

先说结论:LangChain的AgentExecutor和AutoGPT的规划循环都很好用,但如果你没亲手写过一遍,遇到问题基本只能靠猜。

我上个月接了个内部知识库问答的项目,一开始直接用create_openai_tools_agent + AgentExecutor,跑demo很顺。上线后问题来了:模型偶尔连续调用同一个工具5次不收敛,Token费用暴涨;工具抛异常时整个链路直接崩,日志里只有一句ToolException;多轮对话里早期信息被挤掉,用户问“刚才那个数乘2”直接失忆。

翻源码才发现,AgentExecutor的max_iterations默认15、early_stopping_method默认"force",这些参数不显式配就是坑。于是我干脆手写一遍,把工具定义、记忆管理、错误处理、循环控制四个模块的控制权全部拿回来。

本文代码基于以下环境,版本都是实测可跑的:

  • Python 3.11.7
  • langchain 0.2.16 / langchain-openai 0.1.23
  • openai 1.42.0
  • pydantic 2.8.2

二、方案设计:一个ReAct循环的最小骨架

AutoGPT这类系统的本质是一个ReAct(Reasoning + Acting)循环,拆开看就四件事:

  1. 工具定义:让模型知道有哪些工具、参数是什么,并能被结构化解析;
  2. 记忆管理:短期对话窗口 + 长期摘要,控制Token上限;
  3. 错误处理:工具异常要变成可观测的观察结果回灌给模型,而不是直接中断;
  4. 循环控制:最大轮次、重复调用检测、收敛判断。

我的设计是:用LangChain的ChatOpenAI做推理引擎(只借用LLM调用和bind_tools的能力),其余全部自己写。整体流程如下:

用户输入 → 组装消息(系统提示+记忆+历史) → LLM推理
  → 有tool_calls? 
      是 → 执行工具(带异常捕获) → 结果作为ToolMessage回灌 → 轮次+1 → 回到LLM
      否 → 输出最终答案,写入记忆
  → 超过max_iterations 或 检测到重复调用 → 强制收敛

关键取舍:不用AgentExecutor,只用ChatOpenAI.bind_tools。这样工具调用的schema解析还是交给官方实现(省得自己处理OpenAI的function calling格式),但循环、记忆、异常全部自己掌控。

三、核心实现:四个模块逐个落地

3.1 工具定义:用Pydantic + @tool装饰器

工具定义的关键是参数schema要严格,否则模型经常传错类型。LangChain的@tool装饰器配合Pydantic Field描述,实测比手写JSON schema的调用准确率高不少。

from langchain_core.tools import tool
from pydantic import BaseModel, Field
import ast, operator, os

class CalcInput(BaseModel):
    expression: str = Field(description="要计算的数学表达式,仅支持+ - * / 和括号,例如 '(3+5)*2'")

@tool("calculator", args_schema=CalcInput)
def calculator(expression: str) -> str:
    """计算数学表达式,返回字符串结果。"""
    allowed = {ast.Add: operator.add, ast.Sub: operator.sub,
               ast.Mult: operator.mul, ast.Div: operator.truediv}
    def _eval(node):
        if isinstance(node, ast.Num):
            return node.n
        if isinstance(node, ast.BinOp) and type(node.op) in allowed:
            return allowed[type(node.op)](_eval(node.left), _eval(node.right))
        raise ValueError(f"不支持的表达式节点: {ast.dump(node)}")
    tree = ast.parse(expression, mode="eval")
    return str(_eval(tree.body))

class WeatherInput(BaseModel):
    city: str = Field(description="城市中文名,如 '北京'")

@tool("get_weather", args_schema=WeatherInput)
def get_weather(city: str) -> str:
    """查询指定城市天气(示例为mock数据)。"""
    mock = {"北京": "晴 26℃", "上海": "多云 24℃", "深圳": "雷阵雨 29℃"}
    if city not in mock:
        raise ValueError(f"暂不支持的城市: {city}")
    return f"{city}:{mock[city]}"

class FileInput(BaseModel):
    path: str = Field(description="文件相对路径")
    content: str = Field(default="", description="写入内容;为空表示读取")

@tool("file_io", args_schema=FileInput)
def file_io(path: str, content: str = "") -> str:
    """读写工作目录下的文本文件。content为空时读取。"""
    safe_root = os.path.abspath("./agent_workspace")
    full = os.path.abspath(os.path.join(safe_root, path))
    if not full.startswith(safe_root):
        raise PermissionError("路径越界,拒绝访问")
    if content:
        os.makedirs(os.path.dirname(full), exist_ok=True)
        with open(full, "w", encoding="utf-8") as f:
            f.write(content)
        return f"已写入 {len(content)} 字符到 {path}"
    if not os.path.exists(full):
        raise FileNotFoundError(f"文件不存在: {path}")
    with open(full, encoding="utf-8") as f:
        return f.read()[:2000]

TOOLS = [calculator, get_weather, file_io]

注意file_io里的路径越界检查——这是生产环境必须的,模型真的会给你传../../etc/passwd。

3.2 记忆管理:滑动窗口 + 摘要压缩

记忆分两层:短期窗口保留最近N条消息原文,长期摘要在超限时把最老的一批消息压成一段摘要。我用的是简单粗暴的Token预算制,而不是按条数,因为一条工具返回可能顶十条对话。

from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, ToolMessage
import tiktoken

enc = tiktoken.encoding_for_model("gpt-4o-mini")

def count_tokens(messages) -> int:
    total = 0
    for m in messages:
        total += len(enc.encode(getattr(m, "content", "") or ""))
        total += 4  # 消息结构开销
    return total

class MemoryManager:
    def __init__(self, llm, max_tokens=2000, keep_recent=6):
        self.llm = llm
        self.max_tokens = max_tokens
        self.keep_recent = keep_recent
        self.summary = ""
        self.history = []  # 只存 Human/AI 文本消息,ToolMessage 不进长期记忆

    def add(self, msg):
        self.history.append(msg)
        if count_tokens(self.history) > self.max_tokens:
            self._compress()

    def _compress(self):
        old, recent = self.history[:-self.keep_recent], self.history[-self.keep_recent:]
        text = "\n".join(f"{m.type}: {m.content}" for m in old)
        prompt = f"已有摘要:{self.summary}\n新对话:\n{text}\n请合并为不超过150字的中文摘要,保留关键实体和数字。"
        self.summary = self.llm.invoke(prompt).content
        self.history = recent

    def build_context(self) -> str:
        parts = []
        if self.summary:
            parts.append(f"【历史摘要】{self.summary}")
        parts.extend(f"{m.type}: {m.content}" for m in self.history)
        return "\n".join(parts)

踩过的坑:ToolMessage不要进长期记忆。工具返回可能很长(比如文件内容),压缩进摘要既贵又容易丢关键信息,而且下一轮推理本来就会重新调工具。只留Human/AI的对话文本就够了。

3.3 循环控制 + 错误处理:主循环

这是整个Agent的心脏。三个控制点:max_iterations(我设8)、重复调用检测(连续3次同工具同参数则强制终止)、异常转观察结果。

from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, ToolMessage
import json, hashlib

SYSTEM_PROMPT = """你是一个工具型助手。规则:
1. 能用工具就用工具,不要编造数据;
2. 每次只调用必要的工具,拿到结果后立即给出最终答案;
3. 工具报错时,根据错误信息调整参数或换方案,不要重复同样的调用。"""

class SimpleAgent:
    def __init__(self, tools, max_iterations=8, model="gpt-4o-mini", temperature=0):
        self.llm = ChatOpenAI(model=model, temperature=temperature).bind_tools(tools)
        self.tool_map = {t.name: t for t in tools}
        self.memory = MemoryManager(ChatOpenAI(model=model, temperature=0))
        self.max_iterations = max_iterations

    def run(self, user_input: str) -> str:
        self.memory.add(HumanMessage(content=user_input))
        messages = [SystemMessage(content=SYSTEM_PROMPT),
                    HumanMessage(content=self.memory.build_context())]
        call_signatures = []

        for step in range(1, self.max_iterations + 1):
            try:
                ai_msg = self.llm.invoke(messages)
            except Exception as e:
                return f"[LLM调用失败] {type(e).__name__}: {e}"

            if not ai_msg.tool_calls:
                self.memory.add(AIMessage(content=ai_msg.content))
                return ai_msg.content or "(模型未返回内容)"

            messages.append(ai_msg)
            for tc in ai_msg.tool_calls:
                sig = hashlib.md5(
                    f"{tc['name']}:{json.dumps(tc['args'], sort_keys=True, ensure_ascii=False)}".encode()
                ).hexdigest()
                call_signatures.append(sig)
                if call_signatures.count(sig) >= 3:
                    return f"[提前终止] 检测到重复调用 {tc['name']},参数: {tc['args']}"

                observation = self._execute_tool(tc)
                messages.append(ToolMessage(content=observation, tool_call_id=tc["id"]))

        return f"[达到最大轮次 {self.max_iterations}] 任务未收敛,最后模型输出: {ai_msg.content}"

    def _execute_tool(self, tool_call) -> str:
        name, args = tool_call["name"], tool_call["args"]
        tool = self.tool_map.get(name)
        if tool is None:
            return f"[错误] 未知工具: {name},可用工具: {list(self.tool_map)}"
        try:
            result = tool.invoke(args)
            return str(result)[:1500]  # 截断,防止上下文爆炸
        except Exception as e:
            return f"[工具异常] {type(e).__name__}: {e}。请检查参数或换一种方式。"

几个细节值得说:

  • 异常必须转成字符串回灌,而不是raise。模型看到[工具异常] ValueError: 暂不支持的城市: 杭州会自己换城市或说明无法查询,这是ReAct循环自愈的关键;
  • tool.invoke(args)而不是直接调用函数,因为@tool包装后要经过Pydantic校验,参数类型不对会在这里报错并被捕获;
  • 结果截断1500字符,否则一次文件读取就能把上下文撑爆;
  • 重复调用检测用参数哈希,比只比较工具名准得多——模型可能反复用不同参数调同一工具,那是合理行为。

一个最小运行示例:

agent = SimpleAgent(TOOLS, max_iterations=8)
print(agent.run("帮我查一下北京和上海天气,然后算一下温差(北京26,上海24),最后把结果写到 report.txt"))

实测这条指令会走:get_weather×2 → calculator×1 → file_io×1,4轮循环收敛,总Token约1650。

四、踩坑与优化:从68%到94%的工具调用成功率

初版跑50条测试指令,工具调用成功率只有68%,主要问题和解法:

坑1:模型把计算器当万能工具。 问“北京天气如何”,它调calculator("北京天气")。原因是calculator的描述太笼统。优化:在Field(description=...)里写清楚“仅支持数学表达式”,并在系统提示加“选择工具前先确认工具用途”。成功率+11%。

坑2:temperature没设0。 默认0.7时,同一输入两次运行的工具选择会飘。设temperature=0后行为稳定,但注意gpt-4o-mini在temperature=0下仍有极小随机性(服务端原因),不能完全依赖复现。

坑3:摘要压缩把数字吃掉。 早期摘要prompt只说“压缩对话”,结果“26℃”被压成“天气不错”。改成明确要求“保留关键实体和数字”后解决。

坑4:max_iterations设15太大。 超过5轮基本就是模型在鬼打墙,白白烧Token。设8,配合重复检测,实测没有正常任务被误杀。

最终50条测试集数据:成功率94%(47/50),平均循环轮次3.2,平均单任务Token 1780,P95延迟4.6秒(gpt-4o-mini,含工具执行)。失败的3条都是需要多步文件操作的复杂指令,属于当前工具粒度的边界。

五、总结:手写一遍,再回去用框架

手写完这300行,再回头看AgentExecutor的源码,那些参数(max_iterations、early_stopping_method、handle_parsing_errors)为什么存在、默认值为什么那么设,全都通了。

我的建议是:生产环境该用框架就用框架,LangChain 0.2.x的AgentExecutor在可观测性(callback、tracing)上比手写完善得多;但至少手写一次,你才能在它出问题时知道该调哪个旋钮。AutoGPT那套规划循环看起来很唬人,剥开也就是本文这个骨架加上更复杂的记忆和任务分解。

下一步我打算把记忆换成向量检索做长期记忆,以及给工具加超时控制(现在同步调用,一个慢工具会卡住整个循环)。这两块踩完坑再写一篇。

代码已放在本地agent_workspace同级目录,直接改OPENAI_API_KEY环境变量即可跑。有问题评论区聊。