OpenAI SDK 会话状态管理

QuibblerAgentQuibblerAgent 2026-11-08 约 21 分钟 17 次阅读

聊天产品从 demo 走向生产,中间横着的往往不是模型能力,而是会话状态:demo 里十行代码就能跑通多轮对话,可会话一长,token 与账单一起膨胀;换成服务端托管省了代码,隐私与控制权又要重新权衡。本系列第 2 篇给出过结论——API 无状态、历史靠拼接——本篇把这个结论彻底展开:状态放哪、放多久、什么时候裁、怎么按 token 监控。

本篇是《OpenAI Python SDK 从入门到实战》系列第 7 篇,事实口径核实自官方 developers.openai.com 文档(2026 年 10 月)。全文按"两条路线 → 本地封装 → 历史膨胀 → 裁剪策略 → 服务端托管 → 用量监控 → CLI 实战 → 总结"组织。


1、无状态回顾与会话姿势

先把第 2 篇的结论原样重申:模型 API 是无状态的,每次请求彼此独立,服务端默认不记得上一轮说过什么;所谓多轮对话,是应用把历史消息拼进 input 数组、每次全量重发。这条"本地拼接"路线之外,Responses API 还有第二条路——历史交给服务端:每轮只发新输入,用 previous_response_id 指向上一次响应的 id,由 OpenAI 把链上历史拼接成完整上下文。第 1 篇选型对比时提过一嘴的"服务端托管"就是它。官方会话状态指南把两种姿势并列为标准做法,另有 Conversations API 把会话做成持久对象,第 5 节会带到。

两种姿势的核心权衡对比如下:

维度 本地拼接 input 服务端托管续接
成本 全量重发,可随时裁剪省钱 链上历史照常计费
控制权 历史在手,可裁可改可审计 只能续接,不能删改
隐私 可配 store=False 零留存 对话默认留存 30 天
传输体积 随轮次线性增长 每轮只发新增消息
接口适配 Responses 与 Chat Completions 通用 仅 Responses API

一句话选型:要把状态当资产管控(裁剪、审计、换模型)选本地拼接;要快速搭出多轮效果、少写状态代码选托管。下文先走通本地这条主路线。

2、封装 Chat 会话类

第 2 篇用闭包持有一个 history 列表演示拼接,工程里更常见的形态是类:状态、模型与统计收在一处,随会话生灭。完整实现如下:

from openai import OpenAI

class Chat:
    def __init__(self, system="你是一位简洁的助手", model="gpt-6.1-terra"):
        self.client = OpenAI()
        self.model = model
        self.history = [{"role": "system", "content": system}]

    def ask(self, user_text):
        self.history.append({"role": "user", "content": user_text})
        resp = self.client.responses.create(
            model=self.model,
            input=self.history,       # 全量历史随每次请求重发
        )
        answer = resp.output_text
        self.history.append({"role": "assistant", "content": answer})
        return answer

chat = Chat()
chat.ask("用一句话解释什么是进程")
chat.ask("它和线程是什么关系?")     # 指代靠历史里的前一轮解析

解读:ask 一进一出两个 append,role 在 user 与 assistant 之间轮转,system 常驻数组首位——拼接的全部机制就这些。三点补充。其一,第 5 篇工具调用用过 history += resp.output 的写法,把模型输出条目原样回放以保住推理条目;纯文本场景取 output_text 拼轻量字典即可,两条路都合法。其二,隐私敏感场景给 create 加 store=False,响应不落库(默认落库、留 30 天,见第 5 节)。其三,一个会话一个实例,Web 服务里把它挂在用户会话或 Redis 上,就完成了状态的外置。

3、历史膨胀的 token 曲线

拼接的代价要算两笔账。单次请求的输入随轮次线性增长;而账单按"每次请求"累计,线性乘轮次就是平方级。设 system 30 token、每轮 user 30、assistant 200(不含推理消耗),增长规模如下:

轮次 本次输入 token 会话累计输入
1 60 60
5 980 2600
10 2130 10950
20 4430 44900
50 11330 284750

