Appearance
08|会话状态:多轮怎么接上
第一阶段的工具调用跑通了单轮。但真实排查是多轮的:第一轮问"Redis 延迟高怎么排查",模型回答查 slowlog、检查 BGSAVE、检查内存淘汰;第二轮问"刚才第三条怎么查?"。
脚本只发第二句话时,模型不知道"第三条"指什么——请求里根本没有第一轮内容。本篇解决多轮对话怎么不丢上下文。
API 本身无状态
先建立一个认知:API 本身是无状态的。每一次请求对模型来说都是全新的,它看不到上一次请求说了什么。多轮对话能接上,不是模型"记住"了,是某一侧把历史重新交给了模型。

问题是历史交给谁存。这正是 OpenAI Responses 和 Anthropic Messages 最大的结构差异。
OpenAI Responses:服务端可以帮你存
OpenAI Responses 默认帮你存对话。第一轮问完,OpenAI 给一个 id。第二轮把上一轮的 id 传回去,OpenAI 自己把上一轮的内容接上,客户端不用存历史:

python
r1 = client.responses.create(
model=settings.model,
input="Redis 延迟高怎么排查?列出 3 条检查项。",
)
r2 = client.responses.create(
model=settings.model,
input="刚才第三条怎么查?",
previous_response_id=r1.id, # OpenAI 回放 r1 的内容
)previous_response_id 是最省事的方式,客户端只存一个 id,不用维护历史列表。链式调用:r1.id → r2.id → r3.id,每次传上一次的 id,服务端沿链回溯整段对话。
这条路有两个隐形成本,设计阶段就要知道。
第一,每一轮的输入 token 都按完整历史计费。服务端帮你存了,但回放时这些 token 仍算你的输入。对话越长,每轮的输入成本越高。
第二,previous_response_id 不携带上一轮的顶层 instructions。第一轮设的系统指令,第二轮必须重新传,否则丢失。上面 r2 没传 instructions 是简化示例,生产里每轮都要重发。
手工回放:自己管上下文
previous_response_id 把上下文管理外包给服务端,但你没法裁剪——历史只会越来越长。需要自己控制上下文(截断早期、压缩中间、保留最近)时,把上一轮的 response.output items 回放进下一轮 input:
python
items = [{"role": "user", "content": "Redis 延迟高怎么排查?列出 3 条检查项。"}]
r1 = client.responses.create(model=settings.model, input=items)
items += r1.output # 把模型输出回放进列表
items.append({"role": "user", "content": "刚才第三条怎么查?"})
r2 = client.responses.create(model=settings.model, input=items)
items += r2.output这相当于自己维护完整历史,每轮全量传。需要压缩或截断时,操作的是 items 列表本身——这是后面压缩策略篇的基础。
store=false:合规场景
有些场景不能让服务端存任何对话内容。ZDR(零数据留存)合规要求服务端不留存,这时加 store=false 关掉留存。
store=false 之后 previous_response_id 这条路就断了——服务端没存东西可回放,必须退回手工回放。推理模型还要配合 encrypted reasoning items,在无状态下把推理状态加密回传,否则跨轮推理断裂。
Anthropic Messages:纯无状态
Anthropic Messages API 不帮你存。每一轮都得由客户端把整段对话历史传回去,没有 previous_response_id 这种东西:
python
r1 = client.messages.create(
model="claude-...", # 以账号可用模型为准
max_tokens=1024,
messages=[{"role": "user", "content": "Redis 延迟高怎么排查?"}],
)
r2 = client.messages.create(
model="claude-...",
max_tokens=1024,
messages=[
{"role": "user", "content": "Redis 延迟高怎么排查?"},
{"role": "assistant", "content": r1.content},
{"role": "user", "content": "刚才第三条怎么查?"},
],
)意思是:如果代码假设了"传个 id 就能接上对话",搬到 Anthropic 上会丢上下文。要同时支持两家,对话历史这件事就得客户端自己维护,把 OpenAI 帮存这个能力当成可选优化,而不是默认形态。
Anthropic 还有个坑:max_tokens 是必填,不传直接报错。OpenAI 的 max_output_tokens 不是必填。要同时支持两家,把"最大输出"做成必填配置项最稳。
从历史列表到会话状态单元
生产环境里,"会话"不是一份消息列表,而是一个可恢复、可审计的状态单元。除了发给模型的历史,还要存审计用的 trace、累计的 token 消耗、轮数计数:

python
class SessionState:
def __init__(self, session_id: str, topic: str):
self.session_id = session_id
self.topic = topic
self.messages = [] # 发给模型的历史
self.trace = [] # 完整工具调用记录,审计用,不发给模型
self.summary = "" # 早期对话的摘要
self.turn_count = 0 # 循环终止判断用
self.usage = {"input_tokens": 0, "output_tokens": 0}
def add_turn(self, user_msg, assistant_msg, tool_calls=None):
self.messages.append({"role": "user", "content": user_msg})
self.messages.append({"role": "assistant", "content": assistant_msg})
if tool_calls:
self.trace.extend(tool_calls)
self.turn_count += 1messages 是发给模型的上下文;trace 是完整工具调用记录,用于审计,不发给模型——工具返回的大段日志原文存这里,不进 messages。turn_count 用于循环终止判断。
会话主题隔离
聊久之后,历史会混在一起。前 5 轮查 Redis 延迟,6-8 轮查 nginx 502,第 9 轮又问 Redis slowlog。不同故障的细节互相干扰,模型可能把 nginx 的时间窗口写进 Redis 报告。
这不是上下文太长的技术问题,是会话主题漂移的管理问题。处理方法是给每个故障开独立会话,每个会话对应一条独立的 previous_response_id 链或独立的 items 列表。切换话题时重置历史,不让多个主题共享同一条链。
会话落盘
会话状态要持久化,进程重启后能恢复:
python
import json
from pathlib import Path
def save_session(state: SessionState) -> None:
Path("sessions").mkdir(exist_ok=True)
record = {
"session_id": state.session_id,
"topic": state.topic,
"summary": state.summary,
"messages": state.messages,
"trace": state.trace,
"turn_count": state.turn_count,
"usage": state.usage,
}
Path(f"sessions/{state.session_id}.json").write_text(
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8",
)会话落盘不是每次请求都写一次磁盘。频繁写盘引入 I/O 延迟和并发问题。内存中维护状态,每几轮或会话结束时批量落盘;关键操作(写操作工具执行前)强制同步落盘。
多 Worker 共享会话时必须处理竞态。两个 Worker 同时加载同一份会话,各自修改后写回,后写的覆盖先写的。用 Redis 或数据库做集中式状态存储,文件系统只作冷备份。
会话之后
会话状态管住了——历史要么服务端托管、要么客户端维护,多轮不再丢上下文。
但每轮请求放什么材料,还是要设计。除了对话历史,还有工具返回的大段日志、检索到的 Runbook 片段、动态的项目状态。这些材料怎么选、怎么排、哪些不能让模型看到,是下一篇上下文组装要解决的。