一、为什么我要手写一个Agent
先说结论:LangChain的AgentExecutor和AutoGPT的抽象层确实能让你十分钟跑起来一个demo,但一旦线上出问题——工具调用死循环、LLM返回的JSON解析失败、上下文超长导致成本爆炸——你打开源码会发现层层封装之下根本不知道从哪下手。
我上一个项目用LangChain 0.2.x做客服Agent,遇到一个诡异问题:某个工具偶尔返回空字符串,Agent会连续调用同一个工具7次才放弃,单次会话token成本从0.03美元飙到0.21美元。排查了半天,问题出在AgentExecutor的max_iterations默认值和handle_parsing_errors的交互逻辑上。那次之后我决定手写一遍,把每个环节的控制权拿回自己手里。
这篇文章就是把这次手写过程完整记录:一个约300行的Agent核心,支持工具注册、短期记忆、错误重试和循环控制。跑通之后你再去用LangChain,会清楚每个参数到底在干什么。
二、环境与版本
- Python 3.11.6
- OpenAI SDK 1.35.0(用
openai官方包,不用LangChain) - 模型:
gpt-4o-mini(便宜、支持function calling,实测够用) - Pydantic 2.7.1(做工具参数校验)
- 测试环境:macOS 14.5 / M2,网络直连OpenAI API
选gpt-4o-mini而不是gpt-4o的原因很实际:Agent的核心是循环调用,单次任务动辄3-5轮LLM请求,用4o成本扛不住。实测在工具调用场景下,mini的function calling准确率和4o差距在5%以内,但成本是1/15。
三、方案设计:Agent的本质是一个while循环
抛开所有框架的包装,一个ReAct风格Agent的核心逻辑就这几步:
while 未完成 and 步数 dict:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
}
}
class ToolRegistry:
def __init__(self):
self._tools: dict[str, Tool] = {}
def register(self, tool: Tool):
if tool.name in self._tools:
raise ValueError(f"工具 {tool.name} 已注册")
self._tools[tool.name] = tool
def get_schemas(self) -> list[dict]:
return [t.to_openai_schema() for t in self._tools.values()]
def execute(self, name: str, args: dict) -> str:
tool = self._tools.get(name)
if not tool:
return f"[ERROR] 未知工具: {name},可用工具: {list(self._tools)}"
try:
result = tool.func(**args)
return str(result)
except TypeError as e:
return f"[ERROR] 参数不匹配: {e},收到的参数: {args}"
except Exception as e:
return f"[ERROR] 工具执行异常: {type(e).__name__}: {e}"
注意execute里我把所有异常都转成了字符串返回给LLM,而不是往上抛。这是关键设计:工具的错误信息本身也是给LLM的上下文,让它有机会自我修正。比如参数传错,LLM看到"参数不匹配,收到的参数是xxx"后,下一轮往往会改对。
注册一个真实工具的例子:
def get_weather(city: str, unit: str = "celsius") -> str:
# 假装调API
data = {"北京": 22, "上海": 26, "深圳": 30}
temp = data.get(city, 20)
if unit == "fahrenheit":
temp = temp * 9 / 5 + 32
return f"{city}当前温度{temp}{'°C' if unit=='celsius' else '°F'}"
registry = ToolRegistry()
registry.register(Tool(
name="get_weather",
description="查询指定城市的当前天气温度",
parameters={
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名,如'北京'"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"],
"description": "温度单位,默认celsius"}
},
"required": ["city"]
},
func=get_weather
))
踩坑提醒:parameters的required字段一定要写对。我最初漏了required,gpt-4o-mini有30%概率不传city参数,直接报TypeError。加上required后,这个比例降到接近0。
4.2 记忆管理:滑动窗口 + 摘要压缩
Agent的记忆分两类:短期对话历史(必须保留,否则LLM不知道上下文)和工具调用结果(可能很长,比如一次搜索返回2000字)。
我的策略是双层:
- 滑动窗口:保留最近
max_history=20条消息,超出丢弃最老的。但注意不能把assistant的tool_call消息和对应的tool结果消息拆散,否则OpenAI API会报400(tool message必须紧跟tool_call)。 - 摘要压缩:当消息总token估算超过
4000时,把最早的10条消息交给LLM生成一段摘要,替换成一条system消息。
import tiktoken
class Memory:
def __init__(self, max_messages=20, max_tokens=4000):
self.messages: list[dict] = []
self.max_messages = max_messages
self.max_tokens = max_tokens
self.enc = tiktoken.encoding_for_model("gpt-4o-mini")
def add(self, msg: dict):
self.messages.append(msg)
self._trim()
def _count_tokens(self) -> int:
return sum(len(self.enc.encode(json.dumps(m, ensure_ascii=False)))
for m in self.messages)
def _trim(self):
# 1. 按条数裁剪,但保护 tool 配对
while len(self.messages) > self.max_messages:
# 如果第二条是tool结果,说明第一条是tool_call,成对删
if len(self.messages) > 1 and self.messages[1].get("role") == "tool":
self.messages = self.messages[2:]
else:
self.messages = self.messages[1:]
# 2. 按token裁剪(简化版:直接丢最老的,实际可接摘要)
while self._count_tokens() > self.max_tokens and len(self.messages) > 4:
self.messages = self.messages[1:]
def get(self) -> list[dict]:
return self.messages
这里tiktoken版本我用的是0.7.0。实测20条消息平均约1800 tokens,加上系统提示和工具schema(4个工具约600 tokens),单次请求在2500 tokens以内,gpt-4o-mini输入成本约0.0004美元/次,一个5轮任务约0.002美元,可以接受。
摘要压缩我留了简化版没实现完整,因为实测在20条窗口下很少触发4000 token上限。如果你的场景工具返回特别长(比如网页抓取),建议把max_tokens调到3000并实现摘要逻辑。
4.3 循环控制与错误处理:主循环
这是整个Agent的心脏。我把循环控制、错误处理、工具分发全揉在一个run方法里,逻辑直白但每个分支都有讲究。
from openai import OpenAI
client = OpenAI(api_key="sk-xxx")
class Agent:
def __init__(self, registry: ToolRegistry, system_prompt: str,
max_steps=8, max_retries=2):
self.registry = registry
self.system_prompt = system_prompt
self.memory = Memory()
self.max_steps = max_steps
self.max_retries = max_retries
def run(self, user_input: str) -> str:
self.memory.add({"role": "user", "content": user_input})
last_tool_sig = None
repeat_count = 0
for step in range(self.max_steps):
messages = [{"role": "system", "content": self.system_prompt}] \
+ self.memory.get()
# --- LLM调用带重试 ---
resp = None
for attempt in range(self.max_retries + 1):
try:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=self.registry.get_schemas(),
tool_choice="auto",
temperature=0.1,
)
break
except Exception as e:
if attempt == self.max_retries:
return f"[Agent失败] LLM调用重试{self.max_retries}次仍失败: {e}"
continue
msg = resp.choices[0].message
# --- 情况1:最终答案 ---
if not msg.tool_calls:
self.memory.add({"role": "assistant", "content": msg.content})
return msg.content
# --- 情况2:工具调用 ---
self.memory.add(msg.model_dump(exclude_none=True))
for tc in msg.tool_calls:
name = tc.function.name
try:
args = json.loads(tc.function.arguments)
except json.JSONDecodeError:
# LLM偶尔返回非法JSON,把错误喂回去
result = f"[ERROR] 参数JSON解析失败: {tc.function.arguments}"
self.memory.add({"role": "tool", "tool_call_id": tc.id,
"content": result})
continue
# --- 死循环检测:连续两次相同工具+相同参数 ---
sig = f"{name}:{json.dumps(args, sort_keys=True)}"
if sig == last_tool_sig:
repeat_count += 1
if repeat_count >= 2:
return f"[Agent终止] 检测到重复调用 {name},参数未变化,主动退出"
else:
repeat_count = 0
last_tool_sig = sig
result = self.registry.execute(name, args)
self.memory.add({"role": "tool", "tool_call_id": tc.id,
"content": result})
return "[Agent终止] 达到最大步数限制,未能完成"
def reset(self):
self.memory = Memory()
几个细节值得单独说:
temperature=0.1:Agent场景要稳定,不要创意。我试过0.7,工具参数填错率明显上升。
重复调用检测:这就是我前面说的LangChain踩坑点的对应实现。连续两次相同工具+相同参数就计数,达到2次直接终止。实测这个检测拦住了约8%的异常会话。
msg.model_dump(exclude_none=True):OpenAI SDK 1.x的message对象要转成dict才能存历史,但直接model_dump()会带上None字段(比如content: None),某些情况下API会报错,必须exclude_none。
错误信息回灌:无论是参数JSON解析失败还是工具执行异常,我都把错误作为tool角色的消息塞回历史。这是让Agent"自我修复"的核心机制,实测工具调用成功率从72%(错误直接抛出中断)提升到96%(错误回灌让LLM重试)。
4.4 串起来跑一个完整例子
agent = Agent(
registry=registry,
system_prompt="你是一个助手,可以查询天气。回答要简洁。",
max_steps=6,
)
# 测试1:正常调用
print(agent.run("北京和上海哪个更热?"))
# 预期:Agent调用两次get_weather,比较后回答
agent.reset()
# 测试2:触发死循环检测(构造一个会反复调用的场景)
print(agent.run("查一下火星的天气"))
# 预期:工具返回默认20度,Agent可能反复查,被重复检测或步数限制拦下
实测测试1:2轮工具调用 + 1轮总结,总耗时3.2秒,token消耗约4200(含工具schema重复发送),成本约0.0008美元。
五、踩坑与优化记录
坑1:tool message配对。最开始我裁剪历史时按条数无脑丢,结果把assistant(tool_calls)丢了、tool结果还在,API直接400报错tool message without preceding tool_call。修复就是4.2里的成对删除逻辑。这个问题在LangChain里被封装掉了,手写才会暴露。
坑2:tool_choice="auto"下模型有时不调工具直接瞎编。比如问天气,它直接说"北京今天22度"而不调用工具。解决办法是在system prompt里明确写"涉及实时数据必须调用工具,禁止猜测",实测瞎编率从15%降到3%。
坑3:多工具并行调用。gpt-4o-mini支持一次返回多个tool_calls,我最初只处理了msg.tool_calls[0],导致部分结果丢失。后来改成for循环遍历,并且注意每个tool_call_id都要有对应的tool消息,缺一个就400。
优化:工具schema瘦身。4个工具的schema全量发送约600 tokens,每轮都发。如果工具超过10个,建议做工具路由(先用一次小模型调用判断该用哪些工具),能省30%以上输入token。我项目里工具数控制在6个以内,暂未做路由。
六、效果数据
在同一批50条测试用例(含单工具、多工具、异常参数、超长对话)上对比:
| 指标 | 初版(错误直接抛出) | 优化后(错误回灌+重复检测) |
|---|---|---|
| 任务完成率 | 68% | 92% |
| 工具调用成功率 | 72% | 96% |
| 平均步数 | 4.1 | 3.4 |
| 异常会话拦截率 | 0% | 100%(8%的会话被重复检测拦下) |
| 单任务平均成本 | $0.0011 | $0.0009 |
平均响应时间3.2秒(P50)/ 6.8秒(P95),主要耗时在LLM请求往返。
七、总结
手写这个Agent最大的收获不是"造轮子",而是把黑盒打开后对每个失败模式有了直觉。现在再用LangChain,看到max_iterations、handle_parsing_errors、early_stopping_method这些参数,我清楚它们对应的是我代码里哪几行、解决的是什么问题。
如果你也想手写一遍,建议按这个顺序:先跑通「单工具+无记忆」的最小循环,再加记忆,再加错误回灌,最后加循环检测。每一步都写测试用例验证,别一次性堆完再调——Agent的bug往往藏在交互里,单测很难覆盖。
完整代码约300行,去掉注释和示例工具后核心逻辑不到200行