系列文章导航:AI系列文章导航目录-持续更新中

第08课:上下文工程(Context Engineering)

📝 本文摘要:本文阐述从Prompt Engineering到Context Engineering的认知转变——核心工作不是写指令,而是管理LLM看到的全部信息(上下文窗口);详解上下文窗口本质、"Lost in the Middle"现象、上下文预算管理,以及五大核心技术(上下文压缩、排序优化、动态选择、缓存、长上下文处理策略),附上下文管理器设计框架和反模式分析。

2025年,业界开始从"提示词工程"转向"上下文工程"。这不是概念游戏,而是认知升级——你的核心工作不是写Prompt,而是管理LLM看到的所有信息。


一、从Prompt Engineering到Context Engineering

1.1 核心区别

一句话理解:Prompt Engineering关注"怎么写指令",Context Engineering关注"模型看到什么"。

Prompt Engineering(提示词工程):
  核心问题: "我怎么写指令让模型做对?"
  关注点: 指令文本本身的措辞、格式、技巧
  类比: 你在给员工写一封邮件,纠结措辞怎么写
  
Context Engineering(上下文工程):
  核心问题: "我怎么管理模型看到的所有信息?"
  关注点: 整个上下文窗口的内容编排——放什么、不放什么、放在哪里
  类比: 你在给员工准备一整套工作资料包(指令+参考文档+历史记录+工具说明)

为什么后者更重要?
  因为在实际的AI应用中(Agent、RAG、多轮对话),
  模型看到的不只是你写的那句指令,而是一大堆信息的组合。
  指令写得再好,如果其他信息是混乱的,模型照样会出错。

1.2 为什么需要这个转变

类比理解:想象你是一个老板,要给新来的实习生布置任务。你不能只说"帮我做个报告"(这是Prompt Engineering),你还需要给他准备好:之前的会议纪要、相关数据、参考模板、注意事项等等。这些"准备工作"就是Context Engineering。

上下文窗口 = 模型的"工作台"(模型一次能看到的所有信息)

一个真实的AI应用中,工作台上堆满了:
  - System Prompt (角色定义、规则)        ← 你写的指令
  - 对话历史 (之前的对话)                 ← 自动累积的
  - 检索结果 (RAG找来的文档)              ← 动态检索的
  - 工具描述 (Function Calling的定义)     ← 预定义的
  - 工具返回结果 (上一步的执行结果)       ← 动态产生的
  - 用户当前输入                          ← 用户给的

你写的指令只占其中很小一部分!大部分内容是动态产生的。

如果工作台混乱:
  - 重要信息被淹没 → 模型忽略关键指令(你的规则被10篇文档淹没了)
  - 无关信息太多 → 模型被干扰(检索了不相关的文档)
  - 信息冲突 → 模型自相矛盾(文档A说向左,文档B说向右)
  
Context Engineering就是: 把工作台整理得井井有条
  → 决定放什么(选择)
  → 决定不放什么(过滤)
  → 决定放在哪里(排序)
  → 决定放多少(压缩)

二、上下文窗口的本质

2.1 上下文窗口是什么

一句话理解:上下文窗口就是模型一次能"看到"的最大信息量。超过这个量,模型就看不到了。

模型的上下文窗口 = 一次推理能处理的最大Token数

什么是Token?
  Token是模型处理文本的最小单位。
  英文: 1个单词 ≈ 1-2个tokens ("hello" = 1 token, "engineering" = 2 tokens)
  中文: 1个汉字 ≈ 1-2个tokens ("你好" ≈ 2 tokens)
  代码: 变量名、符号各算token

主流模型的上下文窗口大小:
  GPT-4o:          128K tokens  ≈ 约10万字中文 ≈ 一本中等长度的书
  Claude 4:        200K tokens (长上下文版本支持1M tokens)
  Gemini 2.5 Pro:  1M tokens   ≈ 约75万字中文 ≈ 好几本书
  Qwen2.5-7B:     128K tokens

注意: 上下文窗口 = 输入 + 输出 的总和
  如果窗口是128K,你输入了100K,模型最多只能生成28K的回答

2.2 上下文窗口的"有效使用"问题

