切换后的上下文行为

摘要

在多模型(Multi-Model)应用越来越普遍的今天,一个高频且容易被忽视的问题是:当用户在会话中途切换模型时,已有的对话历史是否保留?后续对话又如何生效? 本文从工程实现角度出发,系统剖析“切换后的上下文行为”这一主题,涵盖上下文存储与模型解耦、切换时的历史保留策略、上下文窗口与 Token 预算的重新计算、系统提示词与工具定义的重新绑定、流式响应中断处理,以及缓存与推理状态失效等关键问题,并给出完整可运行的代码示例。无论你是在构建聊天机器人、AI Agent 平台还是企业内部 Copilot,本文都能帮助你设计出行为可预期、可测试、可回滚的模型切换机制。


引言

设想这样一个场景:用户在一个聊天窗口里和 GPT 类模型聊了二十多轮,突然发现某个回答不满意,于是从下拉框切换到另一个模型继续追问。此时系统内部会发生什么?

  • 之前的二十多轮对话,新模型“看得到”吗?
  • 如果看得到,是如何拼接进新模型的请求里的?
  • 如果两个模型的上下文窗口大小不同、消息格式不同、系统提示词要求不同,又该如何处理?
  • 切换瞬间正在流式输出的那条消息怎么办?

这些问题看似琐碎,却直接决定了产品的可用性。很多团队在早期只实现了“把 model 参数换一下”,结果上线后收到大量“换了模型它就不记得我说过什么了”“切换后回答风格突变、丢失约束”的反馈。

本文的核心观点是:上下文应当与模型解耦存储,切换模型时上下文“保留但需要重新适配” 。下面我们逐节展开。


一、核心原则:上下文与模型解耦

要正确处理切换行为,第一步是在架构上把“对话状态”和“模型调用”分开。

一个常见的错误设计是把对话历史直接塞进某个模型 SDK 的 session 对象里,模型一换,session 就失效。正确做法是维护一份模型无关的对话记录(canonical conversation) ,每次调用模型时再把它“翻译”成目标模型所需的请求格式。

from dataclasses import dataclass, field
from typing import Literal, Optional
from datetime import datetime

@dataclass
class Message:
    """模型无关的规范化消息结构"""
    role: Literal["system", "user", "assistant", "tool"]
    content: str
    name: Optional[str] = None          # tool 消息的工具名
    tool_call_id: Optional[str] = None  # 工具调用关联 ID
    created_at: datetime = field(default_factory=datetime.utcnow)
    # 记录这条消息由哪个模型生成,便于审计与回放
    produced_by: Optional[str] = None

@dataclass
class Conversation:
    """会话:只存规范化历史,不绑定任何模型"""
    id: str
    messages: list[Message] = field(default_factory=list)
    current_model: str = "model-a"

    def append(self, msg: Message) -> None:
        self.messages.append(msg)

有了这层抽象,切换模型时我们只需要改 current_model,对话历史原封不动。真正的工作量在下一节:如何把这份历史适配给新模型。


二、切换时历史是否保留:策略与取舍

“保留”并不是一个二元的 yes/no,而是一组策略选择。工程上通常有三种:

策略行为适用场景
全量保留把完整历史重新发送给新模型短对话、对连贯性要求高
摘要压缩把较早历史压缩成摘要,保留最近 N 轮长对话、跨模型窗口差异大
截断保留仅保留最近若干条消息成本敏感、上下文窗口小

需要强调的是:“保留”指的是逻辑上不丢失,物理上可以压缩。用户感知到的应该是“它还记得”,而不是“它必须逐字记住”。

下面是一个带摘要压缩的切换适配器示例:

def build_context_for_model(
    conv: Conversation,
    target_model: str,
    max_tokens: int,
    tokenizer,               # 传入目标模型对应的 tokenizer
    summarize_fn,            # 摘要函数:list[Message] -> str
    keep_recent: int = 6,
) -> list[dict]:
    """
    为指定模型构建请求上下文:
    1. 保留 system 消息
    2. 超出预算时,将早期消息压缩为一条摘要
    3. 始终保留最近 keep_recent 条原始消息
    """
    system_msgs = [m for m in conv.messages if m.role == "system"]
    dialog = [m for m in conv.messages if m.role != "system"]

    def to_payload(msgs):
        return [
            {"role": m.role, "content": m.content}
            | ({"name": m.name} if m.name else {})
            for m in msgs
        ]

    # 先尝试全量
    full_payload = to_payload(system_msgs + dialog)
    if count_tokens(full_payload, tokenizer) <= max_tokens:
        return full_payload

    # 超预算:摘要早期 + 保留最近
    recent = dialog[-keep_recent:]
    early = dialog[:-keep_recent]
    summary_text = summarize_fn(early) if early else ""

    payload = to_payload(system_msgs)
    if summary_text:
        payload.append({
            "role": "system",
            "content": f"以下是此前对话的摘要,请据此保持上下文连贯:\n{summary_text}",
        })
    payload.extend(to_payload(recent))
    return payload


def count_tokens(payload: list[dict], tokenizer) -> int:
    """粗略统计 token 数,实际应使用目标模型的 tokenizer"""
    return sum(len(tokenizer(m["content"])) for m in payload)

关键点:摘要必须由切换前或独立于模型的环节生成,且摘要文本要以“system 补充说明”的形式注入,明确告诉新模型“这是历史摘要”,避免它把摘要当成用户原话。


三、上下文窗口差异与 Token 预算重算

不同模型的上下文窗口差异巨大(例如 8K、32K、128K、200K 不等)。切换时最容易踩的坑是:沿用旧模型的预算假设,导致新模型请求超限报错,或者反过来浪费大量额度。

正确做法是在切换时重新计算预算,并且把“输出预留”也考虑进去:

MODEL_LIMITS = {
    "model-a": {"context": 32_000, "max_output": 4_000},
    "model-b": {"context": 128_000, "max_output": 8_000},
    "model-c": {"context": 8_000,  "max_output": 2_000},
}

def compute_budget(model: str, safety_margin: int = 512) -> int:
    """计算可用于输入的最大 token 数"""
    limits = MODEL_LIMITS[model]
    return limits["context"] - limits["max_output"] - safety_margin

def switch_model(conv: Conversation, new_model: str) -> Conversation:
    """切换模型:更新字段,并校验历史是否可容纳"""
    budget = compute_budget(new_model)
    # 这里只做提示,实际压缩在 build_context_for_model 中完成
    print(f"切换到 {new_model},输入预算 {budget} tokens")
    conv.current_model = new_model
    return conv

实践建议:

  1. 降级切换(大窗口 → 小窗口) 必须触发压缩,否则必然超限。
  2. 升级切换(小窗口 → 大窗口) 可以放宽截断,但不要自动“补全”历史——历史本就完整存在,只是之前没发全。
  3. 把 safety_margin 留足,因为不同 tokenizer 对同一段文本的计数可能相差 5%~15%。

四、系统提示词、工具定义与消息格式的重新绑定

模型切换不只是换一个 endpoint。不同模型对以下内容的要求往往不同:

  • 系统提示词:有的模型对 system 角色支持弱,需要降级为“首条 user 消息”。
  • 工具/函数调用格式:tools schema、tool_calls 字段结构、并行调用支持度都不同。
  • 多模态消息结构:图片是 image_url 还是 base64,字段名各异。

因此切换时需要一层适配器(Adapter) :

class ModelAdapter:
    """把规范化上下文转换为特定模型请求"""
    def __init__(self, model: str):
        self.model = model

    def adapt_system(self, system_text: str) -> list[dict]:
        if self.model == "model-c":
            # 该模型不支持 system 角色,降级为 user 前缀
            return [{"role": "user", "content": f"[系统指令] {system_text}"}]
        return [{"role": "system", "content": system_text}]

    def adapt_tools(self, tools: list[dict]) -> list[dict]:
        if self.model == "model-b":
            # 该模型要求 tools 使用不同字段名
            return [{"type": "function", "function": t} for t in tools]
        return tools

    def build_request(self, conv: Conversation, system_text: str,
                      tools: list[dict], tokenizer, summarize_fn) -> dict:
        budget = compute_budget(self.model)
        history = build_context_for_model(
            conv, self.model, budget, tokenizer, summarize_fn
        )
        # 移除 history 中可能已存在的 system(由 adapt_system 统一注入)
        history = [m for m in history if m["role"] != "system"]
        payload = self.adapt_system(system_text) + history
        return {
            "model": self.model,
            "messages": payload,
            "tools": self.adapt_tools(tools),
        }

