一、为什么我要自己写一个Agent

先说结论:LangChain的initialize_agent和AutoGPT这类框架,开箱即用是真的,但出了问题你基本是瞎的。

上个月我们内部要做一个"运维问答助手",需求很朴素:用户问"昨天订单服务的错误率为什么涨了",Agent要能自己决定先查监控、再查日志、最后查发布记录,然后综合回答。一开始我直接用LangChain的AgentExecutor + create_openai_tools_agent,两天跑通demo,然后上线测试,问题全来了:

  • 工具参数传错格式,模型给了个字符串,工具要dict,直接抛异常,整个链路挂掉;
  • 对话超过8轮后,prompt token飙到6000+,成本和延迟都顶不住;
  • 有一次模型陷入"调用工具A→结果不满意→再调用工具A"的死循环,烧了12万token才被我们手动kill;
  • 工具内部报错(比如监控API 502),异常直接冒泡,用户看到一个500。

这些问题框架不是不能配,但配置项散落在max_iterations、handle_parsing_errors、memory、early_stopping_method各处,文档还经常对不上版本。于是我决定手写一个,把控制权拿回来。最终成品约180行核心代码,跑在LangChain 0.1.16 + openai 1.30.1 + Python 3.11,下面把实现细节掰开讲。

二、环境与版本

先把版本钉死,LangChain 0.1.x和0.2.x的BaseTool签名有差异,别混用:

python==3.11.8
langchain==0.1.16
langchain-core==0.1.46
langchain-openai==0.1.7
openai==1.30.1
pydantic==2.7.1
tiktoken==0.6.0

模型用gpt-3.5-turbo-0125(便宜、够用),temperature=0保证工具调用稳定。如果你用GPT-4o,下面所有逻辑不变,只是延迟数字会变。

三、方案设计:ReAct + 显式状态机

我选的是ReAct范式(Reasoning + Acting),但不用LangChain的agent executor,而是自己写一个显式循环。整体结构:

用户输入 → 拼装system prompt(工具描述+记忆) → LLM
   ↓
LLM返回:要么 final_answer,要么 tool_call
   ↓ (tool_call)
参数校验 → 执行工具 → 捕获异常 → 结果写回记忆 → 回到LLM
   ↓ (final_answer 或 达到max_steps)
输出

四个核心模块对应四个类:ToolRegistry(工具定义与校验)、MemoryManager(记忆管理)、AgentLoop(循环控制)、RetryPolicy(错误处理)。这样拆的好处是每一块都能单独单测,出问题一眼定位。

# agent_core.py 骨架
from dataclasses import dataclass, field
from typing import Any
import json, time, logging

log = logging.getLogger("agent")

@dataclass
class Step:
    role: str          # "user" / "assistant" / "tool"
    content: str
    tool_name: str | None = None
    tool_input: dict | None = None
    ts: float = field(default_factory=time.time)

class ToolRegistry:
    def __init__(self): self._tools: dict[str, dict] = {}
    def register(self, name, func, description, schema): ...
    def call(self, name, raw_args) -> str: ...   # 含校验+异常捕获

class MemoryManager:
    def __init__(self, max_tokens=2500, keep_recent=6): ...
    def add(self, step: Step): ...
    def build_context(self) -> list[dict]: ...   # 滑动窗口

class AgentLoop:
    def __init__(self, llm, tools, memory, max_steps=8): ...
    def run(self, user_input: str) -> str: ...

四、核心实现

4.1 工具定义:JSON Schema + 显式校验

工具定义我不用LangChain的@tool装饰器,因为它对参数校验太松(args_schema不匹配时经常静默通过)。我要求每个工具必须带一份JSON Schema,调用前用jsonschema校验:

import jsonschema

