This chapter has not been translated yet — the Chinese original is shown below.

一轮循环里到底发生了什么

所属:第一部分 · 模型和 Agent 到底是什么(第 1–4 章) 前置:第 3 章(下一步由谁决定)、第 2 章(工具、循环、上下文装配、轮数上限) 本章新概念:工具 schema、工具调用请求、调用编号(call ID)、结果回填、停止条件


一、上一章留下的问题

上一章把 Agent 和工作流分开的判据压成了一句话:下一步做什么,是程序事先定好的,还是模型在运行时决定的?

假设一项调研的路径确实画不出来,决定让模型选择下一步。这个选择落到代码里,会出现两种结果:模型直接给出回答,或者提出一条工具调用请求。第二种结果还没有让任何工具真正运行——模型只完成了「选下一步」,程序仍要接住这个选择,把动作做完。

于是,一份最小 Agent 程序必须回答四个问题:

  1. 当前要延续的输入状态和可用工具怎样交给模型?
  2. 程序怎样识别模型提出的调用请求?
  3. 工具由谁执行,结果怎样与原请求配对?
  4. 什么情况继续下一轮,什么情况停止?

这一章用 OpenAI Responses API 和 Python 把四个问题拼成一个可运行版本。具体字段按 2026 年 8 月的 OpenAI function calling 官方文档编写;换服务商时字段可能不同,但这四个问题不会消失。

二、先拿到整轮的低分辨率地图

这里把一轮定义为:从程序发起一次模型调用开始,到程序返回回答,或者把这一轮全部工具结果回填完毕为止。

一轮不是一条没有岔路的直线,而是一次分流:

提交当前输入状态和可用工具
└─ 模型返回
   ├─ 响应已完成、没有调用且有文本 → 返回回答,正常停止
   └─ 响应已完成、存在调用请求     → 校验和分发 → 执行或报错 → 按编号回填 → 下一轮

响应未完成、达到轮数上限或触发超时 → 异常或强制停止,任务不算完成

只看工具分支,程序反复承担四个职责:

职责 程序要做什么 OpenAI 实现里的落点
提交 交出当前输入状态,并声明模型可以请求哪些动作 inputinstructionstools
识别 检查响应是否完整,再从输出中找调用请求 response.statusresponse.output
执行 校验工具名和参数;接受的请求调用真实函数,拒绝的请求生成错误 TOOL_IMPL[...]
回填 把每个结果与原请求配对,加入下一轮状态 call_idfunction_call_output

停止不是和这四项并列的第五个动作。它是「识别」之后的另一条分支:响应完整、没有调用请求而且有可返回文本,程序才走本例的正常出口。

把这张地图压成一句话:

一轮 Agent 循环是一次受控交接:程序划定当前状态和可请求的动作,模型选择下一步;程序根据输出停止,或者校验和分发请求、按编号回填执行结果或错误,再把更新后的状态交给模型。

模型决定下一步,不等于模型接管了一切。状态装什么、开放哪些工具、真实副作用是否发生、最多转几轮,仍由模型外的程序控制。

三、没有循环:模型说要做,不等于程序会接着做

任务仍用全课共用的例子:把这周关于某个题目的公开资料查一遍,写成一页纸并附出处。

先只调用一次模型:

from openai import OpenAI

client = OpenAI()
response = client.responses.create(
    model="gpt-5.6",
    input="把这周关于稳定币监管的公开资料查一遍,写成一页纸,每条结论后面附出处。",
)
print(response.output_text)

这段程序只有一次生成,没有声明搜索工具,也没有第二次模型调用。模型可能说明自己缺少实时资料,也可能根据已有知识写出一段看似完整的文字;无论文字质量如何,程序都没有执行这周的搜索。

即使模型在文字里写「我还需要搜索」,这也只是一句话。程序既没有把它识别成结构化动作,也没有执行动作,更不会因为这句话自动再调用一次模型。没有外部循环,就没有可继续的「下一步」。

