一、问题背景:为什么我要手写,而不是直接抄 LangChain 的 AgentExecutor

先说结论:LangChain 的 AgentExecutor 很好用,但它是黑盒,出问题时你根本不知道是哪一步在坑你。

我去年做过一个内部数据查询 Agent,用 create_openai_tools_agent + AgentExecutor,上线后遇到三个经典问题:

  1. 工具抛异常时,整个 Agent 直接崩,用户看到 500;
  2. 对话超过 15 轮后 token 爆炸,成本失控;
  3. 模型偶尔会陷入"调工具 → 看到结果 → 再调一次同样的工具"的死循环,烧钱。

排查时我翻 AgentExecutor 源码,发现它内部把 max_iterations、handle_parsing_errors、early_stopping_method 这些参数揉在一起,改一个影响一片。于是我干脆花两天手写了一遍,把工具定义、记忆管理、错误处理、循环控制这四件事拆开,每一块都自己掌控。

这篇文章就是那次重写的完整记录。AutoGPT 的思路(Thought → Action → Observation 循环)我会借鉴,但实现上不抄它的 prompt 模板,用 LangChain 的 ChatOpenAI 和 BaseTool 做底座,自己写 executor 循环。

二、环境与版本

python 3.11.9
langchain-core==0.3.28
langchain-openai==0.2.14
openai==1.59.0
pydantic==2.10.4
tiktoken==0.8.0

模型用 gpt-4o-mini,temperature=0,max_tokens=1024。为什么不用 GPT-4o?因为 Agent 循环里模型调用次数多,mini 的成本是 4o 的 1/15,实测多步任务成功率只差 6 个百分点(88% vs 94%),性价比更高。追求极限准确率再换 4o。

三、方案设计:四层结构

我把 Agent 拆成四个正交模块,主循环只负责调度:

┌─────────────────────────────────────┐
│           Agent Loop (主循环)         │
│  while step  list[dict]:
        """把注册的工具转成 OpenAI tools 格式"""
        schemas = []
        for spec in self._tools.values():
            # 从函数签名提取参数模型
            sig = inspect.signature(spec.func)
            fields = {}
            for pname, p in sig.parameters.items():
                ann = p.annotation if p.annotation is not inspect.Parameter.empty else str
                fields[pname] = (ann, Field(..., description=f"参数 {pname}"))
            ArgsModel = create_model(f"{spec.name}_args", **fields)
            schemas.append({
                "type": "function",
                "function": {
                    "name": spec.name,
                    "description": spec.description,
                    "parameters": ArgsModel.model_json_schema(),
                }
            })
        return schemas

    def execute(self, name: str, args: dict) -> str:
        spec = self._tools.get(name)
        if not spec:
            return f"[ERROR] 未知工具: {name}"
        last_err = None
        for attempt in range(spec.max_retries + 1):
            try:
                result = spec.func(**args)
                return str(result)[:2000]  # 截断,防止 observation 撑爆上下文
            except Exception as e:
                last_err = e
                time.sleep(0.3 * (attempt + 1))  # 简单退避
        return f"[ERROR] 工具 {name} 执行失败: {type(last_err).__name__}: {last_err}"

这里有个关键细节:model_json_schema() 生成的 schema 里 Pydantic 会带 title 字段,部分模型(尤其是老版本 function calling)对多余字段敏感,我实测 GPT-4o-mini 没问题,但如果换 Claude 或国产模型,建议手动精简 schema,只留 type/properties/required。

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

Agent 的记忆不是简单堆 messages,因为工具返回的 observation 动辄几百 token。我的策略是:

  • 最近 K=6 条消息(约 3 轮对话)原样保留;
  • 更早的消息,当累计 token 超过 SUMMARY_THRESHOLD=3000 时,触发一次摘要,把早期对话压成一段 system 消息;
  • 摘要本身用同一个 LLM,prompt 就一句:"用 100 字以内概括以下对话中已确认的事实和已完成的动作"。
# memory.py
import tiktoken
from langchain_openai import ChatOpenAI

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

class MemoryManager:
    def __init__(self, llm: ChatOpenAI, window: int = 6,
                 summary_threshold: int = 3000):
        self.llm = llm
        self.window = window
        self.threshold = summary_threshold
        self.summary: str = ""
        self.history: list[dict] = []  # 原始消息

    def _count(self, msgs: list[dict]) -> int:
        return sum(len(ENC.encode(str(m.get("content", "")))) for m in msgs)

    def add(self, msg: dict):
        self.history.append(msg)
        if len(self.history) > self.window * 2:
            self._maybe_compress()

    def _maybe_compress(self):
        early = self.history[:-self.window]
        if self._count(early)  list[dict]:
        msgs = [{"role": "system", "content": system_prompt}]
        if self.summary:
            msgs.append({"role": "system", "content": f"[历史摘要] {self.summary}"})
        return msgs + self.history

踩过的坑:摘要 prompt 千万别让模型自由发挥。我第一版 prompt 是"总结对话",结果模型把工具返回的原始 JSON 也塞进摘要,token 没省多少。改成"只保留事实和已完成动作"后,压缩率从 40% 提到 78%。

4.3 主循环:循环控制与死循环检测

这是整个 Agent 的心脏。AutoGPT 的经典问题是"没有终止条件就无限转",我加了三道闸:

  1. max_steps=8(硬上限);
  2. 动作指纹:连续两次 (tool_name, args_json) 完全相同,直接终止并让模型基于已有信息作答;
  3. 无进展检测:连续 3 步 observation 都是 [ERROR] 开头,终止。
# agent.py
import json, hashlib
from langchain_openai import ChatOpenAI
from tools import ToolRegistry
from memory import MemoryManager

