一、为什么我决定手写一个Agent

先说结论:LangChain的initialize_agent和AutoGPT的autogpt包确实能跑,但当你需要控制每一步的Token预算、自定义错误重试逻辑、或者把记忆压缩到固定窗口时,黑盒会让人抓狂。

我最近在做一个内部数据查询Agent,需求很简单:用户问“上个月华东区销售额最高的三个产品是什么”,Agent需要自己决定调用query_sales_dbget_regionsort_and_limit这几个工具,中间可能失败,也可能需要多轮追问。用现成框架跑了一周,发现两个硬伤:

  1. 工具调用失败后直接抛异常,整个Agent挂掉,没有降级路径。
  2. 记忆无限增长,跑20轮后Prompt超过8k tokens,响应从1.2秒涨到4.7秒。

所以我决定用LangChain的底层API(BaseToolChatOpenAIPromptTemplate)手写一个Agent循环。下面把完整实现拆开讲。

环境与版本
- Python 3.11.7
- LangChain 0.1.16
- langchain-openai 0.1.3
- OpenAI API(gpt-4-0125-preview,temperature=0,max_tokens=1024)
- 本地Redis 7.2用于记忆持久化(可选)

二、方案设计:四个模块的职责边界

我不打算做通用Agent,而是针对“工具调用型任务”设计一个最小闭环。架构如下:

用户输入 → Prompt组装 → LLM推理 → 解析输出
                ↑                      ↓
            记忆管理 ← 循环控制 ← 工具执行
                                      ↓
                                  错误处理

核心决策:
- 工具定义:继承BaseTool,强制每个工具返回str,避免LLM解析JSON时出错。
- 记忆管理:滑动窗口 + 摘要压缩。最近3轮完整保留,更早的用LLM生成一句话摘要。
- 错误处理:工具执行失败时,不抛异常,而是返回"ERROR: {原因}",让LLM决定重试或换工具。
- 循环控制:最大迭代6次,超过则强制返回当前最佳结果。每次迭代检查是否包含Final Answer:标记。

三、核心实现:代码与细节

3.1 工具定义(BaseTool子类)

from langchain.tools import BaseTool
from pydantic import BaseModel, Field
import sqlite3

class SalesQueryInput(BaseModel):
    region: str = Field(description="区域名称,如'华东'")
    month: str = Field(description="月份,格式YYYY-MM")

class SalesQueryTool(BaseTool):
    name = "query_sales_db"
    description = "查询指定区域和月份的销售额数据,返回JSON字符串"
    args_schema = SalesQueryInput

    def _run(self, region: str, month: str) -> str:
        try:
            conn = sqlite3.connect("sales.db")
            cursor = conn.cursor()
            cursor.execute(
                "SELECT product, amount FROM sales WHERE region=? AND month=? ORDER BY amount DESC LIMIT 10",
                (region, month)
            )
            rows = cursor.fetchall()
            conn.close()
            if not rows:
                return f"ERROR: 未找到{region}{month}的销售数据"
            return str([{"product": r[0], "amount": r[1]} for r in rows])
        except Exception as e:
            return f"ERROR: 数据库查询失败 - {str(e)}"

    async def _arun(self, *args, **kwargs):
        raise NotImplementedError("不支持异步")

注意_run里捕获了所有异常并返回ERROR:前缀。这是后面错误处理的基础——LLM看到ERROR会主动调整策略,而不是让程序崩溃。

3.2 记忆管理:滑动窗口 + 摘要

from langchain.memory import ConversationSummaryBufferMemory
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4-0125-preview", temperature=0, max_tokens=1024)

memory = ConversationSummaryBufferMemory(
    llm=llm,
    max_token_limit=500,  # 超过500 token触发摘要
    return_messages=True,
    memory_key="chat_history"
)

ConversationSummaryBufferMemory有个坑:它会在每次save_context时同步调用LLM做摘要,延迟增加300-500ms。我的优化是改成异步摘要,或者用更便宜的gpt-3.5-turbo做摘要,实测摘要质量下降不到5%,但延迟从420ms降到180ms。

实际记忆中,我保留最近3轮完整对话(约200 tokens),更早的压缩成一句。这样在20轮对话后,Prompt长度稳定在800 tokens左右,而不是无限增长。

3.3 循环控制与错误处理

import re
from langchain.prompts import PromptTemplate

