- 本文的代码基于
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。
- 模型不会执行工具。 一次工具调用至少需要两次模型请求:模型先提出调用,程序执行后把结果送回去,模型再继续生成。
- Agent Loop 就是把这个过程放进
while循环。 用 30 到 40 行代码,就能分别基于 OpenAI 和 Anthropic 的原生 SDK 写出一个能用的 agent。 - 看懂这两段手写代码之后,后面每一个 SDK(Agents SDK、Tool Runner、Claude Agent SDK、pi、DSH)替你做了什么,就一目了然了。
- 进阶部分讲状态管理、并行调用、流式输出、上下文压缩和终止条件。
1. 一次工具调用的完整往返#
OpenAI 把这个过程概括为五步:带着工具发请求 → 收到工具调用 → 在应用侧执行 → 带着结果再发一次请求 → 收到最终回复,或者收到更多的工具调用3。
三个关键点:
- 工具定义由三部分组成:
name、description,以及一份描述参数的 JSON Schema。模型主要依靠description来判断什么时候该用这个工具。 - 调用和结果靠 ID 关联:OpenAI 用
call_id3,Anthropic 用tool_use块的id,回传结果时写在tool_use_id字段里4。 - 一次响应里可能包含多个调用(并行工具调用),详见第 7 节。
2. 同一件事,两家 API 的写法对照#
| 概念 | OpenAI Responses API | Anthropic Messages API |
|---|---|---|
| 端点 | POST /v1/responses | POST /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 字符串形式的 arguments | content 里出现 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_call | stop_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 jsonfrom typing import Any, Callable
from openai import OpenAIfrom 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,而不是只取文本4. 手写一个最小 Agent Loop:Anthropic 版#
"""最小 Agent Loop:Anthropic Messages API 版(基于 anthropic 1.12.1)"""from typing import Callable
import anthropicfrom 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("北京和上海今天天气怎么样?"))- thinking 块要原样传回去。
claude-opus-5-5的 thinking 始终开启。官方要求把 thinking 块不加修改地传回,工具调用期间尤其如此,因为这些块里承载着模型做出这次调用的推理9。直接追加response.content就满足了这个要求。 - 并行调用的结果不能拆开发送。 所有
tool_result必须放在同一条 user 消息里,并且结果前面不能有文本,否则会削弱模型的并行调用能力10。 - 生产代码还要处理
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)超出轮数则报错这正是各 SDK 文档描述的循环。OpenAI Agents SDK 的 Runner 是这样运行的:调用 LLM,如果得到最终输出就结束;如果是 handoff,就切换 agent 后继续;如果是工具调用,就执行工具、追加结果后继续;超过 max_turns 时抛出 MaxTurnsExceeded11。Claude Agent SDK 则把“一次模型输出加上它触发的工具执行”称为一个 turn,直到某次输出中不再有工具调用为止12。
6. 状态管理:谁来记住对话#
| 策略 | 优点 | 代价与注意事项 |
|---|---|---|
| 客户端回放完整历史 | 完全可控,可以跨厂商迁移 | 请求体越来越大,需要自己管理上下文 |
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 Responses | Anthropic Messages | pi-ai(统一层) | |
|---|---|---|---|
| 协议 | SSE 语义事件 | SSE | 异步迭代器 |
| 开始 | response.created | message_start | start |
| 文本增量 | response.output_text.delta | content_block_delta,其中 delta.type = text_delta | text_delta |
| 工具参数增量 | response.function_call_arguments.delta | content_block_delta,其中 delta.type = input_json_delta | toolcall_delta |
| 结束 | response.completed | message_delta,然后 message_stop | done 或 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 隔离 | 子任务放在独立的上下文里做,只把结论返回给父 agent | Claude 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 SDK | Anthropic Tool Runner | Claude Agent SDK | pi-agent-core | DSH |
|---|---|---|---|---|---|---|
| 从函数生成工具 Schema | 手写 JSON | @tool 装饰器,基于 Pydantic | @beta_tool 装饰器 | @tool 加进程内 MCP | TypeBox 定义 | defineTool |
| 循环本身 | for 循环 | Runner | tool_runner | 内置 | Agent | agent-loop 插件 |
| 历史和状态 | list | Session / 服务端状态 | 内部保存 | JSONL 会话 | AgentState | 事件日志 |
| 并行执行 | 自己实现 | 内置 | 内置 | 按只读或可写区分 | parallel 模式 | 工具流水线 |
| 人工审批 | 自己实现 | needs_approval | 在工具内部实现 | 权限模式加 hooks | beforeToolCall | 审批 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 核心机制与插件开发。
思考题:如果模型一直要求调用同一个工具,你的循环会怎样?
小结#
- Tool calling 的本质是一个协议:模型提出结构化的调用,程序执行后把结果回传,双方靠调用 ID 对应起来。
- Agent Loop 的本质是一个
while循环,难点不在循环本身,而在状态、上下文、并行、终止和安全这些问题上。 - 后面各篇讲的 SDK,本质上都是在替你处理这些难点,区别只在于替你处理了多少,以及你还能控制多少。
相关笔记#
参考资料#
注释与出处#
-
OpenAI,Using GPT-6(列出 GPT-6 Astra、GPT-6.1 Sol、GPT-6 Luna),https://developers.openai.com/api/docs/guides/latest-model ↩
-
Anthropic,Models overview,https://platform.claude.com/docs/en/about-claude/models/overview ↩
-
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
-
Anthropic,Handle tool calls,https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls ↩ ↩2
-
Anthropic,Working with the Messages API(“The Messages API is stateless”),https://platform.claude.com/docs/en/build-with-claude/working-with-messages ↩
-
OpenAI,Migrate to the Responses API(Messages vs. Items 一节),https://developers.openai.com/api/docs/guides/migrate-to-responses ↩ ↩2
-
Anthropic,Tool use with Claude,https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview ↩
-
OpenAI,Conversation state,https://developers.openai.com/api/docs/guides/conversation-state ↩ ↩2 ↩3
-
Anthropic,Adaptive thinking(“pass them back unmodified, particularly during tool use”),https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking ↩
-
Anthropic,Parallel tool use(结尾总结的两条格式规则),https://platform.claude.com/docs/en/agents-and-tools/tool-use/parallel-tool-use ↩ ↩2 ↩3
-
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 -
Anthropic,How the agent loop works,https://code.claude.com/docs/en/agent-sdk/agent-loop ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
OpenAI,Streaming API responses,https://developers.openai.com/api/docs/guides/streaming-responses ↩
-
Anthropic,Streaming Messages(Event types 一节),https://platform.claude.com/docs/en/build-with-claude/streaming ↩ ↩2
-
earendil-works/pi,
packages/ai/README.md(Complete Event Reference 一节),https://github.com/earendil-works/pi/blob/6fb2e7815167e6b19006fc526d1a5d0f5f998787/packages/ai/README.md ↩ -
earendil-works/pi,
packages/agent/README.md(Message Flow 一节),https://github.com/earendil-works/pi/blob/6fb2e7815167e6b19006fc526d1a5d0f5f998787/packages/agent/README.md ↩ -
Anthropic,Subagents in the SDK,https://code.claude.com/docs/en/agent-sdk/subagents ↩
-
OpenAI,Using tools(Tool search 一节),https://developers.openai.com/api/docs/guides/tools ↩
-
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 ↩ -
openai/openai-agents-python,
docs/agents.md(Forcing tool use 一节末尾的 note),https://github.com/openai/openai-agents-python/blob/26345c1e45ebede8e2fc9b0bc7341dedab5e01fc/docs/agents.md ↩ -
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 ↩