深入 ‘Context Window Compression’:利用 LLM 将历史对话压缩为‘关键事实矢量’以节省 70% 的 Token 开销

开篇引言:上下文窗口的永恒挑战

各位同仁,大家好。在人工智能,特别是大型语言模型(LLM)的浪潮中,我们正经历着一场前所未有的技术变革。LLM以其强大的语言理解和生成能力,正在重塑人机交互的边界。然而,在享受其带来便利的同时,一个核心的架构限制也日益凸显,成为制约其在长对话、复杂任务中表现的关键瓶颈——那就是上下文窗口(Context Window)

LLM的工作原理是基于其在训练时学习到的语言模式,对输入序列(即上下文)进行理解,并生成下一个最可能的词元(token)。这个输入序列的长度是有限的,由模型的上下文窗口大小决定。目前主流的LLM,如GPT-3.5、GPT-4,其上下文窗口长度从几千到几十万个token不等。表面上看,这似乎很宽裕,但在实际的、持续进行的对话或复杂任务中,这些token很快就会被历史对话、文档内容、指令等填充。

当对话持续进行,历史消息不断累积,最终会超出LLM的上下文窗口限制。一旦超出,模型就无法看到完整的历史信息,导致:

  1. 信息遗忘 (Forgetting Information):LLM无法回忆起对话早期提到的关键信息,导致对话变得不连贯,甚至出现逻辑错误。
  2. 性能下降 (Degraded Performance):在需要长程依赖推理的任务中,模型因缺乏完整上下文而无法给出高质量的回答。
  3. 高昂的成本 (Prohibitive Costs):每次API调用,我们都需要将整个上下文(包括历史对话)发送给LLM。随着对话长度的增加,发送的token数量呈线性增长,导致API费用急剧上升。对于大规模应用而言,这可能是不可承受的开销。
  4. 增加延迟 (Increased Latency):发送和处理大量token会增加API调用的网络传输时间和模型推理时间,影响用户体验。

这些痛点使得LLM在需要记忆、理解和推理长篇对话或文档的场景中,面临着巨大的挑战。为了克服这些挑战,各种上下文管理策略应运而生,而今天我们将深入探讨一种高效且极具前景的方法:利用LLM自身的能力,将历史对话压缩为“关键事实矢量”,以显著节省token开销。

核心理念:从冗余对话到关键事实矢量

我们提出的解决方案核心在于:不是简单地截断历史对话,也不是机械地提取关键词,而是利用LLM强大的语义理解和摘要能力,将冗长、重复、低价值的历史对话内容,智能地提炼、压缩成一系列高度凝练的“关键事实矢量”。

这里的“关键事实矢量”并非传统意义上的数学矢量(如嵌入向量),而是指一系列经过LLM智能处理后,能够代表历史对话核心信息、关键点、用户意图、实体关系等的结构化或半结构化文本信息集合。它们是对话的“DNA”,是高度浓缩的精华,能够以最小的token数量,承载最大的信息量。

例如,一段关于用户预订机票的对话,可能包含寒暄、查询、确认等多个来回。当用户最终确认了航班、日期、乘客信息后,我们可以将这些核心要素(如:目的地:纽约,日期:下周三,乘客:张三,航班号:MU587)提炼出来,而不是保留整个对话的逐字记录。

压缩的目标:

  • 保留核心信息: 确保经过压缩后,对话的关键内容、用户需求、已决定的事项、待办事项等不丢失。
  • 剔除冗余信息: 过滤掉寒暄、重复的确认、无关紧要的细节、语法修正等不影响对话核心逻辑的部分。
  • 结构化表示: 尽可能将提炼出的事实以清晰、易于LLM理解和后续处理的格式呈现(如JSON、列表、关键-值对)。

LLM在此过程中的独特优势:

为什么选择LLM来执行这个压缩任务?因为LLM拥有以下无与伦比的能力,使其成为理想的压缩引擎:

  1. 语义理解能力: LLM能够理解对话的深层含义、上下文关联、用户意图和隐含信息,而不仅仅是字面意思。这使得它能够准确识别哪些是“关键事实”,哪些是“冗余信息”。
  2. 归纳与摘要能力: LLM在大量文本数据上进行过训练,天然具备强大的信息归纳和摘要能力,能够将复杂的信息提炼成简洁明了的表述。
  3. 推理能力: LLM可以根据对话逻辑,推理出一些未明确表达但重要的事实,或修正之前可能存在的模糊信息。
  4. 格式化输出能力: 通过精心的Prompt工程,LLM可以按照我们预设的结构(如JSON、XML、Markdown列表)输出压缩后的事实,这对于后续的程序化处理至关重要。

通过利用LLM的这些能力,我们可以构建一个智能的、自适应的上下文压缩系统,从而显著延长LLM在长对话中的有效记忆,同时大幅削减运营成本。

架构设计:实现高效压缩的工作流

要实现上下文窗口压缩,我们需要一个精心设计的系统架构。以下是一个典型的、模块化的工作流,旨在实现高效的对话历史压缩和管理。

整体系统流程描述:

  1. 用户与系统交互: 用户发起对话,系统将用户的消息和LLM的回复都记录下来。
  2. 对话监控: 系统持续监控当前对话的长度(token数量或消息轮次)。
  3. 压缩触发: 当对话长度达到预设阈值时,触发压缩机制。
  4. 压缩模块(LLM驱动): 将当前累积的完整对话历史(或部分历史)发送给一个专门的LLM实例(或通过特定的Prompt调用主LLM),要求其提炼出“关键事实矢量”。
  5. 事实存储: 将LLM生成的关键事实存储起来,可以是简单的文本列表、结构化数据,甚至是一个本地的向量数据库(用于更复杂的检索)。
  6. 上下文重构: 在下一次LLM调用时,系统不再发送全部历史对话。而是将最新的几轮对话、从事实存储中检索出的相关“关键事实矢量”,以及当前的User Query,组合成一个新的、精简的上下文,发送给LLM。
  7. 循环: 这一过程不断循环,确保LLM始终拥有最新且最相关的上下文,而无需每次都处理冗长的完整历史。

模块分解:

  • ConversationManager (对话管理器): 负责接收用户和LLM的消息,维护完整的对话历史,并计算当前上下文的token数量。
  • CompressionTrigger (压缩触发器): 根据预设规则(如token阈值、消息轮次、时间间隔)决定何时启动压缩。
  • CompressionEngine (压缩引擎): 核心模块,利用LLM进行历史对话的压缩。它负责构建压缩Prompt,调用LLM API,并解析LLM的输出。
  • FactStore (事实存储): 负责存储和管理压缩后生成的“关键事实矢量”。它可能需要提供添加、更新、检索和删除事实的功能。
  • ContextBuilder (上下文构建器): 负责在每次LLM调用前,根据当前对话状态和从FactStore中检索出的事实,构建最终的、精简的LLM输入上下文。

代码示例:基本类结构

为了更好地理解,我们先勾勒出这些模块的Python类结构。

import tiktoken
import openai
import json
from typing import List, Dict, Any, Optional

# 假设的LLM API调用函数
def call_llm_api(prompt: List[Dict[str, str]], model: str = "gpt-4-0125-preview", temperature: float = 0.7) -> str:
    """
    模拟调用LLM API,实际应用中会替换为openai.ChatCompletion.create等。
    """
    # 这里为了演示,我们假设LLM会根据prompt模拟返回一个结果。
    # 实际生产中,会用try-except包裹,处理API错误。
    try:
        response = openai.ChatCompletion.create(
            model=model,
            messages=prompt,
            temperature=temperature,
            response_format={"type": "json_object"} # 假设我们期望JSON输出
        )
        return response.choices[0].message['content']
    except Exception as e:
        print(f"LLM API call failed: {e}")
        return json.dumps({"facts": []}) # 返回空JSON以避免程序崩溃

