返回专栏
Agent SDK/01 · 基础/1.2

Tool Calling 与 Agent Loop 原理

模型不会执行工具。

预计阅读
21分钟
全文字数
3,197字
资料截至
2026-10-09
Agent SDK · 基础1.2
版本与时效声明
  • 本文的代码基于 openai(Python)3.26.1 和 anthropic(Python)1.12.1 编写,已用 pyright 1.1.414 对照这两个版本做过类型检查。由于没有 API Key,代码没有实际运行。
  • 示例中的模型名 gpt-6-astra 和 claude-opus-5-5,取自 2026-10 两家的官方文档12。
  • 资料截至 2026-10-09。本文不会随 SDK 更新自动更新,动手前请先对照官方文档:OpenAI Function calling · Claude Tool use。
本文要点
  1. 模型不会执行工具。 一次工具调用至少需要两次模型请求:模型先提出调用,程序执行后把结果送回去,模型再继续生成。
  2. Agent Loop 就是把这个过程放进 while 循环。 用 30 到 40 行代码,就能分别基于 OpenAI 和 Anthropic 的原生 SDK 写出一个能用的 agent。
  3. 看懂这两段手写代码之后,后面每一个 SDK(Agents SDK、Tool Runner、Claude Agent SDK、pi、DSH)替你做了什么,就一目了然了。
  4. 进阶部分讲状态管理、并行调用、流式输出、上下文压缩和终止条件。

1. 一次工具调用的完整往返#

模型 API你的程序模型 API你的程序模型只“提出”调用,真正执行的是你的代码用户“北京今天天气怎么样?”1请求 1:消息 + 工具清单(get_weather 的 JSON Schema)2响应 1:工具调用请求 {name: get_weather, args: {city: 北京}}3执行 get_weather("北京") → “晴,25°C”4请求 2:历史 + 模型的调用 + 工具结果(用 ID 关联)5响应 2:最终文本 “北京今天晴,25°C”6展示答案7用户
模型 API你的程序模型 API你的程序模型只“提出”调用,真正执行的是你的代码用户“北京今天天气怎么样?”1请求 1:消息 + 工具清单(get_weather 的 JSON Schema)2响应 1:工具调用请求 {name: get_weather, args: {city: 北京}}3执行 get_weather("北京") → “晴,25°C”4请求 2:历史 + 模型的调用 + 工具结果(用 ID 关联)5响应 2:最终文本 “北京今天晴,25°C”6展示答案7用户

OpenAI 把这个过程概括为五步:带着工具发请求 → 收到工具调用 → 在应用侧执行 → 带着结果再发一次请求 → 收到最终回复,或者收到更多的工具调用3。

三个关键点:

  1. 工具定义由三部分组成:name、description,以及一份描述参数的 JSON Schema。模型主要依靠 description 来判断什么时候该用这个工具。
  2. 调用和结果靠 ID 关联:OpenAI 用 call_id3,Anthropic 用 tool_use 块的 id,回传结果时写在 tool_use_id 字段里4。
  3. 一次响应里可能包含多个调用(并行工具调用),详见第 7 节。

2. 同一件事,两家 API 的写法对照#

概念OpenAI Responses APIAnthropic Messages API
端点POST /v1/responsesPOST /v1/messages
系统提示词instructions 参数system 参数
输入input:字符串,或 item 列表messages:role 加 content blocks
工具定义{"type": "function", "name", "description", "parameters", "strict"}{"name", "description", "input_schema"}
模型请求调用output 里出现 function_call item,带 call_id、name,以及 JSON 字符串形式的 argumentscontent 里出现 tool_use 块,带 id、name,以及已解析为对象的 input;同时 stop_reason == "tool_use"
回传结果在 input 里追加 {"type": "function_call_output", "call_id", "output"}追加一条 user 消息,content 是 tool_result 块(带 tool_use_id、content,出错时加 is_error)
结束信号output 里没有 function_callstop_reason == "end_turn"
状态默认存储 response,可用 previous_response_id 或 Conversations 接续无状态,每次都要发送完整历史5

对照的出处:OpenAI 部分见 36,Anthropic 部分见 74。OpenAI 官方对这种设计的解释是:Chat Completions 的 Message 把很多关注点揉在一个对象里,而 Responses 的 Item 把“消息、函数调用、函数输出”拆成彼此独立的单元6。


3. 手写一个最小 Agent Loop:OpenAI 版#

