OpenAI Agents SDK #10:99% 的开发者都搞错了——Context 到底传没传给 LLM?

从「context 对象传了却对 LLM 无效」这个高频 bug 切入,系统拆解 OpenAI Agents SDK 的 Context 双轨设计:本地 RunContextWrapper 与 LLM-visible Context 的本质边界、ToolContext 的 5 个工具级元数据属性、多 Agent Handoff 下 Context 单例自动流转机制,附两个完整带注释代码示例(基础用法 + 客服多 Agent 流转),结尾给出 3 条立即可用的实践建议。

研究速览

你有没有遇到过这种情况:把用户信息塞进 context 对象,满以为 Agent 能「记住」用户是谁,结果 LLM 给出的回答毫无个性化,完全不知道上下文里装了什么?
这不是 bug。这是你对 Context 的理解出了根本性偏差。
OpenAI Agents SDK 里的 Context 其实分成两个完全独立的世界:一个给你的代码用,另一个给 LLM 看。搞混这两者,是 Agent 开发者最常踩的坑之一1

RunContextWrapper:本地 Context 的载体

RunContextWrapper[TContext] 是 SDK 对本地 Context 的泛型封装,把你的自定义业务对象在整个运行链路里透传1
正在加载统计卡片...
重点看 wrapper.context:这里存的是你自定义的 Python 对象,比如用户 ID、数据库连接池、租户信息,任何你需要在代码里用的东西2。它不会被序列化、不会进 prompt,只在本地 Python 进程内流转。
wrapper.usage 特别有用——它聚合了单次运行里所有 Agent 消耗的 token 总量,省去你自己累加的麻烦。

代码示例一:基础用法

下面是一个完整可运行的例子,展示如何定义 Context 类型、如何在工具里读取 Context1
from dataclasses import dataclass
from agents import Agent, Runner, function_tool
from agents.run_context import RunContextWrapper

# 1. 定义你的业务状态对象
@dataclass
class UserInfo:
    name: str
    uid: int
    is_premium: bool = False

# 2. 工具函数接收 RunContextWrapper[UserInfo],获取本地 Context
@function_tool
def greet_user(wrapper: RunContextWrapper[UserInfo]) -> str:
    """向当前登录用户打招呼"""
    user = wrapper.context          # 拿到 UserInfo 实例
    tier = "高级会员" if user.is_premium else "普通用户"
    return f"你好,{user.name}!你当前是{tier}(UID: {user.uid})"

# 3. Agent 标注泛型类型,绑定 UserInfo
agent: Agent[UserInfo] = Agent(
    name="PersonalAssistant",
    instructions="你是一个个人助理,可以通过工具获取用户信息来提供服务。",
    tools=[greet_user],
)

# 4. 运行时把 UserInfo 实例传给 context 参数
user_info = UserInfo(name="张三", uid=10086, is_premium=True)

result = Runner.run_sync(
    starting_agent=agent,
    input="请帮我打个招呼",
    context=user_info,              # 整次运行内所有工具都能访问
)

print(result.final_output)
# 示例输出:你好,张三!你当前是高级会员(UID: 10086)

# 5. 查看 token 消耗(wrapper.usage 聚合了全部 Agent)
# result.usage.total_tokens 可直接查看
注意第 3 步:Agent[UserInfo] 是泛型标注,让类型检查器(mypy / pyright)在编译期捕获类型不匹配——比如你把 OrderInfo 传给一个期望 UserInfo 的 Agent,立刻报错1
关键提醒greet_user 里的 wrapper.context.name 是 Python 本地读取,对 LLM 完全透明。如果你想让模型「知道」用户叫什么,必须把用户名写入 instructions 或通过工具返回值暴露出来。

ToolContext:工具级元数据的精准入口

