**

一、为什么我要自己写Agent循环

上个月接了个内部需求:让模型能查数据库、读文档、调HTTP接口,串起来回答运维问题。第一反应是用LangChain的initialize_agent,三行代码搞定。结果上线两天就出问题:某次工具返回超时,Agent直接卡死;另一次模型反复调用同一个搜索工具,烧了8万token还在打转;还有记忆无限增长,第15轮对话直接把context撑爆。

翻源码才发现,AgentExecutor的默认行为对生产环境并不友好:max_iterations默认15但错误处理很粗糙,early_stopping_method="force"会直接丢弃中间结果,memory是简单的ConversationBufferMemory全量拼接。于是我决定把Agent循环拆开自己写,搞清楚每一层在干什么。

本文的目标:用LangChain的底层组件(ChatModel、Tool、Prompt),但不用AgentExecutor,手写一个可控的Agent。技术栈版本如下:

Python 3.11.6
langchain==0.1.17
langchain-openai==0.1.6
openai==1.30.1
pydantic==2.7.1

二、方案设计:Agent本质是个带状态的while循环

剥开所有包装,Agent的核心就四件事:

  1. 工具定义:把Python函数转成模型能理解的schema(name + description + JSON参数)
  2. 循环控制:LLM输出 → 判断是工具调用还是最终答案 → 执行工具 → 结果回填 → 再调LLM
  3. 记忆管理:维护消息历史,但要有压缩策略,不能无限增长
  4. 错误处理:工具异常、模型输出格式错误、超时、死循环,都要有兜底

整体架构我用一个SimpleAgent类承载,内部状态是List[BaseMessage]。循环伪代码:

while step  str:
    """根据用户ID查询用户基本信息,包括姓名、等级、注册时间。
    参数 user_id 必须是正整数。"""
    # 模拟数据库查询
    if user_id  str:
    """在内部知识库中搜索文档片段。keyword为搜索关键词,top_k控制返回条数(1-5)。"""
    top_k = max(1, min(5, top_k))
    return json.dumps([{"doc": f"关于{keyword}的说明文档", "score": 0.92}][:top_k], ensure_ascii=False)

class ToolRegistry:
    def __init__(self):
        self.tools: Dict[str, BaseTool] = {}
    def register(self, t: BaseTool):
        self.tools[t.name] = t
    def schemas(self) -> List[Dict]:
        # 转成OpenAI function calling格式
        return [{"type": "function", "function": {
            "name": t.name,
            "description": t.description,
            "parameters": t.args_schema.model_json_schema() if t.args_schema else {"type": "object", "properties": {}}
        }} for t in self.tools.values()]
    def execute(self, name: str, args: Dict[str, Any]) -> str:
        if name not in self.tools:
            return f"[ERROR] 未知工具: {name}"
        return self.tools[name].invoke(args)

这里有个细节:@tool对docstring很敏感,description写不清楚,模型就选错工具。我踩过的坑是把"查询用户"和"搜索文档"的description都写得太泛,模型在"查张三的资料"这类query上频繁调错。后来在description里明确写了"用户ID是数字"、"关键词是文本",准确率明显回升。

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

全量拼接的memory在第10轮左右就会让token成本翻倍。我的策略是保留最近N轮原文 + 更早内容做摘要:

from langchain_core.messages import BaseMessage, SystemMessage, HumanMessage, AIMessage
from langchain_openai import ChatOpenAI

class HybridMemory:
    def __init__(self, llm: ChatOpenAI, window: int = 6, max_summary_tokens: int = 300):
        self.llm = llm
        self.window = window              # 保留最近6条消息原文
        self.summary: str = ""
        self.buffer: List[BaseMessage] = []
        self.max_summary_tokens = max_summary_tokens

    def add(self, msg: BaseMessage):
        self.buffer.append(msg)
        if len(self.buffer) > self.window * 2:   # 超出阈值触发压缩
            self._compress()

    def _compress(self):
        old = self.buffer[:-self.window]
        text = "\n".join(f"{m.type}: {m.content[:300]}" for m in old if m.content)
        prompt = f"将以下对话压缩为不超过{self.max_summary_tokens}字的事实摘要,保留关键实体和结论:\n{text}"
        new_summary = self.llm.invoke([HumanMessage(content=prompt)]).content
        self.summary = (self.summary + "\n" + new_summary)[-1200:]  # 摘要本身也限长
        self.buffer = self.buffer[-self.window:]

    def get_messages(self) -> List[BaseMessage]:
        msgs = []
        if self.summary:
            msgs.append(SystemMessage(content=f"[历史摘要] {self.summary}"))
        return msgs + self.buffer

window=6是实测调出来的:太小(3)模型丢失上下文,太大(10)压缩收益不明显。摘要调用本身也耗token,所以只在超阈值时触发,平均每7-8轮压缩一次。

3.3 循环控制与错误处理:三层防护

这是最容易被忽略的部分。我的SimpleAgent主循环:

import random
from openai import RateLimitError, APITimeoutError

