2671 字
约 8 分钟
19
S20-X13 · 上下文工程:历史压缩与记忆

同一个 Agent,第一轮能准确复述用户的约束,聊到第十五轮就开始答非所问。不是模型变笨,是那条约束早已被挤出窗口,或者被摘要压成了一句"用户有一些偏好"。

上下文工程的本质是预算管理:窗口是硬上限,注意力是软衰减,钱按 token 计费。三件事同时约束,决定了你不能"把历史全塞进去"。


1. 上下文是有限资源

约束 表现 工程含义
窗口大小 超出直接报错或截断 必须留输出预留,不能占满
注意力衰减 长上下文中段信息被忽略 关键约束放首尾,别埋中间
成本线性 输入 token 越多越贵 每轮重发全部历史 = 成本随轮次平方增长
延迟线性 首 token 时间随长度上升 长上下文影响交互体感

第三条最容易被忽略:多轮对话每轮都重发历史,第 N 轮的输入长度约等于前 N-1 轮之和,累计成本是平方级。这也是前缀缓存存在的理由。


2. 上下文的组成与配比

组成 典型占比 超配的后果
系统指令 小 挤占证据空间,规则太长模型只记住开头
工具定义 中 工具多时可达数千 token,且每轮重复
检索片段 大 稀释注意力,模型抓不住关键句
对话历史 中到大 早期信息挤掉当前问题
当前输入 小 —
输出预留 必须留 不留会截断回答或直接报错
# 预算分配:先扣输出预留,再按优先级分配
WINDOW = 32_000                     # 按所用模型核对
RESERVE_OUT = 2_000                 # 输出预留,别省
budget = WINDOW - RESERVE_OUT

