OpenAI Agents SDK 工程笔记:自定义 Session 接口(get_items / add_items / clear)与多租户隔离的生产禁区

官方给了 SQLiteSession,也给了托管的会话实现。很多团队还是会自己写一个 Session,原因很实际,历史要进公司已有的 Postgres 或 Redis,要按租户分库,要接审计。自己写的那一刻,SDK 不再替你兜底。Runner 只管调用四个方法,键怎么拼、写入是否原子、读出来多少条、清空清到哪一层,全是实现者的责任。

先说清标题里的简写。本机 openai-agents 0.23.1 的 Session 协议实际是四个方法,get_items(limit)、add_items(items)、pop_item()、clear_session(),外加 session_id 与可选的 session_settings 两个属性。标题里的 clear 指的就是 clear_session。升级 SDK 后请先用 isinstance(obj, Session) 复核,协议是 runtime_checkable 的,方法名对不上会直接返回 False。

本文的离线 smoke 在 _w/custom-session-smoke/,不连模型接口。好实现跑 P0 到 P4,反例实现种植 F1 到 F4。

图注 左侧为自写实现必须守住的四条契约,右侧为 smoke 里种植的四类失败样本。

目标说明

读完你应能做到四件事。

  1. 写出一个能通过协议校验的自定义 Session。
  2. 说清多租户场景下存储键为什么不能只用 session_id。
  3. 让 add_items 对工具调用和工具输出成对写入,失败时整批回滚。
  4. 给 get_items 设硬上限,让 clear_session 真的删掉落盘数据。

四条契约

方法 契约 违反后的现象
get_items(limit) 返回最新 N 条且按时间正序;None 时也要有服务端硬上限 历史全量灌回,窗口被撑爆
add_items(items) 一批要么全写要么全不写;function_call 与 function_call_output 的 call_id 成对 续跑时模型看到孤立调用,报错或胡编结果
pop_item() 只弹本会话最后一条 弹到别的会话
clear_session() 删本会话的落盘行,内存缓存一并清掉 用户点了清空,数据库里还在

session_id 只是一个字符串,它不认证任何人。谁能拿到这个字符串,谁就能读那段历史。所以隔离必须在存储键上做,而不是指望调用方不传错。

好实现的骨架

python 复制代码
class TenantSQLiteSession:
    session_settings = None
    def __init__(self, tenant, user, sid, db):
        self.session_id = f"{tenant}:{user}:{sid}"
        self.db = db

    async def get_items(self, limit=None):
        n = min(limit or HARD_CAP, HARD_CAP)
        # select ... where skey=? order by id desc limit ?,再反转成正序

    async def add_items(self, items):
        calls = {i["call_id"] for i in items if i.get("type") == "function_call"}
        outs  = {i["call_id"] for i in items if i.get("type") == "function_call_output"}
        if calls != outs:
            raise ValueError("unpaired call_id")
        # 单事务 executemany,写入前用正则把 sk- 与 Bearer 形态替换掉

    async def clear_session(self):
        # delete from items where skey=?

键由租户、用户、会话三段拼成。服务端从登录态里取租户与用户,前端只传会话号。这样即使两个租户恰好都用了 s1,落到库里也是两条不同的键。

配对校验放在写入口,而不是读出口。读出口发现断裂时,坏数据已经落盘,只能靠人工修。写入口直接拒收,Runner 会把异常抛到上层,你能在监控里看见。

离线 smoke 结果

text 复制代码
P0  PASS 协议校验 isinstance(Session) -> True
P1  PASS 同 sid 不同租户互不可见 -> alice 读到 bob 条目=False
P2  PASS add_items 原子且校验配对 -> 拒收未配对写入,历史仍配对=True
P3  PASS get_items(None) 硬上限 -> 返回 50 条(上限 50)
P4  PASS 脱敏落盘 + clear_session 删盘 -> 明文密钥行=0,clear 后残留=0
F1  FAIL 种植:键只用 sid 跨租户复用 -> alice 读到 bob 条目=True
F2  FAIL 种植:add_items 逐条写中途失败 -> 落盘 2 条,配对=False
F3  FAIL 种植:get_items 无上限回灌 -> 返回 240 条
F4  FAIL 种植:clear 只清缓存 + 密钥原样落盘 -> clear 后盘上仍有 2 条,含明文密钥