四、提交之前:当前状态与可用动作

一次 Responses API 请求里,本章会反复交三类内容:

内容 回答什么问题 本例放什么
instructions 模型做这项任务时要遵守什么 只根据工具结果写,不编造来源
tools 模型可以请求哪些动作,参数怎样填写 一个名为 search 的函数工具
input 任务目前进行到哪里 用户任务、上一轮模型输出、工具结果

本章让应用自己维护 input_items,把它作为手动接续的跨轮输入状态。任务刚开始时,里面只有用户要求:

input_items = [{"role": "user", "content": task}]

模型提出调用后,程序把该轮的完整 response.output 追加进去;工具执行后,再追加对应的工具结果。下一轮把更新后的 input_items 重新提交,模型才能接着上一轮往下做。

input_items 只是本例手动接续的协议状态,不是整个 Agent 的任务状态,也不等于一次调用的全部有效上下文。轮数、工具实现和外部文件都在它之外;模型这一轮还会看到 instructions 和工具描述。这里把跨轮输入单独命名,是为了看清它怎样延续。

可用动作则用一份工具 schema 声明。它至少要回答三件事:

问题 本例的答案
工具叫什么 search
它做什么、什么时候该请求 按关键词返回模拟搜索结果
参数是什么形状 必须提供字符串 query,不接受额外字段

这份 schema 是模型能读到的动作契约,不是工具实现。模型能看到名字、描述和参数规则,却看不到后面的 Python 函数怎样工作。strict: true 可以约束参数形状,但不能保证模型选对工具、关键词合理,或者工具返回的事实可靠。

工具描述必须忠于实现。本章的 search 不访问互联网,只返回标题和链接的假数据,所以 schema 也会明确写「模拟结果」。如果描述声称它能实时搜索,程序却只吐出假网页,那不是模型犯错,而是程序给了错误契约。

五、模型返回以后:回答,还是调用请求

Responses API 的 response.output 是一组项目,顶层可能包含 messagereasoningfunction_call 等不同类型;response.output_text 只是 SDK 汇总文本的快捷属性。

当模型选择调用工具时,其中会出现一个类似这样的项目:

{
  "type": "function_call",
  "call_id": "call_01",
  "name": "search",
  "arguments": "{\"query\":\"稳定币监管 本周\"}"
}

四个字段各管一件事:

字段 含义 程序接下来做什么
type 这是一条函数调用请求 用它从混合输出中筛出调用
name 模型想用哪个工具 在允许的实现表里找对应函数
arguments 模型生成的参数,形式是 JSON 字符串 解析成 Python 值,再传给已登记的实现
call_id 这一次请求的唯一配对编号 回填结果时原样带回

此刻搜索仍然没有发生。 function_call 是一张待处理的动作单,不是执行记录。模型负责生成这张单;程序决定是否接受它,并调用真正的函数。

程序还不能只摘出 function_call,把同一轮其他输出丢掉。手动接续 GPT-5 等推理模型的工具调用时,官方协议要求把该轮 response.output 中返回的项目与工具结果一起交回下一轮,其中包括随调用返回的 reasoning 项目。因此代码先保存整组输出,再从中筛选要执行的调用:

input_items += response.output
calls = [item for item in response.output if item.type == "function_call"]

这里的两行代码做的是两件不同的事:第一行维护完整状态,第二行找出当前需要执行的动作。

六、工具执行以后:每条请求都要得到同编号的结果

程序解析 arguments,从白名单里找到真实函数并执行。得到结果后,追加一条 function_call_output

input_items.append({
    "type": "function_call_output",
    "call_id": call.call_id,
    "output": json.dumps(result, ensure_ascii=False),
})

output 装真实执行结果,call_id 说明它在回答哪一条请求。把这个关系写成一条可检查的合同:

每条调用请求都必须得到一个同编号的终态结果;成功和失败都算结果,漏掉或配错都不能安全进入下一轮。

