跳转至

上下文工程

上下文工程负责在有限窗口内选择、组织和更新模型真正需要的信息。其对象不只是提示词,还包括对话历史、检索文档、工具结果、记忆、代码和运行状态。

上下文管理

上下文管理 (Context Management) 决定哪些历史消息、系统规则、工具结果和检索内容进入模型的上下文窗口。它的由来是语言模型的两项硬约束:一是上下文窗口存在固定上限(不同模型从几千到数百万 token 不等,具体数值以各模型官方文档为准),二是注意力开销随序列长度增长,塞得越满推理越慢、成本越高。因此上下文管理不能「有多少放多少」,而是围绕 token 预算与信息优先级展开,目标是让模型始终看到当前任务最需要的内容。它与压缩、选择形成分工:管理定预算与结构,选择定放哪些内容,压缩定如何把放不下的内容变短。

快速开始

第一步是建立 token 预算。设模型上下文窗口上限为 \(W\),其中系统规则与安全约束占 \(S\),当前任务占 \(T\),为模型输出预留 \(O\),则留给历史与检索内容的预算为:

\[ H = W - S - T - O \]

\(H\) 就是动态内容可使用的上限,超出部分必须通过滚动、摘要或淘汰来释放。预算要留出余量,因为工具调用、检索注入和输出本身都会消耗 token,且各模型的 token 化规则不同,估算时应以实际计数为准。

第二步是定义保留优先级。通常系统规则与安全约束优先级最高,其次是当前任务目标和最近几轮对话,再次是已确认的用户偏好和关键证据;过早的细节可以摘要化或丢弃。这一步的本质是给每条内容打分并排序,只有排在前 \(H\) 预算内的内容才进入窗口。每次裁剪历史时,建议记录「保留了哪些、丢弃了哪些、为什么」,形成可审计的上下文清单。

第三步是设计滚动与回退策略。当对话变长时,可以按轮次滚动窗口,只保留最近 N 轮加上被标记为重要的更早内容。滚动窗口的经典实现是把 KV Cache 做成固定大小的环形缓冲:新 token 进入时覆盖最老 token,从而把内存占用压到常数级。滑动窗口注意力 (Sliding Window Attention, SWA) 正是这一思路在模型架构层面的体现,Longformer 与 Mistral 7B 都采用局部窗口加少量全局/滚动缓存来降低长序列成本。与之互补,StreamingLLM 指出只保留滑动窗口会导致困惑度崩溃,原因是模型依赖开头的「注意力汇」位置,因此需要在窗口之外额外保留起始 token,才能在无限流式输入下稳定运行。

发生错误时,先查看上下文清单判断是否丢掉了关键信息,而不是凭感觉猜测原因。上下文管理没有统一答案,应根据任务的上下文敏感度动态调整预算和优先级。一个最小实现可以先把「优先级 + 预算」固化成代码:

def build_context(items, budget):
    items = sorted(items, key=lambda x: x.priority, reverse=True)
    kept, used = [], 0
    for it in items:
        n = count_tokens(it)
        if used + n <= budget:
            kept.append(it)
            used += n
    return kept, used   # 返回保留下来的内容与已用 token 数

验证成功的标准是:任何时刻 used <= budget,回归集上「关键事实是否仍在窗口内」的命中率达到预期,且错误定位时能凭清单回答「丢了什么、为什么」。

案例

一个长会话的客服助手需要同时处理用户身份、订单信息和多轮追问。典型做法是:始终保留系统规则和用户已确认的偏好(例如配送地址、会员等级),保留最近几轮对话原文,把更早的轮次压缩为简短摘要。

当用户提出新问题时,助手根据当前任务从保留内容中挑选相关证据,把无关的历史摘要放在低优先级位置。这样既不会超出预算,也能保证回答所需的上下文仍然在场。对应到上面的伪代码,就是把「配送地址」「会员等级」设为高优先级字段,把「最近几轮原文」设为中优先级,把「更早摘要」设为低优先级,让 build_context 在预算内自动取舍。

如果助手答错,排查的第一步不是调模型,而是检查上下文清单:关键的用户偏好是否还在窗口内、摘要是否丢失了重要细节、检索结果是否被错误裁剪。把上下文管理做成显式、可回查的过程,能让问题定位从「猜」变成「查」。

上下文压缩

上下文压缩 (Context Compression) 通过摘要、信息提取或结构化状态,把长历史改写为更短的表示,从而降低 token 占用与推理成本。它源于一个直接的压力:标准 Transformer 的自注意力在计算与内存上都随序列长度呈 \(O(n^2)\) 增长,而长上下文与检索增强又不断把更多文本塞进窗口。压缩是一条可行的缓解路径,但本质是有损的,需要在压缩率与信息保真之间权衡。

