一、为什么我要手写一个Agent

先说结论:LangChain的AgentExecutor和AutoGPT都很好用,但当你需要排查“为什么它调了三次同一个工具”“为什么记忆突然丢了”“为什么循环停不下来”时,黑盒会让你抓狂。

我上个项目用LangChain的create_openai_tools_agent搭了个查数据库+查天气的Agent,上线后遇到三个典型问题:

  1. 模型偶尔返回不存在的工具名,AgentExecutor直接抛OutputParserException,整个请求500;
  2. 多轮对话超过8轮后,历史消息把上下文撑爆,token费用飙升;
  3. 某个工具超时后,Agent不知道降级,反复重试同一个调用,直到max_iterations耗尽。

于是我把Agent拆开自己写了一遍。本文就是这次拆解的完整记录。核心结论:Agent的本质就是一个while循环 + 工具注册表 + 消息历史 + 终止条件判断,没有魔法。

二、环境与版本

Python 3.11.8
langchain 0.1.16
langchain-openai 0.1.7
openai 1.30.1
pydantic 2.7.1

模型用gpt-4o-mini(便宜、function calling稳定),温度设为0(Agent场景下确定性比创造性重要)。如果你用本地模型,把ChatOpenAI换成ChatOllama即可,工具调用格式需要模型支持,qwen2和llama3.1都行。

三、方案设计:四个模块

我把Agent拆成四块,每块职责单一:

模块 职责 关键设计
工具层 定义可调用函数 + JSON Schema Pydantic模型自动生成schema,带参数校验
记忆层 管理对话历史 滑动窗口保留最近6轮 + 超长时用LLM摘要压缩
执行层 解析模型输出、调用工具、捕获异常 重试2次 + 工具级降级
循环层 控制迭代 max_steps=8 + 重复调用检测

主循环伪代码:

messages = [system_prompt] + memory
for step in range(max_steps):
    response = llm.invoke(messages, tools=schemas)
    if response has no tool_calls:
        return response.content   # 终止
    for call in response.tool_calls:
        result = safe_execute(call)   # 带重试
        messages.append(tool_message(result))
raise MaxStepsExceeded

下面逐块给代码。

四、核心实现

4.1 工具定义:Pydantic + Schema自动生成

不要手写JSON Schema,容易和函数签名不一致。用Pydantic的model_json_schema()生成,LangChain的convert_to_openai_tool可以直接转换。

from pydantic import BaseModel, Field
from langchain_core.utils.function_calling import convert_to_openai_tool
import json, time

class WeatherInput(BaseModel):
    city: str = Field(description="城市名,如 北京")
    unit: str = Field(default="celsius", description="温度单位,celsius 或 fahrenheit")

class WeatherTool:
    name = "get_weather"
    description = "查询指定城市的当前天气"
    args_schema = WeatherInput

    @staticmethod
    def run(city: str, unit: str = "celsius") -> str:
        # 模拟外部API,真实场景换成requests
        if city not in {"北京", "上海", "深圳"}:
            raise ValueError(f"不支持的城市: {city}")
        time.sleep(0.1)
        temp = {"北京": 22, "上海": 26, "深圳": 30}[city]
        if unit == "fahrenheit":
            temp = temp * 9 / 5 + 32
        return json.dumps({"city": city, "temp": temp, "unit": unit}, ensure_ascii=False)

TOOLS = [WeatherTool]
TOOL_SCHEMAS = [convert_to_openai_tool({
    "name": t.name, "description": t.description, "args_schema": t.args_schema
}) for t in TOOLS]
TOOL_MAP = {t.name: t for t in TOOLS}

这里有个细节:convert_to_openai_tool在langchain 0.1.x接受dict形式,直接传类会报错,我踩过这个坑(见第5节)。

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

简单用ConversationBufferMemory的问题是历史无限增长。我的策略:

  • 保留最近K=6轮完整消息(user + assistant + tool);
  • 超过时,把更早的消息交给LLM生成一段不超过200字的摘要,插入到system消息之后;
  • 摘要本身也做长度上限,避免二次膨胀。
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, ToolMessage

class Memory:
    def __init__(self, llm, window=6, summary_max_tokens=200):
        self.llm = llm
        self.window = window
        self.summary_max_tokens = summary_max_tokens
        self.summary = ""
        self.buffer = []   # list of BaseMessage

    def add(self, msg):
        self.buffer.append(msg)
        if len(self.buffer) > self.window * 2:
            self._compress()

    def _compress(self):
        old, self.buffer = self.buffer[:-self.window * 2], self.buffer[-self.window * 2:]
        text = "\n".join(f"{m.type}: {m.content}" for m in old if m.content)
        prompt = f"把以下对话压缩成不超过{self.summary_max_tokens}字的中文摘要,保留关键事实和已完成的工具调用结果:\n{text}"
        new_summary = self.llm.invoke([HumanMessage(content=prompt)]).content
        self.summary = (self.summary + "\n" + new_summary)[-800:]  # 硬上限

    def build(self, system_prompt):
        msgs = [SystemMessage(content=system_prompt)]
        if self.summary:
            msgs.append(SystemMessage(content=f"历史摘要:{self.summary}"))
        return msgs + self.buffer

