AI Agent 开发实战:从零搭一个能用的 Agent

AI Agent 开发实战:从零搭一个能用的 Agent

"Agent"这个词这两年被说烂了,但落到工程上,一个能用的 Agent 到底长什么样,很多教程讲不清。这篇不玩概念,直接写代码。我们从零搭一个带工具调用能力的最小 Agent:它能听懂自然语言、自己决定调哪个工具、拿结果继续推理,直到给出答案。

选型上我特意不走重型框架。核心循环只用 Python 标准库加一个 requests,LLM 用 OpenAI 兼容接口------这样 DeepSeek、通义、OpenAI、本地跑的服务都能接,换一家模型只改一个环境变量。把机制讲透之后,你再去套 LangChain 这类框架,会发现它们干的其实就是这篇代码里那几件事,只不过帮你写好了。

代码是完整的,可以直接存成文件跑。诚实声明:文中的模型调用走真实 API,但天气这类工具我用模拟数据,避免教程依赖外部服务;截图留占位,预期输出写在注释里。

一、Agent 架构拆解

一个能用的 Agent,核心就三块:规划、工具调用、记忆

模块 职责 常见实现
规划(Planning) 决定"下一步做什么",拆任务、排序 靠模型推理 + system prompt 约束
工具调用(Tool Use) 执行动作:查数据、算数、调接口 函数注册表 + 模型返回结构化调用
记忆(Memory) 记住上下文和中间结果 对话消息列表 / 外部存储

规划不一定要单独一个组件。简单场景里,规划就是让模型在每轮输出里决定"这轮是继续调工具还是给最终答案"。拆任务的显式规划器只在任务复杂时才需要,一开始别过度设计。

工具调用是核心。模型的文本输出不可靠,所以不能让模型"手写"调用过程,而是让它在消息里返回结构化的 tool_calls------包含工具名和参数 JSON。你的代码负责解析、执行、把结果回传给模型。这样循环才能转起来。

记忆在这个最小实现里就是消息列表本身。每次工具结果都以 role: tool 的消息追加进去,模型下一轮就能看到。够用就行,等到对话超长再考虑摘要压缩。

二、环境与框架选型

动手前先想清楚用哪条路。市面上选项大致分三档:

方案 学习成本 可控性 适合场景
手写核心循环(本文方式) 学习原理、简单 Agent、想完全掌控
轻量封装(如 openai 函数调用封装) 不想处理消息细节,快速出活
重型框架(LangChain / LlamaIndex) 复杂编排、多模型、企业级场景

我的取舍逻辑:先手写一遍,理解工具调用的完整链路,再用框架。很多人上来就套 LangChain,遇到问题不知道是自己逻辑错了还是框架行为不符合预期,根源就是没理解底层循环。手写版几百行,一周后用得上这个理解。

环境要求很简单:Python 3.9+,装个 requests。虚拟环境建议建一个:

bash 复制代码
python -m venv .venv
# Windows: .venv\Scripts\activate
# Linux/macOS: source .venv/bin/activate
pip install requests

三、从零搭一个 demo Agent

完整代码存成 agent.py。先配置区,全部走环境变量,换模型不用改代码:

python 复制代码
# agent.py ------ 最小可用 Agent:工具调用循环
import ast
import json
import os
import requests

# ---- 配置区:全部用环境变量,避免密钥写进代码 ----
API_KEY = os.environ.get("LLM_API_KEY", "")
BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1")
MODEL = os.environ.get("LLM_MODEL", "gpt-4o-mini")

工具注册表。每个工具两个东西:给模型看的描述(function schema),和真正执行的 Python 函数:

python 复制代码
def safe_calc(expr):
    """只允许数字和四则运算的安全计算器,避免 eval 注入"""
    tree = ast.parse(expr, mode="eval")
    allowed = (ast.Expression, ast.BinOp, ast.Add, ast.Sub,
               ast.Mult, ast.Div, ast.UnaryOp, ast.USub, ast.Constant)
    for node in ast.walk(tree):
        if not isinstance(node, allowed):
            raise ValueError("表达式包含不允许的运算")
    return eval(compile(tree, "<calc>", "eval"), {"__builtins__": {}})


def get_weather(city):
    """模拟天气数据,真实项目替换成第三方天气 API"""
    mock = {"北京": (26, "晴"), "上海": (29, "多云"), "广州": (31, "阵雨")}
    temp, cond = mock.get(city, (25, "晴"))
    return {"city": city, "temp": temp, "condition": cond}


