一、为什么我不建议你直接上LangChain做Agent

先说结论:LangChain和AutoGPT是好东西,但如果你连Agent的循环是怎么转的、工具调用失败后消息队列怎么变都没搞清楚,直接用框架就是在给自己埋雷。

我最近在一个内部知识库问答项目里用LangChain的AgentExecutor,遇到几个很典型的问题:

  1. 错误处理黑盒:工具抛异常后,AgentExecutor默认行为是把异常信息塞回observation,但格式不可控,模型有时候会反复调用同一个失败工具,陷入死循环。我遇到过连续7次调用同一个搜索工具返回空结果的情况,Token烧了不说,还超时了。
  2. 记忆膨胀:ConversationBufferMemory在长对话下直接把context撑爆,换成SummaryBufferMemory又引入额外的LLM调用开销,延迟从1.2s涨到3.8s。
  3. 版本耦合:LangChain 0.2.x到0.3.x的Agent API变动不小,AgentExecutor的参数名和回调机制都改过,升级一次修半天。

所以我决定手写一个。不是为了造轮子,是为了把每个环节的控制权拿回来。下面是我最终落地的方案,Python 3.11 + openai 1.35.0,不依赖LangChain。


二、环境与版本

Python 3.11.9
openai==1.35.0          # 用新版client,支持tool_calls
pydantic==2.7.4         # 工具参数Schema校验
tiktoken==0.7.0         # Token计数,用于记忆裁剪

模型用gpt-4o-mini,temperature=0保证工具调用决策的确定性。如果你用本地模型,把client换掉即可,逻辑不变。


三、方案设计:ReAct循环的四个核心模块

整体架构就一个while循环,每轮做四件事:

用户输入 → [LLM推理] → 有tool_call? → 执行工具 → 结果回填 → 继续循环
                ↓ 无tool_call
             返回最终答案

四个必须自己控制的模块:

  1. 工具定义:用JSON Schema描述参数,注册到字典,执行时做参数校验。
  2. 记忆管理:消息列表 + 滑动窗口裁剪 + 关键信息摘要。
  3. 错误处理:工具异常分类(可重试/不可重试),重试上限,失败后给模型结构化的错误反馈。
  4. 循环控制:最大步数(我设的8)、重复调用检测(相同工具+相同参数连续出现2次就强制中断)。

四、核心实现

4.1 工具定义与注册

工具用dataclass封装,参数Schema直接给OpenAI的tools字段用。关键点是参数校验前置——模型生成的参数经常缺字段或类型不对,不校验直接执行会炸。

import json
from dataclasses import dataclass, field
from typing import Callable, Any
from pydantic import BaseModel, ValidationError

@dataclass
class Tool:
    name: str
    description: str
    args_schema: type[BaseModel]
    func: Callable[..., Any]

    def to_openai_schema(self) -> dict:
        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": self.args_schema.model_json_schema(),
            },
        }

    def run(self, raw_args: str) -> str:
        try:
            args = self.args_schema.model_validate_json(raw_args)
        except ValidationError as e:
            return f"[ARG_ERROR] 参数校验失败: {e.errors()[:2]}"
        try:
            return str(self.func(**args.model_dump()))
        except Exception as e:
            return f"[TOOL_ERROR] {type(e).__name__}: {e}"

# 示例工具
class SearchArgs(BaseModel):
    query: str
    top_k: int = 3

def fake_search(query: str, top_k: int = 3) -> str:
    # 实际项目里换成向量检索
    return f"关于'{query}'的{top_k}条结果:..."

class CalcArgs(BaseModel):
    expression: str

def calculator(expression: str) -> str:
    # 生产环境别用eval,这里仅演示
    allowed = set("0123456789+-*/(). ")
    if not set(expression)  self.max_msgs:
            self._compress()

    def _compress(self):
        old = self.messages[:-self.keep_recent]
        # 按tool_call pair成对裁剪,避免孤立tool消息
        cut = 0
        for i, m in enumerate(old):
            cut = i + 1
            if m.get("role") == "tool":
                break
        to_summarize = self.messages[:cut]
        self.messages = self.messages[cut:]
        text = "\n".join(
            f"{m['role']}: {m.get('content') or m.get('tool_calls')}"
            for m in to_summarize
        )
        new_summary = self.summarize_fn(self.summary, text)
        self.summary = new_summary

    def build(self) -> list[dict]:
        sys_content = self.system
        if self.summary:
            sys_content += f"\n\n[历史摘要]\n{self.summary}"
        return [{"role": "system", "content": sys_content}] + self.messages

这里有个坑:tool消息不能孤立存在。OpenAI API要求每个role=tool的消息前面必须有对应的assistant消息带tool_call_id。裁剪时如果从中间切断,直接报400。上面_compress里找role=="tool"作为切分点就是为了保证pair完整。


4.3 循环控制与错误处理

主循环逻辑,带三层防护:

import time
from openai import OpenAI

client = OpenAI()
MAX_STEPS = 8
MAX_RETRY_PER_TOOL = 2

