一、为什么还要手写 Agent
先说结论:LangChain 的 AgentExecutor 和 AutoGPT 适合快速验证,但如果你要排查「为什么模型第三步突然不调工具了」「为什么记忆越堆越乱」「为什么死循环烧了 200 次 token」,封装层反而会拖慢你。
我最近在做一个本地化的运维助手,模型是 Qwen2.5-7B-Instruct(GGUF Q4_K_M,llama.cpp b3600 启动),最初用 LangChain 0.1.16 的 create_react_agent,遇到两个具体问题:
- 工具描述稍微长一点,7B 模型就开始胡编工具名,
Invalid tool name报错后整个 chain 直接抛异常退出,没有重试机会。 ConversationBufferMemory默认把全部历史塞进 prompt,跑到第 6 轮时上下文 3800 token,推理时间从 1.8s 涨到 6.4s。
所以我决定手写一个。目标很明确:工具定义要显式、记忆要可控、错误要能重试、循环要能刹车。最终 380 行,跑在 16GB 内存的 MacBook Pro M1 上。
二、环境与版本
- Python 3.11.6
- llama-cpp-python 0.2.79(本地推理)
- Qwen2.5-7B-Instruct-Q4_K_M.gguf
- 无 LangChain 依赖,纯标准库 +
llama_cpp - 推理参数:
n_ctx=4096, temperature=0.1, top_p=0.9, max_tokens=512
温度设 0.1 很关键。Agent 场景要的是稳定遵循格式,不是创意。我试过 0.7,工具名拼错率从 4% 飙到 23%。
三、方案设计
整体是一个 ReAct 风格的循环,但做了三处简化:
- 不用正则解析,强制模型输出 JSON,用
json.loads解析,失败就走错误处理。 - 记忆分两层:
scratchpad(当前任务的推理轨迹,可裁剪)+history(跨轮对话,只保留最近 3 轮)。 - 循环控制:
max_steps=8,同时检测「连续两步动作完全相同」直接中断,防止原地打转。
数据流:用户输入 → 组装 prompt(系统提示 + 工具 schema + scratchpad)→ 模型输出 JSON → 解析 → 若是工具调用则执行并写回 scratchpad → 否则返回最终答案。
四、核心实现
4.1 工具定义
工具用 dataclass 描述,schema 直接给模型看,执行函数单独存。这里刻意不用装饰器魔法,就是为了调试时能一眼看清。
import json
from dataclasses import dataclass, field
from typing import Callable
@dataclass
class Tool:
name: str
description: str
parameters: dict
func: Callable
def to_schema(self) -> str:
return json.dumps({
"name": self.name,
"description": self.description,
"parameters": self.parameters
}, ensure_ascii=False)
def get_weather(city: str) -> str:
# 真实场景换成 API 调用,这里 mock
data = {"北京": 18, "上海": 24, "广州": 29}
return f"{city}当前气温 {data.get(city, '未知')} 摄氏度"
def calc(expression: str) -> str:
# 只允许数字和运算符,避免 eval 注入
allowed = set("0123456789+-*/(). ")
if not set(expression) self.max_scratch:
# 保留首条任务描述,砍掉最早的中间步骤
self.scratchpad = [self.scratchpad[0]] + self.scratchpad[-(self.max_scratch - 1):]
def add_history(self, q, a):
self.history.append({"q": q, "a": a})
if len(self.history) > self.max_history:
self.history = self.history[-self.max_history:]
def render(self):
lines = []
for h in self.history:
lines.append(f"用户: {h['q']}\n助手: {h['a']}")
for s in self.scratchpad:
lines.append(f"{s['role']}: {s['content']}")
return "\n".join(lines)
裁剪策略是踩坑换来的。最初直接 pop(0),结果模型忘了原始任务,第二步就开始答非所问。保留首条后,任务完成率从 68% 回到 91%。
4.3 循环控制与错误处理
主循环每步做四件事:组装 prompt、调用模型、解析 JSON、分发执行。解析失败或工具执行异常,都把错误信息写回 scratchpad,让模型自己修正,最多重试 2 次。
class Agent:
def __init__(self, llm, tools, max_steps=8, max_retry=2):
self.llm = llm
self.tools = tools
self.memory = Memory()
self.max_steps = max_steps
self.max_retry = max_retry
def _build_prompt(self, task):
tool_desc = "\n".join(t.to_schema() for t in self.tools.values())
return f"""你是一个会使用工具的助手。可用工具:
{tool_desc}
请严格输出 JSON,二选一:
{{"action": "工具名", "args": {{...}}}}
{{"answer": "最终答案"}}
历史:
{self.memory.render()}
当前任务:{task}
输出:"""
def _parse(self, text):
# 模型偶尔会包 ```json ```,剥掉
text = text.strip().removeprefix("```json").removesuffix("```").strip()
return json.loads(text)
def run(self, task):
self.memory.add_scratch("user", task)
last_action = None
repeat_count = 0
retry = 0
for step in range(self.max_steps):
raw = self.llm(self._build_prompt(task))
try:
parsed = self._parse(raw)
except json.JSONDecodeError:
retry += 1
if retry > self.max_retry:
return f"解析失败超过 {self.max_retry} 次,终止"
self.memory.add_scratch("system", "上一步输出不是合法 JSON,请重新输出")
continue
if "answer" in parsed:
self.memory.add_history(task, parsed["answer"])
return parsed["answer"]
action = parsed.get("action")
args = parsed.get("args", {})
# 防死循环:连续相同动作
sig = f"{action}:{json.dumps(args, sort_keys=True)}"
if sig == last_action:
repeat_count += 1
if repeat_count >= 2:
return "检测到重复动作,终止以避免死循环"
else:
repeat_count = 0
last_action = sig
tool = self.tools.get(action)
if not tool:
self.memory.add_scratch("system", f"工具 {action} 不存在,可用:{list(self.tools)}")
continue
try:
result = tool.func(**args)
except Exception as e:
self.memory.add_scratch("system", f"工具 {action} 执行失败:{e},请调整参数")
continue
self.memory.add_scratch("assistant", f"调用 {action}({args})")
self.memory.add_scratch("tool", str(result))
return "达到最大步数,未得出答案"
五、踩坑与优化
坑 1:模型输出带 markdown 代码块。 Qwen2.5 有 15% 概率把 JSON 包在 ```json 里。用 removeprefix/removesuffix 处理,比正则稳。
坑 2:工具参数类型不匹配。 模型把 expression 传成数字而非字符串,calc 里 set(expression) 直接报 TypeError。改成先 str() 转换,并在错误回写时明确提示期望类型。
坑 3:max_steps 设太大。 最初设 15,结果模型在第 9 步还在绕圈,白烧 token。改成 8 后,正常任务 3.2 步完成,异常任务最迟 8 步退出,单次任务平均耗时从 11.4s 降到 5.7s。
优化:prompt 里工具顺序固定。 Python 3.7+ dict 有序,但我在 _build_prompt 里显式按名称排序,避免每次顺序变化导致模型行为抖动。
六、效果数据
在 50 条测试任务上(含 12 条故意触发错误的):
- 任务完成率:94%(47/50),未手写前 LangChain 版本 71%
- 平均步数:3.2 步
- 平均耗时:5.7s(M1,7B Q4)
- 错误恢复:12 条错误任务中 11 条通过重试自行修正
- 死循环:0 次(重复动作检测生效 3 次)
七、总结
手写 Agent 的核心就四件事:工具 schema 要短而明确、记忆要分层且可裁剪、错误要回写给模型让它自愈、循环要有步数和重复双重刹车。这 380 行代码没有 LangChain 优雅,但每一行你都能改、能打日志、能解释为什么。等你把这套跑通,再回头看 AgentExecutor 的源码,会发现它做的也就是这些事,只是多包了几层。
如果你的模型更小(3B 以下),建议把工具压到 2 个以内,max_retry 提到 3;如果换 GPT-4o 这类,温度可以提到 0.3,步数压到 5,基本两步就能收敛。