不用 LangChain,手写一个能跑的 AI Agent:原理、工具调用与完整代码
很多人会调用大模型 API,却说不清 Agent 为什么能够自己选择工具、执行任务和继续推理。本文不使用任何 Agent 框架,只用 Python 手写一个具备工具调用、循环执行和异常处理能力的最小 AI Agent。
前言
很多人第一次接触 AI Agent,往往是从 LangChain、AutoGen 等框架开始。代码看起来很完整,但只要离开框架,就很难说清楚几个基本问题:
-
Agent 和普通大模型对话有什么区别?
-
模型是怎么决定调用工具的?
-
工具执行结果如何重新交给模型?
-
Agent 为什么需要循环?
-
如何避免模型输出格式不稳定?
其实,一个最小可用的 Agent 并不复杂,它的核心就是一套不断执行的循环:
模型分析任务 → 决定是否调用工具 → 程序执行工具 → 返回执行结果 → 模型继续判断 → 输出最终答案
本文不用 LangChain,也不用 AutoGen,只使用 Python 和 OpenAI 兼容接口,从零实现一个具备以下能力的极简 Agent:
-
自主判断是否调用工具;
-
按照约定格式生成工具参数;
-
执行工具并读取返回结果;
-
多轮循环,直到任务完成;
-
处理非法输出、错误参数和工具异常。
<font color="#1E80FF"><b>本文目标</b></font>:代码可以直接运行,也方便后续扩展搜索、文件读取、数据库查询等工具。
一、普通对话和 AI Agent 有什么区别
普通大模型应用通常只有一次请求:
用户提出问题
↓
大模型生成回答
↓
程序返回结果
这种模式适合写作、总结和知识问答,但模型只能生成文本,不能真正执行外部操作。Agent 则增加了工具和循环:
用户提出任务
↓
模型分析下一步动作
↓
是否需要调用工具?
├─ 是:程序执行工具,并返回结果
└─ 否:输出最终答案
↓
继续分析,直到任务完成
例如,用户提出下面这个任务:
计算 128 × 96 + 45² - 88 ÷ 2
普通对话会直接生成一个答案,而 Agent 可以按照以下步骤执行:
-
判断该任务需要精确计算;
-
生成计算器工具调用指令;
-
由 Python 执行数学表达式;
-
将真实计算结果返回给模型;
-
由模型整理并输出最终答案。
因此,可以用一句话概括二者的关系:
大模型负责理解、规划和决策,工具负责执行,Agent 程序负责把二者连接起来。
二、一个 Agent 最少需要哪些模块
一个最小可用的 Agent,通常包含四个核心模块。
1. 模型
模型相当于 Agent 的决策中心,负责理解用户任务,并判断下一步应该:
-
直接回答问题;
-
调用某个工具;
-
根据工具结果继续执行。
2. 工具
工具是 Agent 能够调用的外部能力,例如:
-
数学计算;
-
联网搜索;
-
文件读取;
-
数据库查询;
-
API 请求;
-
代码执行。
在程序中,一个工具通常就是一个可以被调用的函数。
3. 工具调度器
模型不会直接执行 Python 函数。它只能输出一段约定格式的内容,再由程序解析工具名称和参数,找到对应函数并执行。
4. 循环控制
一次工具调用不一定能完成整个任务。真正的 Agent 需要不断重复下面的过程:
模型决策 → 工具执行 → 返回结果 → 模型再次决策
直到模型给出最终答案,或者程序达到最大循环次数。
三、项目准备
本文建议使用 Python 3.10 或更高版本。
1. 安装依赖
pip install openai python-dotenv
2. 配置 API Key
在项目根目录创建 .env 文件:
GENVIS_API_KEY=替换成你的_API_KEY
不要把真实 API Key 直接写进 Python 源码,也不要将 .env 提交到公开仓库。
建议同时创建 .gitignore:
.env
__pycache__/
项目结构如下:
my-agent/
├── .env
├── .gitignore
└── agent.py
四、完整可运行代码
新建 agent.py,写入以下代码:
import ast
import json
import operator
import os
from typing import Any
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
api_key = os.getenv("GENVIS_API_KEY")
if not api_key:
raise RuntimeError("未读取到 GENVIS_API_KEY,请检查 .env 文件")
client = OpenAI(
api_key=api_key,
base_url="https://genvis.xyz/v1"
)
MODEL_NAME = "gpt-5.6-sol"
MAX_STEPS = 5
# 计算器允许使用的运算符
BINARY_OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
ast.Pow: operator.pow,
}
UNARY_OPERATORS = {
ast.UAdd: operator.pos,
ast.USub: operator.neg,
}
def evaluate_expression(expression: str) -> int | float:
"""安全解析数学表达式,不直接使用 eval。"""
tree = ast.parse(expression, mode="eval")
def calculate(node: ast.AST) -> int | float:
if isinstance(node, ast.Expression):
return calculate(node.body)
if isinstance(node, ast.Constant):
if isinstance(node.value, (int, float)):
return node.value
raise ValueError("表达式中包含非数字常量")
if isinstance(node, ast.BinOp):
operator_func = BINARY_OPERATORS.get(type(node.op))
if operator_func is None:
raise ValueError("不支持该运算符")
left = calculate(node.left)
right = calculate(node.right)
return operator_func(left, right)
if isinstance(node, ast.UnaryOp):
operator_func = UNARY_OPERATORS.get(type(node.op))
if operator_func is None:
raise ValueError("不支持该一元运算符")
return operator_func(calculate(node.operand))
raise ValueError("表达式中包含不允许的内容")
return calculate(tree)
def calculator_tool(expression: str) -> dict[str, Any]:
"""计算器工具:执行表达式并返回结构化结果。"""
try:
result = evaluate_expression(expression)
return {
"success": True,
"expression": expression,
"result": result
}
except Exception as error:
return {
"success": False,
"expression": expression,
"error": str(error)
}
# 工具注册表
TOOLS = {
"calculator": calculator_tool
}
SYSTEM_PROMPT = """
你是一个能够调用外部工具的 AI Agent。
当前可用工具:
1. calculator
用途:计算数学表达式。
参数格式:
{
"expression": "128*96 + 45**2 - 88/2"
}
你每次只能选择下面两种输出格式之一。
需要调用工具时,输出:
{
"type": "tool_call",
"tool": "calculator",
"arguments": {
"expression": "需要计算的表达式"
}
}
任务已经完成时,输出:
{
"type": "final",
"answer": "最终答案"
}
规则:
1. 只能输出一个合法 JSON 对象;
2. 不要输出 Markdown 代码块;
3. 不要在 JSON 前后添加解释;
4. 工具执行失败时,根据错误信息修改参数或给出说明;
5. 获得工具结果后,继续判断任务是否已经完成。
"""
def ask_model(messages: list[dict[str, str]]) -> dict[str, Any]:
"""请求模型,并将模型输出解析为 JSON。"""
response = client.chat.completions.create(
model=MODEL_NAME,
messages=messages,
temperature=0.1
)
content = response.choices[0].message.content
if not content:
raise RuntimeError("模型返回了空内容")
try:
return json.loads(content)
except json.JSONDecodeError as error:
raise RuntimeError(
f"模型没有返回合法 JSON:{content}"
) from error
def run_agent(user_query: str) -> str:
"""运行 Agent,直到得到最终答案或达到最大步数。"""
messages = [
{
"role": "system",
"content": SYSTEM_PROMPT
},
{
"role": "user",
"content": user_query
}
]
for step in range(1, MAX_STEPS + 1):
print(f"\n[Agent] 正在执行第 {step} 步")
action = ask_model(messages)
action_type = action.get("type")
if action_type == "final":
answer = action.get("answer")
if not answer:
return "模型没有生成最终答案"
return str(answer)
if action_type != "tool_call":
return f"无法识别模型动作:{action}"
tool_name = action.get("tool")
arguments = action.get("arguments", {})
tool_func = TOOLS.get(tool_name)
if tool_func is None:
tool_result = {
"success": False,
"error": f"工具不存在:{tool_name}"
}
else:
try:
tool_result = tool_func(**arguments)
except TypeError as error:
tool_result = {
"success": False,
"error": f"工具参数错误:{error}"
}
except Exception as error:
tool_result = {
"success": False,
"error": f"工具执行异常:{error}"
}
print(f"[Agent] 调用工具:{tool_name}")
print(f"[Agent] 工具结果:{tool_result}")
messages.append({
"role": "assistant",
"content": json.dumps(action, ensure_ascii=False)
})
messages.append({
"role": "user",
"content": (
"下面是工具执行结果,请根据结果继续完成任务:\n"
+ json.dumps(tool_result, ensure_ascii=False)
)
})
return f"任务未在 {MAX_STEPS} 步内完成,已停止运行"
if __name__ == "__main__":
test_questions = [
"计算 128*96 + 45**2 - 88/2,并说明最终结果。",
"用一句话解释什么是 AI Agent。"
]
for question in test_questions:
print("\n" + "=" * 60)
print(f"用户:{question}")
print(f"Agent:{run_agent(question)}")
五、代码是怎么运行的
1. 初始化模型客户端
client = OpenAI(
api_key=api_key,
base_url="这里填写兼容接口地址"
)
这里使用 OpenAI Python SDK 初始化客户端。只要服务端兼容对应的接口格式,就可以通过 base_url 和模型名称切换模型,不需要重写 Agent 主体逻辑。
API Key 从环境变量读取:
api_key = os.getenv("GENVIS_API_KEY")
这种方式比直接把 Key 写进源码更安全,也方便开发、测试和生产环境使用不同配置。
2. 为什么不直接使用 eval
很多极简 Agent 教程会这样实现计算器:
eval(expression)
代码确实很短,但如果表达式来自用户输入,就可能执行非预期代码,不适合直接用于真实项目。本文使用 Python AST 解析表达式,只允许以下内容:
-
数字;
-
加减乘除;
-
整除与取余;
-
幂运算;
-
正负号。
<font color="#F59E0B"><b>安全提示</b></font>:其他语法都会被拒绝。需要注意的是,这仍然是教学版本,正式部署时还应该继续增加:
-
表达式长度限制;
-
数值大小限制;
-
幂指数限制;
-
执行超时;
-
调用频率限制。
3. 工具注册表
TOOLS = {
"calculator": calculator_tool
}
工具注册表负责建立“工具名称”和“Python 函数”之间的映射。后面增加新工具时,只需要新增函数并完成注册:
TOOLS = {
"calculator": calculator_tool,
"search": search_tool,
"read_file": read_file_tool
}
Agent 主循环不需要大改。
4. 为什么要求模型输出 JSON
模型需要告诉程序四件事:
-
是否调用工具;
-
调用哪个工具;
-
传入什么参数;
-
是否已经完成任务。
如果让模型随意输出自然语言,程序就很难稳定解析。因此,本文约定两种 JSON 格式:
调用工具:
{
"type": "tool_call",
"tool": "calculator",
"arguments": {
"expression": "128*96 + 45**2 - 88/2"
}
}
输出答案:
{
"type": "final",
"answer": "最终计算结果为 14269"
}
这种方式虽然没有使用框架,但已经体现了工具调用协议的基本思想。
5. Agent 循环
核心循环位于:
for step in range(1, MAX_STEPS + 1):
每一轮都会完成以下操作:
-
请求模型生成下一步动作;
-
解析模型返回的 JSON;
-
如果是
tool_call,执行对应工具; -
将工具结果加入上下文;
-
再次请求模型判断;
-
如果是
final,结束循环并返回答案。
同时设置最大执行步数:
MAX_STEPS = 5
这是为了避免模型持续调用工具,造成死循环和不必要的 Token 消耗。
六、运行程序
在终端执行:
python agent.py
计算任务的运行过程大致如下:
用户:计算 128*96 + 45**2 - 88/2,并说明最终结果。
[Agent] 正在执行第 1 步
[Agent] 调用工具:calculator
[Agent] 工具结果:{'success': True, 'result': 14269.0}
[Agent] 正在执行第 2 步
Agent:最终计算结果为 14269。
知识问答不需要调用计算器,模型可以直接返回:
用户:用一句话解释什么是 AI Agent。
[Agent] 正在执行第 1 步
Agent:AI Agent 是能够理解目标、制定决策并调用工具完成任务的智能系统。
这说明同一个 Agent 可以根据任务类型,自主决定是否调用工具。
七、如何增加第二个工具
理解计算器工具以后,可以继续增加时间工具。
from datetime import datetime
def current_time_tool() -> dict[str, Any]:
return {
"success": True,
"time": datetime.now().isoformat(timespec="seconds")
}
注册工具:
TOOLS = {
"calculator": calculator_tool,
"current_time": current_time_tool
}
然后在系统提示词中补充工具说明:
2. current_time
用途:获取当前系统时间。
参数:不需要参数。
模型需要调用该工具时,输出:
{
"type": "tool_call",
"tool": "current_time",
"arguments": {}
}
程序就能自动找到并执行该函数。
由此可以看出,扩展 Agent 的基本流程只有三步:
-
编写工具函数;
-
注册到
TOOLS; -
把工具名称、用途和参数告诉模型。
八、这个版本还可以怎样升级
本文实现的是教学型 Agent,目的是弄清楚最核心的执行链路。实际项目中还可以继续增加以下能力。
1. 使用原生 Function Calling
本文手动约定 JSON 格式,便于理解底层原理。如果模型和接口支持原生工具调用,可以进一步使用 tools 和 JSON Schema 等机制,提高参数约束和解析稳定性。
2. 增加上下文记忆
当前程序每次运行只处理一个任务。可以将历史消息保存到:
-
内存;
-
JSON 文件;
-
Redis;
-
MySQL;
-
向量数据库。
这样就能实现连续对话和跨会话记忆。
3. 增加联网搜索
接入搜索工具后,Agent 可以处理:
-
实时新闻;
-
商品信息;
-
技术文档检索;
-
行业数据查询。
搜索结果仍然应该交给模型进行筛选、整理和总结。
4. 增加任务规划
复杂任务可以先由模型输出步骤列表,再逐步执行,例如:
目标:生成一份竞品分析
步骤:
1. 搜索竞品资料;
2. 提取产品功能;
3. 比较价格和定位;
4. 汇总结论;
5. 生成结构化报告。
还可以进一步把“规划”和“执行”拆成两个不同角色。
5. 增加可观测性
Agent 上线以后,建议记录以下信息:
-
每轮模型输入与输出;
-
工具名称和参数;
-
工具执行结果;
-
任务耗时;
-
Token 使用量;
-
单次任务成本;
-
错误和重试次数。
这些信息对于调试 Agent、控制成本和优化提示词非常重要。
九、常见问题与避坑建议
1. 模型偶尔不返回合法 JSON
即使提示词要求只输出 JSON,模型也可能添加 Markdown 代码块或额外说明。
可以采用以下方式改善:
-
将温度设置得更低;
-
使用支持结构化输出的模型;
-
增加 JSON Schema;
-
对解析失败进行有限次数重试。
2. 工具参数可能不完整
模型可能遗漏参数或使用错误字段名,因此工具执行必须包含异常处理,不能默认模型输出永远正确。
3. 必须限制最大循环次数
如果不设置 MAX_STEPS,模型可能反复调用同一个工具,形成死循环并持续消耗 Token。
4. 不要直接执行模型生成的代码
<font color="#E5484D"><b>风险警告</b></font>:代码执行、Shell 命令和文件操作都属于高风险工具。如果确实需要,应放在受限沙箱中,并增加:
-
权限控制;
-
命令白名单;
-
网络限制;
-
文件范围限制;
-
执行超时;
-
人工确认。
5. 先理解原理,再使用框架
框架能够减少重复代码,但不会替代对 Agent 执行流程的理解。手写一次最小 Agent 后,再学习 LangChain、AutoGen 等框架,会更容易理解其中的 Tool、Memory、Planner、Executor 和 Callback 等概念。
十、总结
本文没有使用任何 Agent 框架,而是用 Python 手写了一个最小可运行 Agent。它已经具备三项关键能力:
-
根据任务自主判断是否调用工具;
-
将工具执行结果重新交给模型;
-
通过循环持续执行,直到输出最终答案。
整个过程可以概括为:
用户任务
→ 模型决策
→ 工具调用
→ 返回结果
→ 再次决策
→ 最终答案
理解这条链路以后,无论后续使用哪种 Agent 框架,本质上都只是在它的基础上增加工具管理、记忆、规划、权限控制和工程化能力。如果准备继续完善这个项目,下一步建议加入搜索工具和对话记忆。这样就能从一个教学 Demo,逐步升级为真正可以处理实际任务的 Agent 应用。
如果你在运行时遇到模型切换、接口兼容或 Token 统计问题,可以重点检查示例代码中的客户端配置、模型名称和环境变量。不同模型共用一套 Agent 主体代码,切换时通常只需要调整配置,不需要重写业务逻辑。
更多推荐

所有评论(0)