关键认知:上下文窗口大 ≠ 随便塞。模型对不同位置的信息关注度是不一样的!

❌ 错误认知: "128K上下文,塞多少都没问题"
✅ 正确认知: 模型对上下文不同位置的关注度不同

Lost in the Middle现象 (Liu et al., 2023):
  论文全名: "Lost in the Middle: How Language Models Use Long Contexts"
  
  核心发现:
  - 上下文开头的信息:关注度最高 ← 模型最先处理,印象最深
  - 上下文结尾的信息:关注度较高 ← 离生成最近,记忆最新鲜
  - 上下文中间的信息:容易被忽略!← 被两头夹在中间,容易"遗忘"
  
  可视化:
  [高关注度] ████──────────████ [高关注度]
  [开头信息] [  中间信息被忽略  ] [结尾信息]

  类比: 就像你读一本很长的书,最记得开头和结尾,中间的章节容易忘。

  实际影响:
  如果你把关键指令放在上下文中间(比如被大量检索文档包围),
  模型很可能会"忘记"这条指令,导致输出不符合预期。

2.3 上下文预算

一句话理解:上下文窗口就像一个固定大小的背包,你要合理分配空间给不同物品,不能超重。

总预算: 128K tokens(背包总容量)
  - System Prompt:     ~2K tokens   (角色定义+规则,必须带)
  - 对话历史:          ~20K tokens  (之前聊了什么,越聊越多!)
  - 工具描述:          ~3K tokens   (告诉模型有哪些工具可用)
  - RAG检索结果:       ~10K tokens  (检索到的参考文档)
  - 用户输入:          ~1K tokens   (用户当前的问题)
  ─────────────────────────────────
  已用:                ~36K tokens
  剩余给模型生成:      ~92K tokens  (模型回答的空间)

看起来还剩很多?但实际场景中:
  - 对话聊了50轮 → 对话历史膨胀到80K tokens
  - 检索了5篇长文档 → RAG结果膨胀到50K tokens
  - 定义了30个工具 → 工具描述膨胀到15K tokens
  → 预算瞬间爆满,模型连回答的空间都没有了!

所以: 必须主动管理上下文预算
  → 对话太长?压缩早期对话为摘要
  → 文档太多?只保留最相关的
  → 工具太多?只加载当前需要的

三、上下文工程的核心技术

3.1 上下文压缩

目标:减少上下文占用,保留关键信息。

为什么需要压缩?

上下文窗口有限(预算有限),但信息会不断增长。尤其是对话历史——每聊一轮就多2条消息,聊50轮就有100条消息。如果不压缩,很快就会超出预算。压缩的核心思想是:用更少的tokens表达同样的关键信息

对话历史压缩
# 注意: 1轮对话 = 1条user消息 + 1条assistant回复 = 2条messages

# 方法1: 滑动窗口——只保留最近N轮
messages = messages[-6:]  # -6条消息 = 最近3轮对话 (3轮 × 2条/轮)

# 方法2: 摘要压缩——把早期对话总结为摘要
# 当对话超过阈值时,用LLM把早期对话压缩为摘要
if len(messages) > 10:
    # messages[:-4]: 除最近2轮(4条)外的早期消息,送去压缩为摘要
    # messages[-4:]: 最近2轮(4条)保留原文,因为最近的上下文最重要不应丢失细节
    summary_prompt = f"请将以下对话历史压缩为简洁的摘要,保留关键信息:\n{format_messages(messages[:-4])}"
    summary = llm.call(summary_prompt)
    messages = [{"role": "system", "content": f"对话摘要: {summary}"}] + messages[-4:]

# 方法3: 混合策略——最近的对话保留原文,早期的压缩为摘要(方法2本身就是这个思路)
RAG结果压缩

为什么要压缩RAG结果?

RAG检索回来的文档往往包含大量与当前问题无关的内容。比如用户问"Python的GIL是什么",检索到一篇5000字的Python文章,但只有其中200字在讲GIL。如果把5000字全塞进上下文,既浪费预算又会干扰模型。

# 方法1: 只返回相关段落,不返回整个文档
# (在检索阶段就做好切分,按段落/句子级别检索,而不是整篇文档)