class TokenCounter:
    """
    一个简单的Token计数器,使用tiktoken库。
    """
    def __init__(self, model_name: str = "gpt-4-0125-preview"):
        self.encoding = tiktoken.encoding_for_model(model_name)

    def count_tokens(self, text: str) -> int:
        return len(self.encoding.encode(text))

    def count_message_tokens(self, messages: List[Dict[str, str]]) -> int:
        """
        计算messages列表中的总token数。
        基于OpenAI官方示例的token计数逻辑。
        """
        num_tokens = 0
        for message in messages:
            num_tokens += 4  # every message follows <im_start>{role/name}n{content}<im_end>n
            for key, value in message.items():
                num_tokens += self.count_tokens(value)
                if key == "name":
                    num_tokens += -1  # role/name are always accompanied by less than 1 token
        num_tokens += 2  # every reply is primed with <im_start>assistant
        return num_tokens

class ConversationManager:
    """
    管理对话历史和当前上下文。
    """
    def __init__(self, max_history_messages: int = 20):
        self.full_history: List[Dict[str, str]] = []
        self.max_history_messages = max_history_messages # 最多保留多少条原始消息用于压缩或最新上下文

    def add_message(self, role: str, content: str):
        self.full_history.append({"role": role, "content": content})

    def get_full_history(self) -> List[Dict[str, str]]:
        return self.full_history

    def get_recent_history(self, num_messages: int) -> List[Dict[str, str]]:
        """
        获取最近的N条消息。
        """
        return self.full_history[-num_messages:]

class FactStore:
    """
    存储和管理压缩后的关键事实。
    这里使用一个简单的列表来存储事实,每个事实可以是文本字符串或结构化数据。
    """
    def __init__(self):
        self.facts: List[Dict[str, Any]] = [] # 存储结构化的事实,例如 {"id": "f1", "content": "用户预订了去纽约的机票", "timestamp": "..."}

    def add_fact(self, fact_content: str, fact_type: str = "general"):
        """
        添加一个事实。这里简化为文本内容,实际可以更复杂。
        """
        fact_id = f"fact_{len(self.facts) + 1}"
        self.facts.append({"id": fact_id, "content": fact_content, "type": fact_type})
        print(f"Fact added: {fact_content}")

    def get_all_facts(self) -> List[Dict[str, Any]]:
        """
        获取所有存储的事实。
        """
        return self.facts

    def retrieve_facts_by_query(self, query: str, top_k: int = 3) -> List[Dict[str, Any]]:
        """
        基于当前用户查询检索最相关的事实。
        这里是一个非常简化的文本匹配,实际应用中会使用向量搜索 (RAG)。
        """
        relevant_facts = []
        # 实际这里会使用 embeddings 和向量相似度搜索
        # for fact in self.facts:
        #     if query.lower() in fact['content'].lower(): # 简单关键词匹配
        #         relevant_facts.append(fact)
        # 为了演示,我们直接返回所有事实,假设它们都相关
        relevant_facts = self.facts 

        # 模拟按相关性排序并返回top_k
        if len(relevant_facts) > top_k:
            return relevant_facts[:top_k]
        return relevant_facts

class CompressionEngine:
    """
    负责执行对话压缩的核心引擎。
    """
    def __init__(self, token_counter: TokenCounter, llm_model: str = "gpt-4-0125-preview"):
        self.token_counter = token_counter
        self.llm_model = llm_model
        # 压缩Prompt模板,后续会详细设计
        self.compression_prompt_template = """
        你是一个专业的对话分析师,你的任务是从以下对话历史中提取所有关键事实。
        提取的事实应该简洁、客观、去重,并以JSON格式返回,包含一个'facts'键,其值为一个字符串数组。
        每个事实应直接表达一个重要的信息点或用户意图。

        对话历史:
        {dialogue_history}

        请提取关键事实:
        """
        self.system_message = {"role": "system", "content": "你是一个专业的对话分析师,你的任务是从对话历史中提取关键事实,并以JSON格式返回。"}

    def compress_dialogue(self, dialogue_history: List[Dict[str, str]]) -> List[str]:
        """
        使用LLM压缩对话历史,提取关键事实。
        """
        # 构建发送给LLM的Prompt
        history_text = "n".join([f"{msg['role']}: {msg['content']}" for msg in dialogue_history])
        user_prompt_content = self.compression_prompt_template.format(dialogue_history=history_text)

        messages_for_llm = [
            self.system_message,
            {"role": "user", "content": user_prompt_content}
        ]

        print("n--- Sending compression request to LLM ---")
        # print(f"Messages for Compression LLM:n{json.dumps(messages_for_llm, indent=2)}")

        response_content = call_llm_api(messages_for_llm, model=self.llm_model)

        try:
            parsed_response = json.loads(response_content)
            facts = parsed_response.get("facts", [])
            print(f"--- LLM compressed facts: {facts} ---")
            return facts
        except json.JSONDecodeError:
            print(f"Error parsing compression LLM response: {response_content}")
            return []

class ContextBuilder:
    """
    负责构建最终发送给LLM的上下文。
    """
    def __init__(self, token_counter: TokenCounter):
        self.token_counter = token_counter
        self.system_prompt_template = """
        你是一个智能助手,能够理解并利用历史信息进行连贯对话。
        以下是你需要参考的关键历史事实:
        {facts_summary}
        """

    def build_context(self,
                      recent_messages: List[Dict[str, str]],
                      current_query: str,
                      relevant_facts: List[Dict[str, Any]],
                      max_context_tokens: int) -> List[Dict[str, str]]:
        """
        构建发送给主LLM的上下文。
        """
        context_messages: List[Dict[str, str]] = []

        # 1. 注入系统Prompt和关键事实
        facts_summary_text = "n".join([f"- {fact['content']}" for fact in relevant_facts])
        system_prompt = self.system_prompt_template.format(facts_summary=facts_summary_text)

        if facts_summary_text: # 只有当有事实时才注入
            context_messages.append({"role": "system", "content": system_prompt})

        # 2. 注入最近的对话消息
        # 为了确保不超限,这里需要一个动态调整的逻辑。
        # 简单起见,我们先添加,如果超限再截断。

        # 倒序添加最近消息,确保最新的在后面
        temp_messages = []
        if context_messages: # 如果有系统消息,先加入
            temp_messages.append(context_messages[0])

        for msg in recent_messages:
            temp_messages.append(msg)

        current_tokens = self.token_counter.count_message_tokens(temp_messages)

        # 3. 添加当前用户查询
        user_query_message = {"role": "user", "content": current_query}
        temp_messages.append(user_query_message)
        current_tokens = self.token_counter.count_message_tokens(temp_messages)

        # 动态截断逻辑 (简化版)
        # 真实场景需要更精细的策略,例如优先保留系统Prompt和用户查询,然后是最近消息
        # 这里只是一个示范,确保最终的token不超限。
        while current_tokens > max_context_tokens and len(temp_messages) > 1: # 至少保留一个用户查询
            # 移除最老的非系统消息
            if len(temp_messages) > 1 and temp_messages[1]['role'] != 'system': # 确保不移除系统消息
                temp_messages.pop(1) 
            else: # 如果只剩下系统消息和用户查询,则无法再移除
                break
            current_tokens = self.token_counter.count_message_tokens(temp_messages)

        # 如果只剩下系统消息和用户查询,并且仍然超限,那么可能需要进一步压缩系统消息或直接截断。
        # 这里为了演示,我们假设系统消息和facts_summary_text是足够精简的。

        print(f"n--- Built context for main LLM ({self.token_counter.count_message_tokens(temp_messages)} tokens) ---")
        # print(f"Built context messages:n{json.dumps(temp_messages, indent=2)}")
        return temp_messages