class ToolRegistry:
    def __init__(self):
        self._tools = {}

    def register(self, name: str, func, description: str, schema: dict):
        jsonschema.Draft7Validator.check_schema(schema)
        self._tools[name] = {"func": func, "desc": description, "schema": schema}

    def specs(self) -> list[dict]:
        # 转成 OpenAI function calling 格式
        return [{
            "type": "function",
            "function": {"name": n, "description": t["desc"], "parameters": t["schema"]}
        } for n, t in self._tools.items()]

    def call(self, name: str, raw_args: str) -> str:
        t = self._tools.get(name)
        if not t:
            return f"[ERROR] 未知工具: {name},可用工具: {list(self._tools)}"
        try:
            args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
            jsonschema.validate(args, t["schema"])
        except (json.JSONDecodeError, jsonschema.ValidationError) as e:
            return f"[ERROR] 工具 {name} 参数非法: {e}. 请修正后重试。"
        try:
            return str(t["func"](**args))[:2000]   # 截断,防止爆token
        except Exception as e:
            log.exception("tool %s failed", name)
            return f"[ERROR] 工具 {name} 执行失败: {type(e).__name__}: {e}"

关键设计:工具永远不抛异常给上层,而是把错误作为字符串返回给LLM。这是Agent和自我修复能力的来源——模型看到[ERROR] 参数非法,下一轮通常会自己改对。实测这一步让参数错误导致的整链路失败率从17%降到0。

注册示例:

tools = ToolRegistry()
tools.register(
    name="query_metrics",
    func=lambda service, metric, window="1h": fake_prometheus(service, metric, window),
    description="查询指定服务在时间窗口内的监控指标,如错误率、QPS、P99延迟",
    schema={
        "type": "object",
        "properties": {
            "service": {"type": "string", "description": "服务名,如 order-service"},
            "metric":  {"type": "string", "enum": ["error_rate", "qps", "p99_latency"]},
            "window":  {"type": "string", "pattern": r"^\d+[mh]$", "default": "1h"}
        },
        "required": ["service", "metric"]
    }
)

注意schema里我用了enum和pattern,模型看到这些约束后参数合规率明显更高(实测从72%到94%)。

4.2 记忆管理:token预算下的滑动窗口 + 工具结果压缩

记忆不能无脑append,gpt-3.5-turbo 16K上下文,多轮工具调用很容易顶到上限。我的策略:

  1. 用tiktoken精确算token,预算2500;
  2. 保留最近6条消息(keep_recent),保证当前推理的连贯性;
  3. 更早的tool消息压缩成一行摘要(工具名+结果前80字符);
  4. system prompt里始终保留"已调用过的工具清单",防止模型重复调用。
import tiktoken
enc = tiktoken.encoding_for_model("gpt-3.5-turbo")

class MemoryManager:
    def __init__(self, max_tokens=2500, keep_recent=6):
        self.steps: list[Step] = []
        self.max_tokens = max_tokens
        self.keep_recent = keep_recent

    def _count(self, msgs) -> int:
        return sum(len(enc.encode(m["content"])) for m in msgs)

    def add(self, step: Step):
        self.steps.append(step)

    def build_context(self) -> list[dict]:
        recent = self.steps[-self.keep_recent:]
        older  = self.steps[:-self.keep_recent]
        # 老的工具结果压成一行
        compressed = []
        for s in older:
            if s.role == "tool":
                compressed.append({"role": "system",
                    "content": f"[历史] {s.tool_name} -> {s.content[:80]}"})
            else:
                compressed.append({"role": s.role, "content": s.content[:200]})
        msgs = compressed + [{"role": s.role, "content": s.content} for s in recent]
        # 超预算从最老的开始丢
        while self._count(msgs) > self.max_tokens and len(msgs) > 2:
            msgs.pop(0)
        return msgs

这个策略在20条测试用例上,平均prompt token从4100降到1900,单次调用成本降了约54%,而任务完成率只掉了2个百分点(87%→85%),我认为划算。

4.3 循环控制:max_steps + 重复调用检测

循环控制两个保险:硬上限max_steps=8,以及软检测——连续两次调用同一工具且参数完全相同,直接判定死循环,强制让模型给最终答案。

