一、为什么我要抛开LangChain和AutoGPT手写一遍

先说结论:LangChain和AutoGPT都是好东西,但如果你没手写过一遍Agent的核心循环,用它们的时候基本是在“盲开”。

我最近在做一个内部数据查询助手,最初用LangChain 0.1.20的AgentExecutor,代码量确实少,但遇到了几个很烦的问题:工具调用失败时它默认的行为不好控制,记忆模块的裁剪策略不透明,调试的时候得翻好几层源码才能知道它到底发了什么prompt给模型。AutoGPT 0.4.7更重,配置项一大堆,跑一个简单任务动不动就几十步停不下来。

于是我决定用纯净的Python + OpenAI SDK 1.30.0,手写一个Agent。目标很明确:

  • 工具定义要显式、可校验
  • 记忆管理要可控,不能无脑塞
  • 错误处理要分层,能重试也能优雅退出
  • 循环控制必须硬性限制步数,防止死循环烧钱

跑下来效果还不错,下面把完整设计讲一遍。

二、环境与版本

我用的是比较稳的一套组合:

  • Python 3.11.6
  • openai 1.30.0(注意这是1.x的新SDK,和0.x的接口完全不同)
  • pydantic 2.7.1,用来做工具参数校验
  • tiktoken 0.6.0,估算Token数量
  • 模型:gpt-4o-mini(便宜、快,做Agent循环足够),关键任务切gpt-4o

为什么不用LangChain?不是它不好,而是我想让每一步都可见。你把下面的代码跑通之后,再回去看LangChain的AgentExecutor源码,会发现清晰很多。

三、方案设计:Agent的四个核心部件

一个能用的Agent,本质上是四个部件的组合:

  1. 工具层:定义Agent能做什么,包括函数签名、参数schema、执行逻辑
  2. 记忆层:管理对话历史,决定每次给模型看多少上下文
  3. 控制层:主循环,决定什么时候继续、什么时候停止
  4. 容错层:处理工具报错、模型返回格式错误、网络超时

我画了一个简单的数据流:

用户输入 → 构建prompt(系统提示+记忆+工具描述) → 调用LLM
    → 解析输出 → 如果是工具调用 → 执行工具 → 结果写入记忆 → 回到调用LLM
    → 如果是最终答案 → 返回用户

关键点在于:每一步都要有明确的退出条件,否则Agent会陷入无限循环。我的做法是硬限制max_steps=8,同时对工具调用失败做最多2次重试。

四、核心实现

4.1 工具定义

工具我用一个基类 + 装饰器的形式做,参数校验交给pydantic:

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

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

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

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


# 示例工具:计算器
class CalcArgs(BaseModel):
    expression: str = Field(..., description="要计算的数学表达式,例如 '2+3*4'")

def calc(expression: str) -> str:
    # 生产环境请用 ast.literal_eval 或 sympy,这里为演示简化
    allowed = set("0123456789+-*/(). ")
    if not set(expression)  int:
        total = 0
        for m in msgs:
            content = m.get("content") or ""
            total += len(ENC.encode(str(content)))
            if m.get("tool_calls"):
                total += len(ENC.encode(str(m["tool_calls"])))
        return total

    def build(self) -> list[dict]:
        # 从后往前保留,直到超过预算
        kept = []
        budget = self.max_tokens
        for m in reversed(self.messages):
            cost = self._count([m])
            if cost > budget and kept:
                break
            kept.append(m)
            budget -= cost
        kept.reverse()
        # 关键:保证 assistant(tool_calls) 和紧随其后的 tool 消息成对出现
        return [self.system] + self._fix_tool_pairs(kept)

    def _fix_tool_pairs(self, msgs: list[dict]) -> list[dict]:
        result = []
        i = 0
        while i < len(msgs):
            m = msgs[i]
            if m.get("role") == "assistant" and m.get("tool_calls"):
                # 找到后续所有 tool 消息
                j = i + 1
                tools = []
                while j < len(msgs) and msgs[j].get("role") == "tool":
                    tools.append(msgs[j])
                    j += 1
                if len(tools) == len(m["tool_calls"]):
                    result.append(m)
                    result.extend(tools)
                i = j
            else:
                result.append(m)
                i += 1
        return result

这里踩了一个大坑:OpenAI的接口要求assistant消息里的每个tool_call都必须有对应的tool消息回应,否则会直接报400。我最初的滑动窗口直接把一半的tool消息裁掉了,导致接口疯狂报错。_fix_tool_pairs就是专门修这个的。

4.3 主循环与错误处理

主循环是整个Agent的心脏,我把每一步都加了日志和超时:

import time
from openai import OpenAI, APIError, RateLimitError

client = OpenAI(api_key="sk-xxx", timeout=30.0)