SYSTEM_PROMPT = """你是一个工具调用型 Agent。规则:
1. 需要外部信息时调用工具,不要编造。
2. 每次只调用一个工具,拿到结果后再决定下一步。
3. 信息足够时直接给出最终答案,格式为 "FINAL: "。
4. 不要重复调用相同参数的工具。"""

class SimpleAgent:
    def __init__(self, llm: ChatOpenAI, tools: ToolRegistry,
                 max_steps: int = 8):
        self.llm = llm.bind_tools(tools.openai_schemas())
        self.tools = tools
        self.memory = MemoryManager(llm)
        self.max_steps = max_steps

    def run(self, user_input: str) -> str:
        self.memory.add({"role": "user", "content": user_input})
        last_fingerprint, err_streak, repeated = None, 0, 0

        for step in range(1, self.max_steps + 1):
            msgs = self.memory.build_messages(SYSTEM_PROMPT)
            resp = self.llm.invoke(msgs)

            # 无工具调用 → 最终答案
            if not resp.tool_calls:
                self.memory.add({"role": "assistant", "content": resp.content})
                return resp.content

            for tc in resp.tool_calls:
                name, args = tc["name"], tc["args"]
                fp = hashlib.md5(
                    f"{name}:{json.dumps(args, sort_keys=True)}".encode()
                ).hexdigest()
                if fp == last_fingerprint:
                    repeated += 1
                    if repeated >= 1:
                        print(f"[Loop] 检测到重复动作 {name},强制终止")
                        return self._force_answer(msgs)
                last_fingerprint, repeated = fp, 0

                obs = self.tools.execute(name, args)
                if obs.startswith("[ERROR]"):
                    err_streak += 1
                else:
                    err_streak = 0

                self.memory.add({"role": "assistant", "content": "",
                                 "tool_calls": [tc]})
                self.memory.add({"role": "tool", "tool_call_id": tc["id"],
                                 "content": obs})
                print(f"[Step {step}] {name}({args}) -> {obs[:80]}...")

                if err_streak >= 3:
                    print("[Loop] 连续3次工具失败,终止")
                    return self._force_answer(msgs)

        print(f"[Loop] 达到 max_steps={self.max_steps}")
        return self._force_answer(msgs)

    def _force_answer(self, msgs: list[dict]) -> str:
        resp = self.llm.invoke(msgs + [
            {"role": "user",
             "content": "基于以上已有信息,直接给出最终答案,不要调用工具。"}
        ])
        return resp.content

注意 bind_tools 返回的是新对象,别在原 llm 上反复 bind,否则工具列表会叠加。这是我踩的第一个坑,排查了半小时。

4.4 错误处理的完整链路

错误分三层,每层职责不同:

层级 位置 处理方式
工具内异常 ToolRegistry.execute 重试 + 退避,最终返回 [ERROR] 字符串
模型解析异常 主循环 tool_calls 为空但 content 非 FINAL 时,视为答案
循环异常 run() 末尾 强制 _force_answer 兜底

关键设计:工具错误不抛异常,而是作为 observation 回灌给模型。这是 AutoGPT 的核心思想——让模型自己看到错误并调整策略。我实测过,如果直接 raise,Agent 崩溃率 29%;改成回灌后,模型有 61% 的概率在下一次调用中修正参数。

五、踩坑与优化

坑 1:observation 太长撑爆上下文。 工具返回的 JSON 有 8KB,第二次调用就超 token。解决:execute 里统一 [:2000] 截断,超过的部分让模型用分页参数再查。

坑 2:模型不看工具描述乱调。 工具 description 写"查询数据库",模型会把任意问题都塞进去。改成"查询用户订单表 orders,参数 user_id 为字符串,返回最近 30 天订单列表"后,误调用率从 23% 降到 7%。description 要写成给实习生看的说明书。

坑 3:摘要触发太频繁。 一开始 window=4,每 3 轮就压缩一次,摘要调用本身耗了 0.8 秒/次。调到 window=6、threshold=3000 后,压缩频率降到每 8 轮一次,整体延迟降 18%。

优化:并行工具调用。 模型一次返回多个 tool_calls 时,用 concurrent.futures 并行执行(我的场景工具无依赖),多工具任务耗时从 4.1s 降到 2.3s。代码就是把上面 for tc in resp.tool_calls 换成 ThreadPoolExecutor,注意结果顺序要按 tc["id"] 对齐回填。

六、效果数据

测试集:50 条多步任务(查订单→算退款→发邮件这类链式操作),对比三个版本:

版本 任务成功率 平均步数 平均耗时 单任务 token
裸写(无错误处理/无循环控制) 71% 4.8 5.2s 3200
LangChain AgentExecutor 默认 86% 3.6 3.4s 2800
本文手写版 94% 3.2 2.8s 2100

失败案例分析:剩下 6% 主要是工具本身返回空数据(比如查不到订单),模型不知道该怎么回退。这块我打算下一步加"工具返回空时的 fallback 提示",而不是让模型瞎猜。

七、总结

手写这 300 行最大的收获不是"造轮子",而是搞清楚了 Agent 的每一次不确定性来自哪里:工具 schema 的模糊、observation 的膨胀、循环的无终止、错误的静默。这四个问题在 LangChain 里都被封装成了参数,封装是好事,但你不拆开看一遍,就永远在调参玄学。

如果你也在做 Agent 开发,建议至少手写一次 executor 循环,哪怕之后回去用框架,你对 max_iterations 该设 5 还是 15 会有完全不同的直觉。代码我整理成了一个单文件版本,跑 python agent.py