结果写在 custom-session-smoke.csv,好实现读回的样例在 session_dump.json。反例实现 NaiveSession 与好实现共用同一张表结构,差别只在键、事务、上限和清空四处,方便你逐条对照。

四类失败样本拆解

F1 串租户。 反例把 session_id 原样当键。alice 与 bob 都传 s1,alice 读回的历史里出现了 bob 的订单。线上更常见的版本是前端用自增数字做会话号,任何人改一下数字就能翻别人的记录。

F2 配对断裂。 反例逐条插入,第三条写入时注入一次磁盘异常。库里留下用户消息和 function_call,却没有对应的 function_call_output。下次续跑把这段历史喂回去,模型会看到一个没有结果的调用。轻则接口报参数错误,重则模型自己补一个假结果继续往下走。

F3 无上限回灌。 反例忽略 limit,120 轮对话读回 240 条。好实现在服务端再卡一次上限,调用方传 None 也只给 50 条。上限数值按你的模型窗口和单条平均长度算,别直接照抄 50。

F4 清空不删盘。 反例的 clear_session 只清了实例里的缓存列表。用户在界面上点了清空历史,数据库里两条记录原封不动,其中一条还带着明文密钥。合规审计时,这是最难解释的一类事故。

生产禁区表

编号 禁区 smoke 证据 替代做法
D1 用前端传来的 session_id 直接当存储键 F1 键里拼服务端认定的租户与用户
D2 add_items 逐条写、无事务 F2 单事务批量写,入口校验 call_id 配对
D3 get_items 只信调用方的 limit F3 服务端硬上限,超长历史走摘要
D4 clear_session 只清内存 F4 删落盘行,并记一条审计日志
D5 把密钥、令牌写进可序列化条目 F4 写入前脱敏,凭据走运行上下文注入

分库还是前缀

前缀隔离实现简单,一张表加一列键就能跑,适合租户数多、单租户数据量小的场景。代价是所有租户共享一份备份和一套权限,某个租户要求导出或彻底删除时,你要写按前缀筛选的脚本。

分库隔离边界更硬,每个租户一个库或一个 schema,删除和导出都按库操作。代价是连接数和迁移成本。两者都不能省掉键里的租户段,因为分库路由本身也可能配错。

预发检查

  1. 用 isinstance(your_session, Session) 做协议校验,写进单测。
  2. 两个租户、同一会话号,交叉读一次,断言读不到对方。
  3. 在 add_items 里注入一次写入异常,断言库里没有半截批次。
  4. 写 120 轮后 get_items(None),断言条数不超过上限。
  5. clear_session 后直接查库,断言该键下零行。

五条都能在本目录的 smoke.py 里找到对应写法,换成你的存储后端时只改连接部分。

何时不必自写

如果你的历史量不大,也没有分租户需求,官方 SQLiteSession 已经够用。需要托管、跨设备续聊,用官方的会话托管实现更省事。只有当存储必须进公司自有库、需要租户隔离或审计时,才值得自己实现协议,并且要把上面五条预发检查一起带上。

Runner 什么时候调哪个方法

弄清调用时机,才能判断哪里必须加锁。一次 Runner.run(agent, input, session=s) 大致是这样走的,开跑前调一次 get_items,把历史和本轮输入拼起来交给模型;本轮结束后调 add_items,把用户输入、模型输出、工具调用与工具结果一起写回。pop_item 一般只在你自己做撤回、改写上一条时才用到,Runner 不会主动调。clear_session 完全由业务触发。

这带来两个推论。第一,同一会话如果被两个请求并发续跑,两次 get_items 读到的是同一份旧历史,两次 add_items 会先后追加,顺序取决于谁先结束。对客服类场景,这通常意味着两段回答交错落库。解决办法是在业务层给会话加互斥,或者在键上带请求序号,读的时候按序号重排。第二,add_items 是在本轮全部结束后才写,所以中途超时或进程被杀,本轮内容一条都不会落库。这比写一半好,但要让前端知道这一轮没有保存。

