一、为什么我要手写一个Agent,而不是直接用LangChain的AgentExecutor
先说结论:LangChain的AgentExecutor和AutoGPT的规划循环都很好用,但如果你没亲手写过一遍,遇到问题基本只能靠猜。
我上个月接了个内部知识库问答的项目,一开始直接用create_openai_tools_agent + AgentExecutor,跑demo很顺。上线后问题来了:模型偶尔连续调用同一个工具5次不收敛,Token费用暴涨;工具抛异常时整个链路直接崩,日志里只有一句ToolException;多轮对话里早期信息被挤掉,用户问“刚才那个数乘2”直接失忆。
翻源码才发现,AgentExecutor的max_iterations默认15、early_stopping_method默认"force",这些参数不显式配就是坑。于是我干脆手写一遍,把工具定义、记忆管理、错误处理、循环控制四个模块的控制权全部拿回来。
本文代码基于以下环境,版本都是实测可跑的:
- Python 3.11.7
- langchain 0.2.16 / langchain-openai 0.1.23
- openai 1.42.0
- pydantic 2.8.2
二、方案设计:一个ReAct循环的最小骨架
AutoGPT这类系统的本质是一个ReAct(Reasoning + Acting)循环,拆开看就四件事:
- 工具定义:让模型知道有哪些工具、参数是什么,并能被结构化解析;
- 记忆管理:短期对话窗口 + 长期摘要,控制Token上限;
- 错误处理:工具异常要变成可观测的观察结果回灌给模型,而不是直接中断;
- 循环控制:最大轮次、重复调用检测、收敛判断。
我的设计是:用LangChain的ChatOpenAI做推理引擎(只借用LLM调用和bind_tools的能力),其余全部自己写。整体流程如下:
用户输入 → 组装消息(系统提示+记忆+历史) → LLM推理
→ 有tool_calls?
是 → 执行工具(带异常捕获) → 结果作为ToolMessage回灌 → 轮次+1 → 回到LLM
否 → 输出最终答案,写入记忆
→ 超过max_iterations 或 检测到重复调用 → 强制收敛
关键取舍:不用AgentExecutor,只用ChatOpenAI.bind_tools。这样工具调用的schema解析还是交给官方实现(省得自己处理OpenAI的function calling格式),但循环、记忆、异常全部自己掌控。
三、核心实现:四个模块逐个落地
3.1 工具定义:用Pydantic + @tool装饰器
工具定义的关键是参数schema要严格,否则模型经常传错类型。LangChain的@tool装饰器配合Pydantic Field描述,实测比手写JSON schema的调用准确率高不少。
from langchain_core.tools import tool
from pydantic import BaseModel, Field
import ast, operator, os
class CalcInput(BaseModel):
expression: str = Field(description="要计算的数学表达式,仅支持+ - * / 和括号,例如 '(3+5)*2'")
@tool("calculator", args_schema=CalcInput)
def calculator(expression: str) -> str:
"""计算数学表达式,返回字符串结果。"""
allowed = {ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv}
def _eval(node):
if isinstance(node, ast.Num):
return node.n
if isinstance(node, ast.BinOp) and type(node.op) in allowed:
return allowed[type(node.op)](_eval(node.left), _eval(node.right))
raise ValueError(f"不支持的表达式节点: {ast.dump(node)}")
tree = ast.parse(expression, mode="eval")
return str(_eval(tree.body))
class WeatherInput(BaseModel):
city: str = Field(description="城市中文名,如 '北京'")
@tool("get_weather", args_schema=WeatherInput)
def get_weather(city: str) -> str:
"""查询指定城市天气(示例为mock数据)。"""
mock = {"北京": "晴 26℃", "上海": "多云 24℃", "深圳": "雷阵雨 29℃"}
if city not in mock:
raise ValueError(f"暂不支持的城市: {city}")
return f"{city}:{mock[city]}"
class FileInput(BaseModel):
path: str = Field(description="文件相对路径")
content: str = Field(default="", description="写入内容;为空表示读取")
@tool("file_io", args_schema=FileInput)
def file_io(path: str, content: str = "") -> str:
"""读写工作目录下的文本文件。content为空时读取。"""
safe_root = os.path.abspath("./agent_workspace")
full = os.path.abspath(os.path.join(safe_root, path))
if not full.startswith(safe_root):
raise PermissionError("路径越界,拒绝访问")
if content:
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", encoding="utf-8") as f:
f.write(content)
return f"已写入 {len(content)} 字符到 {path}"
if not os.path.exists(full):
raise FileNotFoundError(f"文件不存在: {path}")
with open(full, encoding="utf-8") as f:
return f.read()[:2000]
TOOLS = [calculator, get_weather, file_io]
注意file_io里的路径越界检查——这是生产环境必须的,模型真的会给你传../../etc/passwd。
3.2 记忆管理:滑动窗口 + 摘要压缩
记忆分两层:短期窗口保留最近N条消息原文,长期摘要在超限时把最老的一批消息压成一段摘要。我用的是简单粗暴的Token预算制,而不是按条数,因为一条工具返回可能顶十条对话。
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, ToolMessage
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o-mini")
def count_tokens(messages) -> int:
total = 0
for m in messages:
total += len(enc.encode(getattr(m, "content", "") or ""))
total += 4 # 消息结构开销
return total
class MemoryManager:
def __init__(self, llm, max_tokens=2000, keep_recent=6):
self.llm = llm
self.max_tokens = max_tokens
self.keep_recent = keep_recent
self.summary = ""
self.history = [] # 只存 Human/AI 文本消息,ToolMessage 不进长期记忆
def add(self, msg):
self.history.append(msg)
if count_tokens(self.history) > self.max_tokens:
self._compress()
def _compress(self):
old, recent = self.history[:-self.keep_recent], self.history[-self.keep_recent:]
text = "\n".join(f"{m.type}: {m.content}" for m in old)
prompt = f"已有摘要:{self.summary}\n新对话:\n{text}\n请合并为不超过150字的中文摘要,保留关键实体和数字。"
self.summary = self.llm.invoke(prompt).content
self.history = recent
def build_context(self) -> str:
parts = []
if self.summary:
parts.append(f"【历史摘要】{self.summary}")
parts.extend(f"{m.type}: {m.content}" for m in self.history)
return "\n".join(parts)
踩过的坑:ToolMessage不要进长期记忆。工具返回可能很长(比如文件内容),压缩进摘要既贵又容易丢关键信息,而且下一轮推理本来就会重新调工具。只留Human/AI的对话文本就够了。
3.3 循环控制 + 错误处理:主循环
这是整个Agent的心脏。三个控制点:max_iterations(我设8)、重复调用检测(连续3次同工具同参数则强制终止)、异常转观察结果。
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, ToolMessage
import json, hashlib
SYSTEM_PROMPT = """你是一个工具型助手。规则:
1. 能用工具就用工具,不要编造数据;
2. 每次只调用必要的工具,拿到结果后立即给出最终答案;
3. 工具报错时,根据错误信息调整参数或换方案,不要重复同样的调用。"""
class SimpleAgent:
def __init__(self, tools, max_iterations=8, model="gpt-4o-mini", temperature=0):
self.llm = ChatOpenAI(model=model, temperature=temperature).bind_tools(tools)
self.tool_map = {t.name: t for t in tools}
self.memory = MemoryManager(ChatOpenAI(model=model, temperature=0))
self.max_iterations = max_iterations
def run(self, user_input: str) -> str:
self.memory.add(HumanMessage(content=user_input))
messages = [SystemMessage(content=SYSTEM_PROMPT),
HumanMessage(content=self.memory.build_context())]
call_signatures = []
for step in range(1, self.max_iterations + 1):
try:
ai_msg = self.llm.invoke(messages)
except Exception as e:
return f"[LLM调用失败] {type(e).__name__}: {e}"
if not ai_msg.tool_calls:
self.memory.add(AIMessage(content=ai_msg.content))
return ai_msg.content or "(模型未返回内容)"
messages.append(ai_msg)
for tc in ai_msg.tool_calls:
sig = hashlib.md5(
f"{tc['name']}:{json.dumps(tc['args'], sort_keys=True, ensure_ascii=False)}".encode()
).hexdigest()
call_signatures.append(sig)
if call_signatures.count(sig) >= 3:
return f"[提前终止] 检测到重复调用 {tc['name']},参数: {tc['args']}"
observation = self._execute_tool(tc)
messages.append(ToolMessage(content=observation, tool_call_id=tc["id"]))
return f"[达到最大轮次 {self.max_iterations}] 任务未收敛,最后模型输出: {ai_msg.content}"
def _execute_tool(self, tool_call) -> str:
name, args = tool_call["name"], tool_call["args"]
tool = self.tool_map.get(name)
if tool is None:
return f"[错误] 未知工具: {name},可用工具: {list(self.tool_map)}"
try:
result = tool.invoke(args)
return str(result)[:1500] # 截断,防止上下文爆炸
except Exception as e:
return f"[工具异常] {type(e).__name__}: {e}。请检查参数或换一种方式。"
几个细节值得说:
- 异常必须转成字符串回灌,而不是raise。模型看到
[工具异常] ValueError: 暂不支持的城市: 杭州会自己换城市或说明无法查询,这是ReAct循环自愈的关键; tool.invoke(args)而不是直接调用函数,因为@tool包装后要经过Pydantic校验,参数类型不对会在这里报错并被捕获;- 结果截断1500字符,否则一次文件读取就能把上下文撑爆;
- 重复调用检测用参数哈希,比只比较工具名准得多——模型可能反复用不同参数调同一工具,那是合理行为。
一个最小运行示例:
agent = SimpleAgent(TOOLS, max_iterations=8)
print(agent.run("帮我查一下北京和上海天气,然后算一下温差(北京26,上海24),最后把结果写到 report.txt"))
实测这条指令会走:get_weather×2 → calculator×1 → file_io×1,4轮循环收敛,总Token约1650。
四、踩坑与优化:从68%到94%的工具调用成功率
初版跑50条测试指令,工具调用成功率只有68%,主要问题和解法:
坑1:模型把计算器当万能工具。 问“北京天气如何”,它调calculator("北京天气")。原因是calculator的描述太笼统。优化:在Field(description=...)里写清楚“仅支持数学表达式”,并在系统提示加“选择工具前先确认工具用途”。成功率+11%。
坑2:temperature没设0。 默认0.7时,同一输入两次运行的工具选择会飘。设temperature=0后行为稳定,但注意gpt-4o-mini在temperature=0下仍有极小随机性(服务端原因),不能完全依赖复现。
坑3:摘要压缩把数字吃掉。 早期摘要prompt只说“压缩对话”,结果“26℃”被压成“天气不错”。改成明确要求“保留关键实体和数字”后解决。
坑4:max_iterations设15太大。 超过5轮基本就是模型在鬼打墙,白白烧Token。设8,配合重复检测,实测没有正常任务被误杀。
最终50条测试集数据:成功率94%(47/50),平均循环轮次3.2,平均单任务Token 1780,P95延迟4.6秒(gpt-4o-mini,含工具执行)。失败的3条都是需要多步文件操作的复杂指令,属于当前工具粒度的边界。
五、总结:手写一遍,再回去用框架
手写完这300行,再回头看AgentExecutor的源码,那些参数(max_iterations、early_stopping_method、handle_parsing_errors)为什么存在、默认值为什么那么设,全都通了。
我的建议是:生产环境该用框架就用框架,LangChain 0.2.x的AgentExecutor在可观测性(callback、tracing)上比手写完善得多;但至少手写一次,你才能在它出问题时知道该调哪个旋钮。AutoGPT那套规划循环看起来很唬人,剥开也就是本文这个骨架加上更复杂的记忆和任务分解。
下一步我打算把记忆换成向量检索做长期记忆,以及给工具加超时控制(现在同步调用,一个慢工具会卡住整个循环)。这两块踩完坑再写一篇。
代码已放在本地agent_workspace同级目录,直接改OPENAI_API_KEY环境变量即可跑。有问题评论区聊。