一、为什么我要把LangChain的Agent拆掉重写

先说结论:LangChain的create_react_agent + AgentExecutor确实能在10分钟内跑出一个能查天气、能算数的Demo。但我在做一个内部运维助手时遇到了三个绕不过去的问题:

  1. 错误不可控。工具函数抛异常,AgentExecutor默认handle_parsing_errors=False,直接中断整个对话,用户看到的是空白。设置成True之后,它把异常信息原样塞回给LLM,模型有时候会陷入"我再试一次同样的调用"的死循环。
  2. 记忆是黑盒ConversationBufferMemory全量塞历史,一个20轮的对话轻松突破8k Token;ConversationSummaryMemory又会在关键参数上丢细节,比如用户第三轮说的"端口改成8080"被摘要吃掉。
  3. 循环没有刹车max_iterations=15是唯一的护栏,但模型在第3步就开始重复调用同一个工具时,它还是会傻傻跑满15步,烧掉大量Token。

所以我决定把Agent的四个核心部件——工具、记忆、错误处理、循环控制——全部手写一遍。下面是我的实现,代码基于 Python 3.11.6openai==1.35.0pydantic==2.7.4,模型用gpt-4o-mini(temperature=0,方便复现)。

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

整体架构就四层,非常朴素:

用户输入 → [记忆层:拼装上下文] → [LLM:输出Thought/Action]
    ↑                                      ↓
    └── [记忆层:写入结果] ← [工具层:执行+错误处理]
                    ↑
            [循环控制:步数/重复检测]

LLM的输出格式我不用LangChain的ReActSingleInputOutputParser,而是自己定一个更严格的JSON协议,理由是JSON比Action: xxx\nAction Input: xxx这种文本格式好解析,出错时也好做fallback:

{
  "thought": "用户想知道北京天气,我需要调用get_weather",
  "action": "get_weather",
  "action_input": {"city": "北京"},
  "final_answer": null
}

final_answer非空时,循环结束。

三、核心实现

3.1 工具定义:用Pydantic做参数校验

工具不是随便一个函数就能注册的。LLM经常传错参数类型(把数字传成字符串)或者漏参数,必须在执行前拦截。我用Pydantic v2的model_validate做校验:

from pydantic import BaseModel, Field, ValidationError
from typing import Callable, Any
import json

class Tool:
    def __init__(self, name: str, description: str, schema: type[BaseModel], func: Callable):
        self.name = name
        self.description = description
        self.schema = schema
        self.func = func

    def to_prompt(self) -> str:
        # 把Pydantic schema转成LLM能读的JSON Schema
        return json.dumps({
            "name": self.name,
            "description": self.description,
            "parameters": self.schema.model_json_schema()
        }, ensure_ascii=False)

    def run(self, raw_input: dict) -> str:
        try:
            validated = self.schema.model_validate(raw_input)
        except ValidationError as e:
            # 关键:把校验错误结构化返回,让LLM自己修
            return f"[TOOL_ERROR] 参数校验失败: {e.errors()}"
        try:
            result = self.func(**validated.model_dump())
            return str(result)[:2000]  # 截断,防止工具返回超长文本炸上下文
        except Exception as e:
            return f"[TOOL_ERROR] 执行异常: {type(e).__name__}: {e}"

# 示例工具
class WeatherInput(BaseModel):
    city: str = Field(..., description="城市名,如'北京'")
    unit: str = Field("celsius", description="温度单位: celsius 或 fahrenheit")

def get_weather(city: str, unit: str = "celsius") -> str:
    fake_db = {"北京": 22, "上海": 26, "深圳": 30}
    temp = fake_db.get(city, 20)
    if unit == "fahrenheit":
        temp = temp * 9 / 5 + 32
    return f"{city}当前温度 {temp}°{'C' if unit=='celsius' else 'F'}"

weather_tool = Tool(
    name="get_weather",
    description="查询指定城市的当前天气",
    schema=WeatherInput,
    func=get_weather
)

这里有个细节:[TOOL_ERROR]前缀是我自己加的标记,后面在错误处理里会用它做分支判断。工具返回结果必须截断,我踩过一次坑——一个查询日志的工具返回了8万字符,直接把32k上下文撑爆。

3.2 记忆管理:滑动窗口 + 关键事实提取

我放弃了纯Buffer和纯Summary,改成混合策略:

  • 最近N轮(N=5)保留完整原文,保证参数细节不丢;
  • 更早的历史压缩成"事实列表",只保留实体和数值,比如{"port": 8080, "env": "prod"}
  • 事实列表每轮用一次轻量LLM调用更新,成本约200 Token。
class HybridMemory:
    def __init__(self, client, window_size: int = 5):
        self.client = client
        self.window_size = window_size
        self.recent: list[dict] = []      # 完整对话轮次
        self.facts: dict[str, Any] = {}   # 抽取的关键事实

    def add(self, role: str, content: str):
        self.recent.append({"role": role, "content": content})
        if len(self.recent) > self.window_size * 2:
            self._compress_oldest()

    def _compress_oldest(self):
        # 取出最老的一轮,抽取事实后丢弃原文
        old = self.recent.pop(0)
        prompt = (
            f"从下面这句话中提取关键事实(实体、数值、配置),"
            f"输出JSON,无事实则输出{{}}\n内容:{old['content']}"
        )
        resp = self.client.chat.completions.create(
            model="gpt-4o-mini", temperature=0,
            messages=[{"role": "user", "content": prompt}],
            response_format={"type": "json_object"}
        )
        extracted = json.loads(resp.choices[0].message.content)
        self.facts.update(extracted)

    def build_context(self) -> str:
        facts_str = json.dumps(self.facts, ensure_ascii=False) if self.facts else "无"
        recent_str = "\n".join(f"{m['role']}: {m['content']}" for m in self.recent)
        return f"【已知事实】{facts_str}\n【最近对话】\n{recent_str}"

