5.3. 摘要即过滤:用 LLM 压缩上下文

摘要即过滤:用 LLM 压缩上下文

还记得上一章结尾你写下的那条监控日志吗?“滑动窗口丢弃了前 32 轮对话”。那一刻,窗口外的历史对你来说是一片黑暗区域——你不知道里面有没有客户那句“我再给你们最后一次机会”,也不知道有没有上个问题的解决方案。事实上,大多数客服升级投诉都发生在第 20~50 轮对话的某个关键信息被遗忘之后。

从 2024 年到 2026 年,几乎所有主流 Agent 框架都将“摘要记忆”作为生产级标配。本章我们不再让旧历史消失,而是把它压成一片浓缩胶囊,随时可以塞回窗口内。


你需要什么

资源 说明
Python 3.10+ 运行环境
OpenAI API Key 或任意兼容 Chat 模型
langchain, langchain-openai, rouge-score pip install langchain langchain-openai rouge-score
一份长度超过 2000 token 的对话日志 可用项目真实日志或我们提供的示例

预计完成时间:45~60 分钟


最终成果

你将得到三个可直接集成到 Agent 内存系统的模块:

  1. 递归摘要器:即使对话长度超过模型上下文窗口,也能稳定输出高密度摘要。
  2. 情感感知摘要模板:在压缩过程中保留用户情绪信号与紧急度标签。
  3. 摘要质量自动评估脚本:用 LLM 评判器 + ROUGE 指标衡量关键信息召回率。

为什么做这个?因为只有把历史“转译”成模型可理解的紧凑表示,你才能在不突破 token 上限的前提下,让 Agent 拥有超过 50 轮的真正长记忆。


步骤 1:搭建环境并快速体验 ConversationSummaryMemory

第一步先不做任何复杂优化,只是用 LangChain 自带的 ConversationSummaryMemory 看一下抽象后的信息长什么样。

# 01_quick_summary_memory.py
import os
from langchain.memory import ConversationSummaryMemory
from langchain_openai import ChatOpenAI
from langchain.chains import ConversationChain

# 初始化模型
llm = ChatOpenAI(
    model="gpt-4o-mini",  # 生产环境可根据性价比选择
    temperature=0.3,
    openai_api_key=os.getenv("OPENAI_API_KEY")
)

# 创建带摘要记忆的对话链
memory = ConversationSummaryMemory(llm=llm, max_token_limit=150)
conversation = ConversationChain(
    llm=llm,
    memory=memory,
    verbose=True  # 观察每次调用时注入的摘要
)

# 模拟一段客服对话
turns = [
    "我的订单 #38291 至今未收到,已经过了承诺的 3 天。",
    "非常抱歉给您带来困扰,我立刻查询物流信息。",
    "上次你们也这样说,结果没有任何进展。",
    "我已经联系物流,他们会加急处理,我备注了紧急。",
    "好吧,但这是我最后一次信任你们。"
]

for msg in turns:
    resp = conversation.predict(input=msg)
    # 打印当前累积的摘要,观察其演变
    print(f"[摘要] {memory.buffer}\n")

预期结果

  • 第 1~2 轮后,摘要类似:“用户反馈订单 #38291 未按时送达,客服已道歉并承诺查询。”
  • 第 4~5 轮后,摘要会变得更提炼,例如:“用户因订单 #38291 配送延迟连续投诉,情绪不满,客服已标记紧急并加急物流。用户表示这是最后一次信任。”

更重要的是:你在 max_token_limit=150 的约束下,仍然保留了“最后一次信任”这类隐蔽但关键的情绪线索。这就是摘要过滤的第一个价值——它强行筛选出最核心的语义单元。

踩坑注意

⚠️ ConversationSummaryMemory 在每次新消息到达时会发起一次 LLM 调用更新摘要。如果对话频率极高,你需要预算额外的推理成本。同时,默认 prompt 追求普适性,可能抹平领域特定的重要细节(比如订单编号中间段的变更)。下一步我们就来解决这两个问题。


步骤 2:递归摘要——当对话长度超出模型上下文时

ConversationSummaryMemory 的致命短板是:它的摘要 prompt 本身就受上下文窗口限制。当原始历史已经超过 16k token,你不可能直接把整段对话扔给模型去生成摘要。这时需要“先分段,后聚合”的递归摘要策略,LangChain 称之为 load_summarize_chainmap_reduce 模式。

假设我们有超过 5000 字的客服对话日志(你可以在本章仓库中找到 sample_long_log.txt)。以下代码将它安全地压缩成固定长度的摘要。

# 02_recursive_summarizer.py
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import ChatOpenAI
from langchain.chains.summarize import load_summarize_chain
from langchain.docstore.document import Document
import os

llm = ChatOpenAI(
    model="gpt-4o-mini",
    temperature=0.1,  # 摘要任务用较低温度
    openai_api_key=os.getenv("OPENAI_API_KEY")
)

