1. 问题背景:Agent框架的黑盒困境
最近接手一个内部知识库问答项目,需要让LLM根据用户问题自动调用搜索、计算、数据库查询等工具。最初直接使用LangChain的initialize_agent,确实快速跑通demo,但线上遇到两个问题:一是工具调用失败后整个Agent直接崩溃,二是多轮对话中记忆混乱导致重复调用相同工具。翻看源码发现AgentExecutor的循环控制被封装得太死,错误处理只有简单的max_iterations参数,而AutoGPT那种任务拆解模式又太重——我们只需要一个能稳定执行3-5步工具调用的轻量Agent。
于是决定手写一个100行左右的Agent核心,把控制权完全握在自己手里。
2. 环境与版本
Python 3.10.12
langchain==0.1.0
langchain-openai==0.0.2
openai==1.10.0
模型使用gpt-4-1106-preview(temperature=0.2),Embedding用text-embedding-3-small。所有测试在本地MacBook Pro M2上运行,单次任务平均耗时8.3秒。
3. 方案设计:ReAct范式的最小实现
核心思路沿用ReAct(Reason+Act)范式,但做了三个关键改造:
- 工具定义结构化:每个工具用Pydantic模型声明参数Schema,方便LLM生成结构化JSON调用
- 记忆管理双层化:短期记忆用滑动窗口(最近5轮),长期记忆用向量库检索(top-3相关历史)
- 错误处理分级化:工具执行异常时,将错误信息回填给LLM重新推理,而不是直接终止
整体流程图:
用户输入 → 记忆检索 → LLM推理(决定行动) → 执行工具 → 结果回填 → 循环判断(是否需继续)
4. 核心实现:工具定义与循环控制
4.1 工具定义:用Pydantic约束参数
from pydantic import BaseModel, Field
from langchain.tools import BaseTool
from typing import Type, Optional
class CalculatorInput(BaseModel):
expression: str = Field(description="数学表达式,如'2+3*4'")
class CalculatorTool(BaseTool):
name = "calculator"
description = "计算数学表达式,支持加减乘除和括号"
args_schema: Type[BaseModel] = CalculatorInput
def _run(self, expression: str) -> str:
try:
# 安全计算:限制在四则运算
result = eval(expression, {"__builtins__": {}}, {})
return f"计算结果: {result}"
except Exception as e:
return f"计算失败: {str(e)}"
async def _arun(self, expression: str) -> str:
return self._run(expression)
class SearchTool(BaseTool):
name = "web_search"
description = "搜索互联网获取最新信息,输入为搜索关键词"
def _run(self, query: str) -> str:
# 模拟搜索,实际项目中替换为真实API
return f"关于'{query}'的搜索结果:根据公开资料,..."
关键点:args_schema必须声明,否则LangChain无法自动将LLM输出解析为工具参数。这里踩过坑——不声明Schema时,LLM经常输出{"query": "..."}这种嵌套结构导致解析失败。
4.2 核心循环:手动控制迭代与错误恢复
from langchain_openai import ChatOpenAI
from langchain.schema import HumanMessage, SystemMessage, AIMessage
import json
class SimpleAgent:
def __init__(self, tools, memory_window=5, max_iters=8):
self.tools = {t.name: t for t in tools}
self.memory = [] # 短期记忆:消息列表
self.memory_window = memory_window # 滑动窗口大小
self.max_iters = max_iters # 最大迭代次数
self.llm = ChatOpenAI(model="gpt-4-1106-preview", temperature=0.2)
self.system_prompt = self._build_system_prompt()
def _build_system_prompt(self):
tool_desc = "\n".join(
f"{name}: {tool.description},参数格式: {tool.args_schema.schema()['properties']}"
for name, tool in self.tools.items()
)
return f"""你是智能助手,可以调用以下工具:
{tool_desc}
严格按以下流程工作:
1. 如果需要调用工具,输出JSON格式:{{"action": "工具名", "action_input": {{"参数名": "参数值"}}}}
2. 如果不需要工具或已获得最终答案,输出:{{"action": "Final Answer", "action_input": "你的回答"}}
3. 工具调用失败时,根据错误信息调整参数重试,最多重试2次。"""
def _trim_memory(self):
"""滑动窗口裁剪:保留最近N轮对话"""
if len(self.memory) > self.memory_window * 2: # 每轮含user+assistant两条
self.memory = self.memory[-(self.memory_window * 2):]
def run(self, user_input: str) -> str:
self.memory.append(HumanMessage(content=user_input))
iterations = 0
while iterations < self.max_iters:
iterations += 1
# 构建消息序列
messages = [SystemMessage(content=self.system_prompt)] + self.memory
# 调用LLM
response = self.llm.invoke(messages)
content = response.content.strip()
self.memory.append(AIMessage(content=content))
self._trim_memory()
# 解析JSON动作
try:
action_data = json.loads(content)
action = action_data.get("action", "")
action_input = action_data.get("action_input", {})
except json.JSONDecodeError:
# 容错:LLM可能输出纯文本,尝试提取JSON
import re
json_match = re.search(r'\{.*\}', content, re.DOTALL)
if json_match:
action_data = json.loads(json_match.group())
action = action_data["action"]
action_input = action_data["action_input"]
else:
return "无法解析LLM输出,请换一种说法重试"
# 执行工具或返回最终答案
if action == "Final Answer":
return action_input
elif action in self.tools:
tool = self.tools[action]
try:
# 工具执行,失败时回填错误信息
result = tool.run(action_input)
self.memory.append(HumanMessage(content=f"工具{action}执行结果: {result}"))
except Exception as e:
error_msg = f"工具{action}执行失败: {str(e)},请检查参数或换一种方式"
self.memory.append(HumanMessage(content=error_msg))
else:
# 未知动作,让LLM重新推理
self.memory.append(HumanMessage(content=f"未知动作'{action}',可用工具: {list(self.tools.keys())}"))
return "达到最大迭代次数,任务未完成"
核心逻辑说明:
- 循环控制:
while iterations < max_iters,配合max_iters=8防止死循环。实测8次足够处理90%的常见任务,超过8次的任务通常是问题描述不清晰。 - 错误处理:工具执行异常时,不直接跳出循环,而是将错误信息作为HumanMessage追加到消息序列,让LLM看到错误后重新推理。实测这种“错误回填”策略使任务完成率提升22%。
- 记忆窗口:滑动窗口裁剪防止上下文过长。保留5轮对话约10条消息,在gpt-4-1106-preview的128K上下文下完全够用。
5. 踩坑与优化:三个真实教训
5.1 坑1:JSON解析失败率高达30%
现象:LLM经常输出{"action": "web_search", "action_input": {"query": "天气"}}这种格式,但偶尔会输出Markdown代码块包裹的JSON,或直接输出自然语言。
解决:增加正则提取JSON的兜底逻辑,同时优化System Prompt,明确要求“不要输出多余解释,直接输出JSON”。优化后JSON解析成功率从68%提升到97%。
5.2 坑2:工具参数类型不匹配
现象:CalculatorTool的expression字段定义为str,但LLM有时会输出数字类型(如{"expression": 123}),导致Pydantic校验失败。
解决:在_run方法中增加str()强制转换,并在描述中明确“请用字符串形式传入表达式”。
5.3 优化:引入长期记忆(AutoGPT启发)
AutoGPT的任务拆解思路启发我加入长期记忆。用MemoryVectorStore存储历史任务的关键结果:
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import MemoryVectorStore
class LongTermMemory:
def __init__(self):
self.embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
self.store = MemoryVectorStore.from_documents([], self.embeddings)
def save(self, task, result):
self.store.add_texts([f"任务: {task}\n结果: {result}"])
def recall(self, query, k=3):
docs = self.store.similarity_search(query, k=k)
return "\n".join(d.page_content for d in docs)
在run方法开头插入:
relevant_history = self.long_memory.recall(user_input)
if relevant_history:
self.memory.append(HumanMessage(content=f"历史相关记录:\n{relevant_history}"))
加入长期记忆后,对于重复类型的问题(如“计算上月销售额”),不再需要重新搜索数据,直接复用历史结果,耗时从平均8.3秒降至5.1秒。
6. 效果数据对比
用50个测试任务(包含数学计算、信息查询、多步骤操作)对比三种方案:
| 方案 | 任务完成率 | 平均耗时 | 平均Token消耗 |
|---|---|---|---|
| LangChain AgentExecutor | 67% | 11.2s | 4,823 |
| 手写Agent(无长期记忆) | 82% | 8.3s | 3,650 |
| 手写Agent(带长期记忆) | 89% | 5.1s | 2,834 |
结论:手写Agent在完成率上比LangChain默认方案高22个百分点,主要得益于错误回填机制和更精准的工具调用(LangChain默认会用Final Answer过早收场)。Token消耗降低41%,长期记忆功不可没。
7. 总结与下一步
手写Agent的核心价值不在于“造轮子”,而是理解循环控制、记忆管理、错误恢复这三块拼图如何协同。当前实现仍有局限——不支持工具并行调用、没有基于Token预算的动态迭代控制。下一步计划:
- 用
LangGraph替代手写循环,享受其状态图控制能力 - 加入工具调用失败时的自动退避策略(如连续失败2次后切换工具)
- 将长期记忆升级为
ChromaDB持久化,支持跨会话记忆
如果你也在被Agent框架的黑盒问题困扰,建议花一个下午读读AgentExecutor源码,然后动手写个最小实现。控制权在手,才能调出性能上限。
完整代码已上传至GitHub仓库(搜索“simple-agent-100lines”),包含测试用例和性能基准脚本,欢迎Star。有问题可在评论区交流,我会在代码维护时同步更新。