# 主协调器
class LLMConversationAgent:
    def __init__(self,
                 llm_model: str = "gpt-4-0125-preview",
                 max_context_tokens: int = 4096,
                 compression_threshold_tokens: int = 2000,
                 recent_messages_to_keep: int = 5, # 每次构建上下文时保留的最新原始消息轮次
                 compression_history_window: int = 10 # 每次压缩时考虑的原始历史消息轮次
                ):
        self.token_counter = TokenCounter(llm_model)
        self.conversation_manager = ConversationManager()
        self.fact_store = FactStore()
        self.compression_engine = CompressionEngine(self.token_counter, llm_model)
        self.context_builder = ContextBuilder(self.token_counter)

        self.llm_model = llm_model
        self.max_context_tokens = max_context_tokens
        self.compression_threshold_tokens = compression_threshold_tokens
        self.recent_messages_to_keep = recent_messages_to_keep
        self.compression_history_window = compression_history_window

        print(f"Agent initialized with max_context_tokens={max_context_tokens}, compression_threshold_tokens={compression_threshold_tokens}")

    def chat(self, user_query: str) -> str:
        # 1. 添加用户查询到完整历史
        self.conversation_manager.add_message("user", user_query)

        # 2. 检查是否需要压缩
        full_history = self.conversation_manager.get_full_history()
        current_full_history_tokens = self.token_counter.count_message_tokens(full_history)
        print(f"nCurrent full history tokens: {current_full_history_tokens}")

        if current_full_history_tokens > self.compression_threshold_tokens:
            print("Compression threshold reached. Triggering compression...")
            # 压缩最近的N条消息,而不是全部历史,避免重复压缩和丢失最新细节
            history_for_compression = self.conversation_manager.get_recent_history(self.compression_history_window)
            new_facts = self.compression_engine.compress_dialogue(history_for_compression)
            for fact_content in new_facts:
                self.fact_store.add_fact(fact_content)

            # 压缩后,可以考虑清除部分原始历史,或者在构建上下文时不再使用它们
            # 这里我们选择不清除,而是依赖ContextBuilder来选择性使用

        # 3. 检索相关事实
        relevant_facts = self.fact_store.retrieve_facts_by_query(user_query) # 实际会更智能地检索

        # 4. 构建发送给LLM的最终上下文
        recent_messages = self.conversation_manager.get_recent_history(self.recent_messages_to_keep)
        context_for_llm = self.context_builder.build_context(
            recent_messages=recent_messages,
            current_query=user_query,
            relevant_facts=relevant_facts,
            max_context_tokens=self.max_context_tokens
        )

        # 5. 调用主LLM
        print("n--- Calling main LLM with built context ---")
        llm_response_content = call_llm_api(context_for_llm, model=self.llm_model)

        # 6. 添加LLM回复到完整历史
        self.conversation_manager.add_message("assistant", llm_response_content)

        print(f"n--- LLM Response: {llm_response_content} ---")
        return llm_response_content

这个框架提供了一个起点。每个模块都可以独立地进行优化和扩展。例如,FactStore可以升级为使用向量数据库进行语义检索,CompressionEngine可以采用更复杂的Prompt策略,ContextBuilder可以实现更智能的截断算法。

深度剖析:Prompt Engineering 的艺术与科学

在我们的上下文压缩策略中,Prompt Engineering是核心中的核心。LLM的压缩质量,几乎完全取决于我们如何有效地向它发出指令。一个优秀的压缩Prompt能够引导LLM准确识别关键信息、生成结构化且高质量的“关键事实矢量”。

为何Prompt Engineering至关重要?

  • 准确性 (Accuracy): 确保LLM提取的是真正重要的事实,而不是无关紧要的细节或错误的推断。
  • 简洁性 (Conciseness): 引导LLM用最少的token表达最多的信息。
  • 结构化 (Structure): 强制LLM以易于程序解析和后续利用的格式(如JSON、列表)输出结果。
  • 去重与整合 (Deduplication & Integration): 指导LLM识别并合并重复的信息,或整合不同时间点出现的相似事实。

设计高效压缩Prompt的原则:

  1. 明确的角色定义 (Clear Role Definition): 给LLM一个清晰的身份,例如“你是一个专业的对话分析师”、“你是一个会议纪要员”。
  2. 详细的任务描述 (Detailed Task Description): 明确告诉LLM它需要做什么,例如“从对话历史中提取所有关键事实”、“总结用户意图和已确认的细节”。
  3. 指定输出格式 (Specify Output Format): 这是最关键的一点。要求LLM以JSON、Markdown列表、键值对等结构化格式输出,大大简化了后续的解析工作。
  4. 提供示例 (Few-shot Examples – Optional but Recommended): 如果可能,提供几个输入对话和对应的理想压缩事实的例子,可以显著提高LLM的输出质量和一致性。
  5. 强调关键属性 (Emphasize Key Attributes): 例如“事实应该简洁、客观、去重”、“每个事实不超过20个词”。
  6. 处理模糊与冲突 (Handle Ambiguity & Conflicts): 指导LLM在遇到模糊或冲突信息时如何处理,例如“如果信息不确定,请标记为[待确认]”。

迭代优化:从简单到复杂

我们可以从一个简单的Prompt开始,然后根据LLM的输出效果逐步进行优化。

阶段一:基础提取(列表格式)

# CompressionEngine中的 compression_prompt_template 示例
# 目标:提取关键信息点,以Markdown列表形式返回。
basic_compression_prompt = """
你是一个对话历史总结机器人。请从以下对话中提取核心的关键信息点和已确认的事项。
以无序列表(Markdown格式)的形式返回,每个项目都是一个简短的事实陈述。

对话历史:
{dialogue_history}

关键事实:
"""

# 示例对话
dialogue_example_1 = [
    {"role": "user", "content": "你好,我想预订一张从上海到北京的机票。"},
    {"role": "assistant", "content": "好的,请问您希望哪天出发?"},
    {"role": "user", "content": "下周五,大概是10月27日。"},
    {"role": "assistant", "content": "好的,10月27日从上海到北京。有偏好的航空公司吗?"},
    {"role": "user", "content": "没有特别偏好,经济舱就好。"},
    {"role": "assistant", "content": "明白了。正在为您查询10月27日上海到北京的经济舱机票。"}
]

# 模拟LLM调用 (为了演示,直接给出预期输出)
# expected_facts_1 = [
#     "- 用户希望预订机票。",
#     "- 出发地:上海。",
#     "- 目的地:北京。",
#     "- 出发日期:10月27日 (下周五)。",
#     "- 舱位:经济舱。"
# ]

阶段二:结构化提取(JSON格式)

JSON格式的输出对于程序化处理最为友好。我们可以要求LLM返回一个JSON对象,其中包含一个事实数组,每个事实可以有多个属性(如类型、内容、置信度等)。