"""最小 Agent Loop:OpenAI Responses API 版(基于 openai 3.26.1)"""
import json
from typing import Any, Callable
from openai import OpenAI
from openai.types.responses import FunctionToolParam
client = OpenAI() # 默认从环境变量 OPENAI_API_KEY 读取密钥
MODEL = "gpt-6-astra" # 示例模型名取自 2026-10 的官方文档,可替换
# 1) 工具的实现:就是普通的 Python 函数
def get_weather(city: str) -> str:
fake_db = {"北京": "晴,25°C", "上海": "小雨,22°C"}
return fake_db.get(city, "暂无数据")
TOOL_IMPLS: dict[str, Callable[..., str]] = {"get_weather": get_weather}
# 2) 工具的声明:告诉模型有哪些工具、参数是什么样(JSON Schema)
TOOLS: list[FunctionToolParam] = [
{
"type": "function",
"name": "get_weather",
"description": "查询某个城市今天的天气",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名,例如 北京"}},
"required": ["city"],
"additionalProperties": False,
},
"strict": True, # 严格模式:模型生成的参数一定符合这份 Schema
}
]
def run_agent(user_input: str, max_turns: int = 10) -> str:
# 历史里既有我们写的 dict,也有 SDK 返回的输出对象,所以用 list[Any]
history: list[Any] = [{"role": "user", "content": user_input}]
for _ in range(max_turns): # 安全阀:防止无限循环
response = client.responses.create(
model=MODEL,
instructions="你是一个简洁的中文助手。",
tools=TOOLS,
input=history,
)
# 3) 把模型的全部输出(function_call、reasoning 等 item)原样放回历史
history += response.output
calls = [item for item in response.output if item.type == "function_call"]
if not calls: # 4) 没有工具调用,说明任务完成
return response.output_text
for call in calls: # 5) 逐个执行工具,并按 call_id 回填结果
args = json.loads(call.arguments) # 注意:arguments 是 JSON 字符串
result = TOOL_IMPLS[call.name](**args)
history.append(
{"type": "function_call_output", "call_id": call.call_id, "output": result}
)
raise RuntimeError("超过最大轮数仍未完成")
if __name__ == "__main__":
print(run_agent("北京和上海今天天气怎么样?"))
为什么是 history += response.output,而不是只取文本

推理模型会在 output 里返回 reasoning item。OpenAI 要求在无状态的多轮请求中保留 output 里的每一个 item,这样推理内容和 phase 字段才能完整地接续下去8。这一点和官方文档的 function calling 示例写法一致3。


4. 手写一个最小 Agent Loop:Anthropic 版#

"""最小 Agent Loop:Anthropic Messages API 版(基于 anthropic 1.12.1)"""
from typing import Callable
import anthropic
from anthropic.types import MessageParam, ToolParam, ToolResultBlockParam
client = anthropic.Anthropic() # 默认从环境变量 ANTHROPIC_API_KEY 读取
MODEL = "claude-opus-5-5"
def get_weather(city: str) -> str:
fake_db = {"北京": "晴,25°C", "上海": "小雨,22°C"}
return fake_db.get(city, "暂无数据")
TOOL_IMPLS: dict[str, Callable[..., str]] = {"get_weather": get_weather}
TOOLS: list[ToolParam] = [
{
"name": "get_weather",
"description": "查询某个城市今天的天气",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string", "description": "城市名,例如 北京"}},
"required": ["city"],
},
}
]
def run_agent(user_input: str, max_turns: int = 10) -> str:
messages: list[MessageParam] = [{"role": "user", "content": user_input}]
for _ in range(max_turns):
response = client.messages.create(
model=MODEL,
max_tokens=16000,
system="你是一个简洁的中文助手。",
tools=TOOLS,
messages=messages,
)
# API 无状态:把这一轮模型输出的完整 content(含 tool_use、thinking 块)原样追加
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "refusal": # 安全分类器拒绝了请求
return "(模型拒绝了该请求)"
if response.stop_reason != "tool_use": # end_turn、max_tokens 等:不再调用工具
return "".join(b.text for b in response.content if b.type == "text")
tool_results: list[ToolResultBlockParam] = []
for block in response.content:
if block.type == "tool_use":
try:
output = TOOL_IMPLS[block.name](**block.input) # input 已经是 dict
tool_results.append(
{"type": "tool_result", "tool_use_id": block.id, "content": output}
)
except Exception as e: # 把错误告诉模型,让它自己调整做法
tool_results.append(
{
"type": "tool_result",
"tool_use_id": block.id,
"content": f"工具出错:{e}",
"is_error": True,
}
)
# 所有结果必须放在**同一条** user 消息里
messages.append({"role": "user", "content": tool_results})
raise RuntimeError("超过最大轮数仍未完成")
if __name__ == "__main__":
print(run_agent("北京和上海今天天气怎么样?"))
这段代码里容易踩的三个坑
  1. thinking 块要原样传回去。 claude-opus-5-5 的 thinking 始终开启。官方要求把 thinking 块不加修改地传回,工具调用期间尤其如此,因为这些块里承载着模型做出这次调用的推理9。直接追加 response.content 就满足了这个要求。
  2. 并行调用的结果不能拆开发送。 所有 tool_result 必须放在同一条 user 消息里,并且结果前面不能有文本,否则会削弱模型的并行调用能力10。
  3. 生产代码还要处理 pause_turn。 使用服务端工具时,可能遇到 stop_reason == "pause_turn",这时要把这一轮追加到历史后重新发送,让模型接着做。完整的写法见 3.2 Anthropic 客户端 SDK 与 Messages API。

