一、问题背景:为什么我要自己写一遍Agent循环
先说结论:LangChain的create_react_agent和AutoGPT都能跑,但当你需要控制"第几步失败该重试、记忆什么时候裁、工具报错怎么回灌给模型"这些细节时,封装层的黑盒就开始咬人了。
我最近在做内部一个运维助手,需要模型根据告警文本决定调用query_metrics、fetch_logs还是restart_service。用现成Agent跑了两周,遇到三个具体问题:
- 工具抛异常时,默认行为是直接把
str(e)塞回消息,模型经常把"连接超时"理解成"服务不可用",然后错误地触发重启。 - 对话历史无限累积,跑到第15轮左右单次请求input token冲到9k+,成本和延迟都难受。
- 模型偶尔陷入"调工具→看结果→再调同一个工具"的死循环,AutoGPT的默认max_iterations是无限,得自己兜底。
所以这篇文章的目标很明确:手写一个最小但完整的Agent执行循环,把工具定义、记忆管理、错误处理、循环控制这四件事的控制权拿回来。代码基于LangChain 0.2.16(langchain-core==0.2.38、langchain-openai==0.1.22),Python 3.11。
二、环境与版本
python==3.11.9
langchain-core==0.2.38
langchain-openai==0.1.22
pydantic==2.8.2
openai==1.42.0
模型用gpt-4o-mini,temperature=0,max_tokens=1024。选mini不是因为省钱,是因为在工具选择这类结构化决策上,它和gpt-4o的准确率差距在我这批任务里只有约3个百分点(后面有效果数据),但延迟低一半。
关键点:不要用langchain全家桶,只装langchain-core和langchain-openai就够了。langchain主包会把langchain-community拖进来,装完接近300MB,而我们只需要BaseTool、ChatPromptTemplate和ChatOpenAI。
三、方案设计
整体是一个标准ReAct循环,但我在三个地方做了非默认处理:
用户输入
→ 构造消息(system + 裁剪后的历史 + 当前输入)
→ 调用LLM(带tools参数)
→ 若返回tool_calls:逐个执行工具(含重试)→ 结果作为ToolMessage回灌
→ 若返回纯文本:结束
→ 步数超限 / 连续重复工具调用:强制终止
四层控制:
- 工具层:用Pydantic定义参数Schema,
BaseTool子类实现_run,异常在_run内部捕获并返回结构化错误字符串,而不是抛出。 - 记忆层:只保留最近6轮(user+assistant+tool消息为一轮),system prompt固定不裁。
- 错误层:工具执行用
tenacity做指数退避,最多3次,base=0.5s;LLM调用单独重试(max_retries=2由openai client处理)。 - 循环层:
max_steps=8硬上限 + 连续两次相同(tool_name, args)则判定死循环并终止。
四、核心实现
4.1 工具定义
from typing import Type
from pydantic import BaseModel, Field
from langchain_core.tools import BaseTool
class QueryMetricsInput(BaseModel):
service: str = Field(description="服务名,如 order-service")
window_minutes: int = Field(default=5, description="查询时间窗口,单位分钟,范围1-60")
class QueryMetricsTool(BaseTool):
name: str = "query_metrics"
description: str = "查询指定服务在最近N分钟的CPU/内存/QPS指标。当用户询问性能或负载时使用。"
args_schema: Type[BaseModel] = QueryMetricsInput
def _run(self, service: str, window_minutes: int = 5) -> str:
# 关键:异常在这里吃掉,返回给模型的是可读的错误描述
try:
if not (1 str:
self.history.append(HumanMessage(content=user_input))
last_call, repeat_count = None, 0
for step in range(self.max_steps):
resp: AIMessage = self.llm.invoke(self._trim())
self.history.append(resp)
if not resp.tool_calls:
return resp.content
for call in resp.tool_calls:
sig = (call["name"], json.dumps(call["args"], sort_keys=True))
repeat_count = repeat_count + 1 if sig == last_call else 0
last_call = sig
if repeat_count >= 2:
self.history.append(ToolMessage(
content="ERROR: 检测到重复调用同一工具同参数,请停止并总结",
tool_call_id=call["id"]))
continue
t0 = time.time()
try:
result = self._exec_tool(call["name"], call["args"])
except Exception as e:
result = f"ERROR: 工具执行重试3次仍失败({type(e).__name__}),请告知用户"
print(f"[step {step}] {call['name']}({call['args']}) -> {time.time()-t0:.2f}s")
self.history.append(ToolMessage(content=str(result), tool_call_id=call["id"]))
return "已达到最大推理步数(8),基于现有信息无法给出确定结论,请缩小问题范围。"
两个设计细节值得说:
一是bind_tools放在__init__里,不要每次invoke重新bind,否则OpenAI的prompt caching命中率会掉。我实测同一会话内工具Schema部分(约400 token)在缓存命中时input计费按50%算,多轮场景能省15%左右。
二是repeat_count判断用完整参数签名,不是只比工具名。只比工具名会误杀"先查A服务再查B服务"这种合理连续调用。
五、踩坑与优化
坑1:ToolMessage的tool_call_id必须严格对应。 有一次我手动构造ToolMessage时漏了id,OpenAI直接返回400 tool_call_id is required。LangChain不会帮你补,AIMessage.tool_calls[i]["id"]是什么就得回填什么。
坑2:记忆裁剪不能切断tool_call和ToolMessage的配对。 如果按消息条数硬裁,可能把AIMessage(tool_calls)裁掉但留下ToolMessage,下一次请求直接报messages with role 'tool' must be a response to a preceding message with 'tool_calls'。所以我的_trim按3条一组(assistant带tool_calls + 对应ToolMessage + 下一轮user)对齐裁剪,memory_window * 3就是这个意思。
坑3:tenacity包在_exec_tool外层,但工具内部已经把异常转成字符串了,所以重试实际只对TimeoutError/ConnectionError这类在_run里没被捕获的漏网之鱼生效。 这是有意的——业务异常不该重试,网络异常才该。如果你把_run写成全捕获,tenacity那层就是摆设,得想清楚异常边界画在哪。
优化:温度设0 + 工具描述里写触发条件。 description里加"当用户询问性能或负载时使用"这类触发语,比单纯描述功能准确率高。我在20条测试样本上对比,加了触发语后工具选择准确率从85%到95%。
六、效果数据
在10类运维任务、每类5条共50条测试集上(人工标注期望工具序列),对比三个版本:
| 版本 | 工具选择准确率 | 任务完成率 | 平均步数 | 平均input token |
|---|---|---|---|---|
| 无重试、无记忆裁剪 | 88% | 76% | 4.1 | 3820 |
| +指数退避重试 | 91% | 88% | 4.3 | 4010 |
| +记忆窗口6轮+死循环检测 | 95% | 94% | 3.8 | 1530 |
失败率(未给出正确答案或异常终止)从18%降到4%,主要来自重试层;Token下降62%来自记忆裁剪,且因为上下文变短,模型"忘记当前目标"的情况反而少了——这点反直觉但确实发生了。延迟方面,单任务P50从3.2s降到2.4s,P95从9.1s降到6.7s(gpt-4o-mini,单并发)。
七、总结
手写Agent循环的价值不在于"不用框架",而在于你清楚每一层在干什么。四件事的优先级我的排序是:错误语义化 > 循环控制 > 记忆裁剪 > 工具定义。前两个决定Agent会不会"发疯",后两个决定它贵不贵。
如果后续要扩展,我会优先把_exec_tool换成异步(ainvoke)以支持并行工具调用,以及在记忆层加一个轻量的摘要节点,替代粗暴的滑动窗口。但就目前这个200行版本,已经能扛住内部日均2000次调用的运维问答了。
代码可以直接跑,把QueryMetricsTool._run里的伪实现换成你的真实查询即可。有疑问评论区聊。