# CompressionEngine中的 compression_prompt_template 示例
# 目标:提取结构化的关键事实,以JSON格式返回。
json_compression_prompt = """
你是一个专业的对话分析师,你的任务是从以下对话历史中提取所有关键事实。
提取的事实应该简洁、客观、去重,并以JSON格式返回。
JSON对象应包含一个名为 'facts' 的数组,数组的每个元素都是一个字符串,表示一个关键事实。

对话历史:
{dialogue_history}

请提取关键事实,并以JSON格式返回,例如:
{"facts": ["事实1", "事实2", "事实3"]}
"""

# 进一步细化,每个事实本身也是一个JSON对象
detailed_json_compression_prompt = """
你是一个专业的对话分析师,你的任务是从以下对话历史中提取所有关键事实。
提取的事实应该简洁、客观、去重,并以JSON格式返回。
JSON对象应包含一个名为 'facts' 的数组,数组的每个元素都是一个JSON对象,包含 'id', 'type', 'content', 'confidence' 字段。
'id' 为唯一标识符,'type' 表示事实类别(如"user_intent", "confirmed_detail", "entity"),'content' 为事实的具体描述,'confidence' 为事实的确定性(0.0-1.0)。

对话历史:
{dialogue_history}

请提取关键事实,并以JSON格式返回,例如:
{
  "facts": [
    {"id": "f1", "type": "user_intent", "content": "用户希望预订机票", "confidence": 1.0},
    {"id": "f2", "type": "confirmed_detail", "content": "出发地为上海", "confidence": 1.0},
    {"id": "f3", "type": "confirmed_detail", "content": "目的地为北京", "confidence": 1.0},
    {"id": "f4", "type": "confirmed_detail", "content": "出发日期为10月27日(下周五)", "confidence": 1.0},
    {"id": "f5", "type": "confirmed_detail", "content": "舱位为经济舱", "confidence": 1.0}
  ]
}
"""

# 更新 CompressionEngine 以使用这个更详细的Prompt
class CompressionEngine:
    # ... (前面的代码不变) ...
    def __init__(self, token_counter: TokenCounter, llm_model: str = "gpt-4-0125-preview"):
        self.token_counter = token_counter
        self.llm_model = llm_model
        # 使用更详细的JSON压缩Prompt
        self.compression_prompt_template = detailed_json_compression_prompt
        self.system_message = {"role": "system", "content": "你是一个专业的对话分析师,你的任务是从对话历史中提取关键事实,并以JSON格式返回。"}

    def compress_dialogue(self, dialogue_history: List[Dict[str, str]]) -> List[Dict[str, Any]]:
        # ... (与之前相同,但返回类型变为 List[Dict[str, Any]]) ...
        history_text = "n".join([f"{msg['role']}: {msg['content']}" for msg in dialogue_history])
        user_prompt_content = self.compression_prompt_template.format(dialogue_history=history_text)

        messages_for_llm = [
            self.system_message,
            {"role": "user", "content": user_prompt_content}
        ]

        print("n--- Sending compression request to LLM ---")
        response_content = call_llm_api(messages_for_llm, model=self.llm_model)

        try:
            parsed_response = json.loads(response_content)
            facts = parsed_response.get("facts", []) # 期望facts是一个列表,元素是字典
            print(f"--- LLM compressed facts: {facts} ---")
            return facts
        except json.JSONDecodeError:
            print(f"Error parsing compression LLM response: {response_content}")
            return []

# 更新 FactStore 以接受结构化事实
class FactStore:
    def __init__(self):
        self.facts: List[Dict[str, Any]] = []

    def add_fact(self, fact_dict: Dict[str, Any]):
        """
        添加一个结构化的事实字典。
        这里可以加入去重逻辑,例如根据content或id判断。
        """
        # 简单去重:如果内容完全相同,则不添加
        if any(f['content'] == fact_dict['content'] for f in self.facts):
            print(f"Fact already exists, skipping: {fact_dict['content']}")
            return

        # 赋予一个唯一ID,如果LLM没有提供
        if 'id' not in fact_dict or not fact_dict['id']:
            fact_dict['id'] = f"fact_{len(self.facts) + 1}"

        self.facts.append(fact_dict)
        print(f"Fact added: {fact_dict}")
    # ... (get_all_facts 和 retrieve_facts_by_query 保持不变,但要确保处理的是字典) ...

# 还需要更新 LLMConversationAgent 的 chat 方法中调用 fact_store.add_fact 的部分
# for fact_dict in new_facts:
#     self.fact_store.add_fact(fact_dict)

表格:Prompt设计要素总结

要素 描述 示例指令 收益
角色设定 赋予LLM一个清晰的身份,有助于其从特定视角执行任务。 "你是一个专业的对话分析师。" 提高输出的专业性和准确性。
任务指令 明确告知LLM要完成的具体工作。 "从对话历史中提取所有关键事实。" 确保LLM理解任务目标,避免偏离。
输出格式 强制LLM以结构化格式(如JSON, Markdown列表)输出。 "以JSON格式返回,包含一个’facts’键,其值为一个字符串数组。" 便于程序解析和后续处理,减少解析错误。
内容约束 对提取内容的质量、数量、特性进行要求。 "事实应该简洁、客观、去重,每个事实不超过20个词。" 提高事实的可用性,减少冗余,控制token消耗。
示例 提供输入-输出对的例子,作为Few-shot Learning的指导。 {"facts": ["用户意图:预订机票", "目的地:北京"]} 显著提高输出质量和格式一致性,尤其是在处理复杂场景时。
处理规则 指导LLM如何处理特定情况,如模糊信息、冲突信息或不确定性。 "如果信息不确定,请标记为[待确认],并说明原因。" 提高系统鲁棒性,减少幻觉和错误。
语境与范围 指明LLM应关注对话的哪些部分,或在哪个时间窗口内进行压缩。 "只关注最近5轮对话中的关键信息。" 优化压缩效率,避免不必要的历史信息处理,聚焦最新进展。

通过细致的Prompt工程,我们可以将LLM从一个通用模型,训练成一个高效、精准的“上下文压缩专家”。

关键事实的管理与检索

压缩后的“关键事实矢量”需要被有效地存储和管理,并在需要时被智能地检索出来,注入到LLM的上下文。这个过程是整个系统流畅运行的关键。

存储策略

存储这些“关键事实”的方式可以根据系统的复杂度和规模进行选择:

  1. 内存中的简单列表/字典: 对于小规模、短生命周期的对话,可以直接在应用程序的内存中维护一个列表或字典。如我们FactStore的初步实现。
    • 优点: 实现简单,访问速度快。
    • 缺点: 不持久化,应用重启则丢失;不适合大量事实和复杂检索。
  2. 文件存储: 将事实以JSON、CSV等格式写入文件。
    • 优点: 简单持久化。
    • 缺点: 检索效率低,不适合并发访问。
  3. 关系型数据库 (RDBMS): 使用PostgreSQL、MySQL等数据库存储结构化事实。可以为每个事实创建字段(如 id, type, content, timestamp, session_id 等)。
    • 优点: 持久化,支持复杂的SQL查询,事务完整性。
    • 缺点: 对于非结构化文本的语义检索能力有限。
  4. NoSQL 数据库: 如MongoDB、Redis(用于缓存)。
    • 优点: 灵活的文档模型,适合半结构化数据。
  5. 向量数据库 (Vector Database): 如Pinecone, Weaviate, Milvus, ChromaDB。这是最先进也是最强大的方式。将每个事实的文本内容转换为嵌入向量,并存储在向量数据库中。
    • 优点: 强大的语义检索能力,可以通过计算查询向量与事实向量的相似度,找到语义上最相关的事实(RAG的核心)。
    • 缺点: 引入额外的技术栈和复杂性,需要额外的嵌入模型和计算资源。

