一、为什么不用现成的AgentExecutor而要手写循环
你可能觉得我有病——LangChain明明提供了AgentExecutor,两行代码就能跑起来。但当我真在订单查询场景里用上它时,发现三个致命问题:
- 记忆无边界:默认的
ConversationBufferMemory会无限堆积历史,实测在第9轮对话时Prompt长度超过8000 token,GPT-4调用直接报错。 - 工具异常是哑弹:当订单接口返回503时,
AgentExecutor会把原始错误堆栈塞给LLM,模型会一本正经地编造“订单已发货”的假象。 - 循环次数不可控:在AutoGPT模式下,Agent容易陷入“思考→调用→再思考”的死循环,最夸张一次连续调了17次工具才给出最终答案。
所以我决定参照AutoGPT的任务分解模式,手写一个轻量级Agent内核。核心思路只有三件事:工具是函数+Schema、记忆是滑窗+摘要、错误是信号+重试。
二、环境与版本锁定
先把版本钉死,免得你复现时遇到玄学问题:
python==3.11.9
langchain==0.3.7
langchain-openai==0.2.14
openai==1.54.3
pydantic==2.9.2
sqlite-utils==3.37
注意:LangChain 0.3.x 里langchain.agents模块被拆分,必须用langchain_core包导入基类。另外OpenAI函数调用接口在langchain-openai里,不能混用老版本。
三、方案设计:一个“最小可用”Agent的四层结构
我的架构参照了AutoGPT的任务栈思想,但做了简化。整个Agent分四层:
| 层级 | 组件 | 职责 |
|---|---|---|
| L1 | 核心循环器 | 控制while True循环,维护迭代次数 |
| L2 | 上下文管理器 | 用SQLite存历史消息,每次请求前取最近6轮+摘要 |
| L3 | 工具注册中心 | 维护name → (function, Pydantic Schema)映射 |
| L4 | 错误恢复模块 | 捕获工具异常,格式化后回填给LLM作为新观察 |
设计原则是每个工具必须纯函数化——接受一个字典参数,返回一个字符串。绝不接受LangChain的BaseMessage类型,这能避免80%的序列化问题。
四、核心实现:工具定义与Agent循环
4.1 工具定义:用Pydantic锁死参数Schema
这是我最得意的一环。用pydantic.BaseModel作为工具入参模板,既能做运行时校验,又能直接生成OpenAI的functions格式:
from pydantic import BaseModel, Field
from langchain_core.tools import StructuredTool
class OrderQueryInput(BaseModel):
order_id: str = Field(description="订单号,格式如SO20231201")
user_id: str = Field(description="用户唯一标识")
def query_order(order_id: str, user_id: str) -> str:
"""查询订单状态和物流信息"""
# 模拟真实API调用,实际场景替换为requests.post
if not order_id.startswith("SO"):
raise ValueError("非法订单号前缀")
return f"订单{order_id}状态:已发货,预计3天后到达"
order_tool = StructuredTool.from_function(
func=query_order,
name="query_order",
description="根据订单号和用户ID查物流",
args_schema=OrderQueryInput
)
坑点:StructuredTool.from_function在0.3.x里要求args_schema必须是pydantic.v1.BaseModel的子类(兼容性包袱)。如果直接继承pydantic.BaseModel会在校验时报错,需用from pydantic.v1 import BaseModel。
4.2 主循环实现:带熔断和最大步数控制
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, ToolMessage
class AgentRunner:
def __init__(self, tools, llm, max_iterations=5):
self.tools = {t.name: t for t in tools}
self.llm = llm.bind_tools(tools) # 关键:绑定工具后LLM输出会带tool_calls
self.max_iterations = max_iterations
self.history = [] # 内存态,实际生产用SQLite持久化
def run(self, user_input: str):
self.history.append(HumanMessage(content=user_input))
for step in range(self.max_iterations):
# 1. 调用LLM,传入完整历史+系统指令
ai_msg = self.llm.invoke(self._build_context())
self.history.append(ai_msg)
# 2. 判断是否有工具调用意图
if not ai_msg.tool_calls:
return ai_msg.content # 最终答案
# 3. 执行工具调用
for tc in ai_msg.tool_calls:
tool_name = tc["name"]
tool_args = tc["args"]
if tool_name not in self.tools:
# 错误处理:非法工具名反馈给LLM
self.history.append(ToolMessage(content=f"工具{tool_name}不存在,可用: {list(self.tools.keys())}", tool_call_id=tc["id"]))
continue
try:
# 4. 真正执行,捕获一切异常
result = self.tools[tool_name].invoke(tool_args)
except Exception as e:
# 关键:把错误字符串作为观察返回
result = f"执行异常: {type(e).__name__}: {str(e)}"
self.history.append(ToolMessage(content=result, tool_call_id=tc["id"]))
# 防止死循环,返回最后一条LLM输出
return self.history[-1].content or "循环超过最大步数,已终止"
循环控制技巧:for step in range(max_iterations)替代AutoGPT的while True。我设了硬上限5步——真实场景中超过4步的工具链调用,要么是问题太复杂,要么是LLM在绕弯子。
4.3 记忆管理:滑窗+滚动摘要
def _build_context(self):
# 取最近6条消息作为滑窗
window = self.history[-6:] if len(self.history) > 6 else self.history
# 如果历史超过6条,把更早的压缩成摘要(用LLM做,但这里用简单截断演示)
if len(self.history) > 6:
old_msgs = self.history[:-6]
summary = f"[早期对话摘要] 共{len(old_msgs)}条,涉及订单查询和优惠券计算。"
system = SystemMessage(content=f"你是客服助手。{summary} 禁止重复查询已确认的信息。")
else:
system = SystemMessage(content="你是客服助手。只调用必要的工具。")
return [system] + window
坦白说,我的摘要策略很粗糙——用固定字符串代替真正的LLM压缩。但在测试集上,这个简单策略已经能把token消耗降低47%(从8200降到4300)。真正常态化的做法是用ConversationSummaryBufferMemory,但需要额外维护一个线程安全对象,我嫌重。
五、踩坑记录:工具返回的字符串被截断
这是最隐蔽的坑。某天测试时发现Agent在查完订单后突然回答“我不知道”,日志里工具明明返回了完整信息。
排查发现:StructuredTool.invoke()在工具返回字符串超过2000字符时会自动截断并加上... (truncated)。而我的物流接口返回的是一个JSON数组,刚好2008字符。
解决方案有两个:
- 在工具内部把长内容拆分成多个短消息返回——但破坏了工具原子性。
- 自定义工具函数,绕过
StructuredTool的自动截断。
我选了后者,直接注册普通函数:
# 不用StructuredTool,而是直接定义成普通函数
def query_logistics(order_id: str) -> str:
# 返回完整JSON,不截断
import json
return json.dumps({"tracking": [...]}, ensure_ascii=False)
# 手动构造Tool对象
from langchain_core.tools import Tool
logistics_tool = Tool.from_function(
func=query_logistics,
name="query_logistics",
description="查物流轨迹,返回JSON"
)
对比测试后确认:Tool.from_function不会做内容截断,虽然损失了Pydantic自动校验,但换来了数据完整性。
六、效果数据与调优总结
在83个真实客服问题组成的测试集上(含多轮追问、上下文指代),最终指标如下:
| 指标 | 数值 | 说明 |
|---|---|---|
| 工具调用准确率 | 92.3% | 参数完全匹配 |
| 平均对话轮数 | 3.2 | 含工具调用链 |
| 每次请求token数 | 4.3K | 滑窗+摘要后 |
| 循环超步数率 | 1.2% | 触发5步上限 |
| 幻觉答复率 | 0.8% | 相比AgentExecutor的6%大幅下降 |
核心调优三件事:
1. 错误信息要结构化:不要返回Exception: xxx,改成工具执行失败: [错误类型] 具体原因,LLM才能理解并选择替代方案。
2. 工具描述要带边界:在description里写明“仅当用户明确提供订单号时调用”,避免LLM在信息不足时瞎调。
3. 系统提示词加抑制条款:我在SystemMessage里加了“如果你记忆中没有该信息,必须询问用户,禁止编造”,这让幻觉率直降75%。
最后说句心里话:手写Agent循环最大的收益不是性能,而是你能看到每一步发生了什么。当LLM开始胡说八道时,你能在日志里定位是工具问题、记忆问题还是模型问题。这种可控性是AutoGPT那种黑盒框架给不了的。如果你想做生产级Agent,强烈建议从这200行代码开始改。