5. 两份代码的共同骨架#

撇开 API 细节,所有 Agent Loop 都是同一个结构:

history = [用户输入]
重复,最多 max_turns 次:
response = 调用模型(history, tools)
history.append(response 的完整输出)
如果 response 里没有工具调用:
返回最终文本
对 response 里的每个工具调用:
result = 执行工具(调用.name, 调用.参数)
history.append(工具结果,并关联到这次调用的 id)
超出轮数则报错
Agent Loop 手绘Agent Loop 手绘
Agent Loop 手绘

这正是各 SDK 文档描述的循环。OpenAI Agents SDK 的 Runner 是这样运行的:调用 LLM,如果得到最终输出就结束;如果是 handoff,就切换 agent 后继续;如果是工具调用,就执行工具、追加结果后继续;超过 max_turns 时抛出 MaxTurnsExceeded11。Claude Agent SDK 则把“一次模型输出加上它触发的工具执行”称为一个 turn,直到某次输出中不再有工具调用为止12。


6. 状态管理:谁来记住对话#

客户端

服务端,链式

服务端,会话对象

Harness 层

对话历史放在哪?

每次发送完整历史

(Anthropic 必须这样做;

OpenAI 可配合 store=false)

previous_response_id

(OpenAI Responses)

Conversations API

(OpenAI,不受 30 天 TTL 限制)

SDK 的 Session 抽象

Agents SDK Session / Claude Agent SDK 会话 /

pi 树状会话 / DSH 事件日志

客户端

服务端,链式

服务端,会话对象

Harness 层

对话历史放在哪?

每次发送完整历史

(Anthropic 必须这样做;

OpenAI 可配合 store=false)

previous_response_id

(OpenAI Responses)

Conversations API

(OpenAI,不受 30 天 TTL 限制)

SDK 的 Session 抽象

Agents SDK Session / Claude Agent SDK 会话 /

pi 树状会话 / DSH 事件日志

策略优点代价与注意事项
客户端回放完整历史完全可控,可以跨厂商迁移请求体越来越大,需要自己管理上下文
previous_response_id每次只需发送新内容链上所有历史输入仍然按输入 token 计费8
Conversations API有持久 ID,可以跨服务、跨设备共享与 OpenAI 平台绑定
Harness 的 Session自动加载和保存,支持恢复、分叉依赖具体 SDK 的存储格式

OpenAI Agents SDK 的文档把这四种策略整理成了一张表,并特别提醒:同一段对话只选一种,否则客户端历史和服务端状态会重复11。


7. 并行工具调用#

  • 模型可以在一次响应里请求多个工具,比如同时查询北京和上海的天气。
  • OpenAI:可以用 parallel_tool_calls=false 关闭,关闭后每次至多调用一个工具3。Agents SDK 还另外提供了 max_function_tool_concurrency,用来限制本地并发执行的数量11。
  • Anthropic:默认允许并行调用。回传时必须遵守两条格式规则:所有结果放在同一条 user 消息里,并且结果前面不能有文本10。
  • 执行策略要看工具本身。只读的、相互独立的操作适合并行;有副作用、共享状态或者有顺序依赖的操作,最好串行执行10。Claude Agent SDK 就是这么做的:Read、Glob、Grep 这类只读工具并行运行,Edit、Write、Bash 串行运行12。