# 方法2: 用LLM对检索结果做二次提取(检索后再压缩)
# 先用向量检索找到相关文档,再用LLM从中提取与问题直接相关的部分
compress_prompt = f"""从以下文档中提取与问题"{query}"相关的信息,去除无关内容:
{retrieved_docs}"""
compressed = llm.call(compress_prompt)
# 这样可能把5000字压缩到200字,只保留真正有用的信息

3.2 上下文排序

目标:利用模型对不同位置的关注度差异,把最重要的信息放在模型最容易"看到"的位置。

为什么需要排序?

前面2.2节提到了"Lost in the Middle"现象——模型对开头和结尾的信息关注度最高,中间的容易被忽略。所以我们不能随意堆放信息,而是要像摆货架一样,把"爆款商品"放在最显眼的位置(开头和结尾)。

基于"Lost in the Middle"的排序策略:

方案1: 最重要的信息放两头(推荐)
  [关键指令] [次要信息...] [关键示例] [用户问题]
  ↑ 开头放规则              ↑ 结尾放示例和问题
  
  为什么有效: 模型对开头和结尾关注度最高
  适用场景: 通用场景,尤其是有明确规则+示例的任务

方案2: 按相关性排序,最相关的放最后(紧挨用户问题)
  [低相关文档] [中相关文档] [高相关文档] [用户问题]
  
  为什么有效: 最相关的文档紧挨着问题,模型更容易把它们关联起来
  适用场景: RAG检索场景,有多个文档需要排列
  
方案3: 重复关键指令(首尾呼应)
  [System: 关键规则] [...中间大量内容...] [再次强调: 关键规则] [用户问题]
  
  为什么有效: 即使中间内容很多,首尾都有关键规则,模型不会遗忘
  适用场景: 上下文很长时,担心模型忘记System Prompt中的规则

实际例子

# 假设你在做一个RAG问答系统,检索到了3个文档片段
# 相关性分数: doc_A=0.95, doc_B=0.72, doc_C=0.61

# ❌ 错误: 随机排列
messages = [system_prompt, doc_C, doc_A, doc_B, user_question]

# ✅ 正确: 最相关的放最后(紧挨用户问题)
messages = [system_prompt, doc_C, doc_B, doc_A, user_question]
#           ↑ 开头(高关注)  ↑ 中间(低关注)    ↑ 结尾(高关注)

3.3 上下文选择(动态构建)

目标:不是把所有信息都塞进上下文,而是根据用户当前的问题,动态选择最相关的信息加载进来。

为什么需要动态选择?

想象你是一个客服,面前有10本手册(退货政策、物流查询、优惠活动、会员规则…)。用户问"我的快递到哪了",你不需要把10本手册全翻开放桌上,只需要翻开"物流查询"那本就够了。

对LLM来说也一样:

  • 上下文窗口有限(预算有限)
  • 无关信息会干扰模型(噪音)
  • 加载越多,推理越慢、成本越高
核心思路: 按需加载,不是全量加载

用户问题 → 意图识别 → 决定需要什么上下文 → 只加载相关部分

例子: 客服Agent(假设有20个工具和50篇知识文档)
  - 用户问"我的订单到哪了" → 只加载: 订单查询工具 + 用户订单历史
  - 用户问"怎么退货" → 只加载: 退货政策文档 + 退货申请工具
  - 用户问"有什么优惠" → 只加载: 当前促销信息
  
  每次只加载2-3个相关工具,而不是全部20个工具
  → 节省token预算,减少干扰,提升回答质量

实现方式

# 方式1: 基于关键词的简单路由(适合工具数量少的场景)
def select_tools(user_input, all_tools):
    """根据用户输入选择相关工具"""
    # 定义每个工具的触发关键词
    tool_keywords = {
        "order_query": ["订单", "快递", "物流", "到哪了"],
        "refund": ["退货", "退款", "退回"],
        "promotion": ["优惠", "折扣", "活动", "券"],
    }
    selected = []
    for tool_name, keywords in tool_keywords.items():
        if any(kw in user_input for kw in keywords):
            selected.append(all_tools[tool_name])
    return selected if selected else all_tools[:3]  # 没匹配到就给默认的