TOOLS = {
    "calculator": {
        "desc": "计算四则运算表达式,参数填字符串表达式",
        "schema": {"type": "object",
                   "properties": {"expr": {"type": "string",
                                           "description": "表达式,如 (23+45)*3"}},
                   "required": ["expr"]},
        "handler": safe_calc,
    },
    "get_weather": {
        "desc": "查询城市当前天气",
        "schema": {"type": "object",
                   "properties": {"city": {"type": "string"}},
                   "required": ["city"]},
        "handler": get_weather,
    },
}

把工具注册表转成 OpenAI 兼容的 tools 参数格式:

python 复制代码
TOOL_SCHEMAS = [
    {
        "type": "function",
        "function": {
            "name": name,
            "description": spec["desc"],
            "parameters": spec["schema"],
        },
    }
    for name, spec in TOOLS.items()
]

主循环。这是整个 Agent 的心脏:

python 复制代码
def run_agent(user_input: str, max_rounds: int = 5) -> str:
    messages = [
        {"role": "system",
         "content": "你是带工具能力的助手。需要计算或查天气时,先调用对应工具,拿到结果后给出最终回答。"},
        {"role": "user", "content": user_input},
    ]

    for _ in range(max_rounds):
        resp = requests.post(
            f"{BASE_URL}/chat/completions",
            headers={"Authorization": f"Bearer {API_KEY}"},
            json={"model": MODEL, "messages": messages, "tools": TOOL_SCHEMAS,
                  "tool_choice": "auto"},
            timeout=60,
        )
        resp.raise_for_status()
        msg = resp.json()["choices"][0]["message"]
        messages.append(msg)

        # 没有工具调用 → 这就是最终答案
        tool_calls = msg.get("tool_calls")
        if not tool_calls:
            return msg["content"]

        # 逐个执行工具,结果回填成 tool 消息
        for call in tool_calls:
            name = call["function"]["name"]
            args = json.loads(call["function"]["arguments"])
            handler = TOOLS.get(name, {}).get("handler")
            if handler is None:
                result = {"error": f"未知工具: {name}"}
            else:
                result = handler(**args)
            messages.append({
                "role": "tool",
                "tool_call_id": call["id"],
                "content": json.dumps(result, ensure_ascii=False),
            })

    return messages[-1].get("content", "达到最大轮数,任务结束")


if __name__ == "__main__":
    # 实际运行前设置好环境变量:LLM_API_KEY / LLM_BASE_URL / LLM_MODEL
    print(run_agent("帮我算 (23+45)*3 的结果,再查一下北京的天气"))

循环逻辑一句话能概括:发消息给模型 → 模型返回工具调用 → 执行工具 → 结果塞回消息 → 再问模型,直到模型不调用工具。max_rounds 是保险丝,防止模型陷入"反复调工具"的死循环。

3.1 加一个调试开关

把主循环里加两行打印,每一轮模型在干什么就一目了然:

python 复制代码
        # 在 run_agent 的 for 循环开头加这几行
        print(f"[round] 工具调用: "
              f"{[c['function']['name'] for c in tool_calls] if msg.get('tool_calls') else '无,准备给出最终答案'}")

运行后预期输出:每一轮的模型决策都打印出来,能直观看到它先算了数、再查了天气、最后汇总回答。调通之后删掉这几行,或者挂一个 DEBUG 环境变量控制开关,正式用的时候别天天看刷屏。

3.2 加第三个工具有多简单

这个 demo 已经支持多工具,任务里同时要计算和查天气时,模型会一次返回两个 tool_calls。要加新工具,只需往 TOOLS 里加一个条目:写一个 handler 函数,配一份 schema。比如加个翻译工具,十几行就完事。工具数量一多,schema 描述的质量就变得关键------写清楚每个参数的含义和边界,模型才不会选错工具。

四、执行与调试

设置环境变量后运行:

bash 复制代码
export LLM_API_KEY="sk-你的key"
export LLM_BASE_URL="https://api.deepseek.com/v1"   # 换成你用的服务
export LLM_MODEL="deepseek-chat"                     # 换成你的模型名
python agent.py

预期输出(分阶段看):

bash 复制代码
第一轮:模型返回 tool_calls = [calculator, get_weather]
第二轮:Agent 执行两个工具,结果回填
第三轮:模型综合结果给出最终回答,
        类似:"(23+45)*3 = 204;北京当前 26℃,晴。"

调试时最常见的几类错误:

报错/现象 原因 处理
401 / 无效 key key 错误或服务不兼容 核对 LLM_API_KEY,确认 base_url 是 /v1 结尾
404 / 模型不存在 模型名不对 换成该服务实际的模型标识,如 deepseek-chat
JSON 解析失败 模型返回的参数格式不规范 捕获 json.JSONDecodeError,把这轮工具结果和提示一起回传让模型重试
工具结果错位 多个 tool_calls 并发返回 严格用 tool_call_id 一一对应,别按顺序猜
循环不收敛 模型反复调工具 提高 max_rounds 前先检查工具描述是否清晰、参数是否够用
工具名匹配不上 模型瞎编工具名 把可用工具清单写进 system prompt 让模型只选列表里的
结果质量不稳定 同问题多轮输出差异大 降低 temperature,system prompt 里给明确格式要求

其中"JSON 解析失败"和"工具名编造"是模型抽风的高发区,工程化的做法是容错重试:解析失败就把原始文本作为 tool 结果回填,让模型自己纠错,而不是直接崩溃。

五、调优与部署建议

工具层面:schema 里的 description 别偷懒,写清楚参数含义和边界,模型调用准确率主要靠它。工具数量控制在五六个以内,超过之后模型选择准确率明显下降,就要考虑分组或路由了。

循环层面:max_rounds 根据任务复杂度定,简单问答 3 轮足够,别给 10 轮以上的预算白烧 token。system prompt 里明确"拿到结果就回答,不要重复调用",能省不少钱。

部署层面,三个点:密钥管理 ------环境变量或密钥管理系统,别写死进代码;日志 ------每轮的 messages 和 tool_calls 落盘,出问题能回放;限流与超时------requests 的 timeout 必须设,LLM 接口抽风时挂起是最常见的事故。

5.1 评测与护栏

Agent 上线前,评测和护栏是两件容易漏的事。评测就是准备一组真实任务,跑通、记录成功率,之后每次改 prompt、改工具都回归一遍,防止"改好一个任务搞坏三个"。护栏则是兜底:工具调用要限流、危险操作要人工审批、输出要过滤敏感内容。Agent 的能力边界由模型决定,但安全底线由代码决定,这部分不能省。

最后说一个常被忽略的点:Agent 的调试成本。核心循环越简单,排错路径越短。把"模型说什么、工具返回什么"完整记录下来,比猜问题出在哪高效得多。这套代码里 messages 列表本身就是一个天然的调试日志,会用它比会写框架更值钱。

再补一条部署时的常见认知:别把 Agent 当成普通接口来压测。它内部是多次模型调用,单次请求的耗时和成本是翻倍的,并发上去之后模型接口的限流、超时都会先暴露。压测要按"一个 Agent 任务 = 多个模型请求"的口径来算,否则线上必然打爆。

再往后要升级的路径是:单轮工具调用升级为多 Agent 协作、消息列表升级为外部记忆(向量库)、规划逻辑独立出来。但这些都是同一个骨架的演进,这篇的循环结构不会作废。

结论

一个能用的 Agent,核心就是"模型 + 工具注册表 + 循环"三个零件。模型负责判断,工具负责执行,循环负责把两者串起来。本文的 60 行代码已经具备完整链路:结构化工具调用、结果回填、终止条件,换上任何 OpenAI 兼容服务都能跑。

先别急着上框架。把这套循环亲手写一遍、跑通一遍、调试一遍,你对 Agent 的理解会比看十篇架构文章都深。之后再回头看 LangChain,你会发现自己已经能读懂它每一层在干什么了。本文首发于 CSDN

相关推荐
行百里er2 小时前
加个依赖就生效?一行搞定 Spring Boot Starter 自动装配
java·后端·监控
程序员鱼皮2 小时前
3 大 DeepSeek Harness 进阶玩法,招多个大肥鱼帮我干活!
前端·后端·ai编程
苍何3 小时前
DeepSeek 终于支持多模态了(附实测及接入教程)
后端
KoPa3 小时前
HeySmart:大模型开源网关基座-请求生命周期与钩子引擎
前端·后端
苍何3 小时前
做AI视频还在拆盲盒?手把手教你导演级运镜(附教程)
后端
爱学习的小邓同学3 小时前
Golang语言入门
开发语言·后端·golang
苍何3 小时前
豆包,开始做普通人的 Codex
后端
七牛开发者3 小时前
Coding Agent 如何跑稳长任务?从上下文管理到运行时状态
前端·javascript·后端