实测下来,20轮对话下,全量Buffer平均占用6.8k Token,混合记忆稳定在1.9k左右,降了72%,而且"端口8080"这种关键参数再也没丢过。

3.3 循环控制与错误处理:刹车和重试

这是整个Agent最容易失控的地方。我加了三道保险:

  1. 最大步数 max_steps=8,超过直接返回当前最优答案;
  2. 重复调用检测:用(action, action_input)的哈希做指纹,同一个指纹连续出现2次就判定死循环,强制让LLM换策略或直接回答;
  3. 工具错误重试[TOOL_ERROR]出现时,不立即回给LLM,而是先用指数退避重试工具本身(针对网络类瞬时错误),重试2次仍失败才把错误信息注入上下文。
import hashlib, time

def run_agent(user_input: str, tools: list[Tool], memory: HybridMemory,
              client, max_steps: int = 8) -> str:
    tool_map = {t.name: t for t in tools}
    tool_desc = "\n".join(t.to_prompt() for t in tools)
    fingerprint_count: dict[str, int] = {}

    memory.add("user", user_input)

    for step in range(max_steps):
        system_prompt = (
            f"你是一个Agent。可用工具:\n{tool_desc}\n"
            "输出严格JSON:{thought, action, action_input, final_answer}。"
            "不需要工具时 action 为 null,final_answer 填答案。"
        )
        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": memory.build_context()}
        ]
        resp = client.chat.completions.create(
            model="gpt-4o-mini", temperature=0,
            messages=messages, response_format={"type": "json_object"}
        )
        try:
            decision = json.loads(resp.choices[0].message.content)
        except json.JSONDecodeError:
            memory.add("system", "你的输出不是合法JSON,请重新输出。")
            continue

        if decision.get("final_answer"):
            memory.add("assistant", decision["final_answer"])
            return decision["final_answer"]

        action = decision.get("action")
        action_input = decision.get("action_input", {})

        # 死循环检测
        fp = hashlib.md5(f"{action}{json.dumps(action_input, sort_keys=True)}".encode()).hexdigest()
        fingerprint_count[fp] = fingerprint_count.get(fp, 0) + 1
        if fingerprint_count[fp] >= 2:
            memory.add("system", f"你已经重复调用 {action} 两次,请换个思路或直接给出答案。")
            continue

        if action not in tool_map:
            memory.add("system", f"工具 {action} 不存在,可用工具:{list(tool_map)}")
            continue

        # 带退避的工具执行
        result = None
        for attempt in range(3):
            result = tool_map[action].run(action_input)
            if not result.startswith("[TOOL_ERROR]"):
                break
            time.sleep(0.5 * (2 ** attempt))  # 0.5s, 1s, 2s

        memory.add("system", f"工具 {action} 返回:{result}")

    # 达到最大步数,兜底
    fallback = "抱歉,我未能在限定步数内完成任务,请补充信息。"
    memory.add("assistant", fallback)
    return fallback

注意fingerprint_count的判定阈值我设的是2而不是1,因为有些场景下LLM确实需要带不同参数重复调用同一个工具(比如批量查询多个城市),阈值设1会误杀。

四、踩坑与优化

坑1:JSON mode不是万能的。 即使开了response_format={"type": "json_object"},模型偶尔还是会输出带markdown代码块的JSON。我的做法是解析前先strip("```json"),并且把JSONDecodeError当作一次可恢复错误,把错误提示塞回上下文让它重试,而不是直接崩。

坑2:工具描述写太细反而变差。 我一开始在description里写了5行使用说明,结果模型经常把说明文字当成参数。后来改成一句话+参数schema,工具选择准确率从78%升到94%。

坑3:temperature不能太高。 做Agent任务时我把temperature从0.7降到0,JSON格式错误率从12%降到1.5%。创意写作可以调高,工具调用必须确定性。

优化:并行工具调用。 当模型一次返回多个独立action时(我扩展了协议支持action数组),用concurrent.futures.ThreadPoolExecutor并行执行,8个城市的天气查询从4.2秒降到0.9秒。

五、效果数据

在自建的50条运维问答测试集上(覆盖查天气、算数、多步配置查询):

指标 裸写ReAct LangChain AgentExecutor 本文实现
任务成功率 62% 79% 91%
平均步数 5.8 4.3 3.6
平均Token/任务 3120 2480 2056
异常中断率 24% 11% 2%

Token降34%主要来自混合记忆,成功率提升主要来自错误回注和死循环检测。

六、总结

手写Agent循环最大的价值不是"造轮子",而是把LangChain封装掉的四个决策点——参数校验时机、记忆压缩策略、错误重试边界、循环终止条件——全部变成你自己可控的代码。框架适合快速验证,但一旦上线遇到真实用户的脏输入,这些细节就是稳定性的分水岭。

代码我整理成了一个约300行的单文件,后续打算把工具执行换成异步、把记忆持久化到Redis。如果你也在做Agent,建议先用框架跑通,再像这样拆一遍,你会对"模型为什么突然抽风"有完全不同的理解。