解读这组数字:第 50 轮时单次输入约 1.1 万 token,整个会话的累计输入已近 28.5 万——后 10 轮的增量比前 40 轮加起来还多,这就是平方增长的含义。GPT-6 家族 105 万 token 的窗口(见官方模型文档)几乎不可能被聊爆,所以裁剪的真实动因不是溢出,而是三件事:费用,历史每次重发都要计费;延迟,输入越长首 token 越慢;噪声,陈年细节干扰当前问题。提示缓存能摊薄一部分——较长的重复前缀自动命中缓存,命中部分单价更低,usage.input_tokens_details.cached_tokens 可以观察命中量——但它不改变越聊越贵的趋势。经验阈值:单次输入超过 8K token、或会话累计输入过万,就该考虑下一节的裁剪。

4、两种历史裁剪策略

裁剪只有两个基本动作:丢与缩。滑动窗口负责丢,摘要压缩负责缩,生产里常组合使用。

4.1、滑动窗口

保住 system 与最近 N 轮原文,其余直接丢弃。核心实现如下:

def trim_window(history, keep_rounds=6):
    """一轮 = user + assistant 两条消息"""
    system = [m for m in history if m["role"] == "system"]
    dialogue = [m for m in history if m["role"] != "system"]
    return system + dialogue[-keep_rounds * 2:]   # 按轮边界从尾部切

解读:切窗必须落在轮边界上——从中间劈开会出现 assistant 打头的历史,模型容易接不上话。窗口大小是精度与成本的旋钮:6 轮覆盖绝大多数追问场景,客服查订单这类只关心最近几条的任务可以压到 2 轮。缺点同样直白:窗口外的信息彻底失忆,用户十轮前留下的姓名与约束一并蒸发;而且窗口每滑动一次,请求前缀就变一次,提示缓存的命中率也随之下降,裁剪省下的钱有一部分被缓存失效吃回去。

4.2、摘要压缩

把窗口外的旧轮交给模型压成一段摘要,换一种形态留在历史里。给第 2 节的 Chat 类加一个方法:

def summarize(self, keep_rounds=2):
    dialogue = [m for m in self.history if m["role"] != "system"]
    old = dialogue[:-keep_rounds * 2]
    recent = dialogue[-keep_rounds * 2:]
    if not old:
        return                       # 没有可压缩的内容
    text = "\n".join(f'{m["role"]}: {m["content"]}' for m in old)
    resp = self.client.responses.create(
        model="gpt-6.1-luna",        # 压缩是简单任务,用低成本档
        input="把以下对话压成 150 字内要点,保留人名、数字与结论:\n" + text,
    )
    self.history = ([self.history[0],
        {"role": "assistant", "content": f"此前对话摘要:{resp.output_text}"}]
        + recent)

解读:压缩本身也是一次 API 调用,所以特意用 Luna 档——摘要任务便宜档完全胜任,这是本系列第 2 篇选型结论的直接应用。摘要以 assistant 消息的形态插在 system 之后,模型读来是一段"自己说过的话"。三个要点:摘要是有损压缩,人名、数字、结论要在提示词里显式要求保留;按阈值触发而不是每轮触发,压缩太频繁会花冤枉钱;压缩后逐字原文没了,涉及合规留存的场景要把原文另存一份。

注意:两种策略不互斥。常见组合是"摘要兜底加窗口保底"——先压缩旧轮,压缩后仍超阈值再裁窗口,两层防线把单次输入钉在预算内。

5、服务端托管续接模式

第二条路线把拼接交给服务端:第一轮正常调用,之后每轮只发新输入,previous_response_id 指向上一轮响应的 id。最小示例如下:

from openai import OpenAI

client = OpenAI()

first = client.responses.create(
    model="gpt-6.1-terra",
    input="用一句话解释什么是进程。",
)
print(first.output_text)

second = client.responses.create(
    model="gpt-6.1-terra",
    previous_response_id=first.id,   # 服务端据此找到历史
    input="它和线程是什么关系?",     # 只发新增的这条消息
)
print(second.output_text)

