手写能跑的AI agent循环 (纯 Python,无框架)原理+示例讲解

核心任务 :不依赖任何框架,用纯 Python 手写一个 ReAct Loop ,理解 Thought → Action → Observation 的交替机制,以及 while 循环如何控制"调用模型 → 执行工具 → 结果回填"的完整流程。

一、

Agent 本质上就是一个调用 LLM 的while循环,循环体里负责执行模型点名的工具。

循环流程(标准 ReAct Agent)

┌─────────┐ ① 发送:对话历史 + 工具说明书 ┌──────────┐

│ 代码 │ ───────────────────────────────▶ │ 模型 │

│(Agent循环)│ ◀─────────────────────────────── │(vLLM/API)│

└─────────┘ ② 返回:文字回答 或 "我想调用X(参数)"└──────────┘

│

│ ③ 代码真正执行 X(参数)

▼

工具函数(查库、搜索、读文件......)

│

│ ④ 结果作为新消息追加进对话历史,回到 ①

  1. 将系统提示词和用户问题组装成 messages。

  2. 把 messages 发给 LLM,由 LLM 二选一:

    • 情况 A:不需要工具 → 直接输出答案,流程结束。

    • 情况 B:需要工具 → 输出 tool_calls,告诉程序要调用哪个工具、参数是什么。

  3. 由 Python 代码在本地执行对应的工具函数,拿到执行结果。

  4. 把工具结果包装成 role:tool 消息,追加到 messages 消息列表中。

  5. 回到循环开头,再次把完整消息发给 LLM。LLM 根据工具返回结果继续思考,并决定下一步动作:继续调用工具,或输出最终答案。

  6. 设置 max_steps 限制最大循环次数,防止无限循环,例如模型一直写错 SQL 而反复重试。

以上就是 ReAct 范式的核心:Reason(思考)→ Act(调用工具)→ Observe(观察工具返回结果),如此循环往复。

二、完整示例

复制代码
# agent.py
import json
import sqlite3
from openai import OpenAI

# 如连 vLLM:先用 `vllm serve <模型名> --enable-auto-tool-choice --tool-call-parser <解析器>` 启动
# 
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
MODEL = "模型名"

# ---------- 1. 工具:普通 Python 函数 ----------
def get_schema(db_path: str) -> str:
    conn = sqlite3.connect(db_path)
    rows = conn.execute("SELECT sql FROM sqlite_master WHERE type='table'").fetchall()
    conn.close()
    return "\n".join(r[0] for r in rows if r[0])

def run_sql(db_path: str, sql: str) -> str:
    # 只允许 SELECT,这是最基本的安全护栏
    if not sql.strip().lower().startswith("select"):
        return "错误:只允许执行 SELECT 语句"
    try:
        conn = sqlite3.connect(db_path)
        rows = conn.execute(sql).fetchmany(20)  # 限制返回行数,防止撑爆上下文
        conn.close()
        return json.dumps(rows, ensure_ascii=False) if rows else "查询成功,但结果为空"
    except Exception as e:
        return f"SQL 执行错误:{e}"  # 把报错回传给模型,这就是"反思纠错"的来源

TOOL_IMPLS = {"get_schema": get_schema, "run_sql": run_sql}

# ---------- 2. 工具描述:告诉模型有哪些工具 ----------
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "get_schema",
            "description": "获取数据库的建表语句",
            "parameters": {
                "type": "object",
                "properties": {"db_path": {"type": "string"}},
                "required": ["db_path"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "run_sql",
            "description": "在数据库上执行一条 SELECT 语句并返回结果",
            "parameters": {
                "type": "object",
                "properties": {
                    "db_path": {"type": "string"},
                    "sql": {"type": "string"},
                },
                "required": ["db_path", "sql"],
            },
        },
    },
]

# ---------- 3. Agent 循环 ----------
def run_agent(question: str, db_path: str, max_steps: int = 8) -> str:
    messages = [
        {"role": "system", "content": (
            "你是 SQL 助手。先查看 schema,再写 SQL 并执行。"
            "如果执行报错或结果不合理,根据错误修正后重试。"
            f"数据库路径:{db_path}"
        )},
        {"role": "user", "content": question},
    ]

    for step in range(max_steps):           # 限制最大步数,防止死循环
        resp = client.chat.completions.create(
            model=MODEL, messages=messages, tools=TOOLS, temperature=0,
        )
        msg = resp.choices[0].message
        messages.append(msg)                # 把模型的回复加入历史

        if not msg.tool_calls:              # 没有工具调用 = 模型给出最终答案
            return msg.content

        for call in msg.tool_calls:         # 可能一次要调多个工具
            name = call.function.name
            args = json.loads(call.function.arguments)
            print(f"[step {step}] 调用 {name}({args})")
            try:
                result = TOOL_IMPLS[name](**args)
            except Exception as e:
                result = f"工具调用失败:{e}"
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,    # 必须对应上,模型才知道是哪次调用的结果
                "content": str(result),
            })

    return "达到最大步数,未能完成。"      # 兜底