错误也要回填。例如搜索超时,可以返回:

{"error":"搜索服务超时"}

下一轮看到明确错误,模型才可能换关键词、重试或说明缺口。若程序把错误只写进日志、不交回模型,下一轮就缺少这次动作的真实终态。生产代码通常还要给错误分类并删去敏感细节;本章只保留最小的错误结果。

一轮还可能出现多条调用。假设模型同时提出 A、B 两条搜索调用,B 先成功,A 后来超时。完成顺序可以是 B 再 A,但进入下一轮前必须同时存在:

配对靠编号,不靠列表顺序。

七、循环怎样停:正常出口不是硬上限

程序至少要区分三种出口:

观察到什么 程序怎样处理 这代表什么
响应已完成,没有 function_call,而且有文本 返回 response.output_text 本例定义的正常停止
response.status 不是 completed,或没有调用也没有文本 报错并保留诊断信息 这一轮没有形成可用终态
达到轮数或时间上限,或程序发现重复调用、连续错误 强制终止,标记任务未完成 保险丝生效,不是成功答案

检查顺序很重要。程序要先确认响应完整,再解析工具参数或判断「没有调用」。否则一个被截断的响应可能被误当成最终答案,残缺的参数也可能被误当成一次普通工具错误。

正常停止也只说明模型这一轮没有再请求工具,不说明答案一定正确。是否满足任务要求、出处是否真实,仍需要另外的校验。反过来,达到 MAX_ROUNDS 只说明系统不再允许它继续消耗资源,不能把当时那段半成品包装成完成结果。

下面的最小代码只实现响应完整性检查、正常出口和轮数上限。生产循环还会加入超时、重复调用检测、连续错误阈值等程序出口;它们共享同一条边界:强制停下来时,要明确标记「未完成」,不能伪装成最终答案。

八、工具分支的一轮时序

下面这张图只展开「模型提出调用请求」的分支;如果响应完整且没有调用,流程会从模型返回处直接走向正常出口。

模型只提出工具调用请求,模型外的运行系统执行工具,并用同一个 call_id 把结果回填给模型。箭头表示工具分支的动作顺序,蓝色编号表示请求与结果的配对

看图时只抓两条承重关系:调用请求与真实执行隔着程序;调用请求与回填结果靠 call_id 闭合。

九、可以运行的最小版本

先安装官方 Python SDK,并设置 API key:

python3 -m pip install --upgrade openai
export OPENAI_API_KEY="你的 API key"

下面的 search 只返回三条模拟数据,不访问互联网。它能证明「请求—执行—回填—再调用」这条协议链确实转起来,不能完成真实的本周调研。完整代码如下:

import json
import os

from openai import OpenAI

MODEL = os.getenv("OPENAI_MODEL", "gpt-5.6")
MAX_ROUNDS = 8
client = OpenAI()

TOOLS = [{
    "type": "function",
    "name": "search",
    "description": (
        "按关键词返回三条模拟搜索结果,只含标题和链接。"
        "它不访问互联网,只用于演示工具循环,结果不得当作真实事实。"
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "query": {"type": "string", "description": "搜索关键词"},
        },
        "required": ["query"],
        "additionalProperties": False,
    },
    "strict": True,
}]


def search(query):
    # 假数据:只验证循环,不代表真实搜索结果
    return [
        {"title": f"{query}:示例结果一", "url": "https://example.com/1"},
        {"title": f"{query}:示例结果二", "url": "https://example.com/2"},
        {"title": f"{query}:示例结果三", "url": "https://example.com/3"},
    ]


TOOL_IMPL = {"search": search}


