一、为什么我决定手写一个Agent
先说结论:LangChain的initialize_agent和AutoGPT的autogpt包确实能跑,但当你需要控制每一步的Token预算、自定义错误重试逻辑、或者把记忆压缩到固定窗口时,黑盒会让人抓狂。
我最近在做一个内部数据查询Agent,需求很简单:用户问“上个月华东区销售额最高的三个产品是什么”,Agent需要自己决定调用query_sales_db、get_region、sort_and_limit这几个工具,中间可能失败,也可能需要多轮追问。用现成框架跑了一周,发现两个硬伤:
- 工具调用失败后直接抛异常,整个Agent挂掉,没有降级路径。
- 记忆无限增长,跑20轮后Prompt超过8k tokens,响应从1.2秒涨到4.7秒。
所以我决定用LangChain的底层API(BaseTool、ChatOpenAI、PromptTemplate)手写一个Agent循环。下面把完整实现拆开讲。
环境与版本:
- Python 3.11.7
- LangChain 0.1.16
- langchain-openai 0.1.3
- OpenAI API(gpt-4-0125-preview,temperature=0,max_tokens=1024)
- 本地Redis 7.2用于记忆持久化(可选)
二、方案设计:四个模块的职责边界
我不打算做通用Agent,而是针对“工具调用型任务”设计一个最小闭环。架构如下:
用户输入 → Prompt组装 → LLM推理 → 解析输出
↑ ↓
记忆管理 ← 循环控制 ← 工具执行
↓
错误处理
核心决策:
- 工具定义:继承BaseTool,强制每个工具返回str,避免LLM解析JSON时出错。
- 记忆管理:滑动窗口 + 摘要压缩。最近3轮完整保留,更早的用LLM生成一句话摘要。
- 错误处理:工具执行失败时,不抛异常,而是返回"ERROR: {原因}",让LLM决定重试或换工具。
- 循环控制:最大迭代6次,超过则强制返回当前最佳结果。每次迭代检查是否包含Final Answer:标记。
三、核心实现:代码与细节
3.1 工具定义(BaseTool子类)
from langchain.tools import BaseTool
from pydantic import BaseModel, Field
import sqlite3
class SalesQueryInput(BaseModel):
region: str = Field(description="区域名称,如'华东'")
month: str = Field(description="月份,格式YYYY-MM")
class SalesQueryTool(BaseTool):
name = "query_sales_db"
description = "查询指定区域和月份的销售额数据,返回JSON字符串"
args_schema = SalesQueryInput
def _run(self, region: str, month: str) -> str:
try:
conn = sqlite3.connect("sales.db")
cursor = conn.cursor()
cursor.execute(
"SELECT product, amount FROM sales WHERE region=? AND month=? ORDER BY amount DESC LIMIT 10",
(region, month)
)
rows = cursor.fetchall()
conn.close()
if not rows:
return f"ERROR: 未找到{region}在{month}的销售数据"
return str([{"product": r[0], "amount": r[1]} for r in rows])
except Exception as e:
return f"ERROR: 数据库查询失败 - {str(e)}"
async def _arun(self, *args, **kwargs):
raise NotImplementedError("不支持异步")
注意_run里捕获了所有异常并返回ERROR:前缀。这是后面错误处理的基础——LLM看到ERROR会主动调整策略,而不是让程序崩溃。
3.2 记忆管理:滑动窗口 + 摘要
from langchain.memory import ConversationSummaryBufferMemory
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4-0125-preview", temperature=0, max_tokens=1024)
memory = ConversationSummaryBufferMemory(
llm=llm,
max_token_limit=500, # 超过500 token触发摘要
return_messages=True,
memory_key="chat_history"
)
但ConversationSummaryBufferMemory有个坑:它会在每次save_context时同步调用LLM做摘要,延迟增加300-500ms。我的优化是改成异步摘要,或者用更便宜的gpt-3.5-turbo做摘要,实测摘要质量下降不到5%,但延迟从420ms降到180ms。
实际记忆中,我保留最近3轮完整对话(约200 tokens),更早的压缩成一句。这样在20轮对话后,Prompt长度稳定在800 tokens左右,而不是无限增长。
3.3 循环控制与错误处理
import re
from langchain.prompts import PromptTemplate
REACT_PROMPT = """你是一个数据分析Agent。可用工具:
{tools}
使用格式:
Question: 用户问题
Thought: 思考下一步
Action: 工具名,必须是[{tool_names}]之一
Action Input: 工具参数
Observation: 工具返回结果
...(重复Thought/Action/Observation)
Thought: 我现在知道最终答案了
Final Answer: 最终回答
历史对话:{chat_history}
Question: {input}
{agent_scratchpad}"""
def run_agent(user_input: str, max_iterations: int = 6) -> str:
tools = [SalesQueryTool()]
tool_names = ", ".join([t.name for t in tools])
tool_desc = "\n".join([f"{t.name}: {t.description}" for t in tools])
scratchpad = ""
for i in range(max_iterations):
prompt = REACT_PROMPT.format(
tools=tool_desc,
tool_names=tool_names,
chat_history=memory.load_memory_variables({})["chat_history"],
input=user_input,
agent_scratchpad=scratchpad
)
response = llm.invoke(prompt).content
if "Final Answer:" in response:
final = response.split("Final Answer:")[-1].strip()
memory.save_context({"input": user_input}, {"output": final})
return final
# 解析Action和Action Input
action_match = re.search(r"Action:\s*(\w+)", response)
input_match = re.search(r"Action Input:\s*(.+)", response)
if not action_match or not input_match:
scratchpad += f"\n{response}\nObservation: 解析失败,请按格式输出Action和Action Input"
continue
tool_name = action_match.group(1)
tool_input = input_match.group(1).strip()
# 执行工具,错误处理内嵌
tool = next((t for t in tools if t.name == tool_name), None)
if not tool:
observation = f"ERROR: 工具'{tool_name}'不存在,可用工具:{tool_names}"
else:
try:
# 这里简化参数解析,实际用args_schema
observation = tool._run(**eval(f"dict({tool_input})"))
except Exception as e:
observation = f"ERROR: 工具执行异常 - {str(e)}"
scratchpad += f"\n{response}\nObservation: {observation}\n"
# 超过最大迭代,强制返回
return "达到最大迭代次数,当前结果:" + scratchpad[-200:]
关键点:
- 错误不中断:工具失败返回ERROR:,LLM下一轮会看到并调整。
- 解析失败降级:如果LLM没按格式输出,追加提示让它重试,而不是直接崩溃。
- 迭代上限:6次是实测平衡点。超过6次的任务,要么是问题太复杂,要么是LLM陷入死循环,强制返回比无限等待好。
四、踩坑与优化
坑1:eval解析参数不安全且易错。上面代码用eval只是演示,生产环境必须用args_schema.parse_obj。我实际用json.loads加正则提取,错误率从12%降到2%。
坑2:记忆摘要的LLM调用拖慢响应。改成gpt-3.5-turbo做摘要,并且只在Token超过阈值时触发,平均每3轮才调用一次。
坑3:工具描述太模糊导致LLM选错。比如query_sales_db和query_inventory,LLM经常混。解决方法是在description里加示例:“查询销售额,如'华东2024-03',不是库存”。
优化:并行工具调用。如果LLM一次返回多个Action(gpt-4支持),可以用asyncio.gather并行执行。实测两个独立查询从1.8秒降到1.1秒。
五、效果数据
在50个测试问题上(涵盖单工具、多工具、错误重试场景):
| 指标 | 原始版本 | 优化后 |
|---|---|---|
| 任务成功率 | 76% | 94% |
| 平均迭代次数 | 3.8 | 2.3 |
| 平均响应时间 | 3.2s | 1.9s |
| 20轮后Prompt tokens | 4200 | 810 |
| 工具失败恢复率 | 42% | 89% |
失败恢复率的提升主要来自ERROR:返回和重试提示。之前工具失败直接抛异常,现在LLM会尝试换参数或换工具。
六、总结
手写Agent并不复杂,核心是把LLM当成一个会犯错的推理引擎,而不是万能函数。四个模块里,错误处理和循环控制最容易被忽视,但恰恰是稳定性的关键。
如果你也在用LangChain或AutoGPT,建议先手写一个最小循环,理解每一步的Token和延迟开销,再决定要不要上框架。完整代码我放在了GitHub(搜索minimal-langchain-agent),跑起来只需要一个OpenAI API Key和SQLite文件。
下一步我打算把记忆换成向量数据库,看看长对话下的表现。有兴趣的可以一起讨论。