压缩对象可以分成两个层次。一是「提示文本压缩」,在送入模型前直接缩短输入的 token 序列,代表方法是 LLMLingua 系列;二是「KV Cache 压缩」,在推理过程中对已缓存的关键值张量做稀疏化或淘汰,代表方法有 H2O、StreamingLLM 与滑动窗口注意力。两者都服务于「用更少资源保留更多有用信息」,但作用位置不同:前者改变输入文本,后者改变模型内部状态。

快速开始

压缩的起点是明确「哪些信息不可丢失」,再选择与信息类型匹配的压缩方式。常用的压缩手段有三类:

  • 摘要:用模型把一段历史改写为更短的叙述,适合保留语义和上下文脉络,但可能丢失具体数值和措辞。
  • 提取:从原文中抽取行动项、决定、约束等结构化字段,保留关键事实,舍弃冗余叙述。
  • 结构化状态:把会话状态维护为键值对或对象(例如「用户偏好」「已确认事项」),每次更新只改增量,不重复保存全文。

这三类手段之外,还存在更细粒度的「token 级压缩」。以 LLMLingua 为例,它先用预算控制器按目标压缩率给每段子提示分配预算,再用一个小语言模型计算每个 token 的条件困惑度,剔除低信息量 token;由于小模型只做压缩、大模型做下游推理,压缩开销远低于直接推理。其后续工作 LLMLingua-2 把压缩建模为「对每个 token 做保留/删除二分类」,用数据蒸馏训练一个与任务无关的 token 分类器,进一步提升了忠实度。面向长上下文的 LongLLMLingua 则引入「问题感知」的由粗到细压缩、文档重排与动态压缩率,在检索增强场景下同时降低延迟并改善下游效果。

压缩率的定义很直观。记原始文本为 \(x\),压缩后文本为 \(\hat{x}\),用 token 数 \(|\cdot|\) 度量长度,则压缩率 \(\tau\) 与保留率 \(\rho\) 分别为:

\[ \tau = \frac{|x| - |\hat{x}|}{|x|}, \qquad \rho = \frac{|\hat{x}|}{|x|} = 1 - \tau \]

其中 \(\tau\) 是压缩掉的比例,\(\rho\) 是保留下来的比例。压缩越激进,\(\tau\) 越大、\(\rho\) 越小,信息丢失风险越高。因此实施时通常先给定目标 token 预算 \(B\),再据此设定压缩强度,并在回归集上验证压缩后回答质量是否可接受。压缩率和质量并非线性关系:超过某个阈值后,进一步压缩会带来质量的断崖式下降,建议在开发阶段画出「\(\tau\) 与下游准确率」的曲线,选择拐点之前的取值。

若压缩作用于 KV Cache 而非输入文本,则压缩率对应「保留的 KV 比例」。例如 H2O 依据注意力分数把 token 分为「heavy hitter」与普通 token,淘汰命中率低的普通 token;StreamingLLM 则观察到即使只保留最近窗口,模型仍会关注开头的少数「注意力汇」 (attention sink) 位置,因此始终保留起始若干 token 再加滑动窗口,就能在无限流式输入下维持稳定。这些方法说明:压缩不只是「缩短文本」,还涉及对模型内部注意力分布的理解。

无论采用哪种方式,实施时都建议保留「原文 -> 摘要」之间的可追溯引用,例如在摘要后附上源消息编号或分块索引。这样当摘要出现偏差或需要细节时,可以按需回查原文,而不是被迫信任可能失真的摘要。对编号、金额、配置项等关键事实,应设置为不可丢失字段,强制从原文原样搬运,禁止被摘要改写。

案例

会议助手是典型的压缩场景。多轮会议会产生大量逐字记录,直接塞入窗口很快超出预算。常见做法是把每轮记录压缩为三类结构化字段:

  • 行动项:负责人、截止时间、具体动作。
  • 决定:已拍板的结论及其约束条件。
  • 待确认问题:尚未达成一致、需要后续跟进的事项。

一个最小实现可以先维护「结构化状态 + 原文索引」,只把增量写入状态,原文按轮次存档。伪代码如下:

def compress_round(round_text: str, state: dict) -> dict:
    fields = extract_fields(round_text)          # 抽取行动项/决定/待确认
    state["action_items"] += fields["action_items"]
    state["decisions"] += fields["decisions"]
    state["open_questions"] += fields["open_questions"]
    state["sources"].append(f"round_{len(state['sources'])}")  # 记录来源轮次
    return state

其中 extract_fields 可以是一次 LLM 调用(结构化输出)或规则抽取;sources 保留了「字段 -> 原文」的可追溯引用,回答前可按需回查原段。验证成功的标准是:压缩后的状态体积明显小于原文,同时回归集上「关键事实(编号、金额、负责人)」的召回率保持 100%,且摘要不改变原意。

压缩后的会议纪要体积远小于原文,但仍保留可执行信息。例如用户询问「上次关于预算的结论是什么」,助手先命中「决定」字段,再回查对应原文确认具体数字,避免摘要放大或扭曲原意。

