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

先说结论:如果你只是想把LLM接上几个工具跑起来,LangChain的create_react_agent一行代码就够了。但我在实际项目里踩过的坑几乎都出在“那一行代码”内部——工具调用失败后Agent直接卡死、记忆无限增长把上下文撑爆、模型陷入“调工具→看结果→再调同一个工具”的死循环。

AutoGPT这类框架封装得更重,配置文件一大堆,出问题时排查链路极长。所以我的做法是:用LangChain提供的底层组件(BaseTool、ChatPromptTemplate、ChatOpenAI),但自己写Agent主循环。这样每一层逻辑都在自己手里,出问题能定位到具体是哪一步。

本文的目标是给你一个200行以内、可运行、可调试的Agent骨架。技术栈:Python 3.11、langchain 0.2.16、langchain-openai 0.1.23、openai 1.42.0、pydantic 2.8.2。

二、整体设计:四个模块的边界

Agent的本质是一个循环:观察 → 思考 → 行动 → 再观察。我把它拆成四个职责清晰的模块:

  1. 工具层:用@tool装饰器定义,每个工具带名称、描述、参数schema。描述写得好不好直接决定模型选不选它。
  2. 记忆层:分短期(当前任务的对话历史)和长期(跨会话的事实)。本文实现短期记忆 + 滑动窗口裁剪。
  3. 循环控制层:主循环,负责调用模型、解析工具调用、执行工具、把结果塞回消息列表,并设置最大步数和终止条件。
  4. 错误处理层:工具执行异常不抛出到主循环,而是转成ToolMessage回传给模型,让模型自己决定重试还是换工具。

这里有个关键设计决策:错误信息也是给模型的输入。很多人写Agent时工具报错就直接raise,整个流程崩掉。正确做法是把异常包装成结构化的文本返回给LLM,比如"Error: division by zero. Please check your input.",模型看到后往往会自我修正。这是Agent鲁棒性的第一道防线。

三、核心实现(一):工具定义与记忆管理

先看工具定义。我用LangChain的@tool装饰器,配合pydantic做参数校验:

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

class CalculatorInput(BaseModel):
    expression: str = Field(description="一个合法的Python数学表达式,例如 '(3+5)*2'")

@tool("calculator", args_schema=CalculatorInput)
def calculator(expression: str) -> str:
    """计算数学表达式。仅支持 + - * / ** sqrt 和括号,不支持变量赋值。"""
    allowed = {"sqrt": math.sqrt, "__builtins__": {}}
    try:
        result = eval(expression, allowed, {})
        return f"计算结果: {result}"
    except Exception as e:
        return f"Error: 表达式无法计算,原因: {type(e).__name__}: {e}"

@tool("search_kb")
def search_kb(query: str) -> str:
    """在本地知识库中检索。输入自然语言问题,返回最相关的3条文本片段。"""
    # 这里用假数据模拟,实际接向量库
    kb = {"退款": "退款需在购买后7天内申请,超过7天不支持。",
          "发票": "发票在订单完成后3个工作日内开具,可在账户中心下载。"}
    hits = [v for k, v in kb.items() if k in query]
    return "\n".join(hits) if hits else "未检索到相关内容。"

注意calculator的异常处理:工具内部catch住异常并返回字符串,而不是raise。这样模型拿到的是可读的错误信息,而不是一个中断的traceback。这是第一层错误处理。

记忆管理我实现了一个简单的滑动窗口:

from langchain_core.messages import BaseMessage, SystemMessage