alloc = {
    "system": min(1_500, budget // 10),
    "tools":  min(4_000, budget // 6),
    "history": int(budget * 0.25),
}
alloc["retrieval"] = budget - sum(alloc.values())   # 剩余给检索
assert alloc["retrieval"] > 0, "配比失衡:先砍工具定义与历史"

纪律:输出预留不可挪用。为了多塞两段检索片段把预留吃光,结果是回答被截断——用户看到的是"说到一半停了",比少两段证据糟得多。


3. 历史压缩的四种做法

做法 机制 信息损失 适用
滑动窗口 只保留最近 N 轮 早期约束直接消失 短会话、闲聊
摘要压缩 让模型把旧历史写成短文 细节被抽象掉,误差会累积 长会话
按重要性保留 打分后留高分消息 打分模型错判就丢关键信息 有明确信号(含订单号、金额)
结构化抽取 抽成 key-value 事实表 只保留"可结构化"的部分 多轮任务、需要稳定约束
# 滑动窗口 + 结构化事实双轨:窗口管近期,事实表管长期约束
def build_history(msgs, keep_recent=6):
    recent = msgs[-keep_recent:]
    facts = extract_facts(msgs[:-keep_recent])       # 抽取而非摘要
    return [{"role": "system", "content": render_facts(facts)}] + recent
def extract_facts(msgs):
    # 只抽稳定、可验证的字段,别让模型自由发挥
    schema = {"budget_max": "number|null", "deadline": "string|null",
              "must_exclude": "list[str]", "city": "string|null"}
    return llm.extract(msgs, schema=schema)

结构化抽取比摘要更适合"约束型"信息:预算上限、截止时间、排除项这类东西一旦被压成自然语言摘要,下一轮很可能就变成"用户有预算要求"。


4. 摘要的时机与代价

def maybe_summarize(msgs, max_tokens):
    used = count_tokens(msgs)
    if used < max_tokens * 0.7:
        return msgs                      # 未到水位,不摘要
    old, recent = msgs[:-8], msgs[-8:]   # 保留最近若干轮原文
    summary = llm.summarize(old, keep=["约束", "决定", "未完成事项"])
    return [{"role": "system", "content": f"历史摘要:{summary}"}] + recent
决策点 选项 代价
何时触发 按 token 水位(70%–80%) 太早浪费调用,太晚已超窗
谁来摘要 同模型 / 小模型 小模型便宜但丢细节更多
摘要粒度 每 N 轮 / 按话题切段 按话题切更准,实现更复杂
摘要是否入档 覆盖旧摘要 / 保留链条 覆盖省 token,无法回溯

摘要误差会累积:第二轮摘要把第一轮的细节压掉,第三轮再摘要第二轮的摘要,几轮之后原始约束可能完全变形。缓解手段是"摘要 + 原始关键消息"双保留——摘要负责叙事,原始消息负责证据。


5. 长期记忆的三种存储

存储 内容 检索时机 风险
向量库会话片段 历史对话切片 每轮按 query 检索 检索到无关旧话题,污染当前上下文
结构化用户画像 偏好、角色、常用地址 会话开始注入 画像过期,用户已搬家仍用旧地址
外部知识库 产品文档、政策 按需检索 与对话记忆混在一起,分不清来源
-- 用户画像:带来源与时间,便于过期与追溯
CREATE TABLE user_profile (
  user_id     BIGINT NOT NULL,
  key         VARCHAR(64) NOT NULL,   -- 如 preferred_lang
  value       TEXT NOT NULL,
  source      VARCHAR(32) NOT NULL,   -- explicit / inferred
  updated_at  TIMESTAMP NOT NULL,
  PRIMARY KEY (user_id, key)
);

source 字段是关键:explicit(用户明说)与 inferred(模型推断)必须分开存。推断出来的偏好一旦被当作事实反复注入,会形成自我强化的错误。


6. "记住什么"的判据

维度 该记 不该记
稳定性 长期偏好、角色、语言 本次会话的临时状态
来源 用户明确陈述 模型推断、猜测
可验证 有明确出处(订单号、日期) 情绪化表达、模糊印象
敏感度 业务必需的最小集 与任务无关的个人信息
时效 带 updated_at 可刷新 永久固化不可更新

一句话:记事实,不记推断;记约束,不记情绪。


7. 多轮任务的上下文漂移

第 1 轮说"预算 5000 以内、不要国产件",到第 12 轮模型推荐了 6800 的国产件——早期约束被后续内容挤走了。这不是偶发,是长上下文的常态。

# 每轮把约束重新注入,而不是指望模型记得
def turn_prompt(state, user_input):
    constraints = state["constraints"]        # 结构化保存,不依赖历史
    return [
        {"role": "system", "content": "当前有效约束:\n" + render(constraints)},
        {"role": "user", "content": user_input},
    ]

# 每轮结束后校验输出是否违反约束,违反则重生成
def check(output, constraints):
    if constraints.get("budget_max") and output.price > constraints["budget_max"]:
        return False, "超出预算上限"
    return True, None

两个配套动作:约束用结构化字段保存并每轮重申;输出侧做规则校验(不是让模型自查)。模型自查容易给出"我已经遵守了"的错觉。


8. 上下文的可调试性

排查"模型为什么这么答",唯一可靠的办法是把最终拼装好的 prompt 原样落盘,而不是看代码里以为发了什么。

import hashlib, json, pathlib

def dump_prompt(trace_id, messages, meta):
    body = json.dumps({"messages": messages, **meta}, ensure_ascii=False)
    h = hashlib.sha1(body.encode()).hexdigest()[:12]
    p = pathlib.Path(f"/var/log/llm/{trace_id}-{h}.json")
    p.write_text(body, encoding="utf-8")
    return h     # 存入响应头,便于用户反馈时定位

必须落的元信息:模型名与版本、各段 token 数、检索命中的 doc_id、是否发生截断/摘要、温度等采样参数。少了任何一项,复现就只能靠猜。


9. 常见坑

坑 后果 改法
只截断不摘要 关键约束静默消失 截断 + 结构化事实双轨
把整个历史塞进去 成本平方增长、注意力稀释 分层:事实表 + 近期原文 + 摘要
摘要里混入模型推断 错误被反复强化 摘要只写"用户说了什么",推断单独标记
跨会话记忆串用户 严重的数据越界 记忆键必须带 user_id/tenant_id
记忆只写不删 过期信息长期污染 带 updated_at 与失效策略
每轮重发全部工具定义 白烧 token 按阶段收窄工具集

10. 排错

现象 先查 处理
早期约束突然失效 落盘 prompt 里约束还在不在 改每轮重申 + 结构化保存
回答越来越笼统 是否连续摘要 保留最近若干轮原文
上下文长度暴涨 检索片段是否重复 按 doc_id 去重 + 控 M
换了模型后频繁截断 新窗口更小 重算预算与配比
记忆检索到无关旧话题 检索阈值与时间窗 加时间衰减、限 top-k
用户反馈"它记错了" 画像 source 字段 区分 explicit / inferred

11. 边界

场景 更合适 理由
单轮问答 无需记忆机制 上下文工程没有收益
需要精确复现的审计场景 全量原文存档,不做摘要 摘要不可作为证据
超长文档问答 检索式分段读取 全量塞入必超窗
强隐私要求 不落盘原始对话,只存脱敏事实 日志本身是泄漏面
高频短会话 无状态 + 轻量画像 维护长期记忆不划算

12. 自测

  1. 为什么多轮对话的成本增长接近平方级,前缀缓存改变了什么?
  2. 输出预留为什么不能为了多塞检索片段而压缩?
  3. 结构化抽取与摘要,哪类信息更适合前者?
  4. 摘要误差如何累积,工程上怎么阻断?
  5. 用户画像里 explicit 与 inferred 为什么必须分开存?
  6. 排查"模型忘了早期约束",第一步该看什么文件?
S20-X13 · 上下文工程:历史压缩与记忆
https://www.clxhxhhr.top/posts/5891/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。