一、为什么我要手写一个Agent
先说结论:LangChain的AgentExecutor和AutoGPT的规划循环都很好用,但当你需要排查"为什么Agent在第4轮突然不调用工具了""为什么记忆把关键上下文挤掉了"这类问题时,黑盒封装会让你抓狂。
上个月我做一个内部运维助手,用LangChain 0.3.x搭了一版,行为不稳定:同样的输入,有时2轮结束,有时陷入工具调用死循环。翻源码发现是max_iterations默认值和early_stopping_method的组合在作怪,加上记忆模块默认只保留最近对话,工具返回的长文本把历史任务目标挤没了。
于是我决定手写一遍,把工具定义、记忆管理、错误处理、循环控制这四件事的每一行都握在自己手里。最终产物约300行Python,跑在GPT-4o-mini上,反而比封装版更可控。
本文代码基于:Python 3.11.7、openai==1.51.0、pydantic==2.9.2、tiktoken==0.7.0。不依赖LangChain。
二、方案设计:Agent的最小闭环
一个Agent的本质是一个循环:
while 未完成 and 步数 dict:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
"strict": True,
},
}
def run(self, args: dict) -> str:
# 第一层:参数校验交给 pydantic 或手写检查
required = self.parameters.get("required", [])
missing = [k for k in required if k not in args]
if missing:
return f"[ERROR] 缺少必填参数: {missing}"
try:
result = self.func(**args)
except Exception as e: # 第二层:执行异常
return f"[ERROR] 工具执行失败: {type(e).__name__}: {e}"
text = str(result)
if len(text) > self.max_result_chars: # 第三层:结果截断
text = text[: self.max_result_chars] + "...[truncated]"
return text
注册三个工具:计算器(用ast.literal_eval白名单,别用eval)、当前时间、文件读取(限制在白名单目录)。
import ast, operator, datetime, os
ALLOWED_OPS = {ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv}
def safe_eval(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in ALLOWED_OPS:
return ALLOWED_OPS[type(node.op)](safe_eval(node.left), safe_eval(node.right))
raise ValueError("不支持的表达式")
def calculator(expression: str) -> float:
return safe_eval(ast.parse(expression, mode="eval").body)
def now() -> str:
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def read_file(path: str) -> str:
base = os.path.realpath("./workspace")
real = os.path.realpath(os.path.join(base, path))
if not real.startswith(base):
raise PermissionError("路径越界")
with open(real, encoding="utf-8") as f:
return f.read()
TOOLS = {
"calculator": Tool("calculator", "计算数学表达式,支持+-*/",
{"type": "object", "properties": {"expression": {"type": "string"}},
"required": ["expression"], "additionalProperties": False}, calculator),
"get_time": Tool("get_time", "获取当前系统时间",
{"type": "object", "properties": {}, "additionalProperties": False}, now),
"read_file": Tool("read_file", "读取workspace目录下的文件",
{"type": "object", "properties": {"path": {"type": "string"}},
"required": ["path"], "additionalProperties": False}, read_file),
}
3.2 记忆管理:滑动窗口 + 滚动摘要
纯滑动窗口的问题是任务目标会被挤掉。我的做法:保留system消息 + 最近6条消息 + 一条summary。当历史超过6条时,把最老的2条交给LLM压缩成摘要并合并。
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o-mini")
class Memory:
def __init__(self, window: int = 6, client=None):
self.window = window
self.summary = ""
self.recent: list[dict] = []
self.client = client
def add(self, msg: dict):
self.recent.append(msg)
if len(self.recent) > self.window:
self._compress()
def _compress(self):
to_fold = self.recent[:2]
self.recent = self.recent[2:]
text = "\n".join(f"{m['role']}: {str(m.get('content'))[:300]}" for m in to_fold)
prompt = f"已有摘要:{self.summary or '无'}\n新增对话:\n{text}\n请合并为不超过150字的摘要,保留任务目标、关键数据和结论。"
resp = self.client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
self.summary = resp.choices[0].message.content.strip()
def build(self, system_prompt: str) -> list[dict]:
msgs = [{"role": "system", "content": system_prompt}]
if self.summary:
msgs.append({"role": "system", "content": f"[历史摘要] {self.summary}"})
msgs.extend(self.recent)
return msgs
实测:摘要压缩平均消耗约380 tokens/次,相比全量历史(任务后期常达3000+ tokens),单轮成本下降约60%。
3.3 循环控制与错误处理
循环体的核心是三个终止条件:模型返回finish、达到max_steps、检测到重复调用。
def run_agent(task: str, max_steps: int = 8) -> str:
mem = Memory(window=6, client=client)
mem.add({"role": "user", "content": task})
last_sig, repeat = None, 0
for step in range(1, max_steps + 1):
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=mem.build(SYSTEM_PROMPT),
tools=[t.to_openai_schema() for t in TOOLS.values()],
tool_choice="auto",
temperature=0,
parallel_tool_calls=False, # 单工具执行,简化状态管理
)
msg = resp.choices[0].message
if not msg.tool_calls: # 终止条件1:自然结束
mem.add({"role": "assistant", "content": msg.content})
return msg.content
mem.add({"role": "assistant", "content": msg.content,
"tool_calls": [tc.model_dump() for tc in msg.tool_calls]})
for tc in msg.tool_calls:
name = tc.function.name
try:
args = json.loads(tc.function.arguments)
except json.JSONDecodeError as e: # 模型偶尔产出坏 JSON
result = f"[ERROR] 参数不是合法JSON: {e},请重新生成"
else:
sig = (name, json.dumps(args, sort_keys=True))
repeat = repeat + 1 if sig == last_sig else 0
last_sig = sig
if repeat >= 1: # 终止条件2:重复调用
result = "[ERROR] 检测到重复调用,请换一种方式或直接给出答案"
else:
result = TOOLS[name].run(args) if name in TOOLS else f"[ERROR] 未知工具 {name}"
mem.add({"role": "tool", "tool_call_id": tc.id, "content": result})
return "[终止] 达到最大步数,最后摘要:" + (mem.summary or "无") # 终止条件3
SYSTEM_PROMPT里明确写了"每轮最多一个工具调用""工具报错时先分析原因,最多重试一次"。
四、踩坑与优化
坑1:strict: True下模型不传可选参数。 我最初给read_file的path设了默认值并标为非必填,结果strict模式要求所有字段必须在required里,API直接400。解决办法是全部字段必填,可选逻辑交给工具函数内部处理。
坑2:工具结果污染上下文。 一次read_file读了个12KB的日志,直接把后续轮次挤爆。加了max_result_chars=2000后解决,同时提示模型"结果被截断时可分段读取"。
坑3:重复调用死循环。 模型遇到[ERROR] 路径越界时会原样重试。加了签名比对后,第二次相同调用直接返回"请换方式",模型随即改用相对路径或放弃,实测死循环率从17%降到2%。
坑4:摘要丢任务目标。 早期摘要prompt没强调"保留任务目标",压缩后模型忘了原始问题。在prompt里显式要求保留"任务目标、关键数据、结论"三项后修复。
五、效果数据
在50条测试任务(含计算、时间查询、文件读取及组合任务)上对比:
| 指标 | LangChain 0.3 封装版 | 手写版 |
|---|---|---|
| 任务完成率 | 84% | 88% |
| 平均迭代轮数 | 3.4 | 2.7 |
| 单任务平均token | 4200 | 2650 |
| 死循环发生率 | 9% | 2% |
| 错误自恢复成功率 | — | 78% |
token下降主要来自记忆压缩和结果截断。注意样本量小,数据仅供方向参考。
六、总结
手写Agent的收益不在性能,而在可观测性。四个模块里,工具定义决定能力边界,记忆管理决定长任务稳定性,错误处理决定鲁棒性,循环控制决定成本上限。LangChain/AutoGPT适合快速验证,但一旦要调优,理解这套循环的每一行是绕不开的。
下一步我打算把工具执行改成异步并发(parallel_tool_calls=True),并给记忆加一层向量检索做长期记忆。代码已放在本地workspace,约300行,无外部框架依赖。