1. 为什么不用AgentExecutor?—— 循环控制的失控现场
上周用LangChain自带的AgentExecutor跑一个“查询订单→计算折扣→调用支付接口”的流程,结果模型在第4步陷入自我对话:Thought: I need to check the order status... 连续重复7次,直到max_iterations=5被强制截断。问题在于——框架默认的循环终止条件只看max_iterations和stoptoken,完全没考虑工具返回的中间状态。
AutoGPT的教训更明显:它的ContinuousMode让模型自主决定是否继续,结果在长任务中模型倾向于“多走一步看看”,导致token消耗飙升。我需要一个可干预的循环控制器,能在每个步骤后检查工具返回的元数据,比如retry_count、memory_usage、confidence_score,并据此决定是继续、重试还是终止。
2. 环境锁定:Python 3.11 + LangChain 0.3.7 + 本地vLLM
langchain==0.3.7
langchain-openai==0.2.3
vllm==0.6.3.post1 (部署Qwen2.5-7B-Instruct)
python=3.11.8
注意:LangChain 0.3.x的BaseTool接口废弃了_run的**kwargs转发,必须显式声明args_schema,否则工具参数传递会静默丢失。我用的模型是Qwen2.5-7B-Instruct,temperature=0.3,top_p=0.9,max_tokens=512。
3. 工具定义:从装饰器到ToolRegistry
核心需求:工具要能被动态注册/注销,并且在Agent循环中能查看到工具的usage_count和last_error。直接使用LangChain的@tool装饰器不够——它不暴露运行时的状态。
# tools.py - 带运行时元数据的工具基类
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from typing import Type, Dict, Optional
class ToolRegistry(BaseModel):
tools: Dict[str, BaseTool] = {}
usage_count: Dict[str, int] = {}
last_error: Dict[str, Optional[str]] = {}
def register(self, tool: BaseTool):
self.tools[tool.name] = tool
self.usage_count[tool.name] = 0
self.last_error[tool.name] = None
def invoke(self, name: str, **kwargs):
tool = self.tools[name]
self.usage_count[name] += 1
try:
result = tool._run(**kwargs)
return {"status": "success", "data": result}
except Exception as e:
self.last_error[name] = str(e)
return {"status": "error", "message": str(e)}
# 具体工具示例 - 查询订单(带参数schema校验)
class OrderQueryInput(BaseModel):
order_id: str = Field(description="订单ID,格式如ORD-2024-001")
class OrderQueryTool(BaseTool):
name = "query_order"
description = "根据订单ID查询订单状态,返回JSON字符串"
args_schema: Type[BaseModel] = OrderQueryInput
def _run(self, order_id: str) -> str:
# 模拟数据库查询
if not order_id.startswith("ORD-"):
raise ValueError(f"非法订单ID: {order_id}")
return f'{{"order_id": "{order_id}", "status": "pending", "amount": 299.00}}'
4. 记忆管理:双缓冲滑动窗口 + token预算
用LangChain的ConversationBufferWindowMemory有坑:它只保存对话字符串,混合了推理链和工具输出。我改为分离记忆池:
reasoning_buffer:保存Thought/Action/Action Input,窗口大小=6轮tool_result_buffer:保存最近3次工具返回,用于上下文关联
关键技巧:每次循环后计算total_tokens,如果超过模型上下文窗口的70%(Qwen2.5-7B是32K,即22K),就强制清空reasoning_buffer,只保留最近的tool_result_buffer。
# memory.py - 双缓冲记忆模块
import json
from collections import deque
class DualBufferMemory:
def __init__(self, reason_window=6, tool_window=3, max_tokens=22000):
self.reason_buffer = deque(maxlen=reason_window)
self.tool_buffer = deque(maxlen=tool_window)
self.max_tokens = max_tokens
self._token_usage = 0
def add_reasoning(self, thought: str, action: str, action_input: dict):
self.reason_buffer.append({
"thought": thought,
"action": action,
"action_input": action_input
})
# 粗略估算:每个字符约0.3个token(中文场景)
self._token_usage += len(thought) * 0.3 + len(action) * 0.5
def add_tool_result(self, tool_name: str, result: dict):
self.tool_buffer.append({
"tool": tool_name,
"result": result
})
self._token_usage += len(json.dumps(result, ensure_ascii=False)) * 0.3
def build_prompt(self) -> str:
"""根据当前缓冲区构建模型输入"""
# 如果超预算,丢弃最旧的reasoning,保留tool_result
while self._token_usage > self.max_tokens and self.reason_buffer:
old = self.reason_buffer.popleft()
self._token_usage -= len(old["thought"]) * 0.3 + len(old["action"]) * 0.5
prompt_sections = []
for item in self.reason_buffer:
prompt_sections.append(
f"Thought: {item['thought']}\n"
f"Action: {item['action']}\n"
f"Action Input: {json.dumps(item['action_input'], ensure_ascii=False)}"
)
for item in self.tool_buffer:
prompt_sections.append(
f"Observation from {item['tool']}: {json.dumps(item['result'], ensure_ascii=False)}"
)
return "\n\n".join(prompt_sections)
5. 循环控制:重试退避 + 熔断 + 终止条件
这是手写Agent的精髓。我设定了三个独立计数器:
success_count:连续成功次数,如果>=3,把temperature降到0.1(减少探索)fail_count:连续失败次数,如果>=2,强制要求模型换一种工具调用策略token_budget:总token预算,默认18000,超出则立刻终止
终止条件不光是max_iters,还要检查工具返回的status是否全为error且无新信息。具体实现:
# agent_loop.py - 核心循环控制
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
class AgentCore:
def __init__(self, registry: ToolRegistry, memory: DualBufferMemory):
self.llm = ChatOpenAI(
model="Qwen2.5-7B-Instruct",
base_url="http://localhost:8000/v1", # vLLM服务地址
temperature=0.3,
max_tokens=512
)
self.registry = registry
self.memory = memory
self.max_iters = 10
self.token_budget = 18000
self.fail_count = 0
self.success_count = 0
def run(self, task: str) -> dict:
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个工具调用Agent。严格按格式输出:\n"
"Thought: \nAction: \nAction Input: "),
("user", f"任务:{task}\n可用工具:{list(self.registry.tools.keys())}")
])
chain = prompt | self.llm
total_tokens = 0
for step in range(self.max_iters):
# 1. 构建带记忆的输入
memory_prompt = self.memory.build_prompt()
response = chain.invoke({"task": task, "memory": memory_prompt})
total_tokens += response.usage_metadata["total_tokens"]
# 2. 解析模型输出
parsed = self._parse_response(response.content)
if not parsed:
self.fail_count += 1
continue
# 3. 执行工具调用
result = self.registry.invoke(parsed["action"], **parsed["action_input"])
# 4. 更新记忆和计数器
self.memory.add_reasoning(parsed["thought"], parsed["action"], parsed["action_input"])
self.memory.add_tool_result(parsed["action"], result)
if result["status"] == "success":
self.success_count += 1
self.fail_count = 0
else:
self.fail_count += 1
self.success_count = 0
# 5. 熔断机制:连续失败3次,直接终止
if self.fail_count >= 3:
return {"status": "failed", "reason": "连续失败超过3次", "steps": step}
# 6. 自适应温度:连续成功则降低探索
if self.success_count >= 3:
self.llm.temperature = 0.1
# 7. token预算检查
if total_tokens > self.token_budget:
return {"status": "partial", "reason": "token超预算", "steps": step}
return {"status": "completed", "steps": self.max_iters}
6. 踩坑记录:三个花了两小时的坑
坑1:LangChain 0.3的BaseTool不再自动解析args_schema的默认值。
在0.2.x中,如果action_input缺字段,会自动补默认值。0.3.7直接抛出ValidationError且不进入_run。解决:在invoke方法里捕获ValidationError,把错误信息返回给模型作为Observation,让模型自己修正。
坑2:vLLM的response.usage_metadata在流式模式下不完整。
我最初用stream=True,结果total_tokens始终为0,导致token预算失效。解决:关闭流式,改为一次性返回。代价是首token延迟从0.4秒增加到0.9秒,但换来可靠的token计数。
坑3:记忆里的中文+JSON混合导致模型输出格式漂移。
当tool_result_buffer中存有大量中文JSON时,模型偶尔会输出Action Input: {“order_id”: “ORD-2024-001”}(带中文引号)。我在_parse_response里加了正则替换:
import re
def _parse_response(self, content: str):
# 修复中文引号
content = content.replace("“", '"').replace("”", '"')
# 提取JSON部分
match = re.search(r'Action Input:\s*({.+?})', content, re.DOTALL)
if match:
try:
action_input = json.loads(match.group(1))
except json.JSONDecodeError:
return None
return {"thought": "", "action": ..., "action_input": action_input}
7. 效果数据:从78%到94%的折腾结果
在100个混合场景测试集上(包含数据库查询、API调用、计算任务各1/3),对比LangChain默认AgentExecutor:
| 指标 | AgentExecutor | 手写Agent |
|---|---|---|
| 工具调用成功率 | 78% | 94% |
| 平均单轮耗时 | 3.2s | 1.8s |
| 平均终止步数 | 5.7(多数因max_iters截断) | 3.2(自然完成) |
| token消耗(平均) | 12,400 | 8,100 |
提升最明显的场景是多步骤工具交替:默认Executor经常在第二步就“迷失”,而手写Agent因为记忆池分离了推理和结果,模型能更清晰地区分“我之前做了什么”和“我得到了什么”。
总结
手写Agent循环的核心价值不在于“重新发明轮子”,而在于每个控制点都能插桩——你可以精确控制何时重试、何时熔断、何时清空记忆。如果你只是跑demo,用AgentExecutor没问题;但如果你要上生产,建议花半天时间按上述结构实现一遍,你不会后悔的。
最后提醒:LangChain 0.3.x的API变动很快,建议锁定requirements.txt版本号,别用latest。