一、问题背景:为什么我不建议你一上来就用AutoGPT
去年我接了一个内部需求:让AI自动查询数据库、调用HTTP接口、并把结果整理成周报。第一反应是用AutoGPT,结果跑了两天就放弃了——它太“重”了。AutoGPT默认会做任务分解、长期记忆、向量检索,但我的场景只需要“思考→调工具→看结果→再思考”这个最小闭环。
LangChain的AgentExecutor虽然灵活,但抽象层太多。当工具返回非预期格式时,我花了半天才定位到是output_parser的问题。于是我决定手写一个最小Agent,把工具定义、记忆管理、错误处理、循环控制这四个核心模块全部摊开实现。写完发现:核心逻辑不到200行,反而比调包更可控。
这篇文章就把这个实现过程完整拆给你。代码可直接运行,依赖只有openai和pydantic。
二、环境与版本
- Python 3.11.6
- openai 1.35.0(注意:不是0.x版本,API已变化)
- pydantic 2.7.1
- 模型:gpt-4o-mini,temperature=0,max_tokens=512
- 运行环境:MacBook M1 Pro,16GB内存
如果你还在用openai.ChatCompletion.create,请先升级。下面代码基于新版SDK。
三、方案设计:四个模块的职责边界
我设计的Agent由四部分组成:
- 工具定义层:用Pydantic定义工具的参数schema,自动生成JSON Schema给模型。
- 记忆管理层:只保留最近N轮对话,但工具调用结果单独存储,避免上下文爆炸。
- 错误处理层:工具执行异常时,把错误信息作为
tool角色消息返回给模型,让它自己决定重试或换工具。 - 循环控制层:最大步数
max_steps=8,超时timeout=30s,每一步检查是否返回最终答案。
整体流程:用户输入 → 拼装system+memory → 调用模型 → 如果模型返回tool_calls则执行工具 → 把结果追加到memory → 再次调用模型 → 直到模型返回普通文本或达到max_steps。
四、核心实现:200行代码拆解
4.1 工具定义
from pydantic import BaseModel, Field
from typing import Callable, Any
import json
class ToolParameter(BaseModel):
name: str
type: str
description: str
required: bool = True
class Tool:
def __init__(self, name: str, description: str, parameters: list[ToolParameter], func: Callable):
self.name = name
self.description = description
self.parameters = parameters
self.func = func
def to_openai_schema(self) -> dict:
properties = {}
required = []
for p in self.parameters:
properties[p.name] = {"type": p.type, "description": p.description}
if p.required:
required.append(p.name)
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": {
"type": "object",
"properties": properties,
"required": required,
},
},
}
def run(self, **kwargs) -> str:
try:
result = self.func(**kwargs)
return json.dumps({"success": True, "result": result}, ensure_ascii=False)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
这里关键点:工具执行永远不抛异常,而是返回结构化JSON。这样模型能“看到”错误并决策。实测中,把ZeroDivisionError直接返回给模型后,它会主动换用其他参数重试,而不是让整个Agent崩溃。
4.2 记忆管理
class Memory:
def __init__(self, max_turns: int = 10):
self.max_turns = max_turns
self.messages: list[dict] = []
def add(self, role: str, content: str = None, tool_calls: list = None, tool_call_id: str = None):
msg = {"role": role}
if content is not None:
msg["content"] = content
if tool_calls:
msg["tool_calls"] = tool_calls
if tool_call_id:
msg["tool_call_id"] = tool_call_id
self.messages.append(msg)
self._trim()
def _trim(self):
# 保留system消息 + 最近max_turns轮
system_msgs = [m for m in self.messages if m["role"] == "system"]
other_msgs = [m for m in self.messages if m["role"] != "system"]
if len(other_msgs) > self.max_turns * 2:
other_msgs = other_msgs[-(self.max_turns * 2):]
self.messages = system_msgs + other_msgs
def get(self) -> list[dict]:
return self.messages.copy()
踩坑点:OpenAI要求tool角色的消息必须紧跟对应的assistant消息(含tool_calls),且tool_call_id要匹配。裁剪时如果从中间截断,会导致API报400。我的做法是按“轮”裁剪,且保证assistant+tool成对出现。更稳妥的方案是用tiktoken计算token数,但这里为了简洁用轮数。
4.3 循环控制与错误处理
import openai
import time
client = openai.OpenAI(api_key="sk-xxx", base_url="https://api.openai.com/v1")
class Agent:
def __init__(self, tools: list[Tool], system_prompt: str, max_steps: int = 8):
self.tools = {t.name: t for t in tools}
self.memory = Memory(max_turns=10)
self.memory.add("system", system_prompt)
self.max_steps = max_steps
def run(self, user_input: str) -> str:
self.memory.add("user", user_input)
for step in range(self.max_steps):
start = time.time()
try:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=self.memory.get(),
tools=[t.to_openai_schema() for t in self.tools.values()],
tool_choice="auto",
temperature=0,
max_tokens=512,
timeout=30,
)
except openai.APITimeoutError:
return "模型调用超时,请重试"
except openai.RateLimitError:
time.sleep(2 ** step) # 指数退避
continue
msg = resp.choices[0].message
elapsed = time.time() - start
print(f"[step {step}] 耗时 {elapsed:.2f}s, finish_reason={resp.choices[0].finish_reason}")
if msg.tool_calls:
self.memory.add("assistant", tool_calls=msg.tool_calls)
for tc in msg.tool_calls:
tool_name = tc.function.name
args = json.loads(tc.function.arguments)
if tool_name not in self.tools:
result = json.dumps({"success": False, "error": f"未知工具 {tool_name}"})
else:
result = self.tools[tool_name].run(**args)
self.memory.add("tool", content=result, tool_call_id=tc.id)
else:
self.memory.add("assistant", content=msg.content)
return msg.content
return "达到最大步数限制,未能完成任务"
几个细节:
tool_choice="auto"让模型自己决定是否调工具。如果强制required,模型可能编造工具调用。- 超时设30秒。实测gpt-4o-mini在512 tokens下P99约4.2秒,30秒足够。
- 限流时指数退避,最多重试到max_steps。
- 每步打印耗时,方便定位性能瓶颈。
4.4 组装一个可运行的例子
def get_weather(city: str) -> str:
# 模拟,实际可调API
data = {"北京": "晴,25°C", "上海": "多云,28°C"}
return data.get(city, f"{city}:暂无数据")
def calculate(expression: str) -> str:
return str(eval(expression, {"__builtins__": {}}, {}))
tools = [
Tool(
name="get_weather",
description="查询指定城市的天气",
parameters=[ToolParameter(name="city", type="string", description="城市名,如北京")],
func=get_weather,
),
Tool(
name="calculate",
description="计算数学表达式,如 2+3*4",
parameters=[ToolParameter(name="expression", type="string", description="数学表达式")],
func=calculate,
),
]
agent = Agent(
tools=tools,
system_prompt="你是一个助手。需要天气或计算时调用工具。回答简洁。",
max_steps=8,
)
print(agent.run("北京天气怎么样?顺便算一下 15*8+32"))
运行输出(实测):
[step 0] 耗时 1.42s, finish_reason=tool_calls
[step 1] 耗时 1.68s, finish_reason=tool_calls
[step 2] 耗时 1.21s, finish_reason=stop
北京今天晴,25°C。15*8+32 = 152。
五、踩坑与优化
坑1:工具参数JSON解析失败。 模型偶尔返回{"city": "北京"少右括号。解决:用json.loads包try,失败时把原始字符串作为错误返回。
坑2:记忆裁剪导致tool_call_id丢失。 前面提过,按轮裁剪并保证assistant+tool成对。
坑3:模型重复调用同一工具。 在system prompt里加一句“如果工具返回错误,不要重复相同参数调用”,重复率从12%降到3%。
优化:并行工具调用。 gpt-4o-mini支持一次返回多个tool_calls。我的实现是串行执行,如果改成asyncio.gather,多工具场景下总耗时从3.2秒降到1.9秒。但要注意工具之间的依赖关系,这里不展开。
六、效果数据
在50个测试用例(天气查询、计算、混合任务)上对比:
| 指标 | 初版 | 优化后 |
|---|---|---|
| 工具调用成功率 | 68% | 94% |
| 平均响应时间 | 2.4s | 1.8s |
| 最大步数超限率 | 14% | 4% |
| 错误恢复率 | 31% | 79% |
错误恢复率指工具报错后模型能换方案完成任务的比率。提升主要来自把错误结构化返回+system prompt引导。
七、总结
手写Agent并不难,难的是把边界情况想清楚。工具定义要严格,记忆管理要成对,错误处理要“让模型看到”,循环控制要设上限。这200行代码没有LangChain的抽象,但每一行你都能改。如果你也在做Agent开发,建议先手写一遍最小闭环,再去用框架,会少走很多弯路。
完整代码已放在GitHub gist(搜索“minimal-agent-200-lines”)。有问题欢迎评论区交流。