OpenAI Agents SDK 工程笔记:ToolContext 工具侧上下文注入、跨请求串租户与生产禁区

一个多租户的计费助手里,扣费工具需要知道当前是哪个租户。最早的写法很省事:请求进来时把租户 id 写进模块级的字典,工具函数里直接读。压测那天,两个租户的请求几乎同时到达,A 租户的一次扣费记到了 B 租户头上。日志里看,工具确实被 A 的会话调用,读到的租户却是 B。

问题出在租户从哪里读。SDK 给工具函数准备了专门的入口,就是 ToolContext。这篇只讲这个对象:它里面有什么,工具该从哪里取租户和调用信息,以及哪些用法在并发和重试下会出事。

本机版本 openai-agents 0.23.1。ToolContext 定义在 agents/tool_context.py,是 RunContextWrapper 的子类,issubclass 实测为真。构造参数依次是 context、usage、tool_name、tool_call_id、tool_arguments、tool_call、tool_namespace、agent、run_config、turn_input 等。tool_name、tool_call_id、tool_arguments 是必填的,我只传 context 去构造,得到 ValueError: tool_name must be passed to ToolContext。

function_tool 装饰的函数,如果第一个参数标注为 ToolContext,运行时 SDK 会把它注入进来,这个参数不出现在给模型看的 JSON schema 里。tool.py 里对应的类型是 ToolFunctionWithToolContext = Callable[Concatenate[ToolContext, ToolParams], Any]。

图注 左侧为工具读取上下文的契约,右侧为 smoke 种植的三类失败。

正确写法先钉死

python 复制代码
from agents import function_tool
from agents.tool_context import ToolContext

@function_tool
async def charge(ctx: ToolContext[dict], amount: int) -> str:
    """扣费"""
    tenant = ctx.context["tenant_id"]       # 租户只从本次运行的 context 取
    key = f"{tenant}:{ctx.tool_call_id}"     # 幂等键
    if not dedup_store.add_if_absent(key):   # 外部去重表,示意
        return "duplicate"
    billing.charge(tenant, amount)
    return "ok"

三件事。租户 id 从 ctx.context 读,那是本次 Runner.run 传进来的上下文对象。调用标识用 ctx.tool_call_id。副作用前先查外部去重表,SDK 本身不替你去重。dedup_store 和 billing 是示意,不是 SDK 的 API。

离线 smoke

为了不依赖模型,我直接调用 FunctionTool.on_invoke_tool(ctx, input_json),手工构造 ToolContext 传进去。这条路径和运行时调用工具走的是同一个入口签名。

text 复制代码
P0  PASS  ToolContext 继承 RunContextWrapper,缺 tool_name 构造即报错
F1  FAIL  全局变量读租户,并发串单      ('bad','t-A','t-B')
P1  PASS  从 ctx.context 读租户         ('good','t-A','t-A')
F2  FAIL  工具改写共享 context,额度 100 → 40
F3  FAIL  同一 tool_call_id 重试,副作用执行两次

脚本在 _w/toolctx-smoke/,smoke.py 和 closure.py 两个文件,结果是 facts.json 和 ctx_fail.csv。

三类种植失败

F1 全局变量串租户。 两个协程各自先写全局 CUR["tenant"],再调用工具,工具里先 await 一下再读全局。结果 A 的那次调用读到了 t-B,同一时刻 ctx.context["tenant_id"] 仍是正确的 t-A。这正是开头的事故形状。闭包捕获请求级变量、线程局部变量用在异步代码里,都会落进同一个坑。好路径只有一条:租户从 ctx.context 取。

F2 工具改脏共享 context。 ToolContext 的 context 字段指向的是你传给运行的那个对象,不是拷贝。工具里写 ctx.context["quota"] -= amount,改的就是那个对象本身。smoke 里额度从 100 被改到 40。把它当可写会话随手改,后续工具和钩子读到的都是改过的值,而且改动没有任何审计记录。好路径是把 context 当只读的请求信息,额度、余额这类状态放在外部存储,由工具读写外部存储并记日志。

F3 重试重复副作用。 网络抖动后,上层用同一个 tool_call_id 重放了这次调用。smoke 里两次调用拿到的都是 call_1,扣费函数也执行了两次,额度被扣了两回。tool_call_id 适合做幂等键,但它只是一个字符串,去重要靠你自己落一张外部表,按「租户 + call id」唯一。另外要注意,重试时如果复用了旧的 context 对象,F2 改过的脏值也会一起带过去。

鉴权该钉哪些字段

工具体内做鉴权,常见的错误是读展示名。ctx.agent 能拿到当前 Agent,ctx.tool_name 是模型可见的工具名,这两个都可能被改名、被覆盖,不适合当权限依据。权限判断应基于 ctx.context 里由服务端写入的稳定 id,比如租户 id、用户 id、角色,这些值在请求进入时由网关写好,模型改不了。tool_arguments 是模型生成的,里面的租户参数一律不信,和 context 里的值不一致时直接拒绝。

生产禁区表

编号 禁区 smoke 证据 替代做法
D1 全局变量或闭包读租户 F1 只读 ctx.context
D2 工具直接改写 context F2 状态放外部存储并留日志
D3 重试不去重 F3 租户加 call id 落去重表
D4 按展示名或工具名鉴权 (对照) 读服务端写入的稳定 id
D5 信任参数里的租户字段 (对照) 与 context 比对,不一致拒绝

