一、为什么我要抛开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,本质上是四个部件的组合:
- 工具层:定义Agent能做什么,包括函数签名、参数schema、执行逻辑
- 记忆层:管理对话历史,决定每次给模型看多少上下文
- 控制层:主循环,决定什么时候继续、什么时候停止
- 容错层:处理工具报错、模型返回格式错误、网络超时
我画了一个简单的数据流:
用户输入 → 构建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,每一层都清清楚楚。
几点建议给想自己动手的朋友:
- 先跑通单工具,再加多工具,工具越多,模型选错的概率越大。
- 记忆的难点是配对和裁剪,不是简单地保留最近N条。
- 循环控制一定要有硬上限,别指望模型自己停。
- 错误信息要喂回模型,让Agent能自愈,这比你自己在代码里if-else管用得多。
下一步我打算在这个骨架上加一个简单的任务规划层(先让模型拆解子任务,再逐步执行),以及把工具调用结果做成结构化缓存。有兴趣的话可以在评论区聊聊你们的Agent是怎么做的。