事实的生命周期管理:

  • 添加 (Add): 每次压缩生成新事实时,将其加入存储。
  • 更新 (Update): 如果新事实与旧事实冲突或补充,可以更新旧事实。例如,“用户预订了去上海的机票” -> “用户将目的地改为北京”。
  • 去重 (Deduplicate): 避免存储完全相同或语义上等价的事实。
  • 过期 (Expire)/删除 (Delete): 对于长时间不活跃的会话或过时的事实,可以设置过期时间或定期清理。

检索策略

当LLM需要新的上下文时,我们不能把所有历史事实都扔给它。我们需要根据当前的对话(特别是用户最新的Query)来检索最相关的少数事实。

  1. 简单关键词匹配: 最简单的方式,但精度低。
    • 实现: 遍历所有事实,检查事实内容是否包含用户Query中的关键词。
  2. 基于规则的检索: 根据事实类型、时间戳或与特定实体的关联进行检索。
    • 实现: 如果事实有结构化字段(如type: "user_intent", entity: "user_name"),可以根据这些字段进行过滤。
  3. 语义检索 (RAG – Retrieval Augmented Generation): 这是最推荐且最有效的方式。
    • 原理:
      • 将所有存储的关键事实文本转换为嵌入向量(使用Sentence Transformer或OpenAI Embeddings等模型)。
      • 将当前的用户查询也转换为嵌入向量。
      • 在向量数据库中,计算用户查询向量与所有事实向量的余弦相似度。
      • 检索相似度最高的Top-K个事实。
    • 优点: 能够理解查询的深层语义,即使关键词不匹配也能找到相关事实。
    • 缺点: 增加了系统复杂性,需要向量数据库和嵌入模型。

代码示例:简单事实存储与检索(在FactStore类中实现)

我们的FactStore已经支持添加结构化事实。现在我们来增强retrieve_facts_by_query方法,使其更贴近实际的语义检索概念(尽管这里仍是模拟,并未真正使用嵌入)。

# 假设我们有一个简化的嵌入函数和相似度计算
def get_embedding(text: str) -> List[float]:
    """
    模拟文本嵌入函数。实际会调用 OpenAI embeddings API 或 Sentence Transformers。
    为了简化,这里返回一个固定长度的随机向量。
    """
    import random
    return [random.random() for _ in range(128)] # 假设嵌入维度是128

def cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
    """
    计算两个向量的余弦相似度。
    """
    dot_product = sum(v1 * v2 for v1, v2 in zip(vec1, vec2))
    magnitude_vec1 = sum(v**2 for v in vec1)**0.5
    magnitude_vec2 = sum(v**2 for v in vec2)**0.5

    if magnitude_vec1 == 0 or magnitude_vec2 == 0:
        return 0.0
    return dot_product / (magnitude_vec1 * magnitude_vec2)

class FactStore:
    # ... (init 和 add_fact 保持不变) ...

    def retrieve_facts_by_query(self, query: str, top_k: int = 3) -> List[Dict[str, Any]]:
        """
        基于当前用户查询检索最相关的事实。
        这里模拟语义检索,实际上会使用向量数据库。
        """
        if not self.facts:
            return []

        query_embedding = get_embedding(query)

        # 计算每个事实与查询的相似度
        scored_facts = []
        for fact in self.facts:
            # 假设事实内容已经可以被嵌入
            fact_embedding = get_embedding(fact['content']) 
            similarity = cosine_similarity(query_embedding, fact_embedding)
            scored_facts.append((similarity, fact))

        # 按相似度降序排序
        scored_facts.sort(key=lambda x: x[0], reverse=True)

        # 返回 top_k 个事实
        relevant_facts = [fact for score, fact in scored_facts[:top_k]]

        print(f"--- Retrieved {len(relevant_facts)} relevant facts for query: '{query}' ---")
        # print(json.dumps(relevant_facts, indent=2, ensure_ascii=False))
        return relevant_facts

# 需要在LLMConversationAgent中,确保FactStore实例使用这个增强后的检索方法。
# 并且要保证在FactStore.add_fact时,如果需要,也生成并存储事实的embedding。

FactStore 增强:存储 Embedding

为了真正实现语义检索,FactStore在存储事实时应该同时存储其Embedding。

# 重新定义 get_embedding 函数,使其更真实(例如,使用一个mock的embedding模型)
# 实际应用中,这里会调用 OpenAI().embed_query(text) 或 SentenceTransformer().encode(text)
def get_embedding_real(text: str) -> List[float]:
    # Placeholder for a real embedding call
    # In a real scenario, you'd integrate with an embedding model
    # For demonstration, let's use a consistent mock embedding
    import hashlib
    import struct

    # Generate a simple hash-based "embedding" for consistent demo
    hash_object = hashlib.sha256(text.encode())
    hash_bytes = hash_object.digest()

    # Convert first 128 bytes of hash into floats (mocking a 128-dim embedding)
    # This is purely illustrative and not a real semantic embedding
    embedding = []
    for i in range(0, min(len(hash_bytes), 512), 4): # Read 4 bytes at a time
        val = struct.unpack('>I', hash_bytes[i:i+4])[0] # Unpack as unsigned int
        embedding.append(val / (2**32 - 1)) # Normalize to [0, 1]
        if len(embedding) >= 128: # Limit to 128 dimensions for this mock
            break

    # Pad if necessary (e.g., for very short hashes if dimension is fixed)
    while len(embedding) < 128:
        embedding.append(0.0)

    return embedding[:128]

class FactStore:
    def __init__(self):
        self.facts: List[Dict[str, Any]] = []

    def add_fact(self, fact_dict: Dict[str, Any]):
        """
        添加一个结构化的事实字典,并为其生成embedding。
        """
        # 简单去重:如果内容完全相同,则不添加
        if any(f['content'] == fact_dict['content'] for f in self.facts):
            # print(f"Fact already exists, skipping: {fact_dict['content']}")
            return

        if 'id' not in fact_dict or not fact_dict['id']:
            fact_dict['id'] = f"fact_{len(self.facts) + 1}"

        # 为事实内容生成embedding并存储
        fact_dict['embedding'] = get_embedding_real(fact_dict['content'])

        self.facts.append(fact_dict)
        print(f"Fact added: {fact_dict['content']}")

    def get_all_facts(self) -> List[Dict[str, Any]]:
        return self.facts

    def retrieve_facts_by_query(self, query: str, top_k: int = 3) -> List[Dict[str, Any]]:
        """
        基于当前用户查询检索最相关的事实,使用embedding相似度。
        """
        if not self.facts:
            return []

        query_embedding = get_embedding_real(query)

        scored_facts = []
        for fact in self.facts:
            if 'embedding' in fact and fact['embedding']:
                similarity = cosine_similarity(query_embedding, fact['embedding'])
                scored_facts.append((similarity, fact))

        scored_facts.sort(key=lambda x: x[0], reverse=True)

        relevant_facts = [fact for score, fact in scored_facts[:top_k]]

        # print(f"--- Retrieved {len(relevant_facts)} relevant facts for query: '{query}' ---")
        return relevant_facts

# 确保LLMConversationAgent的CompressionEngine和FactStore实例化时,
# 传递正确的get_embedding函数(如果它是外部的)。
# 但更常见的做法是 FactStore 内部自行调用 embedding model。

通过这些策略,我们可以确保LLM在需要时,能够高效、准确地访问到最相关的历史“记忆”。

上下文注入策略:将事实带回对话

