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是如何决策的?我目前是简单的“后写覆盖前写”,但在复杂场景下可能需要在记忆里增加一个“冲突标记”。欢迎在评论区讨论你的做法。