SYSTEM_PROMPT = """你是一个会使用工具的助手。
规则:
1. 需要计算时调用 calculator 工具。
2. 工具返回以 [TOOL_ERROR] 或 [ARG_ERROR] 开头时,说明调用失败,请修正参数后重试,最多2次。
3. 拿到足够信息后,直接给出最终答案,不要再调用工具。"""

def call_llm_with_retry(messages, tools, max_retry=3):
    for attempt in range(max_retry):
        try:
            return client.chat.completions.create(
                model="gpt-4o-mini",
                messages=messages,
                tools=[t.to_schema() for t in tools],
                tool_choice="auto",
                temperature=0.2,
            )
        except RateLimitError:
            wait = 2 ** attempt
            print(f"[WARN] 限流,{wait}s 后重试")
            time.sleep(wait)
        except APIError as e:
            if attempt == max_retry - 1:
                raise
            time.sleep(1)
    raise RuntimeError("LLM调用失败")


def run_agent(user_input: str, tools: list[Tool], max_steps: int = 8):
    tool_map = {t.name: t for t in tools}
    mem = Memory(SYSTEM_PROMPT, max_tokens=3000)
    mem.add({"role": "user", "content": user_input})

    for step in range(max_steps):
        print(f"\n===== Step {step + 1}/{max_steps} =====")
        resp = call_llm_with_retry(mem.build(), tools)
        msg = resp.choices[0].message

        # 没有工具调用 → 最终答案
        if not msg.tool_calls:
            mem.add({"role": "assistant", "content": msg.content})
            return msg.content

        # 有工具调用 → 逐个执行
        mem.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 = tc.function.arguments
            print(f"[TOOL] {name}({args})")
            if name not in tool_map:
                result = f"[TOOL_ERROR] 未知工具: {name}"
            else:
                result = tool_map[name].run(args)
            print(f"[RESULT] {result[:200]}")
            mem.add({
                "role": "tool",
                "tool_call_id": tc.id,
                "content": result,
            })

    return "[FAILED] 达到最大步数仍未完成,请简化任务或检查工具定义。"

这段代码看着简单,但里面有三个关键设计:

一是tool_choice="auto"配合tool_calls判断。不要用LangChain那种解析文本Action: xxx的老套路,用OpenAI原生的function calling,稳定得多。

二是温度设成0.2。Agent任务不需要创造力,低温度能让输出更稳定,我实测温度从0.7降到0.2之后,工具调用参数格式错误的概率从12%降到2%左右。

三是max_steps硬限制。这个数字不是拍脑袋定的,我统计了20个常见任务,平均步数3.2,P95是6,所以8是个比较安全的阈值。

五、踩坑与优化记录

坑1:tool_calls和tool消息不配对。 上面说过了,滑动窗口裁剪时必须做配对修复,否则400错误。

坑2:模型偶尔返回不存在的工具名。 尤其是工具超过5个的时候。解决方案是在系统提示里明确列出工具名,同时在执行层做白名单校验。

坑3:工具返回内容过长。 比如查数据库返回了5000行,直接塞进记忆会瞬间爆预算。我的做法是工具内部先截断,超过800字符就总结成前N行+“共X条”。

坑4:限流。 gpt-4o-mini的TPM限制在200k,多Agent并发时很容易撞。指数退避是必须的,我用的2^n秒,最多3次。

优化点:把系统提示从200字压到80字,任务完成率没变,但每步平均Token从1450降到980,整体成本降了约31%。

六、效果数据

在3个任务上各跑10次对比(gpt-4o-mini,temperature=0.2):

任务 裸调GPT-4o完成率 手写Agent完成率 平均步数
三步复合计算 40% 100% 2.8
多轮数据查询 50% 90% 4.1
带纠错的表达式求解 35% 80% 3.5

平均完成率从42%提升到89%,Token消耗比最初版本降低31%。延迟方面,平均每步1.2秒,8步上限对应最坏情况约10秒,用户可接受。

七、总结

手写一遍Agent的价值不在于“我不用框架”,而在于你终于知道框架在替你做什么。写完这300行代码,再回头看LangChain的AgentExecutor,每一层都清清楚楚。

几点建议给想自己动手的朋友:

  1. 先跑通单工具,再加多工具,工具越多,模型选错的概率越大。
  2. 记忆的难点是配对和裁剪,不是简单地保留最近N条。
  3. 循环控制一定要有硬上限,别指望模型自己停。
  4. 错误信息要喂回模型,让Agent能自愈,这比你自己在代码里if-else管用得多。

下一步我打算在这个骨架上加一个简单的任务规划层(先让模型拆解子任务,再逐步执行),以及把工具调用结果做成结构化缓存。有兴趣的话可以在评论区聊聊你们的Agent是怎么做的。