# 方式2: 基于语义相似度(适合工具数量多的场景)
# 把每个工具的描述做embedding,和用户问题计算相似度,选Top-K
def select_tools_by_embedding(user_input, all_tools, top_k=3):
    query_embedding = embed(user_input)
    scores = [(tool, cosine_sim(query_embedding, tool.embedding)) for tool in all_tools]
    scores.sort(key=lambda x: x[1], reverse=True)
    return [tool for tool, score in scores[:top_k]]

3.4 上下文缓存(Prompt Caching)

目标:避免对重复不变的上下文内容进行重复计算,降低延迟和成本。

为什么需要缓存?先理解LLM推理的计算过程:

每次你调用LLM API时,模型需要对你发送的所有tokens进行一次前向计算(生成KV Cache——这是Transformer注意力机制的中间计算结果)。但在实际应用中,很多内容每次请求都一模一样:

请求1: [System Prompt + 工具描述] + [用户问题A]
请求2: [System Prompt + 工具描述] + [用户问题B]
请求3: [System Prompt + 工具描述] + [用户问题C]
         ↑ 这5K tokens每次都一样 ↑    ↑ 只有这部分变化 ↑

如果每次都重新计算前面5K tokens的KV Cache → 纯粹的浪费!

缓存的核心原理:

Prompt Caching = 把不变的前缀部分的计算结果(KV Cache)存起来,下次直接复用

第一次请求:
  [System Prompt + 工具描述]  →  计算KV Cache  →  存入缓存
  [用户问题A]                →  正常计算
  总计算量: 5K + 1K = 6K tokens

第二次请求:
  [System Prompt + 工具描述]  →  命中缓存!直接复用(不重新计算)
  [用户问题B]                →  正常计算
  实际计算量: 只有1K tokens(省了5K的计算)

两家主流厂商的实现差异:

OpenAI(自动缓存,无需改代码):
  - 工作方式: API自动检测,如果连续请求的messages前缀相同,自动复用缓存
  - 省多少钱: 缓存命中的tokens按50%价格计费(省一半)
  - 限制: 前缀必须完全一致,改了一个字都不命中
  - 优点: 零改造成本,开箱即用

Anthropic(显式缓存,需要手动标记):
  - 工作方式: 你在API请求中手动标记哪些部分可以缓存
  - 省多少钱: 缓存命中的tokens按10%价格计费(省90%!)
  - 代价: 首次写入缓存时多付25%费用(之后就一直省钱)
  - 优点: 更灵活,可以精确控制缓存边界

Anthropic显式缓存的代码示例:

import anthropic

client = anthropic.Anthropic()

# 在system prompt上标记缓存点
response = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=1024,
    system=[
        {
            "type": "text",
            "text": "你是一个专业客服助手...(很长的系统提示,包含各种规则和知识)",
            "cache_control": {"type": "ephemeral"}  # ← 标记: 到这里为止的内容可以缓存
        }
    ],
    messages=[{"role": "user", "content": "我的订单到哪了?"}]
)
# 第一次请求: 计算system prompt的KV Cache并存入缓存(多付25%)
# 后续请求: 直接复用缓存,system prompt部分只收10%费用(省90%)

什么场景适合用缓存?

✅ 适合缓存(内容不变):
  - 固定的长System Prompt(角色定义、规则说明)
  - 大量工具描述(Function Calling的工具定义)
  - RAG中固定的知识库前缀
  - Few-shot示例(每次都一样的示例)

❌ 不适合缓存(内容每次都变):
  - 用户的输入问题
  - 动态检索的文档
  - 对话历史(每轮都在增长)

本质: Prompt Caching = 用存储空间换计算时间和金钱

3.5 长上下文处理策略

目标:当文档太长,一次塞不进上下文窗口(或者塞进去效果不好)时,如何处理。

为什么需要这些策略?

即使模型支持128K tokens,但:

  1. 文档可能超过128K(比如一本完整的技术手册)
  2. 即使塞得下,"Lost in the Middle"会导致中间内容被忽略
  3. 塞满上下文后,留给模型生成回答的空间就很少了

所以需要"分而治之"的策略:

策略1: 分块处理(Chunking)
─────────────────────────────
思路: 把长文档切成小块,只选相关的块放入上下文

