LLM 上下文窗口溢出维修实录:3个真实踩坑案例与工程解法

LLM上下文窗口溢出维修实录封面图

问题:上下文窗口不是无限的

上线客服机器人三个月后,用户开始反馈"答非所问"。查日志发现单会话 token 数已经突破 18 万——上下文窗口爆了,最关键的用户订单信息被截断在最前面,模型根本"看不到"最近几轮对话的内容。

这不是个例。上下文窗口管理是所有 LLM 应用必经的工程坎。本文从三个真实踩坑案例出发,给出可复制的解法。

坑1:对话历史无限累积,窗口被灌爆

最常见的死法:对话历史无上限地往 context 里塞,直到触发 4096 或 128k token 限制。

# 典型错误代码:无限追加
messages = []
for turn in conversation_history:
    messages.append({"role": "user", "content": turn["user"]})
    messages.append({"role": "assistant", "content": turn["assistant"]})
# 没有任何截断逻辑,context 必然爆炸

实测发现:GPT-4 Turbo 128k 窗口看似很大,但 100 轮对话(含详细上下文)就能塞满 70%。超过 90% 之后模型开始"忘记"最近的信息——不是线性遗忘,是突然断层。

坑2:滑动窗口"一刀切",把关键信息切没了

有人用简单的滑动窗口:只保留最近 N 条消息或最近 4k token。看起来解决了问题,但踩到这个坑:

# 错误:只保留最近 N 条
window_size = 10
messages = conversation_history[-window_size:]
# 问题:最近10条里可能不包含用户的核心诉求

真实案例:用户问"我上周五的订单什么时候发货",滑动窗口只保留最近10条,刚好覆盖的是"确认收货地址"的流程,而历史里有"订单号 20230517J"。关键信息被切掉了。

正确做法:分离关键信息池。用户 ID、订单号、当前问题摘要永远置顶,其余才用滑动窗口。

# 正确:关键信息置顶
SYSTEM_PROMPT = """当前用户ID: {user_id}
当前订单号: {order_id}
用户当前问题: {current_issue}
"""
messages = [
    {"role": "system", "content": SYSTEM_PROMPT},
    {"role": "user", "content": recent_history},
]

坑3:RAG 检索到相似内容,但向量相似度欺骗了你

上了 RAG 之后,以为能精准召回相关内容。实测发现一个诡异现象:

用户问"工单处理进度",系统检索回来的内容排名第一的是"订单查询"相关,相似度 0.73。而真正相关的"工单系统"内容相似度只有 0.68。差 0.05,模型拿到的却是错误语境。

# 问题出在向量模型对中文近义词的区分能力
# "工单" vs "订单":字形接近,向量空间中可能被判定为相似
# 但语义完全不同——一个是售后流程,一个是购买流程

# 解法:用 HyDE(假设答案检索)
# 先生成一个"工单处理进度"的假设回答
hypothetical_answer = llm.generate(f"用一句话描述:用户问'{query}'时,正确的工单回复内容")
# 用这个假设答案去检索,而不是直接用用户问题
results = vector_db.search(hypothetical_answer)

三个工程解法,直接抄

方案1:滑动窗口 + 关键信息池(轻量)

import tiktoken

def build_context(conversation_history, current_issue, user_id, order_id, max_tokens=4000):
    enc = tiktoken.get_encoding("cl100k_base")

    # 关键信息永远置顶,不受 token 限制
    system = f"[关键信息] user_id={user_id} order_id={order_id} 当前问题={current_issue}"
    system_tokens = len(enc.encode(system))

    # 可用 token 预算
    budget = max_tokens - system_tokens - 200  # 留 200 buffer

    # 从最新往最老扫描,凑满 budget
    context_messages = []
    total = 0
    for msg in reversed(conversation_history):
        msg_tokens = len(enc.encode(msg["content"]))
        if total + msg_tokens > budget:
            break
        context_messages.insert(0, msg)
        total += msg_tokens

    return [{"role": "system", "content": system}] + context_messages

方案2:语义摘要双轨(适合长会话)

def summarize_and_compress(conversation_history, llm):
    """
    对话历史超过阈值时,用语义摘要压缩
    摘要只保留:用户目标、已确认信息、待处理事项
    """
    if len(conversation_history) <= 5:
        return conversation_history

    raw_history = "\n".join([
        f"用户: {m['content']}" if m['role'] == 'user' else f"助手: {m['content']}"
        for m in conversation_history
    ])

    summary_prompt = f"""将以下对话压缩为简短摘要,保留:
1. 用户核心诉求
2. 已确认的关键信息(订单号、地址、时间等)
3. 待解决的问题

对话:
{raw_history}

摘要格式:目标|已确认|待处理"""

    summary = llm.invoke(summary_prompt)

    return [
        {"role": "system", "content": f"[历史摘要] {summary}"},
        {"role": "user", "content": "继续上文的待处理问题"}
    ]

方案3:RAG + HyDE(适合知识库问答)

def hyde_search(query, vector_db, llm):
    # Step 1: 生成假设答案
    hypothetical = llm.invoke(
        f"针对问题「{query}」,写一段100字以内的标准答案:"
    )

    # Step 2: 用假设答案检索(召回比直接用 query 高 15-20%)
    results = vector_db.similarity_search(hypothetical, top_k=3)

    # Step 3: 将真实答案与假设答案一起交给模型判断
    return results

# 验证:HyDE vs 直接检索
# 直接检索"工单处理" → 召回"订单查询"(错误)
# HyDE 检索"工单处理" → 生成假设答案"关于工单号的处理进度说明" → 召回"工单系统"(正确)

方案对比

方案 适用场景 延迟 成本
滑动窗口+关键池 简单客服、单轮问答
语义摘要双轨 长会话、多轮对话 中(多一次 LLM 调用)
RAG+HyDE 知识库、企业文档 高(2次检索)

现在你可以做什么

第一步:检查你的代码里是否有 messages.append() 无限累积的模式。有的话立即加 token 预算检查。

第二步:如果上了 RAG,用"工单"和"订单"这类近义词测试一下你的召回效果。如果相似度差值小于 0.1,考虑上 HyDE。

第三步:上了语义摘要的同学,注意监控摘要的 token 消耗——有时候摘要本身就把 context 占满了,等于没压缩。

上下文窗口管理的本质是:在有限 token 内,让模型始终能看到最相关的信息。这不是调参数能解决的,是架构设计问题。

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