解读:第二轮请求体里没有历史,input 只带新问题;服务端凭 id 找到上一条响应,把链上输入自动拼成完整上下文。续接的前提是上一轮响应存在服务端——store 参数默认 True,响应对象默认留存 30 天,可随时在控制台日志页查回;置为 False 的响应不落库,链到它这里就断了。托管模式下的三方分工如下:

sequenceDiagram participant A as 应用 participant S as Responses API A->>S: 首轮请求(仅新消息) S-->>A: 响应一(id=resp_1 落库) A->>S: 次轮请求(新消息 + id) S->>S: 凭 id 拼接链上历史 S-->>A: 响应二(自带完整上下文)

从时序图看,应用每轮只携带增量,拼接的活全部发生在服务端。最容易想当然的是成本,官方文档的原话值得抄在这里:

即使使用 previous_response_id,链上此前所有响应的输入 token 仍会按输入 token 计费。

也就是说托管省的是你的代码与传输体积,不省模型要读的历史——聊到第 50 轮,模型照样要读前 49 轮。真正要掂量的是隐私:默认模式下对话明文在 OpenAI 服务端留 30 天,而 store=False 与续接互斥,要零留存只能回本地拼接。此外还有 Conversations API(见 Responses API 参考):把会话做成带独立 id 的持久对象,responses.create 传 conversation=会话 id 即可挂靠,会话内条目不受 30 天 TTL 限制,适合跨会话、跨设备的长程对话;官方也在推进服务端压缩(compaction),把裁剪也交给服务端做,本文成稿时较新,生产链路建议仍以第 4 节的本地方案为主。托管的甜区是快速原型与短生命周期会话;多数团队最终落在本地拼接上,原因就一条——状态在自己手里,才有第 4 节那些旋钮。

6、用 usage 统计会话消耗

无论哪条路线,每轮的真实开销都记录在响应的 usage 对象里。Responses API 的一次 usage 摘录如下:

{
  "input_tokens": 520,
  "input_tokens_details": { "cached_tokens": 260 },
  "output_tokens": 186,
  "output_tokens_details": { "reasoning_tokens": 0 },
  "total_tokens": 706
}

解读:input_tokens 是本次全部输入(含历史),cached_tokens 标出其中命中缓存的部分,是观察"历史重发到底多花钱"的直接窗口;reasoning_tokens 记录推理模型藏在输出里的思考消耗。两条接口的字段命名不同,对照如下:

接口 输入字段 输出字段 合计字段
Responses input_tokens output_tokens total_tokens
Chat Completions prompt_tokens completion_tokens total_tokens

看旧教程或旧代码时按这张表对号入座即可。给 Chat 类加上会话级累计——在 __init__ 里把 in_total 与 out_total 初始化为 0,改写后的 ask 与新增的 report 如下:

def ask(self, user_text):
    self.history.append({"role": "user", "content": user_text})
    resp = self.client.responses.create(
        model=self.model, input=self.history)
    self.in_total += resp.usage.input_tokens    # 累计输入
    self.out_total += resp.usage.output_tokens  # 累计输出
    answer = resp.output_text
    self.history.append({"role": "assistant", "content": answer})
    return answer

def report(self):
    return f"会话累计:输入 {self.in_total} / 输出 {self.out_total} tokens"

解读:report 每轮打一行,配合第 3 节的增长表,裁剪阈值就有了数据依据;线上则把这两个数接进监控系统,按会话设预算告警,超限自动触发压缩或收口。

7、带摘要压缩的 CLI 聊天

把全文的零件装进一个可直接运行的脚本:多轮循环、超长自动压缩、退出时报账。完整代码如下:

from openai import OpenAI

client = OpenAI()
history = [{"role": "system", "content": "你是一位简洁的终端助手"}]
in_total = 0

def compress(old_msgs):
    text = "\n".join(f'{m["role"]}: {m["content"]}' for m in old_msgs)
    resp = client.responses.create(
        model="gpt-6.1-luna",
        input="把以下对话压成 150 字内要点,保留人名、数字、结论:\n" + text)
    return resp.output_text