原始文档(50页) → 切成50个块(每块1页) → 用向量检索找到最相关的3个块 → 只把这3块放入上下文

优点: 简单高效,是RAG的基础
缺点: 可能丢失跨块的上下文关联(比如第3页引用了第1页的定义)
适用: 问答场景,用户问题只涉及文档的局部内容


策略2: Map-Reduce
─────────────────────────────
思路: 对每个块分别处理(Map),再把所有结果合并为最终答案(Reduce)

原始文档 → 切成10个块
  ↓ Map阶段: 对每个块分别提问
  块1 → "这块中关于X的信息是: ..."  
  块2 → "这块中关于X的信息是: ..."  
  块3 → "这块中没有相关信息"
  ...  
  ↓ Reduce阶段: 把所有Map结果合并
  "综合所有块的信息,最终答案是: ..."

优点: 能处理任意长度的文档,不会遗漏信息
缺点: 需要多次LLM调用,成本高、速度慢
适用: 需要全文总结、全文分析的场景(如"总结这本书的核心观点")


策略3: 迭代精炼(Refine)
─────────────────────────────
思路: 逐块阅读,每读一块就更新一次答案,像人类逐页阅读一样

初始答案 = ""
读块1 → 更新答案: "根据第1块,答案是A"
读块2 → 更新答案: "结合第2块的新信息,答案修正为B"
读块3 → 更新答案: "第3块补充了细节,最终答案是B+细节C"
...

优点: 能保持上下文连贯性,后面的块可以修正前面的结论
缺点: 串行处理,速度慢;且对块的顺序敏感
适用: 需要连贯理解的场景(如"分析这篇论文的论证逻辑")


策略4: 分层摘要(Hierarchical Summarization)
─────────────────────────────
思路: 先对每块生成摘要,再对摘要生成摘要,层层压缩

10个块 → 10个摘要(每个100字) → 合并为1个总摘要(500字)

优点: 能把任意长文档压缩到可控大小
缺点: 每层压缩都会丢失细节
适用: 需要快速了解长文档大意的场景

实际代码示例(Map-Reduce):

def map_reduce_summarize(document: str, chunk_size=2000):
    """用Map-Reduce策略总结长文档"""
    # 1. 分块
    chunks = [document[i:i+chunk_size] for i in range(0, len(document), chunk_size)]
    
    # 2. Map阶段: 对每个块生成摘要
    chunk_summaries = []
    for i, chunk in enumerate(chunks):
        response = llm.call(f"请用2-3句话总结以下内容的要点:\n{chunk}")
        chunk_summaries.append(response)
    
    # 3. Reduce阶段: 合并所有摘要为最终总结
    all_summaries = "\n".join(f"第{i+1}部分: {s}" for i, s in enumerate(chunk_summaries))
    final_summary = llm.call(f"请将以下各部分摘要合并为一个连贯的总结:\n{all_summaries}")
    
    return final_summary

四、上下文工程的实战框架

4.1 上下文管理器设计

这是什么? 把前面学到的所有技术(压缩、排序、选择、预算管理)整合到一个类里,每次调用LLM前自动构建最优的上下文。你可以把它理解为一个"上下文调度中心"。

核心流程

用户输入 → ContextManager.build_context() → 最终的messages列表 → 发送给LLM

build_context内部做了什么:
  1. 放入System Prompt(必须有,不可压缩)
  2. 根据用户问题选择相关工具(动态选择,不是全部加载)
  3. 压缩检索文档到预算内(按相关性排序,超预算的丢弃)
  4. 压缩对话历史到预算内(最近的保留原文,早期的压缩为摘要)
  5. 放入用户当前输入
