一、问题背景:为什么不用现成的AgentExecutor

先说结论:生产环境我依然推荐LangChain的AgentExecutor或LangGraph,但你必须自己写过一遍。

原因很实际。上个月接了个内部工具链项目,需要Agent帮运营同学做"查数据→算指标→写报告"的串联操作。一开始直接上create_react_agent + AgentExecutor,两天跑通demo,然后开始出问题:

  1. 工具报错时,AgentExecutor会把异常信息原样塞回prompt,模型有时会陷入"重试同一个错误调用"的死循环,跑到max_iterations=15才停,一次任务烧掉3万token。
  2. 记忆管理是个黑盒。ConversationBufferMemory在长对话里无限增长,第8轮之后prompt直接顶到8k上下文,响应延迟从1.2s涨到4.5s。
  3. 想看中间步骤的决策逻辑,得开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 错误处理的三个层次

我把错误分成三层,分别在不同位置处理,这是踩坑之后总结的:

  1. 参数层:Pydantic schema校验。模型给{"city": 123}这种,tool.invoke直接抛ValidationError,被_execute_tool捕获返回ERROR: ...给模型看。
  2. 执行层:工具内部异常,重试2次(tool_retry=2),还失败就把异常类型和截断后的message写回observation。关键是只回传200字符,完整traceback进日志不进prompt,否则一个长报错就吃掉1000 token。
  3. 循环层:死循环检测 + 步数/超时/预算三重兜底。死循环检测用(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行,直接可跑。有问题的评论区聊。