同一个 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. 自测
- 为什么多轮对话的成本增长接近平方级,前缀缓存改变了什么?
- 输出预留为什么不能为了多塞检索片段而压缩?
- 结构化抽取与摘要,哪类信息更适合前者?
- 摘要误差如何累积,工程上怎么阻断?
- 用户画像里
explicit与inferred为什么必须分开存? - 排查"模型忘了早期约束",第一步该看什么文件?