# 从文件读取长对话日志
with open("sample_long_log.txt", "r") as f:
    long_conversation = f.read()

# 1. 分块:每块约 2000 字符,重叠 200 字符防止割裂关键句
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=2000,
    chunk_overlap=200,
    separators=["\n\n", "\n", "。", ".", " "]
)
docs = text_splitter.create_documents([long_conversation])
print(f"分段数:{len(docs)}")

# 2. 使用 map_reduce 链:每段先独立摘要,再汇总成一个最终摘要
chain = load_summarize_chain(
    llm,
    chain_type="map_reduce",
    verbose=True  # 观察 map 和 reduce 两阶段
)

final_summary = chain.invoke(docs)  # 自动处理 token 超限问题
print(f"\n=== 最终递归摘要 ({len(final_summary['output_text'])} 字符) ===\n")
print(final_summary["output_text"])

预期结果

  • 你会看到输出以 > Entering new MapReduceDocumentsChain... 开头,每块独立摘要完成后,第二个 LLM 调用将这些片段摘要融合成一个凝练的最终版。
  • 最终摘要长度通常在 300~500 字符之间,但完整保留了跨段落的因果链。例如:“对话前半段用户反复催促订单 #38291 物流,情绪逐渐恶化;后半段客服给出赔偿方案后态度缓和,但要求确认退款时间。”

踩坑经验

chunk_size 不能太大也不能太小。太大(如 8000)会导致 map 阶段单块摘要丢失细节;太小(如 500)会让 reduce 阶段面对过多碎片,汇总时容易遗漏远端信息。本章示例的 2000 是一个在 GPT-4o-mini 上经过实证的平衡值,但建议针对你的领域和模型做 3~5 组 AB 测试。


步骤 3:保留情感的摘要提示工程

现在你有了一个能对抗超长历史的摘要器,但它有一个隐患:压缩文本往往会把“语调”和“情绪”也一并压缩掉。在客服或谈判场景中,一句平静的“我同意”和一句咬着牙的“行吧,我同意”含义完全不同。

我们需要自定义摘要 prompt,让模型不单提取事实,还要标注情绪强度与紧急信号。以下是实现。

# 03_emotional_summarizer_prompt.py
from langchain.chains.summarize import load_summarize_chain
from langchain.prompts import PromptTemplate
from langchain_openai import ChatOpenAI
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.docstore.document import Document
import os

# 自定义 Map 阶段的 prompt:强调保留情感和情绪变化
map_template = """您是一名客服对话摘要专家。请将以下对话片段总结为一段高密度摘要。

要求:
1. 保留客户的核心诉求与客服的解决方案。
2. 必须记录客户的情绪状态(平静/烦躁/愤怒/绝望)及其转变点。
3. 如果出现“最后机会”“不再信任”“投诉升级”等紧急信号,必须在摘要中明确标注。
4. 用方括号 [ ] 包裹情绪标签,例如 [客户情绪:愤怒→略微缓和]。
5. 整个摘要不超过 200 字。

对话片段:
{text}

浓缩摘要:"""

map_prompt = PromptTemplate.from_template(map_template)

# 自定义 Reduce 阶段的 prompt:融合多个带情绪的片段摘要
reduce_template = """您需要将以下多段客服对话的片段摘要融合为一个完整摘要。

融合规则:
- 保持时间顺序。
- 整合所有出现的情绪转变,不可遗漏。
- 突出最终的情绪状态和未解决的问题。
- 最终摘要长度不超过 300 字。

片段摘要:
{text}

完整摘要:"""

reduce_prompt = PromptTemplate.from_template(reduce_template)

# 初始化 LLM 和分块器
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.2, openai_api_key=os.getenv("OPENAI_API_KEY"))
text_splitter = RecursiveCharacterTextSplitter(chunk_size=2000, chunk_overlap=200)

# 加载带情绪的对话日志(或复用上一节的日志)
with open("sample_long_log.txt", "r") as f:
    conversation = f.read()

docs = text_splitter.create_documents([conversation])

chain = load_summarize_chain(
    llm,
    chain_type="map_reduce",
    map_prompt=map_prompt,
    combine_prompt=reduce_prompt,
    verbose=False
)

emotional_summary = chain.invoke(docs)
print(emotional_summary["output_text"])

预期结果

输出类似于:

“客户因订单 #38291 配送延迟发起投诉 [客户情绪:烦躁→愤怒]。在客服多次承诺加急并给予赔偿后情绪略缓和,但用户警告这是最后一次信任[紧急信号:流失风险]。最终客户接受赔偿方案,但仍要求确认退款到账时间。”

对比普通摘要,你会看到情绪轨迹从一维事实变成了二维动态曲线。这让下游的 Agent 可以在触发退款流程时,自动提高处理优先级,或通知人工客服介入。