while (q := input("\n你> ").strip()) not in ("exit", "quit", ""):
    history.append({"role": "user", "content": q})
    dialogue = history[1:]
    if len(dialogue) > 12:                    # 超过 6 轮触发压缩
        summary = compress(dialogue[:-4])     # 旧轮交给摘要
        history = ([history[0],
                    {"role": "assistant", "content": f"此前摘要:{summary}"}]
                   + dialogue[-4:])           # 最近两轮留原文
    resp = client.responses.create(model="gpt-6.1-terra", input=history)
    in_total += resp.usage.input_tokens
    history.append({"role": "assistant", "content": resp.output_text})
    print(f"助手> {resp.output_text}")

print(f"[账单] 累计输入 {in_total} tokens")

解读三个设计点。其一,walrus 表达式把空行与退出命令统一收进循环条件,循环体内只留正事。其二,压缩触发用消息条数近似,生产建议换成第 6 节的 token 口径——条数看不出一条消息里藏了多少 token。其三,dialogue[:-4] 保最近两轮原文,压缩后历史回落到 6 条上下,输入长期钉在低水位。跑起来聊上十几轮再退出,对比账单数字与第 3 节的增长表,裁剪的价值一目了然;更多会话管理示例可翻 OpenAI Cookbook。

8、总结

无状态的本质没有变,变的是状态放哪:本地拼接给足控制权与裁剪空间,配 store=False 可零留存;previous_response_id 托管省代码,但对话默认留 30 天、链上历史照常计费。输入随轮次线性涨、会话累计按平方涨,裁剪的真实动因是费用、延迟与噪声;滑动窗口丢旧轮、摘要压缩缩旧轮,组合成两层防线最稳;usage.input_tokens 与 cached_tokens 是一切监控的起点。对于要上生产的会话型应用,建议默认本地拼接,配摘要压缩与 token 预算告警,隐私硬约束再加 store=False;快速验证阶段用托管起步,不丢人。

留一道练习:把第 7 节脚本的触发条件从"消息条数超过 12"改成"上一轮 resp.usage.input_tokens 超过 4000",用真实 token 数而非条数触发,更贴近计费口径。改完各跑一次 20 轮对话,对比两版累计输入——阈值设计直接决定账单量级。想加难度,再给脚本加一个 /compact 命令,让用户能手动触发压缩。

相关推荐

获取系统SDK版本、判断手机ROM
精选Code

获取系统SDK版本、判断手机ROM

Build获取系统SDK版本Android中部分API的使用,需要在特定的SDK版本之后才能使用,因此在兼容老版本SDK的时候,经常需要判断API的版本。各种Android版本的对应关系参考《Android各版本对应的SDK版本》判断手机ROM有时候需要判断手机系统的ROM,检测ROM是MIUI、EMUI还是Flyme,可以使用getprop命令,去系统build.prop文件(关于build.p

4.2k
AndroidStudio中各种中文乱码问题
精选Code

AndroidStudio中各种中文乱码问题

AndroidStudio中各种中文乱码问题1、编译Java错误信息乱码在出现这个Annotation processors must be explicitly declared now...问题的时候,正好也发现AndroidStudio Build Output错误信息都乱码。经常遇到各种问题,习惯了,现在遇到的问题,都是以后的答案。 1.1、修改项目build.gradle(无效)在整个p

2.8k
PydanticAI 详解:类型安全的 Python AI SDK
精选AI

PydanticAI 详解:类型安全的 Python AI SDK

PydanticAI 详解:类型安全的 Python AI SDK写 LLM 应用最疼的两件事:模型返回的 JSON 不合预期、换一家模型商要改一串代码。pydantic/pydantic-ai(PydanticAI)用 Pydantic 团队的老本行回答了第一件——输出用 BaseModel 定义、运行前先校验,错误从运行时提前到编码时;第二件则用"模型即字符串"回答:换个厂商只改一个参数。如今

101