if __name__ == "__main__":
    print(run_agent("有多少位歌手?", "singer_test.db"))

人工冒烟测试用例

新建 init_test_db.py,用来生成干净测试库,避免依赖外部 db

复制代码
# init_test_db.py
import sqlite3

def init():
    conn = sqlite3.connect("singer_test.db")
    cur = conn.cursor()
    cur.execute("""
    CREATE TABLE singers (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        country TEXT,
        birth_year INTEGER
    );
    """)
    # 插入测试数据
    data = [
        ("周杰伦", "中国", 1979),
        ("林俊杰", "新加坡", 1981),
        ("Taylor", "美国", 1989),
        ("邓紫棋", "中国", 1991),
    ]
    cur.executemany("INSERT INTO singers(name, country, birth_year) VALUES (?,?,?)", data)
    conn.commit()
    conn.close()
    print("singer_test.db 初始化完成,4条歌手数据")

if __name__ == "__main__":
    init()

执行:python init_test_db.py,得到 singer_test.db,路径直接给 agent 使用。

三、涉及知识点

代码位置 对应概念
TOOLS 的 JSON Schema Function Calling 的原理:模型只"选择"并生成参数,真正执行的是我们的代码
for step in range(max_steps) 终止条件设计,防止无限循环和成本失控
messages.append(...) 上下文管理:历史越长越贵,这里正是"历史压缩"能接入的位置
run_sql 返回错误字符串 反思纠错闭环:错误信息回灌给模型
startswith("select") 工具权限最小化,最基础的护栏
fetchmany(20) 工具输出截断,防止撑爆上下文
try/except 包住工具调用 工具失败不能让整个循环崩溃
1. OpenAI 兼容接口与 vLLM
复制代码
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
  • OpenAI 的 chat.completions 接口格式已成为事实上的行业标准,vLLM、SGLang、Ollama、很多国产模型的 API 都兼容它。
  • 好处:只改 base_url 和 model 就能切换商业 API 与自部署模型,业务代码不动。
  • api_key="EMPTY":vLLM 默认不校验,但 SDK 要求必须传值。
2. messages:对话历史就是 Agent 的"全部记忆"

模型本身无状态,每次调用都是全新的,所谓"记得上文",是因为每次把完整历史重新发给它。

messages 里有四种角色:

role 含义
system 设定身份、规则、约束
user 用户输入
assistant 模型的回复(可能带 tool_calls)
tool 工具执行结果

一次典型交互的 messages 如下:

复制代码
[system]    你是 SQL 助手...
[user]      有多少位歌手?
[assistant] (tool_calls: get_schema(...))        ← 模型想看表结构
[tool]      CREATE TABLE singer(...)             ← 执行后回灌
[assistant] (tool_calls: run_sql("SELECT count(*)..."))
[tool]      [[6]]
[assistant] 共有 6 位歌手。                       ← 无 tool_calls,循环结束

历史越长,每次调用越贵、越慢,还可能超出上下文窗口。

3. Function Calling:工具说明书
复制代码
TOOLS = [{"type": "function", "function": {
    "name": "run_sql",
    "description": "在数据库上执行一条 SELECT 语句并返回结果",
    "parameters": {"type": "object", "properties": {...}, "required": [...]}
}}]
  • 这是用 JSON Schema 写的"工具说明书",每次请求时随 tools= 发给模型。
  • 模型根据 name + description 判断要不要用、什么时候用 ;根据 parameters 生成符合格式的参数。
  • description 写得好不好,直接决定工具选择的准确率。 这是 Agent 调优的重要抓手:描述模糊,模型就乱选。
  • 底层实现(简化理解):服务端把工具说明书拼进提示词,模型生成特定格式的文本,再由解析器 把它还原成结构化的 tool_calls。所以 vLLM 需要指定 --tool-call-parser,不同模型的输出格式不同,解析器必须匹配。参数名随版本可能变化,以官方文档为准。
4. 循环的终止条件(最关键的设计点)
复制代码
for step in range(max_steps):
    ...
    if not msg.tool_calls:
        return msg.content

两种出口:

  • 正常出口:模型不再调用工具,说明它认为任务完成。
  • 兜底出口 :max_steps 用尽。

必须有兜底:模型可能反复调用同一个工具、陷入"报错→重试→同样的报错"的死循环。没有上限,就是无限循环 + 无限烧钱。生产里还会再加:总 token 预算、总耗时超时、"连续相同调用检测"。

5. tool_call_id:对应关系
复制代码
messages.append({"role": "tool", "tool_call_id": call.id, "content": str(result)})

模型一次可能发起多个 工具调用(并行调用)。tool_call_id 告诉模型"这个结果对应哪次调用"。缺了或对不上,接口会报错或模型会混淆。

6. 把错误当作"观察结果"回灌(反思纠错的本质)
复制代码
except Exception as e:
    return f"SQL 执行错误:{e}"

