LLM 上下文窗口快满了?我在生产踩过的3种应对方案与坑

LLM 上下文窗口溢出示意

上下文越来越长,模型却开始答非所问

用 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 动态检索 知识密集型、长文档问答 按需取用,最精准 实现复杂,依赖检索质量

现在你可以做什么

不要等到线上爆了才处理。从今天开始:

  1. 在对话类应用里加入 token 计数器,接近窗口 70% 时主动触发告警
  2. 滑动窗口方案最容易落地,先跑一个 MVP 看用户反馈
  3. 如果你在做一个需要"记住三个月前说过什么"的应用,RAG 是你最终要上的方案

上下文管理没有银弹。选哪个方案,取决于你的业务需要模型"记多久"和"记多准"。

© 版权声明
THE END
喜欢就支持一下吧
点赞15 分享