最近在折腾一些基于大模型的对话应用时,发现一个挺有意思的现象:很多朋友搭建的“酒馆”类应用,初期用起来很爽,但随着聊天轮次越来越多,对话历史越来越长,API调用成本开始直线上升,甚至到了“聊不起”的地步。这背后其实是一个典型的工程问题——上下文窗口的“通货膨胀”。

每次调用模型,我们都会把完整的对话历史塞进上下文里,让模型“记住”之前聊了什么。这就像每次开会,都要把过去所有的会议纪要从头到尾念一遍。聊得越久,纪要越厚,念的时间(也就是Token数)就越长,成本自然就上去了。更关键的是,很多历史对话其实已经“冷却”了,对当前回复的影响微乎其微,但我们依然在为它们支付高昂的“记忆租金”。

于是,一个很自然的想法就冒出来了:能不能把冗长的聊天历史,压缩成一段精炼的摘要?这样,每次只需要把“摘要”和最新的几条对话发给模型,既能保留核心的对话脉络和关键信息,又能大幅削减上下文长度,从而提高缓存的利用率,最终降低调用成本。这个思路,就是“聊天历史摘要化”。

我最近就用 DeepSeek 的 API 实践了这个想法,做了一个简单的插件原型。整个过程下来,发现它远不止是“省点钱”那么简单,更涉及到对对话系统工作流的重新思考。今天,我就把这个从问题发现、方案设计、到具体实现和踩坑经验的完整过程分享出来。

1. 问题根源:为什么“酒馆”会越聊越贵?

在深入技术方案之前,我们得先搞清楚,成本到底花在了哪里。这不仅仅是“Token多所以贵”这么简单。

1.1 上下文成本的“复利效应”

大模型 API 的计费,通常是按输入和输出的总 Token 数来算的。假设我们使用一个支持 128K 上下文窗口的模型。

  • 单次对话成本 :看起来不高。一次问答,用户输入 100 Token,模型回复 200 Token,总计 300 Token。
  • 累积对话成本 :麻烦从这里开始。第二次对话时,为了保持连贯性,你需要把第一次的“用户输入100 + 模型回复200”共 300 Token,再加上第二次的用户输入 100 Token,一起发给模型。模型基于这 400 Token 的历史,生成新的 200 Token 回复。此时, 第二次调用的输入 Token 成本已经是 400,而不是 100
  • “复利”形成 :第三次对话,你需要带上前两次的全部历史(300+400=700 Token),加上本次输入 100 Token,总计 800 Token 作为输入。如此循环,对话轮次(N)和单次调用的输入 Token 数近似成平方关系增长。聊到第10轮,你可能已经在为上千个“历史Token”付费,而它们对当前回复的直接影响可能已经很小了。

这就像滚雪球,历史包袱越来越重。你的大部分成本,其实是在为“记忆”付费,而不是为“思考”付费。

1.2 缓存命中率的困境

很多服务商提供了上下文缓存功能(例如 OpenAI 的 gpt-3.5-turbo-instruct seed 参数,或其他服务的类似机制)。其原理是:如果你发送的输入前缀(即对话历史部分)与之前某次请求的完全一致,服务端可以直接从缓存中返回结果,而无需重新计算,从而降低成本、提高速度。

但在长对话中, 缓存几乎形同虚设 。因为你的对话历史一直在变(追加新的内容),导致每次请求的“输入前缀”都不同,缓存键(Cache Key)永远无法匹配。你拥有一个强大的缓存系统,却因为工作流设计问题,导致其命中率为零。

1.3 模型性能的隐性损耗

除了钱,还有效率问题。过长的上下文会:

  1. 增加延迟 :模型处理长序列需要更多时间。
  2. 分散注意力 :尽管现代大模型有强大的长上下文能力,但将关键信息淹没在大量历史细节中,仍然可能影响模型对最新指令和重点的把握。这有点像让你从一本500页的书里快速找到某个问题的答案,虽然你能读完,但效率肯定不如直接看10页的摘要。
  3. 触及长度限制 :当对话历史超过模型的最大上下文窗口时,你必须进行截断。粗暴地截掉最旧的信息,可能会丢失重要的早期设定(比如角色扮演中的核心人物设定)。

