Skip to content

08|会话状态:多轮怎么接上

第一阶段的工具调用跑通了单轮。但真实排查是多轮的:第一轮问"Redis 延迟高怎么排查",模型回答查 slowlog、检查 BGSAVE、检查内存淘汰;第二轮问"刚才第三条怎么查?"。

脚本只发第二句话时,模型不知道"第三条"指什么——请求里根本没有第一轮内容。本篇解决多轮对话怎么不丢上下文。

API 本身无状态

先建立一个认知:API 本身是无状态的。每一次请求对模型来说都是全新的,它看不到上一次请求说了什么。多轮对话能接上,不是模型"记住"了,是某一侧把历史重新交给了模型。

AIOps 概念图:API 无状态

问题是历史交给谁存。这正是 OpenAI Responses 和 Anthropic Messages 最大的结构差异。

OpenAI Responses:服务端可以帮你存

OpenAI Responses 默认帮你存对话。第一轮问完,OpenAI 给一个 id。第二轮把上一轮的 id 传回去,OpenAI 自己把上一轮的内容接上,客户端不用存历史:

AIOps 概念图:previous_response_id 链

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 消耗、轮数计数:

AIOps 概念图:会话状态单元

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 += 1

messages 是发给模型的上下文;trace 是完整工具调用记录,用于审计,不发给模型——工具返回的大段日志原文存这里,不进 messagesturn_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 片段、动态的项目状态。这些材料怎么选、怎么排、哪些不能让模型看到,是下一篇上下文组装要解决的。