实测:10轮对话下,窗口方案比全量历史节省约34%的prompt token(从平均4200降到2780),摘要压缩再多省15%左右,但摘要会损失细节,只在长会话开启。

4.3 执行层:重试 + 降级 + 异常兜底

工具调用失败分三类,处理方式不同:

  • 参数错误(Pydantic校验失败):不重试,把错误信息回传给模型让它修正;
  • 瞬时错误(超时、网络):重试2次,指数退避0.5s/1s;
  • 业务错误(城市不支持):直接返回错误字符串,让模型决定是否换参数。

关键是:任何异常都不要让主循环崩掉,而是转成ToolMessage喂回模型。模型看到错误通常会自己调整。

import logging
logger = logging.getLogger(__name__)

def safe_execute(tool_name: str, args: dict, max_retry: int = 2) -> str:
    tool = TOOL_MAP.get(tool_name)
    if tool is None:
        return f"错误:工具 {tool_name} 不存在,可用工具:{list(TOOL_MAP)}"

    # 参数校验
    try:
        validated = tool.args_schema(**args).model_dump()
    except Exception as e:
        return f"参数校验失败:{e}。请检查参数格式后重试。"

    last_err = None
    for attempt in range(max_retry + 1):
        try:
            return tool.run(**validated)
        except (TimeoutError, ConnectionError) as e:
            last_err = e
            if attempt  str:
    system_prompt = "你是一个助手。需要外部信息时调用工具。得到足够信息后直接回答,不要重复调用相同工具。"
    memory.add(HumanMessage(content=user_input))
    seen_calls = {}

    for step in range(max_steps):
        messages = memory.build(system_prompt)
        resp = llm.invoke(messages, tools=TOOL_SCHEMAS)

        if not resp.tool_calls:
            memory.add(resp)
            return resp.content

        memory.add(resp)
        for call in resp.tool_calls:
            key = call["name"] + hashlib.md5(
                json.dumps(call["args"], sort_keys=True).encode()).hexdigest()
            seen_calls[key] = seen_calls.get(key, 0) + 1
            if seen_calls[key] > 2:
                logger.warning(f"检测到重复调用 {call['name']},强制终止")
                return "抱歉,我陷入了重复调用,请换个问法或补充信息。"

            result = safe_execute(call["name"], call["args"])
            memory.add(ToolMessage(content=result, tool_call_id=call["id"]))

    return "已达到最大推理步数(8步),任务可能未完成。请拆分问题后重试。"

注意ToolMessage必须带tool_call_id,否则下一次invoke时OpenAI会报400,这个坑很隐蔽。

五、踩坑与优化

  1. convert_to_openai_tool传类报错:langchain 0.1.16要求传dict且key为args_schema,直接传Pydantic类会KeyError。解法如上,包一层dict。
  2. tool_call_id丢失:自己拼消息时忘了带id,模型侧400。所有ToolMessage必须和AIMessage.tool_calls的id一一对应。
  3. 温度不为0导致工具参数飘:温度0.7时,同一个问题两次调用get_weather的unit参数一次celsius一次fahrenheit,测试用例直接挂。Agent场景建议温度0~0.2。
  4. 摘要压缩把工具结果丢了:早期摘要prompt没强调保留事实,导致多轮后Agent忘记已经查过的天气,重复调用。后来在prompt里明确“保留已完成的工具调用结果”。
  5. 重试把业务错误也重试了:一开始except Exception全重试,城市不支持也重试3次,浪费2秒。后来按异常类型分流。

性能数据(GPT-4o-mini,20条测试用例,含单工具/多工具/需澄清三类):

指标 裸写(无重试无校验) 本文方案
任务完成率 78% 96%
平均步数 3.8 3.1
平均耗时 2.1s 2.3s
平均prompt token 4200 2780

完成率提升主要来自参数校验回传和重试;耗时略增是因为重试和摘要,但token降了34%,综合成本反而更低。

六、总结

手写Agent最大的价值不是省依赖,而是让你对每一步都有控制权:工具schema自己生成所以不会漂,记忆策略自己定所以不会爆,异常自己分流所以不会500,循环自己掐所以不会跑飞。

如果你要上生产,建议在本文基础上补三件事:工具调用的结构化日志(便于回放)、按工具配置超时(concurrent.futures包一层)、以及对max_steps做业务分级(查询类3步、分析类8步)。LangChain的AgentExecutor可以继续用,但至少你现在知道它里面在干什么了。