一、问题背景:为什么我要手写,而不是直接抄 LangChain 的 AgentExecutor
先说结论:LangChain 的 AgentExecutor 很好用,但它是黑盒,出问题时你根本不知道是哪一步在坑你。
我去年做过一个内部数据查询 Agent,用 create_openai_tools_agent + AgentExecutor,上线后遇到三个经典问题:
- 工具抛异常时,整个 Agent 直接崩,用户看到 500;
- 对话超过 15 轮后 token 爆炸,成本失控;
- 模型偶尔会陷入"调工具 → 看到结果 → 再调一次同样的工具"的死循环,烧钱。
排查时我翻 AgentExecutor 源码,发现它内部把 max_iterations、handle_parsing_errors、early_stopping_method 这些参数揉在一起,改一个影响一片。于是我干脆花两天手写了一遍,把工具定义、记忆管理、错误处理、循环控制这四件事拆开,每一块都自己掌控。
这篇文章就是那次重写的完整记录。AutoGPT 的思路(Thought → Action → Observation 循环)我会借鉴,但实现上不抄它的 prompt 模板,用 LangChain 的 ChatOpenAI 和 BaseTool 做底座,自己写 executor 循环。
二、环境与版本
python 3.11.9
langchain-core==0.3.28
langchain-openai==0.2.14
openai==1.59.0
pydantic==2.10.4
tiktoken==0.8.0
模型用 gpt-4o-mini,temperature=0,max_tokens=1024。为什么不用 GPT-4o?因为 Agent 循环里模型调用次数多,mini 的成本是 4o 的 1/15,实测多步任务成功率只差 6 个百分点(88% vs 94%),性价比更高。追求极限准确率再换 4o。
三、方案设计:四层结构
我把 Agent 拆成四个正交模块,主循环只负责调度:
┌─────────────────────────────────────┐
│ Agent Loop (主循环) │
│ while step list[dict]:
"""把注册的工具转成 OpenAI tools 格式"""
schemas = []
for spec in self._tools.values():
# 从函数签名提取参数模型
sig = inspect.signature(spec.func)
fields = {}
for pname, p in sig.parameters.items():
ann = p.annotation if p.annotation is not inspect.Parameter.empty else str
fields[pname] = (ann, Field(..., description=f"参数 {pname}"))
ArgsModel = create_model(f"{spec.name}_args", **fields)
schemas.append({
"type": "function",
"function": {
"name": spec.name,
"description": spec.description,
"parameters": ArgsModel.model_json_schema(),
}
})
return schemas
def execute(self, name: str, args: dict) -> str:
spec = self._tools.get(name)
if not spec:
return f"[ERROR] 未知工具: {name}"
last_err = None
for attempt in range(spec.max_retries + 1):
try:
result = spec.func(**args)
return str(result)[:2000] # 截断,防止 observation 撑爆上下文
except Exception as e:
last_err = e
time.sleep(0.3 * (attempt + 1)) # 简单退避
return f"[ERROR] 工具 {name} 执行失败: {type(last_err).__name__}: {last_err}"
这里有个关键细节:model_json_schema() 生成的 schema 里 Pydantic 会带 title 字段,部分模型(尤其是老版本 function calling)对多余字段敏感,我实测 GPT-4o-mini 没问题,但如果换 Claude 或国产模型,建议手动精简 schema,只留 type/properties/required。
4.2 记忆管理:滑动窗口 + 摘要压缩
Agent 的记忆不是简单堆 messages,因为工具返回的 observation 动辄几百 token。我的策略是:
- 最近
K=6条消息(约 3 轮对话)原样保留; - 更早的消息,当累计 token 超过
SUMMARY_THRESHOLD=3000时,触发一次摘要,把早期对话压成一段 system 消息; - 摘要本身用同一个 LLM,prompt 就一句:"用 100 字以内概括以下对话中已确认的事实和已完成的动作"。
# memory.py
import tiktoken
from langchain_openai import ChatOpenAI
ENC = tiktoken.encoding_for_model("gpt-4o-mini")
class MemoryManager:
def __init__(self, llm: ChatOpenAI, window: int = 6,
summary_threshold: int = 3000):
self.llm = llm
self.window = window
self.threshold = summary_threshold
self.summary: str = ""
self.history: list[dict] = [] # 原始消息
def _count(self, msgs: list[dict]) -> int:
return sum(len(ENC.encode(str(m.get("content", "")))) for m in msgs)
def add(self, msg: dict):
self.history.append(msg)
if len(self.history) > self.window * 2:
self._maybe_compress()
def _maybe_compress(self):
early = self.history[:-self.window]
if self._count(early) list[dict]:
msgs = [{"role": "system", "content": system_prompt}]
if self.summary:
msgs.append({"role": "system", "content": f"[历史摘要] {self.summary}"})
return msgs + self.history
踩过的坑:摘要 prompt 千万别让模型自由发挥。我第一版 prompt 是"总结对话",结果模型把工具返回的原始 JSON 也塞进摘要,token 没省多少。改成"只保留事实和已完成动作"后,压缩率从 40% 提到 78%。
4.3 主循环:循环控制与死循环检测
这是整个 Agent 的心脏。AutoGPT 的经典问题是"没有终止条件就无限转",我加了三道闸:
max_steps=8(硬上限);- 动作指纹:连续两次
(tool_name, args_json)完全相同,直接终止并让模型基于已有信息作答; - 无进展检测:连续 3 步 observation 都是
[ERROR]开头,终止。
# agent.py
import json, hashlib
from langchain_openai import ChatOpenAI
from tools import ToolRegistry
from memory import MemoryManager
SYSTEM_PROMPT = """你是一个工具调用型 Agent。规则:
1. 需要外部信息时调用工具,不要编造。
2. 每次只调用一个工具,拿到结果后再决定下一步。
3. 信息足够时直接给出最终答案,格式为 "FINAL: "。
4. 不要重复调用相同参数的工具。"""
class SimpleAgent:
def __init__(self, llm: ChatOpenAI, tools: ToolRegistry,
max_steps: int = 8):
self.llm = llm.bind_tools(tools.openai_schemas())
self.tools = tools
self.memory = MemoryManager(llm)
self.max_steps = max_steps
def run(self, user_input: str) -> str:
self.memory.add({"role": "user", "content": user_input})
last_fingerprint, err_streak, repeated = None, 0, 0
for step in range(1, self.max_steps + 1):
msgs = self.memory.build_messages(SYSTEM_PROMPT)
resp = self.llm.invoke(msgs)
# 无工具调用 → 最终答案
if not resp.tool_calls:
self.memory.add({"role": "assistant", "content": resp.content})
return resp.content
for tc in resp.tool_calls:
name, args = tc["name"], tc["args"]
fp = hashlib.md5(
f"{name}:{json.dumps(args, sort_keys=True)}".encode()
).hexdigest()
if fp == last_fingerprint:
repeated += 1
if repeated >= 1:
print(f"[Loop] 检测到重复动作 {name},强制终止")
return self._force_answer(msgs)
last_fingerprint, repeated = fp, 0
obs = self.tools.execute(name, args)
if obs.startswith("[ERROR]"):
err_streak += 1
else:
err_streak = 0
self.memory.add({"role": "assistant", "content": "",
"tool_calls": [tc]})
self.memory.add({"role": "tool", "tool_call_id": tc["id"],
"content": obs})
print(f"[Step {step}] {name}({args}) -> {obs[:80]}...")
if err_streak >= 3:
print("[Loop] 连续3次工具失败,终止")
return self._force_answer(msgs)
print(f"[Loop] 达到 max_steps={self.max_steps}")
return self._force_answer(msgs)
def _force_answer(self, msgs: list[dict]) -> str:
resp = self.llm.invoke(msgs + [
{"role": "user",
"content": "基于以上已有信息,直接给出最终答案,不要调用工具。"}
])
return resp.content
注意 bind_tools 返回的是新对象,别在原 llm 上反复 bind,否则工具列表会叠加。这是我踩的第一个坑,排查了半小时。
4.4 错误处理的完整链路
错误分三层,每层职责不同:
| 层级 | 位置 | 处理方式 |
|---|---|---|
| 工具内异常 | ToolRegistry.execute |
重试 + 退避,最终返回 [ERROR] 字符串 |
| 模型解析异常 | 主循环 | tool_calls 为空但 content 非 FINAL 时,视为答案 |
| 循环异常 | run() 末尾 |
强制 _force_answer 兜底 |
关键设计:工具错误不抛异常,而是作为 observation 回灌给模型。这是 AutoGPT 的核心思想——让模型自己看到错误并调整策略。我实测过,如果直接 raise,Agent 崩溃率 29%;改成回灌后,模型有 61% 的概率在下一次调用中修正参数。
五、踩坑与优化
坑 1:observation 太长撑爆上下文。 工具返回的 JSON 有 8KB,第二次调用就超 token。解决:execute 里统一 [:2000] 截断,超过的部分让模型用分页参数再查。
坑 2:模型不看工具描述乱调。 工具 description 写"查询数据库",模型会把任意问题都塞进去。改成"查询用户订单表 orders,参数 user_id 为字符串,返回最近 30 天订单列表"后,误调用率从 23% 降到 7%。description 要写成给实习生看的说明书。
坑 3:摘要触发太频繁。 一开始 window=4,每 3 轮就压缩一次,摘要调用本身耗了 0.8 秒/次。调到 window=6、threshold=3000 后,压缩频率降到每 8 轮一次,整体延迟降 18%。
优化:并行工具调用。 模型一次返回多个 tool_calls 时,用 concurrent.futures 并行执行(我的场景工具无依赖),多工具任务耗时从 4.1s 降到 2.3s。代码就是把上面 for tc in resp.tool_calls 换成 ThreadPoolExecutor,注意结果顺序要按 tc["id"] 对齐回填。
六、效果数据
测试集:50 条多步任务(查订单→算退款→发邮件这类链式操作),对比三个版本:
| 版本 | 任务成功率 | 平均步数 | 平均耗时 | 单任务 token |
|---|---|---|---|---|
| 裸写(无错误处理/无循环控制) | 71% | 4.8 | 5.2s | 3200 |
| LangChain AgentExecutor 默认 | 86% | 3.6 | 3.4s | 2800 |
| 本文手写版 | 94% | 3.2 | 2.8s | 2100 |
失败案例分析:剩下 6% 主要是工具本身返回空数据(比如查不到订单),模型不知道该怎么回退。这块我打算下一步加"工具返回空时的 fallback 提示",而不是让模型瞎猜。
七、总结
手写这 300 行最大的收获不是"造轮子",而是搞清楚了 Agent 的每一次不确定性来自哪里:工具 schema 的模糊、observation 的膨胀、循环的无终止、错误的静默。这四个问题在 LangChain 里都被封装成了参数,封装是好事,但你不拆开看一遍,就永远在调参玄学。
如果你也在做 Agent 开发,建议至少手写一次 executor 循环,哪怕之后回去用框架,你对 max_iterations 该设 5 还是 15 会有完全不同的直觉。代码我整理成了一个单文件版本,跑 python agent.py