手写一个带 tool calling 的最小 Agent 循环(OpenAI 兼容 Chat Completions),附超时、JSON 解析、工具错误回灌 3 个踩坑
2026 年 Agent 框架很多:LangGraph、Pydantic AI、各家 Agents SDK……教程一上来就绑框架,排障时反而看不清「模型到底回了什么、工具结果怎么塞回去」。这篇用 纯标准库 写一个 OpenAI 兼容 Chat Completions 形状的最小 tool-calling 循环:messages → 调模型 → 执行 tool_calls → role=tool 回灌 → 再调,直到没有工具调用或触达轮次上限。
你会得到:
- 一个可运行的
FakeLLM(按脚本返回tool_calls),零密钥复现完整多轮; - 超时包装、
argumentsJSON 解析、工具错误回灌三处硬坑的复现与修法; - 何时该停手改上框架的判断标准。
一、为什么要自己写一遍最小循环
框架帮你做了:重试、流式、并行工具、状态机、人机确认。入门和排障时更需要看清三件事:
| 环节 | 协议里长什么样 |
|---|---|
| 请求 | messages + tools(JSON Schema) |
| 模型要调工具 | finish_reason=tool_calls,message.tool_calls[].function.arguments 常是 字符串 |
| 你执行完 | 追加 role=tool,带上同一个 tool_call_id,content 是字符串结果 |
把这三步手写通,再读框架源码或对接自建兼容端点会轻松很多。
二、环境
# 主路径:零依赖
python3 --version # 本文环境:Python 3.13.5
# 不需要 venv、不需要 pip install
# 可选:对接真实兼容端点时再装
# python3 -m venv .venv && source .venv/bin/activate
# pip install httpx
# 或:pip install openai
下文完整示例在同目录的 agent.py,直接:
python3 agent.py
python3 demo_pitfalls.py
python3 demo_timeout.py
三、工具 Schema + 本地实现
给模型看的是 Schema;真正执行的是本地函数。两边用同名对齐:
TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询某城市当前天气(示意)",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名,如 北京"},
},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "calc",
"description": "计算简单算术表达式,如 12*7+3",
"parameters": {
"type": "object",
"properties": {"expr": {"type": "string"}},
"required": ["expr"],
},
},
},
]
def get_weather(city: str) -> str:
data = {"北京": "晴 22°C", "上海": "多云 25°C", "深圳": "小雨 28°C"}
if city not in data:
raise ValueError(f"暂不支持城市: {city}")
return data[city]
def calc(expr: str) -> str:
allowed = set("0123456789+-*/(). ")
if not expr or any(c not in allowed for c in expr):
raise ValueError(f"非法表达式: {expr!r}")
result = eval(expr, {"__builtins__": {}}, {})
return str(result)
TOOL_IMPL = {"get_weather": get_weather, "calc": calc}
calc 用字符白名单约束后再 eval,仅作 demo;生产环境请换成正式表达式引擎或禁用任意表达式。
四、执行单个 tool_call(两个坑都在这里)
import json
def run_one_tool(name: str, arguments: str | dict | None) -> str:
try:
if arguments is None or arguments == "":
args = {}
elif isinstance(arguments, str):
args = json.loads(arguments) # 坑 1
elif isinstance(arguments, dict):
args = arguments
else:
return f"ERROR: arguments 类型不支持: {type(arguments).__name__}"
if name not in TOOL_IMPL:
return f"ERROR: 未知工具 {name}"
return TOOL_IMPL[name](**args)
except json.JSONDecodeError as e:
return f"ERROR: arguments 不是合法 JSON: {e}"
except TypeError as e:
return f"ERROR: 参数不匹配: {e}"
except Exception as e:
return f"ERROR: {type(e).__name__}: {e}" # 坑 2:别往外抛
要点:
- 永远返回字符串,写进
role=tool的content; - 解析失败、未知工具、业务异常,全部变成
ERROR: ...文本,让模型有机会改参数,而不是把整个循环打断。
五、FakeLLM:按脚本吐 OpenAI 形状的响应
真实调用就是 POST /v1/chat/completions。这里用脚本模拟三次响应:先并行两个工具 → 再故意调一个会失败的工具 → 最后给终答。
from dataclasses import dataclass, field
from typing import Any
import time
@dataclass
class FakeLLM:
script: list[dict[str, Any]]
call_count: int = 0
history: list[list[dict[str, Any]]] = field(default_factory=list)
def chat(self, messages, tools=None) -> dict:
self.history.append(list(messages))
if self.call_count >= len(self.script):
msg = {"role": "assistant", "content": "(脚本已用尽,结束)"}
else:
msg = dict(self.script[self.call_count])
self.call_count += 1
time.sleep(0.01)
return {
"choices": [{
"index": 0,
"finish_reason": "tool_calls" if msg.get("tool_calls") else "stop",
"message": msg,
}]
}
脚本片段(注意 arguments 是字符串,和真实 API 一致):
{
"role": "assistant",
"content": None,
"tool_calls": [{
"id": "call_weather_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "北京"}',
},
}, {
"id": "call_calc_1",
"type": "function",
"function": {
"name": "calc",
"arguments": '{"expr": "12*7+3"}',
},
}],
}
六、Agent 循环本体
class LLMTimeout(Exception):
pass
def call_llm_with_timeout(llm, messages, tools, timeout_s: float) -> dict:
t0 = time.monotonic()
resp = llm.chat(messages, tools=tools)
elapsed = time.monotonic() - t0
if elapsed > timeout_s:
raise LLMTimeout(f"LLM 调用超时: {elapsed:.3f}s > {timeout_s}s")
return resp
def agent_loop(llm, user_text, *, max_rounds=5, timeout_s=30.0, verbose=True) -> str:
messages = [
{"role": "system", "content": "你是助手。需要查天气或算数时请调用工具,再根据结果回答。"},
{"role": "user", "content": user_text},
]
for round_i in range(1, max_rounds + 1):
if verbose:
print(f"\n===== round {round_i} =====")
resp = call_llm_with_timeout(llm, messages, TOOLS_SCHEMA, timeout_s)
choice = resp["choices"][0]
msg = choice["message"]
finish = choice.get("finish_reason")
tool_calls = msg.get("tool_calls") or [] # 坑 3
if verbose:
print(f"finish_reason={finish!r}, tool_calls={len(tool_calls)}")
if msg.get("content"):
print(f"assistant content: {msg['content']}")
messages.append(msg) # 含 tool_calls 的 assistant 原样留下
if not tool_calls:
return msg.get("content") or ""
for tc in tool_calls:
tc_id = tc.get("id") or "call_0"
fn = tc.get("function") or {}
name = fn.get("name") or ""
arguments = fn.get("arguments")
if verbose:
print(f" -> call {name}({arguments!r})")
result = run_one_tool(name, arguments)
if verbose:
print(f" <- {result}")
messages.append({
"role": "tool",
"tool_call_id": tc_id,
"content": result,
})
return f"(已达 max_rounds={max_rounds},强制结束)"
循环不变式:
- 先把带
tool_calls的 assistant 消息 append; - 每个
tool_call对应一条role=tool; tool_calls为空(或字段缺失)→ 结束,返回content;max_rounds兜底,防止模型反复瞎调工具。
七、真实运行输出
python3 agent.py
输出(本机实测):
===== round 1 =====
finish_reason='tool_calls', tool_calls=2
-> call get_weather('{"city": "北京"}')
<- 晴 22°C
-> call calc('{"expr": "12*7+3"}')
<- 87
===== round 2 =====
finish_reason='tool_calls', tool_calls=1
-> call get_weather('{"city": "火星"}')
<- ERROR: ValueError: 暂不支持城市: 火星
===== round 3 =====
finish_reason='stop', tool_calls=0
assistant content: 北京今天晴,约 22°C;12×7+3 = 87。另外「火星」不在支持列表里,工具返回了错误,我没有中断,而是根据错误提示改口说明暂不支持。
===== FINAL =====
北京今天晴,约 22°C;12×7+3 = 87。另外「火星」不在支持列表里,工具返回了错误,我没有中断,而是根据错误提示改口说明暂不支持。
LLM 被调用了 3 次
三轮分别对应:并行工具 → 错误回灌 → 终答。FakeLLM 第二次故意查「火星」,工具抛错被吃掉变成 ERROR: ...,循环继续,第三次模型(脚本)据此改口。
八、三个踩坑
坑 1:arguments 是字符串,要先 json.loads
兼容协议里 function.arguments 几乎总是 JSON 字符串,不是 dict。直接当映射解包会炸:
错法直接 **字符串: TypeError: dict() argument after ** must be a mapping, not str
对法 json.loads 后: 多云 25°C
有的 SDK 帮你解析成 dict,自己写 HTTP 客户端时务必分支处理 str | dict | None。
坑 2:工具抛错要当 content 回灌,别中断循环
ERROR: ValueError: 暂不支持城市: 火星
ERROR: ValueError: 计算失败: division by zero
ERROR: arguments 不是合法 JSON: Expecting value: line 1 column 1 (char 0)
若在 run_one_tool 里 raise,外层一崩,模型永远看不到失败原因,也无法自我纠正。约定:工具层失败 → 字符串错误 → role=tool;只有协议层/超时才中断或重试。
坑 3:finish_reason 与空的 tool_calls
不同兼容端点行为不一致:
finish_reason=stop且没有tool_calls字段;finish_reason=stop但带了tool_calls: [];- 偶发
finish_reason=tool_calls却是空数组。
稳健写法:
tool_calls = msg.get("tool_calls") or []
if not tool_calls:
return msg.get("content") or ""
不要只看 finish_reason == "tool_calls"。实测空数组分支:
tool_calls=[] → bool(tool_calls)=False,应结束循环
附:超时
真实场景用 httpx.Client(timeout=...) 或 asyncio.wait_for。本 demo 用耗时阈值示意(demo_timeout.py 把 chat 故意睡 0.2s,阈值 0.05s):
捕获超时: LLM 调用超时: 0.210s > 0.05s
超时属于协议/基础设施错误,通常不应塞进 role=tool,而是重试、换端点或向用户报错。
九、换成真实兼容端点(示意)
把 FakeLLM.chat 换成一次 HTTP POST 即可,形状不变:
# 示意:需要可用的 base_url / api_key,本文未实际调用
import httpx
def chat_completions(base_url, api_key, messages, tools, timeout=30.0):
with httpx.Client(timeout=timeout) as client:
r = client.post(
f"{base_url.rstrip('/')}/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json={"model": "your-model", "messages": messages, "tools": tools},
)
r.raise_for_status()
return r.json()
openai 官方 SDK 的 client.chat.completions.create(...) 返回对象,取 message.tool_calls 时注意 arguments 仍可能是字符串,解析逻辑可复用 run_one_tool。
十、小结:什么时候该上框架
| 你现在的状态 | 建议 |
|---|---|
| 搞清 tool calling 协议、写单工具 PoC、排障兼容端点 | 手写最小循环(本文)足够 |
| 多 Agent、分支/循环图、持久化状态、人机确认、流式 UI | LangGraph / Pydantic AI / 各家 Agents SDK |
| 工具很多、要鉴权/配额/审计 | 在最小循环外包一层注册表与中间件,或直接用框架的 tool 抽象 |
最小循环大概就这些:messages、tools schema、最多 N 轮、arguments 先 json.loads、工具错误当 content 回灌、空 tool_calls 当结束、LLM 调用加超时。先跑通,再决定要不要引入框架。
环境
- Python 3.13.5,Linux
- 标准库 only(主路径);httpx / openai 为可选对接
作者目前在做短视频自动化与 MCP 相关产品,这篇是对接兼容端点排障时整理的笔记。