手写函数调用循环

工具注册好了(第 5 章),模型也认字了,但两者还没接上:模型怎么「开口要工具」?程序怎么把结果喂回去?这就是 OpenAI 的 function calling 协议。本章手写一个 run_with_tools():请求时带上 TOOL_SCHEMAS → 判断响应里有没有 tool_calls → 有就逐个执行、把结果以 tool 消息回填 → 再请求,直到模型不再要工具为止,并用 max_steps 防死循环。

1. 函数调用的消息协议

一次完整往返涉及 4 种角色消息,顺序不能乱:

# 1. 用户提问
{"role": "user", "content": "现在几点?"}
# 2. 模型回复(内容为空,带 tool_calls,指定要调 get_current_time)
{"role": "assistant", "content": "", "tool_calls": [
    {"id": "call_abc", "type": "function",
     "function": {"name": "get_current_time", "arguments": "{}"}}]}
# 3. 程序执行工具后回填(tool_call_id 必须对应上一步的 id)
{"role": "tool", "tool_call_id": "call_abc", "content": "2026-01-01 12:00:00"}

然后把 1~3 全部带上再发一次请求,模型看到工具结果后给出最终文字答案。协议要点:assistant 消息里的 tool_calls 必须原样带回去;每个 tool_call 都要有一条同 id 的 tool 消息回填。

2. 完整代码 main.py

"""main.py —— 第 6 章:手写函数调用循环(替换第 3 章的 main.py)。"""
import json
from typing import Any, Dict, List

from llm_client import LLMClient, get_message
from tools import TOOL_SCHEMAS, call_tool

llm = LLMClient()


def assistant_to_dict(msg: Any) -> Dict[str, Any]:
    """把 API 返回的 assistant 消息转成可回传的字典(tool_calls 必须原样保留)。"""
    item: Dict[str, Any] = {"role": "assistant", "content": msg.content or ""}
    if msg.tool_calls:
        item["tool_calls"] = [
            {"id": tc.id,
             "type": "function",
             "function": {"name": tc.function.name,
                          "arguments": tc.function.arguments}}
            for tc in msg.tool_calls
        ]
    return item


def run_with_tools(user_input: str, system_prompt: str = "",
                   max_steps: int = 6) -> str:
    """函数调用主循环:请求 -> 执行工具 -> 回填,直到模型给出最终答案。"""
    messages: List[dict] = []
    if system_prompt:
        messages.append({"role": "system", "content": system_prompt})
    messages.append({"role": "user", "content": user_input})

    for step in range(1, max_steps + 1):
        print(f"\n===== 第 {step} 步:请求模型 =====")
        resp = llm.chat(messages, tools=TOOL_SCHEMAS)
        msg = get_message(resp)
        print("模型:", msg.content or "(无文字,发起工具调用)")

        if not msg.tool_calls:            # 不再要工具,这就是最终答案
            return msg.content or ""

        messages.append(assistant_to_dict(msg))
        for tc in msg.tool_calls:
            name = tc.function.name
            try:
                args = json.loads(tc.function.arguments or "{}")
            except json.JSONDecodeError:
                args = {}                 # 参数不是合法 JSON 也先执行,让错误回灌
            print(f"  -> 调用 {name}({args})")
            result = call_tool(name, args)
            print(f"  <- 结果:{result}")
            messages.append({"role": "tool", "tool_call_id": tc.id,
                             "content": result})

    return "达到最大步骤数仍未完成,已停止(可调大 max_steps 重试)"


if __name__ == "__main__":
    question = "现在是几点?北京天气怎么样?顺便帮我算一下 123+456 等于多少。"
    answer = run_with_tools(question, system_prompt="请用简体中文回答,多个结果分点列出。")
    print("\n===== 最终答案 =====")
    print(answer)

3. 运行与对话轨迹

python main.py

一次真实运行的对话轨迹大致如下(内容随模型与时间变化;模型可能一次只调一个工具,也可能并行调多个):

===== 第 1 步:请求模型 =====
模型:(无文字,发起工具调用)
  -> 调用 get_current_time({})
  <- 结果:2026-01-01 12:00:00
  -> 调用 get_weather({"city": "北京"})
  <- 结果:晴,24℃
  -> 调用 add({"a": 123, "b": 456})
  <- 结果:579

===== 第 2 步:请求模型 =====
模型:当前时间是 2026-01-01 12:00:00;北京天气晴朗,24℃;123 + 456 = 579。

===== 最终答案 =====
当前时间是 2026-01-01 12:00:00;北京天气晴朗,24℃;123 + 456 = 579。

看到第 1 步模型一次性发起三个并行工具调用、第 2 步直接给答案,说明循环闭环成功。

4. 要点拆解

  • 判断依据msg.tool_calls 为空表示模型认为不需要工具,此时 content 就是最终答案,循环结束;反之继续。也可看 finish_reason:值为 tool_calls 表示还要调工具,stop 表示结束(第 8 章还会用到它判断截断)。
  • assistant 消息必须带着 tool_calls 回传:直接 messages.append(assistant_to_dict(msg)),绝不能只存 content,否则服务端会报错或丢掉调用上下文。
  • 每个 tool_call 都要回填:一个 assistant 回合可能带多个 tool_calls(并行工具调用),逐条执行、逐条回填,id 一一对应。
  • max_steps 保护:模型偶尔会陷入「调了工具又调」的循环,步骤上限是最后一道保险;参数解析失败也不中断,把错误交给模型自己纠正。
  • 个别服务或模型不支持 tools 参数时会直接报错,此时退回第 7 章的纯文本 ReAct 方案即可,业务逻辑无需改动。

5. 常见问题排查

  • 报 tools / function 相关错误:说明该模型或服务不支持函数调用(或模型名配错),第 7 章的 ReAct 文本方案不依赖 tools 参数,可作替代。
  • 模型反复调用同一个工具:本循环只靠 max_steps 兜底,不会主动纠正重复动作;第 8 章的 run_agent 加入「动作签名计数」,专门治这类死循环。
  • 报 tool_call_id 不匹配 / 缺 tool 消息:多半是 messages 里丢了 assistant 的 tool_calls 或某条 tool 回填,检查 assistant_to_dict 与逐条回填的逻辑。
  • 助手内容为空属正常:发起工具调用的那一轮,assistant 消息 content 通常为空字符串,重点看 tool_calls 而不是 content。
  • 最终回答被截断:没设 max_tokens 时偶发,回复到一半被服务截断(finish_reason 为 length),第 8 章会检测该状态并让模型继续。 小结:函数调用循环只有 4 步——带 tools 请求、读 tool_calls、执行并回填 tool 消息、再来一轮直到无 tool_calls。这个「模型当大脑、代码当手脚」的循环,就是智能体的核心引擎,第 8 章将给它加上各种保险丝。
笔记加载中…