一个多租户的计费助手里,扣费工具需要知道当前是哪个租户。最早的写法很省事:请求进来时把租户 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,比上线后对账便宜得多。
预发检查
- 仓库搜工具函数里对模块级字典、
contextvars、线程局部变量的读取,逐个改成读ctx.context。 - 搜
ctx.context[...] =和-=这类写法,确认没有工具在改共享对象。 - 有副作用的工具逐个确认去重表和唯一键。
- 鉴权逻辑只依赖服务端写入的字段,不看
tool_name和参数里的租户。 - 升级 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,并发和重试就不会把一个租户的账记到另一个租户头上。