
上下文越来越长,模型却开始答非所问
用 LLM 做应用开发,最怕的不是模型不够聪明,而是上下文窗口越来越不够用。
我接手过一个客服机器人项目,上线第一个月效果很好。第三个月开始,用户频繁反馈"答非所问"。查了一圈日志,发现对话历史已经塞进了超过十几万 token,模型收到的上下文早已超出窗口限制,关键信息被截断——模型根本看不到最核心的用户问题。
这不是个例。GPT-4 Turbo 有 128k token,Claude 3.5 有 200k token,听起来很大。但当对话历史跑上几个月,加上 RAG 检索结果,上下文增长速度远超预期。
本文不解释"什么是上下文窗口",而是解决一个具体问题:当上下文快要溢出时,哪些方案可以真正落地?各有啥坑?
方案一:滑动窗口(Sliding Window)—— 最朴素但最常用
思路很简单:只保留最近 N 条对话,旧的直接丢弃。
import os
from anthropic import Anthropic
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
def chat_sliding_window(messages, max_messages=20):
"""只保留最近 max_messages 条消息"""
return messages[-max_messages:]
messages = [
{"role": "user", "content": "我要退订服务"},
{"role": "assistant", "content": "好的,请问是什么原因?"},
{"role": "user", "content": "太贵了用不起"},
]
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=chat_sliding_window(messages, max_messages=20)
)
踩坑点:这个方案最大的坑是"一刀切"。假设用户问的是三个月前的订单,但对话窗口只保留最近20条——模型根本不知道这件事发生过,直接拒绝处理。
改进做法:维护一个"关键信息"池,独立于滑动窗口之外。
KEY_CONTEXT_MAX_TOKENS = 4000 # 独立于滑动窗口的关键信息上限
def build_final_prompt(user_input, chat_history, key_context):
"""
key_context: 用户信息、长期偏好等独立于滑动窗口的关键上下文
chat_history: 最近对话(滑动窗口过滤后)
"""
# token 估算:中文约 1 token / 1.5 字,英文约 1 token / 4 字符
# 这里用简化公式:4 字符约等于 1 token
key_len = len(key_context) // 4
history_len = sum(len(m["content"]) for m in chat_history) // 4
if key_len + history_len > KEY_CONTEXT_MAX_TOKENS:
# 超限就压缩关键上下文
key_context = compress_by_token(key_context, KEY_CONTEXT_MAX_TOKENS - history_len)
system_prompt = "[用户关键信息]\n" + key_context + "\n\n[近期对话]"
return [{"role": "system", "content": system_prompt}] + chat_history + [{"role": "user", "content": user_input}]
方案二:语义摘要(Summarization)—— 长期记忆的近似解
滑动窗口的问题是"丢弃",摘要的做法是"压缩"——用模型把历史对话浓缩成几条关键信息。
def summarize_history(messages, model="claude-sonnet-4-20250514"):
"""把对话历史浓缩成摘要"""
history_text = "\n".join(
f"{m['role']}: {m['content']}" for m in messages
)
summary_prompt = (
"请把以下对话的要点提炼成3-5条,每条不超过50字。保留关键决策、用户偏好、待办事项。\n\n"
+ history_text
)
response = client.messages.create(
model=model,
max_tokens=512,
messages=[{"role": "user", "content": summary_prompt}]
)
return response.content[0].text
def should_summarize(messages, threshold=30):
"""超过 threshold 条消息时触发摘要"""
return len(messages) > threshold
实测踩的两个坑:
第一坑:摘要本身也要消耗 token。 我第一次实现时,每30条消息触发一次摘要,摘要生成本身又要花几十k token。结果总成本不降反升,因为模型跑了两次。正确做法是严格控制摘要频率(比如每100条才触发一次)和输出长度。
第二坑:摘要丢失细节。 用户的原始表述是"我要退订",摘要后变成"用户有退订意向"——"意向"和"正式申请"在法律意义上完全不同。下游业务逻辑基于摘要判断时,就会出现语义偏差。
正确做法:摘要 + 原文双轨。 向量数据库里保留原始对话片段,摘要只用于快速判断,真正需要细节时召回原文。
# 摘要层 + 召回层分离
vector_store = ChromaCollection("chat_history")
summaries = [] # 轻量摘要列表
def add_message(role, content):
# 1. 原文直接存向量
vector_store.add_texts([content], metadatas=[{"role": role}])
# 2. 更新摘要
if summaries:
new_summary = summarize_history([*summaries, {"role": role, "content": content}])
summaries.append(new_summary)
else:
# 第一条直接截断保留,不走摘要
summaries.append(content[:200])
def retrieve_context(query, top_k=5):
"""查询时同时召回摘要和原文"""
summary_results = search_vector(summaries, query, top_k=2)
raw_results = vector_store.similarity_search(query, k=top_k)
return {"summary": summary_results, "raw": raw_results}
方案三:RAG 动态检索(Dynamic Retrieval)—— 最优雅但最复杂
不把历史全部塞给模型,而是在每次请求时,根据当前问题从历史中检索相关内容。
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma
def build_rag_retriever(persisted_chats):
"""persisted_chats: 持久化的对话历史字典,key 是 chat_id"""
vectorstore = Chroma(embedding_function=OpenAIEmbeddings())
store = InMemoryStore()
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=store,
child_splitter=RecursiveCharacterTextSplitter(chunk_size=500),
parent_splitter=RecursiveCharacterTextSplitter(chunk_size=5000),
search_kwargs={"k": 5}
)
for chat_id, messages in persisted_chats.items():
for msg in messages:
retriever.add_documents([Document(
page_content=msg["content"],
metadata={"chat_id": chat_id, "role": msg["role"]}
)])
return retriever
def rag_chat(user_input, chat_history, persisted_chats, retriever):
"""RAG 增强的对话"""
# 1. 检索相关历史
relevant = retriever.get_relevant_documents(user_input)
# 2. 构造带上下文的 prompt
context = "\n".join(doc.page_content for doc in relevant)
prompt = (
"根据以下相关对话历史回答用户问题。如果历史中无相关信息,请基于你的知识回答。\n\n"
"[相关历史]\n" + context + "\n\n[当前问题]\n" + user_input
)
# 3. 生成回复(保留最近几条保证连贯)
messages = [{"role": "user", "content": prompt}]
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=messages
)
return response.content[0].text
动态检索最大的坑:检索质量决定一切。
我遇到过这个问题:用户说"接着上次那个订单继续",系统检索不到任何相关内容——因为历史里写的是"工单",不是"订单",两个词语义接近但向量相似度只有 0.3,检索不到。
解决方案是用 HyDE(Hypothetical Document Embeddings)——先让模型生成一个假设答案,用这个假设答案去匹配真实文档,比直接用原问题匹配效果好得多。
def hyde_retrieve(query, retriever, hyde_model="claude-sonnet-4-20250514"):
"""HyDE: 先让模型生成假设答案,再检索"""
hyde_prompt = "假设你是客服,请针对以下问题写一个你认为合理的回答:\n" + query
hyde_response = client.messages.create(
model=hyde_model,
max_tokens=256,
messages=[{"role": "user", "content": hyde_prompt}]
)
hypothetical_answer = hyde_response.content[0].text
# 用假设答案检索
results = retriever.get_relevant_documents(hypothetical_answer)
# 再用原问题检索,取并集去重
original_results = retriever.get_relevant_documents(query)
merged = {doc.metadata["id"]: doc for doc in results}
for doc in original_results:
merged[doc.metadata["id"]] = doc
return list(merged.values())
三种方案对比
| 方案 | 适用场景 | 最大优点 | 最大缺点 |
|---|---|---|---|
| 滑动窗口 | 短期对话、客服类场景 | 实现简单,无额外成本 | 丢失远期信息,无法处理跨会话推理 |
| 语义摘要 | 长期陪伴型 AI、固定领域 | 保留关键信息,可压缩 | 摘要失真、有延迟、成本双倍 |
| RAG 动态检索 | 知识密集型、长文档问答 | 按需取用,最精准 | 实现复杂,依赖检索质量 |
现在你可以做什么
不要等到线上爆了才处理。从今天开始:
- 在对话类应用里加入 token 计数器,接近窗口 70% 时主动触发告警
- 滑动窗口方案最容易落地,先跑一个 MVP 看用户反馈
- 如果你在做一个需要"记住三个月前说过什么"的应用,RAG 是你最终要上的方案
上下文管理没有银弹。选哪个方案,取决于你的业务需要模型"记多久"和"记多准"。
更多交流点击入群






