一、为什么我要手写一个Agent

上个月团队要做一个内部运维助手,需求很朴素:根据用户问题调用几个内部API(查监控、查日志、重启服务),然后汇总结果。第一反应是用LangChain的AgentExecutor,结果调了两天,被它的回调机制和中间步骤日志搞得头大——出问题时根本不知道是prompt的问题、工具描述的问题,还是它内部解析输出格式失败。

后来索性花一个下午自己写了一个。核心逻辑其实就一个while循环:让模型决定调用哪个工具,执行工具,把结果塞回对话历史,再问模型,直到它给出最终答案。就这么简单。

这篇文章把实现过程完整拆开讲。代码基于Python 3.11 + openai 1.35.0,不依赖LangChain,全部代码可以直接跑。

二、整体设计:一个Agent需要哪几块

先明确一个最小可用Agent的组成:

  1. 工具定义:用JSON Schema描述每个工具的名称、参数、用途,让模型知道有什么可调用
  2. 消息历史(记忆):维护一个messages列表,包含system prompt、用户输入、模型回复、工具执行结果
  3. 循环控制:最大迭代次数限制 + 终止条件判断(模型不再请求工具即为结束)
  4. 错误处理:工具执行异常时不能直接崩溃,要把错误信息作为工具结果返回给模型,让它自己决定重试还是换方案

整个执行流程是:

用户输入 → 组装messages → 调用LLM
    ↓
LLM返回tool_calls? 
    ├─ 是 → 执行工具 → 结果追加到messages → 回到调用LLM(循环)
    └─ 否 → 返回最终文本答案,结束

这个循环就是所有Agent框架的核心,AutoGPT也好、LangChain的AgentExecutor也好,本质都是这个循环加上各种增强(反思、规划、多Agent协作)。

三、核心实现

3.1 工具定义与注册

工具定义的关键是描述要写清楚用途和边界。模型选错工具,90%是因为description写得含糊。

import json
import inspect
from typing import Callable

class Tool:
    def __init__(self, name: str, description: str, func: Callable, parameters: dict):
        self.name = name
        self.description = description
        self.func = func
        self.parameters = parameters

    def to_openai_schema(self) -> dict:
        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": self.parameters,
            },
        }

class ToolRegistry:
    def __init__(self):
        self._tools: dict[str, Tool] = {}

    def register(self, tool: Tool):
        self._tools[tool.name] = tool

    def get(self, name: str) -> Tool | None:
        return self._tools.get(name)

    def all_schemas(self) -> list[dict]:
        return [t.to_openai_schema() for t in self._tools.values()]

注册两个示例工具。注意参数schema用标准JSON Schema,required字段一定要写,否则模型可能传空参数:

def get_weather(city: str) -> str:
    # 模拟实现,真实场景换成HTTP请求
    fake_db = {"北京": 18, "上海": 24, "广州": 29}
    if city not in fake_db:
        raise ValueError(f"不支持的city参数: {city},仅支持北京/上海/广州")
    return json.dumps({"city": city, "temp_c": fake_db[city]}, ensure_ascii=False)

def calculate(expression: str) -> str:
    # 只允许数字和四则运算,防止注入
    allowed = set("0123456789+-*/(). ")
    if not set(expression)  200:
                msg["content"] = "[历史工具结果已省略]"
        self.messages = keep_head + keep_tail

3.3 循环控制与错误处理

这是整个Agent的"心脏"。我踩过的坑集中在这里:

坑1:模型可能无限调用工具。 必须有max_iterations,我设的是8。超过后强制让模型输出最终答案(追加一条user消息:"请基于已有信息直接给出结论")。

坑2:工具抛异常直接崩。 必须try/except,把异常信息转成字符串作为tool消息返回。模型看到错误后通常会自己调整参数重试,实测GPT-4o-mini对"参数非法"这类错误的自愈率大概70%。

坑3:模型返回的arguments不是合法JSON。 虽然function calling模式下概率很低,但出现过一次(模型把中文引号写进去了)。用json.loads包一层try,失败就返回错误消息让它重试。

坑4:并行tool_calls。 模型一次可能返回多个tool_call,必须全部执行、全部追加结果,缺一个都会报错。

from openai import OpenAI

client = OpenAI(api_key="sk-xxx", base_url="https://api.openai.com/v1")

SYSTEM_PROMPT = """你是一个运维助手。你可以调用工具获取信息。
规则:
1. 需要数据时必须调用工具,不要编造。
2. 工具返回错误时,分析错误原因并调整参数重试,最多重试2次。
3. 信息足够后直接给出简洁结论。"""