8. 流式输出(Streaming)#

为什么要用流式:长输出时可以边生成边显示,减少等待;同时也能避免长请求触发 HTTP 超时。

OpenAI ResponsesAnthropic Messagespi-ai(统一层)
协议SSE 语义事件SSE异步迭代器
开始response.createdmessage_startstart
文本增量response.output_text.deltacontent_block_delta,其中 delta.type = text_deltatext_delta
工具参数增量response.function_call_arguments.deltacontent_block_delta,其中 delta.type = input_json_deltatoolcall_delta
结束response.completedmessage_delta,然后 message_stopdone 或 error

出处:OpenAI 见 13,Anthropic 见 14,pi-ai 见 15。

Anthropic 的流有固定结构:先是 message_start;然后每个内容块依次出现 content_block_start、若干个 content_block_delta、content_block_stop;接着是一个或多个 message_delta;最后以 message_stop 结束14。

pi-ai 的价值之一,就是把各家不同的流式事件统一成同一套事件,这样上层代码不用关心底层是哪家厂商。详见 4.2 pi-ai 统一多模型 API。


9. 上下文管理:Agent 为什么会“失忆”#

每一轮循环都会往上下文里追加内容:模型输出、工具参数、工具结果。读一个大文件或者跑一条输出很长的命令,一次就可能吃掉数千个 token12。常见的应对办法:

手段做法实现示例
裁剪 / 过滤每次请求前修剪历史Agents SDK 的 call_model_input_filter11;pi-agent-core 的 transformContext16
压缩(compaction)用摘要替换较早的历史Claude Agent SDK 自动压缩,并发出 compact_boundary 事件12;OpenAI 的服务端 compaction8;pi 和 DSH 的 compaction 模块
子 agent 隔离子任务放在独立的上下文里做,只把结论返回给父 agentClaude Agent SDK subagents17
工具按需加载先只给模型一个“工具搜索”工具,需要时再加载具体定义OpenAI tool search18;Claude Agent SDK 中的 MCP 工具搜索12
把规则放进持久文件规则写在 CLAUDE.md 或 AGENTS.md 里,每次请求都重新注入,压缩时不会丢Claude Agent SDK 的建议12

10. 终止条件与安全阀#

情况识别方法建议的处理方式
正常完成没有工具调用,或 stop_reason == "end_turn"返回结果
轮数超限自己计数;Agents SDK 抛 MaxTurnsExceeded11;Claude Agent SDK 返回 error_max_turns12提示用户缩小任务范围,或者继续执行
预算超限Claude Agent SDK 返回 error_max_budget_usd12停止,或请求追加预算
输出被截断max_tokens 或 length截断的工具调用参数不要执行。pi-agent-core 遇到 length 截断时,会把整批工具调用都判为失败19
模型拒绝stop_reason == "refusal"换一种问法,或者使用服务端 fallback(见 3.2)
工具出错执行时抛出异常把错误作为工具结果回传(is_error: true),让模型自己修正做法
用户中止abort 或 cancel 信号安全地停下,并保存会话

11. 从手写循环到 SDK:SDK 到底替你做了什么#

关注点手写(本文)OpenAI Agents SDKAnthropic Tool RunnerClaude Agent SDKpi-agent-coreDSH
从函数生成工具 Schema手写 JSON@tool 装饰器,基于 Pydantic@beta_tool 装饰器@tool 加进程内 MCPTypeBox 定义defineTool
循环本身for 循环Runnertool_runner内置Agentagent-loop 插件
历史和状态listSession / 服务端状态内部保存JSONL 会话AgentState事件日志
并行执行自己实现内置内置按只读或可写区分parallel 模式工具流水线
人工审批自己实现needs_approval在工具内部实现权限模式加 hooksbeforeToolCall审批 seam
内置文件与 shell 工具无Sandbox Agents无有无(在 L4 层才有)插件提供

每一格的具体用法,见后续各篇:2.3 OpenAI Agents SDK · 3.2 Anthropic 客户端 SDK 与 Messages API · 3.3 Claude Agent SDK · 4.3 pi-agent-core 最小 Agent 运行时 · 5.3 DSH 核心机制与插件开发。

思考题:如果模型一直要求调用同一个工具,你的循环会怎样?