注意这里没有 raise ,而是把错误变成字符串返回。模型看到报错,下一轮就能改 SQL 重试。"反思纠错"没有任何神秘之处,就是:错误信息 → 追加进上下文 → 模型再生成。

两层容错要分清:

  • 工具内部:捕获业务错误(SQL 语法错),转成有信息量的文字给模型看。
  • 循环外层 try/except:捕获意外异常(参数缺失、工具崩溃),保证循环本身不挂。

错误信息的质量影响重试效果:"no such column: nam" 比 "出错了" 有用得多,甚至可以提示"可用列有:name, age"。

7. 工具层的安全与资源护栏
代码 防止目的
startswith("select") 模型被误导或被注入后执行 DROP TABLE
fetchmany(20) 大表查询返回百万行,撑爆上下文、烧 token
路径参数 db_path 由模型传入 这其实是个隐患,见下

这里坦白一个示例里的缺陷:让模型自己传 db_path 不安全 ,模型可能被诱导访问别的文件。更好的做法是 db_path 由你的代码固定(闭包或配置绑定),工具只暴露 sql 一个参数。这也是"工具权限最小化"的真实例子。

另外 startswith("select") 只是最基础 的检查,不严谨(比如 select 1; drop table x 在某些驱动下的行为、注释绕过等)。更稳的做法是用只读连接(SQLite 可用 mode=ro 的 URI 打开)+ 语句白名单解析。要讲清楚:护栏应该落在能力层(只读连接),而不是只靠字符串检查。

8. temperature=0

降低输出随机性,让结果更稳定、更便于复现和评测。但要知道:

  • 即使设为 0,不同硬件、批处理方式下也可能有细微差异,不是严格确定性。
  • 做评测时,通常固定它,否则指标波动会掩盖你的改动效果。

四、以上是agent循环demo版本,后续还可如何升级

缺失 生产中怎么做 对应后续的升级
上下文管理 超长时压缩、摘要、只保留关键步骤 历史压缩
可观测性 记录每步的 prompt、输出、token、耗时 加 trace(Langfuse)
重试与限流 模型接口超时、429 的指数退避重试 给 client.create 包一层
流式输出 边生成边返回,降低用户感知延迟 FastAPI + SSE
并行工具调用 多个互不依赖的调用并发执行 asyncio.gather
状态持久化 中途断了能恢复 LangGraph 的 checkpoint 就是做这个
评测 批量跑题、统计成功率 写 eval.py
结构化最终输出 用 Pydantic 校验最终答案格式 Pydantic

LangGraph 等框架本质上是把"状态管理、检查点、条件分支、并行、可观测"这些做成了通用组件,核心循环仍是上面那个 while

五、常见问题

  1. "Agent 和普通的 LLM 调用有什么区别?"
    普通调用是一问一答;Agent 是模型驱动的循环,由模型决定下一步动作,并通过工具与外部环境交互、根据反馈调整。
  2. "如何防止 Agent 死循环或成本失控?"
    最大步数、token 预算、超时、重复调用检测、工具层限流。
  3. "工具调用失败怎么办?"
    分层处理:工具内转成可读错误让模型自纠;外层兜底保证流程不崩;多次失败后降级或交给人。
  4. "模型生成的参数能直接信任吗?"
    不能。要做参数校验(Pydantic)、权限限制、只读隔离,敏感操作人工确认。
  5. "ReAct 是什么?"
    "推理 + 行动"交替的模式:想一步、调一个工具、看结果、再想。你这个循环就是 ReAct 的一种工程实现。

相关文档:

工具调用协议------OpenAI 官方 Function calling 文档:https://developers.openai.com/api/docs/guides/function-calling?api-mode=responses

相关推荐
大力财经1 小时前
山东山大华天软件与阿里云达成合作,旗下产品CrownCAD已接入Qoder
人工智能·阿里云·云计算
徐小夕@趣谈前端1 小时前
老旧Word格式全支持!JitWord协同AI文档4.1,让Word文档无缝迁移到Web端
前端·人工智能·word
AiNightVision1 小时前
星光级 是什么?——星光级摄像机是什么?0.001Lux 照度门槛与选型真相
人工智能·算法·车载系统·自动驾驶·无人机·视频·智能硬件
飞猫的边缘AI1 小时前
机器人基础模型的两种赌注:VLA与VFA
人工智能·机器人·vla·ai模型·边缘ai·视觉语言动作模型·vfa
Sailing1 小时前
🚀🚀【封闭结束6】Agent 会话服务与鉴权上下文
前端·人工智能·agent
红海云1 小时前
Agent Skills Framework:SKILL.md驱动的AI代理工程化
人工智能
LX567771 小时前
离职后准备重新就业,AI证书选哪个方向?
人工智能
天远API1 小时前
零信任架构实战:基于天远企业股权变更构建自动化私募股权投后控制权穿透与异动预警网关
大数据·人工智能·架构·自动化