手写函数调用循环
工具注册好了(第 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 章将给它加上各种保险丝。