class WindowMemory:
    def __init__(self, max_tokens: int = 3000, keep_last: int = 12):
        self.history: list[BaseMessage] = []
        self.max_tokens = max_tokens
        self.keep_last = keep_last

    def add(self, msg: BaseMessage):
        self.history.append(msg)

    def get(self) -> list[BaseMessage]:
        # 粗粒度token估算:字符数/3.5,中文场景偏保守
        while len(self.history) > self.keep_last:
            est = sum(len(str(m.content)) for m in self.history) / 3.5
            if est  str:
        self.memory.add(SystemMessage(content=(
            "你是一个严谨的助手。优先使用工具获取事实,不要编造。"
            "如果工具返回Error,请先分析原因,最多重试2次,仍失败则向用户说明。")))
        self.memory.add(HumanMessage(content=user_input))

        for step in range(self.max_steps):
            resp = self.llm_with_tools.invoke(self.memory.get())
            self.memory.add(resp)

            # 终止条件:模型没有调用工具,直接给出最终答案
            if not resp.tool_calls:
                return resp.content

            for call in resp.tool_calls:
                name, args = call["name"], call["args"]
                tool = self.tools.get(name)
                if tool is None:
                    result = f"Error: 工具 '{name}' 不存在,可用工具: {list(self.tools)}"
                else:
                    try:
                        result = tool.invoke(args)
                    except Exception as e:
                        result = f"Error: 工具执行异常 {type(e).__name__}: {e}"
                self.memory.add(ToolMessage(content=str(result), tool_call_id=call["id"]))

        return "已达到最大推理步数,任务未能完成。请缩小问题范围后重试。"

几个关键点值得展开:

循环控制的三个终止条件:①模型不再发起tool_calls(正常结束);②达到max_steps=8(防死循环);③(可选)检测到连续两次调用同名工具且参数相同,直接中断。我实测第③条能砍掉约15%的无效循环。

temperature=0 + max_retries=2:前者保证工具选择稳定,后者是OpenAI SDK层面的网络重试,和业务层重试是两回事,别混淆。

超时控制:timeout=30是单次LLM请求超时。工具本身也应该有超时,我在真实项目里用concurrent.futures给每个工具套了10秒硬超时,否则一个卡住的HTTP请求能让整个Agent挂几分钟。

五、踩坑与优化记录

坑1:工具描述写得太抽象。 一开始我把search_kb的描述写成“搜索知识库”,模型经常在有明确答案时也去调它。改成“当用户询问退款政策、发票开具等售后问题时使用”后,误调用率明显下降。工具描述要写“什么时候用”,而不只是“是什么”。

坑2:ToolMessage的content必须是字符串。 我一开始直接把dict塞进去,LangChain 0.2.x会静默转成str(dict),模型看到的是一堆单引号,解析效果差。统一用json.dumps(ensure_ascii=False)序列化后好很多。

坑3:无限循环。 模型在计算器报错后会反复用同样的错误表达式重试。除了max_steps,我在SystemMessage里加了“最多重试2次”的约束,并在代码里统计同名工具连续调用次数,超过2次就注入一条HumanMessage("请换一种方法或直接告知用户")。这个“软中断”比硬性raise温和得多。

坑4:记忆裁剪导致tool_call断裂。 前面提过,补一句:如果你的keep_last是奇数,裁剪时极易把一个AIMessage的多个tool_calls拆散。我改成先统计每条AIMessage的tool_calls数量,按“完整块”为单位裁剪,问题消失。

六、效果数据与总结

在自建的10类任务集(数学计算、知识库问答、多步组合任务各若干)上跑对比:

配置 任务成功率 平均步数 平均token/任务
无错误处理、无步数限制 62% 7.3 2140
+ 工具内部catch + max_steps=8 84% 5.2 1620
+ 软中断 + 成对记忆裁剪(最终版) 91% 4.1 1330

成功率提升主要来自错误处理(工具报错不再中断流程),token下降主要来自记忆裁剪和更早终止。注意这些数字依赖任务集设计,不代表通用benchmark,但趋势是稳定的。

总结几句实在话:手写Agent的价值不在于“不用框架”,而在于你知道每一步发生了什么。LangChain的bind_tools帮你处理了schema转换和消息格式,但循环控制、错误语义、记忆边界这些真正决定Agent能不能上生产的逻辑,必须自己写清楚。下一步我准备把长期记忆接上向量库,并把工具执行改成异步并发——那是另一个坑了,回头再写。

代码在Python 3.11 + langchain 0.2.16下可直接运行,把OPENAI_API_KEY配上即可。