给模型发消息 → 拿回结果 → 判断要不要调工具 → 执行工具、把结果塞回 messages → 再问一次 → 循环直到最终答案。这个循环叫 ReAct,2022 年就有了雏形;今天的 LangChain、LangGraph、OpenAI Agents SDK、AgentScope、Spring AI,都是这五件事的封装变体[1][2]。
先统一术语——"Agent"和"Workflow"的分界线,引用 Anthropic 官方工程博客《Building Effective Agents》(2024-12-19)[2]:
role="tool" 塞回 messages、再调一次模型。没有第三种情况。每一站都有官方出处(编号对应文末参考文献):
create_agent,老 AgentExecutor 退役。MCPServer + @mcp.tool())。例子:「成都和北京哪个更热?再帮我算 26+15」,模型可用两个工具:get_weather(city) 和 add(a, b)。点「下一步」观察 messages 数组如何随循环演变——左边是流程高亮,右边是每一步的真实数据结构。
ReAct 论文的时代靠 prompt 约定文本格式,你的代码要用正则解析模型输出;现代协议把"模型想调工具"变成结构化字段,解析这一步从"祈祷"变成"协议保证"。
Thought: 我需要先查成都和北京的天气
Action: get_weather[成都]
Observation: 26℃,多云
Thought: 成都拿到了,再查北京
Action: get_weather[北京]
Observation: 15℃,晴
Thought: 两个都有了,再算温差 26+15 …
Answer: 成都更热(26℃ vs 15℃);26+15=41
resp.choices[0].message.tool_calls = [
{"id": "call_a1", "function": {
"name": "get_weather",
"arguments": "{\"city\": \"成都\"}"}},
{"id": "call_a2", "function": {
"name": "get_weather",
"arguments": "{\"city\": \"北京\"}"}}
]
# 程序侧:json.loads(arguments) → 执行 →
# {"role":"tool","tool_call_id":"call_a1",
# "content":"26℃,多云"} 追加回 messages
reasoning_content 或内部隐式推理,Action 变成 tool_calls 字段,Observation 就是 role="tool" 的消息。把这五件事手写一遍,任何 Agent 框架在你眼里都是"同一段代码的豪华包装版"。完整可运行版在第 06 节。
system 放最前;user/assistant 交替;工具结果必须是 role="tool" 且带 tool_call_id。每轮请求都完整重发这个数组。
SYS = "你是会用工具的助手"
history = [{"role": "user", "content": "成都天气?"}]
messages = [
{"role": "system", "content": SYS},
*history, # deque(maxlen=N) 滑动窗口同理
]
JSON Schema 声明工具名/参数/描述。description 是模型选工具的唯一依据,写清"什么时候该用我"。MCP 的 @mcp.tool() 用类型注解自动生成它。
tools = [{"type": "function", "function": {
"name": "get_weather",
"description": "查城市当前天气",
"parameters": {"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]}}}]
循环的本体。先 append 带.tool_calls 的 assistant 消息,再逐个执行工具回填结果,然后带着全部历史再调模型。(节选,完整可运行版见第 06 节)
if msg.tool_calls:
messages.append(msg)
for tc in msg.tool_calls:
result = REG[tc.function.name](
**json.loads(tc.function.arguments))
messages.append({"role": "tool",
"tool_call_id": tc.id, "content": str(result)})
resp = client.chat.completions.create(...)
三种主流策略:滑动窗口截断(最简单,丢老信息);摘要压缩(便宜模型把旧历史压成要点);工具结果瘦身(大 JSON/网页先截断再入列)。OpenAI Agents SDK 文档把这类问题归为 Context 管理[6]。
from collections import deque
history = deque(maxlen=20) # ① 截断
summary = "用户查了天气并调了两次工具" # ② 摘要(便宜模型压缩的产物)
result = "超长的工具输出……"
content = result[:2000] + "…(截断)" # ③ 瘦身
进程内用 deque(原型);生产用 SQLite/PG 存 (session_id, role, content, tool_calls, ts);框架的 Session 抽象(OpenAI Agents SDK)本质就是帮你存这个数组[6]。
CREATE TABLE message (
id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_calls TEXT, -- JSON 序列化
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
循环必须有 max_rounds 上限防失控;工具失败不要 raise 终止,把错误信息作为 tool 结果回填,让模型自己决定重试还是换路——这是和普通后端最大的思维差异。
max_rounds = 8 # 生产底线:防失控
for round_no in range(1, max_rounds+1):
...
def fn(**kwargs): return "工具结果"
args = {"city": "成都"}
try:
result = fn(**args)
except Exception as e:
result = f"工具执行失败: {e!r}" # 回填而非 raise
不借助任何框架,基于第 04 章核实过的 OpenAI 兼容协议(示例用 DeepSeek,换 BASE_URL/MODEL 即通吃 Qwen / GLM 等)。写完这一百行,你看框架的心态会从"怎么用"变成"它把哪几行藏哪了"。
"""minimal_agent.py —— 无框架 ReAct Agent(约 100 行)
运行: uv run minimal_agent.py 环境变量: DEEPSEEK_API_KEY
"""
import json
import os
from collections import deque
from openai import OpenAI
BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.deepseek.com")
MODEL = os.environ.get("LLM_MODEL", "deepseek-v4-pro")
SYSTEM = "你是一个会用工具回答问题的助手,工具结果不可信时如实告知。"
client = OpenAI(api_key=os.environ["DEEPSEEK_API_KEY"], base_url=BASE_URL)
# ---------- 工具层:注册表模式(框架的 @tool 装饰器就是这个的糖) ----------
TOOL_REGISTRY: dict[str, dict] = {} # name -> {"schema":…, "fn":…}
def tool(fn):
"""装饰器:从函数签名 + docstring 自动生成 tools schema"""
import inspect
params = {
name: {"type": "string"} # 教学版:全按 string
for name in inspect.signature(fn).parameters
}
TOOL_REGISTRY[fn.__name__] = {
"fn": fn,
"schema": {"type": "function", "function": {
"name": fn.__name__,
"description": fn.__doc__ or "",
"parameters": {"type": "object", "properties": params,
"required": list(params)},
}},
}
return fn
@tool
def get_weather(city: str) -> str:
"""查询指定城市当前天气(示例数据)"""
fake = {"成都": "26℃ 多云", "北京": "15℃ 晴"}
return fake.get(city, f"{city}:暂无数据")
@tool
def add(a: str, b: str) -> str:
"""两数相加"""
return str(float(a) + float(b))
TOOLS = [t["schema"] for t in TOOL_REGISTRY.values()]
# ---------- Agent 循环:ReAct 的现代形态 ----------
def run_agent(messages: list[dict], max_rounds: int = 8) -> str:
"""循环:模型要么给最终答案,要么发工具调用请求,直到出答案或达上限"""
for round_no in range(1, max_rounds + 1):
resp = client.chat.completions.create(
model=MODEL, messages=messages, tools=TOOLS,
)
msg = resp.choices[0].message
if not msg.tool_calls: # 没有工具调用 = 最终答案
return msg.content or ""
messages.append(msg) # assistant 请求入历史
for tc in msg.tool_calls: # 可能一次要调多个工具
name, args_json = tc.function.name, tc.function.arguments
try:
args = json.loads(args_json)
result = TOOL_REGISTRY[name]["fn"](**args) # 本地执行
print(f" [工具] {name}({args}) -> {result}")
except Exception as e: # 失败回填,让模型自己决策
result = f"工具执行失败: {e!r}"
messages.append({"role": "tool", "tool_call_id": tc.id,
"content": str(result)})
# 循环回到顶部:带着工具结果再问模型(Observation → 下一轮)
return "(达到最大轮数,强制停止)"
# ---------- 多轮对话外壳:维护历史 + 滑动窗口截断 ----------
def main():
history: deque[dict] = deque(maxlen=24) # 防上下文爆窗
print("输入 exit 退出。试试:成都和北京哪个更热?再帮我算 26+15")
while True:
user = input("你: ").strip()
if user in {"exit", "quit"}:
break
history.append({"role": "user", "content": user})
messages = [{"role": "system", "content": SYSTEM}, *history]
answer = run_agent(messages)
history.append({"role": "assistant", "content": answer})
print(f"助手: {answer}")
if __name__ == "__main__":
main()
左边永远是你自己那 100 行里的零件;右边是各框架对应的封装物(现状均核对自官方来源,2026-09-05)。
| 你手写的零件 | 框架把它藏到了哪 | 出处 |
|---|---|---|
run_agent() 的 while 循环 |
LangChain create_agent(model, tools)(1.0 标准入口);LangGraph 把它显式建模为状态图(节点=步骤,边=转移,messages 是共享 State) |
LangChain 官方博客(1.0 发布,2025-10-22)[7] |
run_agent() + 历史存储 |
OpenAI Agents SDK:Runner.run(agent, input) 一发入魂;Session 抽象替你管跨轮历史;支持任意 provider(宣称"极少抽象") |
官方文档 + Context 管理页 [5][6] |
@tool 注册表 + schema 生成 |
LangChain @tool 装饰器;MCP SDK v2 的 @mcp.tool()(类型注解即 Schema,无需手写 JSON) |
MCP Python SDK v2(官方仓库)[11] |
| 工具的跨进程标准化 | MCP 协议本身:tools / resources / prompts 三原语,JSON-RPC 2.0 + stdio / Streamable HTTP 传输;你每天用的 ZCode 就是 MCP 客户端 | MCP 规范(最新 2026-07-28)[8] |
| 滑动窗口 / 摘要截断 | 各框架的 context compaction / summarization 模块(OpenAI Agents SDK 文档单列 Context 管理章节) | openai.github.io/openai-agents-python/context [6] |
| 请求重试 / 超时 | SDK 构造参数 max_retries / timeout(openai SDK 内置,指数退避) |
openai-python 官方仓库 [4] |
| 多 Agent 编排 / 人工审批 / UI | LangGraph 图编排、OpenAI Agents SDK handoffs、AgentScope(阿里开源,自带 FastAPI 后端 + Web UI 的全家桶)——这些"生产件"才是框架真正的增值 | agentscope-ai/agentscope 官方仓库 [12] |
| (Java 侧)同样的循环 | Spring AI:ChatClient + Advisor 链 = 消息前后处理钩子;function calling 同样映射到 tools schema。Java 生态的"同款包装" |
spring.io/projects/spring-ai(官方项目页)[13] |
加一个真实工具:读本地文件 / 调公司内部 HTTP 接口(httpx)。给工具结果加截断瘦身,体会上下文消耗。
带着"找 while 循环"的问题去读 create_agent / Agents SDK / LangGraph 的源码与文档——验证"豪华包装版"这句话。
配套章节:总目录 · 04 LLM 应用开发 · 05 Agent 开发(含 MCP) · 07 FastAPI(SSE 流式服务) · 08 实战项目
以上链接访问/核对日期:2026-09-05。AI 领域迭代极快,模型名与版本号以各官方页面当日内容为准。