核心任务 :不依赖任何框架,用纯 Python 手写一个 ReAct Loop ,理解 Thought → Action → Observation 的交替机制,以及 while 循环如何控制"调用模型 → 执行工具 → 结果回填"的完整流程。
一、
Agent 本质上就是一个调用 LLM 的while循环,循环体里负责执行模型点名的工具。
循环流程(标准 ReAct Agent)
┌─────────┐ ① 发送:对话历史 + 工具说明书 ┌──────────┐
│ 代码 │ ───────────────────────────────▶ │ 模型 │
│(Agent循环)│ ◀─────────────────────────────── │(vLLM/API)│
└─────────┘ ② 返回:文字回答 或 "我想调用X(参数)"└──────────┘
│
│ ③ 代码真正执行 X(参数)
▼
工具函数(查库、搜索、读文件......)
│
│ ④ 结果作为新消息追加进对话历史,回到 ①
-
将系统提示词和用户问题组装成
messages。 -
把
messages发给 LLM,由 LLM 二选一:-
情况 A:不需要工具 → 直接输出答案,流程结束。
-
情况 B:需要工具 → 输出
tool_calls,告诉程序要调用哪个工具、参数是什么。
-
-
由 Python 代码在本地执行对应的工具函数,拿到执行结果。
-
把工具结果包装成
role:tool消息,追加到messages消息列表中。 -
回到循环开头,再次把完整消息发给 LLM。LLM 根据工具返回结果继续思考,并决定下一步动作:继续调用工具,或输出最终答案。
-
设置
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
五、常见问题
- "Agent 和普通的 LLM 调用有什么区别?"
普通调用是一问一答;Agent 是模型驱动的循环,由模型决定下一步动作,并通过工具与外部环境交互、根据反馈调整。 - "如何防止 Agent 死循环或成本失控?"
最大步数、token 预算、超时、重复调用检测、工具层限流。 - "工具调用失败怎么办?"
分层处理:工具内转成可读错误让模型自纠;外层兜底保证流程不崩;多次失败后降级或交给人。 - "模型生成的参数能直接信任吗?"
不能。要做参数校验(Pydantic)、权限限制、只读隔离,敏感操作人工确认。 - "ReAct 是什么?"
"推理 + 行动"交替的模式:想一步、调一个工具、看结果、再想。你这个循环就是 ReAct 的一种工程实现。
相关文档:
工具调用协议------OpenAI 官方 Function calling 文档:https://developers.openai.com/api/docs/guides/function-calling?api-mode=responses