1. 为什么我需要自己写Agent?
用LangChain的AgentExecutor跑一个ReAct Agent很简单,但当我需要控制每一步的思考深度、自定义记忆过期策略、或者对工具调用失败进行特殊处理时,框架的抽象反而成了阻碍。
在最近的爬虫项目中,我需要Agent能:
- 根据用户模糊指令(如“抓取所有文章的标题和摘要”)自动规划步骤
- 在中间某一步网络超时后,自动重试并跳过坏数据
- 在连续3次推理失败时主动终止并报告原因
LangChain原生Agent做不到第2点和第3点,AutoGPT的无限循环又太危险。所以,我决定基于LangChain的LLM封装,结合AutoGPT的“目标-任务-行动”循环思想,写一个可控的Agent内核。
2. 环境与版本
python 3.10.11
langchain 0.1.0
openai 1.6.1
tiktoken 0.5.1
模型:gpt-4-1106-preview(temperature=0.2,max_tokens=1024)
嵌入模型:text-embedding-ada-002(用于记忆检索)
3. 方案设计:三明治架构
我的Agent分三层:
- 规划层(Planner):根据目标生成任务列表,用LangChain的LLMChain + ReAct提示词
- 执行层(Executor):调用工具函数,捕获异常,记录结果
- 记忆层(Memory):短期记忆用对话列表,长期记忆用向量库(Chroma),定期压缩
核心循环(AutoGPT风格):
while not done and step 类型
def tool(name: str, description: str, args_schema: Dict[str, str]):
def decorator(func):
return Tool(name, description, func, args_schema)
return decorator
# 示例工具
@tool("search_web", "搜索网页,返回前k条结果", {"query": "string", "k": "int"})
def search_web(query: str, k: int = 3):
# 模拟搜索API
results = [f"{query} 结果{i}" for i in range(k)]
return "\n".join(results)
@tool("extract_content", "从URL提取正文文本", {"url": "string"})
def extract_content(url: str):
# 模拟HTML解析,实际可用BeautifulSoup
return f"{url} 的正文内容,长度约500字"
4.2 记忆管理:短期 + 长期 + 压缩
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma
import json
class MemoryManager:
def __init__(self, max_short_term=6):
self.short_term = [] # 最近对话(原始文本)
self.max_short_term = max_short_term
self.long_term = Chroma(collection_name="agent_mem",
embedding_function=OpenAIEmbeddings(model="text-embedding-ada-002"))
def add(self, role: str, content: str):
self.short_term.append({"role": role, "content": content})
# 保持短期记忆长度
if len(self.short_term) > self.max_short_term:
# 将最早的两条记忆压缩进长期记忆
old = self.short_term.pop(0)
self.long_term.add_texts([old["content"]], metadatas=[{"role": old["role"]}])
self.short_term.pop(0) # 同时丢弃对应的assistant回复
def get_context(self, query: str, k: int = 3) -> str:
# 合并短期与长期检索结果
long_results = self.long_term.similarity_search(query, k=k)
long_text = "\n".join([doc.page_content for doc in long_results])
short_text = "\n".join([f"{m['role']}: {m['content']}" for m in self.short_term])
return f"长期记忆:\n{long_text}\n\n短期记忆:\n{short_text}"
5. 错误处理与循环控制
5.1 错误金字塔策略
我设计了三层错误处理:
1. 工具级重试:网络错误等瞬时故障,最多重试2次,指数退避(1s, 2s)
2. 动作级回退:LLM输出非法JSON或工具名不存在,重新调用LLM一次,并附上“上次输出解析失败”的提示
3. 任务级终止:连续3次工具返回空结果,或累计错误超过5次,触发AgentTermination
5.2 主循环实现
class Agent:
def __init__(self, tools: list, llm, memory: MemoryManager, max_steps=10):
self.tools = {t.name: t for t in tools}
self.llm = llm
self.memory = memory
self.max_steps = max_steps
self.error_count = 0
def run(self, goal: str) -> str:
step = 0
done = False
while step = 3:
return f"失败: 连续3次解析错误,最后错误: {e}"
self.memory.add("assistant", f"解析失败: {e},请重新输出JSON")
continue
# 执行工具(带重试)
tool_func = self.tools[tool_name].func
try:
for attempt in range(3): # 重试2次
try:
result = tool_func(**args)
break
except Exception as e:
if attempt == 2:
raise
time.sleep(2 ** attempt) # 指数退避
self.error_count = 0 # 成功则重置错误计数
except Exception as e:
self.error_count += 1
self.memory.add("assistant", f"工具执行失败: {e}")
continue
# 记录观察
self.memory.add("assistant", f"执行{tool_name},结果: {result}")
# 判断是否完成
check_prompt = f"目标: {goal}\n最新结果: {result}\n是否已达成目标? 回答yes或no"
done = self.llm.invoke(check_prompt).content.strip().lower() == "yes"
if not done:
return "已达最大步骤数,任务未完成"
return result
6. 踩坑与优化:三个真实教训
6.1 教训一:LLM输出JSON不稳定的坑
用gpt-4时约5%概率会输出多余文字(如“```json”前缀)。我用正则清洗后仍会有漏网之鱼,最终方案是:要求模型只输出JSON,并在解析失败时把错误信息加回对话历史,让LLM自己“看见”错误并修正。
6.2 教训二:短期记忆过长导致上下文爆炸
之前直接把所有历史塞给LLM,token消耗增长很快(每步约2000 token)。压缩策略上线后,单步token稳定在1200左右,且因为长期记忆的RAG,回答质量不降反升。
6.3 教训三:错误重试必须区分错误类型
最初对所有异常都重试3次,结果遇到参数校验错误(如URL格式不对)也重试,白白耗费时间和token。现在只对ConnectionError、Timeout等网络类异常重试,其余直接进入回退流程。
7. 效果数据与总结
我用50个真实爬虫任务测试(每个任务需2-5个步骤):
| 指标 | 单轮LLM | 无记忆Agent | 本Agent |
|---|---|---|---|
| 成功率 | 38% | 61% | 92% |
| 平均步数 | 1 | 4.2 | 5.1 |
| 平均耗时 | 2.3s | 18.7s | 22.4s |
| token消耗 | 1.2k | 9.3k | 11.8k |
成功率从38%提升到92%,代价是约10倍的token消耗。在需要高可靠性的场景(如自动化测试、数据采集),这个交换非常值。
总结:
- 记忆管理是Agent的“骨架”,短期+长期结合能解决大部分多跳问题
- 错误处理要分层,不能一刀切重试
- 循环控制必须带熔断机制,防止死循环烧钱
- 框架(LangChain)负责LLM封装,核心逻辑自己写反而更可控
下一步准备加入任务优先级排序和并行工具调用,目标是让Agent在10步内完成更多子任务。代码已整理到Gist,链接在评论区,欢迎交流。
(全文约1800字)