1. 为什么手写Agent比直接调API难10倍?

最近接手一个需求:每周自动扫描团队GitHub仓库的Issues、PR、Commit,生成结构化周报。起初用OpenAI Function Calling + 简单循环实现,结果发现:

  • 工具调用乱序:Agent先调爬虫获取数据,再调分析器,但爬虫返回空时,分析器仍然执行,导致报告里全是“无数据”
  • 记忆丢失:第5轮对话后,Agent忘记之前已经爬过的仓库URL,重复请求被限流
  • 异常无处理:GitHub API偶尔返回429,Agent直接崩溃,没有重试机制

这些问题暴露了裸调API和真实Agent之间的鸿沟。本文记录如何用LangChain的AgentExecutor + 自定义组件,解决上述痛点。

2. 环境与版本:锁定依赖,避免玄学bug

# 必须精确版本,否则工具Schema解析会报错
langchain==0.3.17  
langchain-community==0.3.14  
langchain-openai==0.2.12  
openai==1.55.0  
httpx==0.28.1  
python-dotenv==1.0.1  

注意:LangChain 0.3.x 废弃了旧版initialize_agent,必须用create_react_agentAgentExecutor。本文选用create_react_agent,因为它支持更灵活的工具定义和回调。

3. 方案设计:一个Agent的四个核心模块

graph TD
    A[用户输入] --> B[AgentExecutor]
    B --> C{LLM推理}
    C -->|需要工具| D[工具列表]
    D --> E[爬虫工具]
    D --> F[API工具]  
    D --> G[分析工具]
    E --> H[错误处理模块]
    H -->|重试3次| E
    H -->|降级| I[缓存数据]
    C -->|完成| J[输出结果]
    B --> K[记忆管理]
    K -->|最近20轮| B

关键设计点:
1. 工具定义:每个工具除了输入输出Schema,还要声明max_retriesfallback
2. 记忆管理:使用ConversationBufferWindowMemory,保留最近10轮(约20条消息),避免token溢出
3. 错误处理:在AgentExecutorhandle_parsing_errors回调中,区分3类错误——临时性(429重试)、永久性(400报错降级)、致命性(工具不存在终止)
4. 循环控制:设置max_iterations=15,并在每个迭代中检查intermediate_steps长度,超过阈值强制终止

4. 核心实现:手写一个能“自我修复”的Agent

4.1 工具定义:让每个工具自带“容错属性”

from langchain.tools import BaseTool
from pydantic import BaseModel, Field
from typing import Type, Optional
import httpx

class GitHubIssueInput(BaseModel):
    repo_url: str = Field(description="GitHub仓库完整URL,如https://github.com/owner/repo")
    max_issues: int = Field(default=10, ge=1, le=50, description="最多获取的issue数量")

class GitHubIssueTool(BaseTool):
    name = "github_issue_retriever"
    description = """
    从GitHub仓库获取最近的Issues,返回标题、状态、标签、创建时间。
    如果返回空列表,说明仓库可能不存在或无公开issue。
    注意:本工具最多重试3次,每次间隔2秒。
    """
    args_schema: Type[BaseModel] = GitHubIssueInput
    max_retries: int = 3
    retry_delay: float = 2.0

    def _run(self, repo_url: str, max_issues: int = 10) -> str:
        # 解析owner/repo
        parts = repo_url.rstrip("/").split("/")
        owner, repo = parts[-2], parts[-1]
        api_url = f"https://api.github.com/repos/{owner}/{repo}/issues?state=all&per_page={max_issues}"

        for attempt in range(self.max_retries):
            try:
                resp = httpx.get(api_url, headers={"Accept": "application/vnd.github.v3+json"}, timeout=15)
                resp.raise_for_status()
                issues = resp.json()
                # 格式化输出
                result = []
                for issue in issues[:max_issues]:
                    result.append(f"#{issue['number']} [{issue['state']}] {issue['title']} - {issue['created_at'][:10]}")
                return "\n".join(result) if result else "未找到任何issue"
            except httpx.HTTPStatusError as e:
                if e.response.status_code == 429 and attempt  str:
    error_str = str(error)
    # 三级错误处理
    if "429" in error_str:
        return "API限流,请等待5秒后重试当前步骤"
    elif "400" in error_str or "404" in error_str:
        return f"参数错误: {error_str},尝试使用备用数据源"
    elif "Tool not found" in error_str:
        return "工具不存在,终止当前任务"
    else:
        return f"未知错误: {error_str},跳过本步骤"