大多数场景下,RunContextWrapper 够用。但有时候你需要知道「这次是哪个工具调用触发的」,这时候就需要用 ToolContext1
ToolContextRunContextWrapper 的子类,在工具执行和工具生命周期钩子里可用,额外暴露:
属性含义
tool_name当前工具名称(如 "search_web"
tool_call_id此次调用的唯一 ID,可用于日志追踪
tool_argumentsLLM 传入的原始参数 JSON 字符串
tool_namespace工具所属命名空间
qualified_tool_name带命名空间修饰的完整工具名
典型用途:在审计日志里精确记录「哪次运行、哪个工具、哪次调用」,或者在工具钩子里根据 tool_call_id 实现幂等控制1

代码示例二:多 Agent Handoff 下的 Context 共享

Context 最有意思的特性:它在整个运行链路里是「单例传递」的。一次 Runner.run() 调用所有涉及的 Agent,共享同一个 Context 实例。
这意味着,Agent A 把某个状态写入 wrapper.context,Agent B 接手后可以直接读到3
from dataclasses import dataclass, field
from typing import List
from agents import Agent, Runner, function_tool, handoff
from agents.run_context import RunContextWrapper

@dataclass
class CustomerServiceContext:
    customer_id: str
    visited_agents: List[str] = field(default_factory=list)  # 追踪流转路径
    resolved: bool = False

# 前台分流 Agent 工具:记录首次接触
@function_tool
def log_triage(wrapper: RunContextWrapper[CustomerServiceContext]) -> str:
    """记录分流信息"""
    ctx = wrapper.context
    ctx.visited_agents.append("triage")   # 直接修改 Context,下游 Agent 能看到
    return f"客户 {ctx.customer_id} 已记录,准备分流"

# 退款 Agent 工具:读取并更新 Context
@function_tool
def process_refund(wrapper: RunContextWrapper[CustomerServiceContext]) -> str:
    """处理退款"""
    ctx = wrapper.context
    ctx.visited_agents.append("refund")   # 追加到路径链
    ctx.resolved = True
    return f"退款已处理,服务路径:{' → '.join(ctx.visited_agents)}"

# 定义退款专项 Agent
refund_agent: Agent[CustomerServiceContext] = Agent(
    name="RefundAgent",
    instructions="你专门处理退款请求,使用 process_refund 工具完成操作。",
    tools=[process_refund],
)

# 前台分流 Agent,完成分流后 Handoff 给退款 Agent
triage_agent: Agent[CustomerServiceContext] = Agent(
    name="TriageAgent",
    instructions="你是前台客服,先记录分流信息,然后把退款请求转给退款专员。",
    tools=[log_triage],
    handoffs=[refund_agent],             # 声明可交接的目标 Agent
)

# 执行:Context 在 TriageAgent → RefundAgent 间自动流转
ctx = CustomerServiceContext(customer_id="C-2025-001")
result = Runner.run_sync(
    starting_agent=triage_agent,
    input="我要申请退款",
    context=ctx,
)

# RefundAgent 处理完后,ctx 里记录了完整路径
print(ctx.visited_agents)   # ['triage', 'refund']
print(ctx.resolved)         # True
注意这里没有任何额外的「Context 传递」代码。Handoff 发生时,SDK 自动把同一个 context 对象透传给下一个 Agent3
单次 Runner.run() 调用涉及的所有 Agent(包括通过 Handoff 链接的)必须使用相同的 TContext 类型。如果 TriageAgent 用 CustomerServiceContext,而 RefundAgent 用 OrderContext,运行时会类型错误。这是 SDK 的强制约束,不是可以绕过的限制1

Handoff 下的进阶:input_filteron_handoff

Context 共享只是 Handoff 状态管理的一面。如果你还需要控制「传给下一个 Agent 的对话历史里有什么」,就需要 input_filter3
from agents.handoffs import HandoffInputData
from agents import handoff

def slim_history_filter(data: HandoffInputData) -> HandoffInputData:
    """
    只保留最近 3 条消息,避免退款 Agent 接收过多无关上下文。
    data.run_context 里有 RunContextWrapper,可以读写 Context。
    """
    recent_items = list(data.new_items)[-3:]
    return data.clone(new_items=tuple(recent_items))

refund_handoff = handoff(
    agent=refund_agent,
    input_filter=slim_history_filter,       # 过滤传入历史
    on_handoff=lambda ctx, _: print(        # 交接时触发回调
        f"即将移交,当前路径:{ctx.context.visited_agents}"
    ),
)
HandoffInputData 里的 run_context 就是当前的 RunContextWrapper,在过滤器里可以同时读写 Context 状态4

「本地 Context 和 LLM-visible Context」的边界在哪里

这是最值得反复确认的认知模型。
本地 Context 能做什么:存数据库连接池、当前登录用户 ID、请求追踪 ID(trace_id)、A/B 实验分组、中间计算结果、已执行工具的幂等记录。一句话,任何「你的代码需要但不想放进 prompt」的东西都可以放这2
LLM 如何获知这些信息:通过四条路径——
  1. instructions(静态或动态函数生成)
  2. Runner.run(input=...) 传入的初始消息
  3. 函数工具的返回值(工具把 Context 里的数据格式化成字符串返回)
  4. 检索结果注入
这个设计不是限制,而是安全边界。密钥、PII、数据库连接等信息留在本地 Context,永远不会意外地出现在发给 LLM 的请求里1

护栏中的 Context 复用

提一个容易被忽略的点:护栏(Guardrails)函数也接收 RunContextWrapper,而且可以在验证逻辑里嵌套调用子 Agent——直接把原 Context 传进去5
@input_guardrail
async def policy_check(
    ctx: RunContextWrapper[CustomerServiceContext],   # 拿到同一个 Context
    agent: Agent,
    input: str,
) -> GuardrailFunctionOutput:
    # 嵌套调用验证 Agent,复用当前 Context
    result = await Runner.run(
        validation_agent,
        input=input,
        context=ctx.context,   # 透传,避免重新构建
    )
    triggered = "违规" in result.final_output
    return GuardrailFunctionOutput(
        output_info=result.final_output,
        tripwire_triggered=triggered,
    )
这种模式让「需要调用 LLM 做语义判断」的护栏也能访问完整的业务 Context,不用把校验逻辑和状态管理拆成两套体系5

3 条可立即落地的实践建议

1. 用 dataclass 或 Pydantic 定义 Context,不要用 dict
dict 意味着放弃类型检查。用 @dataclassBaseModel 定义 Context 类,配合 Agent[YourContext] 泛型标注,mypy/pyright 会在 CI 里替你挡住类型不匹配的问题1
2. 让工具「翻译」Context,而不是让 LLM「猜」Context
需要 LLM 了解某个 Context 字段?写一个工具函数读取该字段并以自然语言返回。这比手动拼接 instructions 灵活,也更容易测试和维护6
3. 多 Agent Handoff 链路中,用 Context 而非对话历史传递结构化状态
对话历史是给 LLM 看的文本流,不适合存结构化数据(JSON 塞到 prompt 里很快吃光 token)。结构化的跨 Agent 状态(已处理的工单 ID、当前流程步骤、用户权限等级)放进 Context 对象,用 wrapper.context.field 读写,干净且不消耗 token3

预告 #11

下一篇我们进入 Models 模块——SDK 如何抽象不同模型提供商、RunConfig.modelAgent.model 的优先级覆盖逻辑、如何接入非 OpenAI 模型(Anthropic、Gemini、本地 Ollama),以及 ModelSettings 里 temperature/top_p 的覆盖机制。
如果你想把同一套 Agent 代码同时跑在 GPT-5 和 Claude 4 上做对比测试,下篇给你完整方案。

封面图:AI 生成(深蓝渐变技术风,RunContextWrapper 数据流架构示意)

围绕这条内容继续补充观点或上下文。

  • 登录后可发表评论。