测试替身怎么写

ToolContext 可以手工构造,这让工具单测很便宜。准备两个不同租户的 context,用 asyncio.gather 并发调用同一个工具,断言每次副作用落到对应租户。再用同一个 tool_call_id 连调两次,断言第二次返回重复。最后在调用前后对 context 做一次深拷贝比较,断言工具没有改它。三条断言放进 CI,比上线后对账便宜得多。

预发检查

  1. 仓库搜工具函数里对模块级字典、contextvars、线程局部变量的读取,逐个改成读 ctx.context。
  2. 搜 ctx.context[...] = 和 -= 这类写法,确认没有工具在改共享对象。
  3. 有副作用的工具逐个确认去重表和唯一键。
  4. 鉴权逻辑只依赖服务端写入的字段,不看 tool_name 和参数里的租户。
  5. 升级 SDK 后复跑 smoke,核对 ToolContext 构造参数和必填项有没有变化。

日志里怎么认出串租户

串租户最难查的地方在于每一条日志单看都像是对的。工具被调用了,扣费成功了,金额也没错,只是租户字段错了。建议工具日志固定写四列:ctx.context 里的租户 id、tool_call_id、实际落库的租户 id、调用时刻。第一列和第三列不一致,就是 F1 的形状,可以直接做成告警规则。同一个 tool_call_id 在去重表里出现两次,是 F3 的形状。额度类字段在两次调用之间被改了却找不到对应的外部写入记录,多半是 F2。

这四列的成本很低,但很多团队只记工具名和耗时。等到对账发现问题再补日志,往往已经过了好几天,事故范围很难再还原。

旧代码怎么迁

已经上线的工具,先把所有 function_tool 函数列出来,标出第一个参数是不是 ToolContext 或 RunContextWrapper。没有这个参数、又要用到租户的工具,就是 F1 的候选,它们一定是从别处拿的租户。迁移顺序先改有副作用的工具,比如扣费、发信、写库,只读查询放后面。每改一个,补一条并发单测。

改签名时注意一点:上下文参数必须是第一个参数,SDK 才会把它当注入参数,不放进给模型的 schema。放错位置也不会静默出错,function_schema.py 里对非首位的 RunContextWrapper 或 ToolContext 参数会直接抛 UserError,提示它出现在非首位,构建阶段就能发现。

什么时候 context 里该放什么

context 不是越大越好。放进去的东西,所有工具、钩子都能读到,也就都能被误用。我的原则是只放请求级、服务端写入、在整次运行里不变的值:租户 id、用户 id、角色、请求 id、配额档位。数据库连接、外部客户端这类对象可以放,但要是线程安全的。会变化的业务状态,比如余额和已用额度,不要放,放了就会有人去改它。

和 RunContextWrapper 的分工

ToolContext 是 RunContextWrapper 的子类,所以生命周期钩子、动态指令里拿到的 wrapper 和工具里拿到的 ToolContext,context 指向的是同一个对象。区别在于工具侧多了这次调用的身份信息:工具名、调用 id、参数原文,必要时还有命名空间和当前 Agent。按这个分工理解就不容易乱:请求级的东西放 context,调用级的东西从 ToolContext 的其余字段取,两者都不要往全局变量里放。

小结

ToolContext 把本次运行的 context 和这次工具调用的身份一起交给工具。租户从 context 读,context 当只读,tool_call_id 配外部去重表,鉴权只信服务端写入的 id。照这四条做,再把三类种植错跑进 CSV,并发和重试就不会把一个租户的账记到另一个租户头上。

相关推荐
一枕槐安4872 天前
多租户 RAG 系统设计实战:如何隔离不同业务知识库数据?
向量数据库·rag·权限控制·多租户·数据隔离·saas架构
AIGC大时代4 天前
OpenAI Agents SDK 工程笔记:RunResult.last_agent 多交接后责任归属、终稿误挂与生产禁区
agents sdk·handoff·生产禁区·last_agent·责任归属
AIGC大时代4 天前
OpenAI Agents SDK 工程笔记:AgentOutputSchema 结构化输出声明、strict 校验失败与生产禁区
结构化输出·agents sdk·生产禁区·strict
AIGC大时代7 天前
OpenAI Agents SDK 工程笔记:TraceProcessor 导出、敏感字段脱敏与生产禁区
脱敏·tracing·agents sdk·生产禁区·traceprocessor
AIGC大时代7 天前
OpenAI Agents SDK 工程笔记:自定义 Session 接口(get_items / add_items / clear)与多租户隔离的生产禁区
session·多租户·agents sdk·生产禁区·get_items
喜欢吃豆8 天前
Better Auth 深度解析:从用户认证到企业级身份基础设施
数据库·架构·权限·多租户
小天源14 天前
智叟云集定时采集与消息推送架构——实现可恢复的订阅通知流程
架构·sass·权限管理·多租户·服务商 api·ai采集
做个文艺程序员17 天前
MQ第07篇:Java SaaS多租户消息隔离实战:RabbitMQ VHost+RocketMQ Tag+命名空间架构
spring boot·rabbitmq·rocketmq·saas·多租户
AIGC大时代17 天前
OpenAI Agents SDK 工程笔记:SQLiteSession 多轮会话持久化与生产禁区
agents sdk·生产禁区·sqlitesession·会话持久化