# 创建Agent
tools = [GitHubIssueTool(), ...]  # 其他工具类似实现
prompt = PromptTemplate.from_template("""
你是一个GitHub分析助手。请根据用户需求,逐步使用工具完成任务。
当前对话历史: {chat_history}
用户输入: {input}
可用工具: {tools}

请严格按照以下格式回答:
Thought: 你当前的思考
Action: 工具名称(从{tool_names}中选择)
Action Input: 工具输入(JSON格式)
Observation: 工具返回结果
...(可重复多轮)
Thought: 我已经完成所有操作
Final Answer: 最终回答
""")

agent = create_react_agent(
    llm=llm,
    tools=tools,
    prompt=prompt,
    # 关键:自定义输出解析器,处理LLM格式错误
    output_parser=...  # 使用默认的ReActSingleInputOutputParser
)

agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    memory=memory,
    max_iterations=15,           # 防止无限循环
    max_execution_time=120,      # 整体超时2分钟
    handle_parsing_errors=custom_error_parser,  # 错误处理回调
    early_stopping_method="generate",  # 达到max_iterations时尝试生成答案
    verbose=True                 # 开发阶段开启调试日志
)

循环控制细节max_iterations=15看起来很大,但实测单次任务平均需要3-5步(爬取→分析→总结)。如果超过15步,说明Agent陷入死循环(比如反复调用同一个工具)。此时early_stopping_method="generate"会让LLM尝试直接输出答案,避免完全崩溃。

5. 踩坑与优化:那些文档没写的事

5.1 记忆Buffer的截断问题

ConversationBufferWindowMemory只保留最后k轮,但工具调用产生的ObservationThought也会占用token。实测发现:当k=10时,单轮对话可能包含6-8条消息(用户输入、Agent思考、工具调用、工具返回),导致实际上下文远超预期。
优化:改为k=5 + 在Prompt中压缩历史摘要:“请参考之前的分析结论,忽略工具调用细节”。

5.2 工具返回格式不一致

爬虫工具返回纯文本,API工具返回JSON字符串,分析工具返回Markdown。LLM在解析混合格式时经常出错。
统一方案:所有工具返回值强制使用|分隔符的结构化格式:
状态|数据摘要|原始数据(可选)
例如:SUCCESS|共获取15个issue,其中3个open|#1 [open] 修复登录bug...
这样LLM可以通过|快速切割,减少解析错误。

5.3 性能数据对比

场景 裸调API 本方案Agent
10次连续任务成功率 42% 89%
平均单次任务耗时 1.2s 2.8s
最大重试次数 0 3次(自动)
记忆丢失率 70% 0%
死循环终止率 100%(人工) 0%(自动)

耗时增加的主要原因是错误重试和记忆管理开销,但成功率翻倍,对于生产环境是可以接受的。

6. 总结:Agent开发的核心是“容错设计”

从踩坑到优化,最大的体会是:Agent不是“聪明的AI”,而是“可靠的执行框架”。工具定义需要防御性编程、记忆管理要前置token估算、错误处理要分级自动化、循环控制要硬限制。这些“笨功夫”才是Agent落地的关键。

下一步计划:
- 集成langchain-tavily实现搜索工具的自动降级
- 使用langfuse追踪Agent的决策链路,优化Prompt
- 将max_iterations改为动态调整(基于任务复杂度预估)

如果你也在踩Agent的坑,欢迎留言交流——毕竟,没有两个Agent的bug是完全相同的。