所以,我们的目标不仅仅是省钱,更是为了构建一个更高效、更稳定、更专注的对话系统。而“摘要化”正是解决这一系列问题的钥匙。

2. 方案核心:用摘要重构对话记忆,而非简单截断

面对长上下文,最简单的办法是“截断”(Truncation):只保留最新的 N 条对话或 N 个 Token。但这是一种“失忆”疗法,粗暴地丢弃了历史,可能破坏对话的连贯性和深层逻辑。

我们需要的是一种“提炼”疗法:将长记忆压缩成短时记忆,保留精髓,丢弃冗余。这就是摘要(Summarization)的核心价值。

2.1 摘要 vs. 截断:本质区别

特性 截断 (Truncation) 摘要 (Summarization)
信息保留 保留 最近 的原始信息,丢弃 最早 的原始信息。 保留 全局 的精华信息,可能来自任何历史位置。
连贯性 如果早期关键信息被截断,连贯性断裂。 通过摘要保留核心脉络,连贯性更强。
成本 零额外成本。 需要额外调用一次模型生成摘要(前期投资)。
缓存潜力 低。历史部分仍在不断变化(只是变短了)。 。摘要相对稳定,可作为缓存的稳定前缀。
适用场景 历史信息重要性随时间衰减快的场景(如简单QA)。 需要长期记忆和逻辑连贯的场景(如编故事、复杂任务分解、角色扮演)。

摘要的本质,是将对话的历史状态,从一个不断增长的、细节丰富的“原始日志”,转换成一个可管理的、高信息密度的“知识图谱索引”。它牺牲了部分细节的精确性,换来了全局的可管理性和系统的长期健康度。

2.2 摘要的生成策略:何时、如何更新?

摘要不是生成一次就一劳永逸的。对话在继续,摘要也需要更新。这里有几个关键策略:

  1. 固定轮次更新 :每对话 N 轮(例如5轮)后,触发一次摘要生成。将“旧摘要 + 最新的N轮原始对话”作为材料,生成“新摘要”。这种方式简单可控,但可能在不合适的时机(如话题刚转换)进行摘要,损失最新细节。
  2. 基于Token数/长度更新 :当累积的未摘要的原始对话长度超过某个阈值(如1000 Token)时触发。更符合成本管理直觉。
  3. 基于话题转换更新 :这是更高级的策略。需要模型判断对话是否进入了新的话题阶段。如果是,则对上一个话题阶段的内容进行摘要。这需要更复杂的逻辑,甚至可能引入另一个分类模型。

在我的插件实现中,为了简单可靠,我选择了 混合策略 :优先检查长度阈值,同时保证至少每 N 轮必须更新一次,避免长时间不摘要导致丢失近期关键信息。

注意 :摘要生成本身也是一次 API 调用,有成本。你需要权衡:是宁愿为冗长的历史付费,还是为精炼的摘要付费?通常,当对话历史超过一定长度后,生成摘要的“一次性投资”会远低于长期携带原始历史的“持续开支”。

2.3 系统工作流的重构

引入摘要后,整个对话系统的工作流发生了变化:

传统工作流: 用户输入 -> 拼接完整历史 -> 调用模型 -> 返回回复 -> 保存完整历史

摘要化工作流:

  1. 用户输入
  2. 读取状态 :读取当前的“摘要”和“未摘要的近期原始对话列表”。
  3. 判断是否需要更新摘要 :根据策略(如长度、轮次)判断。
  4. 如需更新 :调用摘要生成模型,将“旧摘要+近期原始对话”生成“新摘要”。清空“近期原始对话列表”。
  5. 构造上下文 :将“当前摘要” + “近期原始对话列表” + “本次用户输入”拼接,作为本次调用的上下文。
  6. 调用模型 ,获得回复。
  7. 保存状态 :将本次的“用户输入”和“模型回复”追加到“近期原始对话列表”。保存更新后的“摘要”。