提示工程踩坑

⚠️ 不要在 prompt 中叠加过于复杂的指令,例如同时要求记录情绪、压缩长度、提取关键词、翻译成英文。每条指令都会消耗注意力,反而导致各维度都表现平庸。本章的建议是:用单独一条链负责情绪标注,别让它同时做事实提取。如果你需要极高精度,可以跑两条并行链,一条提取事实,一条提取情绪,最后再融合。


步骤 4:摘要质量自动评估

有了生成器,你必须能回答一个问题:“这个摘要丢掉了多少关键信息?” 我们引入两种评估手段:LLM 评判器进行语义层面的关键信息召回评估,以及 ROUGE 进行 n-gram 重叠计算。前者昂贵但准确,后者便宜但机械,两者结合可提供多维度质量信号。

# 04_evaluate_summary.py
from langchain_openai import ChatOpenAI
from langchain.evaluation import load_evaluator
from rouge_score import rouge_scorer
import os

# === 1. LLM 评判器:基于参考文本逐条检查关键信息 ===
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0, openai_api_key=os.getenv("OPENAI_API_KEY"))

# 使用 LangChain 的 labeled_pairwise_string 评估器(比较两份摘要的关键信息保留)
# 此处简化为:让 LLM 评判摘要是否涵盖原对话的关键点
evaluator = load_evaluator("labeled_criteria", criteria="correctness", llm=llm)

original_text = """(这里放入原始对话的前 500 字,或完整日志的缩减版)"""
summary_text = """(放入生成的摘要,如步骤 2 或 3 的输出)"""

eval_result = evaluator.evaluate_strings(
    prediction=summary_text,
    reference=original_text,
    input="请判断以上摘要是否准确反映了对话中的关键信息。"
)
print(f"[LLM评判] 分数: {eval_result['score']}, 理由: {eval_result['reasoning']}")

# === 2. ROUGE 自动指标 ===
scorer = rouge_scorer.RougeScorer(['rouge1', 'rouge2', 'rougeL'], use_stemmer=True)
scores = scorer.score(original_text, summary_text)

print(f"ROUGE-1 (单字重叠) F1: {scores['rouge1'].fmeasure:.3f}")
print(f"ROUGE-2 (双字重叠) F1: {scores['rouge2'].fmeasure:.3f}")
print(f"ROUGE-L (最长公共子序列) F1: {scores['rougeL'].fmeasure:.3f}")

预期结果

  • LLM 评判器会给出一个二进制分数或 1~5 的等级,并附上推理(例如:“摘要保留了订单号、客户诉求和解决方案,但遗漏了用户提到的‘上次问题也没解决’这一关键背景。”)
  • ROUGE 分数通常较低(0.15~0.35 之间),因为摘要大幅压缩了重复词和填充语,这并非问题,而是摘要的本质目的。你应该关注的是 ROUGE-L 能否稳定在一个可接受基线(比如 0.25 以上)。

评估踩坑

❗ ROUGE 完全不懂语义,如果你把“客户很生气”改成“客户情绪稳定”,ROUGE 不会惩罚。因此,评估管线必须包含 LLM 评判器这一“语义裁判”。但 LLM 评判器本身也有 bias,比如倾向于给较长摘要更高分。建议在对比不同压缩策略时,控制摘要长度在相同区间。


回顾

在这一章里,你从零构建了一条完整的摘要压缩流水线,用时约 50 分钟:

  1. ConversationSummaryMemory 体验了摘要记忆的雏形。
  2. map_reduce 递归摘要链攻克了超长对话无法直接摘要的难题。
  3. 通过情感感知提示工程,让压缩后的历史保留情绪动态,避免“去人性化”风险。
  4. 建立了 LLM + ROUGE 的双轨评估体系,使质量问题可量化、可回归。

现在你手中拥有了一套“压缩上下文”的武器。但压缩终究是一种被动的管理方式:即使摘要效率很高,你仍需要把不相关的历史塞进上下文,浪费宝贵的 token。下一章,我们将从“被动压缩”走向“主动选择”,让 Agent 自己判断哪些历史片段真正值得此刻的注意。


下一步行动清单

  • [ ] 在你的真实项目日志上运行步骤 3 的情绪摘要器,观察是否有未预见的情绪噪音。
  • [ ] 调整 chunk_sizetemperature,找到领域最优配置并记录成配置文件。
  • [ ] 将评估脚本集成到 CI 流程,每次修改 prompt 都自动跑一遍 ROUGE 基线。
  • [ ] 阅读下一章《智能上下文选择远比保留一切更有效》,准备将摘要与检索式记忆结合。

本文章首发在 LearnKu.com 网站上。

上一篇 下一篇
讨论数量: 0
发起讨论 查看所有版本


暂无话题~