一、为什么我还要手写Agent?LangChain的Agent框架不够用吗?

先说结论:LangChain的AgentExecutor确实能跑,但当你需要精细控制“何时停止、何时重试、记忆怎么存”时,它的黑盒特性会让人抓狂。

我最近在做一个内部运维机器人,需要Agent自主调用查日志执行命令读配置三个工具,并基于结果决定下一步操作。用LangChain自带的create_react_agent跑了一周,发现三个痛点:

  1. 循环失控:Agent在某个工具返回错误后,会反复调用同一工具,最多一次卡了9轮才退出。
  2. 记忆混乱:默认的ConversationBufferMemory会把工具返回的冗长日志也塞进上下文,导致token爆掉,单次任务平均消耗12k token,成本高得离谱。
  3. 错误处理缺失:工具抛异常时,Agent会直接崩溃,而不是尝试换个方式。

所以,我决定基于LangChain的底层组件,手写一个精简版的AutoGPT风格Agent——自己控制循环、记忆和错误恢复。

二、环境与版本:锁死版本,避免玄学问题

我的开发环境如下,建议保持一致:

  • Python 3.10.13
  • LangChain 0.1.0(注意,0.1.x和0.2.x的API差异巨大,尤其是BaseTool的接口)
  • langchain-openai 0.0.5
  • OpenAI模型:gpt-4-turbo-2024-04-09(temperature=0.2)
  • 依赖安装:pip install langchain==0.1.0 langchain-openai==0.0.5

为什么不用LangChain 0.2+?因为create_react_agent在0.2里改成了create_agent,且工具装饰器@tool的行为有变化,我懒得重新适配。

三、方案设计:AutoGPT的“思考-行动-观察”循环,手动实现

AutoGPT的核心思路是让LLM在循环中做三步:思考(Thought)行动(Action)观察(Observation),直到满足终止条件。

我的设计如下:

  1. 工具定义:用BaseTool子类,而不是@tool装饰器(因为需要更精细控制错误返回)。
  2. 记忆管理:只保留最近3轮(思考+行动+观察)作为短期记忆,更早的丢弃,控制token预算。
  3. 循环控制:最大迭代5次,如果第5次还没得到最终答案,强制终止并返回当前最佳结果。
  4. 错误处理:工具内部捕获异常,返回结构化错误信息,让LLM能“看到”错误并自我修正。

整体流程图:

while step  str:
        try:
            # 这里替换为真实的日志查询逻辑
            if service == "auth-service":
                return "2024-06-01 10:00:01 ERROR timeout connecting to db"
            elif service == "order-service":
                return "2024-06-01 10:00:02 INFO order created"
            else:
                return f"未找到服务 {service} 的日志"
        except Exception as e:
            # 关键:返回错误字符串,而不是抛出
            return f"[工具内部错误] {str(e)},请检查service参数是否拼写正确"

4.2 记忆管理:用deque实现滑动窗口记忆

我用了collections.deque(maxlen=6),这里的6表示最多保留6条消息(3轮思考+3轮观察)。每次循环后,把新的(思考, 观察)追加进去,最老的自动弹出。

from collections import deque
from typing import List, Dict

class SlidingMemory:
    def __init__(self, max_rounds: int = 3):
        # maxlen = max_rounds * 2 (思考+观察)
        self.storage = deque(maxlen=max_rounds * 2)

    def add(self, thought: str, observation: str):
        self.storage.append(("thought", thought))
        self.storage.append(("observation", observation))

    def build_context(self) -> str:
        context = ""
        for role, content in self.storage:
            if role == "thought":
                context += f"AI思考: {content}\n"
            else:
                context += f"观察结果: {content}\n"
        return context

实测效果:加入滑动窗口后,单次任务token消耗从12k降到了4.5k(GPT-4-turbo输入价格$0.01/1k,单次任务成本从$0.12降到$0.045)。

4.3 循环控制与错误重试:最大5轮,失败提前终止

这里有个关键设计:如果LLM连续两次选择同一个工具且参数完全相同,我直接判定为死循环,强制跳出。这个逻辑救了我很多次。