可以看到,系统需要持久化存储两个核心状态: summary (摘要文本)和 recent_messages (未摘要的原始消息列表)。这通常需要借助数据库或文件存储。

3. 实战:基于 DeepSeek API 实现摘要插件

理论说完了,我们来点实际的。我选择 DeepSeek 作为实验模型,主要是因为它性价比高,API 易于使用,适合做这种工程化尝试。

3.1 环境与依赖准备

首先,你需要一个 DeepSeek 的 API Key。前往其平台注册获取即可。然后安装必要的 Python 库:

pip install openai  # DeepSeek 兼容 OpenAI SDK
pip install tiktoken  # 用于计算 Token,管理长度

这里使用 openai 这个通用库,因为 DeepSeek 的 API 格式与 OpenAI 兼容,只需修改 base_url api_key 即可。

3.2 核心代码结构解析

我设计了一个相对独立的 DialogueSummarizer 类,它可以比较容易地集成到现有的“酒馆”后端中。

import json
import tiktoken
from openai import OpenAI

class DialogueSummarizer:
    def __init__(self, api_key, model="deepseek-chat", summary_model="deepseek-chat",
                 max_recent_tokens=500, summary_trigger_rounds=5):
        """
        初始化对话摘要器。
        
        Args:
            api_key: DeepSeek API Key
            model: 用于对话的主模型
            summary_model: 用于生成摘要的模型(可以和主模型相同)
            max_recent_tokens: 触发摘要的未摘要内容最大Token数
            summary_trigger_rounds: 触发摘要的最大对话轮次数(保底)
        """
        self.client = OpenAI(
            api_key=api_key,
            base_url="https://api.deepseek.com"  # DeepSeek API 端点
        )
        self.model = model
        self.summary_model = summary_model
        self.max_recent_tokens = max_recent_tokens
        self.summary_trigger_rounds = summary_trigger_rounds
        
        # 状态存储
        self.summary = ""  # 当前摘要
        self.recent_messages = []  # 未摘要的原始消息列表,每个元素是 {"role": "user"/"assistant", "content": "..."}
        self.rounds_since_summary = 0  # 自上次摘要以来的对话轮数
        
        # 用于计算 Token 的编码器(以 gpt-3.5-turbo 近似,实际需根据模型调整)
        self.encoder = tiktoken.get_encoding("cl100k_base")
        
    def count_tokens(self, text):
        """计算文本的 Token 数。"""
        return len(self.encoder.encode(text))
    
    def _messages_to_token_count(self, messages):
        """估算消息列表的总Token数(简易估算)。"""
        count = 0
        for msg in messages:
            # 粗略估算:内容 + 角色标记
            count += self.count_tokens(msg["content"]) + 5
        return count

这个类封装了核心状态和配置。 summary recent_messages 是关键。 max_recent_tokens summary_trigger_rounds 是触发摘要的双重条件。

3.3 摘要生成:提示词工程是关键