class ContextManager:
    """管理LLM的上下文窗口"""
    
    def __init__(self, max_tokens=128000):
        self.max_tokens = max_tokens
        self.system_prompt = ""
        self.tools = []
        self.history = []
        self.retrieved_docs = []
    
    def build_context(self, user_input: str) -> list:
        """构建最终的messages"""
        budget = self.max_tokens
        
        # 1. System Prompt (不可压缩)
        messages = [{"role": "system", "content": self.system_prompt}]
        budget -= count_tokens(self.system_prompt)
        
        # 2. 工具描述 (不可压缩,但可按需加载)
        tool_descriptions = self._select_relevant_tools(user_input)
        budget -= count_tokens(tool_descriptions)
        
        # 3. 检索结果 (可压缩)
        docs_budget = min(budget * 0.3, 20000)  # 最多用30%预算
        docs = self._compress_docs(self.retrieved_docs, docs_budget)
        context_with_docs = self._format_docs(docs)
        budget -= count_tokens(context_with_docs)
        
        # 4. 对话历史 (可压缩)
        history = self._compress_history(self.history, budget * 0.5)
        messages.extend(history)
        budget -= count_tokens(history)
        
        # 5. 当前输入 + 文档上下文
        messages.append({"role": "user", "content": 
            f"{context_with_docs}\n\n{user_input}" if context_with_docs else user_input})
        
        return messages
    
    def _compress_history(self, history, budget):
        """压缩对话历史到预算内"""
        token_count = count_tokens(history)
        if token_count <= budget:
            return history
        
        # 保留最近2轮,早期压缩为摘要
        recent = history[-4:]
        older = history[:-4]
        summary = self._summarize(older)
        return [{"role": "system", "content": f"Earlier conversation summary: {summary}"}] + recent
    
    def _select_relevant_tools(self, user_input):
        """根据用户输入选择相关工具"""
        # 简单实现: 基于关键词匹配
        # 生产级: 用嵌入相似度匹配
        return [t for t in self.tools if self._is_relevant(t, user_input)]
    
    def _compress_docs(self, docs, budget):
        """压缩检索文档到预算内"""
        result = []
        remaining = budget
        for doc in sorted(docs, key=lambda d: d.relevance, reverse=True):
            tokens = count_tokens(doc.content)
            if tokens <= remaining:
                result.append(doc)
                remaining -= tokens
        return result

五、上下文工程的反模式

反模式 = 常见的错误做法。了解这些"坑",可以帮你避免踩雷。

5.1 塞入过多信息

错误心态:“信息越多越好,模型自己会找有用的”

❌ 错误做法: 把10篇完整文档全部放入上下文
✅ 正确做法: 只放入与问题最相关的段落

为什么这是错的?
  1. 稀释效应: 关键信息被大量无关内容淹没,模型找不到重点
     类比: 在100页文件中找1句关键信息 vs 在1页纸上找 → 后者准确率高得多
  
  2. 干扰效应: 无关信息可能误导模型
     例子: 用户问"Python的GIL",你塞了一篇讲Java线程的文档
     → 模型可能把Java的信息混入回答中
  
  3. 预算浪费: 无关内容占用了宝贵的token预算
     → 留给模型生成回答的空间变少了

经验法则: 宁可少放,不要多放。质量 > 数量。

5.2 忽略信息冲突

错误心态:“把所有信息都给模型,它自己会判断哪个对”

❌ 错误做法: System Prompt说"用中文回答",但检索到的文档里有"Please respond in English"
✅ 正确做法: 检查上下文中的信息是否一致,发现冲突时主动处理

为什么这是错的?
  模型面对冲突信息时,行为是不确定的——有时听System Prompt,有时听文档。
  这会导致输出不稳定、不可预测。

常见冲突场景:
  - System指令 vs 检索文档中的指令
    例: System说"简洁回答",文档里有"请详细解释每个步骤"
    
  - 不同文档的信息矛盾
    例: 文档A说"截止日期是3月",文档B说"截止日期是5月"
    
  - 对话历史中的旧信息 vs 新信息
    例: 用户之前说"我用的是Mac",后来又说"我用的是Windows"

解决方法:
  - 在System Prompt中明确优先级: "如果文档与指令冲突,以指令为准"
  - 检索后做冲突检测,去除矛盾文档
  - 对话历史中标记信息的时效性

5.3 不做位置优化

错误心态:“反正都在上下文里,放哪里都一样”

❌ 错误做法: 把重要指令埋在上下文中间(被大量文档包围)
✅ 正确做法: 关键指令放开头或结尾

为什么这是错的?
  回顾"Lost in the Middle"——中间的信息最容易被忽略。
  如果你的关键规则(如"不要编造信息""必须用中文回答")被放在中间,
  模型很可能会"忘记"这些规则。

