1. 问题背景:当框架成为瓶颈

上个月我在做一个内部运维机器人,需求很朴素:根据用户自然语言描述,调用现有的监控API、日志查询API和工单系统API,完成故障排查闭环。我第一反应是上LangChain的Agent + Tool组合,毕竟社区案例多得是。

但跑了三天就发现问题:
- 记忆不可控:ConversationBufferWindowMemory默认保留最近5轮,但每轮如果塞入大段JSON响应,token消耗直接爆炸(实测单轮峰值超过6k tokens,而GPT-4的8k上下文窗口根本扛不住)。
- 错误处理黑盒:工具抛异常时,LangChain的AgentExecutor只是把错误字符串拼回prompt,导致模型经常陷入“循环道歉”模式,最多一次连续重试7次,烧了0.5美元还没搞定。
- 循环控制缺失:无法限制Agent的“思考→行动→观察”循环次数,遇到模糊指令时它会自己跟自己对话,直到超时。

于是决定推倒重来,参考AutoGPT的任务分解思路(虽然它也被很多人吐槽不稳定),但它的“规划-执行-检查”循环骨架是清晰的。我要做的,是把这个骨架显式地写出来,并且加上硬性约束。

2. 环境与版本

  • Python 3.10.12
  • openai 1.12.0(使用gpt-4-turbo-preview模型)
  • 无LangChain依赖,仅用标准库 + requests
  • 操作系统:macOS 14.3 (M1 Pro)

核心思路:不用任何Agent框架,直接用OpenAI的function calling API来约束模型输出结构。这样至少错误是可解析的。

3. 方案设计:Agent的三个核心抽象

我把它拆成三个类:
1. AgentCore:负责与LLM通信,维护工具列表和对话历史。
2. MemoryManager:独立的记忆管理模块,不混在Agent逻辑里。
3. ToolRegistry:注册和分发工具调用。

关键设计决策:Agent循环的终止条件只有三个——成功返回结果给用户、达到最大迭代次数(我设为5)、或者主动放弃(标记为need_human)。

来看核心调度循环的骨架,这是整个Agent的心脏:

import json
from typing import Dict, Any, List, Optional

class AgentCore:
    def __init__(self, llm_client, memory_manager, tool_registry, max_iterations=5):
        self.llm = llm_client
        self.memory = memory_manager
        self.tools = tool_registry
        self.max_iterations = max_iterations

    def run(self, user_query: str) -> Dict[str, Any]:
        """主循环:思考 -> 行动 -> 观察,直到满足终止条件"""
        self.memory.add_user_message(user_query)
        iteration = 0

        while iteration  List[Dict[str, str]]:
        """返回完整的上下文,system prompt永远在最前"""
        return [{"role": "system", "content": self.system_prompt}] + self.messages

    def _trim_to_budget(self):
        """核心:从最旧的消息开始删,直到总token数低于预算"""
        while self._count_tokens() > self.token_budget and len(self.messages) > 2:
            # 删除最旧的一条消息(但保留最后两条用户消息作为基础上下文)
            self.messages.pop(0)

    def _count_tokens(self) -> int:
        total = len(self.encoder.encode(self.system_prompt))
        for msg in self.messages:
            total += len(self.encoder.encode(msg["content"]))
            if msg["role"] == "tool":
                # tool消息内容可能超长,需要额外附加的token
                total += 20  # 经验值:函数调用ID和结构开销
        return total

这里有个细节:为什么不用LangChain的ConversationTokenBufferMemory?因为它在裁剪时只保留“消息列表”,但OpenAI的function calling要求tool_call_id和对应的tool消息必须严格配对。如果裁剪掉了一条assistant消息但保留了它的tool结果,API就会报400错误。所以我这里的裁剪策略是成对删除:找到最早的一条assistant消息,连同它的所有tool子消息一起删。但为了简洁,上面代码只是简单pop,实际生产代码需要处理成对逻辑。

5. 工具定义与注册:用装饰器消灭样板代码

AutoGPT最大的问题是工具定义松散,纯靠prompt约束。我这里用OpenAI function calling的schema格式,同时用Python装饰器把函数签名自动转换成schema:

import inspect
import functools
from typing import get_type_hint

class ToolRegistry:
    def __init__(self):
        self._tools: Dict[str, Dict] = {}
        self._functions: Dict[str, callable] = {}

    def register(self, name: str, description: str):
        """装饰器:自动从函数签名生成OpenAI tool schema"""
        def decorator(func):
            # 获取类型标注,生成parameters schema
            hints = get_type_hints(func)
            signature = inspect.signature(func)
            properties = {}
            required = []

            for param_name, param in signature.parameters.items():
                if param_name == 'return':
                    continue
                if hasattr(param, 'default') and param.default is not inspect.Parameter.empty:
                    # 有默认值则为可选参数
                    properties[param_name] = {"type": self._map_type(hints.get(param_name, str)), "description": f"Parameter {param_name}"}
                else:
                    required.append(param_name)
                    properties[param_name] = {"type": self._map_type(hints.get(param_name, str)), "description": f"Parameter {param_name}"}

            schema = {
                "type": "function",
                "function": {
                    "name": name,
                    "description": description,
                    "parameters": {
                        "type": "object",
                        "properties": properties,
                        "required": required
                    }
                }
            }

            self._tools[name] = schema
            self._functions[name] = func

            @functools.wraps(func)
            def wrapper(*args, **kwargs):
                return func(*args, **kwargs)
            return wrapper
        return decorator

    def get_function_schemas(self) -> List[Dict]:
        return [t["function"] for t in self._tools.values()]  # 注意:OpenAI API需要的是function列表

    def execute(self, name: str, **kwargs) -> Any:
        if name not in self._functions:
            raise ValueError(f"Tool {name} not registered")
        return self._functions[name](**kwargs)

    @staticmethod
    def _map_type(python_type) -> str:
        mapping = {int: "integer", float: "number", str: "string", bool: "boolean"}
        return mapping.get(python_type, "string")

6. 踩坑记录与优化:三个血泪教训

教训一:不要信任模型的JSON输出。
最初我让模型直接输出JSON格式的动作,结果在50次测试中,有7次产生非法JSON(比如多余的逗号,或者把数字写成了123.0但schema要求integer)。后来切换到OpenAI的function calling参数tool_choice="auto"之后,非法JSON几乎消失了(50次测试仅出现1次,且是因为工具参数超出schema定义)。

教训二:错误处理不能只靠字符串拼接。
第一版当工具调用失败时,我只是把错误堆栈附在观察里返回给模型。结果模型经常道歉而不是修正参数。优化方案是在observation中增加一个hint字段,引导模型重新检查参数格式。这个改动让工具调用失败后的恢复成功率从23%提升到67%。

教训三:循环控制必须有硬上限。
AutoGPT默认允许无限循环,但在真实场景(比如查询工单)中,如果API持续返回超时,模型会一直重试直到超时。我做了三个措施:
1. 设置max_iterations=5,超过则返回“需要人工介入”状态;
2. 同一工具连续失败2次后,强制切换策略(提示模型换一个工具或放弃);
3. 记录每次迭代的token消耗,如果单次迭代超过1500 tokens,立即截断。

效果数据(模拟环境,50个故障排查任务):
- 平均迭代次数:3.2次(原方案6.8次)
- 平均耗时:4.1秒/任务(原方案7.3秒)
- Token消耗:平均2.1k tokens/任务(原方案5.6k)
- 工具调用失败后的任务成功率:从58%提升到89%

7. 总结与思考

这个手写Agent并不比LangChain高级,但它解决了我的核心痛点——可控性。你可以清楚地看到每一步发生了什么,token花在哪了,为什么失败。

如果你只是做原型验证,LangChain的AgentExecutor依然是最快的。但如果你要部署到生产环境,面对复杂的错误场景和严格的成本控制,我建议至少把记忆管理和循环控制这部分抽出来自己写。

最后留一个问题给读者:当多个工具返回的结果相互冲突时,你的Agent是如何决策的?我目前是简单的“后写覆盖前写”,但在复杂场景下可能需要在记忆里增加一个“冲突标记”。欢迎在评论区讨论你的做法。