一、问题背景:为什么我要自己写一遍Agent循环

先说结论:LangChain的create_react_agent和AutoGPT都能跑,但当你需要控制"第几步失败该重试、记忆什么时候裁、工具报错怎么回灌给模型"这些细节时,封装层的黑盒就开始咬人了。

我最近在做内部一个运维助手,需要模型根据告警文本决定调用query_metrics、fetch_logs还是restart_service。用现成Agent跑了两周,遇到三个具体问题:

  1. 工具抛异常时,默认行为是直接把str(e)塞回消息,模型经常把"连接超时"理解成"服务不可用",然后错误地触发重启。
  2. 对话历史无限累积,跑到第15轮左右单次请求input token冲到9k+,成本和延迟都难受。
  3. 模型偶尔陷入"调工具→看结果→再调同一个工具"的死循环,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里的伪实现换成你的真实查询即可。有疑问评论区聊。