重要提醒:系统提示词里的“你是 XXX 助手,回答需遵循 YYY 约束”这类内容,切换后必须重新注入,而不是依赖历史里旧模型的 system 消息。否则新模型可能完全忽略这些约束——这也是“切换后风格突变”的常见根因。


五、流式响应中断与切换时序

如果用户在模型正在流式输出时点击“切换”,会面临一个时序问题:

  1. 旧模型的流还没结束,已输出的部分内容算不算历史?
  2. 切换后新模型要不要“接着写”?

推荐的处理原则是:切换时先终止旧流,把已生成的部分作为一条完整的 assistant 消息落库,再以新模型发起新一轮请求。不要试图让新模型“续写”半句话,因为不同模型的 tokenization 与措辞习惯不同,续写几乎必然割裂。

import asyncio

async def stream_with_switch(conv: Conversation, user_input: str,
                             adapter: ModelAdapter, client):
    """带切换能力的流式调用"""
    conv.append(Message(role="user", content=user_input))
    stream_task = asyncio.create_task(
        client.stream(adapter.build_request(conv, SYSTEM_TEXT, TOOLS,
                                            TOKENIZER, summarize_fn))
    )

    buffer = []
    try:
        async for chunk in await stream_task:
            buffer.append(chunk)
            yield chunk
    except asyncio.CancelledError:
        # 用户切换模型:把已生成内容落库,标记为被中断
        partial = "".join(buffer)
        if partial:
            conv.append(Message(
                role="assistant",
                content=partial + "\n[此回复因切换模型而中断]",
                produced_by=adapter.model,
            ))
        raise
    else:
        conv.append(Message(
            role="assistant",
            content="".join(buffer),
            produced_by=adapter.model,
        ))

这样做的收益是:历史始终是完整且合法的消息序列(不存在“半条 assistant 消息”),新模型拿到的是一个干净的状态。


六、缓存与推理状态失效

最后是一个容易被忽略但影响性能与成本的细节:切换模型会使大量缓存失效。

  • Prompt Cache / KV Cache:多数厂商的上下文缓存按“模型 + 前缀”命中。换模型后前缀缓存必然 miss,首 token 延迟会上升。
  • 会话级缓存:如果你在应用层缓存了“某模型的推理结果”,切换后这些缓存不能再复用。
  • Embedding 缓存:若历史消息做过向量化(用于检索增强),不同模型的 embedding 空间不可混用,切换后需按模型隔离。

工程上的建议:

class CacheKey:
    """缓存键必须包含模型维度"""
    def __init__(self, conv_id: str, model: str, prefix_hash: str):
        self.key = f"{conv_id}:{model}:{prefix_hash}"

    def __str__(self):
        return self.key

# 切换时主动清理或隔离旧模型缓存
def on_model_switch(conv: Conversation, old_model: str, new_model: str, cache):
    cache.invalidate_prefix(f"{conv.id}:{old_model}:")
    # 注意:不要删除历史消息本身,只清理派生缓存

同时,在切换的响应里可以给前端一个提示:“已切换至 X 模型,历史对话已保留,首条回复可能稍慢”,用透明的方式管理用户预期。


总结

“切换后的上下文行为”看似只是换一个 model 参数,实则牵涉架构分层、上下文压缩、预算重算、提示词与工具适配、流式时序、缓存失效等多个环节。本文的核心结论可以浓缩为以下几点:

  1. 上下文与模型解耦存储:维护模型无关的规范化对话记录,切换只改“当前模型”指针。
  2. 历史逻辑保留、物理压缩:全量保留 / 摘要压缩 / 截断三选一,摘要要以 system 补充说明注入。
  3. 切换即重算预算:按新模型的上下文窗口与输出预留重新计算,降级切换必须触发压缩。
  4. 系统提示词与工具定义必须重新绑定:不要依赖历史里的旧 system 消息,切换后重新注入约束。
  5. 流式中断要落库:终止旧流、保存已生成部分为完整消息,再以新模型开新轮次。
  6. 缓存按模型隔离:Prompt Cache、Embedding 缓存等派生数据在切换时失效或隔离,并主动向用户提示延迟变化。

把这六点落实到代码里,你的多模型产品在“切换”这个高频操作上就能做到行为可预期、可测试、可回滚——而这恰恰是用户信任的基础。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