手写一个带 tool calling 的最小 Agent 循环(OpenAI 兼容Chat Completions),附超时、JSON解析、工具错误回灌3个踩坑

0 阅读7分钟

手写一个带 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 回灌 → 再调,直到没有工具调用或触达轮次上限。

你会得到:

  1. 一个可运行的 FakeLLM(按脚本返回 tool_calls),零密钥复现完整多轮;
  2. 超时包装、arguments JSON 解析、工具错误回灌三处硬坑的复现与修法;
  3. 何时该停手改上框架的判断标准。

一、为什么要自己写一遍最小循环

框架帮你做了:重试、流式、并行工具、状态机、人机确认。入门和排障时更需要看清三件事:

环节协议里长什么样
请求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},强制结束)"

循环不变式:

  1. 先把带 tool_calls 的 assistant 消息 append;
  2. 每个 tool_call 对应一条 role=tool;
  3. tool_calls 为空(或字段缺失)→ 结束,返回 content;
  4. 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、分支/循环图、持久化状态、人机确认、流式 UILangGraph / 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 相关产品,这篇是对接兼容端点排障时整理的笔记。