将压缩后的“关键事实矢量”有效地注入到LLM的输入上下文,是确保LLM能够利用这些信息并保持对话连贯性的关键。这不仅仅是简单地拼接文本,更需要考虑如何以自然、清晰、高效的方式呈现这些事实。

注入方式

  1. 系统消息注入 (System Message Injection):

    • 方式: 将压缩后的事实作为LLM系统消息的一部分。这种方式通常用于提供背景信息、设定LLM行为或提供参考资料。
    • 优点: 优先级高,LLM倾向于重视系统消息;不会干扰用户与助手的对话流。
    • 缺点: 如果事实过多,系统消息会变得很长,占用大量token。
    • 示例:

      SYSTEM: 你是一个智能助手。请参考以下关键背景信息:
      - 用户希望预订一张从上海到北京的机票。
      - 确认出发日期为10月27日(下周五)。
      - 舱位要求为经济舱。
      - 用户姓名为张三,联系电话139...
      
      USER: 请问有10月27日早上8点的航班吗?
  2. 前缀注入 (Prefix Injection):
    • 方式: 在用户消息之前,或在整个对话历史的开头,插入一个特殊的“参考信息”段落。
    • 优点: 相对简单,可以根据需要灵活调整位置。
    • 缺点: 可能会打断对话的自然流畅性,让LLM感觉这些信息是额外插入的。
  3. RAG模式变体 (Retrieval-Augmented Generation Variant):
    • 方式: 结合向量检索,将检索到的相关事实作为额外上下文,与用户Query和少量最新对话历史一起,发送给LLM。
    • 优点: 高度灵活,只注入最相关的信息,最小化token消耗。
    • 缺点: 需要更复杂的检索系统。

在我们的ContextBuilder中,我们采用了类似系统消息注入和RAG模式结合的方式。即,我们将检索到的关键事实作为系统Prompt的一部分,然后注入最近的几轮原始对话,最后是当前的用户查询。

保持连贯性与自然度

在注入事实时,我们需要注意以下几点,以确保LLM能够更好地利用这些信息:

  • 清晰的标记: 使用明确的前缀(如“参考信息:”、“关键事实:”)或结构化格式(如列表、JSON),让LLM清楚地知道这部分内容是背景信息。
  • 简洁的表达: 事实本身应该尽可能简洁,避免冗余的描述。这也是压缩Prompt设计时强调的。
  • 语序优化: 通常,将最重要的背景信息放在上下文的开头(如系统消息中),LLM会给予更高的关注度。
  • 避免冲突: 确保注入的事实与当前对话没有明显的冲突。如果存在潜在冲突,LLM需要被训练去处理它们(例如,询问用户哪个信息是正确的)。

代码示例:构建最终上下文 (回顾ContextBuilder)

class ContextBuilder:
    def __init__(self, token_counter: TokenCounter):
        self.token_counter = token_counter
        # 修改system_prompt_template,使其更自然地引导LLM利用事实
        self.system_prompt_template = """
        你是一个智能助手,能够理解并利用历史信息进行连贯对话。
        以下是一些你应参考的关键背景信息和已确认的事实:
        {facts_summary}

        请根据这些事实和当前的对话进行回应。
        """
        # 当没有事实时,可以使用一个更简洁的系统Prompt
        self.default_system_prompt = "你是一个智能助手,请进行连贯的对话。"

    def build_context(self,
                      recent_messages: List[Dict[str, str]],
                      current_query: str,
                      relevant_facts: List[Dict[str, Any]],
                      max_context_tokens: int) -> List[Dict[str, str]]:
        """
        构建发送给主LLM的上下文。
        确保系统消息、事实、最近消息和当前查询的优先级和token限制。
        """
        context_messages: List[Dict[str, str]] = []

        # 1. 构建系统Prompt,优先包含关键事实
        facts_summary_text = "n".join([f"- {fact['content']}" for fact in relevant_facts])

        if facts_summary_text:
            system_message_content = self.system_prompt_template.format(facts_summary=facts_summary_text)
        else:
            system_message_content = self.default_system_prompt

        system_message = {"role": "system", "content": system_message_content}
        context_messages.append(system_message)

        # 2. 准备用户查询
        user_query_message = {"role": "user", "content": current_query}

        # 3. 动态添加最近对话消息,并进行token管理
        # 目标:系统消息 + 最近消息 + 用户查询 的总token数不超过 max_context_tokens

        # 首先计算系统消息和用户查询的token数
        base_messages = [system_message, user_query_message]
        current_tokens = self.token_counter.count_message_tokens(base_messages)

        # 倒序添加最近消息,从最新的开始,直到达到token限制
        messages_to_add = []
        # 注意:recent_messages 已经是按时间顺序排列的(最老到最新)
        # 我们想优先保留最新的,所以从列表末尾开始遍历
        for msg in reversed(recent_messages):
            temp_messages_with_new = [system_message, msg, user_query_message] # 尝试加入这条消息
            if self.token_counter.count_message_tokens(temp_messages_with_new) <= max_context_tokens:
                messages_to_add.insert(0, msg) # 插入到列表开头,保持时间顺序
            else:
                break # 如果加入这条消息会导致超限,则停止

        # 组合最终上下文
        final_context = [system_message] + messages_to_add + [user_query_message]

        final_tokens = self.token_counter.count_message_tokens(final_context)
        print(f"n--- Built context for main LLM ({final_tokens} tokens, max: {max_context_tokens}) ---")
        # print(f"Final context messages:n{json.dumps(final_context, indent=2)}")

        return final_context

这个ContextBuilder的优化版本会更智能地管理token:它总是会包含系统消息(包含事实)和当前用户查询,然后尽可能多地从最近的对话历史中添加消息,直到达到最大token限制。这种策略确保了LLM始终能看到最重要的信息:最新的用户意图、相关的历史事实,以及最新的几轮交互。

成本效益分析:70% Token 开销节省的量化

现在我们来量化这种上下文压缩策略带来的实际效益,特别是对Token开销的显著节省。我们的目标是实现至少70%的Token开销节省。

理论分析:压缩率与Token节省

假设:

  • 平均每轮对话(用户消息 + 助手回复)长度为 L_dialogue tokens。
  • 平均每个“关键事实矢量”的长度为 L_fact tokens。
  • 我们每 N 轮对话触发一次压缩。
  • 每次压缩,可以将这 N 轮对话中的核心信息提炼为 K 个关键事实。
  • 每次LLM调用时,我们只保留最近 M 轮原始对话,并注入 K_retrieved 个相关事实。

传统方法 (无压缩):
对于一个持续 T 轮的对话,每次LLM调用都需要发送 T * L_dialogue tokens(简化模型,忽略系统Prompt)。
总token开销 ≈ T * (T * L_dialogue) (每次调用都发送全部历史)

压缩方法:

  1. 压缩阶段:N 轮对话,将 N * L_dialogue tokens 压缩为 K * L_fact tokens。
    • 压缩成本:N * L_dialogue (发送给压缩LLM) + K * L_fact (存储成本,忽略API调用费用)。
  2. 对话阶段: 每次LLM调用发送 M * L_dialogue (最近消息) + K_retrieved * L_fact (检索事实) tokens。
    • 对话成本:M * L_dialogue + K_retrieved * L_fact

关键假设:

  • K * L_fact 远小于 N * L_dialogue
  • M 是一个较小的常数(例如5-10轮)。
  • K_retrieved 也是一个较小的常数(例如3-5个事实)。

节省的关键在于: N * L_dialogue (压缩前历史) 与 K * L_fact (压缩后事实) 的巨大差异。以及 T * L_dialogue (每次调用都发送全部历史) 与 M * L_dialogue + K_retrieved * L_fact (每次调用发送精简上下文) 的巨大差异。