def run_agent(user_input: str, max_iterations: int = 8) -> str:
    memory = Memory(SYSTEM_PROMPT)
    memory.add({"role": "user", "content": user_input})
    tool_retry_count: dict[str, int] = {}

    for i in range(max_iterations):
        resp = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=memory.messages,
            tools=registry.all_schemas(),
            tool_choice="auto",
            temperature=0,
        )
        msg = resp.choices[0].message

        # 终止条件:模型不再请求工具
        if not msg.tool_calls:
            memory.add({"role": "assistant", "content": msg.content})
            return msg.content

        # 把assistant的tool_calls消息原样存回历史(必须!)
        memory.add({
            "role": "assistant",
            "content": msg.content,
            "tool_calls": [
                {
                    "id": tc.id,
                    "type": "function",
                    "function": {"name": tc.function.name,
                                 "arguments": tc.function.arguments},
                } for tc in msg.tool_calls
            ],
        })

        # 逐个执行(支持并行tool_calls)
        for tc in msg.tool_calls:
            result = _execute_tool(tc, tool_retry_count)
            memory.add({
                "role": "tool",
                "tool_call_id": tc.id,
                "content": result,
            })

    # 迭代超限,强制收敛
    memory.add({"role": "user", "content": "已达到最大工具调用次数,请基于现有信息直接给出最终结论。"})
    final = client.chat.completions.create(
        model="gpt-4o-mini", messages=memory.messages, temperature=0
    )
    return final.choices[0].message.content


def _execute_tool(tc, retry_count: dict) -> str:
    tool = registry.get(tc.function.name)
    if tool is None:
        return f"错误:工具 {tc.function.name} 不存在"
    try:
        args = json.loads(tc.function.arguments)
    except json.JSONDecodeError as e:
        return f"错误:参数不是合法JSON({e}),请重新生成"
    try:
        return str(tool.func(**args))
    except TypeError as e:
        return f"错误:参数不匹配({e})"
    except Exception as e:
        cnt = retry_count.get(tc.function.name, 0) + 1
        retry_count[tc.function.name] = cnt
        if cnt >= 3:
            return f"错误:{e}。该工具已失败{cnt}次,请放弃调用并基于已有信息作答。"
        return f"错误:{e}。请调整参数后重试。"

跑一下:

print(run_agent("北京和上海现在温差多少度?用华氏度表示温差"))

实际执行路径是:get_weather(北京) → get_weather(上海) → calculate((24-18)*1.8) → 最终答案。三次工具调用,一次循环内完成两个并行调用。

四、踩坑与优化记录

1. temperature必须设0。 一开始用默认值0.7,同一句话两次运行走了不同工具路径,有一次甚至跳过工具直接编了个温度。改0之后行为稳定。

2. description里写"不要用于X"比写"用于Y"更有效。 我一开始把calculate描述成"数学计算工具",结果模型查完天气后直接心算温差,压根不调calculate。改成"所有数值计算必须调用此工具,不要自行心算"后解决。

3. tool消息的content不能是None。 有次工具函数返回None,直接传进去API报错。统一str()包一层。

4. token成本。 每个tool schema大约占80-120 tokens,4个工具的情况下每轮请求的固定开销约400 tokens。加上历史累积,一个三步骤任务实测平均1200 tokens、耗时3.2秒(gpt-4o-mini,含3次LLM调用)。如果工具超过10个,建议做工具动态筛选,不然光schema就吃掉上千token。

5. 错误信息要写给模型看,不是给人看。 早期我返回KeyError: 'city',模型完全不知道怎么改。改成"缺少必填参数city,请补充"之后,自愈率明显上升。

五、效果数据

在20条测试用例上(覆盖单工具、多工具串联、参数错误、不存在的城市四类场景):

指标 结果
任务完成率 19/20(95%)
平均LLM调用次数 3.1 次
平均耗时 3.2 s
平均token消耗 1210 tokens
参数错误自愈率 7/10(70%)
唯一失败案例 用户问"火星天气",模型重试3次后放弃,但没给出明确"无法回答"的结论

失败案例后来通过在第3次重试的错误信息里加"请直接告知用户该信息无法获取"解决。

六、总结

手写Agent之后我的感受是:LangChain和AutoGPT的价值在于生态和开箱即用,但核心循环本身不到100行。自己写一遍的好处是每个环节都可控——工具为什么没被调用、错误为什么没被自愈、循环为什么停不下来,都能直接定位到具体的那几行代码。

如果要做生产级Agent,在这个骨架上还需要补的东西:流式输出、工具调用的并发执行(现在是串行for循环,可以改成asyncio.gather)、结构化日志(记录每次tool_call的入参出参耗时)、以及工具级别的权限控制。但这些都是在同一个循环上做加法,核心逻辑不会变。

代码全部在上面,复制粘贴就能跑,建议自己改改工具描述和max_iterations,观察模型行为的变化,比看十篇框架文档有用。