SessionSettings 与服务端上限的关系

0.23.1 里 Session 可以挂 session_settings,其中的 limit 会在 Runner 读取历史时作为 get_items 的参数传进来。它是调用方的意愿,不是服务端的保证。别的服务、脚本、后台任务直接调你的 Session 时,未必会带这个设置。所以服务端硬上限仍然要有,两者取较小值。

超长历史不要只靠截断。截掉最早的若干条,可能正好截掉了一次 function_call,留下孤立的 function_call_output。比较稳的做法是按轮截,一轮里的用户输入、调用、结果、回答作为一个单元,要么整轮保留,要么整轮丢弃,再用一条摘要替代被丢掉的部分。

审计与删除请求

多租户系统迟早会收到删除请求。好实现里,键是三段拼接,删除某个用户的全部会话只要按前缀删除,再记一条审计日志,写清谁、什么时候、删了哪个前缀、删了多少行。反例实现里,键只有会话号,你根本无法回答某个用户名下有哪些会话。这一点在设计期就要定下来,上线后再补键结构,就得做一次全量迁移。

单测怎么组织

建议把好实现和反例实现放在同一个测试文件里,用同一组用例跑两遍。好实现应全部通过,反例应在对应用例上稳定失败。这样做的好处是,哪天有人重构存储层,不小心把事务去掉了,F2 那条用例会立刻从"反例失败"变成"好实现也失败",比人工代码审查更早发现。

迁移旧数据

已经上线、键里没有租户段的系统,迁移时先别急着改键。第一步加一列租户,按业务库里的会话归属回填;第二步让新写入同时带新旧两种键;第三步读路径切到新键,观察一周;最后删掉旧键。每一步都可以单独回滚,比一次性改表安全。

监控指标

上线后至少盯三个数,配对校验拒收次数、单次读取命中上限的比例、清空请求后的残留行数。第一个数突然升高,说明上游有地方在拆批写入;第二个数长期很高,说明上限太小或摘要没生效;第三个数应该永远是零,不是零就立即排查。

总结

自定义 Session 是四个方法的契约,不是一个随便能填的存储接口。键里要有租户,写入要成批原子,读出要有硬上限,清空要删到盘。四个反例在 smoke 里都能稳定复现,升级 SDK 或换存储后端时复跑一次,比事后翻日志便宜得多。方法名与签名以你安装的 openai-agents 版本为准。

相关推荐
喜欢吃豆15 小时前
Better Auth 深度解析:从用户认证到企业级身份基础设施
数据库·架构·权限·多租户
承渊政道4 天前
Linux网络学习【深入理解应用层HTTP协议、Cookie与Session机制及HTTPS协议原理】
linux·网络·学习·http·cookie·session
大连好光景5 天前
Agent SDK默认将哪三类状态写入本地磁盘?
session·agentsdk·多租户隔离·sessionstore
小天源7 天前
智叟云集定时采集与消息推送架构——实现可恢复的订阅通知流程
架构·sass·权限管理·多租户·服务商 api·ai采集
一条泥憨鱼10 天前
【Redis | 黑马点评】短信登录功能 源码深度分析
java·redis·后端·session·黑马点评
做个文艺程序员10 天前
MQ第07篇:Java SaaS多租户消息隔离实战:RabbitMQ VHost+RocketMQ Tag+命名空间架构
spring boot·rabbitmq·rocketmq·saas·多租户
AIGC大时代10 天前
OpenAI Agents SDK 工程笔记:SQLiteSession 多轮会话持久化与生产禁区
agents sdk·生产禁区·sqlitesession·会话持久化
AIGC大时代10 天前
OpenAI Agents SDK 工程笔记:AgentsException 族、失败分类与生产恢复禁区
agents sdk·agentsexception·maxturns·behaviorerror
AIGC大时代10 天前
OpenAI Agents SDK 工程笔记:WebSearchTool 与 FileSearchTool 托管工具边界与生产禁区
agents sdk·websearchtool·filesearchtool·托管检索