实际案例模拟

让我们用一个具体的例子来模拟token消耗。
假设:

  • L_dialogue = 50 tokens/轮 (用户消息25,助手回复25)。
  • L_fact = 20 tokens/个 (每个事实非常精简)。
  • N = 10 轮对话触发一次压缩。
  • 每次压缩,10轮对话 (10 50 = 500 tokens) 可以压缩为 5 个关键事实 (5 20 = 100 tokens)。
  • 每次LLM调用,保留 M = 5 轮最近对话 (5 * 50 = 250 tokens)。
  • 每次LLM调用,检索并注入 K_retrieved = 3 个相关事实 (3 * 20 = 60 tokens)。
  • 一个系统Prompt大约 50 tokens。
  • 最大上下文窗口 max_context_tokens = 4096 tokens。

场景:一个持续 30 轮的对话。

1. 传统方法 (无压缩,每次发送全部历史):

  • 第1轮: 发送 50 (系统) + 50 (用户) = 100 tokens。
  • 第2轮: 发送 50 (系统) + 50*2 (历史) + 50 (用户) = 200 tokens。
  • 第3轮: 发送 50 (系统) + 50*3 (历史) + 50 (用户) = 250 tokens。
  • 第30轮: 发送 50 (系统) + 50*30 (历史) + 50 (用户) = 1600 tokens。
  • 总计Token开销: 假设每次调用发送的上下文是 (系统 + 历史 + 用户查询)。
    • 近似计算:sum(50 + 50*i + 50 for i in range(1, 31))
    • 实际总开销会非常大,因为每次都重新发送整个增长的历史。
    • 计算前30轮总开销:
      (50+50) + (50+100+50) + (50+150+50) + ... + (50+1500+50)
      = 100 + 200 + 250 + ... + 1600
      = 50 * (2 + 4 + 5 + ... + 32) (这里计算方式简化,实际是每次都发送递增的历史)
      每次LLM调用,发送 System + History + User
      历史长度从 0 增长到 29*50
      i 轮 (i从1开始):发送 50 + (i-1)*50 + 50 = 50 * (i+1) tokens。
      总开销 = sum(50*(i+1) for i in range(1, 31))
      = 50 * sum(i+1 for i in range(1, 31))
      = 50 * (sum(range(2, 32)))
      = 50 * (31 * 32 / 2 - 1) (减去1是因为sum从2开始)
      = 50 * (496 - 1) = 50 * 495 = 24750 tokens
    • 在第30轮时,单次API调用发送了1600 tokens。
    • 总Token开销:24750 tokens。

2. 压缩方法:

  • 初始系统Prompt: 50 tokens。
  • 每次LLM调用上下文: 系统Prompt (50 tokens,包含事实) + M轮最近对话 (5 * 50 = 250 tokens) + 用户查询 (25 tokens)。
    • 总计 50 + 250 + 25 = 325 tokens (如果无事实,则为 50 + 250 + 25 = 325 tokens)
    • 如果注入3个事实:50 (系统基础) + 60 (3个事实) + 250 (最近5轮) + 25 (用户查询) = 385 tokens
    • 这里我们假设系统Prompt+事实部分是 50 + K_retrieved * L_fact,即 50 + 3*20 = 110 tokens
    • 所以每次主LLM调用上下文是 110 + 250 + 25 = 385 tokens
  • 压缩触发: 每10轮触发一次。
    • 第10轮结束时: 历史对话为 10*50 = 500 tokens。
      发送给压缩LLM: 50 (系统) + 500 (历史) + 50 (压缩指令) = 600 tokens。
      LLM返回 5 个事实 (100 tokens)。
    • 第20轮结束时: 历史对话为 10*50 = 500 tokens。
      发送给压缩LLM: 50 (系统) + 500 (历史) + 50 (压缩指令) = 600 tokens。
      LLM返回 5 个事实 (100 tokens)。
    • 第30轮结束时: 历史对话为 10*50 = 500 tokens。
      发送给压缩LLM: 50 (系统) + 500 (历史) + 50 (压缩指令) = 600 tokens。
      LLM返回 5 个事实 (100 tokens)。
    • 总计3次压缩。

总Token开销 (压缩方法):

  • 主LLM对话开销: 30 轮 * 385 tokens/轮 = 11550 tokens。
  • 压缩LLM开销: 3 次压缩 * 600 tokens/次 = 1800 tokens。
  • 总计Token开销:11550 + 1800 = 13350 tokens。

对比结果:

方法 单次API调用Token (第30轮) 总Token开销 (30轮对话)
传统方法 1600 tokens 24750 tokens
压缩方法 385 tokens 13350 tokens

节省比例计算:

  • 单次调用节省: (1600 – 385) / 1600 ≈ 75.9%
  • 总Token节省: (24750 – 13350) / 24750 ≈ 46.1%

等等,为什么总Token节省只有46.1%,而不是70%?

这里有一个关键点:70%的节省通常指的是“每次主LLM调用的上下文大小”相比于“不压缩时发送全部历史”的节省。 在我们这个例子中,单次调用确实节省了75.9%。

如果我们的压缩策略是:每次压缩后,原始历史消息全部被清除,只保留事实和最新几轮对话。 这样总token开销会进一步降低。

让我们重新审视总Token开销的计算。
在传统方法中,随着对话进行,每次发送的上下文会越来越长。
在压缩方法中,每次发送给主LLM的上下文,其长度是相对稳定的(系统+事实+最近M轮+当前Query)。
压缩LLM的调用是额外的开销。

为了达到70%或更高的总Token节省,我们需要更激进的压缩策略,或者对话持续更长。

优化策略以达到更高节省:

  1. 更小的 M (最近消息轮次): 如果 M 设为 3 轮,则 3*50 = 150 tokens
    • 单次主LLM调用上下文:110 + 150 + 25 = 285 tokens
    • 总主LLM开销:30 * 285 = 8550 tokens
    • 总计:8550 + 1800 = 10350 tokens
    • 总Token节省:(24750 - 10350) / 24750 ≈ 58.18%
  2. 更高的压缩率: 假设每次压缩 500 tokens 历史只产生 3 个事实 (60 tokens)。
    • 单次主LLM调用上下文:50 (系统) + 60 (3个事实) + 250 (最近5轮) + 25 (用户查询) = 385 tokens (不变)。
    • 总主LLM开销:11550 tokens
    • 压缩LLM开销:3 * (600 - (100-60)) = 3 * 560 = 1680 tokens (压缩LLM调用本身不变,但结果更短)。
    • 总计:11550 + 1680 = 13230 tokens
    • 总Token节省:(24750 - 13230) / 24750 ≈ 46.6%

看来要达到70%的总Token节省,需要对话非常非常长,或者 L_dialogue 很大而 L_fact 极小,且压缩频率很高。

让我们重新设计一个极端但更符合“70%节省”场景的分析:

假设:

  • 对话持续 100轮
  • L_dialogue = 50 tokens/轮。
  • L_fact = 20 tokens/个。
  • N = 5 轮触发压缩。
  • 每次压缩,5轮对话 (250 tokens) 压缩为 2 个关键事实 (40 tokens)。
  • 每次LLM调用,保留 M = 1 轮最近对话 (50 tokens)。
  • 每次LLM调用,检索并注入 K_retrieved = 2 个相关事实 (40 tokens)。
  • 系统Prompt基础:50 tokens。

