一、问题背景:为什么不用现成的AgentExecutor
先说结论:生产环境我依然推荐LangChain的AgentExecutor或LangGraph,但你必须自己写过一遍。
原因很实际。上个月接了个内部工具链项目,需要Agent帮运营同学做"查数据→算指标→写报告"的串联操作。一开始直接上create_react_agent + AgentExecutor,两天跑通demo,然后开始出问题:
- 工具报错时,AgentExecutor会把异常信息原样塞回prompt,模型有时会陷入"重试同一个错误调用"的死循环,跑到
max_iterations=15才停,一次任务烧掉3万token。 - 记忆管理是个黑盒。
ConversationBufferMemory在长对话里无限增长,第8轮之后prompt直接顶到8k上下文,响应延迟从1.2s涨到4.5s。 - 想看中间步骤的决策逻辑,得开
verbose=True读一大坨彩色日志,没法结构化落库做分析。
所以我花了一个周末,把Agent的核心循环手写了一遍。写完之后对LangChain那几个抽象(Tool、Memory、Callback)的理解完全不一样了,也知道了哪些地方该用框架、哪些地方必须自己控。
本文的代码基于 LangChain 0.3.7、openai 1.54.0、Python 3.11,模型用gpt-4o-mini(temperature=0,成本可控)。文末有完整仓库结构说明。
二、环境与版本
python==3.11.9
langchain==0.3.7
langchain-core==0.3.15
langchain-openai==0.2.8
openai==1.54.0
pydantic==2.9.2
关键配置参数(这些数字后面会反复用到):
| 参数 | 值 | 说明 |
|---|---|---|
max_iterations |
8 | 循环上限,超过强制终止 |
max_execution_time |
60s | 墙钟超时 |
max_token_budget |
12000 | 累计token预算 |
memory_window |
6 | 保留最近6轮对话 |
tool_retry |
2 | 单工具失败重试次数 |
temperature |
0 | 决策稳定性优先 |
三、方案设计:ReAct循环的四个模块
整体就是一个while循环,每轮做四件事:
用户输入
↓
[记忆组装] → 拼接 system prompt + 历史窗口 + 工具描述
↓
[LLM决策] → 输出 Thought + Action(工具名+参数) 或 Final Answer
↓
[工具执行] → 参数校验 → 执行 → 异常捕获 → 结果截断
↓
[循环控制] → 判断终止条件(Final / 超步数 / 超时 / 超预算)
↓
回到[记忆组装],把 Observation 追加进scratchpad
这里有个设计取舍值得说:我没有用LangChain的AgentOutputParser,而是让模型直接输出JSON。原因是我用的gpt-4o-mini在function calling上很稳,用with_structured_output约束成Pydantic模型,比解析Action: xxx\nAction Input: {...}这种文本格式可靠得多。实测文本解析的格式错误率约7%,结构化输出降到0.8%。
记忆管理我分成两层:
- 短期记忆(scratchpad):当前任务内的Thought/Action/Observation序列,每轮全量带上,但做token截断。
- 长期记忆(history):跨任务的对话摘要,用滑动窗口保留最近6轮,超出部分用LLM压缩成一句话。
四、核心实现
4.1 工具定义
用Pydantic定义工具的入参schema,LangChain的@tool装饰器会自动从docstring和类型注解生成描述——这个描述质量直接决定模型选工具的准确率,必须写清楚"什么时候用"而不只是"是什么"。
from langchain_core.tools import tool
from pydantic import BaseModel, Field
import json, time
class CalcInput(BaseModel):
expression: str = Field(description="要计算的数学表达式,例如 '(1200*0.85)+350',仅支持四则运算和括号")
@tool("calculator", args_schema=CalcInput)
def calculator(expression: str) -> str:
"""当你需要进行数值计算时使用此工具。不要自己心算,所有算术一律走这个工具。"""
allowed = set("0123456789+-*/(). ")
if not set(expression) str:
"""查询指定城市当前天气。仅当用户明确询问天气、温度、是否下雨时使用。"""
# 模拟外部API,故意留10%失败率用于测试错误处理
if time.time() % 10 str:
start = time.time()
scratchpad = []
last_call, repeat_count = None, 0
for step in range(self.max_iterations):
# 终止条件1:超时
if time.time() - start > self.max_seconds:
return f"[超时终止] 已执行{step}步,最后观察:{scratchpad[-1] if scratchpad else '无'}"
# 终止条件2:token预算
if self.total_tokens > self.token_budget:
return f"[预算终止] 累计{self.total_tokens}tokens"
msgs = self._build_messages(user_input, scratchpad)
step_out: AgentStep = structured_llm.invoke(msgs)
self.total_tokens += sum(len(m.content) for m in msgs) // 3 # 粗略估算
if step_out.action == "final_answer":
answer = step_out.action_input.get("answer", "")
self.history.append((user_input, answer))
return answer
# 终止条件3:死循环检测
call_sig = (step_out.action, json.dumps(step_out.action_input, sort_keys=True))
if call_sig == last_call:
repeat_count += 1
if repeat_count >= 2:
return f"[循环终止] 工具{step_out.action}连续重复调用"
else:
repeat_count, last_call = 0, call_sig
observation = self._execute_tool(step_out.action, step_out.action_input)
scratchpad.append({
"thought": step_out.thought,
"action": step_out.action,
"action_input": step_out.action_input,
"observation": observation,
})
return f"[步数终止] 达到max_iterations={self.max_iterations}"
def _execute_tool(self, name: str, args: dict) -> str:
tool = TOOL_MAP.get(name)
if tool is None:
return f"ERROR: 未知工具 {name},可用工具:{list(TOOL_MAP)}"
for attempt in range(2): # tool_retry=2
try:
result = tool.invoke(args)
return str(result)[:2000] # 截断,防上下文爆炸
except Exception as e:
if attempt == 1:
return f"ERROR: {type(e).__name__}: {str(e)[:200]}"
time.sleep(0.5)
return "ERROR: unreachable"
跑一下:
agent = SimpleAgent()
print(agent.run("杭州今天天气怎么样?如果气温高于20度,帮我算一下 (22*1.8)+32 是多少"))
# 典型输出(2-3步):
# 多云 22℃ → 22>20 → calculator('(22*1.8)+32') → 71.6
4.3 错误处理的三个层次
我把错误分成三层,分别在不同位置处理,这是踩坑之后总结的:
- 参数层:Pydantic schema校验。模型给
{"city": 123}这种,tool.invoke直接抛ValidationError,被_execute_tool捕获返回ERROR: ...给模型看。 - 执行层:工具内部异常,重试2次(
tool_retry=2),还失败就把异常类型和截断后的message写回observation。关键是只回传200字符,完整traceback进日志不进prompt,否则一个长报错就吃掉1000 token。 - 循环层:死循环检测 + 步数/超时/预算三重兜底。死循环检测用
(action, sorted_args)做签名,连续2次相同就终止——实测能拦住90%以上的"模型反复调同一个错工具"场景。
五、踩坑与优化
坑1:scratchpad无限增长。 第一版每轮把完整observation塞进scratchpad,第5步时prompt已经6k token。优化:observation硬截断2000字符,且当scratchpad超过5条时,把最早的几条用LLM压缩成一句"前情摘要"。压缩本身花约300 token,但省下的上下文更多。实测12个任务平均token从2840降到2330,降18%。
坑2:temperature=0不等于确定性。 同样的输入,OpenAI后端偶尔还是会给出不同工具选择(约3%概率),导致死循环检测的签名偶尔对不上。缓解办法是死循环检测放宽到"连续2次",而不是"连续1次"——宁可多跑一步,也别误杀。
坑3:工具描述写太"功能化"。 最初calculator描述是"计算数学表达式",模型经常自己心算完直接给答案。改成"不要自己心算,所有算术一律走这个工具"之后,工具调用率从62%提到97%。工具描述要写"触发条件",不是"功能说明",这是和写API文档最大的区别。
六、效果数据
在12个混合任务(4个纯计算、4个天气+计算串联、4个含故意失败工具的鲁棒性测试)上对比:
| 指标 | 自研SimpleAgent | LangChain AgentExecutor |
|---|---|---|
| 任务完成率 | 91.7% (11/12) | 91.7% (11/12) |
| 平均步数 | 3.2 | 3.4 |
| 平均token/任务 | 2330 | 2840 |
| P95延迟 | 4.1s | 5.3s |
| 死循环触发次数 | 0 | 2(跑到max_iterations) |
完成率打平,token和延迟略优,主要收益来自observation截断和更紧的终止条件。但代价是:自研版没有并行工具调用、没有流式输出、没有callback生态,要做这些还是得回到LangGraph。
七、总结
手写这一遍最大的收获不是"我能不用框架了",而是知道了框架每个抽象在解决什么问题:
- Tool = schema + 描述 + 执行函数,描述质量决定调度准确率;
- Memory = 分层 + 截断 + 压缩,无脑全量带上一定爆;
- 循环控制 = 步数/时间/预算/死循环四重兜底,少一个都会在生产上翻车;
- 错误处理 = 分层捕获 + 短消息回传,traceback进日志不进prompt。
我的实际选择是:原型和简单场景用自研内核(可控、好调试、便宜),复杂编排(并行、人工介入、多Agent)上LangGraph。AutoGPT那套"自主目标分解"的玩法,在没有强约束的情况下很容易发散,生产环境慎用——先把ReAct循环的四个模块吃透,比追新概念有用得多。
代码已按模块拆分(tools.py / agent.py / run_demo.py),约300行,直接可跑。有问题的评论区聊。