聊天产品从 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 的响应不落库,链到它这里就断了。托管模式下的三方分工如下:
从时序图看,应用每轮只携带增量,拼接的活全部发生在服务端。最容易想当然的是成本,官方文档的原话值得抄在这里:
即使使用
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 命令,让用户能手动触发压缩。