def run_agent(user_query: str, tools: List[BaseTool], llm, max_steps: int = 5):
    memory = SlidingMemory(max_rounds=3)
    tool_map = {t.name: t for t in tools}
    last_action = None
    last_input = None
    repeat_count = 0

    for step in range(max_steps):
        prompt = build_prompt(user_query, tools, memory.build_context())
        response = llm.invoke(prompt).content

        if "Final Answer:" in response:
            return response.split("Final Answer:")[-1].strip()

        action, action_input = parse_response(response)  # 正则解析
        if action == "无法决策":
            return "Agent判定无法处理,请人工介入。"

        # 死循环检测
        if action == last_action and action_input == last_input:
            repeat_count += 1
        else:
            repeat_count = 0
        if repeat_count >= 2:
            return f"检测到重复操作({action}: {action_input}),已强制终止。"

        last_action, last_input = action, action_input

        if action not in tool_map:
            observation = f"工具 {action} 不存在,可用工具: {list(tool_map.keys())}"
        else:
            observation = tool_map[action].run(action_input)

        memory.add(f"调用工具 {action} 参数 {action_input}", observation)
        print(f"[Step {step+1}] action={action}, observation={observation[:50]}...")

    return "达到最大步数,返回当前记忆中的最后观察结果。" + memory.build_context()

4.4 构建Prompt:工具描述动态拼接

这一步很关键,我把工具描述、记忆、用户问题拼在一起,并强调“你必须输出JSON格式的Action,或者‘Final Answer’”。

def build_prompt(user_query: str, tools: List[BaseTool], memory_context: str) -> str:
    tools_desc = "\n".join([f"- {t.name}: {t.description}" for t in tools])
    prompt = f"""你是一个运维机器人,请根据工具返回结果决定下一步。你必须严格按以下格式输出:
1. 如果需要调用工具,输出:Action: 工具名\nAction Input: JSON字符串
2. 如果得到最终答案,输出:Final Answer: 你的答案

可用工具:
{tools_desc}

历史记忆:
{memory_context or "无"}

用户问题:{user_query}
现在开始:"""
    return prompt

五、踩坑与优化:三个真实教训

5.1 工具返回类型必须是字符串,否则LangChain会报错

第一次用BaseTool时,我的_run返回了dict,结果LangChain内部会尝试用str()转换,但嵌套的中文引号会被转义,导致LLM误读。解决方案:_run一律返回格式化好的字符串

5.2 OpenAI的JSON输出不稳定,用正则兜底

我最初让LLM输出严格JSON,但实测在temperature=0.2下,大约有7%的概率输出Action: query_log\nAction Input: {"service": "auth"}这种非JSON格式。后来我写了正则re.search(r'Action Input: (.+)', response),直接抓取后半部分,再用json.loads解析,失败就返回“无法决策”。

5.3 死循环检测比“最大步数”更有效

之前只设了max_steps=5,但LLM可能会在5步内重复调用同一个工具3次,浪费了步数。加了repeat_count >= 2的检测后,平均提前1.8步终止,单次任务耗时从21秒降到了14秒。

六、效果数据:对比LangChain原生Agent

我用了20个测试用例(包含正常查询、工具不存在、参数错误、死循环诱导)做对比:

指标 LangChain AgentExecutor 手写Agent
工具调用成功率 67% 94%
平均完成时间 23s 14s
平均token消耗 11.8k 4.6k
死循环卡死率 25% 0%
错误恢复率(工具报错后自动换参) 30% 85%

提升最明显的是“错误恢复率”——手写Agent可以在工具返回错误信息后,从错误字符串中提取关键词(比如“未找到服务”),然后修正参数重试。而LangChain原生Agent往往会把错误信息当作最终答案直接返回。

七、总结:什么时候该手写?

如果只是Demo级别,直接用AgentExecutor没问题。但如果是生产环境,涉及成本控制、死循环兜底、工具错误恢复,我强烈建议基于LangChain底层组件手写循环。我的代码总共约200行,比想象中少,但控制力强了一个量级。

下一步我会尝试把SlidingMemory换成向量数据库存储(比如Chroma),支持长对话的持久化记忆,等有数据了再来分享。有问题欢迎评论区交流,特别是那些被LangChain黑盒坑过的朋友,你们懂的。