一、为什么还要手写Agent?直接上LangChain不香吗?
先说结论:LangChain的AgentExecutor确实能跑,但当你需要精细控制每一步的上下文、记忆的遗忘策略、以及错误恢复的粒度时,框架的抽象反而成了束缚。
我最近在做一个内部知识库问答机器人,需要Agent能够自主调用搜索、数据库查询、代码执行三类工具。最初用LangChain的initialize_agent,发现两个痛点:
1. 记忆无差别累积:超过5轮对话后,token消耗暴涨3倍,且旧信息干扰新决策。
2. 错误处理黑盒:工具执行失败时,框架只返回一个错误字符串,Agent经常陷入“反复调用同一个失败工具”的死循环。
所以决定参考AutoGPT的“计划-执行-反思”循环,结合LangChain的工具装饰器,手写一个轻量级Agent。最终效果:在30个混合型任务上,相比默认实现,成功率提升28%,平均轮次减少2.1次。
二、环境与版本:2024年10月的技术栈
Python 3.11.5
langchain 0.2.14
langchain-openai 0.1.9
openai 1.40.3
faiss-cpu 1.8.0
注意:LangChain 0.2.x起,Tool类推荐直接用@tool装饰器,比旧的BaseTool子类写法简洁得多。模型我用的是gpt-4o-mini,每千token成本约$0.00015,跑完整个测试集花了大约$0.4。
三、方案设计:把AutoGPT的循环“瘦身”成四个模块
AutoGPT的完整实现太臃肿(光文件解析就几百行),我提取了它的核心思想,设计如下架构:
- 工具层:用
@tool装饰器定义三个函数,每个工具附带description,这是模型选择工具的唯一依据。 - 记忆层:用
ConversationBufferWindowMemory,但手动设置k=4,只保留最近4轮。另外维护一个长期记忆文件,存关键结论。 - 循环控制层:手动写
while循环,每次迭代调用一次LLM,解析返回的JSON动作,执行工具,收集观察结果。 - 错误处理层:针对三种高频异常(JSON解析失败、工具不存在、工具超时),分别设定重试或降级策略。
核心流程伪代码:
while step 3 and last_action == current_action:
force_switch_tool() # 防止死循环
四、核心实现:从工具到循环的完整代码
4.1 工具定义:用@tool让模型理解参数
这里的关键是描述要带数据样例。比如搜索工具,我明确写了“输入应为关键名词,非完整句子”,否则模型会传整个问题进去,导致搜索结果噪音大。
from langchain_core.tools import tool
import requests, json, time
@tool
def search_web(query: str) -> str:
"""搜索互联网。query应为2-5个关键词,不要带疑问词。返回前3条结果的标题和URL。"""
params = {"q": query, "count": 3}
resp = requests.get("https://api.duckduckgo.com/", params=params, timeout=5)
data = resp.json()
results = [f"{item.get('Text', '')[:80]} - {item.get('FirstURL', '')}"
for item in data.get("RelatedTopics", []) if isinstance(item, dict)]
return "\n".join(results) if results else "无结果"
@tool
def query_database(table: str, condition: str) -> str:
"""查询SQLite数据库。table可选值: employees, orders。condition是WHERE子句。"""
import sqlite3
conn = sqlite3.connect("company.db", timeout=3)
sql = f"SELECT * FROM {table} WHERE {condition} LIMIT 5"
try:
cur = conn.execute(sql)
rows = cur.fetchall()
return json.dumps(rows, ensure_ascii=False)
except Exception as e:
return f"SQL错误: {e}"
finally:
conn.close()
4.2 循环控制与错误处理:核心中的核心
这一步我踩了很多坑。最开始直接用eval()解析模型的输出,结果模型偶尔多输出一个反引号就崩。后来改用正则提取JSON块 + json.loads,失败时重试一次,并把错误信息反馈给模型。
import re, json, time
from langchain_openai import ChatOpenAI
from langchain.memory import ConversationBufferWindowMemory
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0, max_tokens=500)
memory = ConversationBufferWindowMemory(k=4, return_messages=True)
SYSTEM_PROMPT = """你是任务规划Agent。请严格输出JSON格式:
{"action": "tool_name", "action_input": {"param": "value"}}
或 {"action": "finish", "action_input": {"answer": "最终答案"}}
可用工具: {tools}"""
def extract_json(text):
match = re.search(r'\{.*\}', text, re.DOTALL)
if not match:
raise ValueError("无JSON块")
return json.loads(match.group())
def run_agent(task, max_steps=5):
tools = {"search_web": search_web, "query_database": query_database}
step = 0
last_action = None
repeat_count = 0
while step 0:
repeat_count += 1
if repeat_count >= 2:
print("[警告] 检测到重复动作,强制切换")
action = {"action": "search_web", "action_input": {"query": "替代方案"}}
else:
repeat_count = 0
# 执行工具
if act in tools:
try:
result = tools[act].invoke(action["action_input"])
memory.save_context({"input": prompt}, {"output": f"观察: {result}"})
print(f"[步骤{step}] {act} -> {str(result)[:50]}...")
except Exception as e:
# 错误处理3: 工具执行异常
err_msg = f"工具{act}执行失败: {str(e)}。请换一个工具。"
memory.save_context({"input": prompt}, {"output": err_msg})
print("[错误]", err_msg)
else:
print(f"[错误] 未知工具: {act}")
last_action = act
step += 1
if step >= max_steps:
print("[超时] 达到最大步数,返回当前记忆")
return memory.load_memory_variables({})
run_agent("找出员工表中工资最高的名字,并在网上搜索该公司的最新动态", max_steps=4)
五、踩坑与优化:三个真实教训
坑1:记忆污染
最初用ConversationBufferMemory,结果第3轮时,模型把第1轮的工具输出当成“用户指令”来执行。解决:改用ConversationBufferWindowMemory(k=4),并手动在sava_context时加上“观察:”前缀,让模型区分事实与指令。
坑2:工具超时无响应
requests库默认无超时,导致搜索工具卡死整个Agent。优化:所有HTTP调用加timeout=5,并在工具内部捕获Timeout异常,返回“搜索超时,请改用其他工具”。
坑3:模型输出带Markdown代码块
即使提示词里写明“严格JSON”,模型偶尔还是输出json {...}。用正则提取时,re.search(r'\{.*\}', text, re.DOTALL)能处理,但要注意.*是贪婪的,如果模型输出两个大括号会误提取。优化:先去除代码块标记text.replace('```json', '').replace('```', '')。
六、效果数据:从61%到89%的调优记录
在30个测试任务(10个单工具调用、15个双工具组合、5个需多步推理)上的结果:
| 配置版本 | 成功率 | 平均步数 | 总token消耗 |
|---|---|---|---|
| 初始版(无记忆窗口) | 61% | 4.8 | 18,200 |
| +记忆窗口k=4 | 73% | 4.1 | 12,500 |
| +重复动作检测 | 82% | 3.6 | 10,800 |
| +错误重试与降级 | 89% | 3.2 | 9,400 |
最明显的变化:重复动作检测减少了20%的无意义工具调用,token消耗降了22%。而错误重试机制,让那些“模型幻觉出不存在工具”的情况,能优雅降级为搜索任务。
七、总结与思考
手写Agent的核心价值不在于“不用框架”,而在于你能在循环里插入任何自定义逻辑。比如我在生产环境中加了“成本控制”——当某一步token消耗超过0.01美元时,自动降低模型温度或切换为更便宜的模型。
如果你也想做类似的事,建议按以下顺序迭代:
1. 先用LangChain跑通基础工具调用
2. 再手动接管循环控制,加入JSON解析与重试
3. 最后根据日志分析失败模式,针对性地加错误处理
最后留个问题:AutoGPT的“短期记忆”与“长期记忆”分离机制,在LangChain里有没有更优雅的实现?欢迎评论区讨论。