def run_agent(user_input: str, memory: Memory, max_steps: int = MAX_STEPS):
    memory.add({"role": "user", "content": user_input})
    call_history = []  # (tool_name, args_str) 用于死循环检测

    for step in range(max_steps):
        resp = call_llm_with_retry(memory.build())
        msg = resp.choices[0].message

        if not msg.tool_calls:
            memory.add({"role": "assistant", "content": msg.content})
            return msg.content

        # 记录assistant的tool_call消息(必须原样回填)
        memory.add({
            "role": "assistant",
            "content": msg.content,
            "tool_calls": [tc.model_dump() for tc in msg.tool_calls],
        })

        for tc in msg.tool_calls:
            name = tc.function.name
            args_str = tc.function.arguments
            sig = (name, args_str)

            # 死循环检测:同签名连续出现2次,注入警告
            if call_history.count(sig) >= MAX_RETRY_PER_TOOL:
                result = f"[LOOP_WARN] 工具{name}已用相同参数调用{MAX_RETRY_PER_TOOL}次,请换策略或直接回答。"
            else:
                tool = TOOLS.get(name)
                result = tool.run(args_str) if tool else f"[UNKNOWN_TOOL] {name}不存在"
                call_history.append(sig)

            memory.add({
                "role": "tool",
                "tool_call_id": tc.id,
                "content": result,
            })

    # 达到最大步数,强制收尾
    memory.add({"role": "user", "content": "已达到最大推理步数,请基于已有信息给出最终答案。"})
    final = call_llm_with_retry(memory.build())
    return final.choices[0].message.content

def call_llm_with_retry(messages: list[dict], retries: int = 3):
    for i in range(retries):
        try:
            return client.chat.completions.create(
                model="gpt-4o-mini",
                messages=messages,
                tools=[t.to_openai_schema() for t in TOOLS.values()],
                tool_choice="auto",
                temperature=0,
                timeout=20,
            )
        except Exception as e:
            if i == retries - 1:
                raise
            time.sleep(2 ** i)  # 指数退避 1s, 2s

几个关键设计点:

  • tool_choice="auto":让模型自己决定调不调工具。如果你做强制流程,可以设成{"type":"function","function":{"name":"xxx"}}。
  • 死循环检测用签名计数,不是简单的步数限制。实测中模型最常见的坏行为就是同参数反复调同一工具,[LOOP_WARN]注入后80%的情况模型会切换策略。
  • 最大步数后强制收尾:不要直接抛异常,再给模型一次机会基于已有信息作答,用户体验好很多。
  • LLM调用本身的重试:网络抖动和429限流用指数退避,和工具错误分开处理。

五、踩坑与优化

坑1:tool_calls回填格式错误。新版openai库的ChatCompletionMessageToolCall是Pydantic对象,直接json.dumps会报错,必须.model_dump()。我第一次没转,报Object of type ChatCompletionMessageToolCall is not JSON serializable。

坑2:摘要函数递归调用。最初摘要用的就是Agent同一个client,结果摘要调用里又触发了工具调用逻辑(因为传了tools参数),死循环。后来摘要单独走一个不带tools的client.chat.completions.create,max_tokens=200。

坑3:temperature=0不等于确定性。gpt-4o-mini在temperature=0下仍有小概率输出不同tool_call,做单测时别断言具体参数,断言"是否调用了某工具"即可。

优化:Token消耗。加记忆裁剪前,10轮对话平均消耗4200 tokens/轮;加滑动窗口+摘要后降到2780 tokens/轮,降幅34%。摘要本身每4轮触发一次,单次约180 tokens,净收益明显。

优化:并行工具调用。模型一次返回多个tool_calls时,我最初串行执行。改成concurrent.futures.ThreadPoolExecutor并行后,双工具场景延迟从1.8s降到1.1s。但注意:记忆写入要串行,按tool_call_id顺序回填,否则消息顺序乱掉API会报错。


六、效果数据

在一个内部文档问答测试集(50条query,涉及搜索+计算组合任务)上对比:

方案 任务完成率 平均步数 平均Token/任务 P95延迟
裸LLM(无工具) 52% 1 1.2k 1.4s
手写Agent(本文) 89% 3.4 3.1k 4.2s
LangChain AgentExecutor 0.2.38 86% 3.8 4.7k 6.8s

完成率略高于LangChain版本,Token少34%,延迟低38%。主要差距来自记忆裁剪策略和死循环检测——LangChain默认配置下重复调用问题更严重。


七、总结

手写Agent的核心就四件事:工具Schema要严格校验、记忆裁剪要保pair完整、错误信息要结构化地喂回模型、循环要有步数+签名双重熔断。这四点框架都帮你做了,但做得好不好、出问题怎么调,只有自己写过一遍才清楚。

完整代码约300行,去掉注释和示例工具不到200行。如果你只是做demo,LangChain十分钟搞定;但如果要上生产、要控成本、要排查诡异行为,建议至少手写一遍理解底层,再决定用不用框架。下一步我打算把工具执行改成异步、加上工具级别的超时控制,以及把摘要换成更便宜的embedding聚类方案,有兴趣的可以关注后续。