会一直跑,直到达到 max_turns。各 SDK 有不同的防护手段:

  • OpenAI Agents SDK 在工具调用之后,默认把 tool_choice 重置为 auto,以避免死循环20。
  • DSH 有一个 repeat-tool-reminder 插件,当 agent 反复发起相同的工具调用时,会给出提醒21。

小结#

  • Tool calling 的本质是一个协议:模型提出结构化的调用,程序执行后把结果回传,双方靠调用 ID 对应起来。
  • Agent Loop 的本质是一个 while 循环,难点不在循环本身,而在状态、上下文、并行、终止和安全这些问题上。
  • 后面各篇讲的 SDK,本质上都是在替你处理这些难点,区别只在于替你处理了多少,以及你还能控制多少。

相关笔记#

参考资料#

注释与出处#

  1. OpenAI,Using GPT-6(列出 GPT-6 Astra、GPT-6.1 Sol、GPT-6 Luna),https://developers.openai.com/api/docs/guides/latest-model ↩

  2. Anthropic,Models overview,https://platform.claude.com/docs/en/about-claude/models/overview ↩

  3. OpenAI,Function calling(tool calling flow、function tool example、parallel function calling 三节),https://developers.openai.com/api/docs/guides/function-calling ↩ ↩2 ↩3 ↩4 ↩5

  4. Anthropic,Handle tool calls,https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls ↩ ↩2

  5. Anthropic,Working with the Messages API(“The Messages API is stateless”),https://platform.claude.com/docs/en/build-with-claude/working-with-messages ↩

  6. OpenAI,Migrate to the Responses API(Messages vs. Items 一节),https://developers.openai.com/api/docs/guides/migrate-to-responses ↩ ↩2

  7. Anthropic,Tool use with Claude,https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview ↩

  8. OpenAI,Conversation state,https://developers.openai.com/api/docs/guides/conversation-state ↩ ↩2 ↩3

  9. Anthropic,Adaptive thinking(“pass them back unmodified, particularly during tool use”),https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking ↩

  10. Anthropic,Parallel tool use(结尾总结的两条格式规则),https://platform.claude.com/docs/en/agents-and-tools/tool-use/parallel-tool-use ↩ ↩2 ↩3

  11. openai/openai-agents-python,docs/running_agents.md(The agent loop、Choose a memory strategy、tool_execution 三节),https://github.com/openai/openai-agents-python/blob/26345c1e45ebede8e2fc9b0bc7341dedab5e01fc/docs/running_agents.md ↩ ↩2 ↩3 ↩4 ↩5

  12. Anthropic,How the agent loop works,https://code.claude.com/docs/en/agent-sdk/agent-loop ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8

  13. OpenAI,Streaming API responses,https://developers.openai.com/api/docs/guides/streaming-responses ↩

  14. Anthropic,Streaming Messages(Event types 一节),https://platform.claude.com/docs/en/build-with-claude/streaming ↩ ↩2

  15. earendil-works/pi,packages/ai/README.md(Complete Event Reference 一节),https://github.com/earendil-works/pi/blob/6fb2e7815167e6b19006fc526d1a5d0f5f998787/packages/ai/README.md ↩

  16. earendil-works/pi,packages/agent/README.md(Message Flow 一节),https://github.com/earendil-works/pi/blob/6fb2e7815167e6b19006fc526d1a5d0f5f998787/packages/agent/README.md ↩

  17. Anthropic,Subagents in the SDK,https://code.claude.com/docs/en/agent-sdk/subagents ↩

  18. OpenAI,Using tools(Tool search 一节),https://developers.openai.com/api/docs/guides/tools ↩

  19. earendil-works/pi,packages/agent/src/agent-loop.ts 中 runLoop 对 stopReason === "length" 的处理,https://github.com/earendil-works/pi/blob/6fb2e7815167e6b19006fc526d1a5d0f5f998787/packages/agent/src/agent-loop.ts#L163-L331 ↩

  20. openai/openai-agents-python,docs/agents.md(Forcing tool use 一节末尾的 note),https://github.com/openai/openai-agents-python/blob/26345c1e45ebede8e2fc9b0bc7341dedab5e01fc/docs/agents.md ↩

  21. deepseek-ai/deepseek-harness,packages/guard/repeat-tool-reminder/package.json 的 description 字段,https://github.com/deepseek-ai/deepseek-harness/tree/5badb15009ae1756c3afe0ae0cef1faafc290ccc/packages/guard/repeat-tool-reminder ↩

输入关键词开始搜索。多个关键词用空格分隔。