
官方给了 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 里种植的四类失败样本。
目标说明
读完你应能做到四件事。
- 写出一个能通过协议校验的自定义 Session。
- 说清多租户场景下存储键为什么不能只用
session_id。 - 让
add_items对工具调用和工具输出成对写入,失败时整批回滚。 - 给
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,删除和导出都按库操作。代价是连接数和迁移成本。两者都不能省掉键里的租户段,因为分库路由本身也可能配错。
预发检查
- 用
isinstance(your_session, Session)做协议校验,写进单测。 - 两个租户、同一会话号,交叉读一次,断言读不到对方。
- 在
add_items里注入一次写入异常,断言库里没有半截批次。 - 写 120 轮后
get_items(None),断言条数不超过上限。 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 版本为准。