WorkBuddy_多模型支持与切换5_切换后的上下文行为
切换后的上下文行为
摘要
在多模型(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
实践建议:
- 降级切换(大窗口 → 小窗口) 必须触发压缩,否则必然超限。
- 升级切换(小窗口 → 大窗口) 可以放宽截断,但不要自动“补全”历史——历史本就完整存在,只是之前没发全。
- 把
safety_margin留足,因为不同 tokenizer 对同一段文本的计数可能相差 5%~15%。
四、系统提示词、工具定义与消息格式的重新绑定
模型切换不只是换一个 endpoint。不同模型对以下内容的要求往往不同:
- 系统提示词:有的模型对 system 角色支持弱,需要降级为“首条 user 消息”。
- 工具/函数调用格式:
toolsschema、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 消息。否则新模型可能完全忽略这些约束——这也是“切换后风格突变”的常见根因。
五、流式响应中断与切换时序
如果用户在模型正在流式输出时点击“切换”,会面临一个时序问题:
- 旧模型的流还没结束,已输出的部分内容算不算历史?
- 切换后新模型要不要“接着写”?
推荐的处理原则是:切换时先终止旧流,把已生成的部分作为一条完整的 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 参数,实则牵涉架构分层、上下文压缩、预算重算、提示词与工具适配、流式时序、缓存失效等多个环节。本文的核心结论可以浓缩为以下几点:
- 上下文与模型解耦存储:维护模型无关的规范化对话记录,切换只改“当前模型”指针。
- 历史逻辑保留、物理压缩:全量保留 / 摘要压缩 / 截断三选一,摘要要以 system 补充说明注入。
- 切换即重算预算:按新模型的上下文窗口与输出预留重新计算,降级切换必须触发压缩。
- 系统提示词与工具定义必须重新绑定:不要依赖历史里的旧 system 消息,切换后重新注入约束。
- 流式中断要落库:终止旧流、保存已生成部分为完整消息,再以新模型开新轮次。
- 缓存按模型隔离:Prompt Cache、Embedding 缓存等派生数据在切换时失效或隔离,并主动向用户提示延迟变化。
把这六点落实到代码里,你的多模型产品在“切换”这个高频操作上就能做到行为可预期、可测试、可回滚——而这恰恰是用户信任的基础。
更多推荐


所有评论(0)