def run(task):
    input_items = [{"role": "user", "content": task}]

    for round_index in range(MAX_ROUNDS):
        response = client.responses.create(
            model=MODEL,
            instructions=(
                "你是资料整理助手。本例的 search 只返回模拟结果,不访问网络。"
                "收到结果后必须明确标注这是演示数据,不能称为本周真实动态。"
                "只引用工具实际返回的链接,缺少的信息不要编造。"
            ),
            tools=TOOLS,
            tool_choice=(
                {"type": "function", "name": "search"}
                if round_index == 0
                else "auto"
            ),
            input=input_items,
        )

        if response.status != "completed":
            details = getattr(response, "incomplete_details", None)
            raise RuntimeError(
                f"模型响应未完成:{response.status},{details}"
            )

        # 手动接续时,整组输出都是下一轮协议状态的一部分
        input_items += response.output
        calls = [
            item for item in response.output
            if item.type == "function_call"
        ]

        if not calls:
            answer = (response.output_text or "").strip()
            if not answer:
                raise RuntimeError("响应已完成,但没有函数调用或文本输出")
            return answer

        for call in calls:
            try:
                args = json.loads(call.arguments)
                result = TOOL_IMPL[call.name](**args)
            except Exception as error:
                result = {"error": str(error)}

            input_items.append({
                "type": "function_call_output",
                "call_id": call.call_id,
                "output": json.dumps(result, ensure_ascii=False),
            })

    raise RuntimeError("达到轮数上限,任务没有完成")


print(run(
    "查一下这周的稳定币监管动态,写三条摘要并附链接。"
    "如果拿到的是模拟结果,必须明确说明无法据此完成真实调研。"
))

为了保证第一次运行一定经过工具分支,代码在第一轮用 tool_choice 指定 search,后续轮次恢复 auto。这是教学脚手架,不是所有 Agent 都必须先调用一次工具;真实系统可以让模型从第一轮就自动选择,也可以由固定工作流决定某一步必须调用。

把代码重新挂回开头那张地图:

地图上的关系 代码里的检查点
跨轮输入状态 input_items 从用户任务开始,逐轮增加模型输出和工具结果
可请求的动作 TOOLS 声明 searchTOOL_IMPL 保存真实实现
模型选择的表示 response.output 中出现 function_call
程序识别调用 response.output 中筛出 function_call
外部实际执行 程序解析参数后调用 TOOL_IMPL[call.name]
请求与结果配对 每条 function_call_output 带回原 call_id
正常停止 响应完整、没有调用请求,而且存在文本
强制停止 MAX_ROUNDS 用尽后抛出未完成错误

示例最值得检查的不是最终那三条文字,而是运行轨迹:第一轮是否出现 function_call,程序是否真的调用 search,每个结果是否带着正确编号回填,模型收到结果后是否从调用分支走向回答分支。

十、换一种接口,怎样认出同一个循环

本章选择手动维护 input_items,因为每一份状态都摊在代码里,最适合学习。OpenAI conversation state 官方文档还提供 previous_response_id,让服务端按响应编号接续状态;框架则可能把状态、分发和重试都藏进一个 run()

三种写法改变的是职责放在哪里,不是循环的因果关系:

写法 谁接续状态 你仍要看清什么
手动 input_items 应用把前一轮输出和工具结果重新提交 哪些项目进入下一轮,何时停止
previous_response_id 服务端按响应链接续 当前轮的 instructions 仍要传;还要继续调用的 tools 也要继续提供
Agent 框架 框架可能接管状态、解析、执行、重试和追踪 它接管了哪些职责,错误与硬上限怎样暴露

previous_response_id 不会让模型快照获得跨调用的内在记忆;它只是让服务端替应用接上前面的响应链。框架也不会让「模型提出请求」和「外部动作发生」变成同一件事,只是可能把中间代码替你写掉。

因此,遇到另一家 SDK 或一个只暴露 register_tool()run() 的框架,不必先背字段。依次问五个问题:

  1. 当前任务状态存在哪里?
  2. 可请求的动作在哪里声明?
  3. 模型的调用请求怎样表示?
  4. 谁真正执行工具,结果怎样与请求配对回填?
  5. 正常出口、错误出口和硬上限在哪里?