摘要的质量直接决定了后续对话的质量。一个糟糕的摘要会让模型“失忆”或“记忆错乱”。因此,设计一个好的提示词(Prompt)至关重要。

    def _generate_summary_prompt(self, old_summary, new_messages):
        """构造生成摘要的提示词。"""
        # 将消息列表格式化成可读的对话文本
        dialogue_text = ""
        for msg in new_messages:
            role = "用户" if msg["role"] == "user" else "助手"
            dialogue_text += f"{role}: {msg['content']}\n"
        
        prompt = f"""你是一个专业的对话摘要助手。你的任务是将一段对话历史(可能包含之前的摘要和新的对话内容)压缩成一个简洁、连贯的段落摘要。

请遵循以下原则:
1. **保留核心事实和承诺**:人物、地点、关键事件、做出的决定、答应的事情必须保留。
2. **保留对话目标和上下文**:清楚说明当前在讨论什么主题、解决什么问题、处于什么阶段。
3. **保留重要的情感基调或角色设定**:例如,如果用户正在扮演一个侦探,或者对话氛围是严肃的,需要在摘要中体现。
4. **忽略重复、客套、无关紧要的细节**:精简表达,合并同类项。
5. **摘要语言为中文**,保持客观、流畅,用第三人称概述。
6. **新摘要应能独立存在**,让一个没看过原始对话的人也能理解目前的状况。

以下是需要处理的材料:
【已有的上一版摘要】
{old_summary if old_summary else "(无上一版摘要)"}

【新增的对话内容】
{dialogue_text}

请生成新版摘要:"""
        return prompt

这个提示词明确了摘要的职责:不是复述,而是 提炼 。它强调了保留什么(核心事实、目标、设定),忽略什么(重复细节),并且要求摘要能“独立存在”。这对于后续将摘要作为独立上下文使用非常重要。

3.4 触发与更新摘要的逻辑

这是整个流程的控制中枢。它决定了“什么时候该花钱生成摘要了”。

    def _should_generate_summary(self):
        """判断是否需要生成摘要。"""
        # 条件1:未摘要的原始对话长度超过阈值
        recent_tokens = self._messages_to_token_count(self.recent_messages)
        if recent_tokens >= self.max_recent_tokens:
            return True
        
        # 条件2:未摘要的对话轮数超过阈值(保底机制,防止长时间不摘要)
        if self.rounds_since_summary >= self.summary_trigger_rounds:
            return True
        
        return False
    
    def _update_summary(self):
        """执行摘要生成和状态更新。"""
        if not self.recent_messages:
            # 没有新内容,无需更新
            return
        
        print(f"[Summarizer] 触发摘要生成,近期消息数:{len(self.recent_messages)}")
        
        prompt = self._generate_summary_prompt(self.summary, self.recent_messages)
        
        try:
            response = self.client.chat.completions.create(
                model=self.summary_model,
                messages=[{"role": "user", "content": prompt}],
                temperature=0.2,  # 低温度,确保摘要稳定、客观
                max_tokens=500    # 控制摘要长度
            )
            new_summary = response.choices[0].message.content.strip()
            
            # 更新状态
            self.summary = new_summary
            self.recent_messages = []  # 清空近期消息
            self.rounds_since_summary = 0
            
            print(f"[Summarizer] 摘要更新成功,长度:{self.count_tokens(new_summary)} tokens")
            print(f"[Summarizer] 新摘要内容:{new_summary[:100]}...")
            
        except Exception as e:
            print(f"[Summarizer] 摘要生成失败:{e}")
            # 失败处理:可以选择保留旧摘要和近期消息,下次再试;或者放弃本次摘要。
            # 这里简单打印错误,不更新状态,让历史继续累积。

这里有几个工程细节:

  1. 双触发条件 max_recent_tokens 是主要条件,控制成本; summary_trigger_rounds 是保底条件,防止因消息都很短而永远不触发摘要,导致早期关键信息在 recent_messages 里留存过久。
  2. 低温度(Temperature) :摘要需要稳定、客观,所以温度设低(如0.2),减少随机性。
  3. 异常处理 :摘要生成可能失败(网络、API限制等)。必须有降级方案。这里选择了保守策略:失败则跳过本次摘要,历史继续累积。你也可以选择重试,但要避免死循环。
  4. 日志 :打印关键日志,便于调试和观察摘要触发频率、长度。

3.5 集成到对话主循环