class AgentLoop:
    def __init__(self, llm, tools: ToolRegistry, memory: MemoryManager,
                 max_steps=8, max_retries=3):
        self.llm, self.tools, self.memory = llm, tools, memory
        self.max_steps, self.max_retries = max_steps, max_retries

    def _call_llm(self, messages):
        for i in range(self.max_retries):
            try:
                return self.llm.invoke(messages)
            except Exception as e:
                wait = 2 ** i          # 1s, 2s, 4s 指数退避
                log.warning("LLM call failed (%s), retry in %ss", e, wait)
                time.sleep(wait)
        raise RuntimeError("LLM 调用重试耗尽")

    def run(self, user_input: str) -> str:
        self.memory.add(Step(role="user", content=user_input))
        last_sig = None
        for step_i in range(self.max_steps):
            msgs = [{"role": "system", "content": SYSTEM_PROMPT}] + self.memory.build_context()
            resp = self._call_llm(msgs)
            msg = resp
            # 1) 模型直接给答案
            if not getattr(msg, "tool_calls", None):
                self.memory.add(Step(role="assistant", content=msg.content))
                return msg.content
            # 2) 处理工具调用
            for tc in msg.tool_calls:
                sig = (tc.function.name, tc.function.arguments)
                if sig == last_sig:
                    log.warning("检测到重复调用,强制收敛")
                    self.memory.add(Step(role="system",
                        content="你已重复调用同一工具,请基于现有信息直接给出最终答案。"))
                    break
                last_sig = sig
                result = self.tools.call(tc.function.name, tc.function.arguments)
                self.memory.add(Step(role="tool", content=result,
                                     tool_name=tc.function.name))
                self.memory.add(Step(role="assistant",
                    content=f"调用 {tc.function.name},结果已记录。"))
        # 3) 达到步数上限
        return "[未收敛] 已达到最大推理步数,请缩小问题范围或补充信息。"

SYSTEM_PROMPT里我明确写了"最多调用工具8次,信息足够就回答",配合硬上限,双保险。

五、踩坑与优化

坑1:tool_calls的arguments是字符串不是dict。 OpenAI返回的tc.function.arguments是JSON字符串,直接**解包会TypeError,必须先json.loads,且模型偶尔会输出带尾逗号的非法JSON。我在ToolRegistry.call里做了兜底,解析失败就把原始字符串和错误一起返回给模型,让它重试——比直接崩溃好太多。

坑2:并行tool_calls。 模型有时一次返回2-3个tool_call,我一开始只处理第一个,导致状态不一致。正确做法是遍历msg.tool_calls全部执行,并且每个结果都要以role="tool"消息回填(OpenAI要求tool消息必须紧跟assistant的tool_calls,顺序不能乱)。

坑3:记忆里的assistant消息格式。 如果你把带tool_calls的assistant消息原样塞回context,某些版本会报400。我的简化方案是回填一条纯文本"调用X,结果已记录",牺牲一点保真度换取稳定性,实测对效果影响很小。

坑4:延迟。 单次GPT-3.5调用约1.1s,4轮工具调用就是4-5s。优化手段:工具内部加10分钟本地缓存(functools.lru_cache改造成带TTL的),重复查询直接命中;prompt压缩(见4.2)。P95从6.1s降到3.8s。

六、效果数据

20条多步运维查询测试集(每条平均需要2-4次工具调用),对比LangChain AgentExecutor默认配置:

指标 LangChain默认 手写Agent
任务完成率 87% 85%
参数错误致失败 17% 0%
死循环发生次数 3/20 0/20
平均prompt token 4100 1900
P95延迟 6.1s 3.8s
代码可控性 黑盒 全白盒

完成率略低2个点,是因为我砍了部分历史上下文,属于预期内的取舍;但稳定性、成本和可调试性提升明显,线上跑了三周没再出现烧token的事故。

七、总结

手写Agent的核心不是"调LLM",而是把不确定性关进笼子:工具层用JSON Schema把参数关进笼子,记忆层用token预算把上下文关进笼子,循环层用max_steps和重复检测把死循环关进笼子,错误处理层把异常转成模型能读懂的反馈。这四件事做好了,180行代码就够用,而且每一行你都看得懂、改得动。框架该用还得用,但至少下次它出问题时,你知道该去哪个文件里找答案。

代码已整理成单文件,依赖装好即可跑,建议你先用假工具跑通循环,再替换成真实API。