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