实际建议:
  - System Prompt(开头): 放核心规则、角色定义
  - 中间: 放参考文档、对话历史等辅助信息
  - 用户问题前(结尾): 再次强调关键规则(首尾呼应)
  
  例:
  [System: 你是客服,必须用中文回答,不要编造信息]
  [... 大量检索文档 ...]
  [提醒: 请记住用中文回答,不要编造信息]
  [User: 我的订单到哪了?]

📝 作业

作业1:实现一个简单的上下文管理器

实现一个对话历史的滑动窗口+摘要压缩策略:

  1. 保留最近4条消息原文
  2. 早期消息用LLM生成摘要
  3. 当总token超过阈值时触发压缩

参考答案

from openai import OpenAI

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")

class SimpleContextManager:
    def __init__(self, max_history_tokens=4000):
        self.max_history_tokens = max_history_tokens
        self.messages = []
        self.summary = ""
    
    def add_message(self, role, content):
        self.messages.append({"role": role, "content": content})
        self._maybe_compress()
    
    def _maybe_compress(self):
        """当历史过长时压缩"""
        total = sum(len(m["content"]) for m in self.messages)
        # 粗略估算: 1个中文字 ≈ 2 tokens
        if total * 2 > self.max_history_tokens and len(self.messages) > 4:
            # 保留最近4条
            older = self.messages[:-4]
            recent = self.messages[-4:]
            
            # 压缩旧消息
            older_text = "\n".join(f"{m['role']}: {m['content']}" for m in older)
            summary_response = client.chat.completions.create(
                model="qwen2.5:7b",
                messages=[{
                    "role": "user", 
                    "content": f"请将以下对话历史压缩为简洁摘要,保留关键信息和结论:\n{older_text}"
                }],
                temperature=0.0,
                max_tokens=500
            )
            self.summary = summary_response.choices[0].message.content
            self.messages = recent
    
    def get_messages(self, system_prompt=""):
        """获取完整的消息列表"""
        result = []
        if system_prompt:
            result.append({"role": "system", "content": system_prompt})
        if self.summary:
            result.append({"role": "system", "content": f"之前的对话摘要: {self.summary}"})
        result.extend(self.messages)
        return result

# 测试
cm = SimpleContextManager(max_history_tokens=1000)
cm.add_message("user", "什么是Python?")
cm.add_message("assistant", "Python是一种高级编程语言...")
cm.add_message("user", "它有什么特点?")
cm.add_message("assistant", "Python的主要特点包括:简洁易读、动态类型、丰富的库...")
cm.add_message("user", "给我一个例子")
cm.add_message("assistant", "当然,这是一个简单的Python示例:print('Hello World')")

print(cm.get_messages(system_prompt="你是一个编程助手"))

作业2:验证"Lost in the Middle"

构造一个测试:在长上下文的不同位置放入关键信息,观察模型是否都能找到。

参考答案

# 构造一个包含20个"事实"的上下文,让模型找到特定事实

facts = [f"事实{i+1}: 第{i+1}个信息的内容是关于主题{chr(65+i%26)}的描述。" for i in range(20)]

# 在不同位置放入"目标事实"
target = "★目标事实: 答案是42★"

# 测试1: 目标在开头
context_start = target + "\n" + "\n".join(facts)

# 测试2: 目标在中间
context_middle = "\n".join(facts[:10]) + "\n" + target + "\n" + "\n".join(facts[10:])

# 测试3: 目标在结尾
context_end = "\n".join(facts) + "\n" + target

question = "请找到带有★标记的事实,告诉我答案是什么?"

for name, context in [("开头", context_start), ("中间", context_middle), ("结尾", context_end)]:
    response = client.chat.completions.create(
        model="qwen2.5:7b",
        messages=[{"role": "user", "content": f"{context}\n\n{question}"}],
        temperature=0.0
    )
    print(f"位置[{name}]: {response.choices[0].message.content[:50]}")

# 预期: 开头和结尾位置的准确率 > 中间位置
# 这就是"Lost in the Middle"效应

下一篇文章见:AI系列文章导航目录-持续更新中

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