最后,我们需要一个方法来处理用户输入,并返回模型回复。这个方法内部集成了摘要的触发、上下文的构建。

    def chat_round(self, user_input):
        """处理一轮用户对话。"""
        # 1. 将用户输入存入近期消息
        self.recent_messages.append({"role": "user", "content": user_input})
        self.rounds_since_summary += 1
        
        # 2. 判断并执行摘要更新
        if self._should_generate_summary():
            self._update_summary()
        
        # 3. 构建本次请求的上下文消息
        messages_for_api = []
        
        # 3.1 如果有摘要,先加入系统提示,说明背景
        if self.summary:
            # 注意:这里将摘要放在一个 `system` 消息中,是一种常见做法。
            # 也可以放在一个 `user` 消息中,以“以下是对话摘要:”开头。取决于模型对系统消息的理解。
            messages_for_api.append({
                "role": "system",
                "content": f"以下是之前对话的摘要,请基于此背景继续对话:\n{self.summary}"
            })
        
        # 3.2 加入未摘要的近期原始对话
        for msg in self.recent_messages[:-1]:  # 不包括刚刚加入的本次用户输入
            messages_for_api.append(msg)
        
        # 3.3 加入本次用户输入
        messages_for_api.append({"role": "user", "content": user_input})
        
        # 4. 调用对话模型
        try:
            response = self.client.chat.completions.create(
                model=self.model,
                messages=messages_for_api,
                temperature=0.7,  # 对话可以有一定创造性
                max_tokens=2048
            )
            assistant_reply = response.choices[0].message.content
            
            # 5. 将助手回复存入近期消息
            self.recent_messages.append({"role": "assistant", "content": assistant_reply})
            self.rounds_since_summary += 1  # 助手回复也算一轮
            
            return assistant_reply
            
        except Exception as e:
            print(f"[Chat] 对话调用失败:{e}")
            # 从 recent_messages 中移除本次失败的输入,避免状态不一致
            self.recent_messages.pop()
            self.rounds_since_summary -= 1
            return f"抱歉,对话处理出现错误:{e}"

这个 chat_round 方法就是一个完整的、带有摘要功能的工作流。它自动管理历史状态,开发者只需要不断喂入 user_input 并获取回复即可。

3.6 状态持久化

上面的例子中,状态保存在内存里。对于真正的“酒馆”应用,你需要将 summary recent_messages 持久化到数据库(如 SQLite、PostgreSQL)或文件中,并与每个对话会话(Session)关联。这样服务器重启后,对话记忆不会丢失。

4. 效果评估、潜在问题与优化方向

实现之后,需要验证它是否真的达到了我们的目标。

4.1 效果评估指标

  1. Token 节省率 :这是最直接的指标。对比使用摘要前后,相同轮次对话的累计输入 Token 总量。通常能在长对话中节省 50%-80% 的输入 Token。
  2. 缓存命中率提升 :如果你的后端使用了缓存(如 Redis),可以监控以摘要为前缀的请求的缓存命中率。摘要相对稳定后,命中率应有显著提升。
  3. 对话连贯性主观评价 :找一些测试用例,进行长对话(如 30+ 轮),然后让人评价摘要版本和完整历史版本在逻辑连贯性、事实一致性上是否有明显差异。理想情况下应无明显感知差异。
  4. 单轮响应延迟 :由于输入上下文变短,单次 API 调用的响应时间可能会略有下降。