常见失败点是摘要过度泛化,把「A 认为可选」写成「团队决定采纳」,导致后续动作基于错误前提。排查方向是检查摘要与原文的差异,对高风险字段保留原文引用,并在敏感场景将关键事实设为不可丢失字段。若采用 token 级压缩,还要留意压缩是否把否定词、数值单位等关键 token 一并删掉——这正是 LLMLingua 类方法强调「忠实压缩」的原因。

上下文选择

上下文选择 (Context Selection) 从候选历史消息和文档中,挑出与当前任务最相关、最可信的内容注入窗口。它与上下文管理互补:管理决定整体预算与结构,选择决定具体放哪些内容。选择问题的由来是「候选集合往往远大于窗口容量」:知识库可能包含成百上千个片段,而窗口只能装下几十段,于是「放什么」本身成为一个排序与过滤问题。检索增强生成 (Retrieval-Augmented Generation, RAG) 的检索环节本质上就是上下文选择,只是它强调「从外部知识库召回」,而本节的「选择」还覆盖历史消息、工具结果等一切候选来源。

快速开始

上下文选择的核心是「排序 + 过滤」:先给候选内容打分,再按 token 预算截取靠前的部分。打分维度通常包括:

  • 相关性:内容与当前问题的语义匹配程度,可由检索或模型判断得出。
  • 时效性:内容是否为最新版本;过期文档即使相似度高也应降权。
  • 权限:内容是否对当前用户可见,敏感数据必须过滤。
  • 可信度:来源是否权威、是否有引用或验证信息。

这四个维度可以合并成一个加权分。设候选 \(c\) 的归一化分量为 \(s_{\text{rel}}, s_{\text{fresh}}, s_{\text{auth}}, s_{\text{trust}} \in [0,1]\),权重为 \(\lambda_1,\dots,\lambda_4\) 且 \(\sum_i \lambda_i = 1\),则总分:

\[ \text{score}(c) = \lambda_1 s_{\text{rel}} + \lambda_2 s_{\text{fresh}} + \lambda_3 s_{\text{auth}} + \lambda_4 s_{\text{trust}} \]

其中权限维度通常不做连续打分,而是作为硬过滤条件先执行:\(s_{\text{auth}}=0\) 的候选直接剔除。随后按总分降序排列,在 token 预算 \(B\) 内取前 \(k\) 项,满足 \(\sum_{i=1}^{k} |c_i| \le B\)。

相关性 \(s_{\text{rel}}\) 的估计有两种主流路线:一是基于向量嵌入的双塔检索,把查询和候选都编码成向量后算余弦相似度;二是交叉编码器 (cross-encoder) 重排,把「查询 + 候选」拼在一起让模型直接打分,精度更高但计算更贵。工程上常采用「先召回、后重排」的两段式:用便宜的向量检索先粗筛出 top-\(M\),再用交叉编码器对 top-\(M\) 精排,兼顾速度与质量。

排序之后保留「选择原因」:每段注入内容附带来源和得分,未选中的内容记录被丢弃的原因。这样当回答出错时,可以回查「为什么选了这段、为什么丢了那段」,而不是盲目重试。实施上可以先从最简单的规则过滤开始,再逐步引入检索打分或模型重排。

需要特别注意 token 预算与质量的关系:选择太少会漏掉关键证据,选择太多会稀释模型注意力并增加成本。建议把预算当作硬约束,在约束内最大化相关性,同时为系统规则和当前问题预留固定空间。一个最小实现如下:

def select_context(candidates, query, budget):
    scored = []
    for c in candidates:
        if not c.allowed or c.is_expired:   # 硬过滤:权限/时效
            continue
        c.score = weighted_score(c, query)  # 相关性等加权
        scored.append(c)
    kept, used = [], 0
    for c in sorted(scored, key=lambda x: x.score, reverse=True):
        n = count_tokens(c)
        if used + n <= budget:
            kept.append(c)
            used += n
        else:
            c.drop_reason = "budget"        # 记录被丢弃原因
    return kept, used

验证成功的标准是:注入内容总数不超过预算,回归集上「正确答案所需的关键证据」被选中的召回率达到预期,且每条注入内容都能给出来源与得分。

案例

支持助手需要回答用户关于某个产品版本的问题,而知识库中同时存在多个版本的文档。正确做法是优先选择当前产品版本的文档,过期版本的文档即使语义相似度高也降权,避免把已废弃的接口或参数告诉用户。

具体步骤是:先按版本过滤候选文档,再对剩余文档做相关性排序,最后按 token 预算截取前几段并附上引用。回答时把引用一并返回,方便用户核验来源。这一步与「先召回、后重排」一致:版本和权限属于硬过滤,相关性排序属于软排序,二者顺序不能颠倒——若先排序再过滤,可能把高相似但过期或越权的文档放进结果。

常见失败点是只按相似度排序、忽略版本和权限,导致回答基于过期或无权访问的内容。排查方向是检查注入清单里的来源字段和得分,确认过滤条件是否正确生效;若答案缺少依据,再检查是否因预算不足而裁剪掉了关键段落。