五个问题都有答案,就能把隐藏的循环重新展开。

手动示例还暴露了下一层因果:input_items 之所以让任务能够延续,是因为模型输出和工具结果会进入后续调用;同一个追加动作也会让搜索结果、失败记录和网页正文逐轮累积。跨轮状态是循环能够前进的条件,永久保留所有状态却不是。

十一、三条结论

一,一轮 Agent 循环是一次受控交接。 程序提交当前输入状态与可用动作,再识别模型选择;有调用就先校验和分发,接受的请求才真正执行,拒绝或失败也要用同编号的错误结果回填。响应完整、没有调用且有可返回文本,才走本例的正常出口。

二,工具调用请求不是工具执行,请求与结果必须可关联才是配对不变量。 在 Responses API 里,这个关系由 call_id 承载。一轮有多少条请求,就要有多少条对应的成功或错误结果;完成顺序可以变,配对关系不能错。

三,框架可以隐藏字段,不能消除职责。 无论状态由应用、服务端还是框架接续,都能用「状态、动作、请求、执行、回填、出口」重新认出同一个循环。

十二、这一章引出的问题

最小循环跑起来后,模型输出、搜索结果和失败记录会不断进入后续调用;下一章就处理这个新问题:下一轮到底该继续看什么,哪些内容应该压缩、外置、检索或隔离。

十三、自检题

  1. 模型返回一条 function_call,是否说明搜索已经发生?谁真正产生搜索结果?
  2. 模型同时提出调用 A、B,B 先成功、A 最后超时。进入下一轮前,状态里必须有什么?
  3. 为什么代码先追加完整的 response.output,再从中筛选 function_call,而不是只保留调用项目?
  4. 「响应已完成、没有工具调用且有可返回文本」「响应被截断」「达到轮数上限」分别属于哪种出口?哪一种可以直接当成最终答案返回?
  5. 改用 previous_response_id 后,模型是否获得了跨调用的内在记忆?当前轮还要重新提供什么?
  6. 某个框架只让你注册工具,然后调用一次 run()。怎样判断它替你接管了循环里的哪些职责?

十四、参考答案

先自己答完再往下看。

  1. 没有。 function_call 只是模型生成的结构化请求。模型外的程序解析工具名和参数,调用真实搜索函数;该函数或它连接的服务才产生结果。
  2. 必须保留模型原始的 A、B 两条调用请求,并为两条请求各回填一个同编号的终态结果。 B 的结果是成功数据,带 B 的 call_id;A 的结果是超时错误,带 A 的 call_id。完成顺序不重要,编号配对不能错;两条调用都得到结果后才进入下一轮。
  3. 因为同一轮输出不只有调用项目。 对 GPT-5 等推理模型,手动接续工具调用时,随调用返回的 reasoning 等项目也是下一轮协议状态的一部分,必须与工具结果一起交回。完整保存负责状态连续,筛选调用只负责找出要执行的动作。
  4. 只有第一种符合本例定义的正常出口,而且还应确认确实有可返回的文本。 响应被截断说明这一轮没有完整终态,应报错;达到轮数上限是强制停止,说明保险丝生效,不代表任务完成。即使正常返回,答案是否正确仍要另行验证。
  5. 没有。 previous_response_id 让服务端替应用接续响应链,不会改变模型每次调用都依赖本轮输入的事实。当前轮需要的 instructions 仍要传入;希望模型继续调用的 tools 也要继续提供。
  6. 用五个问题把 run() 展开:状态存在哪里,动作在哪里声明,调用请求怎样表示,谁执行工具并怎样配对回填,以及正常出口、错误出口和硬上限在哪里。 框架文档或运行轨迹能回答哪一项,就说明它接管了哪一项;答不出来的部分不是消失了,而是被隐藏了。

上一章:第 3 章 「Agent」——智能体是什么 下一章:第 5 章 上下文:Agent 真正的瓶颈