传统方法 (100轮对话):

  • 总Token开销 = sum(50*(i+1) for i in range(1, 101))
    = 50 * sum(range(2, 102))
    = 50 * (101 * 102 / 2 - 1) = 50 * (5151 - 1) = 50 * 5150 = 257500 tokens

压缩方法 (100轮对话):

  • 主LLM每次调用上下文:
    • 系统Prompt (50 tokens) + 2个事实 (40 tokens) + 1轮最近对话 (50 tokens) + 用户查询 (25 tokens) = 165 tokens。
  • 主LLM对话开销: 100 轮 * 165 tokens/轮 = 16500 tokens。
  • 压缩LLM开销:
    • 100 轮 / 5 轮/次 = 20 次压缩。
    • 每次压缩发送:50 (系统) + 250 (历史) + 50 (压缩指令) = 350 tokens。
    • 总压缩LLM开销:20 次 * 350 tokens/次 = 7000 tokens。
  • 总计Token开销: 16500 + 7000 = 23500 tokens。

对比结果:

方法 总Token开销 (100轮对话)
传统方法 257500 tokens
压缩方法 23500 tokens

节省比例计算:

  • (257500 - 23500) / 257500 ≈ 90.87%

结论: 70%的Token开销节省是完全可以实现的,甚至更高。这主要取决于:

  1. 对话的长度: 对话越长,传统方法的Token开销增长越快,压缩方法的优势越明显。
  2. 压缩率: 将大量原始对话压缩成少量关键事实的能力。
  3. 每次注入的原始历史消息数量 (M): M 越小,节省越多。
  4. 注入的关键事实数量 (K_retrieved): K_retrieved 越小,节省越多。

这个分析清晰地展示了上下文窗口压缩在降低LLM运营成本方面的巨大潜力。

表格:Token消耗对比 (100轮对话示例)

阶段/类型 传统方法Token 压缩方法Token (主LLM) 压缩方法Token (压缩LLM)
每轮对话上下文 递增 (100-5150) 稳定 (约165) N/A
压缩操作 N/A N/A 20次 * 350 = 7000
总Token 257500 16500 7000
总计 257500 23500
节省比例 N/A 约90.87%

挑战与考量:权衡之道

尽管上下文窗口压缩带来了巨大的效益,但在实际部署中,我们仍需面对一系列挑战和权衡。

  1. 信息丢失与精度权衡 (Information Loss vs. Accuracy Trade-off):

    • 挑战: 压缩本质上是一种信息概括和提炼,不可避免地会丢失一些细节、语气或细微之处。如果LLM在压缩时遗漏了关键信息,或对信息进行了错误的概括,将直接影响后续对话的质量。
    • 考量: 需要精心设计Prompt,并对压缩结果进行评估。在某些对细节要求极高的场景(如法律咨询、医疗诊断),可能需要更保守的压缩策略或人工审核。
    • 对策: 允许用户提供反馈,以识别和纠正压缩错误。
  2. 压缩幻觉 (Compression Hallucination):

    • 挑战: LLM可能在压缩过程中“想象”出不存在的事实,或者对现有事实进行错误的推断和扭曲。这类似于LLM在生成文本时可能出现的幻觉。
    • 考量: 压缩的Prompt需要强调客观性、忠实于原文,并避免进行推断。
    • 对策: 引入事实校验机制(如果可能),或通过多轮压缩、交叉验证来提高事实准确性。对于高风险场景,人工审查是必要的。
  3. 系统复杂性与延迟 (System Complexity & Latency):

    • 挑战: 引入压缩模块、事实存储和检索机制,增加了整个系统的复杂性。每次压缩和检索都需要额外的API调用或计算,这可能会引入额外的延迟。
    • 考量: 需要在节省成本和用户体验之间找到平衡。对于实时性要求高的应用,可能需要优化压缩频率、选择高效的检索方案(如内存缓存或高性能向量数据库)。
    • 对策: 异步压缩、批量处理、边缘计算、优化LLM调用链条、使用更小更快的LLM进行压缩任务。
  4. 动态调整与用户反馈 (Dynamic Adaptation & User Feedback):

    • 挑战: 最佳的压缩策略(如压缩阈值、保留多少轮最近对话)可能因应用场景和用户而异。
    • 考量: 系统需要具备一定的自适应能力。
    • 对策: 允许管理员或通过A/B测试动态调整参数。收集用户对对话质量的反馈,并利用这些反馈来优化压缩模型和策略。
  5. 事实一致性与演变 (Fact Consistency & Evolution):

    • 挑战: 随着对话的进行,某些事实可能会被更新、修正或废弃。如何有效地管理这些事实的生命周期,确保注入的始终是最新的、一致的信息?
    • 考量: 需要设计一套事实更新和冲突解决机制。
    • 对策:FactStore中为事实添加时间戳、版本号,并设计更新逻辑。当新事实与旧事实冲突时,可以优先保留新事实,或提示LLM进行判断。
  6. 安全性与隐私 (Security & Privacy):

    • 挑战: 对话历史可能包含敏感的个人信息或商业机密。在将这些信息发送给LLM进行压缩时,需要确保数据安全和隐私合规。
    • 考量: 选择可信赖的LLM服务提供商,了解其数据处理政策。在本地部署LLM进行压缩,或对敏感信息进行脱敏处理。
    • 对策: 实施严格的数据访问控制、加密传输、数据脱敏等措施。

这些挑战并非不可逾越,但它们要求我们在设计和实现系统时进行细致的思考和权衡。通过持续的迭代和优化,我们可以构建出既高效又健壮的上下文压缩系统。

展望未来:更智能的上下文管理

上下文窗口压缩只是迈向更智能LLM应用的第一步。未来的发展方向将更加注重精细化、自适应和多模态的上下文管理。

  • 分层压缩与渐进式细化: 不再是单一的压缩层,而是多层级的压缩。例如,先将对话压缩成主题,再在每个主题下保留关键事实。当LLM需要更深入的细节时,可以“解压缩”某个主题以获取更多信息。
  • 基于用户意图的自适应压缩: 系统能够实时分析用户意图。如果用户在追问某个特定细节,即使该细节在较早的对话中,也应该被优先保留或重新提取,而不是被简单压缩。
  • 结合向量数据库实现更复杂的检索: 充分利用向量数据库的语义检索能力,不仅检索“事实”,还可以检索相关的代码片段、文档章节、FAQ条目等,实现更广泛的知识增强。
  • 多模态上下文压缩: 不仅仅是文本,还包括图像、音频、视频等模态的信息压缩。例如,从视频会议中提取关键视觉线索和发言要点。
  • “遗忘”机制的引入: 智能地识别并删除不再相关、已经解决或过期的事实,保持事实库的精简和高效。
  • LLM自我反思与纠错: 让LLM不仅能压缩,还能对自己的压缩结果进行评估和修正,甚至识别出可能的信息遗漏或幻觉,并尝试进行自我修复。

这些前沿探索将使LLM在处理长对话和复杂任务时,拥有更接近人类的记忆、理解和推理能力,为我们开启更广阔的应用前景。

迈向更经济、更高效的 LLM 应用

我们深入探讨了利用LLM将历史对话压缩为“关键事实矢量”的技术原理、架构设计、实现细节和成本效益。通过精心的Prompt工程和模块化系统构建,我们能够显著降低LLM的Token开销,同时保持对话的连贯性和准确性。这项技术不仅是解决LLM上下文窗口限制的有效途径,更是推动LLM在生产环境中大规模应用的关键一步。通过持续的优化与创新,我们正迈向更经济、更高效、更智能的LLM应用时代。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注