信息的精准配额:动态上下文组装与滑动窗口压缩
What(本文讲什么)
动态上下文组装(Dynamic Context Assembly, DCA)不是“多塞一点文本”,而是一个 runtime 组件:它把上下文当作可调度资源,在每次模型调用前决定“哪些信息必须出现、以什么顺序出现、以什么形式出现(原文/骨架/摘要/检索片段)”。
这篇文章会把 DCA 拆成可落地的工程模块,并解释为什么“更长的上下文窗口”并不能自动解决问题。
Problem(要解决的工程问题)
你会遇到三类典型失败:
- token 爆炸: 规则、工具定义、历史对话、代码、日志、RAG 全部堆进来,一轮请求直接到上限。
- 注意力退化: 即使没超上限,长上下文也会出现“关键信息在中间更难被用到”的现象,导致推理质量下降。
- 不可复现: 你不知道某一轮为什么“忽然变笨”,因为你没有记录那一轮到底喂了什么上下文。
第二点不是玄学。学术与实践都观察到“Lost in the Middle”现象:当相关信息出现在输入的中间区域时,模型更容易用不上;信息靠近开头或结尾时表现更好。你不能指望“塞得更多”自动变强,你必须设计上下文的结构与位置策略。
参考: https://arxiv.org/abs/2307.03172
Principle(DCA 的设计原则)
1) 把上下文分层,而不是平铺
一个可维护的 DCA 一般至少分四层:
- 稳定前缀(Rules/Tools): 工作区规则、工具 schema、权限边界、输出约束。尽量稳定,避免每轮漂移。
- 现场层(Workspace/Task): 当前正在编辑的文件片段、报错栈、测试失败、命令输出。这是“物理现场”,优先级最高。
- 近邻对话(Recent Turns): 保留最近几轮原文,保留语境与即时反馈。
- 远期记忆(Summary/RAG): 更早历史的结构化摘要、检索片段、索引结果。
注意: “比例”不是固定的。真正可落地的做法是把每一层做成独立模块,并用预算器根据任务形态动态调参。
2) 先给骨架,再按需水合
把一个 5000 行文件全文喂给模型,通常是浪费 token 和注意力。更可靠的工程手段是两段式:
- Skeleton View: 只给类/函数签名、注释、导入依赖、关键常量,让模型先获得 API 拓扑。
- Hydration: 当模型明确指出“我要改哪个函数/哪段逻辑”时,再把那一段函数体注入进来。
Skeleton 的实现不要求你发明新工具。你可以用 Tree-sitter 或语言自带 parser 把结构摘出来,原则是“让模型先知道结构,再看细节”。
3) 压缩不是删掉,是把历史变成可执行事实
所谓“摘要”,不是把文字变短,而是把历史对话变成:
- 已确认事实(例如:某个路径、某个配置、某个接口契约)。
- 已做动作(例如:已经修改了哪些文件,做过哪些验证)。
- 关键决策点(例如:为什么选择方案 A,而不是 B)。
- 未决问题(需要额外信息或需要人类决策的点)。
这样压缩后的内容,才能被 runtime 用来驱动后续动作,而不是只当作文段背景。
Usage(一个可运行的 DCA 管线)
下面给一个“能运行、能调试”的最小实现骨架。重点不是代码写法,而是接口与日志。
1) 数据结构:把上下文当作块(Block)
from dataclasses import dataclass
from typing import Literal
ContextKind = Literal["rules", "workspace", "recent", "summary", "retrieval"]
@dataclass(frozen=True)
class ContextBlock:
kind: ContextKind
title: str
content: str
# 用于观测:这块是原文/骨架/摘要/检索
form: Literal["raw", "skeleton", "summary", "retrieved"]
# 预算:这块的最大 token 上限(粗略估算即可)
budget_hint_tokens: int
2) 装配器:先排布,再裁剪
class ContextAssembler:
"""
动态上下文组装器:
负责把不同来源的信息块组装成可控的 prompt。
"""
def __init__(self, token_counter):
self._token_counter = token_counter
def assemble(self, *, rules, workspace, recent, summary, retrieval, max_tokens: int):
blocks = []
blocks += rules
blocks += workspace
blocks += recent
blocks += retrieval
blocks += summary
# 关键:记录每一轮装配的“物理证据”,否则无法复盘
debug_plan = [(b.kind, b.form, b.title, b.budget_hint_tokens) for b in blocks]
packed = []
used = 0
for b in blocks:
cost = self._token_counter.estimate(b.content)
if used + cost > max_tokens:
# 最小可行策略:优先裁剪 summary/retrieval,再裁剪 recent,再裁剪 workspace 的非关键部分
continue
packed.append(b)
used += cost
return packed, {"used_tokens_est": used, "plan": debug_plan}
上面这段代码故意“朴素”。真正重要的是你必须有一个可观测的装配计划(plan),这样当模型某轮输出离谱时,你能回答“当时喂了什么”。
3) 依赖追踪:把“引用关系”变成上下文抓取半径
当模型在处理 A 文件,而 A 引用了 B 的类/函数时,装配器应该具备“按需拓展半径”的能力:
- 解析 A 的 import。
- 把 B 的 skeleton 注入。
- 模型若要改 B,再水合 B 的局部实现。
这比“一上来把整仓库都喂进去”更可靠,也更可控。
Pitfall(常见坑与防错)
- 盲目追求全量: 你以为更全会更准,实际是更难用到关键点。
- 摘要丢失决策点: 只保留故事,不保留“为什么”,会导致后续反复走弯路。
- 检索噪声污染: RAG 片段太多、太杂,会把模型带偏;检索要可解释并且可调参。
- 没有装配日志: 无法复盘,也无法做 A/B 试验,最后只能靠感觉调 prompt。
Debug(如何调 DCA)
把 DCA 当成一个可以做“单元测试”和“回放”的模块:
- 对同一任务输入固定的规则/现场/历史,确保装配结果稳定。
- 记录每次 assemble 的 plan 与 used_tokens_est,并在失败轮输出到 trace。
- 对“失败轮”做回放:只替换某一层(例如 retrieval),观察输出变化。
Source(资料来源)
- Lost in the Middle: https://arxiv.org/abs/2307.03172
指标与验收(怎么证明 DCA 变好了)
没有指标的 DCA 只是“写法不同”。你至少需要三类指标:
1) 成本与容量
prompt_tokens_est(预估)与prompt_tokens_actual(真实)误差分布。retrieval_tokens占比(检索片段到底吃掉了多少预算)。- 每轮上下文块数量与平均块大小(块太多本身就是注意力噪声)。
2) 质量与稳定性
- 任务成功率(最终是否解决问题)。
- 首次成功率(不重试就成功的比例)。
- 重试次数分布(重试是否在“原地打转”)。
- 关键事实丢失率(可以通过“回放评测题”验证)。
3) 可解释性
- 每轮装配 plan 是否可回放(同输入能否得到同 plan)。
- 失败轮能否定位到某一层(例如 retrieval 噪声、summary 丢决策点、workspace 截断)。
位置策略(把“中间信息更难用到”变成工程手段)
Lost in the Middle 的现实含义是:你不能只在乎“有没有喂进去”,还要在乎“放在哪里、以什么形式出现”。
可落地的策略通常是:
- 关键约束放在稳定位置: 规则/工具 schema 放在最前(或按模型习性放在末尾),但必须稳定。
- 关键证据放在边缘: 对当前任务最关键的一两条错误栈、关键文件片段,尽量靠近输入开头或结尾。
- 中间区做“索引而不是全文”: 用骨架、目录、摘要、关键行号索引,避免把关键证据淹没在中间。
你不需要一次做对。关键是把“位置”当成可调参,并在装配日志里记录。
RAG 去噪(让检索帮忙,而不是带偏)
检索引擎常见的失败不是“检索不到”,而是“检索到了太多相似但无关的东西”。
一个实用的去噪策略是:
- 限制召回数量: 宁可少给,也不要把十段相似片段塞进来。
- 做去重: 相似度过高的片段只保留一条。
- 做归因: 每条检索片段必须带上来源(文件路径/URL/标题/段落 id)。
- 做置信: 检索分数低于阈值时,不要硬塞,宁可让模型提问或要求补充信息。
这类策略的共同点是:把检索从“自动追加文本”变成“带证据的上下文块”,让它可解释、可回放、可调参。
回放评测(把 DCA 变成可测的模块)
DCA 最容易陷入“改了很多,但没法证明更好”。一个务实的做法是准备一组可回放的评测用例:
- 用例输入固定: 同一组 rules、同一段 workspace、同一段 recent、同一组 retrieval 候选。
- 评测问题固定: 例如“指出错误根因”“给出最小修复 diff”“解释为什么这样修”。
- 只替换一个变量: 例如只换装配顺序、只换 retrieval 数量、只换 summary 的格式。
你不需要一上来搞完整的基准测试框架,先做到两点就足够强:
- 每次装配都有 plan 日志,能复现当时到底喂了什么。
- 失败轮能被回放,且能通过替换某一层快速定位是哪个模块在制造噪声。
当你能回放并定位问题时,DCA 才从“玄学调 prompt”变成“可工程化迭代的模块”。