4.2 可能遇到的问题与解决方案

  1. 摘要信息丢失或扭曲

    • 现象 :模型在后续对话中“忘记”了摘要中本应包含的关键信息,或对摘要的理解出现偏差。
    • 排查 :首先检查摘要生成提示词是否足够强调“保留核心事实”。其次,检查摘要模型的能力,对于特别复杂或微妙的信息,可能需要更强大的模型(如 DeepSeek 的最新版本)来生成摘要。
    • 优化 :在提示词中加入具体例子。或者采用“关键信息提取+摘要”的两步法:先让模型从历史中提取出关键实体、事实、承诺列表,再基于列表生成连贯摘要。
  2. 摘要更新过于频繁,成本反增

    • 现象 max_recent_tokens 设置过小,导致每聊几句就生成一次摘要,摘要生成成本超过了节省的成本。
    • 解决 :调整触发阈值。进行成本估算:假设生成一次摘要需要 S 个 Token,平均每次对话输入节省 T 个 Token。那么需要满足 S < n * T (n 是触发间隔内的对话轮数)才能有净节省。通过实验找到一个平衡点。
  3. 对话风格或角色设定被淡化

    • 现象 :在角色扮演中,摘要可能只保留了事实,但丢失了角色的语气、性格特点。
    • 优化 :在摘要提示词中明确要求:“保留对话中体现的角色性格和语言风格特点”。或者,将“角色设定”单独存储为一个固定的系统提示(System Prompt),不参与摘要过程,每次对话都固定注入。
  4. 多话题交错时摘要混乱

    • 现象 :对话可能在多个话题间来回切换,摘要可能将它们混为一谈,导致后续模型困惑。
    • 优化 :实现更智能的“话题分割”检测。当检测到话题明显转换时,强制触发一次摘要,将上一个话题封存。这可以通过分析消息内容的相似度,或训练一个简单的话题分类器来实现。

4.3 进阶优化方向

  1. 分层摘要 :不只有一个摘要,而是维护一个“摘要栈”。例如,最近10轮对话有详细摘要,更早的对话有更概括的摘要。在构造上下文时,按需组合不同层级的摘要。
  2. 向量检索辅助 :将历史对话的每一轮都生成向量嵌入(Embedding)存入向量数据库。当需要回忆某个具体细节时,可以用当前问题去检索最相关的历史片段,作为“补充记忆”插入上下文。这实现了“摘要提供主线,检索补充细节”的混合记忆模式。
  3. 摘要模型微调 :如果你有大量的领域特定对话数据,可以微调一个小模型专门做摘要任务,可能比通用大模型更准确、更廉价。
  4. 成本与质量的自适应平衡 :系统可以动态调整摘要的“精细度”。在成本敏感时,生成更简略的摘要;在需要高质量连贯性时,生成更详细的摘要。

5. 总结:从“成本优化技巧”到“对话系统设计哲学”

回过头看,这个“酒馆聊天摘要插件”的实践,起点是一个具体的成本问题,但它的意义远不止于此。它迫使我们去重新思考人与 AI 对话的交互模式。

我们习惯了人类对话的模式:我们不会在脑子里逐字背诵所有过往对话,而是会形成一种“理解”、“印象”和“要点”。摘要正是让 AI 模拟这个过程。它不仅仅是一个缓存策略,更是一种 记忆管理机制

对于开发者而言,这意味着我们从“如何把更多历史塞给模型”的思维,转向“如何为模型提炼和呈现最相关的历史信息”。这是一种更高级的系统设计思路。它要求我们:

  • 理解模型的能力边界 :知道模型擅长从哪种形式的信息中提取价值。
  • 设计状态管理 :对话状态不再是简单的消息列表,而是包括摘要、近期消息、话题阶段等更复杂的结构。
  • 权衡工程取舍 :在信息完整性、计算成本、响应延迟和用户体验之间找到最佳平衡点。

实现这个插件的过程,也再次印证了一个朴素的道理:在 AI 应用开发中, 提示词工程、工作流设计和状态管理,往往比单纯追求更强大的模型更能带来质的提升 。用 DeepSeek 这样性价比优秀的模型,搭配一个精心设计的摘要工作流,完全可以在成本可控的前提下,实现堪比甚至超越直接使用超长上下文模型的对话体验。

如果你也在为长对话的成本和效率发愁,不妨从实现一个简单的摘要模块开始。先设定一个固定的长度阈值,用清晰的提示词让模型生成摘要,观察效果。这个过程本身,就是对对话系统本质的一次深入理解。

更多推荐