class SimpleAgent:
    def __init__(self, llm, registry: ToolRegistry, memory: HybridMemory,
                 max_steps: int = 8, tool_retries: int = 2):
        self.llm = llm
        self.registry = registry
        self.memory = memory
        self.max_steps = max_steps
        self.tool_retries = tool_retries
        self.call_history: List[str] = []   # 用于死循环检测

    def _safe_tool_call(self, name: str, args: Dict) -> str:
        for attempt in range(self.tool_retries + 1):
            try:
                return self.registry.execute(name, args)
            except (APITimeoutError, RateLimitError) as e:
                if attempt == self.tool_retries:
                    return f"[ERROR] 工具{name}重试{self.tool_retries}次仍失败: {e}"
                time.sleep((2 ** attempt) + random.random())   # 指数退避+抖动
            except Exception as e:
                return f"[ERROR] 工具{name}执行异常: {type(e).__name__}: {e}"
        return "[ERROR] unreachable"

    def run(self, user_input: str) -> str:
        self.memory.add(HumanMessage(content=user_input))
        for step in range(self.max_steps):
            messages = self.memory.get_messages()
            try:
                resp = self.llm.bind(tools=self.registry.schemas()).invoke(messages)
            except Exception as e:
                return f"[AGENT_ERROR] 模型调用失败: {e}"

            self.memory.add(resp)

            if not resp.tool_calls:
                return resp.content or "[空响应]"

            for call in resp.tool_calls:
                sig = f"{call['name']}:{json.dumps(call['args'], sort_keys=True)}"
                if self.call_history.count(sig) >= 2:
                    return f"[AGENT_ERROR] 检测到重复工具调用,终止。最后尝试: {sig}"
                self.call_history.append(sig)

                result = self._safe_tool_call(call["name"], call["args"])
                from langchain_core.messages import ToolMessage
                self.memory.add(ToolMessage(content=result, tool_call_id=call["id"]))

        return "[AGENT_ERROR] 达到最大迭代步数,未得到最终答案"

三层防护各司其职:

  • 工具层:_safe_tool_call捕获超时/限流做指数退避,其他异常直接转成[ERROR]文本回填给模型——让模型自己看到错误并决定下一步,比直接抛异常终止优雅得多。实测模型看到"非法user_id"后,60%的情况会修正参数重试。
  • 循环层:max_steps=8是硬上限。call_history做重复检测,同一工具+参数出现2次就判定死循环。这里踩过坑:一开始只比工具名,结果模型用不同参数反复调同一工具被误杀;改成比完整签名后准确了。
  • 模型层:invoke外面包try,防止网络抖动直接崩。

3.4 跑起来看看

if __name__ == "__main__":
    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0, timeout=30)
    reg = ToolRegistry()
    reg.register(query_user_db)
    reg.register(search_docs)
    agent = SimpleAgent(llm, reg, HybridMemory(llm))
    print(agent.run("帮我查一下用户ID为1024的等级,然后搜一下VIP2的权益文档"))

预期输出会依次触发query_user_db(1024)和search_docs("VIP2 权益"),最后汇总成自然语言答案。

四、踩坑与优化记录

坑1:ToolMessage的tool_call_id必须对上。 早期版本我漏传tool_call_id,OpenAI API直接400报错。这是function calling协议的硬要求,每个tool结果必须关联到对应的call id。

坑2:temperature=0不等于确定性。 即使温度0,模型偶尔仍会重复调工具。所以重复检测不能省。

坑3:摘要压缩会丢信息。 有次用户前面说过"只看2023年数据",压缩后被摘要吞掉,后续回答跑偏。解决办法是在system prompt里加一句"若摘要与当前问题冲突,以最近对话为准"。

优化:工具结果截断。 某HTTP工具返回了12KB JSON,直接撑爆context。我在_safe_tool_call里加了result[:4000]截断,配合提示"结果过长已截断"。

五、效果数据

在内部50条运维问答测试集上对比(gpt-4o-mini,单条平均2.3次工具调用):

方案 任务完成率 平均token/任务 死循环次数
裸调LLM(无工具) 42% 1.1k 0
LangChain AgentExecutor默认 81% 4.8k 3
本文手写Agent 89% 3.7k 0

手写版在完成率高8个点的同时token少23%,主要收益来自记忆压缩和死循环拦截。代价是代码量从3行变成200行,以及对模型function calling能力的硬依赖。

六、总结

Agent不神秘,核心就是一个带工具调度、记忆压缩、错误兜底的while循环。LangChain的AgentExecutor适合快速验证,但要上生产,把循环拆开自己控制反而更省心。几个建议:工具description当API文档写;记忆一定要有压缩策略;错误尽量转成文本回填给模型而非直接抛;死循环检测按完整调用签名做。代码已跑通,拿去改改就能用。

后续我打算把工具调用改成并行(多个独立tool_call并发执行),以及接入一个轻量的向量检索做长期记忆,有兴趣的可以关注。