SDK:从 OpenAI Agents 迁移
OpenAI Agents SDK 到 Claude SDK 的迁移指南
🌐 查看英文原文 | 源码 Notebook
从 OpenAI Agents SDK 迁移
如果你有一个基于 OpenAI Agents SDK 的应用并想将其移植到 Claude Agent SDK,本 notebook 使用一个单一示例映射每个原语:一个费用审批 Agent。
迁移后的收益。 Claude Agent SDK 运行在与 Claude Code 相同的运行时上——你继承了其内置的 Read、Edit、Bash 和 Grep 工具,一个用于控制 Agent 可访问内容的分层权限系统,自动 prompt 缓存,以及直接访问事件流以进行进度流式传输或运行中拦截。工具定义是显式的(你声明 schema 而不是依赖类型提示内省),循环由你来驱动。大多数移植在每个工具上需要更多样板代码,但在其他地方需要更少的样板代码。
通过本 notebook,你将能够:
- 用 Claude 的等价物替换
@function_tool、guardrails 和Runner.run,而无需重写你的业务逻辑 - 移植一个单 Agent 应用:自定义工具、输入/输出 guardrails、多轮会话和持久化恢复
- 知道何时使用
ClaudeSDKClient与无状态的query()函数 - 将 SDK 的 OpenTelemetry 导出连接到你现有的可观测性栈
两个 SDK 都会实际运行。你需要在 .env 中设置 OPENAI_API_KEY 和 ANTHROPIC_API_KEY。
| OpenAI Agents SDK | Claude Agent SDK |
|---|---|
Agent(name, instructions, tools) |
ClaudeAgentOptions + 系统提示 |
@function_tool |
@tool + create_sdk_mcp_server |
@input_guardrail |
循环前的普通函数(或 UserPromptSubmit hook) |
@output_guardrail |
ResultMessage.result 上的普通函数 |
Runner.run(agent, msg) |
ClaudeSDKClient 上下文管理器 |
Sessions(客户端管理) |
复用同一个 ClaudeSDKClient |
conversation_id(服务端管理) |
resume=session_id(磁盘支持) |
| 内置追踪仪表板 | OTel 原生——插入你现有的 Grafana/Datadog/Honeycomb |
handoffs=[...] |
AgentDefinition + Agent 工具——见附录 |
示例:费用审批
一个审批或标记费用提交的单 Agent。一个工具(check_policy),一个输入 guardrail(如果没有金额则拒绝),以及循环。
我们先用 OpenAI Agents SDK 构建它,然后移植每个原语。
前提条件
所需知识
- 熟悉 Python 中的
async/await - 熟悉你正在迁移的 OpenAI Agents SDK 原语
所需工具
推荐:
- 浏览 notebook 00 了解
query()和ClaudeSDKClient基础知识
openai-agents固定在0.9.3——它处于 1.0 之前的阶段,API 变化频繁。如果你升级版本,请重新验证Agent/Runner/@function_tool的签名。
%%capture
# openai-agents pinned to 0.9.3 — see prereqs note before bumping
%pip install "openai-agents==0.9.3" claude-agent-sdk python-dotenvimport os
import re
from dataclasses import replace
from dotenv import load_dotenv
load_dotenv()
assert os.getenv("OPENAI_API_KEY"), "OPENAI_API_KEY missing from .env"
assert os.getenv("ANTHROPIC_API_KEY"), "ANTHROPIC_API_KEY missing from .env"
OAI_MODEL = "gpt-4.1"
CLAUDE_MODEL = "claude-sonnet-4-6"# ===== OpenAI Agents SDK — full implementation =====
from agents import Agent, GuardrailFunctionOutput, Runner, function_tool, input_guardrail
@function_tool
def check_policy(category: str, amount: float) -> dict:
"""Look up the expense policy for a category. Returns the approval limit. Valid categories: meals, travel, software, other."""
limits = {"meals": 75.0, "travel": 500.0, "software": 200.0, "other": 50.0}
return {
"category": category,
"limit": limits.get(category.lower(), 50.0),
"requires_receipt": amount > 25.0,
}
@input_guardrail
def has_dollar_amount(ctx, agent, user_input: str) -> GuardrailFunctionOutput:
has_amount = bool(re.search(r"\$\d+", user_input))
return GuardrailFunctionOutput(
output_info={"has_amount": has_amount},
tripwire_triggered=not has_amount,
)
expense_agent = Agent(
name="Expense Approver",
instructions=(
"You approve or flag expense submissions. "
"Always call check_policy first to get the limit for the expense category. "
"Approve if the amount is under the limit; otherwise flag for manager review."
),
tools=[check_policy],
input_guardrails=[has_dollar_amount],
model=OAI_MODEL,
)
async def run_oai(msg: str) -> str:
result = await Runner.run(expense_agent, msg)
return result.final_output
print(await run_oai("Lunch with Acme, $47"))This lunch expense of $47 falls under the "meals" category, which has an approval limit of $75. Since the amount is below the limit, I approve the expense (assuming a receipt is provided, as required by policy).
逐个原语移植
@function_tool → @tool + create_sdk_mcp_server
OpenAI: @function_tool 从类型提示和文档字符串推导工具 schema。被装饰的函数直接放入 tools=[...]。
Claude: @tool 将 name、description 和 schema 作为显式参数——你写什么,模型就看到什么。处理函数是 async 的,接收一个 args 字典,返回 {"content": [{"type": "text", "text": ...}]}。工具被打包到一个进程内 MCP 服务器中(尽管名字如此,但没有子进程或网络传输),这是你传递给 Agent 的单元。
函数内部的逻辑不变,只有包装器变了。
import json
from claude_agent_sdk import create_sdk_mcp_server, tool
@tool(
"check_policy",
"Look up the expense policy for a category. Returns the approval limit. Valid categories: meals, travel, software, other.",
{"category": str, "amount": float},
)
async def check_policy_claude(args):
limits = {"meals": 75.0, "travel": 500.0, "software": 200.0, "other": 50.0}
category, amount = args["category"], args["amount"]
result = {
"category": category,
"limit": limits.get(category.lower(), 50.0),
"requires_receipt": amount > 25.0,
}
return {"content": [{"type": "text", "text": json.dumps(result)}]}
policy_server = create_sdk_mcp_server(name="expense", tools=[check_policy_claude])Agent(...) → ClaudeAgentOptions
ClaudeAgentOptions 承载模型、工具和系统提示——实际发送给 Claude 的配置。你的 guardrail 逻辑保留在应用代码中,而不是注册在框架对象上。OpenAI 的 Agent 将所有这些捆绑在一起。
Agent(...) 字段 |
ClaudeAgentOptions 等价物 |
|---|---|
instructions=... |
system_prompt=... |
tools=[fn] |
mcp_servers={...} + allowed_tools=[...] |
model=... |
model=... |
name=... |
不需要——options 是配置值,不是命名实体。子 Agent 名称放在 AgentDefinition 上(见附录)。 |
input_guardrails=[...] |
在你的运行函数中调用——见下一节 |
allowed_tools 中的工具名称遵循 mcp__{server_name}__{tool_name} 模式。
关于权限的说明:
allowed_tools是一个允许规则——它使工具对 Agent 可用。Agent 是否可以在没有用户批准的情况下调用它取决于permission_mode。像check_policy这样的只读自定义工具默认自由运行;写入文件或运行 shell 命令的工具会提示,除非你设置permission_mode="acceptEdits"或"bypassPermissions"。如果你的工具不仅仅是读取,请参阅权限指南。
from claude_agent_sdk import ClaudeAgentOptions
expense_system_prompt = (
"You approve or flag expense submissions. "
"Always call check_policy first to get the limit for the expense category. "
"Approve if the amount is under the limit; otherwise flag for manager review."
)
expense_options = ClaudeAgentOptions(
model=CLAUDE_MODEL,
system_prompt=expense_system_prompt,
mcp_servers={"expense": policy_server},
allowed_tools=["mcp__expense__check_policy"],
)内置工具。 费用 Agent 只使用自定义工具,但 SDK 附带了
Read、Edit、Bash、Grep等——通过名称将它们添加到allowed_tools(例如allowed_tools=["Read", "Grep", "mcp__expense__check_policy"])。有关文件系统模式,请参阅notebook 00。
@input_guardrail / @output_guardrail → 调用前/后检查
OpenAI: @input_guardrail 验证用户的消息;@output_guardrail 验证 Agent 的最终答案。两者都返回 GuardrailFunctionOutput(tripwire_triggered=True) 来阻止,这会引发 InputGuardrailTripwireTriggered(或输出变体)。输入 guardrails 与 Agent 并发运行。
Claude: 你的检查是普通函数。在启动客户端之前调用输入检查,在循环结束后对 ResultMessage.result 调用输出检查。
| OAI | Claude |
|---|---|
@input_guardrail 装饰器 |
循环前调用的普通函数 |
@output_guardrail 装饰器 |
循环后对 result.result 调用的普通函数 |
tripwire_triggered=True 引发异常 |
提前返回(输入)或覆盖结果(输出) |
注册在 Agent(input_guardrails=[...]) 上 |
在你的运行函数中调用 |
UserPromptSubmit hook 替代方案
SDK 的 UserPromptSubmit hook 在 prompt 到达 Claude 之前触发并可以阻止它——最接近 @input_guardrail 的结构匹配。我们在下面展示了两种方式,但使用普通函数进行演示,原因有两个:拒绝消息保持在你的控制之下(当 hook 阻止时,ResultMessage.result 返回空——原因不会暴露给调用者),guardrail 逻辑在你的运行函数中保持可见,而不是注册在 options 对象上。@output_guardrail 没有干净的 hook 等价物——Stop 在响应完成后触发但不重写输出。有关 PreToolUse hooks 控制工具调用的内容(这是一个不同的用例),请参阅notebook 03。
def has_dollar_amount_check(user_input: str) -> tuple[bool, str | None]:
"""Input check — returns (allowed, rejection_message)."""
if re.search(r"\$\d+", user_input):
return True, None
return False, "I need a dollar amount to process this. Please include one (e.g., '$47')."
def has_decision_check(result: str) -> tuple[bool, str | None]:
"""Output check — returns (allowed, override_message)."""
# Stem match is intentionally permissive for a demo; tighten for production.
if re.search(r"\b(approv|flag|review)", result, re.IGNORECASE):
return True, None
return False, "I couldn't reach a clear approve/flag decision. Please resubmit."为了对比,以下是将相同检查作为 UserPromptSubmit hook 连接的方式。回调接收 input_data["prompt"],返回 {} 允许或 {"decision": "block", "reason": "..."} 阻止。这里的 HookMatcher 不接受 matcher 参数,因为 UserPromptSubmit 在每个 prompt 上触发——没有工具名称可以过滤。
要尝试它,在下一节的 ClaudeSDKClient(...) 调用中将 options=expense_options 替换为 options=hooked_options。一个注意事项:当 UserPromptSubmit hook 阻止时,阻止原因目前不会出现在 ResultMessage.result 中——你会得到一个空字符串而不是拒绝文本。这就是为什么演示循环使用普通函数来控制自己的返回。
对于工具级别的 guardrails——保护 Agent 做什么 而不是用户 发送什么——请参阅notebook 03 中的 PreToolUse hook 模式。
from claude_agent_sdk import HookMatcher
async def has_dollar_amount_hook(input_data, tool_use_id, context):
# tool_use_id is None for UserPromptSubmit — it's a uniform signature across all hook types.
if re.search(r"\$\d+", input_data["prompt"]):
return {}
return {"decision": "block", "reason": "I need a dollar amount to process this."}
hooked_options = ClaudeAgentOptions(
model=CLAUDE_MODEL,
system_prompt=expense_system_prompt,
mcp_servers={"expense": policy_server},
allowed_tools=["mcp__expense__check_policy"],
hooks={"UserPromptSubmit": [HookMatcher(hooks=[has_dollar_amount_hook])]},
)Runner.run() → ClaudeSDKClient
OpenAI: result = await Runner.run(agent, msg) 运行循环并返回一个结果对象。读取 result.final_output 获取文本。
Claude: ClaudeSDKClient 是一个异步上下文管理器。调用 .query(msg) 发送,然后迭代 .receive_response() 获取到达的事件——你可以按顺序看到每个工具调用、每个文本块和最终结果。事件类型:
| 事件 | 包含 |
|---|---|
SystemMessage |
会话初始化元数据 |
AssistantMessage |
文本块或工具使用块 |
UserMessage |
工具结果块 |
ResultMessage |
始终最后:.result、.usage、.total_cost_usd |
最终答案是 messages[-1].result。
SDK 还暴露了一个无状态的
query()函数(参见 notebook 00),用于使用内置工具进行一次性调用。当你通过create_sdk_mcp_server引入自定义工具时,ClaudeSDKClient是正确的选择,因为它的持久传输处理进程内 MCP 握手。
from utils.agent_visualizer import display_agent_response, print_activity, reset_activity_context
from claude_agent_sdk import ClaudeSDKClient
async def run_claude(msg: str) -> None:
# Input guardrail — short-circuit before the agent runs
allowed, rejection = has_dollar_amount_check(msg)
if not allowed:
print(rejection)
return
reset_activity_context()
messages = []
async with ClaudeSDKClient(options=expense_options) as client:
await client.query(msg)
async for event in client.receive_response():
print_activity(event)
messages.append(event)
# receive_response() guarantees ResultMessage is the last event yielded
final = messages[-1].result
ok, override = has_decision_check(final or "")
if not ok:
print(override)
return
display_agent_response(messages)
await run_claude("Lunch with Acme, $47")🤖 Thinking...
🤖 Thinking...
🤖 Using: mcp__expense__check_policy()
✓ Tool completed
🤖 Thinking...
<IPython.core.display.HTML object>
Sessions → ClaudeSDKClient(内存中)或 resume=(磁盘支持)
OpenAI 提供两种会话模式:
| OAI 模式 | 持久性 | Claude 等价物 |
|---|---|---|
result.to_input_list() + 重新发送 |
客户端在内存中保存历史 | 复用一个 ClaudeSDKClient——在同一开放上下文中再次调用 .query() |
conversation_id |
服务端,在进程重启后存活 | resume=session_id——对话记录写入本地磁盘,在重启后存活 |
内存中(如下): 保持 ClaudeSDKClient 上下文打开并再次调用 .query()。无需重新发送历史。当你的进程终止时也会终止。
磁盘支持: 每次运行产生一个 session_id(在 ResultMessage.session_id 上)。存储它,然后在下次运行时传递 ClaudeAgentOptions(resume=session_id, ...)。记录保存在本地文件系统上,而不是 Anthropic 的服务器上——它在重启后持久化,但不能跨机器。
对于在任一模式下超出上下文窗口的对话,请参阅 ../misc/session_memory_compaction.ipynb。
# --- In-memory: reuse the same client ---
async def run_claude_multiturn():
async with ClaudeSDKClient(options=expense_options) as client:
await client.query("Lunch with Acme, $47")
async for _ in client.receive_response():
pass # consume the stream
# Same client — remembers turn 1
await client.query("What about $90?")
turn2 = [m async for m in client.receive_response()]
# receive_response() guarantees ResultMessage is the last event yielded
return turn2[-1].result
print(await run_claude_multiturn())🚩 **Flagged for Manager Review**
| Detail | Info |
|---|---|
| **Description** | Lunch with Acme |
| **Category** | Meals |
| **Amount** | $90.00 |
| **Policy Limit** | $75.00 |
| **Over Limit By** | $15.00 |
| **Status** | **Flagged** |
The $90 lunch exceeds the $75 meals limit by **$15.00**, so it requires manager approval before it can be reimbursed.
# --- Disk-backed: capture session_id, resume later ---
# Turn 1: capture the session_id from the ResultMessage (always the last event)
async with ClaudeSDKClient(options=expense_options) as client:
await client.query("Lunch with Acme, $47")
turn1 = [m async for m in client.receive_response()]
session_id = turn1[-1].session_id
# Turn 2: new client, new process — resume from disk
resume_opts = replace(expense_options, resume=session_id)
async with ClaudeSDKClient(options=resume_opts) as client:
await client.query("What about $90?")
turn2 = [m async for m in client.receive_response()]
print(f"[resumed {session_id[:8]}...] {turn2[-1].result}")[resumed 4fc6dc1e...] Here's the rundown for both expenses:
| Expense | Amount | Limit | Status |
|---|---|---|---|
| Lunch with Acme | $47 | $75 | ✅ **Approved** |
| Lunch with Acme | $90 | $75 | 🚩 **Flagged for Manager Review** |
- **$47** – Under the $75 meals limit, so it's **approved**. (Receipt required.)
- **$90** – Exceeds the $75 meals limit by $15, so it's **flagged for manager review**.
并排对比
通过两个 SDK 运行所有三个测试输入。我们期望两者都批准 47 美元的午餐,标记 650 美元的航班,并拒绝没有金额的输入。
from agents.exceptions import InputGuardrailTripwireTriggered
async def run_claude_quiet(msg: str) -> str:
# Same guardrail as run_claude — duplicated here for a minimal comparison helper
allowed, rejection = has_dollar_amount_check(msg)
if not allowed:
return rejection
async with ClaudeSDKClient(options=expense_options) as client:
await client.query(msg)
messages = [m async for m in client.receive_response()]
# receive_response() guarantees ResultMessage is the last event yielded
return messages[-1].result or ""
test_inputs = [
("Lunch with Acme, $47", "approve"),
("Flight to NYC, $650", "flag"),
("Need approval for the thing", "guardrail"),
]
for msg, expected in test_inputs:
print(f"\n{'=' * 60}\nINPUT: {msg} (expect: {expected})\n{'=' * 60}")
try:
oai_out = await run_oai(msg)
except InputGuardrailTripwireTriggered:
oai_out = "[guardrail: InputGuardrailTripwireTriggered]"
claude_out = await run_claude_quiet(msg)
print(f"OAI: {str(oai_out)[:200]}")
print(f"Claude: {str(claude_out)[:200]}")
============================================================
INPUT: Lunch with Acme, $47 (expect: approve)
============================================================
OAI: The expense for lunch ($47) is under the meals category limit of $75. This expense is approved.
Claude: ✅ **Expense Approved**
| Detail | Info |
|---|---|
| **Description** | Lunch with Acme |
| **Category** | Meals |
| **Amount** | $47.00 |
| **Meal Limit** | $75.00 |
| **Status** | **Approved** |
Yo
============================================================
INPUT: Flight to NYC, $650 (expect: flag)
============================================================
OAI: The approval limit for travel expenses is $500. Since your flight to NYC costs $650, this expense exceeds the limit and will be flagged for manager review.
Claude: 🚩 **Expense Flagged for Manager Review**
| Detail | Info |
|---|---|
| **Description** | Flight to NYC |
| **Category** | Travel |
| **Amount** | $650.00 |
| **Policy Limit** | $500.00 |
| **Over Lim
============================================================
INPUT: Need approval for the thing (expect: guardrail)
============================================================
OAI: [guardrail: InputGuardrailTripwireTriggered]
Claude: I need a dollar amount to process this. Please include one (e.g., '$47').
追踪
Claude 发射到你的团队已经运行的标准。
每次运行的指标已经在你的手中。 每个 ResultMessage 都带有 .total_cost_usd 和 .usage——输入/输出 token、缓存读取、缓存写入、轮次计数。你将在下面的缓存单元格中看到这一点。有关程序化成本跟踪,请参阅 ../observability/usage_cost_api.ipynb。
OpenTelemetry 是原生的。 SDK 共享 Claude Code 运行时,在启用时导出 OTel 指标和事件:
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://your-collector:4317这些都不是移植所必需的——无论遥测是否开启,你的 Agent 都会以相同的方式运行。
每次工具调用和 API 请求都会发射一个标记有 prompt.id 的事件,因此你可以将所有活动关联回原始 prompt。将它指向你现有的 Grafana / Datadog / Honeycomb / Langfuse 栈。有关完整的事件 schema,请参阅监控文档。
Claude 专有:prompt 缓存
系统提示和工具 schema 在第一次调用后自动缓存——无需配置。具有相同前缀的后续调用只需为该前缀支付大约 10% 的输入 token 成本。
由于之前的单元格已经使用
expense_options运行过,缓存在我们到达这里时已经是热的——所以即使是运行 1 也显示出大量的cache_read_input_tokens。在一个冷启动的 notebook 中,运行 1 主要是cache_creation,运行 2 主要是cache_read。工具调用循环每次查询发起多个 API 请求,每个请求都扩展缓存的前缀——所以即使在重复运行中 cache_creation 也不会降到零,但它应该会缩小,而 cache_read 会增长。
for i in range(2):
async with ClaudeSDKClient(options=expense_options) as client:
await client.query("Coffee, $6")
messages = [m async for m in client.receive_response()]
# receive_response() guarantees ResultMessage is the last event yielded
usage = messages[-1].usage or {}
print(
f"Run {i + 1}: cache_creation={usage.get('cache_creation_input_tokens', 0):>5} "
f"cache_read={usage.get('cache_read_input_tokens', 0):>5} "
f"input={usage.get('input_tokens', 0):>5}"
)Run 1: cache_creation=38728 cache_read=66272 input= 4
Run 2: cache_creation= 659 cache_read=95025 input= 4
附录:迁移 handoffs
如果你的 OpenAI Agents SDK 应用使用 handoffs=[...],这就是心智模型转变的地方。
OpenAI: handoffs=[specialist] 暴露一个 transfer_to_specialist 工具。调用时,专家接管对话。第一个 Agent 就完成了。
Claude: 子 Agent 通过 AgentDefinition 以编程方式定义,并在 ClaudeAgentOptions(agents={...}) 中传递。编排者通过 Agent 工具委托,获取结果,并保持控制。
对于纯路由器(分诊→专家),差异很小。对于”Agent B 接管后 A 不再运行”是承重逻辑的应用,考虑使用轻量级 Python 分发器而不是 LLM 决定的路由。
你也可能在 Claude Code CLI 工作流中看到子 Agent 定义为
.claude/agents/*.md文件(例如 notebook 01)。那是交互式使用的基于文件系统的路径。对于作为代码交付的 SDK 应用,下面的编程式agents=参数是自然的选择——没有文件系统依赖。
from claude_agent_sdk import AgentDefinition
approver = AgentDefinition(
description="Approves expenses under policy limits. Use for any submission where the amount is at or under the limit returned by check_policy.",
prompt="You approve expense submissions that are within policy. Confirm the amount and category, and remind the submitter if a receipt is required.",
tools=["mcp__expense__check_policy"],
)
escalator = AgentDefinition(
description="Escalates over-limit expenses to a manager. Use when the submitted amount exceeds the policy limit for its category.",
prompt="You escalate over-limit expenses. Draft a one-line note to the manager: include the amount, the category, and how far over the limit it is.",
tools=["mcp__expense__check_policy"],
)
triage_options = ClaudeAgentOptions(
model=CLAUDE_MODEL,
system_prompt="Route each expense to the appropriate subagent. Delegate to approver if under limit, escalator if over.",
mcp_servers={"expense": policy_server},
allowed_tools=["Agent", "mcp__expense__check_policy"],
agents={"approver": approver, "escalator": escalator},
)
print(f"Orchestrator configured with subagents: {list(triage_options.agents)}")
# Config only — not executed here. For a full end-to-end multi-agent run,
# see ./01_The_chief_of_staff_agent.ipynbOrchestrator configured with subagents: ['approver', 'escalator']
总结
你构建了什么
一个费用审批 Agent,逐个原语移植:@function_tool → @tool,Agent → ClaudeAgentOptions,@input_guardrail/@output_guardrail → 普通的调用前/后检查,Runner.run → ClaudeSDKClient,内存中和磁盘支持的会话。
关键要点
- 工具: 显式 schema 意味着你写什么,模型就看到什么——没有内省意外。打包为进程内 MCP 服务器并通过
ClaudeSDKClient传递。 - Guardrails: 普通函数将拒绝流程放在你的代码中,而不是框架的。当你想在 options 上注册时,
UserPromptSubmithook 就在那里。 - 会话: 一个
ClaudeSDKClient用于内存中多轮对话。resume=session_id用于在重启后存活的磁盘支持对话。 - 追踪: OpenTelemetry 原生,因此它插入你已经在运行的栈。每轮成本和 token 使用量都在每个
ResultMessage上——无需额外连线。
后续步骤
- 多 Agent 编排:01_The_chief_of_staff_agent.ipynb
- 工具生命周期 hooks(
PreToolUse/PostToolUse):03_The_site_reliability_agent.ipynb - 成本和使用量跟踪模式:../observability/usage_cost_api.ipynb
中文读者提示:本章节的代码和输出为英文原文。如需理解具体实现细节,可参考上方中文导读和代码注释。如有疑问,欢迎在 GitHub Issues 讨论。