REACT_PROMPT = """你是一个数据分析Agent。可用工具:
{tools}

使用格式:
Question: 用户问题
Thought: 思考下一步
Action: 工具名,必须是[{tool_names}]之一
Action Input: 工具参数
Observation: 工具返回结果
...(重复Thought/Action/Observation)
Thought: 我现在知道最终答案了
Final Answer: 最终回答

历史对话:{chat_history}
Question: {input}
{agent_scratchpad}"""

def run_agent(user_input: str, max_iterations: int = 6) -> str:
    tools = [SalesQueryTool()]
    tool_names = ", ".join([t.name for t in tools])
    tool_desc = "\n".join([f"{t.name}: {t.description}" for t in tools])

    scratchpad = ""
    for i in range(max_iterations):
        prompt = REACT_PROMPT.format(
            tools=tool_desc,
            tool_names=tool_names,
            chat_history=memory.load_memory_variables({})["chat_history"],
            input=user_input,
            agent_scratchpad=scratchpad
        )
        response = llm.invoke(prompt).content

        if "Final Answer:" in response:
            final = response.split("Final Answer:")[-1].strip()
            memory.save_context({"input": user_input}, {"output": final})
            return final

        # 解析Action和Action Input
        action_match = re.search(r"Action:\s*(\w+)", response)
        input_match = re.search(r"Action Input:\s*(.+)", response)

        if not action_match or not input_match:
            scratchpad += f"\n{response}\nObservation: 解析失败,请按格式输出Action和Action Input"
            continue

        tool_name = action_match.group(1)
        tool_input = input_match.group(1).strip()

        # 执行工具,错误处理内嵌
        tool = next((t for t in tools if t.name == tool_name), None)
        if not tool:
            observation = f"ERROR: 工具'{tool_name}'不存在,可用工具:{tool_names}"
        else:
            try:
                # 这里简化参数解析,实际用args_schema
                observation = tool._run(**eval(f"dict({tool_input})"))
            except Exception as e:
                observation = f"ERROR: 工具执行异常 - {str(e)}"

        scratchpad += f"\n{response}\nObservation: {observation}\n"

    # 超过最大迭代,强制返回
    return "达到最大迭代次数,当前结果:" + scratchpad[-200:]

关键点:
- 错误不中断:工具失败返回ERROR:,LLM下一轮会看到并调整。
- 解析失败降级:如果LLM没按格式输出,追加提示让它重试,而不是直接崩溃。
- 迭代上限:6次是实测平衡点。超过6次的任务,要么是问题太复杂,要么是LLM陷入死循环,强制返回比无限等待好。

四、踩坑与优化

坑1:eval解析参数不安全且易错。上面代码用eval只是演示,生产环境必须用args_schema.parse_obj。我实际用json.loads加正则提取,错误率从12%降到2%。

坑2:记忆摘要的LLM调用拖慢响应。改成gpt-3.5-turbo做摘要,并且只在Token超过阈值时触发,平均每3轮才调用一次。

坑3:工具描述太模糊导致LLM选错。比如query_sales_dbquery_inventory,LLM经常混。解决方法是在description里加示例:“查询销售额,如'华东2024-03',不是库存”。

优化:并行工具调用。如果LLM一次返回多个Action(gpt-4支持),可以用asyncio.gather并行执行。实测两个独立查询从1.8秒降到1.1秒。

五、效果数据

在50个测试问题上(涵盖单工具、多工具、错误重试场景):

指标 原始版本 优化后
任务成功率 76% 94%
平均迭代次数 3.8 2.3
平均响应时间 3.2s 1.9s
20轮后Prompt tokens 4200 810
工具失败恢复率 42% 89%

失败恢复率的提升主要来自ERROR:返回和重试提示。之前工具失败直接抛异常,现在LLM会尝试换参数或换工具。

六、总结

手写Agent并不复杂,核心是把LLM当成一个会犯错的推理引擎,而不是万能函数。四个模块里,错误处理循环控制最容易被忽视,但恰恰是稳定性的关键。

如果你也在用LangChain或AutoGPT,建议先手写一个最小循环,理解每一步的Token和延迟开销,再决定要不要上框架。完整代码我放在了GitHub(搜索minimal-langchain-agent),跑起来只需要一个OpenAI API Key和SQLite文件。

下一步我打算把记忆换成向量数据库,看看长对话下的表现。有兴趣的可以一起讨论。